Files
competition-website/app/controller/score.js
WorkBuddy a303cf2d53 chore: 后端首次基线提交
运行必须源码:app/(controller/model/service/middleware/lib/utils/validate/router)、config/、database/(migrations/seeders/sql)、启动脚本、app/public/templates 模板。
已忽略运行非必须:node_modules/、logs/、run/、app/public 运行时上传数据、.env*、构建/缓存产物。
2026-08-17 12:25:42 +08:00

231 lines
9.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
const Controller = require('egg').Controller;
/**
* 成绩查询控制器 - 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'],
});
const keys = [String(operatorId)];
if (operatorAggUid) keys.push(String(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;