Files
competition-website/app/lib/aggregationKey.js
qubiaobiao 481de69e3d fix(agg-key): 归集键串行修复 —— 考试/作品提交记录不得跨选手串行展示
现象(人工测试反馈,已取证):
  测试账号 吴巍荪(cid=621, test_flag=1) 完成一次高职考试提交后,
  正常生产账号 王乐乐(cid=600, test_flag=0) 在管理端评分列表出现**完全相同**的
  作答数据(70 题 / 38 分),而王乐乐本人从未作答。

根因:
  各提交表的 aggregation_uid 写入键不统一 ——
    - 中职/高职:controller/exam.js 写 aggregation_uid = contestant.id
    - 本科/教师:*_submissions 写 aggregation.aggregation_uid(真实聚合 uid)
  历史实现为兼容「两种写法并存」,对两侧都做**无条件双键归集**
  (同时用 contestant.id 与真实 aggregation_uid 去匹配提交表)。
  而这两套编号来自**彼此独立的自增序列**、数值区间完全重叠 ⇒
  「某人的真实聚合 uid == 同组另一人的 contestant.id」时,
  前者会把后者的作答/作品记录「借」过来展示。
  生产实测共 283 对碰撞(gaozhi 239 / benke 43 / jiaoshi 1)。

治法:新增唯一真相源 app/lib/aggregationKey.js
  - 主键按组语义确定:考试组 = contestant.id;作品组 = 真实 aggregation_uid
  - 兜底键沿用历史混杂写法,但**仅在与同组任何主键不冲突时**才启用
    (未通过者丢弃 —— 只丢掉「本就会命中他人数据」的兜底,保留其余兼容性)
  - 不设「兜底键被多人共用」分支:两套编号各自都是 PRIMARY KEY
    (aggregation_uid / contestants.id),该情形结构上不可能发生,属死判据
  - 6 处归集点全部改走该模块:
      service/admin.js  getExamSubmissions / getScores(考试分支) / getScores(作品分支)
                        getContestantScore / _reviewWorks
      controller/score.js  _resolveTeamScoreSource(考生端,与 getContestantScore 同函数)

生产实测影响面(用**线上实际部署的模块** + 生产真实数据核算,n=600):
  旧规则碰撞对 283 → 新规则残留碰撞 **0**;兜底保留 317 / 丢弃 283
  守恒校验:保留 317 + 丢弃 283 = 600;丢弃数 == 旧规则碰撞对 ⇒ YES
  观测值会变化的考生 = **仅 1 人**(gaozhi cid=600 王乐乐 旧=70 → 新=0,即本病例)
  吴巍荪 cid=621 归集键 ["621","642"] 行数 70 保持 **未被误伤**

验证:scripts/verify_agg_key_crosstalk.py 17/17 PASS(真实 HTTP + 库内真值)
  夹具刻意构造「真实聚合 uid == 同组他人 contestant.id」并含**正向对照**(兜底保留者)
  变异自证 A(拆掉碰撞闸门)→ H1/H4/H5/H6/B1/B4 共 6 项变红,失败信息精确复现原现象
  变异自证 B(兜底键整体删除=过度修复)→ H3/H4/H5/H8 共 4 项变红
  ⇒ 两条职责各有独立判别器;还原后 diff 空、md5 一致、零标记残留

部署(生产 back-end):线上 3 文件 md5 与本地逐文件一致;
  MainPID 3343212 → 3344477;8023 监听正常;journalctl -p err = -- No entries --;
  路由探针 health 200 / 错口令 HTTP200+code4004 / 不存在路由 404 / 未授权 401
2026-10-08 12:23:18 +08:00

