diff --git a/app/controller/score.js b/app/controller/score.js index 5874140..03581e4 100644 --- a/app/controller/score.js +++ b/app/controller/score.js @@ -1,6 +1,7 @@ 'use strict'; const Controller = require('egg').Controller; +const { resolveAggregationKeysForContestant } = require('../lib/aggregationKey'); /** * 成绩查询控制器 - ScoreController @@ -138,8 +139,10 @@ class ScoreController extends Controller { where: { id: operatorId }, attributes: ['id', 'name', 'word_score', 'ppt_score', 'score', 'exam_score'], }); - const keys = [String(operatorId)]; - if (operatorAggUid) keys.push(String(operatorAggUid)); + // 归集键(单一真相源 lib/aggregationKey): + // 中职/高职 = contestant.id 为主键;兜底键(真实聚合 uid)仅在无歧义时启用, + // 避免「操作员真实聚合 uid 撞上同组他人 contestant.id」而读到他人(甚至他队)作答记录。 + const keys = await resolveAggregationKeysForContestant(ctx, group, operatorId, operatorAggUid); return { group, isExam, operatorId, operator, aggKeys: keys, viewerName: me.name }; } diff --git a/app/lib/aggregationKey.js b/app/lib/aggregationKey.js new file mode 100644 index 0000000..b6febc7 --- /dev/null +++ b/app/lib/aggregationKey.js @@ -0,0 +1,152 @@ +'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} realAggByContestant contestant.id -> 真实 aggregation_uid + * @returns {Object} 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} 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} 归集键数组(首元素必为主键) + */ +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, +}; diff --git a/app/service/admin.js b/app/service/admin.js index d8fc323..53e3fc4 100644 --- a/app/service/admin.js +++ b/app/service/admin.js @@ -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) } },