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

@@ -10,6 +10,8 @@ const { sanitizeUploadFileName } = require('../utils/filename');
const { acquire: acquireFileIo, release: releaseFileIo } = require('../lib/file_io_guard');
// 🔴 同源 collapse(缺口9 修复):所有文件发送通道统一走 sendFile util
const { sendFile } = require('../utils/sendFile');
// R178 高危操作审计(best-effort,不阻断业务)
const { recordAudit, ACTIONS: AUDIT_ACTIONS } = require('../lib/auditLog');
// 🔴 R106-D7(2026-10-12):测试数据默认口令改走唯一入口 —— 生产未显式注入
// TEST_DEFAULT_PASSWORD 时**明确失败**,⛔ 不再静默批量设置内置弱口令 '123456'。
const { requireTestPassword } = require('../lib/passwordPolicy');
@@ -231,6 +233,172 @@ class AdminController extends Controller {
}
}
/**
* 指导教师回填(R176)
* POST /api/admin/contestants/backfill-teacher
*
* multipart:file(报名表 xlsx)+ group(可选,限定组别)+ overwrite(可选,默认 1)
*
* 🔴 内存解析,**不落盘**:报名表含身份证号,而 `app/public` 对静态服务开放
* (题目导入曾落 `app/public/tmp/excel`),一旦落盘即可被猜名下载。
* 🔴 只写 contestants.teacher_name,不新建账号、不改队伍、不碰成绩。
*/
async backfillTeachers() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
const MAX_BYTES = 5 * 1024 * 1024;
try {
const stream = await ctx.getFileStream();
if (!stream || !stream.filename) {
ctx.body = ctx.helper.error(400, '请选择报名表文件');
return;
}
const ext = path.extname(stream.filename).toLowerCase();
if (!['.xlsx', '.xls'].includes(ext)) {
ctx.body = ctx.helper.error(400, '仅支持 .xlsx / .xls 格式的报名表');
return;
}
const chunks = [];
let size = 0;
for await (const chunk of stream) {
size += chunk.length;
if (size > MAX_BYTES) {
ctx.body = ctx.helper.error(400, '文件超过 5MB,请确认上传的是报名表而不是全量明细');
return;
}
chunks.push(chunk);
}
if (size === 0) {
ctx.body = ctx.helper.error(400, '文件内容为空');
return;
}
const fields = stream.fields || {};
const group = fields.group ? String(fields.group) : '';
// 只有显式传 '0'/'false' 才关闭覆盖;缺省 = 覆盖("重导一次"的语义就是取最新)
const overwrite = !(fields.overwrite === '0' || fields.overwrite === 'false');
const result = await service.admin.backfillTeachers(Buffer.concat(chunks), { group, overwrite });
if (!result.ok) {
// 业务性失败(文件不是报名表 / 缺列 / 找不到教师)⇒ code=400 + 纯人话原因。
// ⚠️ helper.error 只接受 (code, message),要带明细必须显式附加(否则第三参数被静默丢弃)
ctx.body = Object.assign(ctx.helper.error(400, result.message), { data: result });
return;
}
ctx.body = ctx.helper.success(result, result.message);
} catch (err) {
// 同上:multipart 白名单拦截时 err.status=400,不要包成 500
if (err && err.status === 400) {
ctx.body = ctx.helper.error(400, '文件格式不被允许:报名表请上传 .xlsx / .xls');
return;
}
ctx.logger.error('指导教师回填失败:', err);
ctx.body = ctx.helper.error(500, (err && err.message) ? err.message : '指导教师回填失败');
}
}
/**
* 成绩公示表导出(R176)
* GET /api/admin/export-publicity/:group
*
* 产物 = 大赛参考模板的 xlsx 半成品:数据行已填,获奖等级留空待人工填。
* ⛔ 与 exportExcel 用途不同:那个是内部核查表(含身份证号/手机号),
* 这个不含任何身份证号 / 手机号,可以直接对外公示。
*/
async exportPublicity() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const { group } = ctx.params;
if (!this._validateGroup(group, true)) return;
// `?meta=1` 只回统计、不下载:前端用它在点「下载」前提示
// 「本组 N 人无小组归属」「标题赛项信息为空」这类需要管理员知情的事实。
// ⚠️ 不能靠自定义响应头带这些字段 —— 生产经 X-Accel 时上游自定义头会被丢弃。
const built = await service.admin.buildPublicityXlsx(group);
if (String(ctx.query.meta || '') === '1') {
ctx.body = ctx.helper.success({
fileName: built.fileName,
rowCount: built.rowCount,
testCount: built.testCount,
noTeamCount: built.noTeamCount,
totalContestants: built.totalContestants,
itemName: built.itemName,
itemCode: built.itemCode,
});
return;
}
ctx.set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
// 中文文件名:content-disposition 会输出 filename*=UTF-8''… 形式,
// nginx 的 X-Accel 只丢弃自定义头、不丢弃 Content-Disposition(实测)
ctx.attachment(built.fileName);
ctx.body = built.buffer;
} catch (err) {
ctx.logger.error('导出成绩公示表失败:', err);
ctx.body = ctx.helper.error(500, (err && err.message) ? err.message : '导出成绩公示表失败');
}
}
// ==================== 赛项信息配置(R176) ====================
/**
* 读取赛项信息
* GET /api/admin/competition-item
*
* 成绩公示表标题的第 2、3 行要用(赛项名称 / 赛项编号)。
* 用户裁定:不做硬编码,改为两项配置由管理人员在后台填写。
*/
async competitionItemInfo() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const data = await service.admin.getCompetitionItemInfo();
ctx.body = ctx.helper.success(data);
} catch (err) {
ctx.logger.error('读取赛项信息失败:', err);
ctx.body = ctx.helper.error(500, '读取赛项信息失败');
}
}
/**
* 保存赛项信息
* PUT /api/admin/competition-item
* body: { itemName?, itemCode? }
*
* ⚠️ 破坏面:覆盖 competition_info 里两个键的值。只会影响公示表标题文案,
* 不影响成绩、账号、队伍;改错重填即可。
*/
async updateCompetitionItemInfo() {
const { ctx, service } = this;
if (!this._ensureAdmin()) return;
try {
const body = ctx.request.body || {};
// 长度兜底:两个值都会写进 xlsx 标题单元格,过长会把标题撑爆
for (const [k, limit] of [['itemName', 120], ['itemCode', 60]]) {
const v = body[k];
if (v !== undefined && v !== null && String(v).length > limit) {
ctx.body = ctx.helper.error(400, `${k === 'itemName' ? '赛项名称' : '赛项编号'}不能超过 ${limit} 字`);
return;
}
}
const saved = await service.admin.setCompetitionItemInfo({
itemName: body.itemName,
itemCode: body.itemCode,
});
ctx.body = ctx.helper.success(saved, '赛项信息已保存');
} catch (err) {
ctx.logger.error('保存赛项信息失败:', err);
ctx.body = ctx.helper.error(500, '保存赛项信息失败');
}
}
// ==================== 提交记录管理 ====================
/**
@@ -859,6 +1027,15 @@ class AdminController extends Controller {
await service.admin.publishScores(publish, group);
// R178 审计:发布/撤销成绩会直接改变「考生能不能看到分数」,
// 且发布还会自动生成公示草稿 —— 属于必须能追溯到人的动作。
await recordAudit(
ctx,
publish ? AUDIT_ACTIONS.SCORE_PUBLISH : AUDIT_ACTIONS.SCORE_UNPUBLISH,
group || 'all',
{ group: group || null, scope: group ? 'single' : 'all' }
);
const scopeLabel = group ? '该组成绩' : '全部组成绩';
ctx.body = ctx.helper.success(
{ published: publish, group: group || null },
@@ -1210,6 +1387,93 @@ class AdminController extends Controller {
}
}
/**
* 上传新闻附件(R176)
* POST /api/admin/upload/news-file
*
* 用户裁定:成绩公示改为「新闻附件」形态,所以新闻要支持文件下载,
* 而不是把下载链接手写进正文(正文一旦人工编辑,链接极易被删掉)。
*
* 与 uploadNewsImage 复用同一目录(`app/public/news`)与同一访问路径前缀,
* 区别只在扩展名白名单与大小上限。
*
* @returns {{fileUrl: string, fileName: string, size: number, filename: string}}
*/
async uploadNewsFile() {
const { ctx } = this;
if (!this._ensureAdmin()) return;
const ALLOWED_EXT = ['.xlsx', '.xls', '.pdf', '.docx', '.doc', '.zip'];
const MAX_BYTES = 20 * 1024 * 1024;
try {
const stream = await ctx.getFileStream();
if (!stream || !stream.filename) {
ctx.body = ctx.helper.error(400, '请选择要上传的附件');
return;
}
const ext = path.extname(stream.filename).toLowerCase();
if (ALLOWED_EXT.indexOf(ext) < 0) {
ctx.body = ctx.helper.error(400, `仅支持 ${ALLOWED_EXT.join(' / ')} 格式的附件`);
return;
}
const chunks = [];
let size = 0;
for await (const chunk of stream) {
size += chunk.length;
if (size > MAX_BYTES) {
ctx.body = ctx.helper.error(400, '附件超过 20MB,请压缩后再上传');
return;
}
chunks.push(chunk);
}
if (size === 0) {
ctx.body = ctx.helper.error(400, '附件内容为空');
return;
}
const fs = require('fs');
const uploadDir = path.join(this.app.baseDir, 'app/public/news');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
// 存盘文件名与展示名分离:存盘用时间戳(避免覆盖与路径穿越),
// 展示名保留原始中文名,写进 news.attachment_name 供下载按钮显示。
const storedName = `news_file_${Date.now()}${ext}`;
fs.writeFileSync(path.join(uploadDir, storedName), Buffer.concat(chunks));
// 🔴 multipart 的 filename 常按 latin1 解出(中文变乱码)。
// 启发式:按 latin1 取回字节后若能完整 UTF-8 解码且含非 ASCII,就用解码结果。
let displayName = String(stream.filename);
const asUtf8 = Buffer.from(displayName, 'latin1').toString('utf8');
if (asUtf8.indexOf('\uFFFD') < 0 && /[^\x00-\x7F]/.test(asUtf8)) {
displayName = asUtf8;
}
// 去掉可能带进来的路径部分(Windows 上传会带 C:\fakepath\)
displayName = displayName.replace(/^.*[\\/]/, '').slice(0, 200);
ctx.body = ctx.helper.success({
fileUrl: `/public/news/${storedName}`,
fileName: displayName,
size,
filename: storedName,
}, '附件上传成功');
} catch (err) {
// 🔴 multipart 层(egg-multipart 的 whitelist)会先于本方法抛 400,
// 统一转成人话;否则前端看到的是一句「上传新闻附件失败」+ 500,
// 管理员无从知道是「格式不支持」。
if (err && err.status === 400) {
ctx.body = ctx.helper.error(400, `文件格式不被允许,仅支持 ${ALLOWED_EXT.join(' / ')}`);
return;
}
ctx.logger.error('上传新闻附件失败:', err);
ctx.body = ctx.helper.error(500, '上传新闻附件失败');
}
}
/**
* 下载考生答案 Word 文件
* GET /api/admin/submissions/:group/:id/word
@@ -1849,6 +2113,16 @@ class AdminController extends Controller {
results.jiaoshiSubmissionsDeleted = jiaoshiDeleted;
ctx.logger.info('[测试数据重置]', results);
// R178 审计:这是本系统唯一的「真实批量删除」入口(并会连带复位发布态),
// 必须记下是谁、删了多少行 —— 事后追查「数据怎么没了」只能靠这一条。
await recordAudit(ctx, AUDIT_ACTIONS.DATA_RESET_TEST, 'test-data', {
contestants: contestantCount,
examSubmissionsDeleted: examDeleted,
workSubmissionsDeleted: workDeleted,
orphanFilesRemoved,
});
ctx.body = ctx.helper.success(results, `测试数据已重置:${contestantCount} 名选手,${examDeleted + workDeleted} 条提交记录。已恢复为「未激活 + 默认口令 ${testPw}」,需先用默认口令完成短信激活即可登录重测`);
} catch (err) {
ctx.logger.error('重置测试数据失败:', err);