231 lines
9.4 KiB
JavaScript
231 lines
9.4 KiB
JavaScript
|
|
'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;
|