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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user