diff --git a/app/controller/admin.js b/app/controller/admin.js index 7719b20..fc1c154 100644 --- a/app/controller/admin.js +++ b/app/controller/admin.js @@ -10,6 +10,8 @@ const { sanitizeUploadFileName } = require('../utils/filename'); const { acquire: acquireFileIo, release: releaseFileIo } = require('../lib/file_io_guard'); // 🔴 同源 collapse(缺口9 修复):所有文件发送通道统一走 sendFile util const { sendFile } = require('../utils/sendFile'); +// R178 高危操作审计(best-effort,不阻断业务) +const { recordAudit, ACTIONS: AUDIT_ACTIONS } = require('../lib/auditLog'); // 🔴 R106-D7(2026-10-12):测试数据默认口令改走唯一入口 —— 生产未显式注入 // TEST_DEFAULT_PASSWORD 时**明确失败**,⛔ 不再静默批量设置内置弱口令 '123456'。 const { requireTestPassword } = require('../lib/passwordPolicy'); @@ -231,6 +233,172 @@ class AdminController extends Controller { } } + /** + * 指导教师回填(R176) + * POST /api/admin/contestants/backfill-teacher + * + * multipart:file(报名表 xlsx)+ group(可选,限定组别)+ overwrite(可选,默认 1) + * + * 🔴 内存解析,**不落盘**:报名表含身份证号,而 `app/public` 对静态服务开放 + * (题目导入曾落 `app/public/tmp/excel`),一旦落盘即可被猜名下载。 + * 🔴 只写 contestants.teacher_name,不新建账号、不改队伍、不碰成绩。 + */ + async backfillTeachers() { + const { ctx, service } = this; + if (!this._ensureAdmin()) return; + + const MAX_BYTES = 5 * 1024 * 1024; + + try { + const stream = await ctx.getFileStream(); + if (!stream || !stream.filename) { + ctx.body = ctx.helper.error(400, '请选择报名表文件'); + return; + } + + const ext = path.extname(stream.filename).toLowerCase(); + if (!['.xlsx', '.xls'].includes(ext)) { + ctx.body = ctx.helper.error(400, '仅支持 .xlsx / .xls 格式的报名表'); + return; + } + + const chunks = []; + let size = 0; + for await (const chunk of stream) { + size += chunk.length; + if (size > MAX_BYTES) { + ctx.body = ctx.helper.error(400, '文件超过 5MB,请确认上传的是报名表而不是全量明细'); + return; + } + chunks.push(chunk); + } + if (size === 0) { + ctx.body = ctx.helper.error(400, '文件内容为空'); + return; + } + + const fields = stream.fields || {}; + const group = fields.group ? String(fields.group) : ''; + // 只有显式传 '0'/'false' 才关闭覆盖;缺省 = 覆盖("重导一次"的语义就是取最新) + const overwrite = !(fields.overwrite === '0' || fields.overwrite === 'false'); + + const result = await service.admin.backfillTeachers(Buffer.concat(chunks), { group, overwrite }); + if (!result.ok) { + // 业务性失败(文件不是报名表 / 缺列 / 找不到教师)⇒ code=400 + 纯人话原因。 + // ⚠️ helper.error 只接受 (code, message),要带明细必须显式附加(否则第三参数被静默丢弃) + ctx.body = Object.assign(ctx.helper.error(400, result.message), { data: result }); + return; + } + ctx.body = ctx.helper.success(result, result.message); + } catch (err) { + // 同上:multipart 白名单拦截时 err.status=400,不要包成 500 + if (err && err.status === 400) { + ctx.body = ctx.helper.error(400, '文件格式不被允许:报名表请上传 .xlsx / .xls'); + return; + } + ctx.logger.error('指导教师回填失败:', err); + ctx.body = ctx.helper.error(500, (err && err.message) ? err.message : '指导教师回填失败'); + } + } + + /** + * 成绩公示表导出(R176) + * GET /api/admin/export-publicity/:group + * + * 产物 = 大赛参考模板的 xlsx 半成品:数据行已填,获奖等级留空待人工填。 + * ⛔ 与 exportExcel 用途不同:那个是内部核查表(含身份证号/手机号), + * 这个不含任何身份证号 / 手机号,可以直接对外公示。 + */ + async exportPublicity() { + const { ctx, service } = this; + if (!this._ensureAdmin()) return; + + try { + const { group } = ctx.params; + if (!this._validateGroup(group, true)) return; + + // `?meta=1` 只回统计、不下载:前端用它在点「下载」前提示 + // 「本组 N 人无小组归属」「标题赛项信息为空」这类需要管理员知情的事实。 + // ⚠️ 不能靠自定义响应头带这些字段 —— 生产经 X-Accel 时上游自定义头会被丢弃。 + const built = await service.admin.buildPublicityXlsx(group); + + if (String(ctx.query.meta || '') === '1') { + ctx.body = ctx.helper.success({ + fileName: built.fileName, + rowCount: built.rowCount, + testCount: built.testCount, + noTeamCount: built.noTeamCount, + totalContestants: built.totalContestants, + itemName: built.itemName, + itemCode: built.itemCode, + }); + return; + } + + ctx.set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + // 中文文件名:content-disposition 会输出 filename*=UTF-8''… 形式, + // nginx 的 X-Accel 只丢弃自定义头、不丢弃 Content-Disposition(实测) + ctx.attachment(built.fileName); + ctx.body = built.buffer; + } catch (err) { + ctx.logger.error('导出成绩公示表失败:', err); + ctx.body = ctx.helper.error(500, (err && err.message) ? err.message : '导出成绩公示表失败'); + } + } + + // ==================== 赛项信息配置(R176) ==================== + + /** + * 读取赛项信息 + * GET /api/admin/competition-item + * + * 成绩公示表标题的第 2、3 行要用(赛项名称 / 赛项编号)。 + * 用户裁定:不做硬编码,改为两项配置由管理人员在后台填写。 + */ + async competitionItemInfo() { + const { ctx, service } = this; + if (!this._ensureAdmin()) return; + try { + const data = await service.admin.getCompetitionItemInfo(); + ctx.body = ctx.helper.success(data); + } catch (err) { + ctx.logger.error('读取赛项信息失败:', err); + ctx.body = ctx.helper.error(500, '读取赛项信息失败'); + } + } + + /** + * 保存赛项信息 + * PUT /api/admin/competition-item + * body: { itemName?, itemCode? } + * + * ⚠️ 破坏面:覆盖 competition_info 里两个键的值。只会影响公示表标题文案, + * 不影响成绩、账号、队伍;改错重填即可。 + */ + async updateCompetitionItemInfo() { + const { ctx, service } = this; + if (!this._ensureAdmin()) return; + try { + const body = ctx.request.body || {}; + // 长度兜底:两个值都会写进 xlsx 标题单元格,过长会把标题撑爆 + for (const [k, limit] of [['itemName', 120], ['itemCode', 60]]) { + const v = body[k]; + if (v !== undefined && v !== null && String(v).length > limit) { + ctx.body = ctx.helper.error(400, `${k === 'itemName' ? '赛项名称' : '赛项编号'}不能超过 ${limit} 字`); + return; + } + } + const saved = await service.admin.setCompetitionItemInfo({ + itemName: body.itemName, + itemCode: body.itemCode, + }); + ctx.body = ctx.helper.success(saved, '赛项信息已保存'); + } catch (err) { + ctx.logger.error('保存赛项信息失败:', err); + ctx.body = ctx.helper.error(500, '保存赛项信息失败'); + } + } + // ==================== 提交记录管理 ==================== /** @@ -859,6 +1027,15 @@ class AdminController extends Controller { await service.admin.publishScores(publish, group); + // R178 审计:发布/撤销成绩会直接改变「考生能不能看到分数」, + // 且发布还会自动生成公示草稿 —— 属于必须能追溯到人的动作。 + await recordAudit( + ctx, + publish ? AUDIT_ACTIONS.SCORE_PUBLISH : AUDIT_ACTIONS.SCORE_UNPUBLISH, + group || 'all', + { group: group || null, scope: group ? 'single' : 'all' } + ); + const scopeLabel = group ? '该组成绩' : '全部组成绩'; ctx.body = ctx.helper.success( { published: publish, group: group || null }, @@ -1210,6 +1387,93 @@ class AdminController extends Controller { } } + /** + * 上传新闻附件(R176) + * POST /api/admin/upload/news-file + * + * 用户裁定:成绩公示改为「新闻附件」形态,所以新闻要支持文件下载, + * 而不是把下载链接手写进正文(正文一旦人工编辑,链接极易被删掉)。 + * + * 与 uploadNewsImage 复用同一目录(`app/public/news`)与同一访问路径前缀, + * 区别只在扩展名白名单与大小上限。 + * + * @returns {{fileUrl: string, fileName: string, size: number, filename: string}} + */ + async uploadNewsFile() { + const { ctx } = this; + if (!this._ensureAdmin()) return; + + const ALLOWED_EXT = ['.xlsx', '.xls', '.pdf', '.docx', '.doc', '.zip']; + const MAX_BYTES = 20 * 1024 * 1024; + + try { + const stream = await ctx.getFileStream(); + if (!stream || !stream.filename) { + ctx.body = ctx.helper.error(400, '请选择要上传的附件'); + return; + } + + const ext = path.extname(stream.filename).toLowerCase(); + if (ALLOWED_EXT.indexOf(ext) < 0) { + ctx.body = ctx.helper.error(400, `仅支持 ${ALLOWED_EXT.join(' / ')} 格式的附件`); + return; + } + + const chunks = []; + let size = 0; + for await (const chunk of stream) { + size += chunk.length; + if (size > MAX_BYTES) { + ctx.body = ctx.helper.error(400, '附件超过 20MB,请压缩后再上传'); + return; + } + chunks.push(chunk); + } + if (size === 0) { + ctx.body = ctx.helper.error(400, '附件内容为空'); + return; + } + + const fs = require('fs'); + const uploadDir = path.join(this.app.baseDir, 'app/public/news'); + if (!fs.existsSync(uploadDir)) { + fs.mkdirSync(uploadDir, { recursive: true }); + } + + // 存盘文件名与展示名分离:存盘用时间戳(避免覆盖与路径穿越), + // 展示名保留原始中文名,写进 news.attachment_name 供下载按钮显示。 + const storedName = `news_file_${Date.now()}${ext}`; + fs.writeFileSync(path.join(uploadDir, storedName), Buffer.concat(chunks)); + + // 🔴 multipart 的 filename 常按 latin1 解出(中文变乱码)。 + // 启发式:按 latin1 取回字节后若能完整 UTF-8 解码且含非 ASCII,就用解码结果。 + let displayName = String(stream.filename); + const asUtf8 = Buffer.from(displayName, 'latin1').toString('utf8'); + if (asUtf8.indexOf('\uFFFD') < 0 && /[^\x00-\x7F]/.test(asUtf8)) { + displayName = asUtf8; + } + // 去掉可能带进来的路径部分(Windows 上传会带 C:\fakepath\) + displayName = displayName.replace(/^.*[\\/]/, '').slice(0, 200); + + ctx.body = ctx.helper.success({ + fileUrl: `/public/news/${storedName}`, + fileName: displayName, + size, + filename: storedName, + }, '附件上传成功'); + } catch (err) { + // 🔴 multipart 层(egg-multipart 的 whitelist)会先于本方法抛 400, + // 统一转成人话;否则前端看到的是一句「上传新闻附件失败」+ 500, + // 管理员无从知道是「格式不支持」。 + if (err && err.status === 400) { + ctx.body = ctx.helper.error(400, `文件格式不被允许,仅支持 ${ALLOWED_EXT.join(' / ')}`); + return; + } + ctx.logger.error('上传新闻附件失败:', err); + ctx.body = ctx.helper.error(500, '上传新闻附件失败'); + } + } + /** * 下载考生答案 Word 文件 * GET /api/admin/submissions/:group/:id/word @@ -1849,6 +2113,16 @@ class AdminController extends Controller { results.jiaoshiSubmissionsDeleted = jiaoshiDeleted; ctx.logger.info('[测试数据重置]', results); + + // R178 审计:这是本系统唯一的「真实批量删除」入口(并会连带复位发布态), + // 必须记下是谁、删了多少行 —— 事后追查「数据怎么没了」只能靠这一条。 + await recordAudit(ctx, AUDIT_ACTIONS.DATA_RESET_TEST, 'test-data', { + contestants: contestantCount, + examSubmissionsDeleted: examDeleted, + workSubmissionsDeleted: workDeleted, + orphanFilesRemoved, + }); + ctx.body = ctx.helper.success(results, `测试数据已重置:${contestantCount} 名选手,${examDeleted + workDeleted} 条提交记录。已恢复为「未激活 + 默认口令 ${testPw}」,需先用默认口令完成短信激活即可登录重测`); } catch (err) { ctx.logger.error('重置测试数据失败:', err); diff --git a/app/controller/adminUser.js b/app/controller/adminUser.js new file mode 100644 index 0000000..2290b80 --- /dev/null +++ b/app/controller/adminUser.js @@ -0,0 +1,118 @@ +'use strict'; + +/** + * 管理员账号管理控制器(R178 · C 层) + * + * 统一错误约定(与项目其它控制器一致): + * - 业务错误 = HTTP 200 + `code !== 0`(helper.error 不改 HTTP 状态) + * - service 通过 `bizCode` 表达期望错误码,这里原样翻译 + * ⛔ 不要把业务错误包成 500(前端会当成系统故障,用户看到「服务器错误」) + */ + +const Controller = require('egg').Controller; +const adminPermissions = require('../lib/adminPermissions'); + +class AdminUserController extends Controller { + /** 把 service 抛出的 bizCode 翻译成业务响应;非业务异常交给上层 catch */ + _fail(err, fallbackMessage) { + const { ctx } = this; + if (err && err.bizCode) { + ctx.body = ctx.helper.error(err.bizCode, err.bizMessage || err.message); + return; + } + ctx.logger.error(`${fallbackMessage}:`, err); + ctx.body = ctx.helper.error(500, fallbackMessage); + } + + /** + * 当前登录管理员的权限信息(自助,无需业务权限) + * GET /api/admin/me + * + * 前端用途:① 渲染侧边菜单;② 权限被他人改动后,下次进页面能感知并提示。 + * 顺带返回权限点目录(catalog),避免前端硬编码一份权限清单(那是第二真相源)。 + */ + async me() { + const { ctx } = this; + const admin = ctx.state.currentAdmin; + if (!admin) { + ctx.body = ctx.helper.error(401, '未登录或登录状态已失效'); + return; + } + ctx.body = ctx.helper.success({ + id: admin.id, + username: admin.username, + isSuper: Number(admin.is_super) === 1, + permissions: admin.permissions || [], + catalog: adminPermissions.PERMISSIONS, + }, '获取当前管理员信息成功'); + } + + /** GET /api/admin/admin-users */ + async list() { + const { ctx, service } = this; + try { + const list = await service.adminUser.list(); + ctx.body = ctx.helper.success({ list, total: list.length }, '获取管理员列表成功'); + } catch (err) { + this._fail(err, '获取管理员列表失败'); + } + } + + /** POST /api/admin/admin-users */ + async create() { + const { ctx, service } = this; + try { + const result = await service.adminUser.create(ctx.request.body || {}); + ctx.body = ctx.helper.success(result, '管理员账号已创建'); + } catch (err) { + this._fail(err, '创建管理员失败'); + } + } + + /** PUT /api/admin/admin-users/:id/permissions */ + async updatePermissions() { + const { ctx, service } = this; + try { + const result = await service.adminUser.updatePermissions(ctx.params.id, ctx.request.body || {}); + ctx.body = ctx.helper.success(result, '权限已更新'); + } catch (err) { + this._fail(err, '更新权限失败'); + } + } + + /** PUT /api/admin/admin-users/:id/password */ + async resetPassword() { + const { ctx, service } = this; + try { + const result = await service.adminUser.resetPassword(ctx.params.id, ctx.request.body || {}); + ctx.body = ctx.helper.success(result, '口令已重置,该账号需重新登录'); + } catch (err) { + this._fail(err, '重置口令失败'); + } + } + + /** PUT /api/admin/admin-users/:id/status */ + async setStatus() { + const { ctx, service } = this; + try { + const result = await service.adminUser.setStatus(ctx.params.id, ctx.request.body || {}); + const msg = Number(result.status) === 1 ? '账号已启用' : '账号已禁用,其在线会话已失效'; + ctx.body = ctx.helper.success(result, msg); + } catch (err) { + this._fail(err, '修改账号状态失败'); + } + } + + /** DELETE /api/admin/admin-users/:id */ + async remove() { + const { ctx, service } = this; + try { + const result = await service.adminUser.remove(ctx.params.id); + ctx.body = ctx.helper.success(result, '管理员账号已删除'); + } catch (err) { + this._fail(err, '删除管理员失败'); + } + } +} + +module.exports = AdminUserController; diff --git a/app/controller/auth.js b/app/controller/auth.js index 807b6bc..8493d94 100644 --- a/app/controller/auth.js +++ b/app/controller/auth.js @@ -6,6 +6,8 @@ const VALIDATION_RULES = require('../validate'); // 口令强度策略(唯一事实来源):黑名单 + 纯重复/序列 + 账号派生值 + 长度上下限。 // ⚠️ 任何新增的口令写入入口都必须调用它,禁止在控制器内自行判断(见 lib/passwordPolicy.js 模块头)。 const passwordPolicy = require('../lib/passwordPolicy'); +// R178:管理端权限点解析(与 middleware/auth.js 同一真相源) +const adminPermissions = require('../lib/adminPermissions'); /** * 认证控制器 - AuthController @@ -372,6 +374,15 @@ class AuthController extends Controller { return; } + // 🔴 R178 禁用检查:必须在口令校验**之后**。 + // 若放在校验之前,「账号已禁用」与「密码错误」的差别会让未授权者探测出 + // 某个用户名真实存在且已被停用。校验通过后才告知状态,不额外泄露信息。 + if (Number(admin.status) !== 1) { + ctx.logger.warn(`[安全] 已禁用的管理员 ${admin.username} 尝试登录,已拒绝`); + ctx.body = ctx.helper.error(4006, '该管理员账号已被禁用,请联系最高管理员'); + return; + } + // P2-3 默认口令守卫:内置默认管理员(见 database/schema.sql 与 database/seeders/002-seed-admin.js), // 生产环境禁止使用默认口令登录,防止默认账号直接上线被接管; // 非生产环境放行但记录告警,并在响应中提示尽快改密。 @@ -410,14 +421,33 @@ class AuthController extends Controller { const token = ctx.helper.generateToken({ id: admin.id, role: 'admin', + // 🔴 R178 令牌版本:middleware/auth.js 每次请求都与库中 token_version 比对, + // 不一致即 401。改密 / 禁用 / 改权限时服务层自增本值 ⇒ 旧令牌立刻作废。 + // 存量令牌里没有 tv,读取时按 0 处理(与初值一致),升级不会踢掉在线会话。 + tv: Number(admin.token_version || 0), }, this.app.config.jwt.secret, this.app.config.jwt.expiresIn); + // 登录痕迹(best-effort):审计要能回答「谁在什么时候从哪登录」。 + // 失败不影响登录本身 —— 记录登录时间不是登录的必要条件。 + try { + await admin.update({ + last_login_at: new Date(), + last_login_ip: String(ctx.ip || '').slice(0, 64) || null, + }); + } catch (recordErr) { + ctx.logger.warn('[adminLogin] 记录登录信息失败:', recordErr && recordErr.message); + } + ctx.body = ctx.helper.success({ token, user: { id: admin.id, username: admin.username, role: 'admin', + // 前端据 isSuper 决定「管理员账号管理」入口与最高管理员的只读展示 + isSuper: Number(admin.is_super) === 1, + // 权限点数组:前端据此过滤侧边菜单与按钮。后端仍逐请求校验,前端过滤只是体验层。 + permissions: adminPermissions.parsePermissions(admin.permissions), }, // 使用默认口令时向前端透出改密提醒标记 mustChangePassword: usingDefaultPassword, diff --git a/app/controller/exam.js b/app/controller/exam.js index 7b695a5..316c11d 100644 --- a/app/controller/exam.js +++ b/app/controller/exam.js @@ -389,6 +389,12 @@ class ExamController extends Controller { return; } + // 🔴 交卷冻结(R178·B①):已正式交卷者不得再改写答案。 + // 位置刻意在判分之前 —— 已冻结的答卷不该再做一次无意义的 autoScore。 + // 与 service.saveAnswers 内的兜底调用是**同一个函数**(单一判据源), + // 不是两套条件;异常由下方 catch 按 bizCode 翻译成 4006。 + await service.exam.assertNotSubmitted(aggregation.aggregation_uid); + // 参数校验 const rule = { answers: { type: 'array', required: true, min: 1 }, @@ -452,6 +458,15 @@ class ExamController extends Controller { savedCount: savedRecords.length, }, '答案提交成功'); } catch (err) { + // 业务性拒绝(如「已交卷,答卷已冻结」)不该被伪装成 500: + // service 层用 bizCode 标记期望的错误码,这里原样翻译给前端。 + if (err && err.bizCode) { + ctx.logger.warn( + `[submitAnswers] 业务拒绝 code=${err.bizCode} msg=${err.bizMessage || err.message}` + ); + ctx.body = ctx.helper.error(err.bizCode, err.bizMessage || err.message); + return; + } ctx.logger.error('提交答案失败:', err); ctx.body = ctx.helper.error(500, '提交答案失败'); } diff --git a/app/lib/adminPermissions.js b/app/lib/adminPermissions.js new file mode 100644 index 0000000..7081e31 --- /dev/null +++ b/app/lib/adminPermissions.js @@ -0,0 +1,284 @@ +'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, +}; diff --git a/app/lib/assets/publicity-template.xlsx b/app/lib/assets/publicity-template.xlsx new file mode 100644 index 0000000..a3e4a59 Binary files /dev/null and b/app/lib/assets/publicity-template.xlsx differ diff --git a/app/lib/auditLog.js b/app/lib/auditLog.js new file mode 100644 index 0000000..dc2eb4b --- /dev/null +++ b/app/lib/auditLog.js @@ -0,0 +1,78 @@ +'use strict'; + +/** + * 管理员高危操作审计(R178) + * ============================================================================ + * + * 三种写法被刻意排除: + * 1. **不在控制器里直接 create** —— 控制器有 60+ 个方法,散落写入必然漏; + * 统一走本函数,写入范围只由「谁调用它」决定,容易被审阅。 + * 2. **不做全量写操作审计** —— 见 model/adminAuditLog.js 头注:约 30 个写接口, + * 日志量增长快且无清理策略;真正要回答的是「谁改了成绩 / 谁动了账号」。 + * 3. **不阻断业务** —— 审计失败只 warn。日志表故障不该让「发布成绩」失败, + * 否则为可观测性牺牲了主流程可用性。 + * + * ⚠️ detail 只放「变更前后的非敏感差异」。⛔ 绝不传口令、令牌、身份证号明文。 + */ + +/** + * 写入一条审计记录(best-effort,永不抛出) + * + * @param {Object} ctx - Egg 上下文(需 ctx.state.currentAdmin 已由 adminAuth 写入) + * @param {string} action - 动作标识,点号分层,如 'score.publish' / 'admin.create' + * @param {string} [target] - 操作对象,如组别、用户名、选手 ID + * @param {Object|string} [detail] - 补充信息(对象会被 JSON 序列化) + * @returns {Promise} + */ +async function recordAudit(ctx, action, target, detail) { + try { + const admin = (ctx && ctx.state && ctx.state.currentAdmin) || null; + // 没有操作人就不写:宁可缺一条,也不要写出一条「admin_id=0」的假归属, + // 那会让人误以为系统账号做过操作。 + if (!admin || !admin.id) { + ctx && ctx.logger && ctx.logger.warn(`[audit] 缺少 currentAdmin,跳过记录 action=${action}`); + return; + } + + let detailText = null; + if (detail !== undefined && detail !== null) { + try { + detailText = typeof detail === 'string' ? detail : JSON.stringify(detail); + } catch (e) { + detailText = '[unserializable]'; + } + if (detailText && detailText.length > 4000) detailText = detailText.slice(0, 4000) + '…'; + } + + await ctx.model.AdminAuditLog.create({ + admin_id: admin.id, + admin_username: admin.username, + action: String(action || 'unknown').slice(0, 64), + target: target === undefined || target === null ? null : String(target).slice(0, 200), + detail: detailText, + ip: String(ctx.ip || '').slice(0, 64) || null, + created_at: new Date(), + }); + } catch (err) { + // 审计失败不得影响业务:只记日志 + if (ctx && ctx.logger) { + ctx.logger.warn(`[audit] 写入失败 action=${action}:`, err && err.message); + } + } +} + +/** 审计动作常量(避免各处手写字符串导致同一动作被记成两个名字) */ +const ACTIONS = { + SCORE_PUBLISH: 'score.publish', + SCORE_UNPUBLISH: 'score.unpublish', + DATA_RESET_TEST: 'data.resetTest', + CONTESTANT_DELETE: 'contestant.delete', + EXAM_UNSUBMIT: 'exam.unsubmit', + ADMIN_CREATE: 'admin.create', + ADMIN_PERMISSIONS: 'admin.permissions', + ADMIN_PASSWORD: 'admin.password', + ADMIN_STATUS: 'admin.status', + ADMIN_DELETE: 'admin.delete', +}; + +module.exports = { recordAudit, ACTIONS }; diff --git a/app/lib/publicitySheet.js b/app/lib/publicitySheet.js new file mode 100644 index 0000000..d646d3c --- /dev/null +++ b/app/lib/publicitySheet.js @@ -0,0 +1,559 @@ +'use strict'; + +/** + * 成绩公示表导出引擎 —— 纯函数层(R176) + * + * 目标:以赛事官方参考模板(`lib/assets/publicity-template.xlsx`)为**基底**生成 + * xlsx,只填数据、不改版式,人工后续补「获奖等级」即可直接用。 + * + * 🔴 为什么不用 `@e965/xlsx` 直接改模板再写回(实测结论,2026-10-19): + * 读入模板 → 写一个单元格 → 写回,`sheet1.xml` 里的 `s=` 样式引用数 + * 从 **214 → 0**、`styles.xml` 从 22773 → 1546 字节,合并单元格还在但 + * 边框/字号/列宽全部丢失。该库 CE 版**不写出样式**,`cellStyles: true` + * 只影响读、不影响写。 + * ⇒ 唯一能保住版式的做法 = 只替换 `xl/worksheets/sheet1.xml` 这一个 zip 条目, + * 其余条目**原始压缩字节照抄**(不解压不重压 ⇒ 连 CRC 都不用重算)。 + * + * 🔴 零依赖约束:只用 Node 内置 `zlib`,CRC32 自己实现。这样 `--self-test` + * 可以在零 DB / 零后端 / 零网络的条件下直接 require 本文件跑矩阵。 + * + * 🔴 数据行数可以超过模板预留的 27 行:模板数据区是 row 3..29,两条签字行在 row 30 + * (裁判长)与 row 33(仲裁长)。新表按「新数据末行 + 1」与「新数据末行 + 4」 + * 的相对偏移重排两条签字行(N=27 时正好复现 row 30 / row 33)。 + * ⚠️ 备注块合并区 `H3:J8` 固定不动(H:J 列,与 A:G 的数据列不冲突), + * 但必须保证至少生成 6 个数据行,否则备注框的下边框会落空。 + */ + +const zlib = require('zlib'); + +// ==================== 常量(模板结构的单一描述源) ==================== + +/** 模板第 2 行的 7 个表头,仅用于自证断言与文档;实际写值不依赖它 */ +const TEMPLATE_HEADERS = ['序号', '参赛单位名称', '参赛选手', '指导教师', '成绩排名', '总分数', '获奖等级']; + +/** 数据区起止行(1 基,含端点)。row 2 = 表头,row 3 起才是数据 */ +const DATA_FIRST_ROW = 3; +/** + * 🔴 数据区实际末行是 29,**不是 30**。 + * 模板的 row 30 是「裁判长签字:」行(A30 / E30 各一个),row 33 是「仲裁长签字:」行。 + * 把 30 当数据行会直接抹掉裁判长签字 —— 2026-10-19 实测踩过这个坑。 + */ +const DATA_LAST_ROW = 29; +/** 签字行原始行号(模板里两条:裁判长在 30,仲裁长在 33) */ +const SIGN_ROWS = [30, 33]; +/** 模板预留的数据行数(row 3..29 = 27 行;模板里 row 29 没有 row 元素,不影响计数口径) */ +const RESERVED_DATA_ROWS = DATA_LAST_ROW - DATA_FIRST_ROW + 1; +/** 末条签字行行号 */ +const TEMPLATE_TAIL_ROW = SIGN_ROWS[SIGN_ROWS.length - 1]; +/** 签字行相对「数据区末行」的偏移(33 - 29 = 4) */ +const TAIL_OFFSET = TEMPLATE_TAIL_ROW - DATA_LAST_ROW; +/** 备注块 H3:J8 要求的最小数据行数(8 - 3 + 1 = 6) */ +const MIN_DATA_ROWS_FOR_NOTE = 6; +/** 数据列 A..G */ +const DATA_COLS = ['A', 'B', 'C', 'D', 'E', 'F', 'G']; +/** 备注块所在列(只补空样式单元格,撑住合并区边框) */ +const NOTE_COLS = ['H', 'I', 'J']; + +/** 模板标题里等待替换的两段占位文本 */ +const TITLE_PLACEHOLDER_ITEM = '***赛项(选拔赛/国内决赛)比赛结果公示表'; +const TITLE_PLACEHOLDER_CODE = '赛项编号:'; + +// ==================== CRC32 / zip 读写 ==================== + +let CRC_TABLE = null; +function crcTable() { + if (CRC_TABLE) return CRC_TABLE; + const t = new Int32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) { + c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1); + } + t[n] = c; + } + CRC_TABLE = t; + return t; +} + +/** 标准 CRC-32(IEEE 802.3),返回无符号 32 位 */ +function crc32(buf) { + const t = crcTable(); + let c = -1; + for (let i = 0; i < buf.length; i++) { + c = t[(c ^ buf[i]) & 0xFF] ^ (c >>> 8); + } + return (c ^ -1) >>> 0; +} + +/** 从尾部找 End Of Central Directory。找不到返回 -1 */ +function findEOCD(buf) { + const min = Math.max(0, buf.length - 65557); + for (let i = buf.length - 22; i >= min; i--) { + if (buf[i] === 0x50 && buf[i + 1] === 0x4b && buf[i + 2] === 0x05 && buf[i + 3] === 0x06) return i; + } + return -1; +} + +/** + * 读 zip,返回每个条目的**原始压缩字节**(不解压)。 + * 中央目录里的 compSize / crc 总是准确的(即使 local header 用了 data descriptor), + * 所以定位数据区一律以中央目录为准。 + */ +function readZip(buf) { + if (!Buffer.isBuffer(buf) || buf.length < 22) throw new Error('不是有效的 zip/xlsx(内容过短)'); + const eocd = findEOCD(buf); + if (eocd < 0) throw new Error('不是有效的 zip/xlsx(找不到 EOCD)'); + + const count = buf.readUInt16LE(eocd + 10); + let off = buf.readUInt32LE(eocd + 16); + if (off >= buf.length) throw new Error('中央目录偏移越界'); + + const entries = []; + for (let i = 0; i < count; i++) { + if (off + 46 > buf.length || buf.readUInt32LE(off) !== 0x02014b50) { + throw new Error(`中央目录第 ${i} 项签名不符 @${off}`); + } + const method = buf.readUInt16LE(off + 10); + const crc = buf.readUInt32LE(off + 16); + const compSize = buf.readUInt32LE(off + 20); + const uncompSize = buf.readUInt32LE(off + 24); + const nameLen = buf.readUInt16LE(off + 28); + const extraLen = buf.readUInt16LE(off + 30); + const commentLen = buf.readUInt16LE(off + 32); + const localOff = buf.readUInt32LE(off + 42); + const name = buf.slice(off + 46, off + 46 + nameLen).toString('utf8'); + + if (localOff + 30 > buf.length || buf.readUInt32LE(localOff) !== 0x04034b50) { + throw new Error(`条目 ${name} 的本地头损坏`); + } + const lnLen = buf.readUInt16LE(localOff + 26); + const leLen = buf.readUInt16LE(localOff + 28); + const dataStart = localOff + 30 + lnLen + leLen; + if (dataStart + compSize > buf.length) throw new Error(`条目 ${name} 的数据区越界`); + + entries.push({ + name, + method, + crc, + compSize, + uncompSize, + data: buf.slice(dataStart, dataStart + compSize), + }); + + off += 46 + nameLen + extraLen + commentLen; + } + return entries; +} + +/** 把条目按原压缩字节重新打包成 zip(不重压缩 ⇒ CRC 无需重算) */ +function writeZip(entries) { + const locals = []; + const centrals = []; + let offset = 0; + + for (const e of entries) { + const nameBuf = Buffer.from(e.name, 'utf8'); + const crc = e.crc >>> 0; + const compSize = e.data.length; + const uncompSize = e.uncompSize; + + const lh = Buffer.alloc(30); + lh.writeUInt32LE(0x04034b50, 0); + lh.writeUInt16LE(20, 4); // version needed to extract + lh.writeUInt16LE(0x0800, 6); // general purpose flag: UTF-8 名称 + lh.writeUInt16LE(e.method, 8); + lh.writeUInt16LE(0, 10); // last mod time(不关心,固定 0) + lh.writeUInt16LE(0, 12); // last mod date + lh.writeUInt32LE(crc, 14); + lh.writeUInt32LE(compSize, 18); + lh.writeUInt32LE(uncompSize, 22); + lh.writeUInt16LE(nameBuf.length, 26); + lh.writeUInt16LE(0, 28); // extra len + locals.push(lh, nameBuf, e.data); + + const ch = Buffer.alloc(46); + ch.writeUInt32LE(0x02014b50, 0); + ch.writeUInt16LE(20, 4); // version made by + ch.writeUInt16LE(20, 6); // version needed + ch.writeUInt16LE(0x0800, 8); + ch.writeUInt16LE(e.method, 10); + ch.writeUInt16LE(0, 12); + ch.writeUInt16LE(0, 14); + ch.writeUInt32LE(crc, 16); + ch.writeUInt32LE(compSize, 20); + ch.writeUInt32LE(uncompSize, 24); + ch.writeUInt16LE(nameBuf.length, 28); + ch.writeUInt16LE(0, 30); // extra + ch.writeUInt16LE(0, 32); // comment + ch.writeUInt16LE(0, 34); // disk number + ch.writeUInt16LE(0, 36); // internal attrs + ch.writeUInt32LE(0, 38); // external attrs + ch.writeUInt32LE(offset, 42); + centrals.push(ch, nameBuf); + + offset += lh.length + nameBuf.length + e.data.length; + } + + const centralStart = offset; + const centralBuf = Buffer.concat(centrals); + const eocd = Buffer.alloc(22); + eocd.writeUInt32LE(0x06054b50, 0); + eocd.writeUInt16LE(0, 4); + eocd.writeUInt16LE(0, 6); + eocd.writeUInt16LE(entries.length, 8); + eocd.writeUInt16LE(entries.length, 10); + eocd.writeUInt32LE(centralBuf.length, 12); + eocd.writeUInt32LE(centralStart, 16); + eocd.writeUInt16LE(0, 20); + return Buffer.concat([Buffer.concat(locals), centralBuf, eocd]); +} + +/** 解压单个条目为 utf8 文本(method 0=store / 8=deflate) */ +function entryText(entry) { + if (!entry) throw new Error('条目不存在'); + const raw = entry.method === 0 ? entry.data : zlib.inflateRawSync(entry.data); + return raw.toString('utf8'); +} + +/** 用 utf8 文本替换某条目(重新 deflate + 重算 CRC) */ +function replaceEntryText(entries, name, text) { + const raw = Buffer.from(text, 'utf8'); + const deflated = zlib.deflateRawSync(raw); + let hit = 0; + const out = entries.map(e => { + if (e.name !== name) return e; + hit++; + return { + name: e.name, + method: 8, + crc: crc32(raw), + compSize: deflated.length, + uncompSize: raw.length, + data: deflated, + }; + }); + if (hit !== 1) throw new Error(`条目 ${name} 命中 ${hit} 次(应恰好 1 次)`); + return out; +} + +// ==================== XML 片段工具 ==================== + +const XML_ESCAPE = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; +function esc(s) { + return String(s === null || s === undefined ? '' : s).replace(/[&<>"']/g, c => XML_ESCAPE[c]); +} + +const ROW_RE = /]*\/>|]*>[\s\S]*?<\/row>/g; +const CELL_RE = /]*\/>|]*>[\s\S]*?<\/c>/g; + +function rowNumber(rowXml) { + const m = /]*\br="(\d+)"/.exec(rowXml); + return m ? parseInt(m[1], 10) : -1; +} + +/** 从一行 XML 里取 { 列字母: s 值 } */ +function rowStyles(rowXml) { + const out = {}; + const cells = rowXml.match(CELL_RE) || []; + for (const c of cells) { + const rm = /\br="([A-Z]+)\d+"/.exec(c); + if (!rm) continue; + const sm = /\bs="(\d+)"/.exec(c); + if (sm) out[rm[1]] = sm[1]; + } + return out; +} + +/** 生成一个数据单元格(数值 / 文本 / 空) */ +function cellXml(col, row, style, value) { + const ref = col + row; + const sAttr = style === undefined ? '' : ` s="${style}"`; + if (value === null || value === undefined || value === '') { + return ``; + } + if (typeof value === 'number' && isFinite(value)) { + return `${value}`; + } + // 用 inlineStr 而不是 sharedStrings:新引入的字符串不用再去维护共享表下标 + return `${esc(value)}`; +} + +/** 选「最常见的数据行样式」作为新行模板,避免拿模板里被手工改过的那一行当标准 */ +function pickDataRowStyles(dataRows) { + const counted = new Map(); + for (const r of dataRows) { + const st = rowStyles(r.xml); + const sig = DATA_COLS.map(c => st[c] === undefined ? '-' : st[c]).join(','); + const cur = counted.get(sig); + if (cur) cur.n++; + else counted.set(sig, { n: 1, styles: st }); + } + let best = null; + for (const [, v] of counted) { + if (!best || v.n > best.n) best = v; + } + return best ? best.styles : { A: '6', B: '7', C: '7', D: '8', E: '6', F: '6', G: '6' }; +} + +/** + * 生成新的 sheetData。 + * + * @param {string} sheetXml 原 sheet1.xml + * @param {Array} rows buildPublicityRows 的产物 + * @returns {{ xml: string, dataRows: number, tailRow: number, styles: Object }} + */ +function patchSheetData(sheetXml, rows) { + const sheetDataMatch = /]*>([\s\S]*?)<\/sheetData>/.exec(sheetXml); + if (!sheetDataMatch) throw new Error('模板缺少 '); + + const allRows = sheetDataMatch[1].match(ROW_RE) || []; + const head = []; + const data = []; + const tail = []; + for (const r of allRows) { + const n = rowNumber(r); + if (n <= 2) head.push(r); + else if (n >= SIGN_ROWS[0]) tail.push(r); + else data.push({ n, xml: r }); + } + if (head.length !== 2) throw new Error(`模板表头行应为 2 行,实际 ${head.length}`); + if (data.length === 0) throw new Error('模板没有数据行可作样式参照'); + // 🔴 必须同时守住两条签字行:只保留一条等于把「裁判长签字」或「仲裁长签字」丢了 + if (tail.length !== SIGN_ROWS.length) { + throw new Error(`模板签字行应为 ${SIGN_ROWS.length} 条(row ${SIGN_ROWS.join(' / ')}),实际 ${tail.length} 条`); + } + + const styles = pickDataRowStyles(data); + // 备注块 H3:J8 的空单元格样式:直接取模板数据行里 H/I/J 的 s(row 3 有备注文本,取 row 4 起) + const noteStyleFrom = {}; + for (const d of data) { + if (d.n <= 8) { + const st = rowStyles(d.xml); + for (const c of NOTE_COLS) { + if (st[c] !== undefined && noteStyleFrom[c] === undefined) noteStyleFrom[c] = st[c]; + } + } + } + + const count = Math.max(rows.length, MIN_DATA_ROWS_FOR_NOTE); + const out = []; + for (const h of head) out.push(h); + + for (let i = 0; i < count; i++) { + const r = DATA_FIRST_ROW + i; + const row = rows[i] || null; + const cells = [ + cellXml('A', r, styles.A, row ? row.seq : null), + cellXml('B', r, styles.B, row ? row.org : null), + cellXml('C', r, styles.C, row ? row.players : null), + cellXml('D', r, styles.D, row ? row.teachers : null), + cellXml('E', r, styles.E, row ? row.rank : null), + cellXml('F', r, styles.F, row ? row.score : null), + // 获奖等级:用户裁定「空置人工填入」⇒ 永远不写值 + cellXml('G', r, styles.G, null), + ]; + // 备注合并区 H3:J8 需要这些单元格存在才能撑住边框 + if (r <= 8) { + for (const c of NOTE_COLS) cells.push(cellXml(c, r, noteStyleFrom[c], null)); + } + out.push(`${cells.join('')}`); + } + + const delta = (count + (DATA_FIRST_ROW - 1)) - DATA_LAST_ROW; // 新数据末行 - 旧数据末行 + const shiftRow = (xml, add) => { + if (add === 0) return xml; + return xml.replace(/(]*\br=")(\d+)(")/, (m, p1, p2, p3) => p1 + (parseInt(p2, 10) + add) + p3) + .replace(/(]*\br="[A-Z]+)(\d+)(")/g, (m, p1, p2, p3) => p1 + (parseInt(p2, 10) + add) + p3); + }; + for (const t of tail) out.push(shiftRow(t, delta)); + + const newSheetData = `${out.join('')}`; + let xml = sheetXml.replace(sheetDataMatch[0], newSheetData); + + // dimension 同步(否则 Excel 认为表格只到旧末行,滚动区/打印区会截断) + const tailRow = TEMPLATE_TAIL_ROW + delta; + xml = xml.replace(/]*\bref="[^"]*"/, `(标题) + const siMatch = /([\s\S]*?)<\/si>/.exec(xml); + if (!siMatch) throw new Error('sharedStrings 里没有 '); + + let inner = siMatch[1]; + + if (itemName && inner.indexOf(TITLE_PLACEHOLDER_ITEM) >= 0) { + // 赛项名替换掉「***」,保留「(选拔赛/国内决赛)比赛结果公示表」的公文后缀 + inner = inner.replace(TITLE_PLACEHOLDER_ITEM, `${esc(itemName)}(选拔赛/国内决赛)比赛结果公示表`); + replacedItem = true; + } + if (itemCode && inner.indexOf(TITLE_PLACEHOLDER_CODE) >= 0) { + // 编号为空时不追加,避免留下一个孤零零的「赛项编号:」 + inner = inner.replace(TITLE_PLACEHOLDER_CODE, `${TITLE_PLACEHOLDER_CODE}${esc(itemCode)}`); + replacedCode = true; + } + + if (!replacedItem && !replacedCode) return { xml, replacedItem, replacedCode }; + + const newSi = `${inner}`; + return { xml: xml.replace(siMatch[0], newSi), replacedItem, replacedCode }; +} + +/** + * 完整流程:模板字节 + 数据行 + 标题信息 → 新 xlsx 字节。 + * @param {Buffer} templateBuf + * @param {Array} rows + * @param {{itemName?: string, itemCode?: string}} [meta] + */ +function buildXlsx(templateBuf, rows, meta) { + const entries = readZip(templateBuf); + + const sheetEntry = entries.find(e => e.name === 'xl/worksheets/sheet1.xml'); + if (!sheetEntry) throw new Error('模板缺少 xl/worksheets/sheet1.xml'); + const sheetXml = entryText(sheetEntry); + const patched = patchSheetData(sheetXml, rows); + + let next = replaceEntryText(entries, 'xl/worksheets/sheet1.xml', patched.xml); + + const ssEntry = next.find(e => e.name === 'xl/sharedStrings.xml'); + let titleResult = { replacedItem: false, replacedCode: false }; + if (ssEntry) { + const ssXml = entryText(ssEntry); + const r = patchSharedStringsTitle(ssXml, meta || {}); + titleResult = { replacedItem: r.replacedItem, replacedCode: r.replacedCode }; + if (r.replacedItem || r.replacedCode) { + next = replaceEntryText(next, 'xl/sharedStrings.xml', r.xml); + } + } + + return { + buffer: writeZip(next), + dataRows: patched.dataRows, + tailRow: patched.tailRow, + title: titleResult, + styles: patched.styles, + }; +} + +// ==================== 数据构造(纯函数) ==================== + +/** 分数归一:null/''/NaN → null;字符串数字 → number */ +function toScore(v) { + if (v === null || v === undefined || v === '') return null; + const n = Number(v); + return isFinite(n) ? Math.round(n * 100) / 100 : null; +} + +/** + * 把「小组」数组排成公示表的行。 + * + * 一行 = 一个小组(用户裁定:一组一人参赛、成绩全组共享 ⇒ 同一分数不该占多行)。 + * 排序:有分的按分数**由高到低**,未评分的排最后;同分按团队编号升序保证稳定。 + * 获奖等级不在此处产生(永远留空)。 + * + * @param {Array<{teamUid?:number|string, teamId?:string, org?:string, + * players?:string[]|string, teachers?:string[]|string, score?:number|string}>} teams + * @returns {Array<{seq:number, org:string, players:string, teachers:string, rank:(number|null), score:(number|null), teamUid:any, teamId:string}>} + */ +function buildPublicityRows(teams) { + const list = Array.isArray(teams) ? teams : []; + const norm = list.map(t => { + const players = Array.isArray(t.players) ? t.players : (t.players ? [t.players] : []); + const teachers = Array.isArray(t.teachers) ? t.teachers : (t.teachers ? [t.teachers] : []); + return { + teamUid: t.teamUid === undefined ? null : t.teamUid, + teamId: t.teamId === undefined || t.teamId === null ? '' : String(t.teamId), + org: t.org === undefined || t.org === null ? '' : String(t.org), + players: uniqKeepOrder(players).join('、'), + teachers: uniqKeepOrder(teachers).join('、'), + score: toScore(t.score), + }; + }); + + norm.sort((a, b) => { + const sa = a.score === null ? -Infinity : a.score; + const sb = b.score === null ? -Infinity : b.score; + if (sb !== sa) return sb - sa; + // 未评分的两个也按编号定序,避免顺序随查询波动 + if (a.teamId < b.teamId) return -1; + if (a.teamId > b.teamId) return 1; + return 0; + }); + + let rank = 0; + return norm.map((t, i) => { + // 未评分不参与排名(否则会出现「0 分并列第 N」的假排名) + if (t.score !== null) rank++; + return { + seq: i + 1, + org: t.org, + players: t.players, + teachers: t.teachers, + rank: t.score === null ? null : rank, + score: t.score, + teamUid: t.teamUid, + teamId: t.teamId, + }; + }); +} + +/** 去重保序(同组同名只留一份) */ +function uniqKeepOrder(arr) { + const seen = new Set(); + const out = []; + for (const x of arr) { + const s = String(x === null || x === undefined ? '' : x).trim(); + if (!s || seen.has(s)) continue; + seen.add(s); + out.push(s); + } + return out; +} + +module.exports = { + TEMPLATE_HEADERS, + DATA_FIRST_ROW, + DATA_LAST_ROW, + SIGN_ROWS, + RESERVED_DATA_ROWS, + TEMPLATE_TAIL_ROW, + TAIL_OFFSET, + MIN_DATA_ROWS_FOR_NOTE, + DATA_COLS, + TITLE_PLACEHOLDER_ITEM, + TITLE_PLACEHOLDER_CODE, + crc32, + findEOCD, + readZip, + writeZip, + entryText, + replaceEntryText, + rowNumber, + rowStyles, + cellXml, + pickDataRowStyles, + patchSheetData, + patchSharedStringsTitle, + buildXlsx, + toScore, + buildPublicityRows, + uniqKeepOrder, +}; diff --git a/app/lib/teacherBackfill.js b/app/lib/teacherBackfill.js new file mode 100644 index 0000000..063f6ff --- /dev/null +++ b/app/lib/teacherBackfill.js @@ -0,0 +1,287 @@ +'use strict'; + +/** + * 指导教师回填 —— 纯判定层(R176) + * + * 背景:`contestants` 原本没有任何「指导教师」承载字段,而成绩公示表 + * (`7-2026一带一路暨金砖大赛成绩公示表-参考模版.xlsx`)第 4 列必须填写。 + * 报名表里其实有指导教师行,但导入流程按业务规则只导入「参赛选手」身份 + * (`service/admin.js` 的 IMPORT_IDENTITY),教师行被计入 `ignored` 丢弃。 + * + * 本模块只负责**从报名表二维数组里把指导教师找出来、按小组归并**, + * 不碰数据库、不碰文件、不依赖 Egg。落库由 `service/admin.js#backfillTeachers` 完成。 + * + * 🔴 为什么不直接复用既有导入:`importContestants` 对已存在的身份证号一律 skip + * (`result.skipped++`),所以「重导一次」不会更新任何字段。回填必须是独立通路。 + * + * 🔴 为什么必须带 group:`teams.team_uid` 仅组内唯一、可跨组重号(实测本科组也有 + * team_uid=1),只按 teamId 归并会把别的组的教师挂到本组头上。 + */ + +/** 教师身份白名单(归一化:去掉所有空白字符后比对) */ +const TEACHER_IDENTITIES = [ + '指导教师', + '指导老师', + '指导专家', + '指导教师(专家)', + '指导教师(专家)', + '指导教师(教练)', + '指导教师(教练)', + '教练', + '导师', +]; + +/** 组别别名 → 系统 key。⚠️ 名字带 TEACHER_ 前缀,避免与 service/admin.js 的 GROUP_ALIAS 撞名 */ +const TEACHER_GROUP_ALIAS = { + 中职: 'zhongzhi', + 中职组: 'zhongzhi', + 高职: 'gaozhi', + 高职组: 'gaozhi', + 本科: 'benke', + 本科组: 'benke', + 教师: 'jiaoshi', + 教师组: 'jiaoshi', + zhongzhi: 'zhongzhi', + gaozhi: 'gaozhi', + benke: 'benke', + jiaoshi: 'jiaoshi', +}; + +/** 报名表列名别名(与导入契约同源,但这里只取回填必需的 4 列) */ +const TEACHER_COLUMN_ALIAS = { + name: ['姓名', '全名', 'name'], + teamId: ['队伍编号', '队伍ID', '队伍id', 'team_id', 'team_number'], + group: ['组别', 'group'], + identity: ['身份', 'identity'], +}; + +const ROW_STATUS = { + TEACHER: 'TEACHER', + NOT_TEACHER: 'NOT_TEACHER', + TEACHER_NO_TEAM: 'TEACHER_NO_TEAM', + TEACHER_NO_NAME: 'TEACHER_NO_NAME', + TEACHER_NO_GROUP: 'TEACHER_NO_GROUP', + BLANK_ROW: 'BLANK_ROW', +}; + +/** + * 去掉所有空白字符(含全角空格、不换行空格、零宽字符)。 + * ⛔ 不能只 trim:上游表格里「指导 教师」「指导教师」混用很常见, + * 只 trim 会把前者判成非教师身份而整行丢掉。 + */ +function squash(v) { + if (v === null || v === undefined) return ''; + return String(v) + .replace(/[\u3000\u00a0\u200b\u200c\u200d\ufeff]/g, '') + .replace(/\s+/g, ''); +} + +/** 组别归一;无法识别时返回空串(调用方据此判定"组别缺失") */ +function normalizeGroup(v) { + const s = squash(v); + if (!s) return ''; + return TEACHER_GROUP_ALIAS[s] || TEACHER_GROUP_ALIAS[s.toLowerCase()] || ''; +} + +/** 是否教师身份。归一化后精确命中白名单才算,⛔ 不做包含匹配(避免「非指导教师」被误判) */ +function isTeacherIdentity(v) { + const s = squash(v); + if (!s) return false; + return TEACHER_IDENTITIES.indexOf(s) >= 0; +} + +/** 队伍编号归一:只 trim 首尾空白与零宽字符,⛔ 不折叠大小写(编号大小写不敏感会串组) */ +function normalizeTeamId(v) { + if (v === null || v === undefined) return ''; + return String(v).replace(/[\u200b\u200c\u200d\ufeff]/g, '').replace(/^[\s\u3000\u00a0]+|[\s\u3000\u00a0]+$/g, ''); +} + +/** 在给定行里按别名找列下标,返回 -1 表示未命中 */ +function findColumn(row, aliases) { + if (!Array.isArray(row)) return -1; + for (let i = 0; i < row.length; i++) { + const cell = squash(row[i]); + if (!cell) continue; + for (const a of aliases) { + if (cell === squash(a)) return i; + } + } + return -1; +} + +/** + * 解析报名表 AOA,提取「小组 → 指导教师姓名列表」。 + * + * @param {Array} aoa 二维数组(xlsx sheet_to_json header:1 的产物) + * @param {Object} [opts] + * @param {number} [opts.maxHeaderScan=10] 表头最多向下扫描的行数 + * @returns {{ + * ok: boolean, code: string, message: string, + * teams: Array<{group: string, teamId: string, names: string[], rowNumbers: number[]}>, + * stats: Object, errors: Array + * }} + */ +function extractTeachers(aoa, opts) { + const maxHeaderScan = (opts && opts.maxHeaderScan) || 10; + const empty = { teams: [], stats: { rows: 0, teachers: 0, notTeachers: 0, blank: 0 }, errors: [] }; + + if (!Array.isArray(aoa) || aoa.length === 0) { + return Object.assign({ ok: false, code: 'EMPTY_FILE', message: '文件内容为空' }, empty); + } + + // ---- 1) 定位表头 ---- + let headerRowIdx = -1; + let colName = -1; + let colTeam = -1; + let colGroup = -1; + let colIdentity = -1; + for (let i = 0; i < Math.min(aoa.length, maxHeaderScan); i++) { + const r = aoa[i]; + const cn = findColumn(r, TEACHER_COLUMN_ALIAS.name); + const ci = findColumn(r, TEACHER_COLUMN_ALIAS.identity); + if (cn >= 0 && ci >= 0) { + headerRowIdx = i; + colName = cn; + colIdentity = ci; + colTeam = findColumn(r, TEACHER_COLUMN_ALIAS.teamId); + colGroup = findColumn(r, TEACHER_COLUMN_ALIAS.group); + break; + } + } + if (headerRowIdx < 0) { + return Object.assign({ + ok: false, + code: 'HEADER_NOT_FOUND', + message: '前 10 行内没有同时包含「姓名」与「身份」的表头,请确认上传的是报名表', + }, empty); + } + if (colTeam < 0) { + return Object.assign({ + ok: false, + code: 'TEAM_COLUMN_MISSING', + message: '报名表缺少「队伍编号」列,无法把指导教师归到具体小组', + }, empty); + } + if (colGroup < 0) { + return Object.assign({ + ok: false, + code: 'GROUP_COLUMN_MISSING', + message: '报名表缺少「组别」列,无法限定同组(team_uid 跨组重号,不能只按编号归并)', + }, empty); + } + + // ---- 2) 逐行收集 ---- + const stats = { rows: 0, teachers: 0, notTeachers: 0, blank: 0 }; + const errors = []; + // key = group + '\u0000' + teamId,用不可见分隔符避免与编号里的字符冲突 + const bucket = new Map(); + + for (let i = headerRowIdx + 1; i < aoa.length; i++) { + const r = aoa[i] || []; + stats.rows++; + const rowNumber = i + 1; // 1 基,与 Excel 行号一致 + + const nameRaw = colName >= 0 ? r[colName] : ''; + const identityRaw = colIdentity >= 0 ? r[colIdentity] : ''; + const allBlank = r.every(c => squash(c) === ''); + if (allBlank) { + stats.blank++; + continue; + } + + if (!isTeacherIdentity(identityRaw)) { + stats.notTeachers++; + continue; + } + + const name = String(nameRaw === null || nameRaw === undefined ? '' : nameRaw).trim(); + if (!name) { + stats.teachers++; + errors.push({ row: rowNumber, status: ROW_STATUS.TEACHER_NO_NAME, message: '身份为导师但姓名为空,已跳过' }); + continue; + } + const group = normalizeGroup(colGroup >= 0 ? r[colGroup] : ''); + if (!group) { + stats.teachers++; + errors.push({ + row: rowNumber, + status: ROW_STATUS.TEACHER_NO_GROUP, + message: `指导教师「${name}」所在行组别为空或无法识别,已跳过(缺少 group 无法安全归组)`, + }); + continue; + } + const teamId = normalizeTeamId(colTeam >= 0 ? r[colTeam] : ''); + if (!teamId) { + stats.teachers++; + errors.push({ + row: rowNumber, + status: ROW_STATUS.TEACHER_NO_TEAM, + message: `指导教师「${name}」所在行队伍编号为空,已跳过(无法定位到具体小组)`, + }); + continue; + } + + stats.teachers++; + const key = `${group}\u0000${teamId}`; + let item = bucket.get(key); + if (!item) { + item = { group, teamId, names: [], rowNumbers: [] }; + bucket.set(key, item); + } + if (item.names.indexOf(name) < 0) item.names.push(name); + item.rowNumbers.push(rowNumber); + } + + const teams = Array.from(bucket.values()); + + if (teams.length === 0) { + return Object.assign({ + ok: false, + code: 'NO_TEACHER_FOUND', + message: stats.teachers === 0 + ? '报名表里没有身份为「指导教师」的行(请确认「身份」列的写法)' + : '识别到指导教师行,但都缺少组别或队伍编号,无法归组', + }, { stats, errors }); + } + + return Object.assign({ ok: true, code: 'OK', message: `识别到 ${teams.length} 个小组的指导教师` }, { teams, stats, errors }); +} + +/** + * 把同一小组的多个指导教师姓名合成一个展示串。 + * ⛔ 去重保序:报名表里同一教师可能重复出现(同一人被登记两次), + * 直接 join 会得到「张三、张三」。 + */ +function joinNames(names) { + if (!Array.isArray(names)) return ''; + const seen = new Set(); + const out = []; + for (const n of names) { + const s = String(n === null || n === undefined ? '' : n).trim(); + if (!s || seen.has(s)) continue; + seen.add(s); + out.push(s); + } + return out.join('、'); +} + +/** 1 基数组是报名表原始行号,直接透出(调用方拼提示用) */ +function formatRowNumbers(nums) { + if (!Array.isArray(nums) || nums.length === 0) return ''; + return nums.join('、'); +} + +module.exports = { + TEACHER_IDENTITIES, + TEACHER_GROUP_ALIAS, + TEACHER_COLUMN_ALIAS, + ROW_STATUS, + squash, + normalizeGroup, + normalizeTeamId, + isTeacherIdentity, + findColumn, + extractTeachers, + joinNames, + formatRowNumbers, +}; diff --git a/app/middleware/auth.js b/app/middleware/auth.js index bc85f80..e5868ac 100644 --- a/app/middleware/auth.js +++ b/app/middleware/auth.js @@ -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; + } } // 继续执行后续中间件和控制器 diff --git a/app/model/admin.js b/app/model/admin.js index 01b7de3..72355b7 100644 --- a/app/model/admin.js +++ b/app/model/admin.js @@ -7,7 +7,7 @@ */ module.exports = app => { - const { STRING, INTEGER, DATE } = app.Sequelize; + const { STRING, INTEGER, DATE, TEXT, TINYINT } = app.Sequelize; const Admin = app.model.define('admin', { // 主键 ID,自增 @@ -30,6 +30,51 @@ module.exports = app => { allowNull: false, comment: '管理员密码', }, + // 🔴 R178 最高管理员标记:唯一、不可改权限 / 不可禁用 / 不可删除 / 不可重置口令。 + // 判据只认本字段(不认 username,避免改名绕过;也不认 id,避免误伤后续自增行)。 + is_super: { + type: TINYINT, + allowNull: false, + defaultValue: 0, + comment: '最高管理员:1=不可变动(唯一)', + }, + // 权限点 JSON 数组(见 lib/adminPermissions.js 的 PERMISSIONS)。 + // NULL / 空数组 = 无任何业务权限;is_super=1 的账号忽略本列(恒全权)。 + permissions: { + type: TEXT, + allowNull: true, + comment: '权限点 JSON 数组', + }, + // 1=启用 0=禁用。禁用后:禁止登录,且已签发的令牌在下一次请求即被拒。 + status: { + type: TINYINT, + allowNull: false, + defaultValue: 1, + comment: '1=启用 0=禁用', + }, + // 令牌版本:改密 / 禁用 / 改权限时自增 ⇒ 旧 JWT 中的 tv 与之不匹配即失效。 + // 这是「改密后旧会话立即下线」的唯一判据(本项目无会话表)。 + token_version: { + type: INTEGER, + allowNull: false, + defaultValue: 0, + comment: '令牌版本(改密/禁用时自增,使旧 JWT 失效)', + }, + last_login_at: { + type: DATE, + allowNull: true, + comment: '最近登录时间', + }, + last_login_ip: { + type: STRING(64), + allowNull: true, + comment: '最近登录 IP', + }, + remark: { + type: STRING(200), + allowNull: true, + comment: '备注(姓名 / 岗位)', + }, // 记录创建时间 created_at: { type: DATE, diff --git a/app/model/adminAuditLog.js b/app/model/adminAuditLog.js new file mode 100644 index 0000000..7588af9 --- /dev/null +++ b/app/model/adminAuditLog.js @@ -0,0 +1,72 @@ +'use strict'; + +/** + * 管理员高危操作审计日志 - AdminAuditLog + * + * 只记录**高危写操作**(不是全量写操作): + * 发布/撤回成绩、重置测试数据、删除选手、撤回交卷、 + * 管理员账号的增 / 改权限 / 重置口令 / 禁用 / 删除。 + * + * 为什么不做全量审计:本项目日志无集中采集,全量写接口约 30 个, + * 日志量增长快且缺少清理策略;而「多管理员下谁改了成绩 / 谁动了账号」 + * 才是真正需要回答的问题。故按「能改变比赛结果或权限边界的动作」划定范围。 + * + * ⚠️ 写入必须 best-effort:审计失败不得阻断业务(否则日志表故障会连累发布成绩)。 + * 统一走 service.admin.recordAudit(),⛔ 不要在控制器里直接 create。 + */ + +module.exports = app => { + const { STRING, INTEGER, DATE, TEXT, BIGINT } = app.Sequelize; + + const AdminAuditLog = app.model.define('adminAuditLog', { + id: { + type: BIGINT, + primaryKey: true, + autoIncrement: true, + comment: '日志唯一标识', + }, + admin_id: { + type: INTEGER, + allowNull: false, + comment: '操作人管理员 ID', + }, + // 冗余存用户名:账号被删除后仍能回答「当时是谁操作的」 + admin_username: { + type: STRING(50), + allowNull: false, + comment: '操作人用户名(冗余,防账号删除后不可追溯)', + }, + action: { + type: STRING(64), + allowNull: false, + comment: '动作标识,如 score.publish / admin.create', + }, + target: { + type: STRING(200), + allowNull: true, + comment: '操作对象(组别 / 账号名 / 选手 ID)', + }, + detail: { + type: TEXT, + allowNull: true, + comment: '补充信息(JSON 文本)', + }, + ip: { + type: STRING(64), + allowNull: true, + comment: '来源 IP', + }, + created_at: { + type: DATE, + allowNull: false, + defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'), + comment: '发生时间', + }, + }, { + tableName: 'admin_audit_logs', + timestamps: false, + underscored: true, + }); + + return AdminAuditLog; +}; diff --git a/app/model/aggregation.js b/app/model/aggregation.js index 7b19e47..2a9045b 100644 --- a/app/model/aggregation.js +++ b/app/model/aggregation.js @@ -133,9 +133,13 @@ module.exports = app => { // 关联定义 Aggregation.associate = function() { + // ⚠️ R176:CompetitionInfo 的运行库结构是 key-value(主键 id), + // 不是原来的三列结构 ⇒ targetKey 必须是 `id`,否则 Sequelize 解析 + // `competition_uid` 这个属性时会找不到定义。 + // `aggregation.competition_uid` 存的是数字(实测恒为 1),对应 competition_info.id。 app.model.Aggregation.belongsTo(app.model.CompetitionInfo, { foreignKey: 'competition_uid', - targetKey: 'competition_uid', + targetKey: 'id', as: 'competition', }); app.model.Aggregation.belongsTo(app.model.Team, { diff --git a/app/model/competitionInfo.js b/app/model/competitionInfo.js index ecb47f6..3f4b9f4 100644 --- a/app/model/competitionInfo.js +++ b/app/model/competitionInfo.js @@ -3,56 +3,66 @@ /** * 竞赛信息模型 - CompetitionInfo * - * 存储竞赛的基本信息,包括竞赛名称和代码。 * 表名:competition_info + * + * 🔴 2026-10-19(R176)修正:原模型声明的是三列结构 + * (`competition_uid` / `competition_name` / `competition_code`), + * 但**运行库实际是 key-value 结构**: + * `id` / `info_key` / `info_value` / `created_at` / `updated_at`(实测 SHOW CREATE TABLE) + * 模型与表不一致的后果: + * ① `findOne()` 默认按模型声明选列 ⇒ `Unknown column 'competition_uid'` 直接 500; + * ② `create()` 会带上 `competition_name`(allowNull:false)⇒ 必然失败。 + * 旧代码之所以没暴露,是因为唯一的使用点(导入时取 competition_uid) + * 包在 try/catch 里,失败后静默回落到默认值 1 —— 而 `info_key='competition_uid'` + * 的值恰好也是 1,所以"看起来一直是对的"。 + * + * ⚠️ 因此本表**不能按列访问**,必须走 `info_key` 取值。 + * 公示表要用的两个键是 `competition_item_name` / `competition_item_code`。 */ module.exports = app => { - const { STRING, INTEGER, DATE } = app.Sequelize; + const { STRING, INTEGER, TEXT, DATE } = app.Sequelize; const CompetitionInfo = app.model.define('competition_info', { - // 主键,自增 - competition_uid: { + // 主键,自增(运行库真实主键是 id,不是 competition_uid) + id: { type: INTEGER, primaryKey: true, autoIncrement: true, - comment: '竞赛唯一标识', + comment: '自增主键', }, - // 竞赛名称 - competition_name: { - type: STRING(200), - allowNull: false, - comment: '竞赛名称', - }, - // 竞赛代码(唯一标识码) - competition_code: { + // 配置键名(如 competition_name / competition_code / competition_item_name) + // ⚠️ 运行库**没有**给 info_key 建唯一索引 ⇒ 写侧必须"先查再写",不能依赖 upsert + info_key: { type: STRING(50), allowNull: false, - unique: true, - comment: '竞赛代码', + comment: '配置键', + }, + // 配置值(统一按字符串存,数字也以文本形式落库) + info_value: { + type: TEXT, + allowNull: true, + comment: '配置值', }, - // 记录创建时间 created_at: { type: DATE, - allowNull: false, - defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'), + allowNull: true, comment: '创建时间', }, - // 记录更新时间 updated_at: { type: DATE, - allowNull: false, - defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'), + allowNull: true, comment: '更新时间', }, }, { tableName: 'competition_info', timestamps: false, underscored: true, + // 索引仅作 Sequelize 元数据声明(sync 时用);⛔ 本项目的表由 SQL 手工维护, + // 不要指望这里加 unique 会同步到运行库 indexes: [ { - unique: true, - fields: ['competition_code'], + fields: ['info_key'], }, ], }); diff --git a/app/model/contestant.js b/app/model/contestant.js index 97560f1..9bea6fb 100644 --- a/app/model/contestant.js +++ b/app/model/contestant.js @@ -74,6 +74,16 @@ module.exports = app => { word_score: decimalScore('word_score', 'Word题/综合大题得分(可含 2 位小数)'), // PPT分数(本科/教师组管理员手动打分) ppt_score: decimalScore('ppt_score', 'PPT得分(本科/教师组,可含 2 位小数)'), + // 指导教师姓名(R176)。来源 = 报名表里 identity 为「指导教师」的行, + // 按 (group, 队伍编号) 归到同组选手身上;同组多人用「、」连接后冗余存一份。 + // ⚠️ 语义上是**组级属性**,冗余到每位组员是为了让成绩公示表能直接按行取用, + // 不必每次再回查 aggregation。回填入口见 service/admin.js#backfillTeachers。 + teacher_name: { + type: STRING(200), + allowNull: true, + defaultValue: null, + comment: '指导教师姓名(同组多人用、分隔)', + }, // 所属团队 ID(外键关联 teams 表) team_id: { type: INTEGER, diff --git a/app/model/news.js b/app/model/news.js index 20069aa..f5c927a 100644 --- a/app/model/news.js +++ b/app/model/news.js @@ -79,6 +79,30 @@ module.exports = app => { defaultValue: null, comment: '标签,多个标签用逗号分隔', }, + // 草稿标记(R176)。成绩发布时会自动生成一条「成绩公示」预览新闻, + // 需人工编辑确认后才可对外发布 ⇒ 草稿期间必须前台不可见。 + // 🔴 news.list / findById 的公开查询必须显式过滤 is_draft=0(默认值 0 保证历史 15 条不受影响)。 + is_draft: { + type: INTEGER, + allowNull: false, + defaultValue: 0, + comment: '草稿标记:0=已发布, 1=草稿(前台不可见)', + }, + // 附件(R176)。用于成绩公示表的文件公示:管理员上传 xlsx/pdf 后, + // 详情页渲染成独立下载按钮,不依赖正文富文本里手写链接 + // (手写链接在人工编辑正文时极易被误删)。 + attachment_url: { + type: STRING(500), + allowNull: true, + defaultValue: null, + comment: '附件下载地址(相对路径 /public/news/xxx)', + }, + attachment_name: { + type: STRING(200), + allowNull: true, + defaultValue: null, + comment: '附件显示文件名(下载按钮文案)', + }, // 记录创建时间 created_at: { type: DATE, diff --git a/app/router.js b/app/router.js index a771558..ee85b22 100644 --- a/app/router.js +++ b/app/router.js @@ -110,6 +110,19 @@ module.exports = app => { // ==================== 管理后台(需管理员认证) ==================== + // ---------- R178 多管理员 + 权限 ---------- + // 这些路由与下面所有 /api/admin/* 一样只挂 adminAuth: + // 权限判定在 middleware/auth.js 的 adminOnly 分支内完成(单一真相源 + // lib/adminPermissions.js 的 ROUTE_TABLE,未登记路由默认拒绝)。 + // ⛔ 不要在这里逐条写权限参数——那会与映射表形成第二套真相源。 + router.get('/api/admin/me', adminAuth, controller.adminUser.me); + router.get('/api/admin/admin-users', adminAuth, controller.adminUser.list); + router.post('/api/admin/admin-users', adminAuth, controller.adminUser.create); + router.put('/api/admin/admin-users/:id/permissions', adminAuth, controller.adminUser.updatePermissions); + router.put('/api/admin/admin-users/:id/password', adminAuth, controller.adminUser.resetPassword); + router.put('/api/admin/admin-users/:id/status', adminAuth, controller.adminUser.setStatus); + router.delete('/api/admin/admin-users/:id', adminAuth, controller.adminUser.remove); + // 数据统计 router.get('/api/admin/stats', adminAuth, controller.admin.stats); @@ -119,6 +132,12 @@ module.exports = app => { // ⚠️ 路径段固定、无通配,与既有路由无匹配冲突。 router.get('/api/admin/teams', adminAuth, controller.admin.teams); router.post('/api/admin/import-excel', adminAuth, controller.admin.importExcel); + // 指导教师回填(R176):报名表 → contestants.teacher_name。 + // 🔴 与 import-excel 是**两条不同通路**:导入对已存在身份证号一律 skip, + // 无法用于"重导一次补字段";本接口只写 teacher_name,不碰账号/队伍/成绩。 + // ⚠️ 路径段固定(backfill-teacher 为字面量),且与下方 PUT /:id 方法不同,无匹配冲突。 + // 破坏面定级:非破坏性(白名单单列写入、按组限定、可重复执行且幂等) + router.post('/api/admin/contestants/backfill-teacher', adminAuth, controller.admin.backfillTeachers); // 选手信息编辑(R108):让「导入之后就改不了」的历史限制成为过去 —— 身份证号 / 姓名 / // 手机号可事后纠正(典型场景:报名表证号录入错误,人工核实后修正)。 // ⚠️ 路径段固定(/contestants/:id),与既有路由无匹配冲突;PUT 方法在 admin 段内唯一。 @@ -132,6 +151,14 @@ module.exports = app => { router.get('/api/admin/export-csv/:group', adminAuth, controller.admin.exportCsv); // R29:成绩导出改为真正的 Excel(.xlsx)(与 CSV 共用同一数据源 buildScoreExportTable) router.get('/api/admin/export-excel/:group', adminAuth, controller.admin.exportExcel); + // 成绩公示表导出(R176):按大赛参考模板生成 xlsx 半成品(获奖等级留空待人工填)。 + // ⚠️ 与 export-excel 是两个不同产物:export-excel 是**内部核查表**(含身份证号/手机号), + // 本接口是**对外公示表**(不含任何身份证号/手机号),⛔ 两者不可互相替代。 + router.get('/api/admin/export-publicity/:group', adminAuth, controller.admin.exportPublicity); + // 赛项信息(R176):成绩公示表标题的「赛项名称 / 赛项编号」,管理后台可维护。 + // ⚠️ 两个值只影响公示表标题文案,与成绩/账号/队伍无关。 + router.get('/api/admin/competition-item', adminAuth, controller.admin.competitionItemInfo); + router.put('/api/admin/competition-item', adminAuth, controller.admin.updateCompetitionItemInfo); router.get('/api/admin/batch-download/:group', adminAuth, controller.admin.batchDownload); router.get('/api/admin/works/:group', adminAuth, controller.admin.works); @@ -206,6 +233,9 @@ module.exports = app => { // 图片上传(轮播图等) router.post('/api/admin/upload/image', adminAuth, controller.admin.uploadImage); router.post('/api/admin/upload/news-image', adminAuth, controller.admin.uploadNewsImage); + // 新闻附件上传(R176):成绩公示表以附件形式挂在新闻里,而不是把链接写进正文 + // (正文会被人工作编辑,手写链接极易被删)。与 news-image 同目录、同访问前缀。 + router.post('/api/admin/upload/news-file', adminAuth, controller.admin.uploadNewsFile); // 下载考生答案 Word 文件 router.get('/api/admin/submissions/:group/:id/word', adminAuth, controller.admin.downloadWord); diff --git a/app/service/admin.js b/app/service/admin.js index 31a6c1a..3082fb6 100644 --- a/app/service/admin.js +++ b/app/service/admin.js @@ -29,6 +29,78 @@ const { inspectIdNumber } = require('../lib/idCard'); const OBJECTIVE_SCORE_PER_QUESTION = 1; const OBJECTIVE_TYPES = ['choice', 'judge']; +/** + * 批量解析「小组编号」标签(R178 · 成绩管理「小组编号」列) + * + * 需求原文:「成绩管理中,需要有小组编号来方便查证小组是否完成考试。」 + * 成绩总览原本只有姓名/身份证号,无法按小组归并核对 ⇒ 补一列人类可读的小组编号。 + * + * 🔴 口径必须与「选手管理」一致,⛔ 不得另立第二套(对照 `getAccounts` 的 R90 段): + * · 小组键 = (组别 group, 队伍号 teamUid),⛔ 不可单用 teamUid(跨组重号) + * · teamUid 解析优先级:`aggregation.team_uid`(报名表聚合,权威)→ `contestants.team_id`(兜底) + * · 展示值取 `teams.team_id`(人类可读队伍编号,如 BRICS2026-ST-027-91001), + * 缺 `team_number` 时回落同一值 —— 与选手管理 `teamNumber` 口径相同 + * + * ⚠️ 为什么不能直接展示 `contestants.team_id`:该列是**内部数字**(实测王学卿=8), + * 与选手管理展示的报名队伍编号不是一回事;两页显示不同值会让「按编号核对」失效。 + * ⚠️ 为什么不能只查 teams 表:实测中职组张明远 `aggregation.team_uid=1` 但 teams 表 + * 没有 (zhongzhi, 1) 行 —— 属「有归属无元信息」的历史数据,此时标签为 null(前端显示「未编组」)。 + * + * @param {Object} ctx - Egg 上下文(用 ctx.model 取模型) + * @param {Array<{id:number, group:string, team_id:?number}>} contestants + * @returns {Promise>} + * 键 = String(contestant.id);无归属者不出现在 Map 中(调用方按 null 处理) + */ +async function resolveTeamLabels(ctx, contestants) { + const out = new Map(); + if (!Array.isArray(contestants) || contestants.length === 0) return out; + + const Op = ctx.model.Sequelize.Op; + const ids = contestants.map(c => c.id); + + // 1) aggregation.team_uid(权威来源) + const aggTeamUid = new Map(); + const aggRows = await ctx.model.Aggregation.findAll({ + where: { account_uid: { [Op.in]: ids } }, + attributes: ['account_uid', 'team_uid'], + raw: true, + }); + for (const a of aggRows) { + if (a.team_uid === null || a.team_uid === undefined) continue; + if (!aggTeamUid.has(a.account_uid)) aggTeamUid.set(a.account_uid, a.team_uid); + } + + // 2) 逐人定 teamUid(aggregation 优先 → contestants.team_id 兜底) + const teamUidOf = c => { + if (aggTeamUid.has(c.id)) return aggTeamUid.get(c.id); + return c.team_id === null || c.team_id === undefined ? null : c.team_id; + }; + + // 3) 一次性取队伍元信息(按 (group, teamUid) 建索引,避免跨组重号串组) + const uids = Array.from(new Set(contestants.map(teamUidOf).filter(u => u !== null))); + const meta = new Map(); + if (uids.length > 0) { + const teamRows = await ctx.model.Team.findAll({ + where: { team_uid: { [Op.in]: uids } }, + attributes: ['team_uid', 'group_name', 'team_id', 'team_number'], + raw: true, + }); + for (const t of teamRows) meta.set(t.group_name + '#' + t.team_uid, t); + } + + for (const c of contestants) { + const teamUid = teamUidOf(c); + if (teamUid === null) continue; + const m = meta.get(c.group + '#' + teamUid) || null; + out.set(String(c.id), { + teamUid, + teamId: m ? m.team_id : null, + teamNumber: m ? (m.team_number || m.team_id) : null, + }); + } + return out; +} + /** * 管理后台服务 - AdminService * @@ -75,15 +147,25 @@ class AdminService extends Service { }); const contestantIds = contestants.map(c => c.id); if (contestantIds.length > 0) { - const SubmissionModel = (group === 'zhongzhi' || group === 'gaozhi') + const isExamGroup = (group === 'zhongzhi' || group === 'gaozhi'); + const SubmissionModel = isExamGroup ? ctx.model.ExamSubmission : ctx.model.WorkSubmission; const submissions = await SubmissionModel.findAll({ where: { contestant_id: contestantIds }, - attributes: ['contestant_id', 'score'], + attributes: isExamGroup + ? ['contestant_id', 'score', 'submit_time'] + : ['contestant_id', 'score'], raw: true, }); - const uniqueIds = new Set(submissions.map(s => s.contestant_id)); + // 🔴 R178(B④)仪表盘「已提交」口径修正 + // 旧实现直接数 exam_submissions 的**行数**,而进考场就会建占位行(submit_time=NULL) + // ⇒ 只是进过考场的人全被算成「已提交」,提交数与提交率双双虚高。 + // 现在中职/高职只统计正式交卷(submit_time 非空);本科/教师仍按「上传作品即有行」。 + const effective = isExamGroup + ? submissions.filter(s => !!s.submit_time) + : submissions; + const uniqueIds = new Set(effective.map(s => s.contestant_id)); submittedCount = uniqueIds.size; totalScore = submissions.reduce((sum, s) => sum + (s.score || 0), 0); } @@ -545,11 +627,16 @@ class AdminService extends Service { // 取一个可用的竞赛 ID 用于聚合报名记录(aggregation.competition_uid 非空约束) let competitionUid = 1; try { + // 🔴 R176:competition_info 是 key-value 表(info_key / info_value), + // 不是三列结构。原实现 `findOne({ attributes:['competition_uid'] })` + // 会抛 `Unknown column`,被下方 catch 吞掉后静默回落默认值 1 —— + // 恰好 info_key='competition_uid' 的值也是 1,所以这个 bug 一直不可见。 const comp = await ctx.model.CompetitionInfo.findOne({ - attributes: ['competition_uid'], - order: [['competition_uid', 'DESC']], + attributes: ['info_value'], + where: { info_key: 'competition_uid' }, }); - if (comp && comp.competition_uid) competitionUid = comp.competition_uid; + const parsed = comp && comp.info_value ? parseInt(comp.info_value, 10) : NaN; + if (isFinite(parsed) && parsed > 0) competitionUid = parsed; } catch (e) { ctx.logger.warn('[import] 获取竞赛 ID 失败,使用默认 1:', e.message); } @@ -730,6 +817,188 @@ class AdminService extends Service { return result; } + /** + * 指导教师回填(R176) + * + * 用户裁定:成绩公示表必须有「指导教师」列,但系统原本无任何承载字段。 + * 报名表里其实有教师行(`身份` = 指导教师/教练…),只是导入时按业务规则 + * 只导入「参赛选手」身份,教师行被计入 ignored 丢弃。 + * + * 🔴 为什么不做成「重跑一次 importContestants」: + * 该流程对已存在的身份证号一律 `skipped++`,重跑不会更新任何字段, + * 只是把整张表再报一遍「已存在」。故单独开一条**只写 teacher_name** 的通路。 + * 🔴 为什么必须带 group:`teams.team_uid` 仅组内唯一、可跨组重号 + * ⇒ 归并键 = (group, 报名表队伍编号 → teams.team_id)。 + * 🔴 本方法**只**写 contestants.teacher_name,不碰账号 / 队伍 / 成绩 / 激活态。 + * + * @param {Buffer} buffer 报名表 xlsx 原始字节(内存解析,不落盘: + * `app/public` 对静态服务开放,报名表含身份证号,落盘即可被猜名下载) + * @param {Object} [options] + * @param {string} [options.group] 仅回填指定组别(不传 = 表里出现的全部组别) + * @param {boolean} [options.overwrite=true] false = 只填空缺,不覆盖已有值 + * @returns {Promise} + */ + async backfillTeachers(buffer, options = {}) { + const { ctx } = this; + const teacherBackfill = require('../lib/teacherBackfill'); + const { Op } = ctx.app.Sequelize; + + if (!buffer || !buffer.length) { + return { ok: false, code: 'EMPTY_BUFFER', message: '未收到文件内容' }; + } + + let aoa; + try { + const workbook = XLSX.read(buffer, { type: 'buffer' }); + const sheetName = workbook.SheetNames[0]; + if (!sheetName) { + return { ok: false, code: 'EMPTY_FILE', message: '文件里没有工作表' }; + } + aoa = XLSX.utils.sheet_to_json(workbook.Sheets[sheetName], { header: 1, blankrows: true }); + } catch (e) { + return { ok: false, code: 'PARSE_FAILED', message: `无法解析为 Excel:${e.message}` }; + } + + const parsed = teacherBackfill.extractTeachers(aoa); + if (!parsed.ok) { + return { + ok: false, + code: parsed.code, + message: parsed.message, + stats: parsed.stats, + warnings: parsed.errors, + }; + } + + // 可选:只处理指定组别(管理端按组操作时避免误改其他组) + const onlyGroup = options.group ? String(options.group) : ''; + const targets = onlyGroup + ? parsed.teams.filter(t => t.group === onlyGroup) + : parsed.teams; + + if (targets.length === 0) { + return { + ok: false, + code: 'NO_TEACHER_IN_GROUP', + message: onlyGroup + ? `报名表里没有属于「${onlyGroup}」组的指导教师行` + : '没有可回填的小组', + stats: parsed.stats, + warnings: parsed.errors, + }; + } + + const overwrite = options.overwrite !== false; + + // 一次性取回本次涉及组别的队伍映射:key = group\u0000team_id + const groupSet = Array.from(new Set(targets.map(t => t.group))); + const teamRows = await ctx.model.Team.findAll({ + attributes: ['team_uid', 'team_id', 'group_name'], + where: { group_name: { [Op.in]: groupSet } }, + raw: true, + }); + const teamKeyMap = new Map(); + for (const t of teamRows) { + if (t.team_uid === null || t.team_uid === undefined) continue; + teamKeyMap.set(`${t.group_name}\u0000${t.team_id}`, t.team_uid); + } + + const details = []; + const unmatched = []; + let matchedTeams = 0; + let updatedRows = 0; + let unchangedRows = 0; + let noMemberTeams = 0; + + for (const t of targets) { + const teamUid = teamKeyMap.get(`${t.group}\u0000${t.teamId}`); + if (teamUid === undefined) { + unmatched.push({ + group: t.group, + teamId: t.teamId, + reason: '本组 teams 表里没有该队伍编号(可能尚未导入选手,或编号写法不一致)', + }); + continue; + } + matchedTeams++; + + const teacherText = teacherBackfill.joinNames(t.names); + const members = await ctx.model.Contestant.findAll({ + attributes: ['id', 'name', 'teacher_name'], + where: { team_id: teamUid, group: t.group }, + raw: true, + }); + if (members.length === 0) { + noMemberTeams++; + details.push({ + group: t.group, + teamId: t.teamId, + teamUid, + teacherName: teacherText, + memberCount: 0, + affected: 0, + note: '该队伍在 contestants 里没有成员', + }); + continue; + } + + // 只更新「需要变」的行:overwrite=false 时仅填空缺 + const needIds = []; + for (const m of members) { + const current = m.teacher_name === null || m.teacher_name === undefined ? '' : String(m.teacher_name); + if (!overwrite && current !== '') { + unchangedRows++; + continue; + } + if (current === teacherText) { + unchangedRows++; + continue; + } + needIds.push(m.id); + } + + if (needIds.length > 0) { + // 🔴 只写 teacher_name 一列:显式 attributes 白名单,避免把整行对象 + // 交给 update 时顺手带回 score / word_score 等无关列(Sequelize 会只更新 + // 传入的键,但显式白名单能让后续 review 一眼看出写入面)。 + await ctx.model.Contestant.update( + { teacher_name: teacherText }, + { where: { id: { [Op.in]: needIds } } } + ); + updatedRows += needIds.length; + } + + details.push({ + group: t.group, + teamId: t.teamId, + teamUid, + teacherName: teacherText, + memberCount: members.length, + affected: needIds.length, + rowNumbers: t.rowNumbers, + }); + } + + return { + ok: true, + code: 'OK', + message: `已回填 ${updatedRows} 名选手的指导教师(命中 ${matchedTeams} 个小组)`, + stats: parsed.stats, + teams: { + total: targets.length, + matched: matchedTeams, + unmatched: unmatched.length, + noMember: noMemberTeams, + }, + updated: updatedRows, + unchanged: unchangedRows, + details, + unmatched, + // 教师行本身的问题(缺组别 / 缺队伍编号 / 姓名为空)——非致命,逐行透出 + warnings: parsed.errors, + }; + } + // ==================== 提交记录管理 ==================== /** @@ -746,7 +1015,10 @@ class AdminService extends Service { const contestants = await ctx.model.Contestant.findAll({ where: { group }, - attributes: ['id', 'name', 'id_number', 'phone', 'group', 'score', 'exam_score', 'word_score'], + // R178:补 team_id —— 后台需要「小组编号」核对某小组是否整体完成考试。 + // 组的归属只认 contestants.team_id(= teams.team_uid),⛔ 不可回落到 + // aggregation.team_uid(生产实测两者不同号,会把不同小组混成一个)。 + attributes: ['id', 'name', 'id_number', 'phone', 'group', 'score', 'exam_score', 'word_score', 'team_id'], order: [['id', 'ASC']], }); @@ -817,10 +1089,17 @@ class AdminService extends Service { const autoScore = subs.reduce((sum, s) => sum + (s.score || 0), 0); const wordSub = wordMap[String(c.id)]; const hasWord = !!(wordSub && wordSub.word_file_path); - // 正式提交状态:优先以 exam_submissions.submit_time 为准(与选手端 hasSubmitted 同一判定源); - // 历史数据/旧流程未回填 submit_time,但已有逐题答题记录(answeredCount>0)同样视为已提交, - // 与 CSV 导出(answeredCount>0 -> 是)保持一致,避免「试卷管理恒显未提交」的错位。 + // 🔴 R178(B④)「已提交」口径拆分 + // + // 旧写法:`submitted: !!formalSubmitTime || answeredCount > 0` + // ⇒ 只要答过一题就算「已提交」,没点交卷的考生在后台也显示「已提交」, + // 管理员无法据此判断谁真的交卷了;`submitTime` 还会退回首条答题行的 + // created_at,把「第一次答题时间」显示成「提交时间」。 + // 现在:submitted / formalSubmitted = 是否**正式交卷**,唯一判据 = exam_submissions.submit_time + // (与选手端 hasSubmitted 同一判定源);作答进度由 answeredCount / totalQuestions 单独表达。 const formalSubmitTime = (wordSub && wordSub.submit_time) || null; + const formalSubmitted = !!formalSubmitTime; + const firstAnswerAt = subs.length ? subs[0].created_at : null; return { contestantId: c.id, aggregationUid: String(c.id), @@ -829,6 +1108,8 @@ class AdminService extends Service { idNumber: c.id_number, phone: c.phone, group: c.group, + // 小组编号(teams.team_uid);为空表示该选手未编入小组(导入时缺小队列的历史数据) + teamId: c.team_id === null || c.team_id === undefined ? null : String(c.team_id), answeredCount, totalQuestions, autoScore, @@ -839,8 +1120,10 @@ answeredCount, hasWordFile: hasWord, wordFilePath: (wordSub && wordSub.word_file_path) || null, wordFileName: (wordSub && wordSub.word_file_name) || null, - submitted: !!formalSubmitTime || answeredCount > 0, - submitTime: formalSubmitTime || (subs.length > 0 ? subs[0].created_at : null), + submitted: formalSubmitted, + formalSubmitted, + firstAnswerAt, + submitTime: formalSubmitTime, // 卷面分(客观分)统一取客观题自动评分合计,与成绩管理表口径一致。 // ⚠️ 此前本行与上方 `examScore: c.exam_score ?? 0` 重复定义(对象字面量重复键,后者静默胜出); // 虽因 updateScores 会同步写 exam_score 而「结果碰巧一致」,但属隐患,故删除上方那条只留本条。 @@ -1024,13 +1307,17 @@ answeredCount, // 故此处取 item.wordScore,仅表头文案按现行口径显示为「大题分」。 const submissions = await this.getExamSubmissions(group); return { - headers: ['选手ID', '身份证号', '姓名', '手机号', '是否提交', '客观题分', '大题分', '总分'], + // 🔴 R178(B④):原「是否提交」列取 `answeredCount > 0`,与页面/接口口径(submit_time) + // 不一致,同一份数据导出与页面显示会互相矛盾。现在拆为「是否交卷」(submit_time) + // 与「答题数」两列,口径与 getExamSubmissions 的 submitted 完全同源。 + headers: ['选手ID', '身份证号', '姓名', '手机号', '是否交卷', '答题数', '客观题分', '大题分', '总分'], rows: submissions.map(item => [ item.contestantId, item.idNumber || '', item.userName || '', item.phone || '', - item.answeredCount > 0 ? '是' : '否', + item.submitted ? '是' : '否', + item.answeredCount ?? 0, item.examScore ?? 0, item.wordScore ?? 0, item.totalScore ?? 0, @@ -1107,8 +1394,199 @@ answeredCount, return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' }); } - // ==================== 批量下载 ZIP ==================== + /** + * 成绩公示表导出(R176) + * + * 产物 = 赛事官方参考模板(`lib/assets/publicity-template.xlsx`)的 xlsx 半成品: + * 数据行已填,**获奖等级留空**待人工填。 + * + * 行粒度(用户裁定):**一行 = 一个小组**。系统规则是「一组一人参赛、成绩全组共享」, + * 所以同一小组不会因为多人而占多行;`参赛选手` / `指导教师` 两列用「、」连接全体。 + * 没有队伍归属的选手(`team_id IS NULL`,单人参赛)各自一行。 + * + * 🔴 `contestants.score` 只在「组内唯一抽题者」名下非空(见 score.js 的共享读), + * 因此按组取 **MAX(score)**,⛔ 不能取第一行 —— 否则会取到 NULL。 + * 🔴 单位取 `aggregation.organization_cn`(`account_uid` = 选手 id), + * 组内取首个非空值(同队理论上相同)。 + * 🔴 本表**不含**身份证号 / 手机号,可直接对外;内部核查请用 exportToXlsx。 + * + * @param {string} group + * @returns {Promise<{buffer: Buffer, fileName: string, rowCount: number, testCount: number, title: Object}>} + */ + async buildPublicityXlsx(group) { + const { ctx } = this; + const publicitySheet = require('../lib/publicitySheet'); + const fs = require('fs'); + const path = require('path'); + const { Op } = ctx.app.Sequelize; + const GROUP_LABEL = { zhongzhi: '中职组', gaozhi: '高职组', benke: '本科组', jiaoshi: '教师组' }; + const groupLabel = GROUP_LABEL[group] || group; + + // ---- 1) 赛项名称 / 编号(管理后台「赛项设置」维护的两项)---- + let itemName = ''; + let itemCode = ''; + try { + const cfgRows = await ctx.model.CompetitionInfo.findAll({ + attributes: ['info_key', 'info_value'], + where: { info_key: { [Op.in]: ['competition_item_name', 'competition_item_code'] } }, + raw: true, + }); + for (const r of cfgRows) { + const v = r.info_value === null || r.info_value === undefined ? '' : String(r.info_value).trim(); + if (r.info_key === 'competition_item_name') itemName = v; + if (r.info_key === 'competition_item_code') itemCode = v; + } + } catch (e) { + // 配置缺失不应阻断导出:标题保持模板占位(***赛项 / 空编号),人工再补 + ctx.logger.warn('[publicity] 读取赛项配置失败,标题使用模板占位:%s', e.message); + } + + // ---- 2) 选手(⛔ 不筛 test_flag:与 exportToXlsx 口径一致;测试行数单独统计给前端提示)---- + const contestants = await ctx.model.Contestant.findAll({ + attributes: ['id', 'name', 'score', 'team_id', 'teacher_name', 'test_flag'], + where: { group }, + raw: true, + }); + + // ---- 3) 队伍编号(报名表里的「队伍编号」落在 teams.team_id)---- + const teams = await ctx.model.Team.findAll({ + attributes: ['team_uid', 'team_id'], + where: { group_name: group }, + raw: true, + }); + const teamIdByUid = new Map(); + for (const t of teams) { + if (t.team_uid !== null && t.team_uid !== undefined) teamIdByUid.set(t.team_uid, t.team_id); + } + + // ---- 4) 参赛单位 ---- + const aggRows = await ctx.model.Aggregation.findAll({ + attributes: ['account_uid', 'organization_cn'], + where: { group }, + raw: true, + }); + const orgByCid = new Map(); + for (const a of aggRows) { + const v = a.organization_cn === null || a.organization_cn === undefined ? '' : String(a.organization_cn).trim(); + if (v && a.account_uid !== null && a.account_uid !== undefined) orgByCid.set(a.account_uid, v); + } + + // ---- 5) 按小组聚合 ---- + const bucket = new Map(); + let testCount = 0; + let noTeamCount = 0; + for (const c of contestants) { + if (c.test_flag === 1) testCount++; + const hasTeam = c.team_id !== null && c.team_id !== undefined; + // 🔴 组归属只认 contestants.team_id(= teams.team_uid)——这是系统里唯一可靠的键。 + // ⛔ 不要回落到 aggregation.team_uid:那是另一套编号(实测本地 zhongzhi 的 + // aggregation.team_uid=1 而 teams.team_uid=8),拿它归组会把不同队伍并到一起。 + // 无 team_id 的选手按「单人参赛」各自成行,并把人数透出给前端提示 + // —— 覆盖率不足是数据问题,⛔ 不能在导出时静默猜。 + if (!hasTeam) noTeamCount++; + const key = hasTeam ? `team:${c.team_id}` : `single:${c.id}`; + let item = bucket.get(key); + if (!item) { + item = { + teamUid: hasTeam ? c.team_id : null, + teamId: hasTeam ? String(teamIdByUid.get(c.team_id) || c.team_id) : '', + org: '', + players: [], + teachers: [], + score: null, + }; + bucket.set(key, item); + } + if (c.name) item.players.push(String(c.name).trim()); + // teacher_name 是「、」连接的整串,拆开交给 buildPublicityRows 统一去重拼接 + if (c.teacher_name) { + for (const n of String(c.teacher_name).split('、')) { + const s = n.trim(); + if (s) item.teachers.push(s); + } + } + if (c.score !== null && c.score !== undefined) { + const s = Number(c.score); + if (isFinite(s) && (item.score === null || s > item.score)) item.score = s; + } + const org = orgByCid.get(c.id); + if (org && !item.org) item.org = org; + } + + const rows = publicitySheet.buildPublicityRows(Array.from(bucket.values())); + + // ---- 6) 套模板 ---- + const tplPath = path.join(__dirname, '../lib/assets/publicity-template.xlsx'); + const template = fs.readFileSync(tplPath); + const built = publicitySheet.buildXlsx(template, rows, { itemName, itemCode }); + + const now = new Date(); + const pad = n => String(n).padStart(2, '0'); + const dateTag = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`; + + return { + buffer: built.buffer, + fileName: `成绩公示表-${groupLabel}-${dateTag}.xlsx`, + rowCount: rows.length, + testCount, + noTeamCount, + totalContestants: contestants.length, + title: built.title, + // 赛项名/编号为空时前端要提示「标题仍是模板占位」 + itemName, + itemCode, + }; + } + + // ==================== 赛项信息配置(R176) ==================== + + /** 成绩公示表标题要用的两项配置。读:`competition_info` 的 key-value 行 */ + async getCompetitionItemInfo() { + const { ctx } = this; + const { Op } = ctx.app.Sequelize; + const out = { itemName: '', itemCode: '' }; + const rows = await ctx.model.CompetitionInfo.findAll({ + attributes: ['info_key', 'info_value'], + where: { info_key: { [Op.in]: ['competition_item_name', 'competition_item_code'] } }, + raw: true, + }); + for (const r of rows) { + const v = r.info_value === null || r.info_value === undefined ? '' : String(r.info_value); + if (r.info_key === 'competition_item_name') out.itemName = v; + if (r.info_key === 'competition_item_code') out.itemCode = v; + } + return out; + } + + /** + * 保存赛项信息。 + * 🔴 用 upsert 语义而非「先删后插」:`competition_info` 的 id 是自增主键, + * 删了重建会让 id 漂移,且若中途失败会留下半张表。 + * @param {{itemName?: string, itemCode?: string}} payload + */ + async setCompetitionItemInfo(payload = {}) { + const { ctx } = this; + const pairs = [ + ['competition_item_name', payload.itemName], + ['competition_item_code', payload.itemCode], + ]; + const saved = {}; + for (const [key, raw] of pairs) { + if (raw === undefined) continue; // 未提交的键不动 + const value = raw === null ? '' : String(raw).trim(); + const exist = await ctx.model.CompetitionInfo.findOne({ where: { info_key: key } }); + if (exist) { + await exist.update({ info_value: value }); + } else { + await ctx.model.CompetitionInfo.create({ info_key: key, info_value: value }); + } + saved[key] = value; + } + return saved; + } + + // ==================== 批量下载 ZIP ==================== /** * 批量下载某组所有提交文件,打包为 ZIP * 文件重命名为 "组号-学校-姓名-原文件名" 格式 @@ -1685,7 +2163,7 @@ answeredCount, const isExam = group === 'zhongzhi' || group === 'gaozhi'; const contestants = await ctx.model.Contestant.findAll({ where: { group }, - attributes: ['id', 'name', 'id_number', 'group', 'score', 'exam_score', 'word_score', 'ppt_score'], + attributes: ['id', 'name', 'id_number', 'group', 'score', 'exam_score', 'word_score', 'ppt_score', 'team_id'], order: [['id', 'ASC']], raw: true, }); @@ -1714,6 +2192,17 @@ answeredCount, pptScore: c.ppt_score ?? 0, })); + // R178:小组编号(与「选手管理」同源口径,见模块级 resolveTeamLabels 注释)。 + // 前端按 teamId 渲染;teamId 为 null ≠ 「没有小组」的权威判据,仅表示「无队伍元信息」, + // 因此另附 teamUid 便于排查「有归属但缺 teams 行」的历史数据。 + const teamLabels = await resolveTeamLabels(ctx, contestants); + list.forEach(item => { + const t = teamLabels.get(String(item.uid)) || null; + item.teamUid = t ? t.teamUid : null; + item.teamId = t ? t.teamId : null; + item.teamNumber = t ? t.teamNumber : null; + }); + if (isExam) { const SubmissionModel = group === 'zhongzhi' ? ctx.model.ZhongzhiSubmission @@ -1752,9 +2241,13 @@ answeredCount, item.score = autoScore + item.wordScore; const es = examMap[String(item.uid)]; item.hasWordFile = !!(es && es.word_file_path); - item.submitted = !!(es && es.submit_time) || answeredCount > 0; - item.submitTime = (es && es.submit_time) - || (answeredCount > 0 ? (es && es.created_at) || null : null); + // 🔴 R178(B④)与「考生答题记录」同一口径:submitted = 是否**正式交卷** + //(唯一判据 exam_submissions.submit_time);作答进度看 answeredCount。 + // ⛔ 不再用 es.created_at(进考场建占位行的时间)冒充 submitTime。 + item.formalSubmitted = !!(es && es.submit_time); + item.submitted = item.formalSubmitted; + item.submitTime = (es && es.submit_time) || null; + item.firstAnswerAt = answeredCount > 0 ? (es && es.created_at) || null : null; }); } else { // 本科/教师组:上传作品即视为已提交(与考生端 exam.status.submitted、作品管理口径一致)。 @@ -2014,34 +2507,10 @@ answeredCount, return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' }); } - // ==================== 新闻查询 ==================== - - /** - * 获取所有新闻列表(管理员视图,包含未发布) - * @returns {Promise} 新闻列表 - */ - async listNews() { - const { ctx } = this; - const news = await ctx.model.News.findAll({ - order: [['pinned', 'DESC'], ['sort_order', 'ASC'], ['publish_time', 'DESC']], - raw: true, - }); - - return news.map(n => ({ - id: n.id, - title: n.title, - content: n.content, - news_type: n.news_type, - wx_link: n.wx_link, - publish_time: n.publish_time, - pinned: n.pinned, - group: n.group, - tags: n.tags, - sort_order: n.sort_order, - created_at: n.created_at, - updated_at: n.updated_at, - })); - } +// R177:删掉此处一个**死代码版** `listNews()` —— 它无分页、缺 is_draft/附件字段、 +// 排序按 sort_order,且在同一个 class 内被下方同名方法覆盖(JS class 后定义胜出)⇒ 永远不执行。 +// ⚠️ 新闻列表的唯一生效实现 = 本文件下方的 `async listNews(query)`(分页 + 含 is_draft/附件字段)。 +// 保留这行说明是为了防止后人「以为这里还有一份实现」而改到静默无效的位置。 // ==================== 轮播图查询 ==================== @@ -2169,9 +2638,99 @@ answeredCount, } catch (bkErr) { this.ctx.logger.error('[publishScores] 节点备份触发失败(不影响发布):%s', bkErr && bkErr.message); } + + // ---- 成绩公示预览新闻(R176)---- + // 用户裁定:成绩发布后直接生成「包含附件的预览新闻」,等人工编辑补齐获奖等级后再发布。 + // 🔴 必须是**草稿**(is_draft=1):news.list / findById 都会过滤 is_draft=1, + // 否则未定稿的成绩会立刻出现在前台新闻列表 ⇒ 等于提前公示错误成绩。 + // ⛔ 与节点备份同理:生成失败不得让「发布成绩」这个动作失败。 + try { + const targets = group ? [ group ] : this.constructor.PUBLISH_GROUPS; + await this._ensurePublicityDraft(targets); + } catch (pubErr) { + this.ctx.logger.error('[publishScores] 生成公示预览新闻失败(不影响发布):%s', pubErr && pubErr.message); + } } } + /** + * 为指定组别生成「成绩公示」草稿新闻(R176)。 + * + * 幂等:同组已有草稿(tags = 'publicity-draft' 且 is_draft=1)时**覆盖**其附件与正文, + * 不新增第二条 —— 反复发布/撤销再发布会堆出一串重复新闻。 + * 覆盖时旧附件文件要一并清理,否则 `app/public/news` 会随发布次数线性膨胀。 + * + * @param {string[]} groups + * @returns {Promise>} 每组的结果摘要(仅供日志/排查) + */ + async _ensurePublicityDraft(groups) { + const { ctx } = this; + const fs = require('fs'); + const path = require('path'); + + const GROUP_LABEL = { zhongzhi: '中职组', gaozhi: '高职组', benke: '本科组', jiaoshi: '教师组' }; + const DRAFT_TAG = 'publicity-draft'; + const results = []; + + for (const g of groups) { + try { + const built = await this.buildPublicityXlsx(g); + + const dir = path.join(this.app.baseDir, 'app/public/news'); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + const storedName = `news_file_publicity_${g}_${Date.now()}.xlsx`; + fs.writeFileSync(path.join(dir, storedName), built.buffer); + const fileUrl = `/public/news/${storedName}`; + + const label = GROUP_LABEL[g] || g; + const title = `${label}成绩公示(待审核)`; + const content = [ + `

