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);
|
||||
|
||||
118
app/controller/adminUser.js
Normal file
118
app/controller/adminUser.js
Normal file
@@ -0,0 +1,118 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 管理员账号管理控制器(R178 · C 层)
|
||||
*
|
||||
* 统一错误约定(与项目其它控制器一致):
|
||||
* - 业务错误 = HTTP 200 + `code !== 0`(helper.error 不改 HTTP 状态)
|
||||
* - service 通过 `bizCode` 表达期望错误码,这里原样翻译
|
||||
* ⛔ 不要把业务错误包成 500(前端会当成系统故障,用户看到「服务器错误」)
|
||||
*/
|
||||
|
||||
const Controller = require('egg').Controller;
|
||||
const adminPermissions = require('../lib/adminPermissions');
|
||||
|
||||
class AdminUserController extends Controller {
|
||||
/** 把 service 抛出的 bizCode 翻译成业务响应;非业务异常交给上层 catch */
|
||||
_fail(err, fallbackMessage) {
|
||||
const { ctx } = this;
|
||||
if (err && err.bizCode) {
|
||||
ctx.body = ctx.helper.error(err.bizCode, err.bizMessage || err.message);
|
||||
return;
|
||||
}
|
||||
ctx.logger.error(`${fallbackMessage}:`, err);
|
||||
ctx.body = ctx.helper.error(500, fallbackMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前登录管理员的权限信息(自助,无需业务权限)
|
||||
* GET /api/admin/me
|
||||
*
|
||||
* 前端用途:① 渲染侧边菜单;② 权限被他人改动后,下次进页面能感知并提示。
|
||||
* 顺带返回权限点目录(catalog),避免前端硬编码一份权限清单(那是第二真相源)。
|
||||
*/
|
||||
async me() {
|
||||
const { ctx } = this;
|
||||
const admin = ctx.state.currentAdmin;
|
||||
if (!admin) {
|
||||
ctx.body = ctx.helper.error(401, '未登录或登录状态已失效');
|
||||
return;
|
||||
}
|
||||
ctx.body = ctx.helper.success({
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
isSuper: Number(admin.is_super) === 1,
|
||||
permissions: admin.permissions || [],
|
||||
catalog: adminPermissions.PERMISSIONS,
|
||||
}, '获取当前管理员信息成功');
|
||||
}
|
||||
|
||||
/** GET /api/admin/admin-users */
|
||||
async list() {
|
||||
const { ctx, service } = this;
|
||||
try {
|
||||
const list = await service.adminUser.list();
|
||||
ctx.body = ctx.helper.success({ list, total: list.length }, '获取管理员列表成功');
|
||||
} catch (err) {
|
||||
this._fail(err, '获取管理员列表失败');
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /api/admin/admin-users */
|
||||
async create() {
|
||||
const { ctx, service } = this;
|
||||
try {
|
||||
const result = await service.adminUser.create(ctx.request.body || {});
|
||||
ctx.body = ctx.helper.success(result, '管理员账号已创建');
|
||||
} catch (err) {
|
||||
this._fail(err, '创建管理员失败');
|
||||
}
|
||||
}
|
||||
|
||||
/** PUT /api/admin/admin-users/:id/permissions */
|
||||
async updatePermissions() {
|
||||
const { ctx, service } = this;
|
||||
try {
|
||||
const result = await service.adminUser.updatePermissions(ctx.params.id, ctx.request.body || {});
|
||||
ctx.body = ctx.helper.success(result, '权限已更新');
|
||||
} catch (err) {
|
||||
this._fail(err, '更新权限失败');
|
||||
}
|
||||
}
|
||||
|
||||
/** PUT /api/admin/admin-users/:id/password */
|
||||
async resetPassword() {
|
||||
const { ctx, service } = this;
|
||||
try {
|
||||
const result = await service.adminUser.resetPassword(ctx.params.id, ctx.request.body || {});
|
||||
ctx.body = ctx.helper.success(result, '口令已重置,该账号需重新登录');
|
||||
} catch (err) {
|
||||
this._fail(err, '重置口令失败');
|
||||
}
|
||||
}
|
||||
|
||||
/** PUT /api/admin/admin-users/:id/status */
|
||||
async setStatus() {
|
||||
const { ctx, service } = this;
|
||||
try {
|
||||
const result = await service.adminUser.setStatus(ctx.params.id, ctx.request.body || {});
|
||||
const msg = Number(result.status) === 1 ? '账号已启用' : '账号已禁用,其在线会话已失效';
|
||||
ctx.body = ctx.helper.success(result, msg);
|
||||
} catch (err) {
|
||||
this._fail(err, '修改账号状态失败');
|
||||
}
|
||||
}
|
||||
|
||||
/** DELETE /api/admin/admin-users/:id */
|
||||
async remove() {
|
||||
const { ctx, service } = this;
|
||||
try {
|
||||
const result = await service.adminUser.remove(ctx.params.id);
|
||||
ctx.body = ctx.helper.success(result, '管理员账号已删除');
|
||||
} catch (err) {
|
||||
this._fail(err, '删除管理员失败');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = AdminUserController;
|
||||
@@ -6,6 +6,8 @@ const VALIDATION_RULES = require('../validate');
|
||||
// 口令强度策略(唯一事实来源):黑名单 + 纯重复/序列 + 账号派生值 + 长度上下限。
|
||||
// ⚠️ 任何新增的口令写入入口都必须调用它,禁止在控制器内自行判断(见 lib/passwordPolicy.js 模块头)。
|
||||
const passwordPolicy = require('../lib/passwordPolicy');
|
||||
// R178:管理端权限点解析(与 middleware/auth.js 同一真相源)
|
||||
const adminPermissions = require('../lib/adminPermissions');
|
||||
|
||||
/**
|
||||
* 认证控制器 - AuthController
|
||||
@@ -372,6 +374,15 @@ class AuthController extends Controller {
|
||||
return;
|
||||
}
|
||||
|
||||
// 🔴 R178 禁用检查:必须在口令校验**之后**。
|
||||
// 若放在校验之前,「账号已禁用」与「密码错误」的差别会让未授权者探测出
|
||||
// 某个用户名真实存在且已被停用。校验通过后才告知状态,不额外泄露信息。
|
||||
if (Number(admin.status) !== 1) {
|
||||
ctx.logger.warn(`[安全] 已禁用的管理员 ${admin.username} 尝试登录,已拒绝`);
|
||||
ctx.body = ctx.helper.error(4006, '该管理员账号已被禁用,请联系最高管理员');
|
||||
return;
|
||||
}
|
||||
|
||||
// P2-3 默认口令守卫:内置默认管理员(见 database/schema.sql 与 database/seeders/002-seed-admin.js),
|
||||
// 生产环境禁止使用默认口令登录,防止默认账号直接上线被接管;
|
||||
// 非生产环境放行但记录告警,并在响应中提示尽快改密。
|
||||
@@ -410,14 +421,33 @@ class AuthController extends Controller {
|
||||
const token = ctx.helper.generateToken({
|
||||
id: admin.id,
|
||||
role: 'admin',
|
||||
// 🔴 R178 令牌版本:middleware/auth.js 每次请求都与库中 token_version 比对,
|
||||
// 不一致即 401。改密 / 禁用 / 改权限时服务层自增本值 ⇒ 旧令牌立刻作废。
|
||||
// 存量令牌里没有 tv,读取时按 0 处理(与初值一致),升级不会踢掉在线会话。
|
||||
tv: Number(admin.token_version || 0),
|
||||
}, this.app.config.jwt.secret, this.app.config.jwt.expiresIn);
|
||||
|
||||
// 登录痕迹(best-effort):审计要能回答「谁在什么时候从哪登录」。
|
||||
// 失败不影响登录本身 —— 记录登录时间不是登录的必要条件。
|
||||
try {
|
||||
await admin.update({
|
||||
last_login_at: new Date(),
|
||||
last_login_ip: String(ctx.ip || '').slice(0, 64) || null,
|
||||
});
|
||||
} catch (recordErr) {
|
||||
ctx.logger.warn('[adminLogin] 记录登录信息失败:', recordErr && recordErr.message);
|
||||
}
|
||||
|
||||
ctx.body = ctx.helper.success({
|
||||
token,
|
||||
user: {
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
role: 'admin',
|
||||
// 前端据 isSuper 决定「管理员账号管理」入口与最高管理员的只读展示
|
||||
isSuper: Number(admin.is_super) === 1,
|
||||
// 权限点数组:前端据此过滤侧边菜单与按钮。后端仍逐请求校验,前端过滤只是体验层。
|
||||
permissions: adminPermissions.parsePermissions(admin.permissions),
|
||||
},
|
||||
// 使用默认口令时向前端透出改密提醒标记
|
||||
mustChangePassword: usingDefaultPassword,
|
||||
|
||||
@@ -389,6 +389,12 @@ class ExamController extends Controller {
|
||||
return;
|
||||
}
|
||||
|
||||
// 🔴 交卷冻结(R178·B①):已正式交卷者不得再改写答案。
|
||||
// 位置刻意在判分之前 —— 已冻结的答卷不该再做一次无意义的 autoScore。
|
||||
// 与 service.saveAnswers 内的兜底调用是**同一个函数**(单一判据源),
|
||||
// 不是两套条件;异常由下方 catch 按 bizCode 翻译成 4006。
|
||||
await service.exam.assertNotSubmitted(aggregation.aggregation_uid);
|
||||
|
||||
// 参数校验
|
||||
const rule = {
|
||||
answers: { type: 'array', required: true, min: 1 },
|
||||
@@ -452,6 +458,15 @@ class ExamController extends Controller {
|
||||
savedCount: savedRecords.length,
|
||||
}, '答案提交成功');
|
||||
} catch (err) {
|
||||
// 业务性拒绝(如「已交卷,答卷已冻结」)不该被伪装成 500:
|
||||
// service 层用 bizCode 标记期望的错误码,这里原样翻译给前端。
|
||||
if (err && err.bizCode) {
|
||||
ctx.logger.warn(
|
||||
`[submitAnswers] 业务拒绝 code=${err.bizCode} msg=${err.bizMessage || err.message}`
|
||||
);
|
||||
ctx.body = ctx.helper.error(err.bizCode, err.bizMessage || err.message);
|
||||
return;
|
||||
}
|
||||
ctx.logger.error('提交答案失败:', err);
|
||||
ctx.body = ctx.helper.error(500, '提交答案失败');
|
||||
}
|
||||
|
||||
284
app/lib/adminPermissions.js
Normal file
284
app/lib/adminPermissions.js
Normal file
@@ -0,0 +1,284 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 管理端权限点与「路由 → 权限」映射(R178·C 层唯一真相源)
|
||||
* ============================================================================
|
||||
*
|
||||
* 为什么需要这层:后端有 63 条 /api/admin/* 路由,原本全部只挂 adminAuth(role='admin')。
|
||||
* 在本模块之前,「是管理员」等于「有全部权限」—— 无法做到「只给阅卷、不给发布」。
|
||||
*
|
||||
* 三条设计纪律(改动前必读):
|
||||
*
|
||||
* 1. **默认拒绝**:`resolvePermission()` 匹配不到任何规则时返回 null,
|
||||
* 中间件对 null **直接 403**,而不是放行。
|
||||
* ⇒ 新增路由若忘了登记规则,表现是「功能不可用」而非「静默成为人人可用的后门」。
|
||||
*
|
||||
* 2. **单一真相源**:路由的权限归属只在本文件声明,⛔ 不得在 router.js 逐条写权限参数
|
||||
* (那会让 63 条路由各带一份权限字符串,与本表必然漂移,且无人能核对全集)。
|
||||
* 全集对齐由门禁 `scripts/verify_admin_rbac.py` 双向断言:
|
||||
* 「router.js 的 /api/admin 路由全集」== 「ROUTE_RULES 覆盖集」,两个方向差集都必须为空。
|
||||
*
|
||||
* 3. **多权限其一(anyOf)**:部分接口被两个菜单共用(如成绩复核页复用成绩列表),
|
||||
* 用 `perms: ['scores','review']` 表达「具备任一即可」。这不会降低安全性 ——
|
||||
* 任何一条都需被显式授予,只是避免「勾了复核却被 403」的误伤。
|
||||
*
|
||||
* 高危权限点单独成项(score.publish / data.reset / admins.manage),
|
||||
* 目的是把「能看成绩」与「能发布成绩」分开 —— 这两件事的责任人通常不同。
|
||||
*/
|
||||
|
||||
const MODULE = 'module';
|
||||
const HIGH_RISK = 'highRisk';
|
||||
|
||||
/**
|
||||
* 全部权限点。顺序即前端展示顺序。
|
||||
* ⚠️ 新增权限点必须同步:① 本数组;② ROUTE_RULES 至少一条规则;③ 前端 `can()` 使用处。
|
||||
*/
|
||||
const PERMISSIONS = [
|
||||
{ key: 'dashboard', label: '数据总览', group: MODULE, desc: '查看各组账号数 / 提交数 / 激活率' },
|
||||
{ key: 'accounts', label: '选手管理', group: MODULE, desc: '查看选手、导入报名表、编辑选手信息、标记测试数据' },
|
||||
{ key: 'questions', label: '试卷管理', group: MODULE, desc: '客观题与综合大题题库维护、Word 模板' },
|
||||
{ key: 'materials', label: '备赛资料', group: MODULE, desc: '上传 / 删除备赛资料' },
|
||||
{ key: 'submissions', label: '作品管理', group: MODULE, desc: '查看作品与答题记录、下载作品文件' },
|
||||
{ key: 'times', label: '时间设置', group: MODULE, desc: '考试起止时间、抽题数量、节点备份状态' },
|
||||
{ key: 'scores', label: '成绩管理', group: MODULE, desc: '阅卷打分、导出成绩、生成公示表(不含发布)' },
|
||||
{ key: 'review', label: '成绩复核', group: MODULE, desc: '按选手查验分项得分' },
|
||||
{ key: 'news', label: '新闻管理', group: MODULE, desc: '新闻增删改、置顶、附件上传、发布 / 撤回' },
|
||||
{ key: 'carousel', label: '轮播管理', group: MODULE, desc: '首页轮播图增删改与排序' },
|
||||
{ key: 'score.publish', label: '发布 / 撤回成绩', group: HIGH_RISK, desc: '控制考生端能否看到成绩,并自动生成公示草稿' },
|
||||
{ key: 'data.reset', label: '重置测试数据 / 删除选手', group: HIGH_RISK, desc: '会真实删除数据的操作,仅授予负责人' },
|
||||
{ key: 'admins.manage', label: '管理员账号管理', group: HIGH_RISK, desc: '新增 / 删除管理员、分配权限、重置口令、禁用' },
|
||||
];
|
||||
|
||||
const PERMISSION_KEYS = PERMISSIONS.map(p => p.key);
|
||||
const PERMISSION_KEY_SET = new Set(PERMISSION_KEYS);
|
||||
|
||||
/**
|
||||
* 路径模式 → 正则。`:xxx` 段匹配任意单段(不含 `/`)。
|
||||
* 其余字符按字面转义,避免 `/api/admin/big-questions/:group/:id` 里的 `-` `.` 被当元字符。
|
||||
*/
|
||||
function toRegExp(pattern) {
|
||||
const src = pattern
|
||||
.split('/')
|
||||
.map((seg) => {
|
||||
if (seg.startsWith(':')) return '[^/]+';
|
||||
return seg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
})
|
||||
.join('/');
|
||||
return new RegExp('^' + src + '$');
|
||||
}
|
||||
|
||||
/**
|
||||
* 路由规则表。**顺序敏感**:更具体的路径必须排在带 `:id` 的通配路径之前
|
||||
* (如 `/news/order` 必须在 `/news/:id` 之前),否则会被后者抢先匹配。
|
||||
*
|
||||
* ⚠️ 每条规则的 perms 至少一个;`perms` 语义 = 「具备其中之一即可」。
|
||||
*/
|
||||
const ROUTE_TABLE = [
|
||||
// ---------- 自助信息(登录即可,无需业务权限) ----------
|
||||
// 前端用它得知「我有哪些权限」以渲染菜单,并感知权限是否被他人改动。
|
||||
// ⚠️ perms 为空数组是本表唯一允许的「显式放行」写法,门禁会断言只有本行如此。
|
||||
['GET', '/api/admin/me', []],
|
||||
|
||||
// ---------- 管理员账号管理(高危) ----------
|
||||
['GET', '/api/admin/admin-users', ['admins.manage']],
|
||||
['POST', '/api/admin/admin-users', ['admins.manage']],
|
||||
['PUT', '/api/admin/admin-users/:id/permissions', ['admins.manage']],
|
||||
['PUT', '/api/admin/admin-users/:id/password', ['admins.manage']],
|
||||
['PUT', '/api/admin/admin-users/:id/status', ['admins.manage']],
|
||||
['DELETE', '/api/admin/admin-users/:id', ['admins.manage']],
|
||||
|
||||
// ---------- 数据总览 ----------
|
||||
['GET', '/api/admin/stats', ['dashboard']],
|
||||
|
||||
// ---------- 选手管理 ----------
|
||||
['GET', '/api/admin/accounts', ['accounts']],
|
||||
['GET', '/api/admin/teams', ['accounts']],
|
||||
['POST', '/api/admin/import-excel', ['accounts']],
|
||||
['POST', '/api/admin/contestants/backfill-teacher', ['accounts']],
|
||||
['PUT', '/api/admin/contestants/:id', ['accounts']],
|
||||
['POST', '/api/admin/toggle-test-flag', ['accounts']],
|
||||
// 重置测试数据是真实删除,单列高危(不给「只做选手维护」的人)
|
||||
['POST', '/api/admin/reset-test-data', ['data.reset']],
|
||||
|
||||
// ---------- 作品管理 ----------
|
||||
// 成绩管理页的「考生答题记录」也读 submissions 接口 ⇒ 允许 scores 其一
|
||||
['GET', '/api/admin/submissions/:group', ['submissions', 'scores']],
|
||||
['GET', '/api/admin/submission-file/:group/:id', ['submissions']],
|
||||
['GET', '/api/admin/submissions/:group/:id/word', ['submissions']],
|
||||
['GET', '/api/admin/works/:group', ['submissions']],
|
||||
['GET', '/api/admin/batch-download/:group', ['submissions']],
|
||||
|
||||
// ---------- 成绩管理(阅卷/导出/公示表) ----------
|
||||
['GET', '/api/admin/scores/:group', ['scores', 'review']],
|
||||
['PUT', '/api/admin/scores/:group', ['scores']],
|
||||
['GET', '/api/admin/export-csv/:group', ['scores']],
|
||||
['GET', '/api/admin/export-excel/:group', ['scores']],
|
||||
['GET', '/api/admin/export-publicity/:group', ['scores']],
|
||||
['GET', '/api/admin/competition-item', ['scores']],
|
||||
['PUT', '/api/admin/competition-item', ['scores']],
|
||||
['GET', '/api/admin/big-answers/:group', ['scores', 'review']],
|
||||
['GET', '/api/admin/big-answers/:group/export', ['scores']],
|
||||
['GET', '/api/admin/big-answers/:group/import-template', ['scores']],
|
||||
['POST', '/api/admin/big-answers/:group/import-preview', ['scores']],
|
||||
['POST', '/api/admin/big-answers/:group/import-scores', ['scores']],
|
||||
['PUT', '/api/admin/big-answers/:group/:uid/scores', ['scores']],
|
||||
['GET', '/api/admin/publish-scores', ['scores', 'review']],
|
||||
// 发布 / 撤回 = 高危:能看成绩 ≠ 能公布成绩
|
||||
['PUT', '/api/admin/publish-scores', ['score.publish']],
|
||||
|
||||
// ---------- 时间设置 ----------
|
||||
['GET', '/api/admin/times', ['times']],
|
||||
['PUT', '/api/admin/times', ['times']],
|
||||
['GET', '/api/admin/node-backup/status', ['times']],
|
||||
|
||||
// ---------- 试卷管理 ----------
|
||||
['GET', '/api/admin/exam/:group/questions', ['questions']],
|
||||
['PUT', '/api/admin/exam/:group/questions', ['questions']],
|
||||
['POST', '/api/admin/exam/:group/questions/import-excel', ['questions']],
|
||||
['GET', '/api/admin/exam/:group/questions/template', ['questions']],
|
||||
['PUT', '/api/admin/exam/:group/word-template', ['questions']],
|
||||
['GET', '/api/admin/exam/:group/word-template', ['questions']],
|
||||
['DELETE', '/api/admin/exam/:group/word-template', ['questions']],
|
||||
['GET', '/api/admin/big-questions/:group', ['questions']],
|
||||
['POST', '/api/admin/big-questions/:group', ['questions']],
|
||||
['GET', '/api/admin/big-questions/:group/export', ['questions']],
|
||||
['GET', '/api/admin/big-questions/:group/template', ['questions']],
|
||||
['POST', '/api/admin/big-questions/:group/import-excel', ['questions']],
|
||||
['PUT', '/api/admin/big-questions/:group/:id', ['questions']],
|
||||
['DELETE', '/api/admin/big-questions/:group/:id', ['questions']],
|
||||
|
||||
// ---------- 备赛资料 ----------
|
||||
['GET', '/api/admin/materials/:group', ['materials']],
|
||||
['POST', '/api/admin/materials/:group', ['materials']],
|
||||
['DELETE', '/api/admin/materials/:group/:fileName', ['materials']],
|
||||
|
||||
// ---------- 新闻管理 ----------
|
||||
// ⚠️ /news/order 必须排在 /news/:id 之前
|
||||
['PUT', '/api/admin/news/order', ['news']],
|
||||
['GET', '/api/admin/news', ['news']],
|
||||
['POST', '/api/admin/news', ['news']],
|
||||
['PUT', '/api/admin/news/:id', ['news']],
|
||||
['DELETE', '/api/admin/news/:id', ['news']],
|
||||
['POST', '/api/admin/upload/news-image', ['news']],
|
||||
['POST', '/api/admin/upload/news-file', ['news']],
|
||||
|
||||
// ---------- 轮播管理 ----------
|
||||
// ⚠️ /carousel/order 必须排在 /carousel/:id 之前
|
||||
['PUT', '/api/admin/carousel/order', ['carousel']],
|
||||
['GET', '/api/admin/carousel', ['carousel']],
|
||||
['POST', '/api/admin/carousel', ['carousel']],
|
||||
['PUT', '/api/admin/carousel/:id', ['carousel']],
|
||||
['DELETE', '/api/admin/carousel/:id', ['carousel']],
|
||||
['POST', '/api/admin/upload/image', ['carousel']],
|
||||
|
||||
// ---------- 成绩复核 ----------
|
||||
['GET', '/api/admin/review/search', ['review']],
|
||||
['GET', '/api/admin/review/score/:group/:id', ['review']],
|
||||
].map(([method, pattern, perms]) => ({ method, pattern, perms, re: toRegExp(pattern) }));
|
||||
|
||||
/**
|
||||
* 解析请求需要哪些权限(任一即可)。
|
||||
*
|
||||
* @param {string} method - HTTP 方法(大小写不敏感)
|
||||
* @param {string} path - 请求路径(不含 query)
|
||||
* @returns {string[]|null} 权限点数组;null = 未登记(调用方必须拒绝)
|
||||
*/
|
||||
function resolvePermission(method, path) {
|
||||
const m = String(method || '').toUpperCase();
|
||||
const p = String(path || '').split('?')[0];
|
||||
for (const rule of ROUTE_TABLE) {
|
||||
if (rule.method !== m) continue;
|
||||
if (rule.re.test(p)) return rule.perms;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析数据库里的 permissions 列(JSON 数组文本)。
|
||||
* 容错策略:解析失败 / 非数组 ⇒ 返回空数组(= 无权限),⛔ 绝不回落到「全权」。
|
||||
*
|
||||
* @param {string|null|undefined} raw
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function parsePermissions(raw) {
|
||||
if (raw === null || raw === undefined || raw === '') return [];
|
||||
let arr = raw;
|
||||
if (typeof raw === 'string') {
|
||||
try {
|
||||
arr = JSON.parse(raw);
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(arr)) return [];
|
||||
return arr.filter(k => typeof k === 'string' && PERMISSION_KEY_SET.has(k));
|
||||
}
|
||||
|
||||
/**
|
||||
* 规整待写入的权限点:只保留已登记的 key,去重,按 PERMISSIONS 顺序排列。
|
||||
* ⛔ 必须过滤未知值 —— 否则前端传错一个字符串就会被存进库,
|
||||
* 将来某个新权限点恰好同名时会「凭空获得权限」。
|
||||
*
|
||||
* @param {string[]} list
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function sanitizePermissions(list) {
|
||||
if (!Array.isArray(list)) return [];
|
||||
const set = new Set(list.filter(k => PERMISSION_KEY_SET.has(k)));
|
||||
return PERMISSION_KEYS.filter(k => set.has(k));
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断某管理员是否满足给定权限(任一命中即可)。
|
||||
*
|
||||
* ⚠️ 三种返回语义必须严格区分:
|
||||
* required === null ⇒ 未登记路由(由 resolvePermission 返回,调用方必须拒绝)
|
||||
* required === [] ⇒ **显式声明**「登录即可,无需业务权限」(如 /api/admin/me)
|
||||
* required === ['a','b'] ⇒ 具备任一即可
|
||||
*
|
||||
* @param {{is_super?:number|boolean, permissions?:string[]|string}} admin
|
||||
* @param {string[]|null} required
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function canAccess(admin, required) {
|
||||
if (!admin) return false;
|
||||
// 🔴 顺序不可换(R178 运行期判据 P3-2 抓到):**未登记路由对任何人都是拒绝**,
|
||||
// 包括最高管理员。若把 is_super 判在前面,`canAccess(super, null)` 会返回 true ——
|
||||
// 中间件虽然另有 `required === null ⇒ 403` 的前置分支,但那样「默认拒绝」就只活在
|
||||
// 一处调用约定里:任何新调用方(例如前端按钮显隐的服务端预判)漏判即成后门。
|
||||
// 此处作为第二道兜底,保证 null 永远不构成授权。
|
||||
if (!Array.isArray(required)) return false;
|
||||
if (Number(admin.is_super) === 1) return true;
|
||||
// 空数组 = 显式放行(仅限登记为「登录即可」的接口)
|
||||
if (required.length === 0) return true;
|
||||
const owned = Array.isArray(admin.permissions)
|
||||
? admin.permissions
|
||||
: parsePermissions(admin.permissions);
|
||||
return required.some(k => owned.includes(k));
|
||||
}
|
||||
|
||||
/**
|
||||
* 账号是否可被管理(改权限 / 重置口令 / 禁用 / 删除)。
|
||||
* 最高管理员一律不可变动 —— 这是用户明确裁定的硬约束,集中在单点判断,
|
||||
* ⛔ 不得在各接口里各写一遍 `id === 1`。
|
||||
*
|
||||
* @param {{is_super?:number|boolean}} admin
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isManageable(admin) {
|
||||
return !!admin && Number(admin.is_super) !== 1;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PERMISSIONS,
|
||||
PERMISSION_KEYS,
|
||||
PERMISSION_KEY_SET,
|
||||
ROUTE_TABLE,
|
||||
resolvePermission,
|
||||
parsePermissions,
|
||||
sanitizePermissions,
|
||||
canAccess,
|
||||
isManageable,
|
||||
MODULE,
|
||||
HIGH_RISK,
|
||||
};
|
||||
BIN
app/lib/assets/publicity-template.xlsx
Normal file
BIN
app/lib/assets/publicity-template.xlsx
Normal file
Binary file not shown.
78
app/lib/auditLog.js
Normal file
78
app/lib/auditLog.js
Normal file
@@ -0,0 +1,78 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 管理员高危操作审计(R178)
|
||||
* ============================================================================
|
||||
*
|
||||
* 三种写法被刻意排除:
|
||||
* 1. **不在控制器里直接 create** —— 控制器有 60+ 个方法,散落写入必然漏;
|
||||
* 统一走本函数,写入范围只由「谁调用它」决定,容易被审阅。
|
||||
* 2. **不做全量写操作审计** —— 见 model/adminAuditLog.js 头注:约 30 个写接口,
|
||||
* 日志量增长快且无清理策略;真正要回答的是「谁改了成绩 / 谁动了账号」。
|
||||
* 3. **不阻断业务** —— 审计失败只 warn。日志表故障不该让「发布成绩」失败,
|
||||
* 否则为可观测性牺牲了主流程可用性。
|
||||
*
|
||||
* ⚠️ detail 只放「变更前后的非敏感差异」。⛔ 绝不传口令、令牌、身份证号明文。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 写入一条审计记录(best-effort,永不抛出)
|
||||
*
|
||||
* @param {Object} ctx - Egg 上下文(需 ctx.state.currentAdmin 已由 adminAuth 写入)
|
||||
* @param {string} action - 动作标识,点号分层,如 'score.publish' / 'admin.create'
|
||||
* @param {string} [target] - 操作对象,如组别、用户名、选手 ID
|
||||
* @param {Object|string} [detail] - 补充信息(对象会被 JSON 序列化)
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function recordAudit(ctx, action, target, detail) {
|
||||
try {
|
||||
const admin = (ctx && ctx.state && ctx.state.currentAdmin) || null;
|
||||
// 没有操作人就不写:宁可缺一条,也不要写出一条「admin_id=0」的假归属,
|
||||
// 那会让人误以为系统账号做过操作。
|
||||
if (!admin || !admin.id) {
|
||||
ctx && ctx.logger && ctx.logger.warn(`[audit] 缺少 currentAdmin,跳过记录 action=${action}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let detailText = null;
|
||||
if (detail !== undefined && detail !== null) {
|
||||
try {
|
||||
detailText = typeof detail === 'string' ? detail : JSON.stringify(detail);
|
||||
} catch (e) {
|
||||
detailText = '[unserializable]';
|
||||
}
|
||||
if (detailText && detailText.length > 4000) detailText = detailText.slice(0, 4000) + '…';
|
||||
}
|
||||
|
||||
await ctx.model.AdminAuditLog.create({
|
||||
admin_id: admin.id,
|
||||
admin_username: admin.username,
|
||||
action: String(action || 'unknown').slice(0, 64),
|
||||
target: target === undefined || target === null ? null : String(target).slice(0, 200),
|
||||
detail: detailText,
|
||||
ip: String(ctx.ip || '').slice(0, 64) || null,
|
||||
created_at: new Date(),
|
||||
});
|
||||
} catch (err) {
|
||||
// 审计失败不得影响业务:只记日志
|
||||
if (ctx && ctx.logger) {
|
||||
ctx.logger.warn(`[audit] 写入失败 action=${action}:`, err && err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 审计动作常量(避免各处手写字符串导致同一动作被记成两个名字) */
|
||||
const ACTIONS = {
|
||||
SCORE_PUBLISH: 'score.publish',
|
||||
SCORE_UNPUBLISH: 'score.unpublish',
|
||||
DATA_RESET_TEST: 'data.resetTest',
|
||||
CONTESTANT_DELETE: 'contestant.delete',
|
||||
EXAM_UNSUBMIT: 'exam.unsubmit',
|
||||
ADMIN_CREATE: 'admin.create',
|
||||
ADMIN_PERMISSIONS: 'admin.permissions',
|
||||
ADMIN_PASSWORD: 'admin.password',
|
||||
ADMIN_STATUS: 'admin.status',
|
||||
ADMIN_DELETE: 'admin.delete',
|
||||
};
|
||||
|
||||
module.exports = { recordAudit, ACTIONS };
|
||||
559
app/lib/publicitySheet.js
Normal file
559
app/lib/publicitySheet.js
Normal file
@@ -0,0 +1,559 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 成绩公示表导出引擎 —— 纯函数层(R176)
|
||||
*
|
||||
* 目标:以赛事官方参考模板(`lib/assets/publicity-template.xlsx`)为**基底**生成
|
||||
* xlsx,只填数据、不改版式,人工后续补「获奖等级」即可直接用。
|
||||
*
|
||||
* 🔴 为什么不用 `@e965/xlsx` 直接改模板再写回(实测结论,2026-10-19):
|
||||
* 读入模板 → 写一个单元格 → 写回,`sheet1.xml` 里的 `s=` 样式引用数
|
||||
* 从 **214 → 0**、`styles.xml` 从 22773 → 1546 字节,合并单元格还在但
|
||||
* 边框/字号/列宽全部丢失。该库 CE 版**不写出样式**,`cellStyles: true`
|
||||
* 只影响读、不影响写。
|
||||
* ⇒ 唯一能保住版式的做法 = 只替换 `xl/worksheets/sheet1.xml` 这一个 zip 条目,
|
||||
* 其余条目**原始压缩字节照抄**(不解压不重压 ⇒ 连 CRC 都不用重算)。
|
||||
*
|
||||
* 🔴 零依赖约束:只用 Node 内置 `zlib`,CRC32 自己实现。这样 `--self-test`
|
||||
* 可以在零 DB / 零后端 / 零网络的条件下直接 require 本文件跑矩阵。
|
||||
*
|
||||
* 🔴 数据行数可以超过模板预留的 27 行:模板数据区是 row 3..29,两条签字行在 row 30
|
||||
* (裁判长)与 row 33(仲裁长)。新表按「新数据末行 + 1」与「新数据末行 + 4」
|
||||
* 的相对偏移重排两条签字行(N=27 时正好复现 row 30 / row 33)。
|
||||
* ⚠️ 备注块合并区 `H3:J8` 固定不动(H:J 列,与 A:G 的数据列不冲突),
|
||||
* 但必须保证至少生成 6 个数据行,否则备注框的下边框会落空。
|
||||
*/
|
||||
|
||||
const zlib = require('zlib');
|
||||
|
||||
// ==================== 常量(模板结构的单一描述源) ====================
|
||||
|
||||
/** 模板第 2 行的 7 个表头,仅用于自证断言与文档;实际写值不依赖它 */
|
||||
const TEMPLATE_HEADERS = ['序号', '参赛单位名称', '参赛选手', '指导教师', '成绩排名', '总分数', '获奖等级'];
|
||||
|
||||
/** 数据区起止行(1 基,含端点)。row 2 = 表头,row 3 起才是数据 */
|
||||
const DATA_FIRST_ROW = 3;
|
||||
/**
|
||||
* 🔴 数据区实际末行是 29,**不是 30**。
|
||||
* 模板的 row 30 是「裁判长签字:」行(A30 / E30 各一个),row 33 是「仲裁长签字:」行。
|
||||
* 把 30 当数据行会直接抹掉裁判长签字 —— 2026-10-19 实测踩过这个坑。
|
||||
*/
|
||||
const DATA_LAST_ROW = 29;
|
||||
/** 签字行原始行号(模板里两条:裁判长在 30,仲裁长在 33) */
|
||||
const SIGN_ROWS = [30, 33];
|
||||
/** 模板预留的数据行数(row 3..29 = 27 行;模板里 row 29 没有 row 元素,不影响计数口径) */
|
||||
const RESERVED_DATA_ROWS = DATA_LAST_ROW - DATA_FIRST_ROW + 1;
|
||||
/** 末条签字行行号 */
|
||||
const TEMPLATE_TAIL_ROW = SIGN_ROWS[SIGN_ROWS.length - 1];
|
||||
/** 签字行相对「数据区末行」的偏移(33 - 29 = 4) */
|
||||
const TAIL_OFFSET = TEMPLATE_TAIL_ROW - DATA_LAST_ROW;
|
||||
/** 备注块 H3:J8 要求的最小数据行数(8 - 3 + 1 = 6) */
|
||||
const MIN_DATA_ROWS_FOR_NOTE = 6;
|
||||
/** 数据列 A..G */
|
||||
const DATA_COLS = ['A', 'B', 'C', 'D', 'E', 'F', 'G'];
|
||||
/** 备注块所在列(只补空样式单元格,撑住合并区边框) */
|
||||
const NOTE_COLS = ['H', 'I', 'J'];
|
||||
|
||||
/** 模板标题里等待替换的两段占位文本 */
|
||||
const TITLE_PLACEHOLDER_ITEM = '***赛项(选拔赛/国内决赛)比赛结果公示表';
|
||||
const TITLE_PLACEHOLDER_CODE = '赛项编号:';
|
||||
|
||||
// ==================== CRC32 / zip 读写 ====================
|
||||
|
||||
let CRC_TABLE = null;
|
||||
function crcTable() {
|
||||
if (CRC_TABLE) return CRC_TABLE;
|
||||
const t = new Int32Array(256);
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) {
|
||||
c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
|
||||
}
|
||||
t[n] = c;
|
||||
}
|
||||
CRC_TABLE = t;
|
||||
return t;
|
||||
}
|
||||
|
||||
/** 标准 CRC-32(IEEE 802.3),返回无符号 32 位 */
|
||||
function crc32(buf) {
|
||||
const t = crcTable();
|
||||
let c = -1;
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
c = t[(c ^ buf[i]) & 0xFF] ^ (c >>> 8);
|
||||
}
|
||||
return (c ^ -1) >>> 0;
|
||||
}
|
||||
|
||||
/** 从尾部找 End Of Central Directory。找不到返回 -1 */
|
||||
function findEOCD(buf) {
|
||||
const min = Math.max(0, buf.length - 65557);
|
||||
for (let i = buf.length - 22; i >= min; i--) {
|
||||
if (buf[i] === 0x50 && buf[i + 1] === 0x4b && buf[i + 2] === 0x05 && buf[i + 3] === 0x06) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读 zip,返回每个条目的**原始压缩字节**(不解压)。
|
||||
* 中央目录里的 compSize / crc 总是准确的(即使 local header 用了 data descriptor),
|
||||
* 所以定位数据区一律以中央目录为准。
|
||||
*/
|
||||
function readZip(buf) {
|
||||
if (!Buffer.isBuffer(buf) || buf.length < 22) throw new Error('不是有效的 zip/xlsx(内容过短)');
|
||||
const eocd = findEOCD(buf);
|
||||
if (eocd < 0) throw new Error('不是有效的 zip/xlsx(找不到 EOCD)');
|
||||
|
||||
const count = buf.readUInt16LE(eocd + 10);
|
||||
let off = buf.readUInt32LE(eocd + 16);
|
||||
if (off >= buf.length) throw new Error('中央目录偏移越界');
|
||||
|
||||
const entries = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (off + 46 > buf.length || buf.readUInt32LE(off) !== 0x02014b50) {
|
||||
throw new Error(`中央目录第 ${i} 项签名不符 @${off}`);
|
||||
}
|
||||
const method = buf.readUInt16LE(off + 10);
|
||||
const crc = buf.readUInt32LE(off + 16);
|
||||
const compSize = buf.readUInt32LE(off + 20);
|
||||
const uncompSize = buf.readUInt32LE(off + 24);
|
||||
const nameLen = buf.readUInt16LE(off + 28);
|
||||
const extraLen = buf.readUInt16LE(off + 30);
|
||||
const commentLen = buf.readUInt16LE(off + 32);
|
||||
const localOff = buf.readUInt32LE(off + 42);
|
||||
const name = buf.slice(off + 46, off + 46 + nameLen).toString('utf8');
|
||||
|
||||
if (localOff + 30 > buf.length || buf.readUInt32LE(localOff) !== 0x04034b50) {
|
||||
throw new Error(`条目 ${name} 的本地头损坏`);
|
||||
}
|
||||
const lnLen = buf.readUInt16LE(localOff + 26);
|
||||
const leLen = buf.readUInt16LE(localOff + 28);
|
||||
const dataStart = localOff + 30 + lnLen + leLen;
|
||||
if (dataStart + compSize > buf.length) throw new Error(`条目 ${name} 的数据区越界`);
|
||||
|
||||
entries.push({
|
||||
name,
|
||||
method,
|
||||
crc,
|
||||
compSize,
|
||||
uncompSize,
|
||||
data: buf.slice(dataStart, dataStart + compSize),
|
||||
});
|
||||
|
||||
off += 46 + nameLen + extraLen + commentLen;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** 把条目按原压缩字节重新打包成 zip(不重压缩 ⇒ CRC 无需重算) */
|
||||
function writeZip(entries) {
|
||||
const locals = [];
|
||||
const centrals = [];
|
||||
let offset = 0;
|
||||
|
||||
for (const e of entries) {
|
||||
const nameBuf = Buffer.from(e.name, 'utf8');
|
||||
const crc = e.crc >>> 0;
|
||||
const compSize = e.data.length;
|
||||
const uncompSize = e.uncompSize;
|
||||
|
||||
const lh = Buffer.alloc(30);
|
||||
lh.writeUInt32LE(0x04034b50, 0);
|
||||
lh.writeUInt16LE(20, 4); // version needed to extract
|
||||
lh.writeUInt16LE(0x0800, 6); // general purpose flag: UTF-8 名称
|
||||
lh.writeUInt16LE(e.method, 8);
|
||||
lh.writeUInt16LE(0, 10); // last mod time(不关心,固定 0)
|
||||
lh.writeUInt16LE(0, 12); // last mod date
|
||||
lh.writeUInt32LE(crc, 14);
|
||||
lh.writeUInt32LE(compSize, 18);
|
||||
lh.writeUInt32LE(uncompSize, 22);
|
||||
lh.writeUInt16LE(nameBuf.length, 26);
|
||||
lh.writeUInt16LE(0, 28); // extra len
|
||||
locals.push(lh, nameBuf, e.data);
|
||||
|
||||
const ch = Buffer.alloc(46);
|
||||
ch.writeUInt32LE(0x02014b50, 0);
|
||||
ch.writeUInt16LE(20, 4); // version made by
|
||||
ch.writeUInt16LE(20, 6); // version needed
|
||||
ch.writeUInt16LE(0x0800, 8);
|
||||
ch.writeUInt16LE(e.method, 10);
|
||||
ch.writeUInt16LE(0, 12);
|
||||
ch.writeUInt16LE(0, 14);
|
||||
ch.writeUInt32LE(crc, 16);
|
||||
ch.writeUInt32LE(compSize, 20);
|
||||
ch.writeUInt32LE(uncompSize, 24);
|
||||
ch.writeUInt16LE(nameBuf.length, 28);
|
||||
ch.writeUInt16LE(0, 30); // extra
|
||||
ch.writeUInt16LE(0, 32); // comment
|
||||
ch.writeUInt16LE(0, 34); // disk number
|
||||
ch.writeUInt16LE(0, 36); // internal attrs
|
||||
ch.writeUInt32LE(0, 38); // external attrs
|
||||
ch.writeUInt32LE(offset, 42);
|
||||
centrals.push(ch, nameBuf);
|
||||
|
||||
offset += lh.length + nameBuf.length + e.data.length;
|
||||
}
|
||||
|
||||
const centralStart = offset;
|
||||
const centralBuf = Buffer.concat(centrals);
|
||||
const eocd = Buffer.alloc(22);
|
||||
eocd.writeUInt32LE(0x06054b50, 0);
|
||||
eocd.writeUInt16LE(0, 4);
|
||||
eocd.writeUInt16LE(0, 6);
|
||||
eocd.writeUInt16LE(entries.length, 8);
|
||||
eocd.writeUInt16LE(entries.length, 10);
|
||||
eocd.writeUInt32LE(centralBuf.length, 12);
|
||||
eocd.writeUInt32LE(centralStart, 16);
|
||||
eocd.writeUInt16LE(0, 20);
|
||||
return Buffer.concat([Buffer.concat(locals), centralBuf, eocd]);
|
||||
}
|
||||
|
||||
/** 解压单个条目为 utf8 文本(method 0=store / 8=deflate) */
|
||||
function entryText(entry) {
|
||||
if (!entry) throw new Error('条目不存在');
|
||||
const raw = entry.method === 0 ? entry.data : zlib.inflateRawSync(entry.data);
|
||||
return raw.toString('utf8');
|
||||
}
|
||||
|
||||
/** 用 utf8 文本替换某条目(重新 deflate + 重算 CRC) */
|
||||
function replaceEntryText(entries, name, text) {
|
||||
const raw = Buffer.from(text, 'utf8');
|
||||
const deflated = zlib.deflateRawSync(raw);
|
||||
let hit = 0;
|
||||
const out = entries.map(e => {
|
||||
if (e.name !== name) return e;
|
||||
hit++;
|
||||
return {
|
||||
name: e.name,
|
||||
method: 8,
|
||||
crc: crc32(raw),
|
||||
compSize: deflated.length,
|
||||
uncompSize: raw.length,
|
||||
data: deflated,
|
||||
};
|
||||
});
|
||||
if (hit !== 1) throw new Error(`条目 ${name} 命中 ${hit} 次(应恰好 1 次)`);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ==================== XML 片段工具 ====================
|
||||
|
||||
const XML_ESCAPE = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
||||
function esc(s) {
|
||||
return String(s === null || s === undefined ? '' : s).replace(/[&<>"']/g, c => XML_ESCAPE[c]);
|
||||
}
|
||||
|
||||
const ROW_RE = /<row\b[^>]*\/>|<row\b[^>]*>[\s\S]*?<\/row>/g;
|
||||
const CELL_RE = /<c\b[^>]*\/>|<c\b[^>]*>[\s\S]*?<\/c>/g;
|
||||
|
||||
function rowNumber(rowXml) {
|
||||
const m = /<row\b[^>]*\br="(\d+)"/.exec(rowXml);
|
||||
return m ? parseInt(m[1], 10) : -1;
|
||||
}
|
||||
|
||||
/** 从一行 XML 里取 { 列字母: s 值 } */
|
||||
function rowStyles(rowXml) {
|
||||
const out = {};
|
||||
const cells = rowXml.match(CELL_RE) || [];
|
||||
for (const c of cells) {
|
||||
const rm = /\br="([A-Z]+)\d+"/.exec(c);
|
||||
if (!rm) continue;
|
||||
const sm = /\bs="(\d+)"/.exec(c);
|
||||
if (sm) out[rm[1]] = sm[1];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 生成一个数据单元格(数值 / 文本 / 空) */
|
||||
function cellXml(col, row, style, value) {
|
||||
const ref = col + row;
|
||||
const sAttr = style === undefined ? '' : ` s="${style}"`;
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return `<c r="${ref}"${sAttr}/>`;
|
||||
}
|
||||
if (typeof value === 'number' && isFinite(value)) {
|
||||
return `<c r="${ref}"${sAttr}><v>${value}</v></c>`;
|
||||
}
|
||||
// 用 inlineStr 而不是 sharedStrings:新引入的字符串不用再去维护共享表下标
|
||||
return `<c r="${ref}"${sAttr} t="inlineStr"><is><t xml:space="preserve">${esc(value)}</t></is></c>`;
|
||||
}
|
||||
|
||||
/** 选「最常见的数据行样式」作为新行模板,避免拿模板里被手工改过的那一行当标准 */
|
||||
function pickDataRowStyles(dataRows) {
|
||||
const counted = new Map();
|
||||
for (const r of dataRows) {
|
||||
const st = rowStyles(r.xml);
|
||||
const sig = DATA_COLS.map(c => st[c] === undefined ? '-' : st[c]).join(',');
|
||||
const cur = counted.get(sig);
|
||||
if (cur) cur.n++;
|
||||
else counted.set(sig, { n: 1, styles: st });
|
||||
}
|
||||
let best = null;
|
||||
for (const [, v] of counted) {
|
||||
if (!best || v.n > best.n) best = v;
|
||||
}
|
||||
return best ? best.styles : { A: '6', B: '7', C: '7', D: '8', E: '6', F: '6', G: '6' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成新的 sheetData。
|
||||
*
|
||||
* @param {string} sheetXml 原 sheet1.xml
|
||||
* @param {Array<Object>} rows buildPublicityRows 的产物
|
||||
* @returns {{ xml: string, dataRows: number, tailRow: number, styles: Object }}
|
||||
*/
|
||||
function patchSheetData(sheetXml, rows) {
|
||||
const sheetDataMatch = /<sheetData\b[^>]*>([\s\S]*?)<\/sheetData>/.exec(sheetXml);
|
||||
if (!sheetDataMatch) throw new Error('模板缺少 <sheetData>');
|
||||
|
||||
const allRows = sheetDataMatch[1].match(ROW_RE) || [];
|
||||
const head = [];
|
||||
const data = [];
|
||||
const tail = [];
|
||||
for (const r of allRows) {
|
||||
const n = rowNumber(r);
|
||||
if (n <= 2) head.push(r);
|
||||
else if (n >= SIGN_ROWS[0]) tail.push(r);
|
||||
else data.push({ n, xml: r });
|
||||
}
|
||||
if (head.length !== 2) throw new Error(`模板表头行应为 2 行,实际 ${head.length}`);
|
||||
if (data.length === 0) throw new Error('模板没有数据行可作样式参照');
|
||||
// 🔴 必须同时守住两条签字行:只保留一条等于把「裁判长签字」或「仲裁长签字」丢了
|
||||
if (tail.length !== SIGN_ROWS.length) {
|
||||
throw new Error(`模板签字行应为 ${SIGN_ROWS.length} 条(row ${SIGN_ROWS.join(' / ')}),实际 ${tail.length} 条`);
|
||||
}
|
||||
|
||||
const styles = pickDataRowStyles(data);
|
||||
// 备注块 H3:J8 的空单元格样式:直接取模板数据行里 H/I/J 的 s(row 3 有备注文本,取 row 4 起)
|
||||
const noteStyleFrom = {};
|
||||
for (const d of data) {
|
||||
if (d.n <= 8) {
|
||||
const st = rowStyles(d.xml);
|
||||
for (const c of NOTE_COLS) {
|
||||
if (st[c] !== undefined && noteStyleFrom[c] === undefined) noteStyleFrom[c] = st[c];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const count = Math.max(rows.length, MIN_DATA_ROWS_FOR_NOTE);
|
||||
const out = [];
|
||||
for (const h of head) out.push(h);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const r = DATA_FIRST_ROW + i;
|
||||
const row = rows[i] || null;
|
||||
const cells = [
|
||||
cellXml('A', r, styles.A, row ? row.seq : null),
|
||||
cellXml('B', r, styles.B, row ? row.org : null),
|
||||
cellXml('C', r, styles.C, row ? row.players : null),
|
||||
cellXml('D', r, styles.D, row ? row.teachers : null),
|
||||
cellXml('E', r, styles.E, row ? row.rank : null),
|
||||
cellXml('F', r, styles.F, row ? row.score : null),
|
||||
// 获奖等级:用户裁定「空置人工填入」⇒ 永远不写值
|
||||
cellXml('G', r, styles.G, null),
|
||||
];
|
||||
// 备注合并区 H3:J8 需要这些单元格存在才能撑住边框
|
||||
if (r <= 8) {
|
||||
for (const c of NOTE_COLS) cells.push(cellXml(c, r, noteStyleFrom[c], null));
|
||||
}
|
||||
out.push(`<row r="${r}" customHeight="1" spans="1:7">${cells.join('')}</row>`);
|
||||
}
|
||||
|
||||
const delta = (count + (DATA_FIRST_ROW - 1)) - DATA_LAST_ROW; // 新数据末行 - 旧数据末行
|
||||
const shiftRow = (xml, add) => {
|
||||
if (add === 0) return xml;
|
||||
return xml.replace(/(<row\b[^>]*\br=")(\d+)(")/, (m, p1, p2, p3) => p1 + (parseInt(p2, 10) + add) + p3)
|
||||
.replace(/(<c\b[^>]*\br="[A-Z]+)(\d+)(")/g, (m, p1, p2, p3) => p1 + (parseInt(p2, 10) + add) + p3);
|
||||
};
|
||||
for (const t of tail) out.push(shiftRow(t, delta));
|
||||
|
||||
const newSheetData = `<sheetData>${out.join('')}</sheetData>`;
|
||||
let xml = sheetXml.replace(sheetDataMatch[0], newSheetData);
|
||||
|
||||
// dimension 同步(否则 Excel 认为表格只到旧末行,滚动区/打印区会截断)
|
||||
const tailRow = TEMPLATE_TAIL_ROW + delta;
|
||||
xml = xml.replace(/<dimension\b[^>]*\bref="[^"]*"/, `<dimension ref="A1:J${tailRow}"`);
|
||||
|
||||
return { xml, dataRows: count, tailRow, styles };
|
||||
}
|
||||
|
||||
/**
|
||||
* 替换标题里的赛项名与赛项编号。
|
||||
*
|
||||
* 标题是 A1 一个单元格里的三行文本(存在 sharedStrings 第 0 项),
|
||||
* 所以改 sharedStrings 比改 sheet1 更准(A1 是 t="s" 引用,改 sheet 只能整体替换)。
|
||||
*
|
||||
* @param {string} xml sharedStrings.xml 原文
|
||||
* @param {{itemName?: string, itemCode?: string}} meta
|
||||
* @returns {{ xml: string, replacedItem: boolean, replacedCode: boolean }}
|
||||
*/
|
||||
function patchSharedStringsTitle(xml, meta) {
|
||||
const itemName = meta && meta.itemName ? String(meta.itemName).trim() : '';
|
||||
const itemCode = meta && meta.itemCode ? String(meta.itemCode).trim() : '';
|
||||
|
||||
let replacedItem = false;
|
||||
let replacedCode = false;
|
||||
|
||||
// 定位第 1 个 <si>(标题)
|
||||
const siMatch = /<si>([\s\S]*?)<\/si>/.exec(xml);
|
||||
if (!siMatch) throw new Error('sharedStrings 里没有 <si>');
|
||||
|
||||
let inner = siMatch[1];
|
||||
|
||||
if (itemName && inner.indexOf(TITLE_PLACEHOLDER_ITEM) >= 0) {
|
||||
// 赛项名替换掉「***」,保留「(选拔赛/国内决赛)比赛结果公示表」的公文后缀
|
||||
inner = inner.replace(TITLE_PLACEHOLDER_ITEM, `${esc(itemName)}(选拔赛/国内决赛)比赛结果公示表`);
|
||||
replacedItem = true;
|
||||
}
|
||||
if (itemCode && inner.indexOf(TITLE_PLACEHOLDER_CODE) >= 0) {
|
||||
// 编号为空时不追加,避免留下一个孤零零的「赛项编号:」
|
||||
inner = inner.replace(TITLE_PLACEHOLDER_CODE, `${TITLE_PLACEHOLDER_CODE}${esc(itemCode)}`);
|
||||
replacedCode = true;
|
||||
}
|
||||
|
||||
if (!replacedItem && !replacedCode) return { xml, replacedItem, replacedCode };
|
||||
|
||||
const newSi = `<si>${inner}</si>`;
|
||||
return { xml: xml.replace(siMatch[0], newSi), replacedItem, replacedCode };
|
||||
}
|
||||
|
||||
/**
|
||||
* 完整流程:模板字节 + 数据行 + 标题信息 → 新 xlsx 字节。
|
||||
* @param {Buffer} templateBuf
|
||||
* @param {Array<Object>} rows
|
||||
* @param {{itemName?: string, itemCode?: string}} [meta]
|
||||
*/
|
||||
function buildXlsx(templateBuf, rows, meta) {
|
||||
const entries = readZip(templateBuf);
|
||||
|
||||
const sheetEntry = entries.find(e => e.name === 'xl/worksheets/sheet1.xml');
|
||||
if (!sheetEntry) throw new Error('模板缺少 xl/worksheets/sheet1.xml');
|
||||
const sheetXml = entryText(sheetEntry);
|
||||
const patched = patchSheetData(sheetXml, rows);
|
||||
|
||||
let next = replaceEntryText(entries, 'xl/worksheets/sheet1.xml', patched.xml);
|
||||
|
||||
const ssEntry = next.find(e => e.name === 'xl/sharedStrings.xml');
|
||||
let titleResult = { replacedItem: false, replacedCode: false };
|
||||
if (ssEntry) {
|
||||
const ssXml = entryText(ssEntry);
|
||||
const r = patchSharedStringsTitle(ssXml, meta || {});
|
||||
titleResult = { replacedItem: r.replacedItem, replacedCode: r.replacedCode };
|
||||
if (r.replacedItem || r.replacedCode) {
|
||||
next = replaceEntryText(next, 'xl/sharedStrings.xml', r.xml);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
buffer: writeZip(next),
|
||||
dataRows: patched.dataRows,
|
||||
tailRow: patched.tailRow,
|
||||
title: titleResult,
|
||||
styles: patched.styles,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 数据构造(纯函数) ====================
|
||||
|
||||
/** 分数归一:null/''/NaN → null;字符串数字 → number */
|
||||
function toScore(v) {
|
||||
if (v === null || v === undefined || v === '') return null;
|
||||
const n = Number(v);
|
||||
return isFinite(n) ? Math.round(n * 100) / 100 : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把「小组」数组排成公示表的行。
|
||||
*
|
||||
* 一行 = 一个小组(用户裁定:一组一人参赛、成绩全组共享 ⇒ 同一分数不该占多行)。
|
||||
* 排序:有分的按分数**由高到低**,未评分的排最后;同分按团队编号升序保证稳定。
|
||||
* 获奖等级不在此处产生(永远留空)。
|
||||
*
|
||||
* @param {Array<{teamUid?:number|string, teamId?:string, org?:string,
|
||||
* players?:string[]|string, teachers?:string[]|string, score?:number|string}>} teams
|
||||
* @returns {Array<{seq:number, org:string, players:string, teachers:string, rank:(number|null), score:(number|null), teamUid:any, teamId:string}>}
|
||||
*/
|
||||
function buildPublicityRows(teams) {
|
||||
const list = Array.isArray(teams) ? teams : [];
|
||||
const norm = list.map(t => {
|
||||
const players = Array.isArray(t.players) ? t.players : (t.players ? [t.players] : []);
|
||||
const teachers = Array.isArray(t.teachers) ? t.teachers : (t.teachers ? [t.teachers] : []);
|
||||
return {
|
||||
teamUid: t.teamUid === undefined ? null : t.teamUid,
|
||||
teamId: t.teamId === undefined || t.teamId === null ? '' : String(t.teamId),
|
||||
org: t.org === undefined || t.org === null ? '' : String(t.org),
|
||||
players: uniqKeepOrder(players).join('、'),
|
||||
teachers: uniqKeepOrder(teachers).join('、'),
|
||||
score: toScore(t.score),
|
||||
};
|
||||
});
|
||||
|
||||
norm.sort((a, b) => {
|
||||
const sa = a.score === null ? -Infinity : a.score;
|
||||
const sb = b.score === null ? -Infinity : b.score;
|
||||
if (sb !== sa) return sb - sa;
|
||||
// 未评分的两个也按编号定序,避免顺序随查询波动
|
||||
if (a.teamId < b.teamId) return -1;
|
||||
if (a.teamId > b.teamId) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
let rank = 0;
|
||||
return norm.map((t, i) => {
|
||||
// 未评分不参与排名(否则会出现「0 分并列第 N」的假排名)
|
||||
if (t.score !== null) rank++;
|
||||
return {
|
||||
seq: i + 1,
|
||||
org: t.org,
|
||||
players: t.players,
|
||||
teachers: t.teachers,
|
||||
rank: t.score === null ? null : rank,
|
||||
score: t.score,
|
||||
teamUid: t.teamUid,
|
||||
teamId: t.teamId,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 去重保序(同组同名只留一份) */
|
||||
function uniqKeepOrder(arr) {
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const x of arr) {
|
||||
const s = String(x === null || x === undefined ? '' : x).trim();
|
||||
if (!s || seen.has(s)) continue;
|
||||
seen.add(s);
|
||||
out.push(s);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
TEMPLATE_HEADERS,
|
||||
DATA_FIRST_ROW,
|
||||
DATA_LAST_ROW,
|
||||
SIGN_ROWS,
|
||||
RESERVED_DATA_ROWS,
|
||||
TEMPLATE_TAIL_ROW,
|
||||
TAIL_OFFSET,
|
||||
MIN_DATA_ROWS_FOR_NOTE,
|
||||
DATA_COLS,
|
||||
TITLE_PLACEHOLDER_ITEM,
|
||||
TITLE_PLACEHOLDER_CODE,
|
||||
crc32,
|
||||
findEOCD,
|
||||
readZip,
|
||||
writeZip,
|
||||
entryText,
|
||||
replaceEntryText,
|
||||
rowNumber,
|
||||
rowStyles,
|
||||
cellXml,
|
||||
pickDataRowStyles,
|
||||
patchSheetData,
|
||||
patchSharedStringsTitle,
|
||||
buildXlsx,
|
||||
toScore,
|
||||
buildPublicityRows,
|
||||
uniqKeepOrder,
|
||||
};
|
||||
287
app/lib/teacherBackfill.js
Normal file
287
app/lib/teacherBackfill.js
Normal file
@@ -0,0 +1,287 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 指导教师回填 —— 纯判定层(R176)
|
||||
*
|
||||
* 背景:`contestants` 原本没有任何「指导教师」承载字段,而成绩公示表
|
||||
* (`7-2026一带一路暨金砖大赛成绩公示表-参考模版.xlsx`)第 4 列必须填写。
|
||||
* 报名表里其实有指导教师行,但导入流程按业务规则只导入「参赛选手」身份
|
||||
* (`service/admin.js` 的 IMPORT_IDENTITY),教师行被计入 `ignored` 丢弃。
|
||||
*
|
||||
* 本模块只负责**从报名表二维数组里把指导教师找出来、按小组归并**,
|
||||
* 不碰数据库、不碰文件、不依赖 Egg。落库由 `service/admin.js#backfillTeachers` 完成。
|
||||
*
|
||||
* 🔴 为什么不直接复用既有导入:`importContestants` 对已存在的身份证号一律 skip
|
||||
* (`result.skipped++`),所以「重导一次」不会更新任何字段。回填必须是独立通路。
|
||||
*
|
||||
* 🔴 为什么必须带 group:`teams.team_uid` 仅组内唯一、可跨组重号(实测本科组也有
|
||||
* team_uid=1),只按 teamId 归并会把别的组的教师挂到本组头上。
|
||||
*/
|
||||
|
||||
/** 教师身份白名单(归一化:去掉所有空白字符后比对) */
|
||||
const TEACHER_IDENTITIES = [
|
||||
'指导教师',
|
||||
'指导老师',
|
||||
'指导专家',
|
||||
'指导教师(专家)',
|
||||
'指导教师(专家)',
|
||||
'指导教师(教练)',
|
||||
'指导教师(教练)',
|
||||
'教练',
|
||||
'导师',
|
||||
];
|
||||
|
||||
/** 组别别名 → 系统 key。⚠️ 名字带 TEACHER_ 前缀,避免与 service/admin.js 的 GROUP_ALIAS 撞名 */
|
||||
const TEACHER_GROUP_ALIAS = {
|
||||
中职: 'zhongzhi',
|
||||
中职组: 'zhongzhi',
|
||||
高职: 'gaozhi',
|
||||
高职组: 'gaozhi',
|
||||
本科: 'benke',
|
||||
本科组: 'benke',
|
||||
教师: 'jiaoshi',
|
||||
教师组: 'jiaoshi',
|
||||
zhongzhi: 'zhongzhi',
|
||||
gaozhi: 'gaozhi',
|
||||
benke: 'benke',
|
||||
jiaoshi: 'jiaoshi',
|
||||
};
|
||||
|
||||
/** 报名表列名别名(与导入契约同源,但这里只取回填必需的 4 列) */
|
||||
const TEACHER_COLUMN_ALIAS = {
|
||||
name: ['姓名', '全名', 'name'],
|
||||
teamId: ['队伍编号', '队伍ID', '队伍id', 'team_id', 'team_number'],
|
||||
group: ['组别', 'group'],
|
||||
identity: ['身份', 'identity'],
|
||||
};
|
||||
|
||||
const ROW_STATUS = {
|
||||
TEACHER: 'TEACHER',
|
||||
NOT_TEACHER: 'NOT_TEACHER',
|
||||
TEACHER_NO_TEAM: 'TEACHER_NO_TEAM',
|
||||
TEACHER_NO_NAME: 'TEACHER_NO_NAME',
|
||||
TEACHER_NO_GROUP: 'TEACHER_NO_GROUP',
|
||||
BLANK_ROW: 'BLANK_ROW',
|
||||
};
|
||||
|
||||
/**
|
||||
* 去掉所有空白字符(含全角空格、不换行空格、零宽字符)。
|
||||
* ⛔ 不能只 trim:上游表格里「指导 教师」「指导教师」混用很常见,
|
||||
* 只 trim 会把前者判成非教师身份而整行丢掉。
|
||||
*/
|
||||
function squash(v) {
|
||||
if (v === null || v === undefined) return '';
|
||||
return String(v)
|
||||
.replace(/[\u3000\u00a0\u200b\u200c\u200d\ufeff]/g, '')
|
||||
.replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
/** 组别归一;无法识别时返回空串(调用方据此判定"组别缺失") */
|
||||
function normalizeGroup(v) {
|
||||
const s = squash(v);
|
||||
if (!s) return '';
|
||||
return TEACHER_GROUP_ALIAS[s] || TEACHER_GROUP_ALIAS[s.toLowerCase()] || '';
|
||||
}
|
||||
|
||||
/** 是否教师身份。归一化后精确命中白名单才算,⛔ 不做包含匹配(避免「非指导教师」被误判) */
|
||||
function isTeacherIdentity(v) {
|
||||
const s = squash(v);
|
||||
if (!s) return false;
|
||||
return TEACHER_IDENTITIES.indexOf(s) >= 0;
|
||||
}
|
||||
|
||||
/** 队伍编号归一:只 trim 首尾空白与零宽字符,⛔ 不折叠大小写(编号大小写不敏感会串组) */
|
||||
function normalizeTeamId(v) {
|
||||
if (v === null || v === undefined) return '';
|
||||
return String(v).replace(/[\u200b\u200c\u200d\ufeff]/g, '').replace(/^[\s\u3000\u00a0]+|[\s\u3000\u00a0]+$/g, '');
|
||||
}
|
||||
|
||||
/** 在给定行里按别名找列下标,返回 -1 表示未命中 */
|
||||
function findColumn(row, aliases) {
|
||||
if (!Array.isArray(row)) return -1;
|
||||
for (let i = 0; i < row.length; i++) {
|
||||
const cell = squash(row[i]);
|
||||
if (!cell) continue;
|
||||
for (const a of aliases) {
|
||||
if (cell === squash(a)) return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析报名表 AOA,提取「小组 → 指导教师姓名列表」。
|
||||
*
|
||||
* @param {Array<Array>} aoa 二维数组(xlsx sheet_to_json header:1 的产物)
|
||||
* @param {Object} [opts]
|
||||
* @param {number} [opts.maxHeaderScan=10] 表头最多向下扫描的行数
|
||||
* @returns {{
|
||||
* ok: boolean, code: string, message: string,
|
||||
* teams: Array<{group: string, teamId: string, names: string[], rowNumbers: number[]}>,
|
||||
* stats: Object, errors: Array<Object>
|
||||
* }}
|
||||
*/
|
||||
function extractTeachers(aoa, opts) {
|
||||
const maxHeaderScan = (opts && opts.maxHeaderScan) || 10;
|
||||
const empty = { teams: [], stats: { rows: 0, teachers: 0, notTeachers: 0, blank: 0 }, errors: [] };
|
||||
|
||||
if (!Array.isArray(aoa) || aoa.length === 0) {
|
||||
return Object.assign({ ok: false, code: 'EMPTY_FILE', message: '文件内容为空' }, empty);
|
||||
}
|
||||
|
||||
// ---- 1) 定位表头 ----
|
||||
let headerRowIdx = -1;
|
||||
let colName = -1;
|
||||
let colTeam = -1;
|
||||
let colGroup = -1;
|
||||
let colIdentity = -1;
|
||||
for (let i = 0; i < Math.min(aoa.length, maxHeaderScan); i++) {
|
||||
const r = aoa[i];
|
||||
const cn = findColumn(r, TEACHER_COLUMN_ALIAS.name);
|
||||
const ci = findColumn(r, TEACHER_COLUMN_ALIAS.identity);
|
||||
if (cn >= 0 && ci >= 0) {
|
||||
headerRowIdx = i;
|
||||
colName = cn;
|
||||
colIdentity = ci;
|
||||
colTeam = findColumn(r, TEACHER_COLUMN_ALIAS.teamId);
|
||||
colGroup = findColumn(r, TEACHER_COLUMN_ALIAS.group);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (headerRowIdx < 0) {
|
||||
return Object.assign({
|
||||
ok: false,
|
||||
code: 'HEADER_NOT_FOUND',
|
||||
message: '前 10 行内没有同时包含「姓名」与「身份」的表头,请确认上传的是报名表',
|
||||
}, empty);
|
||||
}
|
||||
if (colTeam < 0) {
|
||||
return Object.assign({
|
||||
ok: false,
|
||||
code: 'TEAM_COLUMN_MISSING',
|
||||
message: '报名表缺少「队伍编号」列,无法把指导教师归到具体小组',
|
||||
}, empty);
|
||||
}
|
||||
if (colGroup < 0) {
|
||||
return Object.assign({
|
||||
ok: false,
|
||||
code: 'GROUP_COLUMN_MISSING',
|
||||
message: '报名表缺少「组别」列,无法限定同组(team_uid 跨组重号,不能只按编号归并)',
|
||||
}, empty);
|
||||
}
|
||||
|
||||
// ---- 2) 逐行收集 ----
|
||||
const stats = { rows: 0, teachers: 0, notTeachers: 0, blank: 0 };
|
||||
const errors = [];
|
||||
// key = group + '\u0000' + teamId,用不可见分隔符避免与编号里的字符冲突
|
||||
const bucket = new Map();
|
||||
|
||||
for (let i = headerRowIdx + 1; i < aoa.length; i++) {
|
||||
const r = aoa[i] || [];
|
||||
stats.rows++;
|
||||
const rowNumber = i + 1; // 1 基,与 Excel 行号一致
|
||||
|
||||
const nameRaw = colName >= 0 ? r[colName] : '';
|
||||
const identityRaw = colIdentity >= 0 ? r[colIdentity] : '';
|
||||
const allBlank = r.every(c => squash(c) === '');
|
||||
if (allBlank) {
|
||||
stats.blank++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isTeacherIdentity(identityRaw)) {
|
||||
stats.notTeachers++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = String(nameRaw === null || nameRaw === undefined ? '' : nameRaw).trim();
|
||||
if (!name) {
|
||||
stats.teachers++;
|
||||
errors.push({ row: rowNumber, status: ROW_STATUS.TEACHER_NO_NAME, message: '身份为导师但姓名为空,已跳过' });
|
||||
continue;
|
||||
}
|
||||
const group = normalizeGroup(colGroup >= 0 ? r[colGroup] : '');
|
||||
if (!group) {
|
||||
stats.teachers++;
|
||||
errors.push({
|
||||
row: rowNumber,
|
||||
status: ROW_STATUS.TEACHER_NO_GROUP,
|
||||
message: `指导教师「${name}」所在行组别为空或无法识别,已跳过(缺少 group 无法安全归组)`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const teamId = normalizeTeamId(colTeam >= 0 ? r[colTeam] : '');
|
||||
if (!teamId) {
|
||||
stats.teachers++;
|
||||
errors.push({
|
||||
row: rowNumber,
|
||||
status: ROW_STATUS.TEACHER_NO_TEAM,
|
||||
message: `指导教师「${name}」所在行队伍编号为空,已跳过(无法定位到具体小组)`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
stats.teachers++;
|
||||
const key = `${group}\u0000${teamId}`;
|
||||
let item = bucket.get(key);
|
||||
if (!item) {
|
||||
item = { group, teamId, names: [], rowNumbers: [] };
|
||||
bucket.set(key, item);
|
||||
}
|
||||
if (item.names.indexOf(name) < 0) item.names.push(name);
|
||||
item.rowNumbers.push(rowNumber);
|
||||
}
|
||||
|
||||
const teams = Array.from(bucket.values());
|
||||
|
||||
if (teams.length === 0) {
|
||||
return Object.assign({
|
||||
ok: false,
|
||||
code: 'NO_TEACHER_FOUND',
|
||||
message: stats.teachers === 0
|
||||
? '报名表里没有身份为「指导教师」的行(请确认「身份」列的写法)'
|
||||
: '识别到指导教师行,但都缺少组别或队伍编号,无法归组',
|
||||
}, { stats, errors });
|
||||
}
|
||||
|
||||
return Object.assign({ ok: true, code: 'OK', message: `识别到 ${teams.length} 个小组的指导教师` }, { teams, stats, errors });
|
||||
}
|
||||
|
||||
/**
|
||||
* 把同一小组的多个指导教师姓名合成一个展示串。
|
||||
* ⛔ 去重保序:报名表里同一教师可能重复出现(同一人被登记两次),
|
||||
* 直接 join 会得到「张三、张三」。
|
||||
*/
|
||||
function joinNames(names) {
|
||||
if (!Array.isArray(names)) return '';
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const n of names) {
|
||||
const s = String(n === null || n === undefined ? '' : n).trim();
|
||||
if (!s || seen.has(s)) continue;
|
||||
seen.add(s);
|
||||
out.push(s);
|
||||
}
|
||||
return out.join('、');
|
||||
}
|
||||
|
||||
/** 1 基数组是报名表原始行号,直接透出(调用方拼提示用) */
|
||||
function formatRowNumbers(nums) {
|
||||
if (!Array.isArray(nums) || nums.length === 0) return '';
|
||||
return nums.join('、');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
TEACHER_IDENTITIES,
|
||||
TEACHER_GROUP_ALIAS,
|
||||
TEACHER_COLUMN_ALIAS,
|
||||
ROW_STATUS,
|
||||
squash,
|
||||
normalizeGroup,
|
||||
normalizeTeamId,
|
||||
isTeacherIdentity,
|
||||
findColumn,
|
||||
extractTeachers,
|
||||
joinNames,
|
||||
formatRowNumbers,
|
||||
};
|
||||
@@ -1,6 +1,8 @@
|
||||
'use strict';
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
// R178:管理端权限判定(单一真相源,见 lib/adminPermissions.js)
|
||||
const adminPermissions = require('../lib/adminPermissions');
|
||||
|
||||
/**
|
||||
* JWT 认证中间件
|
||||
@@ -81,6 +83,77 @@ module.exports = (options = {}, app) => {
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
// ==================== R178 多管理员 + 权限(RBAC) ====================
|
||||
//
|
||||
// 🔴 为什么挂在这里,而不是新写一个全局中间件:
|
||||
// Egg 的全局中间件早于 router 级中间件执行,此时 ctx.state.currentUser 还不存在;
|
||||
// 若改成「逐条路由追加 adminPermission」,63 条 /api/admin 路由每一条都要改一次
|
||||
// —— 漏一条就是后门,且新增路由默认无保护。
|
||||
// adminOnly 分支已经挂在**每一条**管理端路由上,是唯一能保证零遗漏的位置。
|
||||
//
|
||||
// 🔴 为什么每次请求都查库(而不是把权限塞进 JWT):
|
||||
// 权限进 JWT 后,改权限 / 禁用 / 重置口令在令牌过期前都不生效(本项目令牌 7 天)。
|
||||
// 禁用某人却要等 7 天,等于没有禁用。单行主键查询的成本可以忽略。
|
||||
//
|
||||
// 三道判定(顺序不可换,否则错误信息会互相掩盖):
|
||||
// 1) 账号仍存在 —— 被删除 ⇒ 401
|
||||
// 2) status 启用 + 令牌版本一致 —— 被禁用 ⇒ 401;改密/禁用后旧令牌 ⇒ 401
|
||||
// (⚠️ 两处都用 401 而非 403:账号停用/口令变更意味着「认证主体不再被接受」,
|
||||
// 前端应回登录页;403 会被误解成「权限不足」。)
|
||||
// 3) 路由已登记 + 权限命中 —— 未登记 ⇒ 403(默认拒绝,不是放行)
|
||||
const admin = await ctx.model.Admin.findOne({
|
||||
where: { id: ctx.state.currentUser.id },
|
||||
attributes: ['id', 'username', 'is_super', 'permissions', 'status', 'token_version'],
|
||||
raw: true,
|
||||
});
|
||||
if (!admin) {
|
||||
ctx.status = 401;
|
||||
ctx.body = { code: 401, message: '管理员账号不存在或已被删除,请重新登录' };
|
||||
return;
|
||||
}
|
||||
if (Number(admin.status) !== 1) {
|
||||
// 🔴 用 401 而不是 403:账号被停用意味着「认证主体不再被接受」,
|
||||
// 前端应把用户送回登录页。若返回 403,前端会理解为「权限不够」,
|
||||
// 提示「无权限执行此操作」,与实际原因(账号已停用)不符。
|
||||
ctx.status = 401;
|
||||
ctx.body = { code: 401, message: '该管理员账号已被禁用,请联系最高管理员' };
|
||||
return;
|
||||
}
|
||||
// 令牌版本:改密 / 禁用 / 改权限时由服务层自增,旧令牌立即失效。
|
||||
// 存量令牌无 tv 字段 ⇒ 视作 0,与初值一致,避免升级瞬间把在线的管理员全部踢下线。
|
||||
const tokenVersion = Number(decoded.tv || 0);
|
||||
if (Number(admin.token_version) !== tokenVersion) {
|
||||
ctx.status = 401;
|
||||
ctx.body = { code: 401, message: '登录状态已失效(口令或权限已变更),请重新登录' };
|
||||
return;
|
||||
}
|
||||
|
||||
admin.permissions = adminPermissions.parsePermissions(admin.permissions);
|
||||
ctx.state.currentAdmin = admin;
|
||||
|
||||
const required = adminPermissions.resolvePermission(ctx.method, ctx.path);
|
||||
if (required === null) {
|
||||
// 未登记 = 配置缺陷。默认拒绝并**大声记日志**,否则新路由会静默变成
|
||||
// 「任何管理员都能调」的后门,而要等到出事才会被发现。
|
||||
ctx.logger.error(
|
||||
`[RBAC] 未登记权限策略的管理端路由: ${ctx.method} ${ctx.path} —— 已按默认拒绝处理,请补 lib/adminPermissions.js 的 ROUTE_TABLE`
|
||||
);
|
||||
ctx.status = 403;
|
||||
ctx.body = {
|
||||
code: 403,
|
||||
message: '该接口未登记权限策略,已拒绝访问(请联系系统管理员)',
|
||||
};
|
||||
return;
|
||||
}
|
||||
if (!adminPermissions.canAccess(admin, required)) {
|
||||
ctx.logger.warn(
|
||||
`[RBAC] 拒绝访问 admin=${admin.username}(${admin.id}) ${ctx.method} ${ctx.path} 需要=${required.join('|')}`
|
||||
);
|
||||
ctx.status = 403;
|
||||
ctx.body = { code: 403, message: '无权限执行此操作,请联系最高管理员分配权限' };
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 继续执行后续中间件和控制器
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
module.exports = app => {
|
||||
const { STRING, INTEGER, DATE } = app.Sequelize;
|
||||
const { STRING, INTEGER, DATE, TEXT, TINYINT } = app.Sequelize;
|
||||
|
||||
const Admin = app.model.define('admin', {
|
||||
// 主键 ID,自增
|
||||
@@ -30,6 +30,51 @@ module.exports = app => {
|
||||
allowNull: false,
|
||||
comment: '管理员密码',
|
||||
},
|
||||
// 🔴 R178 最高管理员标记:唯一、不可改权限 / 不可禁用 / 不可删除 / 不可重置口令。
|
||||
// 判据只认本字段(不认 username,避免改名绕过;也不认 id,避免误伤后续自增行)。
|
||||
is_super: {
|
||||
type: TINYINT,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
comment: '最高管理员:1=不可变动(唯一)',
|
||||
},
|
||||
// 权限点 JSON 数组(见 lib/adminPermissions.js 的 PERMISSIONS)。
|
||||
// NULL / 空数组 = 无任何业务权限;is_super=1 的账号忽略本列(恒全权)。
|
||||
permissions: {
|
||||
type: TEXT,
|
||||
allowNull: true,
|
||||
comment: '权限点 JSON 数组',
|
||||
},
|
||||
// 1=启用 0=禁用。禁用后:禁止登录,且已签发的令牌在下一次请求即被拒。
|
||||
status: {
|
||||
type: TINYINT,
|
||||
allowNull: false,
|
||||
defaultValue: 1,
|
||||
comment: '1=启用 0=禁用',
|
||||
},
|
||||
// 令牌版本:改密 / 禁用 / 改权限时自增 ⇒ 旧 JWT 中的 tv 与之不匹配即失效。
|
||||
// 这是「改密后旧会话立即下线」的唯一判据(本项目无会话表)。
|
||||
token_version: {
|
||||
type: INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
comment: '令牌版本(改密/禁用时自增,使旧 JWT 失效)',
|
||||
},
|
||||
last_login_at: {
|
||||
type: DATE,
|
||||
allowNull: true,
|
||||
comment: '最近登录时间',
|
||||
},
|
||||
last_login_ip: {
|
||||
type: STRING(64),
|
||||
allowNull: true,
|
||||
comment: '最近登录 IP',
|
||||
},
|
||||
remark: {
|
||||
type: STRING(200),
|
||||
allowNull: true,
|
||||
comment: '备注(姓名 / 岗位)',
|
||||
},
|
||||
// 记录创建时间
|
||||
created_at: {
|
||||
type: DATE,
|
||||
|
||||
72
app/model/adminAuditLog.js
Normal file
72
app/model/adminAuditLog.js
Normal file
@@ -0,0 +1,72 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 管理员高危操作审计日志 - AdminAuditLog
|
||||
*
|
||||
* 只记录**高危写操作**(不是全量写操作):
|
||||
* 发布/撤回成绩、重置测试数据、删除选手、撤回交卷、
|
||||
* 管理员账号的增 / 改权限 / 重置口令 / 禁用 / 删除。
|
||||
*
|
||||
* 为什么不做全量审计:本项目日志无集中采集,全量写接口约 30 个,
|
||||
* 日志量增长快且缺少清理策略;而「多管理员下谁改了成绩 / 谁动了账号」
|
||||
* 才是真正需要回答的问题。故按「能改变比赛结果或权限边界的动作」划定范围。
|
||||
*
|
||||
* ⚠️ 写入必须 best-effort:审计失败不得阻断业务(否则日志表故障会连累发布成绩)。
|
||||
* 统一走 service.admin.recordAudit(),⛔ 不要在控制器里直接 create。
|
||||
*/
|
||||
|
||||
module.exports = app => {
|
||||
const { STRING, INTEGER, DATE, TEXT, BIGINT } = app.Sequelize;
|
||||
|
||||
const AdminAuditLog = app.model.define('adminAuditLog', {
|
||||
id: {
|
||||
type: BIGINT,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
comment: '日志唯一标识',
|
||||
},
|
||||
admin_id: {
|
||||
type: INTEGER,
|
||||
allowNull: false,
|
||||
comment: '操作人管理员 ID',
|
||||
},
|
||||
// 冗余存用户名:账号被删除后仍能回答「当时是谁操作的」
|
||||
admin_username: {
|
||||
type: STRING(50),
|
||||
allowNull: false,
|
||||
comment: '操作人用户名(冗余,防账号删除后不可追溯)',
|
||||
},
|
||||
action: {
|
||||
type: STRING(64),
|
||||
allowNull: false,
|
||||
comment: '动作标识,如 score.publish / admin.create',
|
||||
},
|
||||
target: {
|
||||
type: STRING(200),
|
||||
allowNull: true,
|
||||
comment: '操作对象(组别 / 账号名 / 选手 ID)',
|
||||
},
|
||||
detail: {
|
||||
type: TEXT,
|
||||
allowNull: true,
|
||||
comment: '补充信息(JSON 文本)',
|
||||
},
|
||||
ip: {
|
||||
type: STRING(64),
|
||||
allowNull: true,
|
||||
comment: '来源 IP',
|
||||
},
|
||||
created_at: {
|
||||
type: DATE,
|
||||
allowNull: false,
|
||||
defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
|
||||
comment: '发生时间',
|
||||
},
|
||||
}, {
|
||||
tableName: 'admin_audit_logs',
|
||||
timestamps: false,
|
||||
underscored: true,
|
||||
});
|
||||
|
||||
return AdminAuditLog;
|
||||
};
|
||||
@@ -133,9 +133,13 @@ module.exports = app => {
|
||||
|
||||
// 关联定义
|
||||
Aggregation.associate = function() {
|
||||
// ⚠️ R176:CompetitionInfo 的运行库结构是 key-value(主键 id),
|
||||
// 不是原来的三列结构 ⇒ targetKey 必须是 `id`,否则 Sequelize 解析
|
||||
// `competition_uid` 这个属性时会找不到定义。
|
||||
// `aggregation.competition_uid` 存的是数字(实测恒为 1),对应 competition_info.id。
|
||||
app.model.Aggregation.belongsTo(app.model.CompetitionInfo, {
|
||||
foreignKey: 'competition_uid',
|
||||
targetKey: 'competition_uid',
|
||||
targetKey: 'id',
|
||||
as: 'competition',
|
||||
});
|
||||
app.model.Aggregation.belongsTo(app.model.Team, {
|
||||
|
||||
@@ -3,56 +3,66 @@
|
||||
/**
|
||||
* 竞赛信息模型 - CompetitionInfo
|
||||
*
|
||||
* 存储竞赛的基本信息,包括竞赛名称和代码。
|
||||
* 表名:competition_info
|
||||
*
|
||||
* 🔴 2026-10-19(R176)修正:原模型声明的是三列结构
|
||||
* (`competition_uid` / `competition_name` / `competition_code`),
|
||||
* 但**运行库实际是 key-value 结构**:
|
||||
* `id` / `info_key` / `info_value` / `created_at` / `updated_at`(实测 SHOW CREATE TABLE)
|
||||
* 模型与表不一致的后果:
|
||||
* ① `findOne()` 默认按模型声明选列 ⇒ `Unknown column 'competition_uid'` 直接 500;
|
||||
* ② `create()` 会带上 `competition_name`(allowNull:false)⇒ 必然失败。
|
||||
* 旧代码之所以没暴露,是因为唯一的使用点(导入时取 competition_uid)
|
||||
* 包在 try/catch 里,失败后静默回落到默认值 1 —— 而 `info_key='competition_uid'`
|
||||
* 的值恰好也是 1,所以"看起来一直是对的"。
|
||||
*
|
||||
* ⚠️ 因此本表**不能按列访问**,必须走 `info_key` 取值。
|
||||
* 公示表要用的两个键是 `competition_item_name` / `competition_item_code`。
|
||||
*/
|
||||
|
||||
module.exports = app => {
|
||||
const { STRING, INTEGER, DATE } = app.Sequelize;
|
||||
const { STRING, INTEGER, TEXT, DATE } = app.Sequelize;
|
||||
|
||||
const CompetitionInfo = app.model.define('competition_info', {
|
||||
// 主键,自增
|
||||
competition_uid: {
|
||||
// 主键,自增(运行库真实主键是 id,不是 competition_uid)
|
||||
id: {
|
||||
type: INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
comment: '竞赛唯一标识',
|
||||
comment: '自增主键',
|
||||
},
|
||||
// 竞赛名称
|
||||
competition_name: {
|
||||
type: STRING(200),
|
||||
allowNull: false,
|
||||
comment: '竞赛名称',
|
||||
},
|
||||
// 竞赛代码(唯一标识码)
|
||||
competition_code: {
|
||||
// 配置键名(如 competition_name / competition_code / competition_item_name)
|
||||
// ⚠️ 运行库**没有**给 info_key 建唯一索引 ⇒ 写侧必须"先查再写",不能依赖 upsert
|
||||
info_key: {
|
||||
type: STRING(50),
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
comment: '竞赛代码',
|
||||
comment: '配置键',
|
||||
},
|
||||
// 配置值(统一按字符串存,数字也以文本形式落库)
|
||||
info_value: {
|
||||
type: TEXT,
|
||||
allowNull: true,
|
||||
comment: '配置值',
|
||||
},
|
||||
// 记录创建时间
|
||||
created_at: {
|
||||
type: DATE,
|
||||
allowNull: false,
|
||||
defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
|
||||
allowNull: true,
|
||||
comment: '创建时间',
|
||||
},
|
||||
// 记录更新时间
|
||||
updated_at: {
|
||||
type: DATE,
|
||||
allowNull: false,
|
||||
defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
|
||||
allowNull: true,
|
||||
comment: '更新时间',
|
||||
},
|
||||
}, {
|
||||
tableName: 'competition_info',
|
||||
timestamps: false,
|
||||
underscored: true,
|
||||
// 索引仅作 Sequelize 元数据声明(sync 时用);⛔ 本项目的表由 SQL 手工维护,
|
||||
// 不要指望这里加 unique 会同步到运行库
|
||||
indexes: [
|
||||
{
|
||||
unique: true,
|
||||
fields: ['competition_code'],
|
||||
fields: ['info_key'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -74,6 +74,16 @@ module.exports = app => {
|
||||
word_score: decimalScore('word_score', 'Word题/综合大题得分(可含 2 位小数)'),
|
||||
// PPT分数(本科/教师组管理员手动打分)
|
||||
ppt_score: decimalScore('ppt_score', 'PPT得分(本科/教师组,可含 2 位小数)'),
|
||||
// 指导教师姓名(R176)。来源 = 报名表里 identity 为「指导教师」的行,
|
||||
// 按 (group, 队伍编号) 归到同组选手身上;同组多人用「、」连接后冗余存一份。
|
||||
// ⚠️ 语义上是**组级属性**,冗余到每位组员是为了让成绩公示表能直接按行取用,
|
||||
// 不必每次再回查 aggregation。回填入口见 service/admin.js#backfillTeachers。
|
||||
teacher_name: {
|
||||
type: STRING(200),
|
||||
allowNull: true,
|
||||
defaultValue: null,
|
||||
comment: '指导教师姓名(同组多人用、分隔)',
|
||||
},
|
||||
// 所属团队 ID(外键关联 teams 表)
|
||||
team_id: {
|
||||
type: INTEGER,
|
||||
|
||||
@@ -79,6 +79,30 @@ module.exports = app => {
|
||||
defaultValue: null,
|
||||
comment: '标签,多个标签用逗号分隔',
|
||||
},
|
||||
// 草稿标记(R176)。成绩发布时会自动生成一条「成绩公示」预览新闻,
|
||||
// 需人工编辑确认后才可对外发布 ⇒ 草稿期间必须前台不可见。
|
||||
// 🔴 news.list / findById 的公开查询必须显式过滤 is_draft=0(默认值 0 保证历史 15 条不受影响)。
|
||||
is_draft: {
|
||||
type: INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
comment: '草稿标记:0=已发布, 1=草稿(前台不可见)',
|
||||
},
|
||||
// 附件(R176)。用于成绩公示表的文件公示:管理员上传 xlsx/pdf 后,
|
||||
// 详情页渲染成独立下载按钮,不依赖正文富文本里手写链接
|
||||
// (手写链接在人工编辑正文时极易被误删)。
|
||||
attachment_url: {
|
||||
type: STRING(500),
|
||||
allowNull: true,
|
||||
defaultValue: null,
|
||||
comment: '附件下载地址(相对路径 /public/news/xxx)',
|
||||
},
|
||||
attachment_name: {
|
||||
type: STRING(200),
|
||||
allowNull: true,
|
||||
defaultValue: null,
|
||||
comment: '附件显示文件名(下载按钮文案)',
|
||||
},
|
||||
// 记录创建时间
|
||||
created_at: {
|
||||
type: DATE,
|
||||
|
||||
@@ -110,6 +110,19 @@ module.exports = app => {
|
||||
|
||||
// ==================== 管理后台(需管理员认证) ====================
|
||||
|
||||
// ---------- R178 多管理员 + 权限 ----------
|
||||
// 这些路由与下面所有 /api/admin/* 一样只挂 adminAuth:
|
||||
// 权限判定在 middleware/auth.js 的 adminOnly 分支内完成(单一真相源
|
||||
// lib/adminPermissions.js 的 ROUTE_TABLE,未登记路由默认拒绝)。
|
||||
// ⛔ 不要在这里逐条写权限参数——那会与映射表形成第二套真相源。
|
||||
router.get('/api/admin/me', adminAuth, controller.adminUser.me);
|
||||
router.get('/api/admin/admin-users', adminAuth, controller.adminUser.list);
|
||||
router.post('/api/admin/admin-users', adminAuth, controller.adminUser.create);
|
||||
router.put('/api/admin/admin-users/:id/permissions', adminAuth, controller.adminUser.updatePermissions);
|
||||
router.put('/api/admin/admin-users/:id/password', adminAuth, controller.adminUser.resetPassword);
|
||||
router.put('/api/admin/admin-users/:id/status', adminAuth, controller.adminUser.setStatus);
|
||||
router.delete('/api/admin/admin-users/:id', adminAuth, controller.adminUser.remove);
|
||||
|
||||
// 数据统计
|
||||
router.get('/api/admin/stats', adminAuth, controller.admin.stats);
|
||||
|
||||
@@ -119,6 +132,12 @@ module.exports = app => {
|
||||
// ⚠️ 路径段固定、无通配,与既有路由无匹配冲突。
|
||||
router.get('/api/admin/teams', adminAuth, controller.admin.teams);
|
||||
router.post('/api/admin/import-excel', adminAuth, controller.admin.importExcel);
|
||||
// 指导教师回填(R176):报名表 → contestants.teacher_name。
|
||||
// 🔴 与 import-excel 是**两条不同通路**:导入对已存在身份证号一律 skip,
|
||||
// 无法用于"重导一次补字段";本接口只写 teacher_name,不碰账号/队伍/成绩。
|
||||
// ⚠️ 路径段固定(backfill-teacher 为字面量),且与下方 PUT /:id 方法不同,无匹配冲突。
|
||||
// 破坏面定级:非破坏性(白名单单列写入、按组限定、可重复执行且幂等)
|
||||
router.post('/api/admin/contestants/backfill-teacher', adminAuth, controller.admin.backfillTeachers);
|
||||
// 选手信息编辑(R108):让「导入之后就改不了」的历史限制成为过去 —— 身份证号 / 姓名 /
|
||||
// 手机号可事后纠正(典型场景:报名表证号录入错误,人工核实后修正)。
|
||||
// ⚠️ 路径段固定(/contestants/:id),与既有路由无匹配冲突;PUT 方法在 admin 段内唯一。
|
||||
@@ -132,6 +151,14 @@ module.exports = app => {
|
||||
router.get('/api/admin/export-csv/:group', adminAuth, controller.admin.exportCsv);
|
||||
// R29:成绩导出改为真正的 Excel(.xlsx)(与 CSV 共用同一数据源 buildScoreExportTable)
|
||||
router.get('/api/admin/export-excel/:group', adminAuth, controller.admin.exportExcel);
|
||||
// 成绩公示表导出(R176):按大赛参考模板生成 xlsx 半成品(获奖等级留空待人工填)。
|
||||
// ⚠️ 与 export-excel 是两个不同产物:export-excel 是**内部核查表**(含身份证号/手机号),
|
||||
// 本接口是**对外公示表**(不含任何身份证号/手机号),⛔ 两者不可互相替代。
|
||||
router.get('/api/admin/export-publicity/:group', adminAuth, controller.admin.exportPublicity);
|
||||
// 赛项信息(R176):成绩公示表标题的「赛项名称 / 赛项编号」,管理后台可维护。
|
||||
// ⚠️ 两个值只影响公示表标题文案,与成绩/账号/队伍无关。
|
||||
router.get('/api/admin/competition-item', adminAuth, controller.admin.competitionItemInfo);
|
||||
router.put('/api/admin/competition-item', adminAuth, controller.admin.updateCompetitionItemInfo);
|
||||
router.get('/api/admin/batch-download/:group', adminAuth, controller.admin.batchDownload);
|
||||
router.get('/api/admin/works/:group', adminAuth, controller.admin.works);
|
||||
|
||||
@@ -206,6 +233,9 @@ module.exports = app => {
|
||||
// 图片上传(轮播图等)
|
||||
router.post('/api/admin/upload/image', adminAuth, controller.admin.uploadImage);
|
||||
router.post('/api/admin/upload/news-image', adminAuth, controller.admin.uploadNewsImage);
|
||||
// 新闻附件上传(R176):成绩公示表以附件形式挂在新闻里,而不是把链接写进正文
|
||||
// (正文会被人工作编辑,手写链接极易被删)。与 news-image 同目录、同访问前缀。
|
||||
router.post('/api/admin/upload/news-file', adminAuth, controller.admin.uploadNewsFile);
|
||||
|
||||
// 下载考生答案 Word 文件
|
||||
router.get('/api/admin/submissions/:group/:id/word', adminAuth, controller.admin.downloadWord);
|
||||
|
||||
@@ -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