Files
competition-website/app/lib/adminPermissions.js
qubiaobiao 0a380371ec 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

287 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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']],
// 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,
};