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:
2026-10-20 11:06:29 +08:00
parent 5392345078
commit 459f1adf6d
21 changed files with 2886 additions and 76 deletions

View File

@@ -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
View 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;

View File

@@ -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

View File

@@ -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'],
});
}