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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
// 继续执行后续中间件和控制器
|
||||
|
||||
Reference in New Issue
Block a user