现象(人工测试反馈,已取证):
测试账号 吴巍荪(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
234 lines
9.7 KiB
JavaScript
234 lines
9.7 KiB
JavaScript
'use strict';
|
||
|
||
const Controller = require('egg').Controller;
|
||
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'],
|
||
});
|
||
// 归集键(单一真相源 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;
|