Files
competition-website/app/lib/adminPermissions.js

287 lines
14 KiB
JavaScript
Raw Permalink Normal View History

'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']],
feat(upload): 组内作品唯一 + 小组编号目录 + 按小组打包下载(R190) 需求(管理人员反馈): ① 本科组/教师组同样遵循「小组内只要有一人上传作品,其他人就不能再上传」; ② 作品按「小组编号」建目录存放并保留原始文件名,管理端下载时按小组打包。 后端落地: - 组锁:新增 getTeamUploadState/assertTeamUploadAllowed,按 (group, teamUid) 取全组 提交记录,**最早提交者为归属人**;其他人上传与分片合并两条路径都返 6006 (data 带 teamLocked/teamOwnerName/teamNumber/folderName)。删除本人作品后自动解锁。 ⛔ 必须先按 group 过滤再认 teamUid(team_uid 仅组内唯一)。 - 落盘:works/<group>/<小组编号>/<原始文件名>。目录名走新增 lib/team_folder.js (唯一 SoT:净化 Windows 非法字符/控制字符/首尾点/设备保留名,限长 60)。 撞名三态:不存在→原名;同一聚合+同一 file_type→原名原子覆盖;否则 (2)(3)。 DB file_path 仍为相对路径 ⇒ 存量平铺文件免迁移。 - 直传与分片合并收敛到同一套 prepareWorkTarget + commitWorkFile:先写同目录临时名 (.upload_*/.merge_*),再 renameSync;失败一律 safeCleanup 后抛。 - status 接口新增 teamLocked/canUpload/teamOwnerName/isTeamOwner/teamFiles/memberCount。 - 管理端:works 列表返回 teamUid/teamNumber/teamFolder(走 resolveTeamLabels 既有口径); 新增 GET /api/admin/team-zip/:group?teamUid= 单组打包(仅本科/教师,其余 400,包内平铺); batch-download 包内改为 <小组编号>/<原始文件名>。两者共用 _buildZip。 验证:真接口 45/45 + 变异 M1(locked=false ⇒ 15 条断言红,还原后复绿); 已收编为门禁第 58 步。
2026-10-21 15:20:42 +08:00
// R190 单小组作品压缩包:与批量下载同属「作品管理」能力,同权限即可
['GET', '/api/admin/team-zip/: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,
};