Files

234 lines
9.7 KiB
JavaScript
Raw Permalink Normal View History

'use strict';
const Controller = require('egg').Controller;
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
const { resolveAggregationKeysForContestant } = require('../lib/aggregationKey');
/**
* 成绩查询控制器 - ScoreController
*
* 负责选手查询个人成绩,包括答题得分、作品评分和总分。
* 成绩发布后才能查看。
*
* 主要接口:
* - GET /api/score : 获取当前选手的(队伍)成绩详情
*
* 设计目标:一人考试,全组(同队伍)都能查看该队伍成绩。
* - 考试以「队伍」为单位,仅一名成员(考试操作员)实际进入并作答;
* 成绩(客观题/作品/演示阅卷分)落在操作员对应的记录上。
* - 同队其他成员查看时,须解析到本队「考试操作员」的成绩并一致返回,
* 而非各自读取自己(未考试、得分恒为 0)的行。
* - 队伍身份以 aggregation.team_uid 为准(与考试锁 _enterExamWithTeamLock 一致),
* 回退到 contestants.team_id(与 teams.id 关联)。
*/
class ScoreController extends Controller {
/**
* 解析「队伍成绩来源」:
* 返回当前用户所在队伍中实际产生成绩的「考试操作员」信息,
* 包括其聚合键(用于客观题/提交记录归集)与分项分。
* @returns {Promise<Object|null>}
* { group, isExam, operatorId, operator, aggKeys, viewerName }
*/
async _resolveTeamScoreSource() {
const { ctx } = this;
const user = ctx.state.currentUser;
if (!user || !user.id) return null;
const me = await ctx.model.Contestant.findOne({
where: { id: user.id },
attributes: ['id', 'team_id', 'group', 'name', 'word_score', 'ppt_score', 'score', 'exam_score'],
});
if (!me || !me.group) return null;
const group = me.group;
const isExam = group === 'zhongzhi' || group === 'gaozhi';
const Op = ctx.model.Sequelize.Op;
// 1) 收集同队成员 contestant id(队伍身份 = aggregation.team_uid,回退 contestants.team_id)
// 必须按 me.group 限定:运行库中 team_uid 仅在本组内唯一(不同组可能复用同一数值,
// 例如 benke 与 zhongzhi 都可能有 team_uid=1 但分属不同队),不限定 group 会把他组
// 成员错误并入本队,导致「一人考试全组看分」归集串组(见 verify_team_uid_consistency.py C2)。
let memberIds = [me.id];
const myAgg = await ctx.model.Aggregation.findOne({
where: { account_uid: me.id },
attributes: ['aggregation_uid', 'team_uid'],
});
if (myAgg && myAgg.team_uid) {
const aggs = await ctx.model.Aggregation.findAll({
where: { team_uid: myAgg.team_uid, group: me.group },
attributes: ['account_uid'],
});
const ids = aggs.map(a => a.account_uid).filter(Boolean);
if (ids.length) memberIds = ids;
} else if (me.team_id) {
const members = await ctx.model.Contestant.findAll({
where: { team_id: me.team_id, group: me.group },
attributes: ['id'],
});
const ids = members.map(m => m.id);
if (ids.length) memberIds = ids;
}
// 2) 找到「考试操作员」:同队中实际产生提交/成绩的选手
let operatorId = me.id;
if (isExam) {
// 客观题考试以 exam_submissions.contestant_id 唯一标记操作员(UNIQUE 约束,仅一人)
const sub = await ctx.model.ExamSubmission.findOne({
where: { contestant_id: memberIds },
attributes: ['contestant_id'],
order: [['submit_time', 'DESC']],
});
if (sub) operatorId = sub.contestant_id;
} else {
// 本科/教师:作品/演示阅卷分落在「考试操作员」的 contestants 行。
// 操作员判定规则(用户决策 #1):
// 1) 优先以 benke_submissions / jiaoshi_submissions 的提交记录存在性定操作员;
// 2) 同队成员中无 / 多人有提交记录时,以 (word_score+ppt_score) 最高者兜底,保证确定性。
// 提交表以 aggregation_uid 归集(≠ contestant_id),须先反查 account_uid -> aggregation_uid。
const SubmissionModel = group === 'benke'
? ctx.model.BenkeSubmission
: ctx.model.JiaoshiSubmission;
// 取同队成员的 aggregation_uid 映射(按当前组过滤,避免跨组行干扰)
const memberAggs = await ctx.model.Aggregation.findAll({
where: { account_uid: memberIds, group },
attributes: ['account_uid', 'aggregation_uid'],
raw: true,
});
const aggUidByAccount = {};
for (const a of memberAggs) aggUidByAccount[a.account_uid] = a.aggregation_uid;
const graded = await ctx.model.Contestant.findAll({
where: { id: memberIds },
attributes: ['id', 'word_score', 'ppt_score'],
});
// 统计「有提交记录」的成员(提交记录以 aggregation_uid 存在为准)
const submittedIds = [];
for (const c of graded) {
const aggUid = aggUidByAccount[c.id];
if (!aggUid) continue;
const sub = await SubmissionModel.findOne({
where: { aggregation_uid: aggUid },
attributes: ['id'],
});
if (sub) submittedIds.push(c.id);
}
// 兜底:总分最高者(无 / 多人有提交记录时皆以此定操作员)
let best = { id: me.id, total: -1 };
for (const c of graded) {
const t = (c.word_score || 0) + (c.ppt_score || 0);
if (t > best.total) best = { id: c.id, total: t };
}
if (submittedIds.length >= 1) {
// 有提交记录:唯一则取其;多人(异常)取总分最高者兜底
operatorId = submittedIds.length === 1 ? submittedIds[0] : best.id;
} else {
operatorId = best.id;
}
}
// 3) 取操作员的聚合键(用于客观题/提交记录归集)与分项分
const opAgg = await ctx.model.Aggregation.findOne({
where: { account_uid: operatorId },
attributes: ['aggregation_uid'],
});
const operatorAggUid = opAgg ? opAgg.aggregation_uid : null;
const operator = await ctx.model.Contestant.findOne({
where: { id: operatorId },
attributes: ['id', 'name', 'word_score', 'ppt_score', 'score', 'exam_score'],
});
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
// 归集键(单一真相源 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 };
}
/**
* 获取当前选手的(队伍)成绩
* GET /api/score
*
* 流程:先检查成绩是否已发布,再解析本队考试操作员并查询其得分。
* 返回:总分、计分式说明、是否已发布。
*/
async index() {
const { ctx, service } = this;
try {
const user = ctx.state.currentUser;
// 仅选手可查看成绩
if (!user || user.role !== 'contestant') {
ctx.body = ctx.helper.error(403, '无权限查看成绩');
return;
}
// 解析队伍成绩来源(含同队归集:一人考试全组可看)
const src = await this._resolveTeamScoreSource();
if (!src) {
ctx.body = ctx.helper.error(404, '未找到您的报名信息');
return;
}
const { group, isExam, operator, aggKeys, viewerName } = src;
// 检查该组成绩是否已发布(按组门控:发布本科组不得让中职组考生看到成绩)
const isPublished = await service.admin.isScorePublished(group);
if (!isPublished) {
ctx.body = ctx.helper.success({
score: null,
details: null,
published: false,
message: '成绩尚未发布,请耐心等待',
}, '获取成绩状态成功');
return;
}
// 按组别返回分项成绩(与后台评分公式一致)
let examScore = undefined;
if (isExam) {
// 客观题分数取逐题自动评分合计;聚合键用「操作员 contestant.id」与「操作员真实 aggregation_uid」双键归集。
const SubmissionModel = group === 'zhongzhi'
? ctx.model.ZhongzhiSubmission
: ctx.model.GaozhiSubmission;
const rows = await SubmissionModel.findAll({
where: { aggregation_uid: aggKeys },
attributes: ['score'],
raw: true,
});
examScore = rows.reduce((sum, r) => sum + (r.score || 0), 0);
}
// 分项分从「操作员」行取(其实际产生成绩),而非查看者本人。
const wordScore = operator.word_score || 0;
const pptScore = isExam ? undefined : (operator.ppt_score || 0);
// 总分 = 客观题 + Word 分(中职/高职);Word 分 + PPT 分(本科/教师)。
const score = isExam
? (examScore + wordScore)
: (wordScore + (pptScore || 0));
const formula = isExam
? '客观题系统评分 + Word 文档阅卷评分'
: 'Word 文档阅卷评分 + PPT 演示阅卷评分';
// 注意:考生端只回传「总分(score)」与计分式说明(formula),
// 不得回传分项分数(examScore/wordScore/pptScore)——分数分布仅管理后台可见。
// nameCn 用查看者本人姓名(各成员看到自己的名字 + 本队共享总分)。
ctx.body = ctx.helper.success({
score,
nameCn: viewerName || '',
group,
published: true,
details: null,
formula,
}, '获取成绩成功');
} catch (err) {
ctx.logger.error('获取成绩失败:', err);
ctx.body = ctx.helper.error(500, '获取成绩失败');
}
}
}
module.exports = ScoreController;