feat(admin): R176-R180 公示表与新闻撤回 + 管理端多管理员权限(C 层)+ 交卷冻结

- R176 成绩公示表引擎(lib/publicitySheet.js + lib/assets/publicity-template.xlsx 官方模板基底)
  与指导教师回填(lib/teacherBackfill.js);赛项名称/编号改为配置项
- R177 新闻「发布 ↔ 撤回」互斥:撤回只置 is_draft=1,不删除数据
- R178 C 层 RBAC:lib/adminPermissions.js(13 权限点 / 70 条路由规则,唯一 SoT)
  + middleware/auth.js 三段判定(默认拒绝 · is_super 单点 · token_version)
  + controller/service/adminUser.js + lib/auditLog.js + model/adminAuditLog.js
- R178 B1 交卷冻结:service.exam.assertNotSubmitted(bizCode 4006 单一判据源)
- R178 B④ 提交口径拆分:formalSubmitted / submitted / answeredCount 三字段
- R178 成绩总览补小组编号:resolveTeamLabels(与选手管理同源,含兜底与三态)
- R181 生产迁移配套:admins +7 列、admin_audit_logs(scripts/migrate_r178_admin_rbac.py)
This commit is contained in:
2026-10-20 11:06:29 +08:00
parent 5392345078
commit 459f1adf6d
21 changed files with 2886 additions and 76 deletions

View File

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