feat(admin): 成绩复核——按姓名/身份证/电话检索考生并查看全卷客观题逐题作答与正确答案(R26)
- service.admin: 新增 reviewSearch / getContestantScore / _reviewWorks;移除同名的死代码旧实现 - 归集双键合并 [contestant.id] ∪ [真实 aggregation_uid],与 getExamSubmissions 口径逐字一致 - 题序还原:优先 exam_submissions.question_order,缺失则回退「选择→判断 + 题号升序」并回传 questionOrderFixed=false - 未作答行 answerMatches 返回 null(非 false),避免界面把「未答」读成「答错」 - 组别以库中考生 group 为准,路由 :group 不符返回 409,防前端传错组照错人 - 异常可区分:questionMissing(题被删/改组)、answerMatches≠isCorrect(判分矛盾) - 因回传正确答案,两条路由均挂 adminAuth —— 选手端可达即成绩泄露(与 verify_score_leak_all_channels.py 三通道门禁一致) - 不改动 schema、不改动既有接口行为 controller.admin: 新增 reviewSearch / contestantScore(NOT_FOUND→404 / GROUP_MISMATCH→409 / 其它→400) router.js: 注册 GET /api/admin/review/search 与 /api/admin/review/score/:group/:id,均 adminAuth
This commit is contained in:
@@ -1906,6 +1906,83 @@ class AdminController extends Controller {
|
||||
ctx.body = ctx.helper.error(500, '大题评分失败');
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 成绩复核 ====================
|
||||
// 场景:考生对成绩有异议 → 管理员按「姓名 / 身份证号 / 电话」任一检索 → 核对全卷题目与作答。
|
||||
// ⚠️ 复核明细会回传 question.answer(正确答案),**只能**挂 admin 路由
|
||||
// (若被选手端复用即为成绩泄露,见 scripts/verify_score_leak_all_channels.py)。
|
||||
|
||||
/**
|
||||
* 检索考生
|
||||
* GET /api/admin/review/search?keyword=xxx[&group=zhongzhi]
|
||||
*
|
||||
* keyword 命中「姓名 / 身份证号 / 电话」任一(模糊匹配)。不传 group 则全组别检索 ——
|
||||
* 复核时管理员通常只拿到一个身份证号,不该被迫先猜组别。
|
||||
*/
|
||||
async reviewSearch() {
|
||||
const { ctx, service } = this;
|
||||
if (!this._ensureAdmin()) return;
|
||||
|
||||
try {
|
||||
const keyword = ctx.query.keyword;
|
||||
const group = ctx.query.group || undefined;
|
||||
|
||||
// 传了 group 就必须合法;不传表示全组别检索。
|
||||
if (group && !this._validateGroup(group, true)) return;
|
||||
|
||||
const result = await service.admin.reviewSearch(keyword, group);
|
||||
if (!result.ok) {
|
||||
ctx.body = ctx.helper.error(400, result.message);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.body = ctx.helper.success({
|
||||
list: result.list,
|
||||
total: result.list.length,
|
||||
truncated: result.truncated,
|
||||
}, '检索完成');
|
||||
} catch (err) {
|
||||
ctx.logger.error('复核检索失败:', err);
|
||||
ctx.body = ctx.helper.error(500, '复核检索失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 考生全卷明细(复核)
|
||||
* GET /api/admin/review/score/:group/:id
|
||||
*
|
||||
* :id 为 contestants.id。:group 仅用于**校验**(与考生实际组别不符则拒绝),
|
||||
* 真正的取数组别以库中考生的 group 为准 —— 防止前端传错组别导致照错人出证据。
|
||||
*/
|
||||
async contestantScore() {
|
||||
const { ctx, service } = this;
|
||||
if (!this._ensureAdmin()) return;
|
||||
|
||||
try {
|
||||
const { group, id } = ctx.params;
|
||||
if (!this._validateGroup(group, true)) return;
|
||||
|
||||
const cid = parseInt(id, 10);
|
||||
if (isNaN(cid)) {
|
||||
ctx.body = ctx.helper.error(400, '考生 ID 非法');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await service.admin.getContestantScore(cid, group);
|
||||
if (!result.ok) {
|
||||
// 404 资源不存在;409 资源存在但与请求上下文冲突(组别不符)
|
||||
const status = result.code === 'NOT_FOUND' ? 404
|
||||
: (result.code === 'GROUP_MISMATCH' ? 409 : 400);
|
||||
ctx.body = ctx.helper.error(status, result.message);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.body = ctx.helper.success(result, '获取考生全卷明细成功');
|
||||
} catch (err) {
|
||||
ctx.logger.error('获取考生全卷明细失败:', err);
|
||||
ctx.body = ctx.helper.error(500, '获取考生全卷明细失败');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = AdminController;
|
||||
|
||||
@@ -187,6 +187,14 @@ module.exports = app => {
|
||||
router.post('/api/admin/toggle-test-flag', adminAuth, controller.admin.toggleTestFlag);
|
||||
router.post('/api/admin/reset-test-data', adminAuth, controller.admin.resetTestData);
|
||||
|
||||
// ==================== 成绩复核(需管理员认证) ====================
|
||||
// 场景:考生对成绩有异议 → 按「姓名 / 身份证号 / 电话」任一检索 → 核对全卷题目与作答。
|
||||
// ⚠️ 本组会回传客观题正确答案,务必保持 adminAuth —— 选手端一旦可达即为成绩泄露。
|
||||
// 注册顺序:两条路径段数不同(3 段 / 5 段),不存在通配段互相吞并的问题;
|
||||
// 仍按「固定段在前」的惯例把 search 放前面。
|
||||
router.get('/api/admin/review/search', adminAuth, controller.admin.reviewSearch);
|
||||
router.get('/api/admin/review/score/:group/:id', adminAuth, controller.admin.contestantScore);
|
||||
|
||||
// ==================== 成绩查询(需认证) ====================
|
||||
|
||||
router.get('/api/score', auth, controller.score.index);
|
||||
|
||||
@@ -1896,51 +1896,333 @@ class AdminService extends Service {
|
||||
await ctx.model.Carousel.destroy({ where: { id } });
|
||||
}
|
||||
|
||||
// ==================== 成绩查询 ====================
|
||||
// ==================== 成绩查询 ====================
|
||||
// ==================== 成绩复核 ====================
|
||||
// 场景:考生对成绩有异议 → 管理员按「姓名 / 身份证号 / 电话」任一检索 → 核对全卷题目与作答。
|
||||
// 两条取数通路(reviewSearch → getContestantScore)**只允许挂在 admin 路由下**。
|
||||
|
||||
/**
|
||||
* 获取选手个人成绩详情
|
||||
* @param {number} aggregationUid - 聚合报名 ID
|
||||
* @param {string} group - 组别
|
||||
* @returns {Promise<Object>} 成绩详情
|
||||
* 检索考生(复核入口)
|
||||
*
|
||||
* 命中口径:姓名 / 身份证号 / 电话 **任一** 模糊匹配(用户明确要求),且**不限组别** ——
|
||||
* 复核时管理员通常只拿到一个身份证号或姓名,不该被迫先猜组别;结果里带 group 供界面展示。
|
||||
*
|
||||
* @param {string} keyword - 检索词(姓名 / 身份证号 / 电话片段)
|
||||
* @param {string} [group] - 可选:限定组别
|
||||
* @returns {Promise<{ok:boolean, code?:string, message?:string, list?:Array, truncated?:boolean}>}
|
||||
*/
|
||||
async getContestantScore(aggregationUid, group) {
|
||||
async reviewSearch(keyword, group) {
|
||||
const { ctx } = this;
|
||||
const result = {};
|
||||
const kw = String(keyword == null ? '' : keyword).trim();
|
||||
|
||||
if (group === 'zhongzhi' || group === 'gaozhi') {
|
||||
const SubmissionModel = group === 'zhongzhi'
|
||||
? ctx.model.ZhongzhiSubmission
|
||||
: ctx.model.GaozhiSubmission;
|
||||
|
||||
const submissions = await SubmissionModel.findAll({
|
||||
where: { aggregation_uid: aggregationUid },
|
||||
attributes: ['id', 'question_uid', 'answer_data', 'is_correct', 'score', 'word_file_path'],
|
||||
});
|
||||
|
||||
const totalScore = submissions.reduce((sum, s) => sum + (s.score || 0), 0);
|
||||
result.score = totalScore;
|
||||
result.totalQuestions = submissions.length;
|
||||
result.correctCount = submissions.filter(s => s.is_correct).length;
|
||||
result.details = submissions;
|
||||
result.hasWordFile = submissions.some(s => s.word_file_path);
|
||||
} else {
|
||||
const SubmissionModel = group === 'benke'
|
||||
? ctx.model.BenkeSubmission
|
||||
: ctx.model.JiaoshiSubmission;
|
||||
|
||||
const submissions = await SubmissionModel.findAll({
|
||||
where: { aggregation_uid: aggregationUid },
|
||||
attributes: ['id', 'work_name', 'file_name', 'file_path', 'file_size', 'upload_time', 'score'],
|
||||
order: [['upload_time', 'DESC']],
|
||||
});
|
||||
|
||||
result.score = submissions.reduce((sum, s) => sum + (s.score || 0), 0);
|
||||
result.submissions = submissions;
|
||||
// 空关键字返回空列表而非全表:避免误操作把整库考生拖给前端。
|
||||
if (!kw) return { ok: true, list: [], truncated: false };
|
||||
if (kw.length > 64) {
|
||||
return { ok: false, code: 'BAD_KEYWORD', message: '检索词过长(上限 64 字符)' };
|
||||
}
|
||||
|
||||
return result;
|
||||
const { Op } = ctx.model.Sequelize;
|
||||
const like = { [Op.like]: `%${kw}%` };
|
||||
const where = { [Op.or]: [{ name: like }, { id_number: like }, { phone: like }] };
|
||||
if (group) where.group = group;
|
||||
|
||||
const LIMIT = 50;
|
||||
const rows = await ctx.model.Contestant.findAll({
|
||||
where,
|
||||
attributes: ['id', 'name', 'id_number', 'phone', 'group'],
|
||||
order: [['group', 'ASC'], ['id', 'ASC']],
|
||||
limit: LIMIT,
|
||||
raw: true,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
list: rows.map(c => ({
|
||||
contestantId: c.id,
|
||||
name: c.name || '',
|
||||
idNumber: c.id_number || '',
|
||||
phone: c.phone || '',
|
||||
group: c.group,
|
||||
// 客观题只存在于中职/高职;本科/教师的「卷面」是作品文件。界面据此切换展示与提示。
|
||||
objectiveApplicable: c.group === 'zhongzhi' || c.group === 'gaozhi',
|
||||
})),
|
||||
// 命中被截断时如实上报,引导管理员补充更多字符(而非静默只展示前 50 条)。
|
||||
truncated: rows.length >= LIMIT,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 成绩复核:取某考生的**全卷**明细。
|
||||
*
|
||||
* 输出 = 客观题逐题(题干 / 选项 / 考生答案 / 正确答案 / 对错 / 得分 / 满分)
|
||||
* + 综合大题(题干快照 + 三小问作答 + 三小问得分)
|
||||
* + 本科/教师组的作品文件清单。
|
||||
*
|
||||
* 设计约束(每条都对应一个已发生过的坑):
|
||||
* 1. **归集口径必须与 getExamSubmissions 完全一致**(双键:contestant.id 与真实 aggregation_uid)。
|
||||
* 否则会出现「列表页说已答 70 题、复核页只有 0 题」—— 管理员会先怀疑数据被篡改。
|
||||
* 2. **组别以考生自身的 group 为准**,并校验入参 group:防跨组串查。
|
||||
* 3. 题目按 **exam_submissions.question_order(考生固化的抽题顺序)** 排列,复现他当时所见的题序;
|
||||
* 无固化顺序的历史数据回退为「选择在前、判断在后,各自按题号升序」。
|
||||
* 4. `question.answer` **必须回传**(复核场景用户明确要求给出正确答案)。
|
||||
* ⚠️ 因此本方法**只能挂 admin 路由**:一旦被选手端复用即为成绩泄露
|
||||
* (参见 scripts/verify_score_leak_all_channels.py 所守的三条通道)。
|
||||
* 也正因如此这里**不做** isScorePublished 门控 —— admin 是可信边界,
|
||||
* 而内部核对往往发生在发布之前;发布态只在返回值里**如实上报**供界面提示。
|
||||
* 5. 题库中已删除 / 改组导致取不到题目时标 `questionMissing`,而非静默留空 ——
|
||||
* 复核要能区分「考生没答」与「题没了」。
|
||||
*
|
||||
* @param {number} contestantId - contestants.id
|
||||
* @param {string} [group] - 调用方声明的组别(须与考生实际组别一致)
|
||||
* @returns {Promise<Object>} { ok, ... } ok=false 时带 code/message(不抛异常)
|
||||
*/
|
||||
async getContestantScore(contestantId, group) {
|
||||
const { ctx } = this;
|
||||
const { Op } = ctx.model.Sequelize;
|
||||
|
||||
const cid = parseInt(contestantId, 10);
|
||||
if (!Number.isFinite(cid)) {
|
||||
return { ok: false, code: 'BAD_ID', message: '考生 ID 非法' };
|
||||
}
|
||||
|
||||
const contestant = await ctx.model.Contestant.findOne({
|
||||
where: { id: cid },
|
||||
attributes: ['id', 'name', 'id_number', 'phone', 'group', 'score', 'exam_score', 'word_score', 'ppt_score'],
|
||||
raw: true,
|
||||
});
|
||||
if (!contestant) return { ok: false, code: 'NOT_FOUND', message: '考生不存在' };
|
||||
if (group && group !== contestant.group) {
|
||||
return { ok: false, code: 'GROUP_MISMATCH', message: `组别不符:该考生属于「${contestant.group}」组` };
|
||||
}
|
||||
|
||||
const g = contestant.group;
|
||||
const isExamGroup = g === 'zhongzhi' || g === 'gaozhi';
|
||||
|
||||
const base = {
|
||||
contestantId: cid,
|
||||
name: contestant.name || '',
|
||||
idNumber: contestant.id_number || '',
|
||||
phone: contestant.phone || '',
|
||||
group: g,
|
||||
objectiveApplicable: isExamGroup,
|
||||
scorePublished: await this.isScorePublished(g), // 只上报,不门控(见方法头第 4 条)
|
||||
};
|
||||
|
||||
if (!isExamGroup) {
|
||||
// 本科/教师:无客观题,「卷面」即提交的作品文件(Word / PPT)。
|
||||
return {
|
||||
ok: true,
|
||||
...base,
|
||||
objective: [],
|
||||
summary: { answered: 0, total: 0, correctCount: 0, autoScore: 0 },
|
||||
bigQuestion: null,
|
||||
works: await this._reviewWorks(cid, g),
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
// ---- 1) 双键归集(与 getExamSubmissions 完全同口径) ----
|
||||
const agg = await ctx.model.Aggregation.findOne({
|
||||
where: { account_uid: cid },
|
||||
attributes: ['aggregation_uid'],
|
||||
raw: true,
|
||||
});
|
||||
const keys = [String(cid)];
|
||||
if (agg && agg.aggregation_uid != null && String(agg.aggregation_uid) !== String(cid)) {
|
||||
keys.push(String(agg.aggregation_uid));
|
||||
}
|
||||
|
||||
const SubmissionModel = g === 'zhongzhi' ? ctx.model.ZhongzhiSubmission : ctx.model.GaozhiSubmission;
|
||||
const subs = await SubmissionModel.findAll({
|
||||
where: { aggregation_uid: { [Op.in]: keys.map(Number) } },
|
||||
attributes: ['id', 'aggregation_uid', 'question_uid', 'question_type', 'answer_data', 'is_correct', 'score'],
|
||||
order: [['id', 'ASC']],
|
||||
raw: true,
|
||||
});
|
||||
|
||||
const warnings = [];
|
||||
const usedKeys = [ ...new Set(subs.map(s => String(s.aggregation_uid))) ];
|
||||
if (usedKeys.length > 1) {
|
||||
warnings.push('该考生的作答记录同时落在两种聚合键下(' + usedKeys.join(' / ') +
|
||||
'),已按与「考生答题记录」一致的口径合并归集;若题数异常请人工核对。');
|
||||
}
|
||||
|
||||
// ---- 2) 固化题序(复现考生当时所见次序) ----
|
||||
const examSub = await ctx.model.ExamSubmission.findOne({
|
||||
where: { contestant_id: cid },
|
||||
attributes: ['question_order', 'submit_time'],
|
||||
raw: true,
|
||||
});
|
||||
const rank = new Map();
|
||||
if (examSub && examSub.question_order) {
|
||||
try {
|
||||
const parsed = JSON.parse(examSub.question_order);
|
||||
if (Array.isArray(parsed)) parsed.forEach((u, i) => rank.set(String(u), i));
|
||||
} catch (e) {
|
||||
// 脏数据不抛异常:回退默认排序(复核页不应因一条脏记录整体打不开)
|
||||
ctx.logger.warn('[getContestantScore] question_order 解析失败 cid=%s', cid);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 3) 批量取题干(按题型分表;必须带 group 限定,防跨组同号题串台) ----
|
||||
const idsByType = { choice: [], judge: [] };
|
||||
for (const s of subs) {
|
||||
const t = s.question_type;
|
||||
// 白名单判定,避免 'constructor' 之类字符串命中 Object 原型链
|
||||
if (!Object.prototype.hasOwnProperty.call(idsByType, t)) continue;
|
||||
const n = Number(s.question_uid);
|
||||
if (Number.isFinite(n)) idsByType[t].push(n);
|
||||
}
|
||||
const qMap = new Map();
|
||||
for (const t of [ 'choice', 'judge' ]) {
|
||||
const ids = idsByType[t];
|
||||
if (!ids.length) continue;
|
||||
const Model = t === 'choice' ? ctx.model.ChoiceQuestion : ctx.model.JudgeQuestion;
|
||||
const rows = await Model.findAll({ where: { id: { [Op.in]: ids }, group: g }, raw: true });
|
||||
rows.forEach(r => qMap.set(`${t}:${r.id}`, r));
|
||||
}
|
||||
|
||||
// ---- 4) 拼装逐题明细 ----
|
||||
const norm = v => String(v == null ? '' : v).trim().toLowerCase();
|
||||
const objective = subs.map(s => {
|
||||
const t = s.question_type;
|
||||
const n = Number(s.question_uid);
|
||||
const q = qMap.get(`${t}:${n}`) || null;
|
||||
const userAnswer = s.answer_data == null ? '' : String(s.answer_data);
|
||||
const correct = q ? String(q.answer == null ? '' : q.answer) : null;
|
||||
const orderKey = `${t}_${n}`;
|
||||
const seq = rank.has(orderKey) ? rank.get(orderKey)
|
||||
: (rank.has(String(n)) ? rank.get(String(n)) : null);
|
||||
return {
|
||||
seq,
|
||||
questionType: t,
|
||||
questionUid: n,
|
||||
question: q ? q.question : null,
|
||||
options: q ? (q.options || null) : null,
|
||||
// 原始值:选择 'A'/'AB';判断 'true'/'false'(由前端做「正确/错误」展示映射,后端不丢事实)
|
||||
correctAnswer: correct,
|
||||
maxScore: q && q.score != null ? Number(q.score) : null,
|
||||
answer: userAnswer,
|
||||
answered: userAnswer.trim() !== '',
|
||||
isCorrect: s.is_correct == null ? null : !!s.is_correct,
|
||||
score: s.score == null ? null : Number(s.score),
|
||||
// 交叉校验:答案一致却判为错(或反之)即判分异常 —— 复核时最先要看的正是这个。
|
||||
// ⚠️ 未作答必须给 null 而非 false:'' 与标准答案比对天然不等,
|
||||
// 若返回 false,「未答」在界面上会被误读成「答错」。
|
||||
answerMatches: (q && userAnswer.trim() !== '') ? (norm(userAnswer) === norm(correct)) : null,
|
||||
questionMissing: !q,
|
||||
};
|
||||
});
|
||||
|
||||
objective.sort((a, b) => {
|
||||
const ra = a.seq == null ? Number.MAX_SAFE_INTEGER : a.seq;
|
||||
const rb = b.seq == null ? Number.MAX_SAFE_INTEGER : b.seq;
|
||||
if (ra !== rb) return ra - rb;
|
||||
if (a.questionType !== b.questionType) return a.questionType === 'choice' ? -1 : 1;
|
||||
return a.questionUid - b.questionUid;
|
||||
});
|
||||
|
||||
// 小数按 1 位收敛(与 DECIMAL(6,1) 及全站评分口径一致,避免 0.30000000000000004)
|
||||
const round1 = v => Math.round(v * 10) / 10;
|
||||
const autoScore = round1(objective.reduce((sum, o) => sum + (o.score || 0), 0));
|
||||
|
||||
// ---- 5) 综合大题(题干取抽题快照,保证考生当时所见可举证) ----
|
||||
let bigQuestion = null;
|
||||
const bigRow = await ctx.model.BigQuestionAnswer.findOne({
|
||||
where: { aggregation_uid: cid },
|
||||
raw: true,
|
||||
});
|
||||
if (bigRow) {
|
||||
const snap = this.service.bigQuestion.parseSnapshot(bigRow.question_snapshot) || {};
|
||||
const subPrompts = Array.isArray(snap.sub_prompts) ? snap.sub_prompts : [];
|
||||
bigQuestion = {
|
||||
drawn: true,
|
||||
bigQuestionId: bigRow.big_question_id || null,
|
||||
bigQuestionUid: bigRow.big_question_uid || null,
|
||||
title: snap.title || '',
|
||||
description: snap.description || '',
|
||||
subPrompts,
|
||||
scorePerSub: snap.score_per_sub != null ? Number(snap.score_per_sub) : null,
|
||||
maxTotal: snap.total_score != null ? Number(snap.total_score) : null,
|
||||
// 固定 3 个槽位,与库表 answer_1..3 对齐(未作答为 '')
|
||||
answers: [ 1, 2, 3 ].map(i => {
|
||||
const a = bigRow[`answer_${i}`] == null ? '' : String(bigRow[`answer_${i}`]);
|
||||
const sc = bigRow[`score_${i}`];
|
||||
return {
|
||||
index: i,
|
||||
prompt: subPrompts[i - 1] || '',
|
||||
answer: a,
|
||||
answered: a.trim() !== '',
|
||||
score: sc == null ? null : Number(sc),
|
||||
};
|
||||
}),
|
||||
totalScore: bigRow.total_score == null ? null : Number(bigRow.total_score),
|
||||
snapshotAt: snap.snapshot_at || null,
|
||||
submitTime: bigRow.submit_time || null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
...base,
|
||||
objective,
|
||||
summary: {
|
||||
answered: objective.length,
|
||||
total: objective.length,
|
||||
correctCount: objective.filter(o => o.isCorrect === true).length,
|
||||
autoScore,
|
||||
wordScore: contestant.word_score == null ? 0 : Number(contestant.word_score),
|
||||
totalScore: round1(autoScore + (contestant.word_score == null ? 0 : Number(contestant.word_score))),
|
||||
},
|
||||
// 固化题序存在与否决定上图顺序的可解释性,一并上报(便于核对与排障)
|
||||
questionOrderFixed: rank.size > 0,
|
||||
matchedKeys: usedKeys,
|
||||
bigQuestion,
|
||||
works: [],
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 本科/教师组的作品文件清单(复核用,只读)
|
||||
*
|
||||
* 不返回 file_path(服务端磁盘路径不该出网),只给下载所需的 submission id;
|
||||
* 下载走既有端点 GET /api/admin/submission-file/:group/:id。
|
||||
*
|
||||
* @param {number} contestantId - contestants.id
|
||||
* @param {string} group - benke / jiaoshi
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
async _reviewWorks(contestantId, group) {
|
||||
const { ctx } = this;
|
||||
const SubmissionModel = group === 'benke' ? ctx.model.BenkeSubmission : ctx.model.JiaoshiSubmission;
|
||||
|
||||
// benke/jiaoshi_submissions.aggregation_uid 存的是「聚合报名 ID」,与 contestants.id 不是同一序列,
|
||||
// 需经 aggregation.account_uid 映射(与 getSubmissions 同口径)。
|
||||
const agg = await ctx.model.Aggregation.findOne({
|
||||
where: { account_uid: contestantId },
|
||||
attributes: ['aggregation_uid'],
|
||||
raw: true,
|
||||
});
|
||||
const keys = [String(contestantId)];
|
||||
if (agg && agg.aggregation_uid != null && String(agg.aggregation_uid) !== String(contestantId)) {
|
||||
keys.push(String(agg.aggregation_uid));
|
||||
}
|
||||
|
||||
const rows = await SubmissionModel.findAll({
|
||||
where: { aggregation_uid: { [ctx.model.Sequelize.Op.in]: keys.map(Number) } },
|
||||
attributes: ['id', 'work_name', 'file_name', 'file_size', 'upload_time', 'score'],
|
||||
order: [['upload_time', 'DESC']],
|
||||
raw: true,
|
||||
});
|
||||
|
||||
return rows.map(r => ({
|
||||
id: r.id,
|
||||
workName: r.work_name || '',
|
||||
fileName: r.file_name || '',
|
||||
fileSize: r.file_size == null ? null : Number(r.file_size),
|
||||
uploadTime: r.upload_time || null,
|
||||
score: r.score == null ? null : Number(r.score),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user