'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, };