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, '提交答案失败');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user