153 lines
6.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/**
* 归集键单一真相源 - aggregationKey
* ===================================================================
* 背景(2026-10-08 生产实测,人工测试反馈「测试账号的提交数据出现在正常
* 生产账号上」):
*
* 各组提交表的 aggregation_uid **写入键并不统一**:
* - 中职 / 高职:controller/exam.js 落库时写 `aggregation_uid = contestant.id`
* - 本科 / 教师:benke_submissions / jiaoshi_submissions 写
* `aggregation.aggregation_uid`(真实聚合 uid)
*
* 历史实现为兼容「两种写法并存」,对两侧都做**双键归集**——同时用
* `contestant.id` 与「真实 aggregation_uid」去匹配提交表。
*
* 但这两套编号来自**彼此独立的自增序列**,数值区间完全重叠。于是当
* 「某人的真实聚合 uid == 同组另一人的 contestant.id」时,前者会把后者的
* 作答记录 / 作品提交「借」过来展示 —— 生产实测共 **283 对**碰撞
* (gaozhi 239 / benke 43 / jiaoshi 1),已复现 1 例:
* 王乐乐(cid=600, 真实聚合 uid=621) 命中 吴巍荪(cid=621) 的 70 题 / 38 分。
*
* 治法(本模块):
* 1. **主键按组语义确定**,单一、无歧义:
* - 考试组(zhongzhi / gaozhi)主键 = contestant.id
* - 作品组(benke / jiaoshi)主键 = 真实 aggregation_uid
* 2. **兜底键沿用历史混杂写法,但必须通过歧义判定**才启用:
* - 不得等于同组任何人的主键(唯一的真实歧义来源:跨编号空间重叠)
* - (「兜底键被多人共用」结构上不可能:两套编号各自都是 PRIMARY KEY,
* 详见 buildAggregationKeyMap 内的不变量说明,故不设该分支)
* 未通过者一律丢弃 —— 只丢掉「本就会命中他人数据」的兜底,保留其余兼容性。
*
* 生产实测效果:283 个碰撞兜底被丢弃,317 个安全兜底保留,残留碰撞 **0**。
*
* ⚠️ 使用约束:本模块的歧义判定以 **同组** 为范围。调用方必须保证
* `contestants` 与查询的提交表同属一个 `group`(提交表按组物理分表,
* 跨组不会互相命中,故同组范围即充分)。
*/
/** 考试类组别(客观题 + 综合大题;提交表按 contestant.id 归集) */
const EXAM_GROUPS = ['zhongzhi', 'gaozhi'];
/** 是否为考试类组别 */
function isExamGroup(group) {
return EXAM_GROUPS.indexOf(group) !== -1;
}
function _blank(v) {
return v === null || v === undefined || v === '';
}
/**
* 主键:该组数据**实际写入**所用的键。
* @param {string} group
* @param {number|string} contestantId contestants.id
* @param {number|string|null} realAggUid aggregation.aggregation_uid
* @returns {string}
*/
function primaryKeyOf(group, contestantId, realAggUid) {
const cid = String(contestantId);
if (isExamGroup(group)) return cid;
// 作品组:主键是真实聚合 uid;缺失时退化为 cid(保持可用,不再引入第二个键)
return _blank(realAggUid) ? cid : String(realAggUid);
}
/**
* 兜底键:历史混杂写法所用键(与主键分属**相反**的编号空间)。
* @returns {string|null} null 表示无兜底键
*/
function fallbackKeyOf(group, contestantId, realAggUid) {
const cid = String(contestantId);
if (isExamGroup(group)) return _blank(realAggUid) ? null : String(realAggUid);
return cid;
}
/**
* 批量:为**同组**全部选手构建归集键。
* @param {string} group
* @param {Array<{id:number|string}>} contestants 同组选手列表
* @param {Object<string, number|string>} realAggByContestant contestant.id -> 真实 aggregation_uid
* @returns {Object<string, string[]>} contestant.id -> 归集键数组(首元素必为主键,且不重复)
*/
function buildAggregationKeyMap(group, contestants, realAggByContestant) {
const aggMap = realAggByContestant || {};
const list = Array.isArray(contestants) ? contestants : [];
const primarySet = new Set();
const plan = new Map();
for (const c of list) {
const cid = String(c.id);
const rag = aggMap[cid] !== undefined ? aggMap[cid] : aggMap[c.id];
const p = primaryKeyOf(group, cid, rag);
const f = fallbackKeyOf(group, cid, rag);
plan.set(cid, { p, f });
primarySet.add(p);
}
// ⚠️ 结构性不变量(已核对 DDL):`aggregation.aggregation_uid` 是 PRIMARY KEY(全局唯一),
// `contestants.id` 也是 PRIMARY KEY ⇒ 兜底键不可能被两名选手共用
// (考试组兜底 = 真实聚合 uid,作品组兜底 = cid,两者各自唯一)。
// 故**不需要**「兜底键被多人共用」的计数分支——那是永假的死判据。
// 唯一真实的歧义来源 = 跨编号空间重叠(兜底键撞上他人主键),下面一行即判据。
const result = {};
for (const [cid, { p, f }] of plan) {
const keys = [p];
if (f && f !== p && !primarySet.has(f)) keys.push(f);
result[cid] = keys;
}
return result;
}
/**
* 判定某个键是否**已被同组他人认领为主键**(单选手路径的兜底键歧义判定)。
*
* - 考试组:主键 = contestant.id ⇒ 查同组 contestants 是否存在该 id
* - 作品组:主键 = 真实 aggregation_uid ⇒ 查同组 aggregation 是否存在该 aggregation_uid
*
* 两处查询都命中主键/唯一列,`COUNT` ∈ {0,1},判据非退化(`>0` 即「有人认领」)。
*
* @returns {Promise<boolean>} true = 有歧义,兜底键必须丢弃
*/
async function isKeyClaimedInGroup(ctx, group, key) {
if (_blank(key)) return false;
const numeric = /^\d+$/.test(String(key)) ? Number(key) : String(key);
if (isExamGroup(group)) {
return (await ctx.model.Contestant.count({ where: { id: numeric, group } })) > 0;
}
return (await ctx.model.Aggregation.count({ where: { aggregation_uid: numeric, group } })) > 0;
}
/**
* 单选手:解析归集键(语义与 buildAggregationKeyMap 一致,歧义判定改为按需查库)。
* @returns {Promise<string[]>} 归集键数组(首元素必为主键)
*/
async function resolveAggregationKeysForContestant(ctx, group, contestantId, realAggUid) {
const cid = String(contestantId);
const p = primaryKeyOf(group, cid, realAggUid);
const f = fallbackKeyOf(group, cid, realAggUid);
const keys = [p];
if (f && f !== p && !(await isKeyClaimedInGroup(ctx, group, f))) keys.push(f);
return keys;
}
module.exports = {
EXAM_GROUPS,
isExamGroup,
primaryKeyOf,
fallbackKeyOf,
buildAggregationKeyMap,
isKeyClaimedInGroup,
resolveAggregationKeysForContestant,
};