文章

多人决策系统

多人决策系统

引言

多人决策系统需要解决两个相互关联的问题:第一,如何将不同参与者的偏好转化为可计算、可比较的数值;第二,如何在参与者互不信任或不希望提前公开偏好的情况下,保证投票、报价和结算过程具有一定的保密性与可审计性。

本文分析的应用是一个运行于浏览器中的多人决策工具。它同时实现了以下两类机制:

  1. 基于平方成本的多人偏好聚合,即二次方投票+落选补偿;
  2. 针对多个物品分别执行的密封二价拍卖。

在非拍卖模式下,参与者为不同选项分配整数分数。分数越高,表达的偏好越强,但所消耗的积分按照平方增长。在拍卖模式下,每名参与者对多个物品提交独立的密封报价,每件物品由唯一最高报价者获得,但支付金额为第二高报价。

从计算机技术角度看,该系统没有使用后端服务器或数据库,而是采用原生 HTML、CSS 和 JavaScript 构成单页应用。密码派生、数据加密、解密和结果计算均在浏览器本地完成,持久化数据存储于 localStorage,敏感投票和报价则以 AES-GCM 密文形式保存。

相关技术及其细节

二次投票与平方成本机制

偏好强度的数值表达

设系统中共有 $n$ 名参与者和 $m$ 个候选选项。参与者 $i$ 对选项 $j$ 给出的分数记为 $x_{ij}$,并满足:

\[0 \leq x_{ij} \leq x_{\max}\]

传统的一人一票机制只能表达选择方向,不能表达偏好强度。该应用允许参与者为不同选项分配不同分数,从而把“是否支持”扩展为“支持程度”。

系统对每个选项计算总分:

\[S_j=\sum_{i=1}^{n}x_{ij}\]

在单选模式下,总分最高的选项成为候选结果;在多选模式下,系统选择总分排名前 $k$ 的选项;在排序模式下,系统直接按照 $S_j$ 从高到低生成完整排名。

当最高分或截止位出现并列时,系统不进行随机裁决,而是明确标记结果未完全确定。这种处理避免了引入不可审计的随机过程,但也意味着系统需要额外的线下规则来解决平局。

平方积分成本

该应用使用二次投票中的核心思想:参与者为某个选项投入 $x$ 分时,不是消耗 $x$ 个积分,而是消耗 $x^2$ 个积分。

参与者 $i$ 的总积分成本为:

\[C_i=\sum_{j=1}^{m}x_{ij}^2\]

平方成本会使边际成本随分数增加而提高。将某个选项从 $x$ 分增加到 $x+1$ 分时,新增成本为:

\[\Delta C=(x+1)^2-x^2=2x+1\]

例如,将某个选项从 $1$ 分提高到 $2$ 分只增加 $3$ 个积分,而从 $9$ 分提高到 $10$ 分则增加 $19$ 个积分。因此,参与者可以低成本地表达对多个选项的一般偏好,但表达极强偏好时必须承担显著更高的成本。

这种机制试图抑制极端投票,同时保留表达偏好强度的能力。

免费积分与额外积分购买

系统为每名参与者提供初始免费积分 $B$。当参与者的平方成本超过免费额度时,需要购买额外积分。

额外购买积分数量为:

\[Q_i=\max(0,C_i-B)\]

若每个额外积分的价格为 $p$,则参与者 $i$ 的购买金额为:

\[M_i=pQ_i\]

这部分金额被平均分配给其他参与者。当参与人数大于 $1$ 时,参与者 $i$ 向每名其他参与者支付的理论金额为:

\[P_{i\rightarrow k}^{\text{purchase}} = \frac{M_i}{n-1}, \qquad k\neq i\]

在程序实现中,金额首先转换为整数分,即“人民币分”,然后执行最大余数法分配,从而避免 JavaScript 浮点数误差造成账目不平。

其核心思想可以表示为:

1
2
3
totalCents = Math.round(amount * 100);
base = Math.floor(totalCents / recipientCount);
remainder = totalCents % recipientCount;

remainder 名收款人额外获得一分钱,因此所有分配金额之和严格等于原始金额。