本公示表按成绩管理数据自动生成,尚未定稿,请核对数据并补齐「获奖等级」后再对外发布。

`, + '

填表说明:一行代表一个参赛小组;「参赛选手」「指导教师」列多人时以顿号分隔;', + '总分数按由高到低排序,「成绩排名」同序排列。

', + `

本次导出共 ${built.rowCount} 个小组${built.testCount ? `(其中 ${built.testCount} 人属于测试数据,发布前请确认是否需要剔除)` : ''}。

`, + ].join(''); + + const exist = await ctx.model.News.findOne({ where: { group: g, is_draft: 1, tags: DRAFT_TAG } }); + if (exist) { + const oldUrl = exist.attachment_url; + await exist.update({ + title, + content, + news_type: 'content', + attachment_url: fileUrl, + attachment_name: built.fileName, + }); + // 旧附件清理:只删本流程自己产出的文件(前缀 + 目录双重限定,绝不误删人工上传的图/文件) + if (oldUrl && oldUrl !== fileUrl && oldUrl.indexOf('/public/news/news_file_publicity_') === 0) { + ctx.helper.safeCleanup(path.join(this.app.baseDir, 'app', oldUrl.replace(/^\//, '')), { + label: 'publicity-draft-old', + }); + } + results.push({ group: g, newsId: exist.id, action: 'updated', fileUrl }); + } else { + const created = await this.createNews({ + title, + content, + news_type: 'content', + group: g, + tags: DRAFT_TAG, + is_draft: 1, + publish_time: new Date(), + attachment_url: fileUrl, + attachment_name: built.fileName, + }); + results.push({ group: g, newsId: created.id, action: 'created', fileUrl }); + } + } catch (e) { + ctx.logger.error('[publicity] 组 %s 生成公示预览失败:%s', g, e.message); + results.push({ group: g, action: 'error', message: e.message }); + } + } + + return results; + } + /** * 检查成绩是否已发布 * @param {string} [group] - 组别;缺省时要求「全部组均已发布」才返回 true @@ -2235,6 +2794,11 @@ answeredCount, pinned: newsData.pinned || false, group: newsData.group || newsData.group_name || 'all', tags: newsData.tags || null, + // R176:草稿标记与附件。缺省 is_draft=0(已发布)保持既有调用方行为不变; + // 成绩发布自动生成的预览新闻会显式传 1,人工编辑确认后再置 0。 + is_draft: newsData.is_draft ? 1 : 0, + attachment_url: newsData.attachment_url || null, + attachment_name: newsData.attachment_name || null, }); } @@ -2257,6 +2821,10 @@ answeredCount, pinned: newsData.pinned !== undefined ? newsData.pinned : news.pinned, group: newsData.group !== undefined ? newsData.group : (newsData.group_name !== undefined ? newsData.group_name : news.group), tags: newsData.tags !== undefined ? newsData.tags : news.tags, + // R176:附件与草稿。人工在新闻管理里「发布」= 把 is_draft 置 0。 + is_draft: newsData.is_draft !== undefined ? (newsData.is_draft ? 1 : 0) : news.is_draft, + attachment_url: newsData.attachment_url !== undefined ? (newsData.attachment_url || null) : news.attachment_url, + attachment_name: newsData.attachment_name !== undefined ? (newsData.attachment_name || null) : news.attachment_name, }); return news; diff --git a/app/service/adminUser.js b/app/service/adminUser.js new file mode 100644 index 0000000..ff80557 --- /dev/null +++ b/app/service/adminUser.js @@ -0,0 +1,270 @@ +'use strict'; + +/** + * 管理员账号管理(R178 · C 层) + * ============================================================================ + * + * 职责:多管理员账号的增 / 改权限 / 重置口令 / 禁用启用 / 删除,以及高危操作审计。 + * + * 三条不可协商的边界: + * + * 1. **最高管理员不可变动**(用户裁定):`is_super=1` 的账号不允许改权限、重置口令、 + * 禁用、删除。判断集中在 `lib/adminPermissions.isManageable()`,⛔ 不得在各方法里 + * 各写一遍 `id === 1` —— 那样改一处漏一处,且将来账号 id 变化就失效。 + * + * 2. **口令策略单源**:一律走 `lib/passwordPolicy.checkPassword()`, + * ⛔ 不在这里写第二套长度/黑名单规则(第 25 步门禁会比对两套规则是否漂移)。 + * 生产环境另外拒绝「已知默认口令」,与 adminLogin 的守卫同源。 + * + * 3. **令牌失效靠自增**:改密 / 禁用时 `token_version += 1`。 + * middleware/auth.js 每请求比对,旧令牌立即 401。本项目没有会话表, + * 这是唯一的「强制下线」手段。 + */ + +const Service = require('egg').Service; +const passwordPolicy = require('../lib/passwordPolicy'); +const adminPermissions = require('../lib/adminPermissions'); +const { recordAudit, ACTIONS } = require('../lib/auditLog'); + +/** 用户名规则:字母数字与 _ . - ,3~50 位。⛔ 不允许中文与空格,避免与登录校验规则不一致。 */ +const USERNAME_RE = /^[A-Za-z0-9_.-]{3,50}$/; +const REMARK_MAX = 200; + +/** 业务错误:由 controller 翻译成 { code, message },不直接改 HTTP 状态。 */ +function bizError(code, message) { + const err = new Error(message); + err.bizCode = code; + err.bizMessage = message; + return err; +} + +class AdminUserService extends Service { + /** + * 管理员列表(脱敏:绝不返回 password) + * 排序:最高管理员置顶,其余按 id 升序 —— 与「主账号永远第一行」的视觉预期一致。 + */ + async list() { + const { ctx } = this; + const rows = await ctx.model.Admin.findAll({ + attributes: [ + 'id', 'username', 'is_super', 'permissions', 'status', + 'last_login_at', 'last_login_ip', 'remark', 'created_at', 'updated_at', + ], + order: [['is_super', 'DESC'], ['id', 'ASC']], + raw: true, + }); + + return rows.map(r => ({ + id: r.id, + username: r.username, + isSuper: Number(r.is_super) === 1, + permissions: adminPermissions.parsePermissions(r.permissions), + status: Number(r.status) === 1 ? 1 : 0, + lastLoginAt: r.last_login_at || null, + lastLoginIp: r.last_login_ip || null, + remark: r.remark || '', + createdAt: r.created_at || null, + // 前端据此把「改权限/重置口令/禁用/删除」四个按钮整体置灰 + manageable: adminPermissions.isManageable(r), + })); + } + + /** + * 取一个**可被管理**的目标账号。 + * @throws bizCode 400 不存在 / 403 最高管理员不可变动 + */ + async _loadManageable(id) { + const { ctx } = this; + const numericId = Number(id); + if (!Number.isInteger(numericId) || numericId <= 0) { + throw bizError(400, '管理员 ID 不合法'); + } + const admin = await ctx.model.Admin.findOne({ + where: { id: numericId }, + attributes: ['id', 'username', 'is_super', 'permissions', 'status', 'token_version'], + raw: true, + }); + if (!admin) throw bizError(400, '该管理员账号不存在'); + if (!adminPermissions.isManageable(admin)) { + throw bizError(403, '最高管理员账号不可修改(权限 / 口令 / 状态 / 删除均被禁止)'); + } + return admin; + } + + /** + * 新增管理员 + * + * @param {{username:string, password:string, permissions:string[], remark?:string}} data + */ + async create(data) { + const { ctx } = this; + const username = String((data && data.username) || '').trim(); + const password = String((data && data.password) || ''); + + if (!USERNAME_RE.test(username)) { + throw bizError(400, '用户名需为 3-50 位字母、数字、下划线、点或短横线'); + } + + const pwdCheck = passwordPolicy.checkPassword(password, { username }); + if (!pwdCheck.ok) throw bizError(400, pwdCheck.reason); + + // 与 adminLogin 的守卫同源:生产环境不允许把「已知默认口令」分配给新账号 + if (this.app.config.isProduction && passwordPolicy.DEFAULT_ADMIN_PASSWORDS.includes(password)) { + throw bizError(400, '生产环境禁止使用默认口令,请设置强密码'); + } + + const exists = await ctx.model.Admin.findOne({ where: { username }, attributes: ['id'] }); + if (exists) throw bizError(400, '该用户名已存在'); + + const perms = adminPermissions.sanitizePermissions(data && data.permissions); + const remark = data && data.remark ? String(data.remark).slice(0, REMARK_MAX) : null; + + let created; + try { + created = await ctx.model.Admin.create({ + username, + password: await ctx.helper.hashPassword(password), + is_super: 0, // 新增账号永远不是最高管理员(最高管理员只能由初始化脚本产生) + permissions: JSON.stringify(perms), + status: 1, + token_version: 0, + remark, + created_at: new Date(), + updated_at: new Date(), + }); + } catch (e) { + // 并发下 findOne 与 create 之间仍可能撞唯一索引 + if (/ER_DUP_ENTRY/.test(String(e && e.message))) { + throw bizError(400, '该用户名已存在'); + } + throw e; + } + + await recordAudit(ctx, ACTIONS.ADMIN_CREATE, `admin:${username}`, { + id: created.id, + permissions: perms, + }); + + return { id: created.id, username, permissions: perms, remark: remark || '' }; + } + + /** + * 编辑权限(含备注) + * + * ⚠️ 不递增 token_version:权限是每请求查库判定,改完立即生效; + * 递增反而会把正在操作的人无谓踢下线。 + */ + async updatePermissions(id, data) { + const { ctx } = this; + const target = await this._loadManageable(id); + + const perms = adminPermissions.sanitizePermissions(data && data.permissions); + const patch = { + permissions: JSON.stringify(perms), + updated_at: new Date(), + }; + if (data && data.remark !== undefined) { + patch.remark = data.remark ? String(data.remark).slice(0, REMARK_MAX) : null; + } + + await ctx.model.Admin.update(patch, { where: { id: target.id } }); + + const before = adminPermissions.parsePermissions(target.permissions); + await recordAudit(ctx, ACTIONS.ADMIN_PERMISSIONS, `admin:${target.username}`, { + before, + after: perms, + added: perms.filter(p => !before.includes(p)), + removed: before.filter(p => !perms.includes(p)), + }); + + return { id: target.id, permissions: perms }; + } + + /** + * 重置口令(管理员代改,不要求旧口令) + * 递增 token_version ⇒ 目标账号所有在线会话立即失效。 + */ + async resetPassword(id, data) { + const { ctx } = this; + const target = await this._loadManageable(id); + + const password = String((data && data.password) || ''); + const pwdCheck = passwordPolicy.checkPassword(password, { username: target.username }); + if (!pwdCheck.ok) throw bizError(400, pwdCheck.reason); + + if (this.app.config.isProduction && passwordPolicy.DEFAULT_ADMIN_PASSWORDS.includes(password)) { + throw bizError(400, '生产环境禁止使用默认口令,请设置强密码'); + } + + const nextVersion = Number(target.token_version || 0) + 1; + await ctx.model.Admin.update({ + password: await ctx.helper.hashPassword(password), + token_version: nextVersion, + updated_at: new Date(), + }, { where: { id: target.id } }); + + // ⛔ 审计 detail 不含口令本身,只记事实 + await recordAudit(ctx, ACTIONS.ADMIN_PASSWORD, `admin:${target.username}`, { + tokenVersion: nextVersion, + }); + + return { id: target.id, tokenVersion: nextVersion }; + } + + /** + * 禁用 / 启用 + * 禁用时递增 token_version ⇒ 该账号已签发的令牌下次请求即 401。 + * 启用不递增:重新启用后旧令牌早已因禁用期间的版本差异失效,无需再踢。 + */ + async setStatus(id, data) { + const { ctx } = this; + const target = await this._loadManageable(id); + + const status = Number(data && data.status); + if (status !== 0 && status !== 1) throw bizError(400, '状态值只能是 0(禁用)或 1(启用)'); + + // 不允许把自己禁用 —— 否则可能一次性把所有可用账号锁死 + const me = ctx.state.currentAdmin; + if (me && Number(me.id) === Number(target.id) && status === 0) { + throw bizError(400, '不能禁用自己的账号'); + } + + const patch = { status, updated_at: new Date() }; + if (status === 0) patch.token_version = Number(target.token_version || 0) + 1; + + await ctx.model.Admin.update(patch, { where: { id: target.id } }); + + await recordAudit(ctx, ACTIONS.ADMIN_STATUS, `admin:${target.username}`, { + status, + tokenVersion: patch.token_version || target.token_version, + }); + + return { id: target.id, status }; + } + + /** + * 删除账号 + * 不可删最高管理员(_loadManageable 已挡)、不可删自己。 + */ + async remove(id) { + const { ctx } = this; + const target = await this._loadManageable(id); + + const me = ctx.state.currentAdmin; + if (me && Number(me.id) === Number(target.id)) { + throw bizError(400, '不能删除自己的账号'); + } + + const affected = await ctx.model.Admin.destroy({ where: { id: target.id } }); + if (affected !== 1) throw bizError(500, '删除失败:目标账号已不存在'); + + await recordAudit(ctx, ACTIONS.ADMIN_DELETE, `admin:${target.username}`, { + id: target.id, + lastPermissions: adminPermissions.parsePermissions(target.permissions), + }); + + return { id: target.id, deleted: true }; + } +} + +module.exports = AdminUserService; diff --git a/app/service/exam.js b/app/service/exam.js index dc5576c..bf6dff5 100644 --- a/app/service/exam.js +++ b/app/service/exam.js @@ -402,6 +402,53 @@ class ExamService extends Service { return timeConfig ? timeConfig.word_template : null; } + /** + * 保存答题记录(逐题存储) + * @param {number} aggregationUid - 聚合报名 ID + * @param {Array} answers - 答案数组 [{ question_uid, answer_data, is_correct, score }] + * @param {string} group - 组别 + * @returns {Promise} 保存后的记录列表 + */ + /** + * 交卷冻结守卫(R178·B①)——**唯一判据源** + * + * 缺陷背景:逐题保存此前只校验「考试窗口 isActive」,不校验是否已正式交卷; + * `submit-all` 的 4006 只挡整卷重交。于是「提前交卷 → 窗口关闭前」这段时间内, + * `POST /submit-answers` 仍可改写答案并重新判分 —— 即「已提交但分数还能变」。 + * 典型触发并非恶意:考生开了两个标签页(或手机+电脑),交卷后另一页仍在自动保存。 + * + * 判据:`exam_submissions.submit_time` 非空即视为已正式交卷(与选手端 hasSubmitted 同源)。 + * + * 调用点(两处,共用本函数,⛔ 不得各写一套条件): + * ① controller.exam.submitAnswers —— 前置快速失败,避免对已冻结答卷做无谓判分; + * ② service.exam.saveAnswers —— 兜底,保证任何调用方(含未来新增)都无法绕过。 + * ⚠️ 门禁 `verify_exam_freeze.py` 的变异体要求**两处同改**才允许变红,否则属前道抢答。 + * + * 为什么不缓存:逐题保存约 59 次/场,主键单列查询为微秒级; + * 换来「交卷状态变更立即生效」,无需判断缓存失效时机。 + * + * @param {number} aggregationUid - 聚合报名 ID(中职/高职即 contestant.id) + * @returns {Promise} 未交卷则静默返回 + * @throws {Error} bizCode=4006 + */ + async assertNotSubmitted(aggregationUid) { + const { ctx } = this; + if (!aggregationUid) return; + + const submitted = await ctx.model.ExamSubmission.findOne({ + where: { contestant_id: aggregationUid }, + attributes: ['contestant_id', 'submit_time'], + raw: true, + }); + if (submitted && submitted.submit_time) { + const err = new Error('答卷已提交,不能再修改答案'); + // bizCode 由 controller 翻译成用户可见的业务错误码(4006 与本项目既有「已提交」口径一致) + err.bizCode = 4006; + err.bizMessage = '您已交卷,答卷已冻结,不能再修改答案;如需重考请联系管理员'; + throw err; + } + } + /** * 保存答题记录(逐题存储) * @param {number} aggregationUid - 聚合报名 ID @@ -412,6 +459,12 @@ class ExamService extends Service { async saveAnswers(aggregationUid, answers, group) { const { ctx } = this; + // 交卷冻结兜底:所有写答案的路径都经此函数,故此处是本守卫的最后一道闸。 + // 不会挡住交卷自身 —— submitAll 是「先 saveAnswers、后 ensureExamSubmission 写 + // submit_time」,执行到本函数时 submit_time 仍为 NULL;整卷重交在更早的 4006 已返回; + // settleOnly 结算通道刻意不调用本函数。 + await this.assertNotSubmitted(aggregationUid); + // 根据组别选择对应的提交模型 const SubmissionModel = group === 'zhongzhi' ? ctx.model.ZhongzhiSubmission diff --git a/app/service/news.js b/app/service/news.js index 9e95dda..1838e83 100644 --- a/app/service/news.js +++ b/app/service/news.js @@ -37,7 +37,11 @@ class NewsService extends Service { const offset = (currentPage - 1) * currentPageSize; // 构建查询条件 - const where = {}; + const where = { + // 🔴 R176:草稿只允许后台可见。成绩发布时会自动生成一条「成绩公示」预览新闻, + // 人工编辑确认前绝不能出现在前台列表(否则等于提前公示未定稿成绩)。 + is_draft: 0, + }; // 组别过滤:同时包含指定组别和通用(all)新闻 if (group) { @@ -90,7 +94,8 @@ class NewsService extends Service { // 并行查询各分组计数 await Promise.all(groups.map(async group => { - const where = {}; + // 🔴 R176:计数口径必须与 list 一致,否则首页 Tab 显示「3 条」点进去只有 2 条 + const where = { is_draft: 0 }; // 对于具体组别,同时包含该组别和 all 通用新闻 if (group !== 'all') { where.group = { [Sequelize.Op.in]: [group, 'all'] }; @@ -110,9 +115,10 @@ class NewsService extends Service { async findById(id) { const { ctx } = this; + // 🔴 R176:草稿不可通过 id 直接访问(前台详情页按 id 拉取 ⇒ 只过滤列表等于没防住) return await ctx.model.News.findOne({ - where: { id }, - attributes: ['id', 'title', 'content', 'publish_time', 'pinned', 'group', 'tags', 'sort_order', 'created_at', 'updated_at'], + where: { id, is_draft: 0 }, + attributes: ['id', 'title', 'content', 'publish_time', 'pinned', 'group', 'tags', 'sort_order', 'attachment_url', 'attachment_name', 'created_at', 'updated_at'], }); }