2026-10-20 11:06:29 +08:00
|
|
|
|
'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']],
|
2026-10-21 15:20:42 +08:00
|
|
|
|
// R190 单小组作品压缩包:与批量下载同属「作品管理」能力,同权限即可
|
|
|
|
|
|
['GET', '/api/admin/team-zip/:group', ['submissions']],
|
2026-10-20 11:06:29 +08:00
|
|
|
|
|
|
|
|
|
|
// ---------- 成绩管理(阅卷/导出/公示表) ----------
|
|
|
|
|
|
['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,
|
|
|
|
|
|
};
|