未实现价值补偿与结果结算

该系统不仅聚合分数,还引入了“未实现平方价值补偿”。

设最终入选选项集合为 $W$。对参与者 $i$ 而言,其在未入选选项上的平方价值为:

\[U_i = p\sum_{j\notin W}x_{ij}^2\]

系统把 $U_i$ 视为参与者 $i$ 因未实现偏好而应获得的补偿。该补偿由其他 $n-1$ 名参与者平均承担,因此每名其他参与者向参与者 $i$ 支付:

\[P_{k\rightarrow i}^{\text{outcome}} = \frac{U_i}{n-1}, \qquad k\neq i\]

这一设计并不是标准二次投票机制的必备组成部分,而是该应用自定义的结果补偿规则。

其经济含义是:当某名参与者对落选方案投入了大量平方积分时,其他人需要共同补偿其未实现偏好。

该机制提高了少数意见的经济权重,但也带来若干需要注意的问题:

  • 补偿规则可能改变参与者真实表达偏好的激励,参与者可能产生策略性投票。

因此,从机制设计角度看,这部分更接近一种实验性补偿机制,而不是严格满足激励相容性的机制。

未实现的备选机制:随机Sink-VCG机制

Sink 的估值不参与方案选择,非 Sink 参与者按照 Clarke 支付规则缴费,剩余资金全部转移给 Sink,从而使机制按设计满足预算平衡。

参考论文:

密封二价拍卖

二价拍卖的基本规则

在拍卖模式下,每名参与者可对多个物品分别提交报价。设参与者 $i$ 对物品 $j$ 的报价为 $b_{ij}$。

物品 $j$ 的最高报价为:

\[h_j=\max_i b_{ij}\]

若最高报价仅由一名参与者提交,则该参与者成为赢家:

\[i_j^*=\arg\max_i b_{ij}\]

赢家不支付自己的最高报价,而是支付第二高报价:

\[p_j=b_{(2)j}\]

其中,$b_{(2)j}$ 表示物品 $j$ 的第二高报价。

这类机制通常称为维克里拍卖或二价密封拍卖。在独立私人价值、风险中性、没有预算约束等经典假设下,参与者如实提交自身估值通常构成占优策略

直观而言,赢家的报价主要决定自己是否获胜,而实际支付金额由其他人的最高报价决定。因此,参与者没有必要通过压低报价来降低支付金额,也不应通过抬高报价来赢得一个价值低于支付价格的物品。

特殊边界情况

该应用对以下情况作出了明确处理。

当所有报价均为 $0$ 时,物品不分配:

\[\max_i b_{ij}=0\]

当最高报价由多人并列提交时,系统不自动选择赢家。当只有一名参与者提交正报价时,第二高报价为 $0$,因此赢家免费获得该物品。

当前应用没有卖家、起拍价和保留价,因此更适合熟人群体内部的资源分配,而不是正式商业拍卖。

多物品拍卖

应用对每件物品分别执行二价拍卖,因此其模型隐含假设不同物品之间相互独立。

如果参与者的估值具有互补性,例如只有同时获得物品 $A$ 和物品 $B$ 才有价值,那么独立拍卖可能导致暴露风险:参与者可能只赢得其中一件,却仍然需要支付价格。若物品间存在替代性、互补性或组合约束,更适合采用组合拍卖。组合拍卖允许参与者对物品集合 $T$ 提交组合报价 $v_i(T)$,但赢家确定问题通常会转化为计算复杂度较高的组合优化问题。

净额结算与支付图

毛支付边

系统首先产生两类原始支付关系:

  1. 额外积分购买款;
  2. 未实现价值补偿款。

这些关系可以构成一个有向加权图:

\[G=(V,E)\]

其中,每名参与者对应一个节点,支付关系对应一条有向边。若参与者 $a$ 应向参与者 $b$ 支付金额 $w_{ab}$,则存在边:

\[a\xrightarrow{w_{ab}}b\]

直接执行所有毛支付边可能产生大量相互抵消的转账。例如,$A$ 需要向 $B$ 支付,同时 $B$ 也需要向 $A$ 支付。对应图出现环的情况。

