feat(admin): R176-R180 公示表与新闻撤回 + 管理端多管理员权限(C 层)+ 交卷冻结
- R176 成绩公示表引擎(lib/publicitySheet.js + lib/assets/publicity-template.xlsx 官方模板基底) 与指导教师回填(lib/teacherBackfill.js);赛项名称/编号改为配置项 - R177 新闻「发布 ↔ 撤回」互斥:撤回只置 is_draft=1,不删除数据 - R178 C 层 RBAC:lib/adminPermissions.js(13 权限点 / 70 条路由规则,唯一 SoT) + middleware/auth.js 三段判定(默认拒绝 · is_super 单点 · token_version) + controller/service/adminUser.js + lib/auditLog.js + model/adminAuditLog.js - R178 B1 交卷冻结:service.exam.assertNotSubmitted(bizCode 4006 单一判据源) - R178 B④ 提交口径拆分:formalSubmitted / submitted / answeredCount 三字段 - R178 成绩总览补小组编号:resolveTeamLabels(与选手管理同源,含兜底与三态) - R181 生产迁移配套:admins +7 列、admin_audit_logs(scripts/migrate_r178_admin_rbac.py)
This commit is contained in:
@@ -29,6 +29,78 @@ const { inspectIdNumber } = require('../lib/idCard');
|
||||
const OBJECTIVE_SCORE_PER_QUESTION = 1;
|
||||
const OBJECTIVE_TYPES = ['choice', 'judge'];
|
||||
|
||||
/**
|
||||
* 批量解析「小组编号」标签(R178 · 成绩管理「小组编号」列)
|
||||
*
|
||||
* 需求原文:「成绩管理中,需要有小组编号来方便查证小组是否完成考试。」
|
||||
* 成绩总览原本只有姓名/身份证号,无法按小组归并核对 ⇒ 补一列人类可读的小组编号。
|
||||
*
|
||||
* 🔴 口径必须与「选手管理」一致,⛔ 不得另立第二套(对照 `getAccounts` 的 R90 段):
|
||||
* · 小组键 = (组别 group, 队伍号 teamUid),⛔ 不可单用 teamUid(跨组重号)
|
||||
* · teamUid 解析优先级:`aggregation.team_uid`(报名表聚合,权威)→ `contestants.team_id`(兜底)
|
||||
* · 展示值取 `teams.team_id`(人类可读队伍编号,如 BRICS2026-ST-027-91001),
|
||||
* 缺 `team_number` 时回落同一值 —— 与选手管理 `teamNumber` 口径相同
|
||||
*
|
||||
* ⚠️ 为什么不能直接展示 `contestants.team_id`:该列是**内部数字**(实测王学卿=8),
|
||||
* 与选手管理展示的报名队伍编号不是一回事;两页显示不同值会让「按编号核对」失效。
|
||||
* ⚠️ 为什么不能只查 teams 表:实测中职组张明远 `aggregation.team_uid=1` 但 teams 表
|
||||
* 没有 (zhongzhi, 1) 行 —— 属「有归属无元信息」的历史数据,此时标签为 null(前端显示「未编组」)。
|
||||
*
|
||||
* @param {Object} ctx - Egg 上下文(用 ctx.model 取模型)
|
||||
* @param {Array<{id:number, group:string, team_id:?number}>} contestants
|
||||
* @returns {Promise<Map<string,{teamUid:?number,teamId:?string,teamNumber:?string}>>}
|
||||
* 键 = String(contestant.id);无归属者不出现在 Map 中(调用方按 null 处理)
|
||||
*/
|
||||
async function resolveTeamLabels(ctx, contestants) {
|
||||
const out = new Map();
|
||||
if (!Array.isArray(contestants) || contestants.length === 0) return out;
|
||||
|
||||
const Op = ctx.model.Sequelize.Op;
|
||||
const ids = contestants.map(c => c.id);
|
||||
|
||||
// 1) aggregation.team_uid(权威来源)
|
||||
const aggTeamUid = new Map();
|
||||
const aggRows = await ctx.model.Aggregation.findAll({
|
||||
where: { account_uid: { [Op.in]: ids } },
|
||||
attributes: ['account_uid', 'team_uid'],
|
||||
raw: true,
|
||||
});
|
||||
for (const a of aggRows) {
|
||||
if (a.team_uid === null || a.team_uid === undefined) continue;
|
||||
if (!aggTeamUid.has(a.account_uid)) aggTeamUid.set(a.account_uid, a.team_uid);
|
||||
}
|
||||
|
||||
// 2) 逐人定 teamUid(aggregation 优先 → contestants.team_id 兜底)
|
||||
const teamUidOf = c => {
|
||||
if (aggTeamUid.has(c.id)) return aggTeamUid.get(c.id);
|
||||
return c.team_id === null || c.team_id === undefined ? null : c.team_id;
|
||||
};
|
||||
|
||||
// 3) 一次性取队伍元信息(按 (group, teamUid) 建索引,避免跨组重号串组)
|
||||
const uids = Array.from(new Set(contestants.map(teamUidOf).filter(u => u !== null)));
|
||||
const meta = new Map();
|
||||
if (uids.length > 0) {
|
||||
const teamRows = await ctx.model.Team.findAll({
|
||||
where: { team_uid: { [Op.in]: uids } },
|
||||
attributes: ['team_uid', 'group_name', 'team_id', 'team_number'],
|
||||
raw: true,
|
||||
});
|
||||
for (const t of teamRows) meta.set(t.group_name + '#' + t.team_uid, t);
|
||||
}
|
||||
|
||||
for (const c of contestants) {
|
||||
const teamUid = teamUidOf(c);
|
||||
if (teamUid === null) continue;
|
||||
const m = meta.get(c.group + '#' + teamUid) || null;
|
||||
out.set(String(c.id), {
|
||||
teamUid,
|
||||
teamId: m ? m.team_id : null,
|
||||
teamNumber: m ? (m.team_number || m.team_id) : null,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理后台服务 - AdminService
|
||||
*
|
||||
@@ -75,15 +147,25 @@ class AdminService extends Service {
|
||||
});
|
||||
const contestantIds = contestants.map(c => c.id);
|
||||
if (contestantIds.length > 0) {
|
||||
const SubmissionModel = (group === 'zhongzhi' || group === 'gaozhi')
|
||||
const isExamGroup = (group === 'zhongzhi' || group === 'gaozhi');
|
||||
const SubmissionModel = isExamGroup
|
||||
? ctx.model.ExamSubmission
|
||||
: ctx.model.WorkSubmission;
|
||||
const submissions = await SubmissionModel.findAll({
|
||||
where: { contestant_id: contestantIds },
|
||||
attributes: ['contestant_id', 'score'],
|
||||
attributes: isExamGroup
|
||||
? ['contestant_id', 'score', 'submit_time']
|
||||
: ['contestant_id', 'score'],
|
||||
raw: true,
|
||||
});
|
||||
const uniqueIds = new Set(submissions.map(s => s.contestant_id));
|
||||
// 🔴 R178(B④)仪表盘「已提交」口径修正
|
||||
// 旧实现直接数 exam_submissions 的**行数**,而进考场就会建占位行(submit_time=NULL)
|
||||
// ⇒ 只是进过考场的人全被算成「已提交」,提交数与提交率双双虚高。
|
||||
// 现在中职/高职只统计正式交卷(submit_time 非空);本科/教师仍按「上传作品即有行」。
|
||||
const effective = isExamGroup
|
||||
? submissions.filter(s => !!s.submit_time)
|
||||
: submissions;
|
||||
const uniqueIds = new Set(effective.map(s => s.contestant_id));
|
||||
submittedCount = uniqueIds.size;
|
||||
totalScore = submissions.reduce((sum, s) => sum + (s.score || 0), 0);
|
||||
}
|
||||
@@ -545,11 +627,16 @@ class AdminService extends Service {
|
||||
// 取一个可用的竞赛 ID 用于聚合报名记录(aggregation.competition_uid 非空约束)
|
||||
let competitionUid = 1;
|
||||
try {
|
||||
// 🔴 R176:competition_info 是 key-value 表(info_key / info_value),
|
||||
// 不是三列结构。原实现 `findOne({ attributes:['competition_uid'] })`
|
||||
// 会抛 `Unknown column`,被下方 catch 吞掉后静默回落默认值 1 ——
|
||||
// 恰好 info_key='competition_uid' 的值也是 1,所以这个 bug 一直不可见。
|
||||
const comp = await ctx.model.CompetitionInfo.findOne({
|
||||
attributes: ['competition_uid'],
|
||||
order: [['competition_uid', 'DESC']],
|
||||
attributes: ['info_value'],
|
||||
where: { info_key: 'competition_uid' },
|
||||
});
|
||||
if (comp && comp.competition_uid) competitionUid = comp.competition_uid;
|
||||
const parsed = comp && comp.info_value ? parseInt(comp.info_value, 10) : NaN;
|
||||
if (isFinite(parsed) && parsed > 0) competitionUid = parsed;
|
||||
} catch (e) {
|
||||
ctx.logger.warn('[import] 获取竞赛 ID 失败,使用默认 1:', e.message);
|
||||
}
|
||||
@@ -730,6 +817,188 @@ class AdminService extends Service {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 指导教师回填(R176)
|
||||
*
|
||||
* 用户裁定:成绩公示表必须有「指导教师」列,但系统原本无任何承载字段。
|
||||
* 报名表里其实有教师行(`身份` = 指导教师/教练…),只是导入时按业务规则
|
||||
* 只导入「参赛选手」身份,教师行被计入 ignored 丢弃。
|
||||
*
|
||||
* 🔴 为什么不做成「重跑一次 importContestants」:
|
||||
* 该流程对已存在的身份证号一律 `skipped++`,重跑不会更新任何字段,
|
||||
* 只是把整张表再报一遍「已存在」。故单独开一条**只写 teacher_name** 的通路。
|
||||
* 🔴 为什么必须带 group:`teams.team_uid` 仅组内唯一、可跨组重号
|
||||
* ⇒ 归并键 = (group, 报名表队伍编号 → teams.team_id)。
|
||||
* 🔴 本方法**只**写 contestants.teacher_name,不碰账号 / 队伍 / 成绩 / 激活态。
|
||||
*
|
||||
* @param {Buffer} buffer 报名表 xlsx 原始字节(内存解析,不落盘:
|
||||
* `app/public` 对静态服务开放,报名表含身份证号,落盘即可被猜名下载)
|
||||
* @param {Object} [options]
|
||||
* @param {string} [options.group] 仅回填指定组别(不传 = 表里出现的全部组别)
|
||||
* @param {boolean} [options.overwrite=true] false = 只填空缺,不覆盖已有值
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async backfillTeachers(buffer, options = {}) {
|
||||
const { ctx } = this;
|
||||
const teacherBackfill = require('../lib/teacherBackfill');
|
||||
const { Op } = ctx.app.Sequelize;
|
||||
|
||||
if (!buffer || !buffer.length) {
|
||||
return { ok: false, code: 'EMPTY_BUFFER', message: '未收到文件内容' };
|
||||
}
|
||||
|
||||
let aoa;
|
||||
try {
|
||||
const workbook = XLSX.read(buffer, { type: 'buffer' });
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
if (!sheetName) {
|
||||
return { ok: false, code: 'EMPTY_FILE', message: '文件里没有工作表' };
|
||||
}
|
||||
aoa = XLSX.utils.sheet_to_json(workbook.Sheets[sheetName], { header: 1, blankrows: true });
|
||||
} catch (e) {
|
||||
return { ok: false, code: 'PARSE_FAILED', message: `无法解析为 Excel:${e.message}` };
|
||||
}
|
||||
|
||||
const parsed = teacherBackfill.extractTeachers(aoa);
|
||||
if (!parsed.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
code: parsed.code,
|
||||
message: parsed.message,
|
||||
stats: parsed.stats,
|
||||
warnings: parsed.errors,
|
||||
};
|
||||
}
|
||||
|
||||
// 可选:只处理指定组别(管理端按组操作时避免误改其他组)
|
||||
const onlyGroup = options.group ? String(options.group) : '';
|
||||
const targets = onlyGroup
|
||||
? parsed.teams.filter(t => t.group === onlyGroup)
|
||||
: parsed.teams;
|
||||
|
||||
if (targets.length === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'NO_TEACHER_IN_GROUP',
|
||||
message: onlyGroup
|
||||
? `报名表里没有属于「${onlyGroup}」组的指导教师行`
|
||||
: '没有可回填的小组',
|
||||
stats: parsed.stats,
|
||||
warnings: parsed.errors,
|
||||
};
|
||||
}
|
||||
|
||||
const overwrite = options.overwrite !== false;
|
||||
|
||||
// 一次性取回本次涉及组别的队伍映射:key = group\u0000team_id
|
||||
const groupSet = Array.from(new Set(targets.map(t => t.group)));
|
||||
const teamRows = await ctx.model.Team.findAll({
|
||||
attributes: ['team_uid', 'team_id', 'group_name'],
|
||||
where: { group_name: { [Op.in]: groupSet } },
|
||||
raw: true,
|
||||
});
|
||||
const teamKeyMap = new Map();
|
||||
for (const t of teamRows) {
|
||||
if (t.team_uid === null || t.team_uid === undefined) continue;
|
||||
teamKeyMap.set(`${t.group_name}\u0000${t.team_id}`, t.team_uid);
|
||||
}
|
||||
|
||||
const details = [];
|
||||
const unmatched = [];
|
||||
let matchedTeams = 0;
|
||||
let updatedRows = 0;
|
||||
let unchangedRows = 0;
|
||||
let noMemberTeams = 0;
|
||||
|
||||
for (const t of targets) {
|
||||
const teamUid = teamKeyMap.get(`${t.group}\u0000${t.teamId}`);
|
||||
if (teamUid === undefined) {
|
||||
unmatched.push({
|
||||
group: t.group,
|
||||
teamId: t.teamId,
|
||||
reason: '本组 teams 表里没有该队伍编号(可能尚未导入选手,或编号写法不一致)',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
matchedTeams++;
|
||||
|
||||
const teacherText = teacherBackfill.joinNames(t.names);
|
||||
const members = await ctx.model.Contestant.findAll({
|
||||
attributes: ['id', 'name', 'teacher_name'],
|
||||
where: { team_id: teamUid, group: t.group },
|
||||
raw: true,
|
||||
});
|
||||
if (members.length === 0) {
|
||||
noMemberTeams++;
|
||||
details.push({
|
||||
group: t.group,
|
||||
teamId: t.teamId,
|
||||
teamUid,
|
||||
teacherName: teacherText,
|
||||
memberCount: 0,
|
||||
affected: 0,
|
||||
note: '该队伍在 contestants 里没有成员',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// 只更新「需要变」的行:overwrite=false 时仅填空缺
|
||||
const needIds = [];
|
||||
for (const m of members) {
|
||||
const current = m.teacher_name === null || m.teacher_name === undefined ? '' : String(m.teacher_name);
|
||||
if (!overwrite && current !== '') {
|
||||
unchangedRows++;
|
||||
continue;
|
||||
}
|
||||
if (current === teacherText) {
|
||||
unchangedRows++;
|
||||
continue;
|
||||
}
|
||||
needIds.push(m.id);
|
||||
}
|
||||
|
||||
if (needIds.length > 0) {
|
||||
// 🔴 只写 teacher_name 一列:显式 attributes 白名单,避免把整行对象
|
||||
// 交给 update 时顺手带回 score / word_score 等无关列(Sequelize 会只更新
|
||||
// 传入的键,但显式白名单能让后续 review 一眼看出写入面)。
|
||||
await ctx.model.Contestant.update(
|
||||
{ teacher_name: teacherText },
|
||||
{ where: { id: { [Op.in]: needIds } } }
|
||||
);
|
||||
updatedRows += needIds.length;
|
||||
}
|
||||
|
||||
details.push({
|
||||
group: t.group,
|
||||
teamId: t.teamId,
|
||||
teamUid,
|
||||
teacherName: teacherText,
|
||||
memberCount: members.length,
|
||||
affected: needIds.length,
|
||||
rowNumbers: t.rowNumbers,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
code: 'OK',
|
||||
message: `已回填 ${updatedRows} 名选手的指导教师(命中 ${matchedTeams} 个小组)`,
|
||||
stats: parsed.stats,
|
||||
teams: {
|
||||
total: targets.length,
|
||||
matched: matchedTeams,
|
||||
unmatched: unmatched.length,
|
||||
noMember: noMemberTeams,
|
||||
},
|
||||
updated: updatedRows,
|
||||
unchanged: unchangedRows,
|
||||
details,
|
||||
unmatched,
|
||||
// 教师行本身的问题(缺组别 / 缺队伍编号 / 姓名为空)——非致命,逐行透出
|
||||
warnings: parsed.errors,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 提交记录管理 ====================
|
||||
|
||||
/**
|
||||
@@ -746,7 +1015,10 @@ class AdminService extends Service {
|
||||
|
||||
const contestants = await ctx.model.Contestant.findAll({
|
||||
where: { group },
|
||||
attributes: ['id', 'name', 'id_number', 'phone', 'group', 'score', 'exam_score', 'word_score'],
|
||||
// R178:补 team_id —— 后台需要「小组编号」核对某小组是否整体完成考试。
|
||||
// 组的归属只认 contestants.team_id(= teams.team_uid),⛔ 不可回落到
|
||||
// aggregation.team_uid(生产实测两者不同号,会把不同小组混成一个)。
|
||||
attributes: ['id', 'name', 'id_number', 'phone', 'group', 'score', 'exam_score', 'word_score', 'team_id'],
|
||||
order: [['id', 'ASC']],
|
||||
});
|
||||
|
||||
@@ -817,10 +1089,17 @@ class AdminService extends Service {
|
||||
const autoScore = subs.reduce((sum, s) => sum + (s.score || 0), 0);
|
||||
const wordSub = wordMap[String(c.id)];
|
||||
const hasWord = !!(wordSub && wordSub.word_file_path);
|
||||
// 正式提交状态:优先以 exam_submissions.submit_time 为准(与选手端 hasSubmitted 同一判定源);
|
||||
// 历史数据/旧流程未回填 submit_time,但已有逐题答题记录(answeredCount>0)同样视为已提交,
|
||||
// 与 CSV 导出(answeredCount>0 -> 是)保持一致,避免「试卷管理恒显未提交」的错位。
|
||||
// 🔴 R178(B④)「已提交」口径拆分
|
||||
//
|
||||
// 旧写法:`submitted: !!formalSubmitTime || answeredCount > 0`
|
||||
// ⇒ 只要答过一题就算「已提交」,没点交卷的考生在后台也显示「已提交」,
|
||||
// 管理员无法据此判断谁真的交卷了;`submitTime` 还会退回首条答题行的
|
||||
// created_at,把「第一次答题时间」显示成「提交时间」。
|
||||
// 现在:submitted / formalSubmitted = 是否**正式交卷**,唯一判据 = exam_submissions.submit_time
|
||||
// (与选手端 hasSubmitted 同一判定源);作答进度由 answeredCount / totalQuestions 单独表达。
|
||||
const formalSubmitTime = (wordSub && wordSub.submit_time) || null;
|
||||
const formalSubmitted = !!formalSubmitTime;
|
||||
const firstAnswerAt = subs.length ? subs[0].created_at : null;
|
||||
return {
|
||||
contestantId: c.id,
|
||||
aggregationUid: String(c.id),
|
||||
@@ -829,6 +1108,8 @@ class AdminService extends Service {
|
||||
idNumber: c.id_number,
|
||||
phone: c.phone,
|
||||
group: c.group,
|
||||
// 小组编号(teams.team_uid);为空表示该选手未编入小组(导入时缺小队列的历史数据)
|
||||
teamId: c.team_id === null || c.team_id === undefined ? null : String(c.team_id),
|
||||
answeredCount,
|
||||
totalQuestions,
|
||||
autoScore,
|
||||
@@ -839,8 +1120,10 @@ answeredCount,
|
||||
hasWordFile: hasWord,
|
||||
wordFilePath: (wordSub && wordSub.word_file_path) || null,
|
||||
wordFileName: (wordSub && wordSub.word_file_name) || null,
|
||||
submitted: !!formalSubmitTime || answeredCount > 0,
|
||||
submitTime: formalSubmitTime || (subs.length > 0 ? subs[0].created_at : null),
|
||||
submitted: formalSubmitted,
|
||||
formalSubmitted,
|
||||
firstAnswerAt,
|
||||
submitTime: formalSubmitTime,
|
||||
// 卷面分(客观分)统一取客观题自动评分合计,与成绩管理表口径一致。
|
||||
// ⚠️ 此前本行与上方 `examScore: c.exam_score ?? 0` 重复定义(对象字面量重复键,后者静默胜出);
|
||||
// 虽因 updateScores 会同步写 exam_score 而「结果碰巧一致」,但属隐患,故删除上方那条只留本条。
|
||||
@@ -1024,13 +1307,17 @@ answeredCount,
|
||||
// 故此处取 item.wordScore,仅表头文案按现行口径显示为「大题分」。
|
||||
const submissions = await this.getExamSubmissions(group);
|
||||
return {
|
||||
headers: ['选手ID', '身份证号', '姓名', '手机号', '是否提交', '客观题分', '大题分', '总分'],
|
||||
// 🔴 R178(B④):原「是否提交」列取 `answeredCount > 0`,与页面/接口口径(submit_time)
|
||||
// 不一致,同一份数据导出与页面显示会互相矛盾。现在拆为「是否交卷」(submit_time)
|
||||
// 与「答题数」两列,口径与 getExamSubmissions 的 submitted 完全同源。
|
||||
headers: ['选手ID', '身份证号', '姓名', '手机号', '是否交卷', '答题数', '客观题分', '大题分', '总分'],
|
||||
rows: submissions.map(item => [
|
||||
item.contestantId,
|
||||
item.idNumber || '',
|
||||
item.userName || '',
|
||||
item.phone || '',
|
||||
item.answeredCount > 0 ? '是' : '否',
|
||||
item.submitted ? '是' : '否',
|
||||
item.answeredCount ?? 0,
|
||||
item.examScore ?? 0,
|
||||
item.wordScore ?? 0,
|
||||
item.totalScore ?? 0,
|
||||
@@ -1107,8 +1394,199 @@ answeredCount,
|
||||
return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
|
||||
}
|
||||
|
||||
// ==================== 批量下载 ZIP ====================
|
||||
/**
|
||||
* 成绩公示表导出(R176)
|
||||
*
|
||||
* 产物 = 赛事官方参考模板(`lib/assets/publicity-template.xlsx`)的 xlsx 半成品:
|
||||
* 数据行已填,**获奖等级留空**待人工填。
|
||||
*
|
||||
* 行粒度(用户裁定):**一行 = 一个小组**。系统规则是「一组一人参赛、成绩全组共享」,
|
||||
* 所以同一小组不会因为多人而占多行;`参赛选手` / `指导教师` 两列用「、」连接全体。
|
||||
* 没有队伍归属的选手(`team_id IS NULL`,单人参赛)各自一行。
|
||||
*
|
||||
* 🔴 `contestants.score` 只在「组内唯一抽题者」名下非空(见 score.js 的共享读),
|
||||
* 因此按组取 **MAX(score)**,⛔ 不能取第一行 —— 否则会取到 NULL。
|
||||
* 🔴 单位取 `aggregation.organization_cn`(`account_uid` = 选手 id),
|
||||
* 组内取首个非空值(同队理论上相同)。
|
||||
* 🔴 本表**不含**身份证号 / 手机号,可直接对外;内部核查请用 exportToXlsx。
|
||||
*
|
||||
* @param {string} group
|
||||
* @returns {Promise<{buffer: Buffer, fileName: string, rowCount: number, testCount: number, title: Object}>}
|
||||
*/
|
||||
async buildPublicityXlsx(group) {
|
||||
const { ctx } = this;
|
||||
const publicitySheet = require('../lib/publicitySheet');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { Op } = ctx.app.Sequelize;
|
||||
|
||||
const GROUP_LABEL = { zhongzhi: '中职组', gaozhi: '高职组', benke: '本科组', jiaoshi: '教师组' };
|
||||
const groupLabel = GROUP_LABEL[group] || group;
|
||||
|
||||
// ---- 1) 赛项名称 / 编号(管理后台「赛项设置」维护的两项)----
|
||||
let itemName = '';
|
||||
let itemCode = '';
|
||||
try {
|
||||
const cfgRows = await ctx.model.CompetitionInfo.findAll({
|
||||
attributes: ['info_key', 'info_value'],
|
||||
where: { info_key: { [Op.in]: ['competition_item_name', 'competition_item_code'] } },
|
||||
raw: true,
|
||||
});
|
||||
for (const r of cfgRows) {
|
||||
const v = r.info_value === null || r.info_value === undefined ? '' : String(r.info_value).trim();
|
||||
if (r.info_key === 'competition_item_name') itemName = v;
|
||||
if (r.info_key === 'competition_item_code') itemCode = v;
|
||||
}
|
||||
} catch (e) {
|
||||
// 配置缺失不应阻断导出:标题保持模板占位(***赛项 / 空编号),人工再补
|
||||
ctx.logger.warn('[publicity] 读取赛项配置失败,标题使用模板占位:%s', e.message);
|
||||
}
|
||||
|
||||
// ---- 2) 选手(⛔ 不筛 test_flag:与 exportToXlsx 口径一致;测试行数单独统计给前端提示)----
|
||||
const contestants = await ctx.model.Contestant.findAll({
|
||||
attributes: ['id', 'name', 'score', 'team_id', 'teacher_name', 'test_flag'],
|
||||
where: { group },
|
||||
raw: true,
|
||||
});
|
||||
|
||||
// ---- 3) 队伍编号(报名表里的「队伍编号」落在 teams.team_id)----
|
||||
const teams = await ctx.model.Team.findAll({
|
||||
attributes: ['team_uid', 'team_id'],
|
||||
where: { group_name: group },
|
||||
raw: true,
|
||||
});
|
||||
const teamIdByUid = new Map();
|
||||
for (const t of teams) {
|
||||
if (t.team_uid !== null && t.team_uid !== undefined) teamIdByUid.set(t.team_uid, t.team_id);
|
||||
}
|
||||
|
||||
// ---- 4) 参赛单位 ----
|
||||
const aggRows = await ctx.model.Aggregation.findAll({
|
||||
attributes: ['account_uid', 'organization_cn'],
|
||||
where: { group },
|
||||
raw: true,
|
||||
});
|
||||
const orgByCid = new Map();
|
||||
for (const a of aggRows) {
|
||||
const v = a.organization_cn === null || a.organization_cn === undefined ? '' : String(a.organization_cn).trim();
|
||||
if (v && a.account_uid !== null && a.account_uid !== undefined) orgByCid.set(a.account_uid, v);
|
||||
}
|
||||
|
||||
// ---- 5) 按小组聚合 ----
|
||||
const bucket = new Map();
|
||||
let testCount = 0;
|
||||
let noTeamCount = 0;
|
||||
for (const c of contestants) {
|
||||
if (c.test_flag === 1) testCount++;
|
||||
const hasTeam = c.team_id !== null && c.team_id !== undefined;
|
||||
// 🔴 组归属只认 contestants.team_id(= teams.team_uid)——这是系统里唯一可靠的键。
|
||||
// ⛔ 不要回落到 aggregation.team_uid:那是另一套编号(实测本地 zhongzhi 的
|
||||
// aggregation.team_uid=1 而 teams.team_uid=8),拿它归组会把不同队伍并到一起。
|
||||
// 无 team_id 的选手按「单人参赛」各自成行,并把人数透出给前端提示
|
||||
// —— 覆盖率不足是数据问题,⛔ 不能在导出时静默猜。
|
||||
if (!hasTeam) noTeamCount++;
|
||||
const key = hasTeam ? `team:${c.team_id}` : `single:${c.id}`;
|
||||
let item = bucket.get(key);
|
||||
if (!item) {
|
||||
item = {
|
||||
teamUid: hasTeam ? c.team_id : null,
|
||||
teamId: hasTeam ? String(teamIdByUid.get(c.team_id) || c.team_id) : '',
|
||||
org: '',
|
||||
players: [],
|
||||
teachers: [],
|
||||
score: null,
|
||||
};
|
||||
bucket.set(key, item);
|
||||
}
|
||||
if (c.name) item.players.push(String(c.name).trim());
|
||||
// teacher_name 是「、」连接的整串,拆开交给 buildPublicityRows 统一去重拼接
|
||||
if (c.teacher_name) {
|
||||
for (const n of String(c.teacher_name).split('、')) {
|
||||
const s = n.trim();
|
||||
if (s) item.teachers.push(s);
|
||||
}
|
||||
}
|
||||
if (c.score !== null && c.score !== undefined) {
|
||||
const s = Number(c.score);
|
||||
if (isFinite(s) && (item.score === null || s > item.score)) item.score = s;
|
||||
}
|
||||
const org = orgByCid.get(c.id);
|
||||
if (org && !item.org) item.org = org;
|
||||
}
|
||||
|
||||
const rows = publicitySheet.buildPublicityRows(Array.from(bucket.values()));
|
||||
|
||||
// ---- 6) 套模板 ----
|
||||
const tplPath = path.join(__dirname, '../lib/assets/publicity-template.xlsx');
|
||||
const template = fs.readFileSync(tplPath);
|
||||
const built = publicitySheet.buildXlsx(template, rows, { itemName, itemCode });
|
||||
|
||||
const now = new Date();
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
const dateTag = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`;
|
||||
|
||||
return {
|
||||
buffer: built.buffer,
|
||||
fileName: `成绩公示表-${groupLabel}-${dateTag}.xlsx`,
|
||||
rowCount: rows.length,
|
||||
testCount,
|
||||
noTeamCount,
|
||||
totalContestants: contestants.length,
|
||||
title: built.title,
|
||||
// 赛项名/编号为空时前端要提示「标题仍是模板占位」
|
||||
itemName,
|
||||
itemCode,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 赛项信息配置(R176) ====================
|
||||
|
||||
/** 成绩公示表标题要用的两项配置。读:`competition_info` 的 key-value 行 */
|
||||
async getCompetitionItemInfo() {
|
||||
const { ctx } = this;
|
||||
const { Op } = ctx.app.Sequelize;
|
||||
const out = { itemName: '', itemCode: '' };
|
||||
const rows = await ctx.model.CompetitionInfo.findAll({
|
||||
attributes: ['info_key', 'info_value'],
|
||||
where: { info_key: { [Op.in]: ['competition_item_name', 'competition_item_code'] } },
|
||||
raw: true,
|
||||
});
|
||||
for (const r of rows) {
|
||||
const v = r.info_value === null || r.info_value === undefined ? '' : String(r.info_value);
|
||||
if (r.info_key === 'competition_item_name') out.itemName = v;
|
||||
if (r.info_key === 'competition_item_code') out.itemCode = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存赛项信息。
|
||||
* 🔴 用 upsert 语义而非「先删后插」:`competition_info` 的 id 是自增主键,
|
||||
* 删了重建会让 id 漂移,且若中途失败会留下半张表。
|
||||
* @param {{itemName?: string, itemCode?: string}} payload
|
||||
*/
|
||||
async setCompetitionItemInfo(payload = {}) {
|
||||
const { ctx } = this;
|
||||
const pairs = [
|
||||
['competition_item_name', payload.itemName],
|
||||
['competition_item_code', payload.itemCode],
|
||||
];
|
||||
const saved = {};
|
||||
for (const [key, raw] of pairs) {
|
||||
if (raw === undefined) continue; // 未提交的键不动
|
||||
const value = raw === null ? '' : String(raw).trim();
|
||||
const exist = await ctx.model.CompetitionInfo.findOne({ where: { info_key: key } });
|
||||
if (exist) {
|
||||
await exist.update({ info_value: value });
|
||||
} else {
|
||||
await ctx.model.CompetitionInfo.create({ info_key: key, info_value: value });
|
||||
}
|
||||
saved[key] = value;
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
// ==================== 批量下载 ZIP ====================
|
||||
/**
|
||||
* 批量下载某组所有提交文件,打包为 ZIP
|
||||
* 文件重命名为 "组号-学校-姓名-原文件名" 格式
|
||||
@@ -1685,7 +2163,7 @@ answeredCount,
|
||||
const isExam = group === 'zhongzhi' || group === 'gaozhi';
|
||||
const contestants = await ctx.model.Contestant.findAll({
|
||||
where: { group },
|
||||
attributes: ['id', 'name', 'id_number', 'group', 'score', 'exam_score', 'word_score', 'ppt_score'],
|
||||
attributes: ['id', 'name', 'id_number', 'group', 'score', 'exam_score', 'word_score', 'ppt_score', 'team_id'],
|
||||
order: [['id', 'ASC']],
|
||||
raw: true,
|
||||
});
|
||||
@@ -1714,6 +2192,17 @@ answeredCount,
|
||||
pptScore: c.ppt_score ?? 0,
|
||||
}));
|
||||
|
||||
// R178:小组编号(与「选手管理」同源口径,见模块级 resolveTeamLabels 注释)。
|
||||
// 前端按 teamId 渲染;teamId 为 null ≠ 「没有小组」的权威判据,仅表示「无队伍元信息」,
|
||||
// 因此另附 teamUid 便于排查「有归属但缺 teams 行」的历史数据。
|
||||
const teamLabels = await resolveTeamLabels(ctx, contestants);
|
||||
list.forEach(item => {
|
||||
const t = teamLabels.get(String(item.uid)) || null;
|
||||
item.teamUid = t ? t.teamUid : null;
|
||||
item.teamId = t ? t.teamId : null;
|
||||
item.teamNumber = t ? t.teamNumber : null;
|
||||
});
|
||||
|
||||
if (isExam) {
|
||||
const SubmissionModel = group === 'zhongzhi'
|
||||
? ctx.model.ZhongzhiSubmission
|
||||
@@ -1752,9 +2241,13 @@ answeredCount,
|
||||
item.score = autoScore + item.wordScore;
|
||||
const es = examMap[String(item.uid)];
|
||||
item.hasWordFile = !!(es && es.word_file_path);
|
||||
item.submitted = !!(es && es.submit_time) || answeredCount > 0;
|
||||
item.submitTime = (es && es.submit_time)
|
||||
|| (answeredCount > 0 ? (es && es.created_at) || null : null);
|
||||
// 🔴 R178(B④)与「考生答题记录」同一口径:submitted = 是否**正式交卷**
|
||||
//(唯一判据 exam_submissions.submit_time);作答进度看 answeredCount。
|
||||
// ⛔ 不再用 es.created_at(进考场建占位行的时间)冒充 submitTime。
|
||||
item.formalSubmitted = !!(es && es.submit_time);
|
||||
item.submitted = item.formalSubmitted;
|
||||
item.submitTime = (es && es.submit_time) || null;
|
||||
item.firstAnswerAt = answeredCount > 0 ? (es && es.created_at) || null : null;
|
||||
});
|
||||
} else {
|
||||
// 本科/教师组:上传作品即视为已提交(与考生端 exam.status.submitted、作品管理口径一致)。
|
||||
@@ -2014,34 +2507,10 @@ answeredCount,
|
||||
return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
|
||||
}
|
||||
|
||||
// ==================== 新闻查询 ====================
|
||||
|
||||
/**
|
||||
* 获取所有新闻列表(管理员视图,包含未发布)
|
||||
* @returns {Promise<Array>} 新闻列表
|
||||
*/
|
||||
async listNews() {
|
||||
const { ctx } = this;
|
||||
const news = await ctx.model.News.findAll({
|
||||
order: [['pinned', 'DESC'], ['sort_order', 'ASC'], ['publish_time', 'DESC']],
|
||||
raw: true,
|
||||
});
|
||||
|
||||
return news.map(n => ({
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
content: n.content,
|
||||
news_type: n.news_type,
|
||||
wx_link: n.wx_link,
|
||||
publish_time: n.publish_time,
|
||||
pinned: n.pinned,
|
||||
group: n.group,
|
||||
tags: n.tags,
|
||||
sort_order: n.sort_order,
|
||||
created_at: n.created_at,
|
||||
updated_at: n.updated_at,
|
||||
}));
|
||||
}
|
||||
// R177:删掉此处一个**死代码版** `listNews()` —— 它无分页、缺 is_draft/附件字段、
|
||||
// 排序按 sort_order,且在同一个 class 内被下方同名方法覆盖(JS class 后定义胜出)⇒ 永远不执行。
|
||||
// ⚠️ 新闻列表的唯一生效实现 = 本文件下方的 `async listNews(query)`(分页 + 含 is_draft/附件字段)。
|
||||
// 保留这行说明是为了防止后人「以为这里还有一份实现」而改到静默无效的位置。
|
||||
|
||||
// ==================== 轮播图查询 ====================
|
||||
|
||||
@@ -2169,9 +2638,99 @@ answeredCount,
|
||||
} catch (bkErr) {
|
||||
this.ctx.logger.error('[publishScores] 节点备份触发失败(不影响发布):%s', bkErr && bkErr.message);
|
||||
}
|
||||
|
||||
// ---- 成绩公示预览新闻(R176)----
|
||||
// 用户裁定:成绩发布后直接生成「包含附件的预览新闻」,等人工编辑补齐获奖等级后再发布。
|
||||
// 🔴 必须是**草稿**(is_draft=1):news.list / findById 都会过滤 is_draft=1,
|
||||
// 否则未定稿的成绩会立刻出现在前台新闻列表 ⇒ 等于提前公示错误成绩。
|
||||
// ⛔ 与节点备份同理:生成失败不得让「发布成绩」这个动作失败。
|
||||
try {
|
||||
const targets = group ? [ group ] : this.constructor.PUBLISH_GROUPS;
|
||||
await this._ensurePublicityDraft(targets);
|
||||
} catch (pubErr) {
|
||||
this.ctx.logger.error('[publishScores] 生成公示预览新闻失败(不影响发布):%s', pubErr && pubErr.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 为指定组别生成「成绩公示」草稿新闻(R176)。
|
||||
*
|
||||
* 幂等:同组已有草稿(tags = 'publicity-draft' 且 is_draft=1)时**覆盖**其附件与正文,
|
||||
* 不新增第二条 —— 反复发布/撤销再发布会堆出一串重复新闻。
|
||||
* 覆盖时旧附件文件要一并清理,否则 `app/public/news` 会随发布次数线性膨胀。
|
||||
*
|
||||
* @param {string[]} groups
|
||||
* @returns {Promise<Array<Object>>} 每组的结果摘要(仅供日志/排查)
|
||||
*/
|
||||
async _ensurePublicityDraft(groups) {
|
||||
const { ctx } = this;
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const GROUP_LABEL = { zhongzhi: '中职组', gaozhi: '高职组', benke: '本科组', jiaoshi: '教师组' };
|
||||
const DRAFT_TAG = 'publicity-draft';
|
||||
const results = [];
|
||||
|
||||
for (const g of groups) {
|
||||
try {
|
||||
const built = await this.buildPublicityXlsx(g);
|
||||
|
||||
const dir = path.join(this.app.baseDir, 'app/public/news');
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
const storedName = `news_file_publicity_${g}_${Date.now()}.xlsx`;
|
||||
fs.writeFileSync(path.join(dir, storedName), built.buffer);
|
||||
const fileUrl = `/public/news/${storedName}`;
|
||||
|
||||
const label = GROUP_LABEL[g] || g;
|
||||
const title = `${label}成绩公示(待审核)`;
|
||||
const content = [
|
||||
`<p>本公示表按成绩管理数据自动生成,<strong>尚未定稿</strong>,请核对数据并补齐「获奖等级」后再对外发布。</p>`,
|
||||
'<p>填表说明:一行代表一个参赛小组;「参赛选手」「指导教师」列多人时以顿号分隔;',
|
||||
'总分数按由高到低排序,「成绩排名」同序排列。</p>',
|
||||
`<p>本次导出共 ${built.rowCount} 个小组${built.testCount ? `(其中 ${built.testCount} 人属于测试数据,发布前请确认是否需要剔除)` : ''}。</p>`,
|
||||
].join('');
|
||||
|
||||
const exist = await ctx.model.News.findOne({ where: { group: g, is_draft: 1, tags: DRAFT_TAG } });
|
||||
if (exist) {
|
||||
const oldUrl = exist.attachment_url;
|
||||
await exist.update({
|
||||
title,
|
||||
content,
|
||||
news_type: 'content',
|
||||
attachment_url: fileUrl,
|
||||
attachment_name: built.fileName,
|
||||
});
|
||||
// 旧附件清理:只删本流程自己产出的文件(前缀 + 目录双重限定,绝不误删人工上传的图/文件)
|
||||
if (oldUrl && oldUrl !== fileUrl && oldUrl.indexOf('/public/news/news_file_publicity_') === 0) {
|
||||
ctx.helper.safeCleanup(path.join(this.app.baseDir, 'app', oldUrl.replace(/^\//, '')), {
|
||||
label: 'publicity-draft-old',
|
||||
});
|
||||
}
|
||||
results.push({ group: g, newsId: exist.id, action: 'updated', fileUrl });
|
||||
} else {
|
||||
const created = await this.createNews({
|
||||
title,
|
||||
content,
|
||||
news_type: 'content',
|
||||
group: g,
|
||||
tags: DRAFT_TAG,
|
||||
is_draft: 1,
|
||||
publish_time: new Date(),
|
||||
attachment_url: fileUrl,
|
||||
attachment_name: built.fileName,
|
||||
});
|
||||
results.push({ group: g, newsId: created.id, action: 'created', fileUrl });
|
||||
}
|
||||
} catch (e) {
|
||||
ctx.logger.error('[publicity] 组 %s 生成公示预览失败:%s', g, e.message);
|
||||
results.push({ group: g, action: 'error', message: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查成绩是否已发布
|
||||
* @param {string} [group] - 组别;缺省时要求「全部组均已发布」才返回 true
|
||||
@@ -2235,6 +2794,11 @@ answeredCount,
|
||||
pinned: newsData.pinned || false,
|
||||
group: newsData.group || newsData.group_name || 'all',
|
||||
tags: newsData.tags || null,
|
||||
// R176:草稿标记与附件。缺省 is_draft=0(已发布)保持既有调用方行为不变;
|
||||
// 成绩发布自动生成的预览新闻会显式传 1,人工编辑确认后再置 0。
|
||||
is_draft: newsData.is_draft ? 1 : 0,
|
||||
attachment_url: newsData.attachment_url || null,
|
||||
attachment_name: newsData.attachment_name || null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2257,6 +2821,10 @@ answeredCount,
|
||||
pinned: newsData.pinned !== undefined ? newsData.pinned : news.pinned,
|
||||
group: newsData.group !== undefined ? newsData.group : (newsData.group_name !== undefined ? newsData.group_name : news.group),
|
||||
tags: newsData.tags !== undefined ? newsData.tags : news.tags,
|
||||
// R176:附件与草稿。人工在新闻管理里「发布」= 把 is_draft 置 0。
|
||||
is_draft: newsData.is_draft !== undefined ? (newsData.is_draft ? 1 : 0) : news.is_draft,
|
||||
attachment_url: newsData.attachment_url !== undefined ? (newsData.attachment_url || null) : news.attachment_url,
|
||||
attachment_name: newsData.attachment_name !== undefined ? (newsData.attachment_name || null) : news.attachment_name,
|
||||
});
|
||||
|
||||
return news;
|
||||
|
||||
270
app/service/adminUser.js
Normal file
270
app/service/adminUser.js
Normal file
@@ -0,0 +1,270 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 管理员账号管理(R178 · C 层)
|
||||
* ============================================================================
|
||||
*
|
||||
* 职责:多管理员账号的增 / 改权限 / 重置口令 / 禁用启用 / 删除,以及高危操作审计。
|
||||
*
|
||||
* 三条不可协商的边界:
|
||||
*
|
||||
* 1. **最高管理员不可变动**(用户裁定):`is_super=1` 的账号不允许改权限、重置口令、
|
||||
* 禁用、删除。判断集中在 `lib/adminPermissions.isManageable()`,⛔ 不得在各方法里
|
||||
* 各写一遍 `id === 1` —— 那样改一处漏一处,且将来账号 id 变化就失效。
|
||||
*
|
||||
* 2. **口令策略单源**:一律走 `lib/passwordPolicy.checkPassword()`,
|
||||
* ⛔ 不在这里写第二套长度/黑名单规则(第 25 步门禁会比对两套规则是否漂移)。
|
||||
* 生产环境另外拒绝「已知默认口令」,与 adminLogin 的守卫同源。
|
||||
*
|
||||
* 3. **令牌失效靠自增**:改密 / 禁用时 `token_version += 1`。
|
||||
* middleware/auth.js 每请求比对,旧令牌立即 401。本项目没有会话表,
|
||||
* 这是唯一的「强制下线」手段。
|
||||
*/
|
||||
|
||||
const Service = require('egg').Service;
|
||||
const passwordPolicy = require('../lib/passwordPolicy');
|
||||
const adminPermissions = require('../lib/adminPermissions');
|
||||
const { recordAudit, ACTIONS } = require('../lib/auditLog');
|
||||
|
||||
/** 用户名规则:字母数字与 _ . - ,3~50 位。⛔ 不允许中文与空格,避免与登录校验规则不一致。 */
|
||||
const USERNAME_RE = /^[A-Za-z0-9_.-]{3,50}$/;
|
||||
const REMARK_MAX = 200;
|
||||
|
||||
/** 业务错误:由 controller 翻译成 { code, message },不直接改 HTTP 状态。 */
|
||||
function bizError(code, message) {
|
||||
const err = new Error(message);
|
||||
err.bizCode = code;
|
||||
err.bizMessage = message;
|
||||
return err;
|
||||
}
|
||||
|
||||
class AdminUserService extends Service {
|
||||
/**
|
||||
* 管理员列表(脱敏:绝不返回 password)
|
||||
* 排序:最高管理员置顶,其余按 id 升序 —— 与「主账号永远第一行」的视觉预期一致。
|
||||
*/
|
||||
async list() {
|
||||
const { ctx } = this;
|
||||
const rows = await ctx.model.Admin.findAll({
|
||||
attributes: [
|
||||
'id', 'username', 'is_super', 'permissions', 'status',
|
||||
'last_login_at', 'last_login_ip', 'remark', 'created_at', 'updated_at',
|
||||
],
|
||||
order: [['is_super', 'DESC'], ['id', 'ASC']],
|
||||
raw: true,
|
||||
});
|
||||
|
||||
return rows.map(r => ({
|
||||
id: r.id,
|
||||
username: r.username,
|
||||
isSuper: Number(r.is_super) === 1,
|
||||
permissions: adminPermissions.parsePermissions(r.permissions),
|
||||
status: Number(r.status) === 1 ? 1 : 0,
|
||||
lastLoginAt: r.last_login_at || null,
|
||||
lastLoginIp: r.last_login_ip || null,
|
||||
remark: r.remark || '',
|
||||
createdAt: r.created_at || null,
|
||||
// 前端据此把「改权限/重置口令/禁用/删除」四个按钮整体置灰
|
||||
manageable: adminPermissions.isManageable(r),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 取一个**可被管理**的目标账号。
|
||||
* @throws bizCode 400 不存在 / 403 最高管理员不可变动
|
||||
*/
|
||||
async _loadManageable(id) {
|
||||
const { ctx } = this;
|
||||
const numericId = Number(id);
|
||||
if (!Number.isInteger(numericId) || numericId <= 0) {
|
||||
throw bizError(400, '管理员 ID 不合法');
|
||||
}
|
||||
const admin = await ctx.model.Admin.findOne({
|
||||
where: { id: numericId },
|
||||
attributes: ['id', 'username', 'is_super', 'permissions', 'status', 'token_version'],
|
||||
raw: true,
|
||||
});
|
||||
if (!admin) throw bizError(400, '该管理员账号不存在');
|
||||
if (!adminPermissions.isManageable(admin)) {
|
||||
throw bizError(403, '最高管理员账号不可修改(权限 / 口令 / 状态 / 删除均被禁止)');
|
||||
}
|
||||
return admin;
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增管理员
|
||||
*
|
||||
* @param {{username:string, password:string, permissions:string[], remark?:string}} data
|
||||
*/
|
||||
async create(data) {
|
||||
const { ctx } = this;
|
||||
const username = String((data && data.username) || '').trim();
|
||||
const password = String((data && data.password) || '');
|
||||
|
||||
if (!USERNAME_RE.test(username)) {
|
||||
throw bizError(400, '用户名需为 3-50 位字母、数字、下划线、点或短横线');
|
||||
}
|
||||
|
||||
const pwdCheck = passwordPolicy.checkPassword(password, { username });
|
||||
if (!pwdCheck.ok) throw bizError(400, pwdCheck.reason);
|
||||
|
||||
// 与 adminLogin 的守卫同源:生产环境不允许把「已知默认口令」分配给新账号
|
||||
if (this.app.config.isProduction && passwordPolicy.DEFAULT_ADMIN_PASSWORDS.includes(password)) {
|
||||
throw bizError(400, '生产环境禁止使用默认口令,请设置强密码');
|
||||
}
|
||||
|
||||
const exists = await ctx.model.Admin.findOne({ where: { username }, attributes: ['id'] });
|
||||
if (exists) throw bizError(400, '该用户名已存在');
|
||||
|
||||
const perms = adminPermissions.sanitizePermissions(data && data.permissions);
|
||||
const remark = data && data.remark ? String(data.remark).slice(0, REMARK_MAX) : null;
|
||||
|
||||
let created;
|
||||
try {
|
||||
created = await ctx.model.Admin.create({
|
||||
username,
|
||||
password: await ctx.helper.hashPassword(password),
|
||||
is_super: 0, // 新增账号永远不是最高管理员(最高管理员只能由初始化脚本产生)
|
||||
permissions: JSON.stringify(perms),
|
||||
status: 1,
|
||||
token_version: 0,
|
||||
remark,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
});
|
||||
} catch (e) {
|
||||
// 并发下 findOne 与 create 之间仍可能撞唯一索引
|
||||
if (/ER_DUP_ENTRY/.test(String(e && e.message))) {
|
||||
throw bizError(400, '该用户名已存在');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
await recordAudit(ctx, ACTIONS.ADMIN_CREATE, `admin:${username}`, {
|
||||
id: created.id,
|
||||
permissions: perms,
|
||||
});
|
||||
|
||||
return { id: created.id, username, permissions: perms, remark: remark || '' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑权限(含备注)
|
||||
*
|
||||
* ⚠️ 不递增 token_version:权限是每请求查库判定,改完立即生效;
|
||||
* 递增反而会把正在操作的人无谓踢下线。
|
||||
*/
|
||||
async updatePermissions(id, data) {
|
||||
const { ctx } = this;
|
||||
const target = await this._loadManageable(id);
|
||||
|
||||
const perms = adminPermissions.sanitizePermissions(data && data.permissions);
|
||||
const patch = {
|
||||
permissions: JSON.stringify(perms),
|
||||
updated_at: new Date(),
|
||||
};
|
||||
if (data && data.remark !== undefined) {
|
||||
patch.remark = data.remark ? String(data.remark).slice(0, REMARK_MAX) : null;
|
||||
}
|
||||
|
||||
await ctx.model.Admin.update(patch, { where: { id: target.id } });
|
||||
|
||||
const before = adminPermissions.parsePermissions(target.permissions);
|
||||
await recordAudit(ctx, ACTIONS.ADMIN_PERMISSIONS, `admin:${target.username}`, {
|
||||
before,
|
||||
after: perms,
|
||||
added: perms.filter(p => !before.includes(p)),
|
||||
removed: before.filter(p => !perms.includes(p)),
|
||||
});
|
||||
|
||||
return { id: target.id, permissions: perms };
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置口令(管理员代改,不要求旧口令)
|
||||
* 递增 token_version ⇒ 目标账号所有在线会话立即失效。
|
||||
*/
|
||||
async resetPassword(id, data) {
|
||||
const { ctx } = this;
|
||||
const target = await this._loadManageable(id);
|
||||
|
||||
const password = String((data && data.password) || '');
|
||||
const pwdCheck = passwordPolicy.checkPassword(password, { username: target.username });
|
||||
if (!pwdCheck.ok) throw bizError(400, pwdCheck.reason);
|
||||
|
||||
if (this.app.config.isProduction && passwordPolicy.DEFAULT_ADMIN_PASSWORDS.includes(password)) {
|
||||
throw bizError(400, '生产环境禁止使用默认口令,请设置强密码');
|
||||
}
|
||||
|
||||
const nextVersion = Number(target.token_version || 0) + 1;
|
||||
await ctx.model.Admin.update({
|
||||
password: await ctx.helper.hashPassword(password),
|
||||
token_version: nextVersion,
|
||||
updated_at: new Date(),
|
||||
}, { where: { id: target.id } });
|
||||
|
||||
// ⛔ 审计 detail 不含口令本身,只记事实
|
||||
await recordAudit(ctx, ACTIONS.ADMIN_PASSWORD, `admin:${target.username}`, {
|
||||
tokenVersion: nextVersion,
|
||||
});
|
||||
|
||||
return { id: target.id, tokenVersion: nextVersion };
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁用 / 启用
|
||||
* 禁用时递增 token_version ⇒ 该账号已签发的令牌下次请求即 401。
|
||||
* 启用不递增:重新启用后旧令牌早已因禁用期间的版本差异失效,无需再踢。
|
||||
*/
|
||||
async setStatus(id, data) {
|
||||
const { ctx } = this;
|
||||
const target = await this._loadManageable(id);
|
||||
|
||||
const status = Number(data && data.status);
|
||||
if (status !== 0 && status !== 1) throw bizError(400, '状态值只能是 0(禁用)或 1(启用)');
|
||||
|
||||
// 不允许把自己禁用 —— 否则可能一次性把所有可用账号锁死
|
||||
const me = ctx.state.currentAdmin;
|
||||
if (me && Number(me.id) === Number(target.id) && status === 0) {
|
||||
throw bizError(400, '不能禁用自己的账号');
|
||||
}
|
||||
|
||||
const patch = { status, updated_at: new Date() };
|
||||
if (status === 0) patch.token_version = Number(target.token_version || 0) + 1;
|
||||
|
||||
await ctx.model.Admin.update(patch, { where: { id: target.id } });
|
||||
|
||||
await recordAudit(ctx, ACTIONS.ADMIN_STATUS, `admin:${target.username}`, {
|
||||
status,
|
||||
tokenVersion: patch.token_version || target.token_version,
|
||||
});
|
||||
|
||||
return { id: target.id, status };
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除账号
|
||||
* 不可删最高管理员(_loadManageable 已挡)、不可删自己。
|
||||
*/
|
||||
async remove(id) {
|
||||
const { ctx } = this;
|
||||
const target = await this._loadManageable(id);
|
||||
|
||||
const me = ctx.state.currentAdmin;
|
||||
if (me && Number(me.id) === Number(target.id)) {
|
||||
throw bizError(400, '不能删除自己的账号');
|
||||
}
|
||||
|
||||
const affected = await ctx.model.Admin.destroy({ where: { id: target.id } });
|
||||
if (affected !== 1) throw bizError(500, '删除失败:目标账号已不存在');
|
||||
|
||||
await recordAudit(ctx, ACTIONS.ADMIN_DELETE, `admin:${target.username}`, {
|
||||
id: target.id,
|
||||
lastPermissions: adminPermissions.parsePermissions(target.permissions),
|
||||
});
|
||||
|
||||
return { id: target.id, deleted: true };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = AdminUserService;
|
||||
@@ -402,6 +402,53 @@ class ExamService extends Service {
|
||||
return timeConfig ? timeConfig.word_template : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存答题记录(逐题存储)
|
||||
* @param {number} aggregationUid - 聚合报名 ID
|
||||
* @param {Array} answers - 答案数组 [{ question_uid, answer_data, is_correct, score }]
|
||||
* @param {string} group - 组别
|
||||
* @returns {Promise<Array>} 保存后的记录列表
|
||||
*/
|
||||
/**
|
||||
* 交卷冻结守卫(R178·B①)——**唯一判据源**
|
||||
*
|
||||
* 缺陷背景:逐题保存此前只校验「考试窗口 isActive」,不校验是否已正式交卷;
|
||||
* `submit-all` 的 4006 只挡整卷重交。于是「提前交卷 → 窗口关闭前」这段时间内,
|
||||
* `POST /submit-answers` 仍可改写答案并重新判分 —— 即「已提交但分数还能变」。
|
||||
* 典型触发并非恶意:考生开了两个标签页(或手机+电脑),交卷后另一页仍在自动保存。
|
||||
*
|
||||
* 判据:`exam_submissions.submit_time` 非空即视为已正式交卷(与选手端 hasSubmitted 同源)。
|
||||
*
|
||||
* 调用点(两处,共用本函数,⛔ 不得各写一套条件):
|
||||
* ① controller.exam.submitAnswers —— 前置快速失败,避免对已冻结答卷做无谓判分;
|
||||
* ② service.exam.saveAnswers —— 兜底,保证任何调用方(含未来新增)都无法绕过。
|
||||
* ⚠️ 门禁 `verify_exam_freeze.py` 的变异体要求**两处同改**才允许变红,否则属前道抢答。
|
||||
*
|
||||
* 为什么不缓存:逐题保存约 59 次/场,主键单列查询为微秒级;
|
||||
* 换来「交卷状态变更立即生效」,无需判断缓存失效时机。
|
||||
*
|
||||
* @param {number} aggregationUid - 聚合报名 ID(中职/高职即 contestant.id)
|
||||
* @returns {Promise<void>} 未交卷则静默返回
|
||||
* @throws {Error} bizCode=4006
|
||||
*/
|
||||
async assertNotSubmitted(aggregationUid) {
|
||||
const { ctx } = this;
|
||||
if (!aggregationUid) return;
|
||||
|
||||
const submitted = await ctx.model.ExamSubmission.findOne({
|
||||
where: { contestant_id: aggregationUid },
|
||||
attributes: ['contestant_id', 'submit_time'],
|
||||
raw: true,
|
||||
});
|
||||
if (submitted && submitted.submit_time) {
|
||||
const err = new Error('答卷已提交,不能再修改答案');
|
||||
// bizCode 由 controller 翻译成用户可见的业务错误码(4006 与本项目既有「已提交」口径一致)
|
||||
err.bizCode = 4006;
|
||||
err.bizMessage = '您已交卷,答卷已冻结,不能再修改答案;如需重考请联系管理员';
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存答题记录(逐题存储)
|
||||
* @param {number} aggregationUid - 聚合报名 ID
|
||||
@@ -412,6 +459,12 @@ class ExamService extends Service {
|
||||
async saveAnswers(aggregationUid, answers, group) {
|
||||
const { ctx } = this;
|
||||
|
||||
// 交卷冻结兜底:所有写答案的路径都经此函数,故此处是本守卫的最后一道闸。
|
||||
// 不会挡住交卷自身 —— submitAll 是「先 saveAnswers、后 ensureExamSubmission 写
|
||||
// submit_time」,执行到本函数时 submit_time 仍为 NULL;整卷重交在更早的 4006 已返回;
|
||||
// settleOnly 结算通道刻意不调用本函数。
|
||||
await this.assertNotSubmitted(aggregationUid);
|
||||
|
||||
// 根据组别选择对应的提交模型
|
||||
const SubmissionModel = group === 'zhongzhi'
|
||||
? ctx.model.ZhongzhiSubmission
|
||||
|
||||
@@ -37,7 +37,11 @@ class NewsService extends Service {
|
||||
const offset = (currentPage - 1) * currentPageSize;
|
||||
|
||||
// 构建查询条件
|
||||
const where = {};
|
||||
const where = {
|
||||
// 🔴 R176:草稿只允许后台可见。成绩发布时会自动生成一条「成绩公示」预览新闻,
|
||||
// 人工编辑确认前绝不能出现在前台列表(否则等于提前公示未定稿成绩)。
|
||||
is_draft: 0,
|
||||
};
|
||||
|
||||
// 组别过滤:同时包含指定组别和通用(all)新闻
|
||||
if (group) {
|
||||
@@ -90,7 +94,8 @@ class NewsService extends Service {
|
||||
|
||||
// 并行查询各分组计数
|
||||
await Promise.all(groups.map(async group => {
|
||||
const where = {};
|
||||
// 🔴 R176:计数口径必须与 list 一致,否则首页 Tab 显示「3 条」点进去只有 2 条
|
||||
const where = { is_draft: 0 };
|
||||
// 对于具体组别,同时包含该组别和 all 通用新闻
|
||||
if (group !== 'all') {
|
||||
where.group = { [Sequelize.Op.in]: [group, 'all'] };
|
||||
@@ -110,9 +115,10 @@ class NewsService extends Service {
|
||||
async findById(id) {
|
||||
const { ctx } = this;
|
||||
|
||||
// 🔴 R176:草稿不可通过 id 直接访问(前台详情页按 id 拉取 ⇒ 只过滤列表等于没防住)
|
||||
return await ctx.model.News.findOne({
|
||||
where: { id },
|
||||
attributes: ['id', 'title', 'content', 'publish_time', 'pinned', 'group', 'tags', 'sort_order', 'created_at', 'updated_at'],
|
||||
where: { id, is_draft: 0 },
|
||||
attributes: ['id', 'title', 'content', 'publish_time', 'pinned', 'group', 'tags', 'sort_order', 'attachment_url', 'attachment_name', 'created_at', 'updated_at'],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user