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
This commit is contained in:
2026-10-08 12:23:18 +08:00
parent a471487092
commit 481de69e3d
3 changed files with 190 additions and 25 deletions

View File

@@ -4,6 +4,10 @@ const Service = require('egg').Service;
const path = require('path');
const fs = require('fs');
const XLSX = require('xlsx');
const {
buildAggregationKeyMap,
resolveAggregationKeysForContestant,
} = require('../lib/aggregationKey');
/**
* 客观题(选择题 / 判断题)每小题固定分值。
@@ -457,11 +461,10 @@ class AdminService extends Service {
order: [['id', 'ASC']],
});
// 建立 contestant.id -> 真实 aggregation_uid 映射。
// 旧代码曾把聚合报名 uid 直接写进 zhongzhi/gaozhi_submissions 的 aggregation_uid 列,
// 而当前 _getUserAggregation 返回 aggregation_uid = contestant.id;两种写法并存于历史数据。
// 若仅按 contestant.id 归集,历史提交者的答题数/提交状态会因聚合键错位而恒为 0 / 未提交。
// 故按「contestant.id」与「真实 aggregation_uid」两种 key 同时归集,消除错位。
// 归集键统一由 lib/aggregationKey 提供(单一真相源):
// 主键按组语义确定(中职/高职 = contestant.id;本科/教师 = 真实 aggregation_uid),
// 兜底键沿用历史混杂写法,但**仅在不与同组任何主键冲突、且不被多人共用时才启用**。
// 旧实现无条件双键归集 ⇒ 两套独立自增序列数值区间重叠时会命中他人作答记录(生产 283 对碰撞)。
const aggRows = await ctx.model.Aggregation.findAll({
where: { account_uid: contestants.map(c => c.id) },
attributes: ['account_uid', 'aggregation_uid'],
@@ -469,6 +472,7 @@ class AdminService extends Service {
});
const realAggByContestant = {};
aggRows.forEach(a => { realAggByContestant[a.account_uid] = a.aggregation_uid; });
const aggKeysByContestant = buildAggregationKeyMap(group, contestants, realAggByContestant);
// 获取所有提交记录
const allSubmissions = await SubmissionModel.findAll({
@@ -495,8 +499,8 @@ class AdminService extends Service {
examSubs.forEach(w => { wordMap[String(w.contestant_id)] = w; });
return contestants.map(c => {
// 兼容新旧两种聚合键:同时归集 contestant.id 与真实 aggregation_uid 下的答题记录
const candidateKeys = [String(c.id), String(realAggByContestant[c.id])].filter(Boolean);
// 归集键(含歧义消解后的兜底键);首元素必为主键
const candidateKeys = aggKeysByContestant[String(c.id)] || [String(c.id)];
const subs = candidateKeys.flatMap(k => submissionsByUser[k] || []);
const answeredCount = subs.length;
const autoScore = subs.reduce((sum, s) => sum + (s.score || 0), 0);
@@ -1353,11 +1357,10 @@ class AdminService extends Service {
raw: true,
});
// contestant.id -> 真实 aggregation_uid 映射。
// 历史数据曾把真实聚合 uid 写进 zhongzhi/gaozhi_submissions 的 aggregation_uid 列,
// 而当前 _getUserAggregation 返回 aggregation_uid = contestant.id;两种写法并存。
// 若仅按 contestant.id 归集,纯历史数据者的答题数/卷面分会因聚合键错位而恒为 0。
// 故按「contestant.id」与「真实 aggregation_uid」两种 key 同时归集,消除错位。
// 归集键统一由 lib/aggregationKey 提供(单一真相源,见模块头注释):
// 主键按组语义确定,兜底键仅在无歧义时启用。
// ⚠️ 旧实现无条件双键归集 ⇒ 「真实聚合 uid」撞上同组他人 contestant.id 时,
// 会把他人作答记录(答题数/卷面分)计入本行,生产已复现(cid=600 命中 cid=621)。
const aggRows = await ctx.model.Aggregation.findAll({
where: { account_uid: contestants.map(c => c.id) },
attributes: ['account_uid', 'aggregation_uid'],
@@ -1365,6 +1368,7 @@ class AdminService extends Service {
});
const realAggByContestant = {};
aggRows.forEach(a => { realAggByContestant[a.account_uid] = a.aggregation_uid; });
const aggKeysByContestant = buildAggregationKeyMap(group, contestants, realAggByContestant);
const list = contestants.map(c => ({
uid: c.id,
@@ -1401,7 +1405,7 @@ class AdminService extends Service {
const examMap = {};
examSubs.forEach(w => { examMap[String(w.contestant_id)] = w; });
list.forEach(item => {
const keys = [String(item.uid), String(realAggByContestant[item.uid])].filter(Boolean);
const keys = aggKeysByContestant[String(item.uid)] || [String(item.uid)];
let answeredCount = 0;
let autoScore = 0;
keys.forEach(k => {
@@ -1438,7 +1442,7 @@ class AdminService extends Service {
if (!subByAgg[k]) subByAgg[k] = s;
});
list.forEach(item => {
const keys = [String(item.uid), String(realAggByContestant[item.uid])].filter(Boolean);
const keys = aggKeysByContestant[String(item.uid)] || [String(item.uid)];
let sub = null;
keys.forEach(k => { if (!sub && subByAgg[k]) sub = subByAgg[k]; });
item.answeredCount = 0;
@@ -2152,16 +2156,20 @@ class AdminService extends Service {
};
}
// ---- 1) 双键归集(与 getExamSubmissions 完全同口径) ----
// ---- 1) 归集键(单一真相源 lib/aggregationKey,与「考生答题记录」完全同口径) ----
// 兜底键仅在无歧义(不被同组他人当作主键)时启用,
// 避免「真实聚合 uid 撞上同组他人 contestant.id」而把他人作答记录并入本复核页。
const agg = await ctx.model.Aggregation.findOne({
where: { account_uid: cid },
attributes: ['aggregation_uid'],
raw: true,
});
const keys = [String(cid)];
if (agg && agg.aggregation_uid != null && String(agg.aggregation_uid) !== String(cid)) {
keys.push(String(agg.aggregation_uid));
}
const keys = await resolveAggregationKeysForContestant(
ctx,
g,
cid,
agg ? agg.aggregation_uid : null
);
const SubmissionModel = g === 'zhongzhi' ? ctx.model.ZhongzhiSubmission : ctx.model.GaozhiSubmission;
const subs = await SubmissionModel.findAll({
@@ -2350,10 +2358,12 @@ class AdminService extends Service {
attributes: ['aggregation_uid'],
raw: true,
});
const keys = [String(contestantId)];
if (agg && agg.aggregation_uid != null && String(agg.aggregation_uid) !== String(contestantId)) {
keys.push(String(agg.aggregation_uid));
}
const keys = await resolveAggregationKeysForContestant(
ctx,
group,
contestantId,
agg ? agg.aggregation_uid : null
);
const rows = await SubmissionModel.findAll({
where: { aggregation_uid: { [ctx.model.Sequelize.Op.in]: keys.map(Number) } },