系统先计算每名参与者的净余额。参与者 $i$ 的净余额定义为:

\[z_i=\sum_k w_{ki}-\sum_k w_{ik}\]

若 $z_i>0$,参与者 $i$ 是净收款人;若 $z_i<0$,参与者 $i$ 是净付款人。

由于系统内部支付总额守恒,因此应满足:

\[\sum_{i=1}^{n}z_i=0\]

债务人与债权人匹配

程序把所有参与者划分为债务人集合和债权人集合,然后使用贪心算法进行匹配。

每次选择一个尚未结清的债务人和债权人,支付金额为:

\[t=\min(d,c)\]

其中,$d$ 是债务人的剩余应付款,$c$ 是债权人的剩余应收款。

完成转账后更新:

\[d\leftarrow d-t\] \[c\leftarrow c-t\]

当任意一方余额归零时,移动到下一名参与者。该算法不能保证在所有约束条件下得到唯一结果,但能够快速产生一组正确的净额转账,并将转账数量控制在较低水平。

在没有额外转账限制的情况下,最终支付边数量通常不超过 $n-1$。

密码派生与身份验证

四位个人密码

每名参与者首次进入个人页面时,系统生成一个四位数字密码。密码由浏览器的密码学安全随机数生成器产生,而不是使用 Math.random()

生成过程基于:

1
window.crypto.getRandomValues(array);

这比普通伪随机函数更适合生成密钥材料和认证凭证。

不过,四位数字密码只有 $10000$ 种可能,其理论熵约为:

\[H=\log_2(10000)\approx 13.29\]

这意味着四位密码的安全性较低。即使采用高迭代次数的密码派生函数,只要攻击者获得加密状态文件,仍然可以离线尝试全部 $10000$ 种密码。

因此,四位密码适合降低误操作和同屏查看风险,但不能提供高等级密码安全。

PBKDF2 密钥派生

系统没有直接使用四位密码作为 AES 密钥,而是采用 PBKDF2 进行密钥派生。

其抽象形式为:

\[DK=\operatorname{PBKDF2}(P,S,c,\operatorname{HMAC\text{-}SHA256},512)\]

其中:

  • $P$ 为四位密码;
  • $S$ 为随机生成的盐;
  • $c=210000$ 为迭代次数;
  • 输出长度为 $512$ 位。

系统将派生结果分成两个 $256$ 位部分:

\[DK=V\parallel K\]

其中,$V$ 用作密码校验值,$K$ 用作 AES-GCM 加密密钥。

这一设计实现了认证材料和加密材料之间的逻辑分离。程序只保存盐、迭代次数和校验值,不保存明文密码。

验证密码时,系统重新执行 PBKDF2,并比较新生成的 $V’$ 与已保存的 $V$:

