'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} * { 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;