- 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)
285 lines
13 KiB
JavaScript
285 lines
13 KiB
JavaScript
'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,
|
||
};
|