\[V'=V\]

若匹配,则使用对应的 $K$ 解密个人数据。

AES-GCM 认证加密

加密对象

普通投票模式下,每名参与者拥有独立的选票密文;拍卖模式下,每名参与者拥有独立的报价密文。

明文首先序列化为 JSON:

1
JSON.stringify(payload)

随后编码为 UTF-8 字节并使用 AES-GCM 加密。

系统使用:

  • AES 密钥长度:$256$ 位;
  • 初始化向量长度:$96$ 位;
  • GCM 认证标签长度:$128$ 位。

加密结果包含:

1
2
3
4
5
6
7
{
  scheme: "AES-GCM-256",
  kind: "ballot",
  iv: "...",
  ciphertext: "...",
  updatedAt: "..."
}

AES-GCM 同时提供机密性和完整性。若密文、初始化向量或附加认证数据被修改,解密操作会失败。

附加认证数据

系统还使用附加认证数据绑定参与者身份和数据类型:

1
"quadratic-group-decision|" + kind + "|" + personId

可将其表示为:

\[AAD=\text{AppID}\parallel\text{Kind}\parallel\text{PersonID}\]

附加认证数据本身不加密,但会参与认证标签计算。

这一设计可以防止以下密文替换攻击:

  • 将参与者 $A$ 的密文替换为参与者 $B$ 的密文;
  • 将普通选票密文冒充为拍卖报价密文;
  • 将相同密文复制到不同数据槽位。

即使攻击者复制了密文,由于解密时使用的 $AAD$ 不一致,AES-GCM 认证也会失败。

随机初始化向量

每次保存数据时,系统都会重新生成一个 $12$ 字节随机初始化向量:

1
var iv = randomBytes(12);

AES-GCM 要求同一密钥下不能重复使用相同的初始化向量。随机生成 $96$ 位初始化向量可以把碰撞概率控制在较低水平。

若使用同一密钥执行 $q$ 次加密,随机初始化向量发生碰撞的近似概率可由生日界估算:

\[P_{\text{collision}} \approx \frac{q(q-1)}{2^{97}}\]

对于普通浏览器应用中的保存次数,该概率可以忽略。

前端单页应用架构

原生 HTML、CSS 与 JavaScript

应用没有引入 React、Vue、Angular 或第三方 UI 框架,而是使用原生浏览器 API 实现。

其技术栈主要包括:

1
2
3
4
5
6
7
8
HTML5
CSS3
JavaScript
Web Crypto API
Web Storage API
FileReader API
Blob 与 Object URL
SVG

HTML 负责页面骨架,CSS 负责响应式布局和视觉组件,JavaScript 负责状态管理、事件绑定、加密、解密和金融计算。

这种方案的优点是部署简单。整个应用可以保存为单个 HTML 文件并在浏览器中直接打开。

缺点是所有渲染逻辑、业务逻辑和安全逻辑集中在一个脚本中,随着功能增加,代码维护成本会迅速上升。

基于步骤的状态机

应用将整个流程建模为顺序状态机。

若参与人数为 $n$,总页面数为:

\[T=n+2\]

其中包括:

  1. 配置页面;
  2. $n$ 个参与者个人页面;
  3. 结果解锁页面。

当前状态由 currentStep 表示:

1
state.currentStep

页面跳转时,系统执行以下操作:

1
2
3
4
5
flushActiveSession()
clearSensitiveMemory()
updateCurrentStep()
saveState()
render()

这一流程保证参与者离开个人页面前,当前数据已经完成加密保存,同时解密密钥和明文数据从全局内存引用中移除。

手工响应式渲染

应用使用 innerHTML 重新生成页面,再重新绑定事件监听器。这是一种命令式渲染方式。

其大致模式为:

1
2
3
4
5
6
7
8
9
function render() {
  if (isSetupStep()) {
    renderSetup();
  } else if (isResultStep()) {
    renderResultsEntry();
  } else {
    renderParticipantEntry();
  }
}

与虚拟 DOM 框架相比,这种方式依赖开发者手工维护状态与界面的一致性。

应用通过统一的 render() 入口降低了局部状态失配风险,但频繁使用 innerHTML 仍可能导致:

  • 事件监听器需要重复绑定;
  • DOM 状态无法自然保留;
  • 大规模界面更新时性能较低;
  • 业务逻辑和视图模板耦合较高。

输入转义与 XSS 防护

系统将参与者名称、选项名称和主题插入 HTML 前,调用自定义的转义函数:

1
2
3
4
5
6
7
8
function esc(value) {
  return String(value)
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#039;");
}

该函数可以降低存储型跨站脚本攻击风险。

由于页面大量采用字符串拼接生成 HTML,所有用户可控字段都必须经过转义。只要出现一个遗漏点,就可能产生 XSS 漏洞。因此,在更大型的生产系统中,更适合使用安全模板引擎或 DOM 节点 API。

本地存储与数据导入导出

localStorage 持久化

应用使用 localStorage 保存完整状态:

1
2
3
4
localStorage.setItem(
  STORAGE_KEY,
  JSON.stringify(state)
);

持久化内容包括:

  • 系统配置;
  • 参与者信息;
  • 选项或物品信息;
  • PBKDF2 盐和密码校验值;
  • AES-GCM 初始化向量和密文;
  • 当前流程步骤。

正常情况下,明文密码、明文选票、明文报价和 AES 密钥不会写入 localStorage

localStorage 的优点是使用简单、刷新页面后状态仍然存在。其缺点是:

  • 数据只存在于当前浏览器环境;
  • 没有跨设备同步;
  • 没有事务和并发控制;
  • 同源脚本可以访问存储内容;
  • 存储容量有限;
  • 浏览器清理站点数据后内容会丢失。

因此,该设计适用于单机、小规模和临时性的群体决策。

串行化异步保存

AES-GCM 加密是异步操作。若用户连续快速修改多个输入,多个加密任务可能同时执行,后完成的旧任务可能覆盖先完成的新任务。

应用通过 Promise 链串行化保存:

1
2
3
4
5
6
7
activeSavePromise = activeSavePromise
  .then(function () {
    return encryptPayload(...);
  })
  .then(function (sealed) {
    saveCiphertext(sealed);
  });

这种设计保证加密保存按照操作顺序完成,避免异步竞态条件导致状态回退。

JSON 导入导出

系统可以将完整状态导出为 JSON 文件。导出数据包括密文和密码校验材料,但不包含明文密码和明文报价。

文件导出使用:

1
2
3
new Blob(...)
URL.createObjectURL(...)
link.click()

文件导入则通过 FileReader 读取并解析 JSON。

导入后,系统会清除内存中的活动会话,并要求所有参与者重新输入密码。这意味着导出的状态文件可以用于备份和迁移,但不能绕过原有密码机制。

内存中的敏感数据管理

参与者成功验证密码后,应用会在内存中保存:

1
2
3
4
5
6
{
  personId,
  aesKey,
  kind,
  data
}

该对象代表当前参与者的活动会话。离开页面、进入下一名参与者页面或重新锁定结果时,系统执行:

1
2
3
activeSession = null;
resultBallots = null;
resultUnlocked = false;

这种设计遵循“尽量缩短明文驻留时间”的原则,但 JavaScript 中将变量设置为 null 并不等于立即安全擦除内存。垃圾回收器何时回收对象由浏览器决定,开发者不能保证明文立即从物理内存中消失。

因此,所谓“清除内存”更准确地说是删除应用层可访问引用,而不是完成密码学意义上的安全擦除。

整体方法

系统初始化

应用启动后首先检查浏览器是否支持以下能力:

1
2
3
4
window.crypto
window.crypto.getRandomValues
window.crypto.subtle
Promise

如果浏览器不支持 Web Crypto API,系统终止初始化并显示错误信息。

随后,应用尝试从 localStorage 读取历史状态。若读取失败、JSON 损坏或状态结构无效,则创建默认状态。

配置阶段

组织者首先配置决策主题、参与人数、选项数量和任务类型。

在二次投票模式下,还需要配置:

  • 最终选择数量;
  • 每人免费积分;
  • 额外积分单价;
  • 单项最高分;
  • 自定义结果说明。

修改参与者数量时,如果删除某名参与者,其密码校验数据、加密选票和密封报价也会被删除。

修改选项数量时,系统保留仍然存在的选项,并为新增选项生成唯一标识符。

唯一标识符比数组下标更适合关联密文中的投票数据。即使选项名称发生变化,数据仍可通过稳定的 ID 进行映射。

个人密码建立阶段

对于尚未建立密码的参与者,系统执行以下步骤:

  1. 使用 crypto.getRandomValues() 生成四位密码;
  2. 生成 $16$ 字节随机盐;
  3. 通过 PBKDF2 派生 $512$ 位材料;
  4. 保存前 $256$ 位校验值;
  5. 使用后 $256$ 位作为 AES-GCM 密钥;
  6. 创建空白选票或空白报价;
  7. 立即加密并保存;
  8. 将四位密码只显示一次。

密码一旦离开当前页面便不会再次显示,系统也没有密码重置机制。

这种设计避免平台保存密码,但可用性风险较高:任何一名参与者忘记密码,都可能导致最终结果无法统一解锁。

个人投票或报价阶段

参与者再次进入个人页面时,需要输入四位密码。

系统重新派生校验值和 AES 密钥。如果校验值不匹配,则拒绝访问;如果校验成功,则使用 AES-GCM 解密个人数据。

在二次投票模式中,每次修改分数后,系统重新计算:

\[C_i=\sum_j x_{ij}^2\] \[Q_i=\max(0,C_i-B)\] \[M_i=pQ_i\]

同时更新界面中的已用积分、剩余免费积分、需购买积分和购买金额。

在拍卖模式中,每名参与者在同一页面填写对所有物品的报价。报价为 $0$ 表示不参与该物品的竞拍。

每次数据发生变化,系统都会将完整个人数据重新序列化并加密,而不是只加密发生变化的字段。这种方式实现简单,也避免部分更新不一致,但数据规模较大时会增加加密成本。

全员解锁阶段

结果页面要求输入所有参与者的密码。

对每名参与者,系统分别执行:

  1. 检查密码是否为四位数字;
  2. 通过 PBKDF2 派生验证材料;
  3. 比较密码校验值;
  4. 使用派生出的 AES 密钥解密个人密文;
  5. 验证 AES-GCM 认证标签;
  6. 将明文暂存在结果页内存中。

系统使用 Promise.all() 等待所有参与者全部解密成功:

1
Promise.all(tasks)

只要任何一人的密码错误或密文认证失败,整个结果保持锁定。

这实现了一种简单的“全员参与解锁”规则,但它并不是真正的门限密码学。所有密文仍然分别由个人密码加密,而不是使用一个必须由多人联合重构的共享密钥。

结果计算阶段

二次投票结果

系统首先计算每个选项的总分:

\[S_j=\sum_i x_{ij}\]

随后根据模式确定最终入选集合 $W$。

若结果唯一确定,则进一步计算:

  • 每名参与者的额外积分购买款;
  • 每名参与者的未实现价值补偿;
  • 所有原始支付边;
  • 每名参与者的净余额;
  • 最终净额转账图。

若最高位或截止位并列,系统仍然显示排名,但不生成结果补偿支付边。

二价拍卖结果

对每件物品,系统独立完成以下处理:

  1. 收集所有参与者报价;
  2. 按报价从高到低排序;
  3. 过滤所有非正报价;
  4. 检查是否无人报价;
  5. 检查最高报价是否并列;
  6. 确定唯一赢家;
  7. 计算第二高报价;
  8. 输出赢家、最高报价、第二高报价和支付金额。

拍卖结果中会公开所有参与者的报价。这符合统一开标的产品设计,但意味着开标后报价不再保密。

结果重新锁定

查看结果后,用户可以点击“清除内存并重新锁定”。

系统会删除:

1
2
3
resultBallots
resultUnlocked
activeSession

随后返回全员密码输入页面。

持久化存储中的数据始终保持为密文。因此,页面刷新或重新打开文件后,仍然需要重新输入参与者密码。

技术评价

其主要限制包括:

  • 四位密码熵过低,不能抵抗离线穷举攻击;
  • localStorage 不适合保存高价值或高敏感度数据;
  • 没有真正的门限解密或多方安全计算;
  • 没有数字签名,无法证明密文由特定参与者提交;
  • 没有可信时间戳,无法严格证明提交顺序;
  • 组织者可能修改前端源码或替换应用文件;
  • 二次投票补偿机制可能产生策略性激励;
  • 没有后端审计日志和权限控制;
  • JavaScript 内存无法保证安全擦除。

因此,该系统更适合作为机制设计实验、教学演示、熟人群体内部决策或低风险资源分配工具,而不应直接用于高金额拍卖、正式选举或具有法律约束力的金融交易。

从工程演进角度看,若要将其升级为生产级系统,可以进一步引入:

  • 前后端分离架构;
  • 用户身份认证与公钥签名;
  • Argon2id 等抗 GPU 密码派生算法;
  • 更长的密码或硬件密钥;
  • 数据库事务与不可篡改审计日志;
  • 门限加密或秘密共享;
  • 可验证洗牌和零知识证明;
  • 服务端权限控制;
  • 正式的机制激励分析;
  • 组合拍卖求解器;
  • 自动化测试和安全审计。

warning

本文由作者按照 CC BY 4.0 进行授权