Compare commits
43 Commits
f281d206fb
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 7bca48369f | |||
| c15b46a0f9 | |||
| 78f34ea9f5 | |||
| 0a380371ec | |||
| de719d8ec6 | |||
| 459f1adf6d | |||
| 5392345078 | |||
| 192b43bc67 | |||
| 14a110eb5f | |||
| 4f78995f1c | |||
| ae436c01ca | |||
| 6d3fbd62b4 | |||
| 706ff2507e | |||
| f87ed306d2 | |||
| 76ac1c7b6b | |||
| cf91f6ba34 | |||
| 89b9b2b0da | |||
| 3b4d422e36 | |||
| c94d8fe3ff | |||
| 95ad1adca4 | |||
| 29ce734ffa | |||
| 26a29b2214 | |||
| c101b15e2e | |||
| 481de69e3d | |||
| a471487092 | |||
| 15c9ab6f91 | |||
| 154338e5dd | |||
| d44d466a90 | |||
| a2e24aba2a | |||
| 31da9580e5 | |||
| e8a5b34d42 | |||
| 070195a22c | |||
| 0531131f0e | |||
| e74778ee08 | |||
| 2ab0991310 | |||
| 37d07907a1 | |||
| cbc05ed4e7 | |||
| 079c1b31c9 | |||
| a019df510d | |||
| 4f1e28d643 | |||
| e7af00beb9 | |||
| 23604f2718 | |||
| 8ef18da395 |
20
.gitignore
vendored
20
.gitignore
vendored
@@ -23,6 +23,15 @@ run/
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# 本地开发配置(Egg 仅在 serverEnv=local 时加载,生产不加载)
|
||||
# 含本地 DB 口令,故排除入库;如需团队共享请提交不含口令的 config.local.example.js
|
||||
config/config.local.js
|
||||
|
||||
# sequelize-cli 数据库配置(含本地 DB 口令)—— 2026-09-23 入库口令处置 P1
|
||||
# 仓库内保留不含口令的 database/config.json.example;使用前复制并填入口令:
|
||||
# cp database/config.json.example database/config.json
|
||||
database/config.json
|
||||
|
||||
# Coverage / cache
|
||||
coverage/
|
||||
.cache/
|
||||
@@ -46,6 +55,13 @@ app/public/carousel/
|
||||
app/public/tmp/
|
||||
app/public/exports/
|
||||
|
||||
# Tests (non-required; code runs without them) — 上传目的=运行必须,test 不入库
|
||||
test/
|
||||
# Tests —— 上传目的=运行必须:`test/` 下的夹具 / 临时物 / 子目录**不入库**。
|
||||
# 例外(P1 ㉚a · R134 候选 ㊴ 修复):`test/` 顶层的 `*.test.js` 最小单测集**必须入库** ——
|
||||
# `scripts/ci-gates.sh` 第 26 步(裸 mocha)直跑它;若被忽略则「本地绿 ≠ 可复现绿」
|
||||
# (干净检出上该步拿不到文件 ⇒ 必红)。
|
||||
# 注:此处必须用 `test/*` 而非 `test/` —— 目录被整体排除后 git 不再进入该目录,
|
||||
# 其内文件无法用 `!` 反忽略(R134 已实测:`test/` + `!test/lib.test.js` 无效)。
|
||||
# 改用 `test/*` 后子目录(如 `test/sub/`)与其它文件仍被忽略,政策不破。
|
||||
test/*
|
||||
!test/*.test.js
|
||||
test_dir/
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
const mysql = require('mysql2/promise');
|
||||
(async () => {
|
||||
// 🔴 口令不再内置明文(2026-09-23 入库口令处置 P2)。运行前请注入 DB_PASSWORD:
|
||||
// DB_PASSWORD='<本机 DB 口令>' node add_word_cols.js
|
||||
const c = await mysql.createConnection({
|
||||
host: 'localhost', user: 'root', password: '74191221', database: 'competition_db'
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME || 'competition_db',
|
||||
});
|
||||
try {
|
||||
await c.query("ALTER TABLE exam_submissions ADD COLUMN word_file_path VARCHAR(500) NULL DEFAULT NULL COMMENT 'Word答案文件路径' AFTER answers");
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
const mysql = require('mysql2/promise');
|
||||
(async () => {
|
||||
// 🔴 口令不再内置明文(2026-09-23 入库口令处置 P2)。运行前请注入 DB_PASSWORD:
|
||||
// DB_PASSWORD='<本机 DB 口令>' node add_wx_link.js
|
||||
const c = await mysql.createConnection({
|
||||
host: 'localhost', user: 'root', password: '74191221', database: 'competition_db'
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME || 'competition_db',
|
||||
});
|
||||
try {
|
||||
await c.query("ALTER TABLE news ADD COLUMN wx_link VARCHAR(500) NULL DEFAULT NULL COMMENT '微信公众号文章链接' AFTER content");
|
||||
|
||||
67
app.js
67
app.js
@@ -61,27 +61,80 @@ class AppBootHook {
|
||||
`[AppBoot] 上传目录就绪:已创建 ${created} 个,已存在 ${existed} 个(根: ${publicDir})`
|
||||
);
|
||||
|
||||
// 🔴 连接池容量自举断言(2026-08-13 推进 #16):egg-cluster 按 os.cpus() 起多 worker,
|
||||
// 总连接数 ≈ worker 数 × pool.max。若超过 MySQL max_connections(默认 151),
|
||||
// 高峰期会偶发 "connection acquire timeout" / "too many connections"。
|
||||
// 启动期只做 WARN(不阻断启动),提醒运维调大 DB 或调小 DB_POOL_MAX。
|
||||
// 🔴 连接池容量自举断言(2026-08-13 推进 #16):总连接数 = worker 数 × pool.max。
|
||||
// 若超过 MySQL max_connections(默认 151),高峰期会偶发 "connection acquire
|
||||
// timeout" / "too many connections"。启动期只做 WARN(不阻断启动)。
|
||||
//
|
||||
// 🔴 2026-09-21 修正(并发压测结论):原实现用 `os.cpus().length` 估 worker 数,
|
||||
// 这是**误估** —— 真实 worker 数由 egg-scripts 的 --workers / EGG_WORKERS 决定
|
||||
// (生产显式设为 2,见 config.default.js「集群 worker 数」段),与 CPU 核数无必然关系。
|
||||
// 误估会在 8 核机上把「2×20=40(安全)」误报成「8×20=160(越限)」,
|
||||
// 既制造假告警、也会掩盖真实的越限,使这条断言失去作用。
|
||||
// 改为优先读 EGG_WORKERS(worker 进程继承 master 的环境变量),回退才用 CPU 数,
|
||||
// 并在日志中显式标注估算依据,便于排障时判断该行可信度。
|
||||
try {
|
||||
const os = require('os');
|
||||
const poolMax = parseInt(process.env.DB_POOL_MAX, 10) || 20;
|
||||
const rows = await this.app.model.query('SELECT @@max_connections AS m', { type: this.app.model.QueryTypes.SELECT });
|
||||
const dbMax = parseInt(rows && rows[0] && rows[0].m, 10) || 151;
|
||||
const workers = os.cpus().length;
|
||||
const explicitWorkers = parseInt(process.env.EGG_WORKERS, 10);
|
||||
const usingExplicit = Number.isFinite(explicitWorkers) && explicitWorkers > 0;
|
||||
// 🔴 2026-09-21 二次修正:egg-bin dev / test 会**显式传** `workers: 1`
|
||||
// (node_modules/egg-bin/scripts/start-cluster.js),此时即便环境里存在
|
||||
// EGG_WORKERS(本地 dev 也会加载 deploy/.env.production,其中写着 2),
|
||||
// 也不是真实 worker 数 —— 真实值是 1。故必须先按 app.config.env 判定
|
||||
// 单 worker 的运行形态,否则本地仍会打出一条假数字日志(实测:dev 下
|
||||
// 真 worker=1 却报 2×20=40)。
|
||||
const envName = (this.app.config && this.app.config.env) || '';
|
||||
const singleWorkerEnv = envName === 'local' || envName === 'unittest';
|
||||
let workers;
|
||||
let workersSource;
|
||||
if (singleWorkerEnv) {
|
||||
workers = 1;
|
||||
workersSource = `egg env=${envName}(egg-bin 强制单 app worker)`;
|
||||
} else if (usingExplicit) {
|
||||
workers = explicitWorkers;
|
||||
workersSource = `EGG_WORKERS=${explicitWorkers}(显式)`;
|
||||
} else {
|
||||
workers = os.cpus().length;
|
||||
workersSource = `os.cpus()=${workers}(EGG_WORKERS 未设置,为回退估算)`;
|
||||
}
|
||||
const estimated = workers * poolMax;
|
||||
if (estimated > dbMax) {
|
||||
this.app.logger.warn(
|
||||
`[AppBoot][pool] 预估总连接数 ${estimated} (${workers} workers × ${poolMax}) 超过 MySQL max_connections=${dbMax},高峰期可能连接获取超时;建议调大 DB max_connections 或调小 DB_POOL_MAX`
|
||||
`[AppBoot][pool] 预估总连接数 ${estimated} (${workersSource} × DB_POOL_MAX=${poolMax}) 超过 MySQL max_connections=${dbMax},高峰期可能连接获取超时;建议调大 DB max_connections 或调小 DB_POOL_MAX / EGG_WORKERS`
|
||||
);
|
||||
} else {
|
||||
this.app.logger.info(`[AppBoot][pool] 连接池容量 OK:${estimated} (${workers} × ${poolMax}) ≤ MySQL max_connections=${dbMax}`);
|
||||
this.app.logger.info(
|
||||
`[AppBoot][pool] 连接池容量 OK:${estimated} (${workersSource} × DB_POOL_MAX=${poolMax}) ≤ MySQL max_connections=${dbMax}`
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
this.app.logger.warn('[AppBoot][pool] 连接数自举断言跳过(无法查询 MySQL):' + (e && e.message));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// 节点备份调度器:进程启动 + hydrate 未到期的三节点计划
|
||||
// -----------------------------------------------------------------
|
||||
// 需求:考试开始前 30 分钟 / 考试结束后 10 分钟 / 成绩发布后 触发生产库备份。
|
||||
// 实现:app/lib/nodeBackupScheduler.js(进程内 setTimeout,无额外常驻进程 ——
|
||||
// 用户裁定①「巡检脚本会占用额外内存」的替代方案)。
|
||||
// ⛔ 非生产环境调度器构造时即 enabled=false ⇒ 这里只在生产实际排计划。
|
||||
try {
|
||||
const { NodeBackupScheduler } = require('./app/lib/nodeBackupScheduler');
|
||||
const scheduler = new NodeBackupScheduler(this.app);
|
||||
// 挂到 app 上:service/controller 通过 app.nodeBackupScheduler 访问(单例)
|
||||
this.app.nodeBackupScheduler = scheduler;
|
||||
if (scheduler.enabled) {
|
||||
const rows = await this.app.model.CompetitionTime.findAll({
|
||||
attributes: [ 'group', 'start_time', 'end_time' ],
|
||||
raw: true,
|
||||
});
|
||||
await scheduler.hydrate(rows);
|
||||
}
|
||||
} catch (e) {
|
||||
this.app.logger.error('[AppBoot][NodeBackup] 调度器初始化失败(不影响主服务):' + (e && e.message));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
118
app/controller/adminUser.js
Normal file
118
app/controller/adminUser.js
Normal file
@@ -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;
|
||||
@@ -3,6 +3,11 @@
|
||||
const Controller = require('egg').Controller;
|
||||
const svgCaptcha = require('svg-captcha');
|
||||
const VALIDATION_RULES = require('../validate');
|
||||
// 口令强度策略(唯一事实来源):黑名单 + 纯重复/序列 + 账号派生值 + 长度上下限。
|
||||
// ⚠️ 任何新增的口令写入入口都必须调用它,禁止在控制器内自行判断(见 lib/passwordPolicy.js 模块头)。
|
||||
const passwordPolicy = require('../lib/passwordPolicy');
|
||||
// R178:管理端权限点解析(与 middleware/auth.js 同一真相源)
|
||||
const adminPermissions = require('../lib/adminPermissions');
|
||||
|
||||
/**
|
||||
* 认证控制器 - AuthController
|
||||
@@ -190,7 +195,7 @@ class AuthController extends Controller {
|
||||
* 1. 校验身份证号、手机号、短信验证码
|
||||
* 2. 通过身份证号查找选手
|
||||
* 3. 检查账号是否已激活
|
||||
* 4. SHA256 哈希密码,更新 activated=1
|
||||
* 4. bcrypt 加盐哈希密码(BCRYPT_ROUNDS=10,见 extend/helper.js),更新 activated=1
|
||||
* 5. 清除 session 中的短信验证码
|
||||
*/
|
||||
async activate() {
|
||||
@@ -206,6 +211,21 @@ class AuthController extends Controller {
|
||||
return;
|
||||
}
|
||||
|
||||
// 口令强度校验(R63 · C1)
|
||||
// 背景:此前仅由 validate 校验「长度 ≥ 6」,`123456` 完全合法 —— R61 测试人员
|
||||
// 实测「浏览器密码管理器预填 123456,直接提交后即成为账号口令」。
|
||||
// 位置(validate 之后 / 短信码之前)的理由:
|
||||
// · validate 只管「必填 + 格式」,强度是业务规则,分层后两者失败文案互不覆盖;
|
||||
// · 纯本地计算、无副作用,可快速失败,不消耗也不依赖 session 中的验证码。
|
||||
// 错误码 2001 = 口令强度不足(activate 段编码;resetPassword 段用 7001)。
|
||||
// ⚠️ 日志只记命中的规则名,绝不记录口令明文或身份证号。
|
||||
const pwdCheck = passwordPolicy.checkPassword(password, { idNumber: id_number, phone });
|
||||
if (!pwdCheck.ok) {
|
||||
ctx.logger.info(`[口令策略] 激活口令被拒:rule=${pwdCheck.rule}`);
|
||||
ctx.body = ctx.helper.error(2001, pwdCheck.reason);
|
||||
return;
|
||||
}
|
||||
|
||||
// 校验短信验证码
|
||||
if (!ctx.session.smsCode || smsCode !== ctx.session.smsCode) {
|
||||
ctx.body = ctx.helper.error(2005, '短信验证码错误');
|
||||
@@ -263,7 +283,8 @@ class AuthController extends Controller {
|
||||
* 流程:
|
||||
* 1. 通过手机号查找选手
|
||||
* 2. 检查账号是否激活
|
||||
* 3. SHA256(输入密码) 与数据库密码比对
|
||||
* 3. bcrypt 比对密码(helper.comparePassword;兼容存量 SHA256 旧哈希,
|
||||
* 并在验证成功后透明升级为 bcrypt)
|
||||
* 4. 生成 JWT Token,返回用户信息和 Token
|
||||
*/
|
||||
async login() {
|
||||
@@ -309,9 +330,9 @@ class AuthController extends Controller {
|
||||
role: 'contestant',
|
||||
}, this.app.config.jwt.secret, this.app.config.jwt.expiresIn);
|
||||
|
||||
// 返回用户信息(排除密码)
|
||||
const userData = contestant.toJSON();
|
||||
delete userData.password;
|
||||
// 返回用户信息 —— 统一走 AuthService.toClientUser 出口:
|
||||
// 剔除密码 + 按「成绩发布门控」决定是否携带 score(铁律15 全通道封堵,2026-09-21)
|
||||
const userData = await service.auth.toClientUser(contestant);
|
||||
|
||||
ctx.body = ctx.helper.success({
|
||||
token,
|
||||
@@ -324,7 +345,7 @@ class AuthController extends Controller {
|
||||
*
|
||||
* 流程:
|
||||
* 1. 通过用户名从 admins 表查找管理员
|
||||
* 2. 密码比对(SHA256)
|
||||
* 2. bcrypt 比对密码(兼容存量 SHA256 旧哈希)
|
||||
* 3. 生成管理员 JWT Token
|
||||
*/
|
||||
async adminLogin() {
|
||||
@@ -353,11 +374,37 @@ class AuthController extends Controller {
|
||||
return;
|
||||
}
|
||||
|
||||
// P2-3 默认口令守卫:schema.sql 内置默认管理员(admin/admin123),
|
||||
// 🔴 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),
|
||||
// 生产环境禁止使用默认口令登录,防止默认账号直接上线被接管;
|
||||
// 非生产环境放行但记录告警,并在响应中提示尽快改密。
|
||||
const DEFAULT_ADMIN_PASSWORDS = ['admin123', 'admin123456'];
|
||||
const usingDefaultPassword = DEFAULT_ADMIN_PASSWORDS.includes(password);
|
||||
//
|
||||
// ⚠️ 【R111 更正 · 实测】原注释写「schema.sql 内置默认管理员(admin/admin123)」,两处失实:
|
||||
// ① 归属错 —— `admin123` 是 **seeder 002** 的 bcrypt 值;`schema.sql:214` 的字面哈希实测 = SHA256(`admin`)。
|
||||
// ② 列表漏项 —— 原列表 `['admin123','admin123456']` **不含 `admin`**,而 `schema.sql` 建库路径的初始
|
||||
// 口令恰为 `admin` ⇒ 生产若未改密,用 `admin/admin` 登录**不会**触发 4005(`verifyPassword` 兼容
|
||||
// SHA256 ⇒ 可直接登录成功)⇒ 恰是本守卫要防的场景却不设防。已补入 `'admin'`。
|
||||
// 详见 `.workbuddy/memory/BACKLOG.md` 候选⑱/㉑。
|
||||
// ⚠️ 结构性缺口**未消除**:本守卫是「口令字面」判据 ⇒ 任何未枚举的默认口令均绕过;
|
||||
// 彻底治法应改为「哈希值判据」(DB 值 ∈ 已知默认哈希集)。见候选㉑。
|
||||
// ✅ 【R112 已消除 · 候选㉒】上述「口令字面」单通道已升级为**双通道**,并把清单下沉为**单源**:
|
||||
// ① 口令通道:`DEFAULT_ADMIN_PASSWORDS.includes(password)`(原逻辑原样保留);
|
||||
// ② 哈希通道:`isDefaultAdminHash(admin.password)` —— **与用户输入无关**,直接判 DB 存储值
|
||||
// 是否为已发布的默认哈希(无盐 SHA256 + 固定盐 bcrypt),并对候选口令做 bcrypt 遍历,
|
||||
// 以覆盖「同口令、**不同盐**」的重新播种场景(见 `002-seed-admin.js` 的 ADMIN_INIT_PASSWORD 分支)。
|
||||
// ⇒ 上面那条「未枚举即绕过」的绕过面已消除;仅剩「新增内置凭据未登记」这一**显式维护点**
|
||||
// (清单已下沉至 `lib/passwordPolicy.js` 模块头 ⚠️,⛔ 勿在本控制器内再写第二份)。
|
||||
const usingDefaultPassword =
|
||||
passwordPolicy.isDefaultAdminHash(admin.password) ||
|
||||
passwordPolicy.DEFAULT_ADMIN_PASSWORDS.includes(password);
|
||||
const isProd = this.app.config.isProduction;
|
||||
if (usingDefaultPassword && isProd) {
|
||||
ctx.logger.error(`[安全阻断] 管理员 ${admin.username} 在生产环境尝试使用默认密码登录,已拒绝`);
|
||||
@@ -374,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,
|
||||
@@ -392,7 +458,7 @@ class AuthController extends Controller {
|
||||
* 获取当前登录用户信息(需认证)
|
||||
*/
|
||||
async currentUser() {
|
||||
const { ctx } = this;
|
||||
const { ctx, service } = this;
|
||||
|
||||
// 从 JWT token 中获取用户信息
|
||||
const token = ctx.headers.authorization
|
||||
@@ -426,8 +492,9 @@ class AuthController extends Controller {
|
||||
return;
|
||||
}
|
||||
|
||||
const userData = contestant.toJSON();
|
||||
delete userData.password;
|
||||
// 与 login 共用同一脱敏出口(同源族:两条 auth 通道口径必须一致,
|
||||
// 且此处原为全字段 toJSON —— 会连带下发 exam_score/word_score/ppt_score)
|
||||
const userData = await service.auth.toClientUser(contestant);
|
||||
|
||||
ctx.body = ctx.helper.success(userData, '获取用户信息成功');
|
||||
}
|
||||
@@ -498,6 +565,18 @@ class AuthController extends Controller {
|
||||
return;
|
||||
}
|
||||
|
||||
// 口令强度校验(R63 · C1)—— 与 activate() 共用同一策略(lib/passwordPolicy.js),
|
||||
// ⚠️ 禁止在本处另行实现:一旦「忘记密码」通道放宽,弱口令缺口会从另一条路重新打开
|
||||
// (这正是本模块必须单源的原因)。
|
||||
// 错误码 7001 = 口令强度不足(resetPassword 段编码;activate 段用 2001)。
|
||||
// 本入口请求体无 phone 字段(见 validate.resetPassword),故仅传 idNumber 做派生值比对。
|
||||
const pwdCheck = passwordPolicy.checkPassword(password, { idNumber: id_number });
|
||||
if (!pwdCheck.ok) {
|
||||
ctx.logger.info(`[口令策略] 重置口令被拒:rule=${pwdCheck.rule}`);
|
||||
ctx.body = ctx.helper.error(7001, pwdCheck.reason);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ctx.session.smsCode || smsCode !== ctx.session.smsCode) {
|
||||
ctx.body = ctx.helper.error(7004, '短信验证码错误');
|
||||
return;
|
||||
@@ -544,7 +623,7 @@ class AuthController extends Controller {
|
||||
* - 该接口刻意不挂 auth 中间件(因为携带的 token 可能已过期)。
|
||||
*/
|
||||
async refresh() {
|
||||
const { ctx } = this;
|
||||
const { ctx, service } = this;
|
||||
|
||||
const authorization = ctx.headers.authorization || '';
|
||||
const token = authorization.replace(/^Bearer\s+/i, '') || null;
|
||||
@@ -597,8 +676,9 @@ class AuthController extends Controller {
|
||||
: { id: account.id, phone: account.phone, role: 'contestant' };
|
||||
const newToken = ctx.helper.generateToken(payload, this.app.config.jwt.secret, this.app.config.jwt.expiresIn);
|
||||
|
||||
const userData = account.toJSON();
|
||||
delete userData.password;
|
||||
// 返回用户信息 —— 统一走 AuthService.toClientUser 出口
|
||||
// (与 login / currentUser 同源:禁止各自直接 toJSON,否则成绩未发布时 score 会从本通道漏出)
|
||||
const userData = await service.auth.toClientUser(account);
|
||||
|
||||
ctx.body = ctx.helper.success({
|
||||
token: newToken,
|
||||
|
||||
@@ -216,6 +216,20 @@ class ExamController extends Controller {
|
||||
return;
|
||||
}
|
||||
|
||||
// 🔴 已正式交卷禁止再次进入(考试组一次性答题,测试反馈 #3)。
|
||||
// 这里是「进入考试」的真实门控:题目经此下发,拒绝即等于无法进入答题界面,
|
||||
// 前端按钮置灰只是 UX 层,绕过前端直调本接口同样被拦。
|
||||
// hasSubmitted 仅在 exam_submissions.submit_time 落库后为 true ⇒
|
||||
// 未交卷的意外退出(断网/断电)不受影响,仍可重进恢复作答。
|
||||
// 仅约束考试组;本科/教师走作品提交,不在此限制。
|
||||
if (group === 'zhongzhi' || group === 'gaozhi') {
|
||||
const alreadySubmitted = await service.exam.hasSubmitted(aggregation.aggregation_uid, group);
|
||||
if (alreadySubmitted) {
|
||||
ctx.body = ctx.helper.error(4006, '您已提交试卷,无法再次进入考试');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 团队锁:同一小组只能一人进入正式考试(原子检查+占位,G1 修复)
|
||||
const teamGate = await this._enterExamWithTeamLock(aggregation);
|
||||
if (!teamGate.allowed) {
|
||||
@@ -375,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 },
|
||||
@@ -438,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, '提交答案失败');
|
||||
}
|
||||
@@ -456,6 +485,17 @@ class ExamController extends Controller {
|
||||
const { group } = ctx.params;
|
||||
if (!this._validateGroup(group)) return;
|
||||
|
||||
// 🔴 中职组/高职组已取消 Word 文件上传题(改为在线作答「综合大题」)。
|
||||
// 前端入口已移除,但直调 API 仍可绕过,故在服务端一并封堵,避免出现
|
||||
// 「界面上没这道题、后台却收到 Word 附件」的越界数据(2026-09-21)。
|
||||
if (['zhongzhi', 'gaozhi'].includes(group)) {
|
||||
ctx.body = ctx.helper.error(
|
||||
400,
|
||||
'中职组/高职组已取消 Word 文件上传题,请在「综合大题」中在线作答'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取用户聚合信息
|
||||
const aggregation = await this._getUserAggregation();
|
||||
if (!aggregation) {
|
||||
@@ -609,6 +649,153 @@ class ExamController extends Controller {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取本次抽中的综合大题(仅中职组 / 高职组)
|
||||
* GET /api/exam/:group/big-question
|
||||
*
|
||||
* 2026-09-21 需求变更:中职/高职取消 Word 文件上传题,改为随机抽取一道大题在线作答。
|
||||
* 语义:**首次调用抽题并落库题干快照,之后一律返回同一道题**(幂等,防考生纠纷),
|
||||
* 同时回传已保存的三小问作答,供重新进入考试时恢复。
|
||||
*
|
||||
* ⚠️ 不回传任何分数:成绩仅经 GET /api/score 单通道(发布门控后)下发,铁律15。
|
||||
*/
|
||||
async bigQuestion() {
|
||||
const { ctx, service } = this;
|
||||
|
||||
try {
|
||||
const { group } = ctx.params;
|
||||
if (!this._validateGroup(group)) return;
|
||||
|
||||
if (!service.bigQuestion.isEnabledGroup(group)) {
|
||||
ctx.body = ctx.helper.error(400, '该组别未启用综合大题');
|
||||
return;
|
||||
}
|
||||
|
||||
const aggregation = await this._getUserAggregation();
|
||||
if (!aggregation) {
|
||||
ctx.body = ctx.helper.error(403, '未找到您的报名信息');
|
||||
return;
|
||||
}
|
||||
|
||||
// 组别归属校验(与 status / questions 同口径,防止跨组越权取题)
|
||||
if (aggregation.team && aggregation.team.group_name
|
||||
&& aggregation.team.group_name !== group) {
|
||||
ctx.body = ctx.helper.error(403, '您不属于该组别');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await service.bigQuestion.getForCandidate(
|
||||
aggregation.aggregation_uid,
|
||||
group,
|
||||
(ctx.state.currentUser && ctx.state.currentUser.id) || aggregation.aggregation_uid
|
||||
);
|
||||
|
||||
if (!result.ok) {
|
||||
// 组别未启用 → 400;无题可抽 → 4004(与「题目未配置」类业务码口径一致)
|
||||
const code = result.code === 'NO_QUESTION' ? 4004 : 400;
|
||||
ctx.body = ctx.helper.error(code, result.message);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.body = ctx.helper.success({
|
||||
question: result.question,
|
||||
answers: result.answers,
|
||||
submitTime: result.submitTime,
|
||||
maxChars: result.maxChars,
|
||||
subCount: result.subCount,
|
||||
reused: result.reused,
|
||||
}, '获取综合大题成功');
|
||||
} catch (err) {
|
||||
ctx.logger.error('获取综合大题失败:', err);
|
||||
ctx.body = ctx.helper.error(500, '获取综合大题失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存综合大题三小问作答(仅中职组 / 高职组)
|
||||
* POST /api/exam/:group/big-answer
|
||||
* Body: { answer_1, answer_2, answer_3, forceSubmit? }
|
||||
*
|
||||
* 每小问上限 2000 字,**后端同步校验**(前端 maxlength 只是体验优化,不可作为唯一防线)。
|
||||
* forceSubmit 与 submit-all 同语义:仅「倒计时归零的自动提交」传入,允许在考试窗口
|
||||
* 临界/已关闭时仍落库考生已写内容,避免整卷提交时丢失大题答案。
|
||||
*/
|
||||
async saveBigAnswer() {
|
||||
const { ctx, service } = this;
|
||||
|
||||
try {
|
||||
const { group } = ctx.params;
|
||||
if (!this._validateGroup(group)) return;
|
||||
|
||||
if (!service.bigQuestion.isEnabledGroup(group)) {
|
||||
ctx.body = ctx.helper.error(400, '该组别未启用综合大题');
|
||||
return;
|
||||
}
|
||||
|
||||
const body = ctx.request.body || {};
|
||||
|
||||
// 🔴 2026-09-21 同源加固(与 submitAll 的结算通道同构):
|
||||
// 原实现为 `if (!examStatus.isActive && !body.forceSubmit)` —— 该布尔完全来自请求体,
|
||||
// 任何考生都能在截止后自行携带 forceSubmit 继续改写综合大题作答;
|
||||
// 而综合大题由人工阅卷计分,等同「截止后继续答题」,公平性受损。
|
||||
// 现改为服务端判定:窗口关闭时只允许把**库内已有作答**标记为已提交(markSubmitted),
|
||||
// 绝不接受请求体内容;库内无作答行则明确拒绝。
|
||||
const examStatus = await service.exam.getExamStatus(group);
|
||||
const windowOpen = examStatus.isActive === true;
|
||||
|
||||
const aggregation = await this._getUserAggregation();
|
||||
if (!aggregation) {
|
||||
ctx.body = ctx.helper.error(403, '未找到您的报名信息');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!windowOpen) {
|
||||
// 结算语义:不写内容,只补 submit_time(库内无该考生作答行 → 返回 null → 拒绝)
|
||||
const settled = await service.bigQuestion.markSubmitted(aggregation.aggregation_uid);
|
||||
if (!settled) {
|
||||
ctx.body = ctx.helper.error(4001, examStatus.message || '考试未在进行中');
|
||||
return;
|
||||
}
|
||||
ctx.body = ctx.helper.success({
|
||||
filled: [ settled.answer_1, settled.answer_2, settled.answer_3 ]
|
||||
.filter((v) => v && String(v).trim().length > 0).length,
|
||||
subCount: service.bigQuestion.subQuestionCount(),
|
||||
maxChars: service.bigQuestion.answerMaxChars(),
|
||||
savedAt: new Date().toISOString(),
|
||||
settled: true,
|
||||
}, '考试已结束,已按已保存内容结算');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await service.bigQuestion.saveAnswers(
|
||||
aggregation.aggregation_uid,
|
||||
group,
|
||||
{
|
||||
answer_1: body.answer_1,
|
||||
answer_2: body.answer_2,
|
||||
answer_3: body.answer_3,
|
||||
}
|
||||
);
|
||||
|
||||
if (!result.ok) {
|
||||
// 超长 → 400(参数非法);未抽题 → 4004(业务前置条件未满足)
|
||||
const code = result.code === 'NOT_DRAWN' ? 4004 : 400;
|
||||
ctx.body = ctx.helper.error(code, result.message);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.body = ctx.helper.success({
|
||||
filled: result.filled,
|
||||
subCount: service.bigQuestion.subQuestionCount(),
|
||||
maxChars: service.bigQuestion.answerMaxChars(),
|
||||
savedAt: new Date().toISOString(),
|
||||
}, '综合大题答案已保存');
|
||||
} catch (err) {
|
||||
ctx.logger.error('保存综合大题答案失败:', err);
|
||||
ctx.body = ctx.helper.error(500, '保存综合大题答案失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 一体提交:答案 + Word 文件
|
||||
* POST /api/exam/:group/submit-all
|
||||
@@ -623,35 +810,106 @@ class ExamController extends Controller {
|
||||
const { group } = ctx.params;
|
||||
if (!this._validateGroup(group)) return;
|
||||
|
||||
// forceSubmit 仅由「倒计时结束的自动提交」传入:允许在考试窗口临界/已关闭、
|
||||
// 且未答满全部题目时仍记录提交(倒计时归零即合法交卷),确保 submit_time 落库,
|
||||
// 否则会被下方两道闸门拦截 → submit_time 永不写入 → 管理后台回退显示进入考试时间。
|
||||
// forceSubmit 由「倒计时结束的自动提交」传入,其作用已被收敛为**仅**豁免下方
|
||||
// 「未答满全部题目」闸门(倒计时归零即合法交卷,不应因少答几题而交不上)。
|
||||
//
|
||||
// 🔴 2026-09-21 加固:forceSubmit **不再**授予「绕过考试窗口」的权力。
|
||||
// 原实现为 `if (!examStatus.isActive && !forceSubmit)` —— 该布尔完全来自请求体,
|
||||
// 任何考生都可在截止后自行携带 forceSubmit 重新提交并改写答案。
|
||||
// 窗口是否开放改由服务端判定(见下方结算通道),不接受客户端的自我声明。
|
||||
const forceSubmit = ctx.request.body && ctx.request.body.forceSubmit === true;
|
||||
|
||||
// 验证考试是否活跃
|
||||
const examStatus = await service.exam.getExamStatus(group);
|
||||
if (!examStatus.isActive && !forceSubmit) {
|
||||
ctx.body = ctx.helper.error(4001, examStatus.message || '考试未在进行中');
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取用户聚合信息
|
||||
// 获取用户聚合信息(须先于窗口判定:结算通道需要按考生查已落库作答)
|
||||
const aggregation = await this._getUserAggregation();
|
||||
if (!aggregation) {
|
||||
ctx.body = ctx.helper.error(403, '未找到您的报名信息');
|
||||
return;
|
||||
}
|
||||
|
||||
// 团队锁:同一小组只能一人进入正式考试(G1 修复,防绕过 questions 直接提交)
|
||||
const teamGate = await this._enterExamWithTeamLock(aggregation);
|
||||
if (!teamGate.allowed) {
|
||||
ctx.body = ctx.helper.error(4005, '您的小组已进入考试');
|
||||
return;
|
||||
// 🔴 服务端窗口判定 + 截止后结算通道(2026-09-21 并发压测结论)
|
||||
//
|
||||
// 缺陷背景:400 并发同刻交卷时,约 20% 考生的连接在 TCP accept 层即被拒
|
||||
// (Windows 在 accept 队列满时回 RST,客户端得到 10061),请求**从未到达业务逻辑**;
|
||||
// 前端遇到该情况只能提示「请手动点击提交全部」,而手动交卷不带 forceSubmit,
|
||||
// 窗口关闭后必被 4001 拦下 —— 这批考生的 exam_submissions.submit_time 永久为空,
|
||||
// 管理后台显示「未提交」、成绩无法归集。即「答案明明已在库里,却交不上卷」。
|
||||
//
|
||||
// 因此窗口关闭后开放**服务端结算通道**(settleOnly):
|
||||
// - 只补写 exam_submissions.submit_time + answers 快照,把库内作答正式标记为已提交;
|
||||
// - 严格以数据库已落库作答为准,**忽略请求体答案** —— 否则考生可借该通道在截止后
|
||||
// 继续作答刷分,公平性受损;
|
||||
// - 一题未答(库内无作答行)→ 仍按 4001 拒绝(无内容可结算)。
|
||||
const windowOpen = examStatus.isActive === true;
|
||||
let settleOnly = false;
|
||||
let storedAnswers = null;
|
||||
|
||||
if (!windowOpen) {
|
||||
const existing = await service.exam.getUserAnswers(aggregation.aggregation_uid, group);
|
||||
storedAnswers = (existing || []).filter(
|
||||
(r) => r.answer_data !== null && r.answer_data !== undefined && String(r.answer_data).trim() !== ''
|
||||
);
|
||||
if (!storedAnswers.length) {
|
||||
ctx.body = ctx.helper.error(4001, examStatus.message || '考试未在进行中');
|
||||
return;
|
||||
}
|
||||
settleOnly = true;
|
||||
|
||||
// 幂等:此前已正式提交过(自动交卷其实成功了,只是响应在连接层丢失)→
|
||||
// 直接返回成功并回吐真实 submit_time,不重复写库。
|
||||
// 这解决了「考生不确定自己交上没有、反复点提交」的焦虑式重试。
|
||||
const alreadySubmitted = await service.exam.hasSubmitted(aggregation.aggregation_uid, group);
|
||||
if (alreadySubmitted) {
|
||||
const rec = await ctx.model.ExamSubmission.findOne({
|
||||
where: { contestant_id: aggregation.aggregation_uid },
|
||||
attributes: [ 'submit_time' ],
|
||||
});
|
||||
ctx.body = ctx.helper.success({
|
||||
answeredCount: storedAnswers.length,
|
||||
totalQuestions: storedAnswers.length,
|
||||
hasWordFile: storedAnswers.some((s) => s.word_file_path),
|
||||
savedCount: storedAnswers.length,
|
||||
submitted: true,
|
||||
settled: true,
|
||||
submitTime: (rec && rec.submit_time)
|
||||
? new Date(rec.submit_time).toISOString()
|
||||
: new Date().toISOString(),
|
||||
}, '提交成功(此前已提交)');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 参数校验
|
||||
// 🔴 窗口内重复交卷拦截(考试组,测试反馈 #3)。
|
||||
// 背景:下方窗口内分支会重新校验请求体答案并覆盖写库 ⇒ 已交卷者若可重进,
|
||||
// 即可「改答案 → 再交卷」无损刷分。入口 questions() 已拦重进,此处再堵住
|
||||
// 绕过前端直接重放 submit-all 的路径。
|
||||
// ⚠️ 只作用于窗口打开时;窗口关闭后走上方结算通道(那是补写标记,不是重新作答)。
|
||||
if (windowOpen && (group === 'zhongzhi' || group === 'gaozhi')) {
|
||||
const alreadyDone = await service.exam.hasSubmitted(aggregation.aggregation_uid, group);
|
||||
if (alreadyDone) {
|
||||
ctx.body = ctx.helper.error(4006, '您已提交试卷,不能重复提交');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 团队锁:同一小组只能一人进入正式考试(G1 修复,防绕过 questions 直接提交)
|
||||
// ⚠️ 结算通道跳过:一是窗口已关闭不应再产生 setTeamLock 写副作用,
|
||||
// 二是结算绝不能被队友残留锁拦下(否则又回到「答案在库却交不上」)。
|
||||
if (!settleOnly) {
|
||||
const teamGate = await this._enterExamWithTeamLock(aggregation);
|
||||
if (!teamGate.allowed) {
|
||||
ctx.body = ctx.helper.error(4005, '您的小组已进入考试');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 参数校验(结算通道不接收请求体答案,故放宽为可空)
|
||||
const rule = {
|
||||
answers: { type: 'array', required: true, min: 1 },
|
||||
answers: settleOnly
|
||||
? { type: 'array', required: false }
|
||||
: { type: 'array', required: true, min: 1 },
|
||||
};
|
||||
try {
|
||||
ctx.validate(rule);
|
||||
@@ -660,6 +918,34 @@ class ExamController extends Controller {
|
||||
return;
|
||||
}
|
||||
|
||||
// ---------- 结算通道:只补 submit_time,绝不改动任何答题行 ----------
|
||||
// 刻意不做 autoScore / saveAnswers:库内 is_correct / score 已由考生逐题保存时
|
||||
// 判分落库(submitAnswers 链路),此处若重新判分并回写,等于在截止后修改作答与判分,
|
||||
// 既有公平性风险,也可能因题目变更而覆盖考生既得的判分结果。
|
||||
if (settleOnly) {
|
||||
const snapshot = storedAnswers.map((r) => ({
|
||||
question_uid: r.question_uid,
|
||||
question_type: r.question_type,
|
||||
answer_data: r.answer_data,
|
||||
}));
|
||||
await service.exam.ensureExamSubmission(aggregation.aggregation_uid, snapshot);
|
||||
// 大题作答同样补 submit_time(结算通道只补标记、不改内容)
|
||||
await service.bigQuestion.markSubmitted(aggregation.aggregation_uid);
|
||||
ctx.logger.warn(
|
||||
`[submitAll][settle] 窗口已关闭,按库内作答结算 agg=${aggregation.aggregation_uid} questions=${snapshot.length}`
|
||||
);
|
||||
ctx.body = ctx.helper.success({
|
||||
answeredCount: snapshot.length,
|
||||
totalQuestions: snapshot.length,
|
||||
hasWordFile: storedAnswers.some((s) => s.word_file_path),
|
||||
savedCount: snapshot.length,
|
||||
submitted: true,
|
||||
settled: true,
|
||||
submitTime: new Date().toISOString(),
|
||||
}, '提交成功(考试已结束,按已保存答案结算)');
|
||||
return;
|
||||
}
|
||||
|
||||
const { answers } = ctx.request.body;
|
||||
|
||||
// 验证每个答案的数据格式(必须包含 question_type,否则 autoScore 无法判分)
|
||||
@@ -735,6 +1021,14 @@ class ExamController extends Controller {
|
||||
// 聚合键取 aggregation.aggregation_uid(中职/高职即 contestant.id),与 hasSubmitted 一致。
|
||||
await service.exam.ensureExamSubmission(aggregation.aggregation_uid, saveData);
|
||||
|
||||
// 综合大题(中职/高职)作答随整卷正式提交补写 submit_time。
|
||||
// 🔴 2026-09-21 修复:service.bigQuestion.markSubmitted 此前**无任何调用点**(死代码),
|
||||
// 而 big_question_answers.submit_time 的列注释明确写着「随整卷正式提交写入(未提交为 NULL)」
|
||||
// → 中职/高职考生的大题 submit_time 恒为 NULL,管理端据此列区分「已作答未提交」时
|
||||
// 会把真实作答误判为未提交。markSubmitted 内部有 if (!submit_time) 保护,
|
||||
// 重复提交不会覆盖首次提交时间,天然幂等;无大题作答行时返回 null(如本科/教师组),无副作用。
|
||||
await service.bigQuestion.markSubmitted(aggregation.aggregation_uid);
|
||||
|
||||
// 成绩门控:不回传 autoScore/total/details(自动评分仅落库供管理后台阅卷与合分使用)。
|
||||
// 考生端提交后只应得到「提交成功 + 已保存题数」的确认,成绩须待管理后台发布。
|
||||
ctx.body = ctx.helper.success({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
const Controller = require('egg').Controller;
|
||||
const { resolveAggregationKeysForContestant } = require('../lib/aggregationKey');
|
||||
|
||||
/**
|
||||
* 成绩查询控制器 - ScoreController
|
||||
@@ -138,8 +139,10 @@ class ScoreController extends Controller {
|
||||
where: { id: operatorId },
|
||||
attributes: ['id', 'name', 'word_score', 'ppt_score', 'score', 'exam_score'],
|
||||
});
|
||||
const keys = [String(operatorId)];
|
||||
if (operatorAggUid) keys.push(String(operatorAggUid));
|
||||
// 归集键(单一真相源 lib/aggregationKey):
|
||||
// 中职/高职 = contestant.id 为主键;兜底键(真实聚合 uid)仅在无歧义时启用,
|
||||
// 避免「操作员真实聚合 uid 撞上同组他人 contestant.id」而读到他人(甚至他队)作答记录。
|
||||
const keys = await resolveAggregationKeysForContestant(ctx, group, operatorId, operatorAggUid);
|
||||
|
||||
return { group, isExam, operatorId, operator, aggKeys: keys, viewerName: me.name };
|
||||
}
|
||||
|
||||
@@ -48,15 +48,48 @@ class UploadController extends Controller {
|
||||
* 上传作品文件
|
||||
* POST /api/upload/:group
|
||||
*
|
||||
* 支持 .pdf / .doc / .docx / .ppt / .pptx / .zip 格式,最大 50MB
|
||||
* 支持 .pdf / .doc / .docx / .ppt / .pptx / .zip 格式,最大 150MB
|
||||
* (🔴 R225:原注释写「最大 50MB」早已失效,实际上限只由
|
||||
* config.multipart.fileSize 决定,当时为 '100mb',现为 '150mb'。
|
||||
* 本注释不再复述数字,改为指向配置项,避免再次漂移。)
|
||||
*/
|
||||
async upload() {
|
||||
const { ctx, service } = this;
|
||||
// 🔴 配额获取标志(见方法末 finally 的说明:为何不照搬 uploadChunk 的无条件 release)
|
||||
let ioAcquired = false;
|
||||
// 🔴 C9 根因①的孪生缺陷(2026-09-29 · R45):文件已落盘、但 DB 记录没写成 ⇒ 同样是孤儿。
|
||||
// 两个标志分别表示「已落盘」「已入库」,只有「落盘但没入库」才允许回滚删除 ——
|
||||
// 若已经入库还去删文件,就会删掉**正在被引用**的文件(下载 404),那是数据丢失。
|
||||
let savedFileInfo = null;
|
||||
let recordPersisted = false;
|
||||
|
||||
try {
|
||||
const { group } = ctx.params;
|
||||
if (!this._validateGroup(group)) return;
|
||||
|
||||
// 🔴 文件 IO 并发护栏(2026-09-29 · R43 补齐 —— 本端点此前**零护栏**)
|
||||
//
|
||||
// 背景:本端点是**在用的**作品上传路径(前端 Competition.vue:477 逐文件串行调用),
|
||||
// 而同文件的 uploadChunk(:156) / mergeChunks(:275) / exam.uploadWord(:503) 都调了本护栏,
|
||||
// 唯独真正承载考生上传的这一条**既未调 file_io_guard、也不在 concurrency_guard
|
||||
// 的 HEAVY_PATTERNS 内**(R42 发现,已同步补入中间件)。两处都补 = 闸门 + 护栏双保险。
|
||||
//
|
||||
// 位置:与 uploadChunk 同构 —— 在**读取请求体之前**(ctx.getFileStream() 在下方)取配额,
|
||||
// 提供连接级背压:极端并发下超限即回 429,请求体不会被解析/缓冲,避免 fd/事件循环雪崩。
|
||||
//
|
||||
// ⚠️ 生产缓解事实(诚实标注,避免高估本改动):服务器入方向带宽仅 ≈1.25 MB/s,
|
||||
// 收包速率被物理封顶、并发上传大多耗在「等字节」⇒ **当前实际风险有限**;
|
||||
// 但一旦按 C3 提升入带宽,收包不再被封顶,护栏即从「保险」变为「必需」
|
||||
// (故 C7 与 C3 绑定:先补护栏再升带宽)。
|
||||
try {
|
||||
await acquireFileIo();
|
||||
ioAcquired = true;
|
||||
} catch (e) {
|
||||
ctx.status = 429;
|
||||
ctx.body = ctx.helper.error(429, '服务器繁忙,上传通道暂满,请稍后重试');
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证截止时间
|
||||
const deadlineCheck = await service.upload.validateDeadline(group);
|
||||
if (!deadlineCheck.valid) {
|
||||
@@ -77,6 +110,25 @@ class UploadController extends Controller {
|
||||
return;
|
||||
}
|
||||
|
||||
// 🔴 R190 组内上传互斥:本科/教师组「小组中只要有一人上传,其他人就不能上传」,
|
||||
// 替换权只归组内最早提交者(归属人)。⚠️ 必须放在 ctx.getFileStream() **之前** ——
|
||||
// 被锁成员根本不该把几十 MB 请求体传上来再被拒(白耗入带宽 + 通道配额)。
|
||||
const gate = await service.upload.assertTeamUploadAllowed(aggregation, group);
|
||||
if (!gate.ok) {
|
||||
// 业务错误 = HTTP 200 + code≠0(helper.error 不改 HTTP 状态),
|
||||
// 附加 data 供前端在按钮/提示里展示归属人,message 已含完整可读文案。
|
||||
const body = ctx.helper.error(6006, gate.message);
|
||||
body.data = {
|
||||
teamLocked: true,
|
||||
teamOwnerName: gate.state.owner && gate.state.owner.name ? gate.state.owner.name : null,
|
||||
teamNumber: gate.state.teamNumber,
|
||||
folderName: gate.state.folderName,
|
||||
};
|
||||
ctx.body = body;
|
||||
return;
|
||||
}
|
||||
const teamFolder = gate.state.folderName;
|
||||
|
||||
// 获取上传的文件流
|
||||
const stream = await ctx.getFileStream();
|
||||
if (!stream) {
|
||||
@@ -102,14 +154,18 @@ class UploadController extends Controller {
|
||||
return;
|
||||
}
|
||||
|
||||
// 保存文件
|
||||
const fileInfo = await service.upload.saveWorkFile(stream, stream.filename, group);
|
||||
|
||||
// 获取作品名称(流式模式字段在 stream.fields 中)
|
||||
// 获取作品名称与文件类型(流式模式字段在 stream.fields 中)。
|
||||
// ⚠️ 必须先取再落盘:落盘要用 file_type 判定「同名文件是否同一次提交的新版本」。
|
||||
const workName = (stream.fields && stream.fields.work_name) || stream.filename;
|
||||
// 文件类型:word / ppt(前端上传时透传)
|
||||
const fileType = (stream.fields && stream.fields.file_type) || 'word';
|
||||
|
||||
// 保存文件:落点 = works/<group>/<小组编号>/<原始文件名>
|
||||
const fileInfo = await service.upload.saveWorkFile(
|
||||
stream, stream.filename, group, teamFolder,
|
||||
{ fileType, aggregationUid: aggregation.aggregation_uid }
|
||||
);
|
||||
savedFileInfo = fileInfo; // 记录落盘结果,供 catch 里回滚孤儿文件用
|
||||
|
||||
// 保存提交记录
|
||||
const record = await service.upload.saveSubmissionRecord(
|
||||
aggregation.aggregation_uid,
|
||||
@@ -118,6 +174,8 @@ class UploadController extends Controller {
|
||||
workName,
|
||||
fileType
|
||||
);
|
||||
// 🔴 入库成功即置位:此后任何异常都**不得**再删这个文件(它已被 DB 引用)
|
||||
recordPersisted = true;
|
||||
|
||||
ctx.body = ctx.helper.success({
|
||||
id: record.id,
|
||||
@@ -130,9 +188,25 @@ class UploadController extends Controller {
|
||||
uploadTime: record.upload_time || new Date().toISOString(),
|
||||
}, '文件上传成功');
|
||||
} catch (err) {
|
||||
// 🔴 孤儿回滚(C9 · R45):文件已落盘但记录未入库 ⇒ 立即清掉这个新文件。
|
||||
// 条件严格限定为 `savedFileInfo && !recordPersisted`:
|
||||
// · 未落盘(校验/护栏阶段失败)→ savedFileInfo 为 null,无事可做;
|
||||
// · 已入库(recordPersisted=true)→ 文件正被 DB 引用,**绝不能删**。
|
||||
// 仍然 best-effort:清理失败只告警,不能把真正的错误信息顶掉(同 helper.safeCleanup 的约定)。
|
||||
if (savedFileInfo && savedFileInfo.filePath && !recordPersisted) {
|
||||
service.upload.removePublicFile(savedFileInfo.filePath, 'upload-rollback');
|
||||
}
|
||||
ctx.logger.error('文件上传失败:', err);
|
||||
const msg = (err && err.message) ? err.message : '未知错误';
|
||||
ctx.body = ctx.helper.error(500, msg);
|
||||
} finally {
|
||||
// 🔴 释放配额。为何用 `if (ioAcquired)` 而非像 uploadChunk 那样无条件 release:
|
||||
// 本方法的外层 try 包住了「校验 + 取配额 + 落盘」全过程,而 429 分支是
|
||||
// **acquire 失败**(配额没拿到)后 return 的 —— 若无条件释放,会把**别人的**配额
|
||||
// 放掉,配额计数下溢 ⇒ 护栏静默失效(真实并发上限被虚高),是最难查的一类退化。
|
||||
// ⚠️ 反之也不能省 last 这个 finally:中途任何 return 都不允许泄漏配额,
|
||||
// 否则配额只减不增,若干轮后上传通道**永久 429**。
|
||||
if (ioAcquired) releaseFileIo();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,6 +216,8 @@ class UploadController extends Controller {
|
||||
*
|
||||
* Body: multipart/form-data 包含 chunk(文件分片)、chunkIndex、totalChunks、fileId、fileName
|
||||
*/
|
||||
// @no-temp-cleanup: 分片接收端,临时目录必须**保留**待 mergeChunks 合并;
|
||||
// 若在此清理则分片无法续传。清理责任在 mergeChunks(upload.js:342 safeCleanup)。
|
||||
async uploadChunk() {
|
||||
const { ctx, app } = this;
|
||||
|
||||
@@ -214,6 +290,11 @@ class UploadController extends Controller {
|
||||
*/
|
||||
async mergeChunks() {
|
||||
const { ctx, service, app } = this;
|
||||
// 🔴 C9 根因①的孪生缺陷(R45):合并已落盘、但记录没入库 ⇒ 孤儿。语义同 upload()。
|
||||
let mergedRelPath = null;
|
||||
// 合并临时文件(先写临时名再原子 rename)。失败时需清掉,否则留下 `.merge_*` 残渣。
|
||||
let mergedTmpPath = null;
|
||||
let recordPersisted = false;
|
||||
|
||||
try {
|
||||
const { group } = ctx.params;
|
||||
@@ -239,6 +320,22 @@ class UploadController extends Controller {
|
||||
return;
|
||||
}
|
||||
|
||||
// 🔴 R190 组内上传互斥:与 upload() 同一个守卫(两条落盘路径都必须拦,
|
||||
// 否则被锁成员改走分片合并即可绕过组内锁)。
|
||||
const gate = await service.upload.assertTeamUploadAllowed(aggregation, group);
|
||||
if (!gate.ok) {
|
||||
const body = ctx.helper.error(6006, gate.message);
|
||||
body.data = {
|
||||
teamLocked: true,
|
||||
teamOwnerName: gate.state.owner && gate.state.owner.name ? gate.state.owner.name : null,
|
||||
teamNumber: gate.state.teamNumber,
|
||||
folderName: gate.state.folderName,
|
||||
};
|
||||
ctx.body = body;
|
||||
return;
|
||||
}
|
||||
const teamFolder = gate.state.folderName;
|
||||
|
||||
// 检查分片目录
|
||||
const tempDir = path.join(app.baseDir, 'app/public/tmp', group, fileId);
|
||||
if (!fs.existsSync(tempDir)) {
|
||||
@@ -258,16 +355,20 @@ class UploadController extends Controller {
|
||||
return;
|
||||
}
|
||||
|
||||
// 构建目标文件路径
|
||||
const uploadDir = path.join(app.baseDir, 'app/public/works', group);
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
|
||||
const ext = path.extname(fileName);
|
||||
const timestamp = Date.now();
|
||||
const safeFileName = `work_${timestamp}_${Math.random().toString(36).substring(2, 8)}${ext}`;
|
||||
const targetPath = path.join(uploadDir, safeFileName);
|
||||
// 构建落盘目标:小组目录 + 原始文件名(唯一命名实现 = service.prepareWorkTarget,
|
||||
// 与 saveWorkFile 同源,⛔ 不得在此另写一份随机命名)。
|
||||
const target = await service.upload.prepareWorkTarget({
|
||||
group,
|
||||
folderName: teamFolder,
|
||||
filename: fileName,
|
||||
fileType: file_type || 'word',
|
||||
aggregationUid: aggregation.aggregation_uid,
|
||||
});
|
||||
// 先合并到临时名,再原子 rename 到最终名 —— 避免管理员在合并过程中下载到半截文件
|
||||
mergedTmpPath = path.join(
|
||||
target.dir,
|
||||
`.merge_${Date.now()}_${Math.random().toString(36).substring(2, 8)}${path.extname(target.fileName)}`
|
||||
);
|
||||
|
||||
// 🔴 文件 IO 并发护栏:分片合并是重 IO,与 uploadChunk 共享同一配额,
|
||||
// 极端并发(无带宽限制 + 单 worker)下被统一限流,避免 fd/事件循环耗尽雪崩。
|
||||
@@ -280,8 +381,8 @@ class UploadController extends Controller {
|
||||
}
|
||||
|
||||
try {
|
||||
// 合并分片
|
||||
const mergeResult = await service.upload.mergeFileChunks(chunkFiles, targetPath);
|
||||
// 合并分片(落到临时名)
|
||||
await service.upload.mergeFileChunks(chunkFiles, mergedTmpPath);
|
||||
|
||||
// 🔴 删除临时目录:best-effort,**绝不能让清理失败把已成功的合并判成 500**。
|
||||
// 旧代码是裸 fs.rmdirSync,删除失败(杀软锁文件/句柄未释放/安全钩子拦截)会
|
||||
@@ -289,16 +390,11 @@ class UploadController extends Controller {
|
||||
// 直接产出孤儿文件 + 数据不一致 + 用户重试再生孤儿。清理属副作用,不是业务结果。
|
||||
ctx.helper.safeCleanup(tempDir, { label: 'mergeChunks-tmp' });
|
||||
|
||||
// 构建相对路径
|
||||
const relativePath = path.join('works', group, safeFileName).replace(/\\/g, '/');
|
||||
// 原子落位 + 计算 size/md5(filePath 为相对 app/public 的正斜杠路径)
|
||||
const fileInfo = await service.upload.commitWorkFile(mergedTmpPath, target);
|
||||
mergedRelPath = fileInfo.filePath; // 供 catch 回滚孤儿文件用
|
||||
|
||||
// 保存提交记录
|
||||
const fileInfo = {
|
||||
filePath: relativePath,
|
||||
fileName,
|
||||
fileSize: mergeResult.fileSize,
|
||||
md5: mergeResult.md5,
|
||||
};
|
||||
const record = await service.upload.saveSubmissionRecord(
|
||||
aggregation.aggregation_uid,
|
||||
group,
|
||||
@@ -306,21 +402,31 @@ class UploadController extends Controller {
|
||||
work_name || fileName,
|
||||
file_type || 'word'
|
||||
);
|
||||
// 入库成功即置位:此后异常不得再删该文件(已被 DB 引用)
|
||||
recordPersisted = true;
|
||||
|
||||
ctx.body = ctx.helper.success({
|
||||
id: record.id,
|
||||
workName: record.work_name,
|
||||
filePath: relativePath,
|
||||
fileUrl: ctx.helper.fileUrl(relativePath),
|
||||
fileName,
|
||||
fileSize: mergeResult.fileSize,
|
||||
md5: mergeResult.md5,
|
||||
filePath: fileInfo.filePath,
|
||||
fileUrl: ctx.helper.fileUrl(fileInfo.filePath),
|
||||
fileName: fileInfo.fileName,
|
||||
fileSize: fileInfo.fileSize,
|
||||
md5: fileInfo.md5,
|
||||
uploadTime: record.upload_time || new Date().toISOString(),
|
||||
}, '分片合并成功');
|
||||
} finally {
|
||||
releaseFileIo();
|
||||
}
|
||||
} catch (err) {
|
||||
// 🔴 孤儿回滚(C9 · R45):合并结果已落盘但记录未入库 ⇒ 清掉该文件。语义同 upload()。
|
||||
if (mergedRelPath && !recordPersisted) {
|
||||
service.upload.removePublicFile(mergedRelPath, 'merge-rollback');
|
||||
}
|
||||
// 合并中途失败 ⇒ 临时名文件也必须清掉(best-effort,绝不掩盖真实错误)
|
||||
if (mergedTmpPath && !mergedRelPath) {
|
||||
ctx.helper.safeCleanup(mergedTmpPath, { label: 'mergeChunks-failed' });
|
||||
}
|
||||
ctx.logger.error('分片合并失败:', err);
|
||||
ctx.body = ctx.helper.error(500, '分片合并失败');
|
||||
}
|
||||
@@ -330,6 +436,8 @@ class UploadController extends Controller {
|
||||
* 查询分片上传进度
|
||||
* GET /api/upload/:group/chunk-status?fileId=xxx
|
||||
*/
|
||||
// @no-temp-cleanup: 本方法**只读探测**临时目录(fs.existsSync + readdirSync),
|
||||
// 不创建也不删除任何文件 ⇒ 「作用域内无清理」是正确形态,不是缺口。
|
||||
async chunkStatus() {
|
||||
const { ctx, app } = this;
|
||||
|
||||
@@ -385,16 +493,33 @@ class UploadController extends Controller {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查提交状态
|
||||
const submitted = await service.upload.hasSubmitted(aggregation.aggregation_uid, group);
|
||||
// 检查提交状态(本人)+ 组内互斥状态(R190)
|
||||
const [submitted, teamState, deadlineCheck] = await Promise.all([
|
||||
service.upload.hasSubmitted(aggregation.aggregation_uid, group),
|
||||
service.upload.getTeamUploadState(aggregation, group),
|
||||
service.upload.validateDeadline(group),
|
||||
]);
|
||||
|
||||
// 检查截止时间
|
||||
const deadlineCheck = await service.upload.validateDeadline(group);
|
||||
// 🔴 组锁状态必须暴露给前端,否则被锁成员会看到「未上传」却任何操作都失败,
|
||||
// 完全不知道原因(同一类「状态不可见」缺陷在考试组锁上已踩过)。
|
||||
const canUpload = deadlineCheck.valid && !teamState.locked;
|
||||
|
||||
ctx.body = ctx.helper.success({
|
||||
submitted,
|
||||
submitted, // 本人是否已上传
|
||||
deadline: deadlineCheck.deadline,
|
||||
canSubmit: deadlineCheck.valid,
|
||||
canSubmit: canUpload, // 兼容旧字段:语义 = 现在能不能上传
|
||||
canUpload,
|
||||
deadlineValid: deadlineCheck.valid,
|
||||
// ---- 小组维度 ----
|
||||
teamNumber: teamState.teamNumber, // 人类可读小组编号(teams.team_number || team_id)
|
||||
folderName: teamState.folderName, // 后端落盘用的目录名(前端只展示,不参与命名)
|
||||
memberCount: teamState.memberCount,
|
||||
teamSubmitted: teamState.hasAnySubmission, // 组内是否已有人上传
|
||||
teamOwnerName: teamState.owner ? teamState.owner.name : null,
|
||||
isTeamOwner: teamState.isOwner,
|
||||
teamLocked: teamState.locked, // true = 组内别人已上传,本人不能再传
|
||||
teamLockMessage: teamState.lockMessage,
|
||||
teamFiles: teamState.teamFiles, // 组内已上传文件清单(含上传者)
|
||||
}, '获取提交状态成功');
|
||||
} catch (err) {
|
||||
ctx.logger.error('查询提交状态失败:', err);
|
||||
|
||||
@@ -3,7 +3,23 @@
|
||||
const crypto = require('crypto');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const fs = require('fs');
|
||||
const bcrypt = require('bcryptjs');
|
||||
// 🔴 R8(2026-09-21):原生 bcrypt(N-API),**不是** bcryptjs。
|
||||
// 原实现 `require('bcryptjs')` 是纯 JS、**同步阻塞事件循环** —— 实测 16 并发 compare
|
||||
// 耗时 1129ms,且期间**心跳达成率 0%**(主线程被冻结 1.1s,无法 accept 新连接),
|
||||
// 这正是登录链路需要"并发闸门"兜底的根因。原生实现走 libuv 线程池:
|
||||
// 同批 compare 265ms(**4.20×**)、心跳持续(环境实测见 probes/_bench_eventloop.cjs)。
|
||||
//
|
||||
// ✅ 兼容性(已验证,drop-in,无需数据迁移):
|
||||
// · API 同形:hash / compare / hashSync / compareSync 均已实测存在;
|
||||
// · 哈希格式同形:产出仍为 `$2b$10$`,与存量 26/26 条完全一致,可互相比对;
|
||||
// · 是 **N-API** 构建(prebuilds 文件名无 ABI 后缀)⇒ node 18(ABI 108) 与
|
||||
// 22(ABI 127) 同一份二进制均可 load;包内自带 win32-x64 与
|
||||
// linux-x64(glibc + musl)预编译 ⇒ 本地与 Linux 生产**均无需编译**。
|
||||
//
|
||||
// ⚠️ 生产部署影响:原生模块**不会**被 rsync 源码带走,服务器须重新 `npm install`
|
||||
// (见交付报告「上线前置」)。⛔ 不要把 `bcryptjs` 残留为依赖 —— 留着只会
|
||||
// 让后人误以为仍在用纯 JS 实现。
|
||||
const bcrypt = require('bcrypt');
|
||||
|
||||
// bcrypt 计算强度(10 轮,登录场景性能与安全的平衡点)
|
||||
const BCRYPT_ROUNDS = 10;
|
||||
|
||||
288
app/lib/adminPermissions.js
Normal file
288
app/lib/adminPermissions.js
Normal file
@@ -0,0 +1,288 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 管理端权限点与「路由 → 权限」映射(R178·C 层唯一真相源)
|
||||
* ============================================================================
|
||||
*
|
||||
* 为什么需要这层:后端有 63 条 /api/admin/* 路由,原本全部只挂 adminAuth(role='admin')。
|
||||
* 在本模块之前,「是管理员」等于「有全部权限」—— 无法做到「只给阅卷、不给发布」。
|
||||
*
|
||||
* 三条设计纪律(改动前必读):
|
||||
*
|
||||
* 1. **默认拒绝**:`resolvePermission()` 匹配不到任何规则时返回 null,
|
||||
* 中间件对 null **直接 403**,而不是放行。
|
||||
* ⇒ 新增路由若忘了登记规则,表现是「功能不可用」而非「静默成为人人可用的后门」。
|
||||
*
|
||||
* 2. **单一真相源**:路由的权限归属只在本文件声明,⛔ 不得在 router.js 逐条写权限参数
|
||||
* (那会让 63 条路由各带一份权限字符串,与本表必然漂移,且无人能核对全集)。
|
||||
* 全集对齐由门禁 `scripts/verify_admin_rbac.py` 双向断言:
|
||||
* 「router.js 的 /api/admin 路由全集」== 「ROUTE_RULES 覆盖集」,两个方向差集都必须为空。
|
||||
*
|
||||
* 3. **多权限其一(anyOf)**:部分接口被两个菜单共用(如成绩复核页复用成绩列表),
|
||||
* 用 `perms: ['scores','review']` 表达「具备任一即可」。这不会降低安全性 ——
|
||||
* 任何一条都需被显式授予,只是避免「勾了复核却被 403」的误伤。
|
||||
*
|
||||
* 高危权限点单独成项(score.publish / data.reset / admins.manage),
|
||||
* 目的是把「能看成绩」与「能发布成绩」分开 —— 这两件事的责任人通常不同。
|
||||
*/
|
||||
|
||||
const MODULE = 'module';
|
||||
const HIGH_RISK = 'highRisk';
|
||||
|
||||
/**
|
||||
* 全部权限点。顺序即前端展示顺序。
|
||||
* ⚠️ 新增权限点必须同步:① 本数组;② ROUTE_RULES 至少一条规则;③ 前端 `can()` 使用处。
|
||||
*/
|
||||
const PERMISSIONS = [
|
||||
{ key: 'dashboard', label: '数据总览', group: MODULE, desc: '查看各组账号数 / 提交数 / 激活率' },
|
||||
{ key: 'accounts', label: '选手管理', group: MODULE, desc: '查看选手、导入报名表、编辑选手信息、标记测试数据' },
|
||||
{ key: 'questions', label: '试卷管理', group: MODULE, desc: '客观题与综合大题题库维护、Word 模板' },
|
||||
{ key: 'materials', label: '备赛资料', group: MODULE, desc: '上传 / 删除备赛资料' },
|
||||
{ key: 'submissions', label: '作品管理', group: MODULE, desc: '查看作品与答题记录、下载作品文件' },
|
||||
{ key: 'times', label: '时间设置', group: MODULE, desc: '考试起止时间、抽题数量、节点备份状态' },
|
||||
{ key: 'scores', label: '成绩管理', group: MODULE, desc: '阅卷打分、导出成绩、生成公示表(不含发布)' },
|
||||
{ key: 'review', label: '成绩复核', group: MODULE, desc: '按选手查验分项得分' },
|
||||
{ key: 'news', label: '新闻管理', group: MODULE, desc: '新闻增删改、置顶、附件上传、发布 / 撤回' },
|
||||
{ key: 'carousel', label: '轮播管理', group: MODULE, desc: '首页轮播图增删改与排序' },
|
||||
{ key: 'score.publish', label: '发布 / 撤回成绩', group: HIGH_RISK, desc: '控制考生端能否看到成绩,并自动生成公示草稿' },
|
||||
{ key: 'data.reset', label: '重置测试数据 / 删除选手', group: HIGH_RISK, desc: '会真实删除数据的操作,仅授予负责人' },
|
||||
{ key: 'admins.manage', label: '管理员账号管理', group: HIGH_RISK, desc: '新增 / 删除管理员、分配权限、重置口令、禁用' },
|
||||
];
|
||||
|
||||
const PERMISSION_KEYS = PERMISSIONS.map(p => p.key);
|
||||
const PERMISSION_KEY_SET = new Set(PERMISSION_KEYS);
|
||||
|
||||
/**
|
||||
* 路径模式 → 正则。`:xxx` 段匹配任意单段(不含 `/`)。
|
||||
* 其余字符按字面转义,避免 `/api/admin/big-questions/:group/:id` 里的 `-` `.` 被当元字符。
|
||||
*/
|
||||
function toRegExp(pattern) {
|
||||
const src = pattern
|
||||
.split('/')
|
||||
.map((seg) => {
|
||||
if (seg.startsWith(':')) return '[^/]+';
|
||||
return seg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
})
|
||||
.join('/');
|
||||
return new RegExp('^' + src + '$');
|
||||
}
|
||||
|
||||
/**
|
||||
* 路由规则表。**顺序敏感**:更具体的路径必须排在带 `:id` 的通配路径之前
|
||||
* (如 `/news/order` 必须在 `/news/:id` 之前),否则会被后者抢先匹配。
|
||||
*
|
||||
* ⚠️ 每条规则的 perms 至少一个;`perms` 语义 = 「具备其中之一即可」。
|
||||
*/
|
||||
const ROUTE_TABLE = [
|
||||
// ---------- 自助信息(登录即可,无需业务权限) ----------
|
||||
// 前端用它得知「我有哪些权限」以渲染菜单,并感知权限是否被他人改动。
|
||||
// ⚠️ perms 为空数组是本表唯一允许的「显式放行」写法,门禁会断言只有本行如此。
|
||||
['GET', '/api/admin/me', []],
|
||||
|
||||
// ---------- 管理员账号管理(高危) ----------
|
||||
['GET', '/api/admin/admin-users', ['admins.manage']],
|
||||
['POST', '/api/admin/admin-users', ['admins.manage']],
|
||||
['PUT', '/api/admin/admin-users/:id/permissions', ['admins.manage']],
|
||||
['PUT', '/api/admin/admin-users/:id/password', ['admins.manage']],
|
||||
['PUT', '/api/admin/admin-users/:id/status', ['admins.manage']],
|
||||
['DELETE', '/api/admin/admin-users/:id', ['admins.manage']],
|
||||
|
||||
// ---------- 数据总览 ----------
|
||||
['GET', '/api/admin/stats', ['dashboard']],
|
||||
|
||||
// ---------- 选手管理 ----------
|
||||
['GET', '/api/admin/accounts', ['accounts']],
|
||||
['GET', '/api/admin/teams', ['accounts']],
|
||||
['POST', '/api/admin/import-excel', ['accounts']],
|
||||
['POST', '/api/admin/contestants/backfill-teacher', ['accounts']],
|
||||
['PUT', '/api/admin/contestants/:id', ['accounts']],
|
||||
['POST', '/api/admin/toggle-test-flag', ['accounts']],
|
||||
// 重置测试数据是真实删除,单列高危(不给「只做选手维护」的人)
|
||||
['POST', '/api/admin/reset-test-data', ['data.reset']],
|
||||
|
||||
// ---------- 作品管理 ----------
|
||||
// 成绩管理页的「考生答题记录」也读 submissions 接口 ⇒ 允许 scores 其一
|
||||
['GET', '/api/admin/submissions/:group', ['submissions', 'scores']],
|
||||
['GET', '/api/admin/submission-file/:group/:id', ['submissions']],
|
||||
['GET', '/api/admin/submissions/:group/:id/word', ['submissions']],
|
||||
['GET', '/api/admin/works/:group', ['submissions']],
|
||||
['GET', '/api/admin/batch-download/:group', ['submissions']],
|
||||
// R190 单小组作品压缩包:与批量下载同属「作品管理」能力,同权限即可
|
||||
['GET', '/api/admin/team-zip/:group', ['submissions']],
|
||||
|
||||
// ---------- 成绩管理(阅卷/导出/公示表) ----------
|
||||
['GET', '/api/admin/scores/:group', ['scores', 'review']],
|
||||
['PUT', '/api/admin/scores/:group', ['scores']],
|
||||
['GET', '/api/admin/export-csv/:group', ['scores']],
|
||||
['GET', '/api/admin/export-excel/:group', ['scores']],
|
||||
['GET', '/api/admin/export-publicity/:group', ['scores']],
|
||||
// R220 成绩排名表:同属「成绩管理」导出能力,与 export-excel / export-publicity 同权限
|
||||
['GET', '/api/admin/export-score-rank/: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,
|
||||
};
|
||||
152
app/lib/aggregationKey.js
Normal file
152
app/lib/aggregationKey.js
Normal file
@@ -0,0 +1,152 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 归集键单一真相源 - aggregationKey
|
||||
* ===================================================================
|
||||
* 背景(2026-10-08 生产实测,人工测试反馈「测试账号的提交数据出现在正常
|
||||
* 生产账号上」):
|
||||
*
|
||||
* 各组提交表的 aggregation_uid **写入键并不统一**:
|
||||
* - 中职 / 高职:controller/exam.js 落库时写 `aggregation_uid = contestant.id`
|
||||
* - 本科 / 教师:benke_submissions / jiaoshi_submissions 写
|
||||
* `aggregation.aggregation_uid`(真实聚合 uid)
|
||||
*
|
||||
* 历史实现为兼容「两种写法并存」,对两侧都做**双键归集**——同时用
|
||||
* `contestant.id` 与「真实 aggregation_uid」去匹配提交表。
|
||||
*
|
||||
* 但这两套编号来自**彼此独立的自增序列**,数值区间完全重叠。于是当
|
||||
* 「某人的真实聚合 uid == 同组另一人的 contestant.id」时,前者会把后者的
|
||||
* 作答记录 / 作品提交「借」过来展示 —— 生产实测共 **283 对**碰撞
|
||||
* (gaozhi 239 / benke 43 / jiaoshi 1),已复现 1 例:
|
||||
* 王乐乐(cid=600, 真实聚合 uid=621) 命中 吴巍荪(cid=621) 的 70 题 / 38 分。
|
||||
*
|
||||
* 治法(本模块):
|
||||
* 1. **主键按组语义确定**,单一、无歧义:
|
||||
* - 考试组(zhongzhi / gaozhi)主键 = contestant.id
|
||||
* - 作品组(benke / jiaoshi)主键 = 真实 aggregation_uid
|
||||
* 2. **兜底键沿用历史混杂写法,但必须通过歧义判定**才启用:
|
||||
* - 不得等于同组任何人的主键(唯一的真实歧义来源:跨编号空间重叠)
|
||||
* - (「兜底键被多人共用」结构上不可能:两套编号各自都是 PRIMARY KEY,
|
||||
* 详见 buildAggregationKeyMap 内的不变量说明,故不设该分支)
|
||||
* 未通过者一律丢弃 —— 只丢掉「本就会命中他人数据」的兜底,保留其余兼容性。
|
||||
*
|
||||
* 生产实测效果:283 个碰撞兜底被丢弃,317 个安全兜底保留,残留碰撞 **0**。
|
||||
*
|
||||
* ⚠️ 使用约束:本模块的歧义判定以 **同组** 为范围。调用方必须保证
|
||||
* `contestants` 与查询的提交表同属一个 `group`(提交表按组物理分表,
|
||||
* 跨组不会互相命中,故同组范围即充分)。
|
||||
*/
|
||||
|
||||
/** 考试类组别(客观题 + 综合大题;提交表按 contestant.id 归集) */
|
||||
const EXAM_GROUPS = ['zhongzhi', 'gaozhi'];
|
||||
|
||||
/** 是否为考试类组别 */
|
||||
function isExamGroup(group) {
|
||||
return EXAM_GROUPS.indexOf(group) !== -1;
|
||||
}
|
||||
|
||||
function _blank(v) {
|
||||
return v === null || v === undefined || v === '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 主键:该组数据**实际写入**所用的键。
|
||||
* @param {string} group
|
||||
* @param {number|string} contestantId contestants.id
|
||||
* @param {number|string|null} realAggUid aggregation.aggregation_uid
|
||||
* @returns {string}
|
||||
*/
|
||||
function primaryKeyOf(group, contestantId, realAggUid) {
|
||||
const cid = String(contestantId);
|
||||
if (isExamGroup(group)) return cid;
|
||||
// 作品组:主键是真实聚合 uid;缺失时退化为 cid(保持可用,不再引入第二个键)
|
||||
return _blank(realAggUid) ? cid : String(realAggUid);
|
||||
}
|
||||
|
||||
/**
|
||||
* 兜底键:历史混杂写法所用键(与主键分属**相反**的编号空间)。
|
||||
* @returns {string|null} null 表示无兜底键
|
||||
*/
|
||||
function fallbackKeyOf(group, contestantId, realAggUid) {
|
||||
const cid = String(contestantId);
|
||||
if (isExamGroup(group)) return _blank(realAggUid) ? null : String(realAggUid);
|
||||
return cid;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量:为**同组**全部选手构建归集键。
|
||||
* @param {string} group
|
||||
* @param {Array<{id:number|string}>} contestants 同组选手列表
|
||||
* @param {Object<string, number|string>} realAggByContestant contestant.id -> 真实 aggregation_uid
|
||||
* @returns {Object<string, string[]>} contestant.id -> 归集键数组(首元素必为主键,且不重复)
|
||||
*/
|
||||
function buildAggregationKeyMap(group, contestants, realAggByContestant) {
|
||||
const aggMap = realAggByContestant || {};
|
||||
const list = Array.isArray(contestants) ? contestants : [];
|
||||
const primarySet = new Set();
|
||||
const plan = new Map();
|
||||
|
||||
for (const c of list) {
|
||||
const cid = String(c.id);
|
||||
const rag = aggMap[cid] !== undefined ? aggMap[cid] : aggMap[c.id];
|
||||
const p = primaryKeyOf(group, cid, rag);
|
||||
const f = fallbackKeyOf(group, cid, rag);
|
||||
plan.set(cid, { p, f });
|
||||
primarySet.add(p);
|
||||
}
|
||||
|
||||
// ⚠️ 结构性不变量(已核对 DDL):`aggregation.aggregation_uid` 是 PRIMARY KEY(全局唯一),
|
||||
// `contestants.id` 也是 PRIMARY KEY ⇒ 兜底键不可能被两名选手共用
|
||||
// (考试组兜底 = 真实聚合 uid,作品组兜底 = cid,两者各自唯一)。
|
||||
// 故**不需要**「兜底键被多人共用」的计数分支——那是永假的死判据。
|
||||
// 唯一真实的歧义来源 = 跨编号空间重叠(兜底键撞上他人主键),下面一行即判据。
|
||||
const result = {};
|
||||
for (const [cid, { p, f }] of plan) {
|
||||
const keys = [p];
|
||||
if (f && f !== p && !primarySet.has(f)) keys.push(f);
|
||||
result[cid] = keys;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判定某个键是否**已被同组他人认领为主键**(单选手路径的兜底键歧义判定)。
|
||||
*
|
||||
* - 考试组:主键 = contestant.id ⇒ 查同组 contestants 是否存在该 id
|
||||
* - 作品组:主键 = 真实 aggregation_uid ⇒ 查同组 aggregation 是否存在该 aggregation_uid
|
||||
*
|
||||
* 两处查询都命中主键/唯一列,`COUNT` ∈ {0,1},判据非退化(`>0` 即「有人认领」)。
|
||||
*
|
||||
* @returns {Promise<boolean>} true = 有歧义,兜底键必须丢弃
|
||||
*/
|
||||
async function isKeyClaimedInGroup(ctx, group, key) {
|
||||
if (_blank(key)) return false;
|
||||
const numeric = /^\d+$/.test(String(key)) ? Number(key) : String(key);
|
||||
if (isExamGroup(group)) {
|
||||
return (await ctx.model.Contestant.count({ where: { id: numeric, group } })) > 0;
|
||||
}
|
||||
return (await ctx.model.Aggregation.count({ where: { aggregation_uid: numeric, group } })) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单选手:解析归集键(语义与 buildAggregationKeyMap 一致,歧义判定改为按需查库)。
|
||||
* @returns {Promise<string[]>} 归集键数组(首元素必为主键)
|
||||
*/
|
||||
async function resolveAggregationKeysForContestant(ctx, group, contestantId, realAggUid) {
|
||||
const cid = String(contestantId);
|
||||
const p = primaryKeyOf(group, cid, realAggUid);
|
||||
const f = fallbackKeyOf(group, cid, realAggUid);
|
||||
const keys = [p];
|
||||
if (f && f !== p && !(await isKeyClaimedInGroup(ctx, group, f))) keys.push(f);
|
||||
return keys;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
EXAM_GROUPS,
|
||||
isExamGroup,
|
||||
primaryKeyOf,
|
||||
fallbackKeyOf,
|
||||
buildAggregationKeyMap,
|
||||
isKeyClaimedInGroup,
|
||||
resolveAggregationKeysForContestant,
|
||||
};
|
||||
BIN
app/lib/assets/publicity-template.xlsx
Normal file
BIN
app/lib/assets/publicity-template.xlsx
Normal file
Binary file not shown.
78
app/lib/auditLog.js
Normal file
78
app/lib/auditLog.js
Normal file
@@ -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<void>}
|
||||
*/
|
||||
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 };
|
||||
429
app/lib/bigScoreImport.js
Normal file
429
app/lib/bigScoreImport.js
Normal file
@@ -0,0 +1,429 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 大题成绩 Excel 导入 —— 纯判定层(R172 建,R174 改三小问四列制)
|
||||
*
|
||||
* 为什么单独抽一个 lib:
|
||||
* 1) **可被 `--self-test` 直接自证**:本文件零依赖(不 require Egg / 不连库 / 不读文件),
|
||||
* `scripts/verify_big_score_import.py` 可直接 `node -e "require(...)"` 拿到**真实实现**,
|
||||
* 而不是在测试里另写一份「看起来一样」的副本(⛔ 副本自证 = 恒绿假自证)。
|
||||
* 2) 判据与主流程**同源**:service/bigQuestion.js 只能调这里,不得内联第二套算法。
|
||||
*
|
||||
* 业务口径(用户 2026-10-19 / 2026-10-20 两次裁定):
|
||||
* · Excel 为**四列**:`小组编号` + `第1小问` + `第2小问` + `第3小问`,四列都必填;
|
||||
* 总分由系统**求和**,表中不再有「分数」列。
|
||||
* · **任一小问留空 ⇒ 整行拒绝并报错**(用户明确裁定:留空按 0 会静默抹分)。
|
||||
* · **缺任一小问列 ⇒ 拒绝整份文件**,并提示重新下载模板。
|
||||
* · **旧版两列模板(小组编号 + 分数)不再支持**,整份拒绝并提示改用三小问模板。
|
||||
* · **一个小组只会有一人参加考试**(真正抽题作答的那位),但成绩**组内共享**
|
||||
* —— 因此只需把分写给「抽题者」,其余成员由 score.js `_resolveTeamScoreSource` 共享读取;
|
||||
* · 已评分默认拒绝,需显式勾选「覆盖已评分」。
|
||||
*
|
||||
* ⚠️ R174 变更史(改动前必读):
|
||||
* 旧实现只有 `scoreRaw` 一列总分,由 `splitScoreAcrossSubs()` **均分**到三小问。
|
||||
* 用户指出「模板忘了改成三小问形式」,故:
|
||||
* · 删除 `splitScoreAcrossSubs()`;
|
||||
* · 新增 `scoreSubs()`(逐项取值 + 逐项校验,不再做均分);
|
||||
* · 行状态 `SCORE_MISSING/SCORE_INVALID/SCORE_OUT_OF_RANGE` → `SUB_MISSING/SUB_INVALID/SUB_OUT_OF_RANGE`;
|
||||
* · 文件级错误码新增 `LEGACY_TEMPLATE` / `SUB_COLUMNS_MISSING`。
|
||||
* ⛔ 不得为「兼容老文件」保留均分分支 —— 用户已裁定「不再支持,只认三小问模板」。
|
||||
*/
|
||||
|
||||
/** 小问数量(题制固定 3 问,与 BIG_QUESTION 域一致) */
|
||||
const SUB_COUNT = 3;
|
||||
|
||||
/** 默认每小问分值(题库可配 score_per_sub,此处仅兜底) */
|
||||
const DEFAULT_PER_SUB = 10;
|
||||
|
||||
/** 分数两位小数(与 DECIMAL(6,2) 同口径) */
|
||||
const SCORE_SCALE = 100;
|
||||
|
||||
/** 表头别名(与实际 Excel 填写习惯对齐;⛔ 大小写与空格在匹配前已归一) */
|
||||
const COLUMN_ALIAS = {
|
||||
teamNumber: ['小组编号', '队伍编号', '团队编号', '小组号', '队号', 'team_number', 'teamnumber', 'team_id', 'teamid'],
|
||||
sub1: [
|
||||
'第1小问', '第1问', '小问1', '小问一', '第一小问', '1小问',
|
||||
'score1', 'score_1', 'sub1', 'sub_score_1', '大题第1小问',
|
||||
],
|
||||
sub2: [
|
||||
'第2小问', '第2问', '小问2', '小问二', '第二小问', '2小问',
|
||||
'score2', 'score_2', 'sub2', 'sub_score_2', '大题第2小问',
|
||||
],
|
||||
sub3: [
|
||||
'第3小问', '第3问', '小问3', '小问三', '第三小问', '3小问',
|
||||
'score3', 'score_3', 'sub3', 'sub_score_3', '大题第3小问',
|
||||
],
|
||||
/**
|
||||
* 旧版两列模板的「总分」列。
|
||||
* ⚠️ 只用于**识别旧文件并给出可读拒绝**,绝不参与取值(用户裁定:不再支持旧模板)。
|
||||
*/
|
||||
legacyTotal: ['分数', '得分', '成绩', '总分', '大题得分', '大题总分', 'score', 'totalscore', 'total'],
|
||||
};
|
||||
|
||||
const ROW_STATUS = {
|
||||
OK: 'OK', // 可导入(该考生尚未评分)
|
||||
OVERWRITE: 'OVERWRITE', // 可导入,但会覆盖既有评分 ⇒ 需显式勾选
|
||||
EMPTY_ROW: 'EMPTY_ROW', // 整行空白 ⇒ 静默跳过,不计错误
|
||||
TEAM_MISSING: 'TEAM_MISSING', // 小组编号列为空
|
||||
SUB_MISSING: 'SUB_MISSING', // 至少一个小问留空 ⇒ 整行拒绝(不按 0 分处理)
|
||||
SUB_INVALID: 'SUB_INVALID', // 小问不是数值 / 负数
|
||||
SUB_OUT_OF_RANGE: 'SUB_OUT_OF_RANGE', // 某小问超过单价分值(perSub)
|
||||
DUPLICATE_IN_FILE: 'DUPLICATE_IN_FILE', // 同一文件内该小组编号出现多次
|
||||
TEAM_NOT_FOUND: 'TEAM_NOT_FOUND', // 全库都没有这个小组编号
|
||||
TEAM_IN_OTHER_GROUP: 'TEAM_IN_OTHER_GROUP', // 小组编号存在,但属于别的组
|
||||
NO_OPERATOR: 'NO_OPERATOR', // 组内无人抽取大题 ⇒ 无 rows 可评分
|
||||
MULTI_OPERATOR: 'MULTI_OPERATOR', // 组内多人抽取大题 ⇒ 违反「一组一人参赛」,拒绝猜测
|
||||
};
|
||||
|
||||
/** 判定为「阻断」的状态(不写库、计入错误) */
|
||||
const BLOCKING_STATUS = [
|
||||
ROW_STATUS.TEAM_MISSING,
|
||||
ROW_STATUS.SUB_MISSING,
|
||||
ROW_STATUS.SUB_INVALID,
|
||||
ROW_STATUS.SUB_OUT_OF_RANGE,
|
||||
ROW_STATUS.DUPLICATE_IN_FILE,
|
||||
ROW_STATUS.TEAM_NOT_FOUND,
|
||||
ROW_STATUS.TEAM_IN_OTHER_GROUP,
|
||||
ROW_STATUS.NO_OPERATOR,
|
||||
ROW_STATUS.MULTI_OPERATOR,
|
||||
];
|
||||
|
||||
/** 文件级错误码(`parseScoreRows` 返回的 `code`) */
|
||||
const FILE_CODE = {
|
||||
EMPTY_FILE: 'EMPTY_FILE',
|
||||
HEADER_NOT_FOUND: 'HEADER_NOT_FOUND',
|
||||
LEGACY_TEMPLATE: 'LEGACY_TEMPLATE', // 只有「分数」一列 ⇒ 旧版两列模板
|
||||
SUB_COLUMNS_MISSING: 'SUB_COLUMNS_MISSING', // 有小问列但不足三列
|
||||
NO_DATA_ROW: 'NO_DATA_ROW',
|
||||
};
|
||||
|
||||
/**
|
||||
* 规范化「小组编号」单元格。
|
||||
*
|
||||
* Excel 常见污染:首尾空格、全角空格(U+3000)、不换行空格(U+00A0)、零宽字符(U+200B/200C/200D/FEFF)。
|
||||
* 这些肉眼不可见,但会让「BRICS2026-ST-027-91001」与库中值不相等 ⇒ 全部行莫名「小组编号不存在」。
|
||||
*
|
||||
* ⛔ 不做大小写折叠:队号含字母,大小写是其字面的一部分,擅自 toUpperCase 会掩盖录入错误。
|
||||
* @param {*} v
|
||||
* @returns {string}
|
||||
*/
|
||||
function normalizeTeamNumber(v) {
|
||||
if (v === null || v === undefined) return '';
|
||||
return String(v)
|
||||
.replace(/[\u3000\u00a0\u200b-\u200d\ufeff]/g, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** 表头单元格归一:去空白/下划线/全角空格后小写,仅用于别名匹配 */
|
||||
function normalizeHeader(v) {
|
||||
if (v === null || v === undefined) return '';
|
||||
return String(v)
|
||||
.replace(/[\s\u3000\u00a0_\-()()]/g, '')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 按别名在同义词表里定位列下标。
|
||||
* @param {Array<string>} headerRow
|
||||
* @param {Array<string>} aliases
|
||||
* @returns {number} 命中列下标,未命中 -1
|
||||
*/
|
||||
function findColumn(headerRow, aliases) {
|
||||
const cells = (headerRow || []).map(normalizeHeader);
|
||||
for (const alias of aliases) {
|
||||
const want = normalizeHeader(alias);
|
||||
const idx = cells.indexOf(want);
|
||||
if (idx >= 0) return idx;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** 单元格是否「空」(null / undefined / 纯空白串三者都算空) */
|
||||
function isBlank(v) {
|
||||
return v === null || v === undefined || String(v).trim() === '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 逐项取三小问得分并校验(**不再均分**)。
|
||||
*
|
||||
* 校验顺序(顺序即报错优先级,⛔ 不可调换):
|
||||
* 1) 先扫全量「留空」⇒ 一次性报出**所有**空列(用户裁定:留空整行拒绝,不按 0 分处理);
|
||||
* 2) 再逐项验「是否数值 / 是否负数」;
|
||||
* 3) 再逐项验「是否超过单问上限 perSub」。
|
||||
*
|
||||
* 为什么「留空」优先于「数值非法」:
|
||||
* 评卷人填到一半时最常见的形态就是「后两问还空着」,此时报「第 2、3 小问未填写」
|
||||
* 比报「第 2 小问 xx 不是数值」更贴近真实原因。
|
||||
*
|
||||
* ⚠️ 为什么不给「留空按 0」的宽松模式:
|
||||
* 用户 2026-10-20 明确裁定「整行拒绝并报错」——留空按 0 会**静默抹掉**已评分数,
|
||||
* 且导入后三问合计与评卷人预期不一致时,无从判断是漏填还是真 0 分。
|
||||
*
|
||||
* @param {Array<*>} subRaws - 三小问原始单元格值,长度不足按空处理
|
||||
* @param {number} [subCount=3]
|
||||
* @param {number} [perSub=10]
|
||||
* @returns {{ok:boolean, subs?:number[], total?:number, message?:string, status?:string}}
|
||||
*/
|
||||
function scoreSubs(subRaws, subCount, perSub) {
|
||||
const n = Number.isFinite(Number(subCount)) && Number(subCount) > 0
|
||||
? Math.floor(Number(subCount))
|
||||
: SUB_COUNT;
|
||||
const per = Number.isFinite(Number(perSub)) && Number(perSub) > 0
|
||||
? Number(perSub)
|
||||
: DEFAULT_PER_SUB;
|
||||
const perCents = Math.round(per * SCORE_SCALE);
|
||||
|
||||
const list = Array.isArray(subRaws) ? subRaws : [];
|
||||
const raws = [];
|
||||
for (let i = 0; i < n; i++) raws.push(list[i]);
|
||||
|
||||
// 1) 留空优先:一次性报出所有空列
|
||||
const emptyIdx = [];
|
||||
raws.forEach((raw, i) => { if (isBlank(raw)) emptyIdx.push(i + 1); });
|
||||
if (emptyIdx.length) {
|
||||
return {
|
||||
ok: false,
|
||||
status: ROW_STATUS.SUB_MISSING,
|
||||
message: `第 ${emptyIdx.join('、')} 小问未填写(三小问必须逐项填写,留空不会按 0 分处理)`,
|
||||
};
|
||||
}
|
||||
|
||||
// 2) / 3) 逐项验数值与上限
|
||||
const cents = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const raw = raws[i];
|
||||
const v = Number(raw);
|
||||
if (!Number.isFinite(v)) {
|
||||
return {
|
||||
ok: false,
|
||||
status: ROW_STATUS.SUB_INVALID,
|
||||
message: `第 ${i + 1} 小问「${raw}」不是数值`,
|
||||
};
|
||||
}
|
||||
const c = Math.round(v * SCORE_SCALE);
|
||||
if (c < 0) {
|
||||
return {
|
||||
ok: false,
|
||||
status: ROW_STATUS.SUB_INVALID,
|
||||
message: `第 ${i + 1} 小问不能为负数(填的是 ${(c / SCORE_SCALE).toFixed(2)})`,
|
||||
};
|
||||
}
|
||||
if (c > perCents) {
|
||||
return {
|
||||
ok: false,
|
||||
status: ROW_STATUS.SUB_OUT_OF_RANGE,
|
||||
message: `第 ${i + 1} 小问 ${(c / SCORE_SCALE).toFixed(2)} 超过单问上限 ${(perCents / SCORE_SCALE).toFixed(2)} 分`,
|
||||
};
|
||||
}
|
||||
cents.push(c);
|
||||
}
|
||||
|
||||
const totalCents = cents.reduce((s, c) => s + c, 0);
|
||||
return {
|
||||
ok: true,
|
||||
subs: cents.map(c => c / SCORE_SCALE),
|
||||
total: totalCents / SCORE_SCALE,
|
||||
message: '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 单行判定(纯函数,零 IO)。
|
||||
*
|
||||
* 入参是**预查好的上下文**,判定逻辑与主流程共用同一份代码:
|
||||
* · foundInGroup / foundInOtherGroup 由 service 查 teams 得到;
|
||||
* · operatorCount = 该小组内「有 big_question_answers 行」的考生数;
|
||||
* · currentTotal = 该考生现有大题总分(null/undefined ⇒ 未评分)。
|
||||
*
|
||||
* ⚠️ 判定顺序即优先级:先排「表格自身问题」,再排「编码/归属问题」,最后才是「覆盖确认」。
|
||||
* 顺序不能乱:否则「已评分但小问越界」会被报成 OVERWRITE,前端勾选覆盖后照样写不进去。
|
||||
*
|
||||
* @param {Object} input
|
||||
* @param {*} input.teamNumber 小组编号单元格原值
|
||||
* @param {Array<*>} input.subRaws 三小问原始单元格值
|
||||
* @returns {{status:string, message:string, subs?:number[], total?:number}}
|
||||
*/
|
||||
function classifyScoreRow(input) {
|
||||
const it = input || {};
|
||||
const teamNumber = normalizeTeamNumber(it.teamNumber);
|
||||
const subRaws = Array.isArray(it.subRaws) ? it.subRaws : [];
|
||||
const allSubsBlank = subRaws.every(isBlank);
|
||||
|
||||
// 1) 整行空白 ⇒ 静默跳过(模板示例后的空行常见)
|
||||
if (!teamNumber && allSubsBlank) {
|
||||
return { status: ROW_STATUS.EMPTY_ROW, message: '' };
|
||||
}
|
||||
// 2) 小组编号为空
|
||||
if (!teamNumber) {
|
||||
return { status: ROW_STATUS.TEAM_MISSING, message: '小组编号为空' };
|
||||
}
|
||||
// 3) 本文件内重复
|
||||
if (Number(it.dupCount) > 1) {
|
||||
return {
|
||||
status: ROW_STATUS.DUPLICATE_IN_FILE,
|
||||
message: `小组编号「${teamNumber}」在本文件中出现 ${it.dupCount} 次,无法判断以哪行为准 ⇒ 全部拒绝`,
|
||||
};
|
||||
}
|
||||
// 4) 三小问本身非法(留空 / 非数值 / 负数 / 越界)
|
||||
const subs = scoreSubs(subRaws, it.subCount, it.perSub);
|
||||
if (!subs.ok) {
|
||||
return { status: subs.status, message: subs.message };
|
||||
}
|
||||
// 5) 小组编号归属
|
||||
if (!it.foundInGroup) {
|
||||
if (it.foundInOtherGroup) {
|
||||
return {
|
||||
status: ROW_STATUS.TEAM_IN_OTHER_GROUP,
|
||||
// ⚠️ otherGroupLabel 已含「组」字(GROUP_LABELS 的值形如「高职组」)⇒ 这里不得再拼「组」
|
||||
message: `小组编号「${teamNumber}」属于${it.otherGroupLabel || '其他组别'},请在对应组别页签导入`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: ROW_STATUS.TEAM_NOT_FOUND,
|
||||
message: `小组编号「${teamNumber}」在本组不存在(请核对空格 / 是否为纯数字编号被 Excel 转成数值)`,
|
||||
};
|
||||
}
|
||||
// 6) 参赛者(抽题者)唯一性
|
||||
const opCount = Number(it.operatorCount) || 0;
|
||||
if (opCount === 0) {
|
||||
return {
|
||||
status: ROW_STATUS.NO_OPERATOR,
|
||||
message: '该小组尚无人抽取综合大题,无法录入成绩(考试结束后再导入)',
|
||||
};
|
||||
}
|
||||
if (opCount > 1) {
|
||||
return {
|
||||
status: ROW_STATUS.MULTI_OPERATOR,
|
||||
message: `该小组有 ${opCount} 人抽取了大题,与「一组一人参赛」不符 ⇒ 拒绝猜测,请先人工核对`,
|
||||
};
|
||||
}
|
||||
// 7) 覆盖确认
|
||||
const hasScore = !isBlank(it.currentTotal);
|
||||
if (hasScore) {
|
||||
return {
|
||||
status: ROW_STATUS.OVERWRITE,
|
||||
message: `该考生已评分(${Number(it.currentTotal).toFixed(2)} 分),需勾选「覆盖已评分」才会写入`,
|
||||
subs: subs.subs,
|
||||
total: subs.total,
|
||||
};
|
||||
}
|
||||
return { status: ROW_STATUS.OK, message: '', subs: subs.subs, total: subs.total };
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 sheet 的 AOA 解析成规范化行(纯函数)。
|
||||
*
|
||||
* 表头契约:**同一行**须同时命中「小组编号」列 + 三个小问列。
|
||||
* 缺列时的错误码必须可区分,否则用户只看到「表头未找到」,不知道是自己删了列还是用了旧模板:
|
||||
* · 只有「分数」一列(无任何小问列)⇒ `LEGACY_TEMPLATE`(提示重新下载三小问模板)
|
||||
* · 有小问列但不足三列 ⇒ `SUB_COLUMNS_MISSING`(点名缺第几列)
|
||||
* · 两者都认不出 ⇒ `HEADER_NOT_FOUND`
|
||||
*
|
||||
* @param {Array<Array>} aoa - 含表头的二维数组(`sheet_to_json(sheet, { header: 1 })`)
|
||||
* @returns {{ok:boolean, code?:string, message?:string, rows?:Array}}
|
||||
*/
|
||||
function parseScoreRows(aoa) {
|
||||
const grid = Array.isArray(aoa) ? aoa : [];
|
||||
if (!grid.length) {
|
||||
return {
|
||||
ok: false,
|
||||
code: FILE_CODE.EMPTY_FILE,
|
||||
message: 'Excel 内容为空(首行须为表头,第 1 页为「成绩导入」)',
|
||||
};
|
||||
}
|
||||
|
||||
// 表头可能不在第 1 行(用户常先写标题)⇒ 在前 5 行里找同时命中「编号 + 三小问」的
|
||||
let headerRowIdx = -1;
|
||||
let teamCol = -1;
|
||||
let subCols = null;
|
||||
/** 部分命中的证据,用于给出可区分的拒绝理由 */
|
||||
let partial = null;
|
||||
|
||||
for (let i = 0; i < Math.min(grid.length, 5); i++) {
|
||||
const line = grid[i] || [];
|
||||
const t = findColumn(line, COLUMN_ALIAS.teamNumber);
|
||||
if (t < 0) continue;
|
||||
const found = [
|
||||
findColumn(line, COLUMN_ALIAS.sub1),
|
||||
findColumn(line, COLUMN_ALIAS.sub2),
|
||||
findColumn(line, COLUMN_ALIAS.sub3),
|
||||
];
|
||||
const hit = found.filter(x => x >= 0).length;
|
||||
if (hit === SUB_COUNT) {
|
||||
headerRowIdx = i;
|
||||
teamCol = t;
|
||||
subCols = found;
|
||||
break;
|
||||
}
|
||||
if (!partial) {
|
||||
if (hit > 0) {
|
||||
partial = { kind: 'subs', missing: [1, 2, 3].filter(k => found[k - 1] < 0) };
|
||||
} else if (findColumn(line, COLUMN_ALIAS.legacyTotal) >= 0) {
|
||||
partial = { kind: 'legacy' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (headerRowIdx < 0) {
|
||||
if (partial && partial.kind === 'legacy') {
|
||||
return {
|
||||
ok: false,
|
||||
code: FILE_CODE.LEGACY_TEMPLATE,
|
||||
message: '检测到旧版「小组编号 + 分数」两列模板,该模板已不再支持。'
|
||||
+ '请在管理端重新下载「成绩导入模板」,按「小组编号 + 第1/2/3小问」四列填写后重试',
|
||||
};
|
||||
}
|
||||
if (partial && partial.kind === 'subs') {
|
||||
return {
|
||||
ok: false,
|
||||
code: FILE_CODE.SUB_COLUMNS_MISSING,
|
||||
message: `表头缺少「第 ${partial.missing.join('、')} 小问」列。`
|
||||
+ '三小问必须逐列填写(缺列会导致该小问成绩被静默丢失)⇒ 整份文件已拒绝,'
|
||||
+ '请重新下载模板后重试',
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
code: FILE_CODE.HEADER_NOT_FOUND,
|
||||
message: '未找到表头:同一行须同时存在「小组编号」列与「第1小问」「第2小问」「第3小问」三列(请先下载模板)',
|
||||
};
|
||||
}
|
||||
|
||||
const rows = [];
|
||||
const seen = {};
|
||||
for (let i = headerRowIdx + 1; i < grid.length; i++) {
|
||||
const line = grid[i] || [];
|
||||
const teamNumber = normalizeTeamNumber(line[teamCol]);
|
||||
const subRaws = subCols.map(c => line[c]);
|
||||
const rowIndex = i + 1; // Excel 行号(1 基)
|
||||
rows.push({ row: rowIndex, teamNumber, subRaws });
|
||||
if (teamNumber) {
|
||||
const k = teamNumber;
|
||||
seen[k] = (seen[k] || 0) + 1;
|
||||
}
|
||||
}
|
||||
for (const r of rows) {
|
||||
r.dupCount = r.teamNumber ? (seen[r.teamNumber] || 0) : 0;
|
||||
}
|
||||
if (!rows.length) {
|
||||
return { ok: false, code: FILE_CODE.NO_DATA_ROW, message: '表头之后没有数据行' };
|
||||
}
|
||||
return { ok: true, rows };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SUB_COUNT,
|
||||
DEFAULT_PER_SUB,
|
||||
SCORE_SCALE,
|
||||
COLUMN_ALIAS,
|
||||
ROW_STATUS,
|
||||
BLOCKING_STATUS,
|
||||
FILE_CODE,
|
||||
isBlank,
|
||||
normalizeTeamNumber,
|
||||
normalizeHeader,
|
||||
findColumn,
|
||||
scoreSubs,
|
||||
classifyScoreRow,
|
||||
parseScoreRows,
|
||||
};
|
||||
42
app/lib/decimal_score.js
Normal file
42
app/lib/decimal_score.js
Normal file
@@ -0,0 +1,42 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 分数列类型工厂(2026-09-28:大题人工评分精确到小数点后**两位**)
|
||||
*
|
||||
* 用法(在 model 文件内):
|
||||
* const decimalScore = require('../lib/decimal_score')(app);
|
||||
* ...
|
||||
* word_score: decimalScore('word_score', 'Word题/综合大题得分(可含 2 位小数)'),
|
||||
*
|
||||
* ⚠️ 三处必须同时成立,小数才不会被截断或变形:
|
||||
* ① DB 列 = DECIMAL
|
||||
* → database/migrations/025-scores-decimal-one-place.sql(INT(11) → DECIMAL(6,1))
|
||||
* → database/migrations/026-scores-decimal-two-places.sql((6,1) → (6,2) ← 现行)
|
||||
* ② model 声明 = DECIMAL(本文件)
|
||||
* → 若仍写 INTEGER,Sequelize 读取时会走 INTEGER.parse(parseInt),
|
||||
* 把 7.5 **静默截成 7**,且不报任何错
|
||||
* ③ mysql2 驱动配 decimalNumbers=true(config/config.default.js)
|
||||
* → 否则 DECIMAL 经驱动读出为**字符串**('7.5'),前端 `+` 会变成拼接
|
||||
*
|
||||
* getter 再兜底转一次 Number,保证 raw / 非 raw 查询路径都拿到数值类型。
|
||||
*
|
||||
* @param {Egg.Application} app Egg 应用实例(用于取 app.Sequelize)
|
||||
* @returns {(field: string, comment: string, extra?: object) => object}
|
||||
*/
|
||||
module.exports = app => {
|
||||
const { DECIMAL } = app.Sequelize;
|
||||
|
||||
return function decimalScore(field, comment, extra = {}) {
|
||||
return Object.assign({
|
||||
// 上限 9999.99,足够覆盖总分 / 单题分;两位小数满足阅卷评分精度
|
||||
// ⚠️ 改这里必须同步 database/migrations/026-scores-decimal-two-places.sql
|
||||
type: DECIMAL(6, 2),
|
||||
allowNull: true,
|
||||
comment,
|
||||
get() {
|
||||
const v = this.getDataValue(field);
|
||||
return (v === null || v === undefined) ? v : Number(v);
|
||||
},
|
||||
}, extra);
|
||||
};
|
||||
};
|
||||
490
app/lib/docx.js
Normal file
490
app/lib/docx.js
Normal file
@@ -0,0 +1,490 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 极简 WordprocessingML(.docx) 生成器(2026-09-23)
|
||||
*
|
||||
* 用途:把综合大题(`big_questions`)导出为**真正的 .docx**,供管理员留存 / 打印 / 分发。
|
||||
*
|
||||
* 为什么不用第三方 docx 库:后端已有 `archiver`(service/admin.js 的批量下载在用),
|
||||
* 而 .docx 本质就是一个 ZIP + 几个 XML part ⇒ 直接手写 OOXML,**新增零依赖**、
|
||||
* 也避免引入未经验证的库影响既有交卷链路。
|
||||
*
|
||||
* 生成的包结构(最小合法 OOXML):
|
||||
* [Content_Types].xml
|
||||
* _rels/.rels
|
||||
* word/document.xml
|
||||
* word/styles.xml
|
||||
* word/_rels/document.xml.rels
|
||||
*
|
||||
* 支持的 HTML 子集(实测题干只用到这些:p / table / tbody / tr / td / strong / span / a / br):
|
||||
* 块级:p、div、h1–h6、li、ul/ol、blockquote、pre、hr
|
||||
* 表格:table(含 tbody/thead/tfoot 包裹)、tr、td/th(th 加粗)
|
||||
* 行内:strong/b(加粗)、em/i(斜体)、u、span、a(只取文字)、br
|
||||
* 样式:仅解析 `text-align`(对齐)与 `text-indent`(首行缩进,pt→twips)
|
||||
* 实体: < > & " ' '
|
||||
* 忽略:script / style / img(图片不外链,避免生成"打不开的图片占位")
|
||||
*
|
||||
* ⛔ 已知取舍:嵌套表格会被扁平化;图片不导出。
|
||||
*/
|
||||
|
||||
// ==================== XML / 实体 ====================
|
||||
|
||||
function decodeEntities(str) {
|
||||
return String(str == null ? '' : str)
|
||||
.replace(/ /gi, ' ')
|
||||
.replace(/</gi, '<')
|
||||
.replace(/>/gi, '>')
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/�*39;/g, "'")
|
||||
.replace(/'/gi, "'")
|
||||
.replace(/&#(\d+);/g, (m, d) => {
|
||||
const code = Number(d);
|
||||
return Number.isFinite(code) && code > 0 && code < 0x110000 ? String.fromCodePoint(code) : m;
|
||||
})
|
||||
// ⚠️ & 必须放最后:否则 `&lt;` 会被二次解码成 `<`
|
||||
.replace(/&/gi, '&');
|
||||
}
|
||||
|
||||
/** XML 文本转义(在 decodeEntities 之后调用,顺序不可颠倒) */
|
||||
function esc(str) {
|
||||
return String(str == null ? '' : str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ==================== 极简 HTML 解析(容错栈式) ====================
|
||||
|
||||
const VOID_TAGS = new Set(['br', 'hr', 'img', 'meta', 'link', 'input', 'col', 'area', 'base', 'source', 'wbr']);
|
||||
|
||||
/**
|
||||
* 词法正则。
|
||||
* ⚠️ 必须用**具名捕获组**:早期版本用「按出现顺序数下标」(m[1]..m[8]),
|
||||
* 但多个 `|` 分支各自贡献捕获组,实际只有 5 组 ⇒ 下标全部错位 ⇒
|
||||
* 所有标签被当成文本、块序列恒为空(静默产出空文档,无任何报错)。
|
||||
* 具名组不受分支数量影响,从根上消除这类错位。
|
||||
*/
|
||||
const TOKEN_RE = /<!--[\s\S]*?-->|<![^>]*>|<\?[\s\S]*?\?>|<\/\s*(?<close>[a-zA-Z][a-zA-Z0-9:-]*)\s*>|<(?<open>[a-zA-Z][a-zA-Z0-9:-]*)(?<attrs>(?:"[^"]*"|'[^']*'|[^>"'])*?)(?<self>\/?)>|(?<text>[^<]+)/g;
|
||||
|
||||
const ATTR_RE = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/g;
|
||||
|
||||
function parseAttrs(raw) {
|
||||
const attrs = {};
|
||||
if (!raw) return attrs;
|
||||
ATTR_RE.lastIndex = 0;
|
||||
let m;
|
||||
while ((m = ATTR_RE.exec(raw))) {
|
||||
attrs[m[1].toLowerCase()] = m[2] !== undefined ? m[2] : (m[3] !== undefined ? m[3] : m[4]);
|
||||
}
|
||||
return attrs;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 HTML 片段解析为节点树。
|
||||
* ⛔ 容错优先:标签不闭合 / 交叉嵌套时按栈就近回退,绝不抛异常
|
||||
* (题干是管理员手工粘贴的富文本,脏 HTML 不能导致整次导出失败)。
|
||||
*/
|
||||
function parseHtml(html) {
|
||||
const root = { type: 'node', tag: '#root', attrs: {}, children: [] };
|
||||
const stack = [root];
|
||||
const src = String(html == null ? '' : html);
|
||||
TOKEN_RE.lastIndex = 0;
|
||||
let m;
|
||||
while ((m = TOKEN_RE.exec(src))) {
|
||||
const g = m.groups || {};
|
||||
if (g.text !== undefined) {
|
||||
stack[stack.length - 1].children.push({ type: 'text', value: g.text });
|
||||
continue;
|
||||
}
|
||||
if (g.close !== undefined) {
|
||||
// 闭标签:从栈顶向下找同名节点;找不到就忽略(脏 HTML 不抛异常)
|
||||
const name = g.close.toLowerCase();
|
||||
for (let i = stack.length - 1; i > 0; i--) {
|
||||
if (stack[i].tag === name) {
|
||||
stack.length = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (g.open !== undefined) {
|
||||
const tag = g.open.toLowerCase();
|
||||
const node = { type: 'node', tag, attrs: parseAttrs(g.attrs), children: [] };
|
||||
stack[stack.length - 1].children.push(node);
|
||||
if (!VOID_TAGS.has(tag) && g.self !== '/') stack.push(node);
|
||||
continue;
|
||||
}
|
||||
// 注释 / doctype / 处理指令:忽略
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
// ==================== 节点树 → 中间块序列 ====================
|
||||
|
||||
const BLOCK_TAGS = new Set([
|
||||
'p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'ul', 'ol',
|
||||
'blockquote', 'pre', 'section', 'article', 'hr', 'dl', 'dt', 'dd',
|
||||
]);
|
||||
const SKIP_TAGS = new Set(['script', 'style', 'head', 'meta', 'link', 'title', 'img']);
|
||||
|
||||
/** 标题级别 → 半磅字号(21 半磅 = 10.5pt = 五号,正文基准) */
|
||||
const HEADING_SIZE = { h1: 32, h2: 28, h3: 26, h4: 24, h5: 22, h6: 21 };
|
||||
const HEADING_BOLD = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
|
||||
|
||||
const INLINE_BOLD = new Set(['strong', 'b', 'th']);
|
||||
const INLINE_ITALIC = new Set(['em', 'i']);
|
||||
|
||||
function styleOf(node) {
|
||||
return (node && node.attrs && node.attrs.style) || '';
|
||||
}
|
||||
|
||||
/** 从 style 里取段落对齐(仅映射 Word 支持的四种) */
|
||||
function alignOf(node) {
|
||||
const s = styleOf(node);
|
||||
const m = /text-align\s*:\s*(left|right|center|justify)/i.exec(s);
|
||||
return m ? m[1].toLowerCase() : '';
|
||||
}
|
||||
|
||||
/** 从 style 里取首行缩进(pt → twips,1pt = 20twips) */
|
||||
function firstLineTwips(node) {
|
||||
const s = styleOf(node);
|
||||
const m = /text-indent\s*:\s*(-?\d+(?:\.\d+)?)\s*pt/i.exec(s);
|
||||
if (!m) return 0;
|
||||
const pt = Number(m[1]);
|
||||
return Number.isFinite(pt) && pt > 0 ? Math.round(pt * 20) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 深度优先遍历,产出「块」序列:
|
||||
* { type:'p', runs:[...], align, firstLine }
|
||||
* { type:'table', rows:[{ cells:[{ blocks:[...] }] }] }
|
||||
* run 形态:{ text, bold, italic, size, color } 或 { br: true }
|
||||
* color 为 6 位十六进制(可带 `#` 前缀),如 'FF0000' / '#FF0000';非法值忽略。
|
||||
*/
|
||||
function walk(nodes, fmt, out) {
|
||||
const flush = () => {
|
||||
if (out.cur.length) {
|
||||
out.blocks.push(Object.assign({ type: 'p', runs: out.cur }, out.curMeta));
|
||||
out.cur = [];
|
||||
out.curMeta = {};
|
||||
}
|
||||
};
|
||||
|
||||
for (const n of nodes) {
|
||||
if (n.type === 'text') {
|
||||
// HTML 源码里的换行/缩进属排版噪声,须丢弃;
|
||||
// 但 ` ` 是作者有意的占位空格(题干排版大量使用),必须保留 ⇒ 用原始值判别
|
||||
if (!/\S/.test(n.value) && !/ /i.test(n.value)) continue;
|
||||
// 原始换行/制表压成单空格:真实换行只由 <br> 与段落边界产生
|
||||
const text = decodeEntities(n.value).replace(/[\r\n\t]+/g, ' ');
|
||||
if (text === '') continue;
|
||||
out.cur.push({ text, bold: !!fmt.bold, italic: !!fmt.italic });
|
||||
continue;
|
||||
}
|
||||
|
||||
const tag = n.tag;
|
||||
if (SKIP_TAGS.has(tag)) continue;
|
||||
if (tag === 'br') {
|
||||
out.cur.push({ br: true });
|
||||
continue;
|
||||
}
|
||||
if (tag === 'table') {
|
||||
flush();
|
||||
out.blocks.push({ type: 'table', rows: extractRows(n) });
|
||||
continue;
|
||||
}
|
||||
if (tag === 'hr') {
|
||||
flush();
|
||||
out.blocks.push({ type: 'p', runs: [] });
|
||||
continue;
|
||||
}
|
||||
if (tag === 'li') {
|
||||
flush();
|
||||
out.curMeta = Object.assign({}, out.curMeta, { align: alignOf(n) || out.curMeta.align, firstLine: 0 });
|
||||
out.cur.push({ text: '• ', bold: false, italic: false });
|
||||
walk(n.children, fmt, out);
|
||||
// 若不 flush,下一条 li 会并入同一段 ⇒ 显式收尾
|
||||
const last = out.blocks[out.blocks.length - 1];
|
||||
if (last && last.type === 'p' && last.runs.length && last.runs[0].text === '• ') flush();
|
||||
continue;
|
||||
}
|
||||
if (BLOCK_TAGS.has(tag)) {
|
||||
flush();
|
||||
const nextFmt = HEADING_BOLD.has(tag)
|
||||
? Object.assign({}, fmt, { bold: true, size: HEADING_SIZE[tag] })
|
||||
: fmt;
|
||||
out.curMeta = { align: alignOf(n), firstLine: firstLineTwips(n) };
|
||||
walk(n.children, nextFmt, out);
|
||||
flush();
|
||||
continue;
|
||||
}
|
||||
// 行内标签
|
||||
const nextFmt = Object.assign({}, fmt);
|
||||
if (INLINE_BOLD.has(tag)) nextFmt.bold = true;
|
||||
if (INLINE_ITALIC.has(tag)) nextFmt.italic = true;
|
||||
walk(n.children, nextFmt, out);
|
||||
}
|
||||
flush();
|
||||
}
|
||||
|
||||
/** 提取表格行/单元格(容忍 tbody / thead / tfoot 包裹;嵌套表格扁平化到单元格文本) */
|
||||
function extractRows(tableNode) {
|
||||
const rows = [];
|
||||
const collect = (nodes) => {
|
||||
for (const n of nodes) {
|
||||
if (n.type !== 'node') continue;
|
||||
if (n.tag === 'tr') {
|
||||
const cells = [];
|
||||
for (const c of n.children) {
|
||||
if (c.type !== 'node') continue;
|
||||
if (c.tag !== 'td' && c.tag !== 'th') continue;
|
||||
const cellOut = { blocks: [], cur: [], curMeta: {} };
|
||||
walk(c.children, { bold: c.tag === 'th', italic: false }, cellOut);
|
||||
cells.push({ blocks: cellOut.blocks, header: c.tag === 'th' });
|
||||
}
|
||||
rows.push({ cells });
|
||||
} else if (n.tag === 'tbody' || n.tag === 'thead' || n.tag === 'tfoot' || n.tag === 'table') {
|
||||
collect(n.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
collect(tableNode.children);
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** HTML → 块序列(对外唯一入口) */
|
||||
function htmlToBlocks(html) {
|
||||
const out = { blocks: [], cur: [], curMeta: {} };
|
||||
walk(parseHtml(html).children, { bold: false, italic: false }, out);
|
||||
return out.blocks;
|
||||
}
|
||||
|
||||
/** 纯文本 → 单块(自动按空行/换行切段) */
|
||||
function textToBlocks(text) {
|
||||
const s = String(text == null ? '' : text);
|
||||
if (!s.trim()) return [];
|
||||
return s
|
||||
.split(/\r?\n/)
|
||||
.map(line => line.trim())
|
||||
.filter(line => line !== '')
|
||||
.map(line => ({ type: 'p', runs: [{ text: line, bold: false, italic: false }] }));
|
||||
}
|
||||
|
||||
// ==================== 块 → WordprocessingML ====================
|
||||
|
||||
const W_NS = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
|
||||
|
||||
/**
|
||||
* 规范化 run 的颜色值。
|
||||
*
|
||||
* ⛔ 只接受 6 位十六进制(可带 `#`),其余一律返回 '':
|
||||
* OOXML 的 `w:val` 若被填入 `red` / `#F00` / 任意字符串,Word 会拒绝打开整个文档,
|
||||
* 而调用方(导出服务)无法感知 —— 表现为「导出的 docx 打不开」,排查成本极高。
|
||||
*
|
||||
* @param {*} v
|
||||
* @returns {string} 6 位大写十六进制或 ''
|
||||
*/
|
||||
function normalizeColor(v) {
|
||||
if (v === null || v === undefined) return '';
|
||||
const s = String(v).trim().replace(/^#/, '');
|
||||
return /^[0-9a-fA-F]{6}$/.test(s) ? s.toUpperCase() : '';
|
||||
}
|
||||
|
||||
function rPrOf(run) {
|
||||
const parts = [];
|
||||
if (run.bold) parts.push('<w:b/><w:bCs/>');
|
||||
if (run.italic) parts.push('<w:i/><w:iCs/>');
|
||||
parts.push('<w:rFonts w:ascii="Times New Roman" w:hAnsi="Times New Roman" w:eastAsia="宋体" w:cs="Times New Roman"/>');
|
||||
// ⚠️ 顺序硬约束:CT_RPr 是 sequence,`w:color` 必须排在 rFonts 之后、`w:sz` 之前。
|
||||
// 顺序错乱(如放到 sz 之后)Word 同样会判文档损坏 —— 与「pPr 必须先于 runs」同理。
|
||||
const color = normalizeColor(run.color);
|
||||
if (color) parts.push(`<w:color w:val="${color}"/>`);
|
||||
const sz = Number(run.size) > 0 ? Number(run.size) : 21;
|
||||
parts.push(`<w:sz w:val="${sz}"/><w:szCs w:val="${sz}"/>`);
|
||||
return `<w:rPr>${parts.join('')}</w:rPr>`;
|
||||
}
|
||||
|
||||
function renderRun(run) {
|
||||
if (run.br) return '<w:r><w:br/></w:r>';
|
||||
const text = String(run.text == null ? '' : run.text);
|
||||
if (text === '') return '';
|
||||
return `<w:r>${rPrOf(run)}<w:t xml:space="preserve">${esc(text)}</w:t></w:r>`;
|
||||
}
|
||||
|
||||
function pPrOf(block, extra) {
|
||||
const parts = [];
|
||||
const align = block.align || '';
|
||||
if (align) parts.push(`<w:jc w:val="${align === 'justify' ? 'both' : align}"/>`);
|
||||
const fl = Number(block.firstLine) > 0 ? Number(block.firstLine) : 0;
|
||||
if (fl) parts.push(`<w:ind w:firstLine="${fl}"/>`);
|
||||
if (extra) parts.push(extra);
|
||||
return parts.length ? `<w:pPr>${parts.join('')}</w:pPr>` : '';
|
||||
}
|
||||
|
||||
/** 段落。⛔ Word 要求 pPr 必须排在 runs 之前(顺序颠倒会导致文档被判损坏) */
|
||||
function renderParagraph(block) {
|
||||
const runs = (block.runs || []).map(renderRun).join('');
|
||||
return `<w:p>${pPrOf(block)}${runs}</w:p>`;
|
||||
}
|
||||
|
||||
const TBL_BORDERS = '<w:tblBorders>'
|
||||
+ ['top', 'left', 'bottom', 'right', 'insideH', 'insideV']
|
||||
.map(side => `<w:${side} w:val="single" w:sz="4" w:space="0" w:color="auto"/>`)
|
||||
.join('')
|
||||
+ '</w:tblBorders>';
|
||||
|
||||
function renderTable(block) {
|
||||
const rows = block.rows || [];
|
||||
if (!rows.length) return '';
|
||||
const cellCount = Math.max(...rows.map(r => r.cells.length), 1);
|
||||
const trs = rows.map(r => {
|
||||
const tcs = [];
|
||||
for (let i = 0; i < cellCount; i++) {
|
||||
const cell = r.cells[i];
|
||||
let inner;
|
||||
if (!cell || !cell.blocks.length) {
|
||||
inner = '<w:p/>';
|
||||
} else {
|
||||
inner = cell.blocks.map(b => (b.type === 'table'
|
||||
// 嵌套表扁平化为文本,避免非法嵌套
|
||||
? renderParagraph({ runs: flattenTableText(b) })
|
||||
: renderParagraph(b))).join('');
|
||||
}
|
||||
tcs.push(`<w:tc><w:tcPr><w:tcW w:w="0" w:type="auto"/></w:tcPr>${inner}</w:tc>`);
|
||||
}
|
||||
return `<w:tr>${tcs.join('')}</w:tr>`;
|
||||
}).join('');
|
||||
return `<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/>${TBL_BORDERS}</w:tblPr>${trs}</w:tbl>`;
|
||||
}
|
||||
|
||||
function flattenTableText(tableBlock) {
|
||||
const buf = [];
|
||||
for (const r of tableBlock.rows || []) {
|
||||
const cells = [];
|
||||
for (const c of r.cells || []) {
|
||||
const texts = [];
|
||||
for (const b of c.blocks || []) {
|
||||
if (b.type === 'p') texts.push((b.runs || []).map(x => (x.br ? ' ' : x.text)).join(''));
|
||||
}
|
||||
cells.push(texts.join(' ').trim());
|
||||
}
|
||||
buf.push({ br: false, text: cells.join(' | ') });
|
||||
}
|
||||
const runs = [];
|
||||
buf.forEach((x, i) => {
|
||||
if (i > 0) runs.push({ br: true });
|
||||
runs.push({ text: x.text, bold: false, italic: false });
|
||||
});
|
||||
return runs;
|
||||
}
|
||||
|
||||
function buildDocumentXml(blocks) {
|
||||
const parts = [];
|
||||
for (const b of blocks) {
|
||||
if (b.type === 'table') {
|
||||
const t = renderTable(b);
|
||||
if (t) {
|
||||
parts.push(t);
|
||||
// 表后补空段:Word 中「表格紧邻表格/节尾」易被判异常
|
||||
parts.push('<w:p/>');
|
||||
}
|
||||
} else {
|
||||
parts.push(renderParagraph(b));
|
||||
}
|
||||
}
|
||||
if (!parts.length) parts.push('<w:p/>');
|
||||
parts.push('<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="851" w:footer="992" w:gutter="0"/></w:sectPr>');
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
+ `<w:document xmlns:w="${W_NS}" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">`
|
||||
+ `<w:body>${parts.join('')}</w:body></w:document>`;
|
||||
}
|
||||
|
||||
// ==================== 打包为 .docx ====================
|
||||
|
||||
const CONTENT_TYPES = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
+ '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
|
||||
+ '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
|
||||
+ '<Default Extension="xml" ContentType="application/xml"/>'
|
||||
+ '<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>'
|
||||
+ '<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>'
|
||||
+ '</Types>';
|
||||
|
||||
const ROOT_RELS = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
+ '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>'
|
||||
+ '</Relationships>';
|
||||
|
||||
const DOC_RELS = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
+ '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>'
|
||||
+ '</Relationships>';
|
||||
|
||||
const STYLES_XML = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
+ `<w:styles xmlns:w="${W_NS}">`
|
||||
+ '<w:docDefaults><w:rPrDefault><w:rPr>'
|
||||
+ '<w:rFonts w:ascii="Times New Roman" w:hAnsi="Times New Roman" w:eastAsia="宋体" w:cs="Times New Roman"/>'
|
||||
+ '<w:sz w:val="21"/><w:szCs w:val="21"/>'
|
||||
+ '</w:rPr></w:rPrDefault></w:docDefaults>'
|
||||
+ '<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:qFormat/></w:style>'
|
||||
+ '</w:styles>';
|
||||
|
||||
/**
|
||||
* 把若干 part 打成一个 ZIP Buffer。
|
||||
* ⚠️ archiver v8 为 ESM:沿用 service/admin.js:754 已验证的 `const { ZipArchive } = require('archiver')` 写法。
|
||||
*/
|
||||
function zipParts(parts) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let ZipArchive;
|
||||
try {
|
||||
({ ZipArchive } = require('archiver'));
|
||||
} catch (e) {
|
||||
reject(new Error('缺少 archiver 依赖,无法生成 .docx'));
|
||||
return;
|
||||
}
|
||||
const chunks = [];
|
||||
const archive = new ZipArchive({ zlib: { level: 9 } });
|
||||
archive.on('error', reject);
|
||||
archive.on('data', c => chunks.push(c));
|
||||
archive.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
for (const p of parts) archive.append(Buffer.from(p.data, 'utf8'), { name: p.name });
|
||||
archive.finalize();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成单个 .docx
|
||||
* @param {Array} blocks - htmlToBlocks / textToBlocks 的产物(可多个数组按顺序拼接)
|
||||
* @returns {Promise<Buffer>}
|
||||
*/
|
||||
async function buildDocx(blocks) {
|
||||
const list = ([]).concat(blocks || []).filter(Boolean);
|
||||
const flat = [];
|
||||
for (const b of list) {
|
||||
if (Array.isArray(b)) flat.push(...b);
|
||||
else flat.push(b);
|
||||
}
|
||||
return zipParts([
|
||||
{ name: '[Content_Types].xml', data: CONTENT_TYPES },
|
||||
{ name: '_rels/.rels', data: ROOT_RELS },
|
||||
{ name: 'word/document.xml', data: buildDocumentXml(flat) },
|
||||
{ name: 'word/styles.xml', data: STYLES_XML },
|
||||
{ name: 'word/_rels/document.xml.rels', data: DOC_RELS },
|
||||
]);
|
||||
}
|
||||
|
||||
/** 把多个 .docx 打成一个 ZIP(批量/全部导出用),返回 Buffer */
|
||||
async function zipDocxFiles(entries) {
|
||||
return zipParts(entries.map(e => ({ name: e.name, data: e.buffer })));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildDocx,
|
||||
zipDocxFiles,
|
||||
htmlToBlocks,
|
||||
textToBlocks,
|
||||
decodeEntities,
|
||||
esc,
|
||||
// 导出内部件便于单测(`scripts/verify_big_score_import.py` 会断言 color 渲染与序列位置)
|
||||
_internal: { parseHtml, buildDocumentXml, rPrOf, normalizeColor, CONTENT_TYPES, ROOT_RELS, STYLES_XML, DOC_RELS },
|
||||
};
|
||||
103
app/lib/idCard.js
Normal file
103
app/lib/idCard.js
Normal file
@@ -0,0 +1,103 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 身份证号判据(导入通道的单一判据源)
|
||||
*
|
||||
* ⚠️ 同源约束:本模块的判定口径必须与下列两处保持一致,任一处变更须同步其余两处:
|
||||
* 1. `scripts/preflight_import_check.py`(导入前离线校验器,CLI)
|
||||
* 2. `backend/app/model/contestant.js`(id_number: STRING(18) + validate.len [18,18])
|
||||
*
|
||||
* 口径来源(2026-10-12 依据生产事故校准):
|
||||
* · 报名表「已审核(含增补)」导入时,第 188 / 1012 / 1421 行因身份证号被写成
|
||||
* 19 / 17 / 20 位,被 Sequelize 的 len 校验拦下,但错误信息只有
|
||||
* `Validation len on id_number failed` —— 管理员在页面上完全看不到是哪几行、是谁。
|
||||
* · 故把「长度必须 18 位」前置为**可读错误**(与原行为等价:原本也进不了库,只是报错难懂)。
|
||||
*
|
||||
* 分级原则(避免误伤真实数据):
|
||||
* · 长度 ≠ 18 → error:绝不会写入成功(模型层硬约束),前置拦截并给出人话原因。
|
||||
* · 18 位但含非法字符 → warn :模型只查长度、不查字符集,原值可入库 ⇒ 只提示不阻断。
|
||||
* · 18 位但校验位不符 → warn :生产库现存 753 条中即有 4 条校验位不符(真实在册考生),
|
||||
* 若硬拦截会造成合法考生漏导 ⇒ 只提示不阻断。
|
||||
*/
|
||||
|
||||
const ID_LEN = 18;
|
||||
const ID_CHARSET_RE = /^\d{17}[\dX]$/;
|
||||
|
||||
// GB 11643-1999 校验位加权因子与余数映射
|
||||
const WEIGHTS = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2];
|
||||
const CHECK_CODES = '10X98765432';
|
||||
|
||||
/**
|
||||
* 身份证号归一化:去首尾空白 + 末位 x 统一为大写 X。
|
||||
* @param {*} raw - 原始值(可能为 undefined / number / string)
|
||||
* @returns {string} 归一化后的字符串(空值返回 '')
|
||||
*/
|
||||
function normalizeIdNumber(raw) {
|
||||
if (raw === undefined || raw === null) return '';
|
||||
return String(raw).trim().toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算 18 位身份证号第 18 位(校验位)。
|
||||
* @param {string} s - 至少 17 位的数字串(已归一化)
|
||||
* @returns {string} 期望的校验位('0'-'9' / 'X');入参不足或含非数字时返回 ''
|
||||
*/
|
||||
function expectedCheckCode(s) {
|
||||
if (typeof s !== 'string' || s.length < 17) return '';
|
||||
const body = s.slice(0, 17);
|
||||
if (!/^\d{17}$/.test(body)) return '';
|
||||
let sum = 0;
|
||||
for (let i = 0; i < 17; i++) sum += Number(body[i]) * WEIGHTS[i];
|
||||
return CHECK_CODES[sum % 11];
|
||||
}
|
||||
|
||||
/**
|
||||
* 检验一个身份证号是否可用于导入,并给出人话级原因。
|
||||
*
|
||||
* @param {*} raw - 原始单元格值
|
||||
* @returns {{ importable: boolean, level: 'error'|'warn'|null, message: string, value: string }}
|
||||
* · importable=false ⇒ 该行**不可能写入成功**,调用方应跳过(level 恒为 'error')
|
||||
* · importable=true 且 level='warn' ⇒ 可导入,但需人工复核(message 说明原因)
|
||||
* · importable=true 且 level=null ⇒ 无异常
|
||||
*/
|
||||
function inspectIdNumber(raw) {
|
||||
const value = normalizeIdNumber(raw);
|
||||
|
||||
if (!value) {
|
||||
return { importable: false, level: 'error', message: '身份证号为空', value };
|
||||
}
|
||||
if (value.length !== ID_LEN) {
|
||||
return {
|
||||
importable: false,
|
||||
level: 'error',
|
||||
message: `身份证号应为 ${ID_LEN} 位,实际 ${value.length} 位(${value})`,
|
||||
value,
|
||||
};
|
||||
}
|
||||
if (!ID_CHARSET_RE.test(value)) {
|
||||
return {
|
||||
importable: true,
|
||||
level: 'warn',
|
||||
message: `身份证号含非法字符(${value}),已按原值导入,请人工核对`,
|
||||
value,
|
||||
};
|
||||
}
|
||||
const expect = expectedCheckCode(value);
|
||||
if (expect && expect !== value[ID_LEN - 1]) {
|
||||
return {
|
||||
importable: true,
|
||||
level: 'warn',
|
||||
message: `身份证号校验位不符(末位应为 ${expect},实际为 ${value[ID_LEN - 1]}),已按原值导入,请人工核对`,
|
||||
value,
|
||||
};
|
||||
}
|
||||
return { importable: true, level: null, message: '', value };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ID_LEN,
|
||||
ID_CHARSET_RE,
|
||||
normalizeIdNumber,
|
||||
expectedCheckCode,
|
||||
inspectIdNumber,
|
||||
};
|
||||
844
app/lib/nodeBackupScheduler.js
Normal file
844
app/lib/nodeBackupScheduler.js
Normal file
@@ -0,0 +1,844 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 节点备份调度器(Node 内置定时器)—— 考试开始前 / 考试结束后 / 成绩发布后
|
||||
* ============================================================================
|
||||
* 需求来源(用户 2026-10-16提出):「系统在考试开始前,考试结束后,成绩发布后
|
||||
* 这三个节点应该执行数据备份」。
|
||||
*
|
||||
* ============================ 为什么不用巡检脚本 ============================
|
||||
* 用户裁定:巡检脚本(cron 每分钟扫状态跃迁)会占用额外内存 ⇒ 改用**后端钩子**。
|
||||
* 本模块就是该钩子的落点:**进程内 Node 定时器**,不新增常驻进程、不新增轮询。
|
||||
* 额外内存开销 = 最多 8 个 setTimeout 句柄(4 组 × 2 节点),可忽略。
|
||||
*
|
||||
* ============================ 三节点的时间口径 ============================
|
||||
* ① before-start :考试开始前 30 分钟(缓冲,留出 dump 落盘时间)
|
||||
* ② after-end :考试结束后 10 分钟(缓冲,等最后一批提交落库)
|
||||
* ③ score-published:成绩发布**瞬间**触发(发布态本身不可逆,见下方「撤销」说明)
|
||||
*
|
||||
* ⚠️ 「撤销发布」不作为节点(用户裁定):撤销发生时备份到的是**已经发布过的数据**,
|
||||
* 对「回退到未发布态」这个诉求毫无意义 ⇒ 只在 publish === true 时触发。
|
||||
*
|
||||
* ============================ 幂等:为什么用「计划键」而不是「组别」 ============================
|
||||
* 定时器只活在内存里,进程重启即丢失 ⇒ 必须在 `didReady` 重新 hydrate。
|
||||
* 若 hydrate 时无条件按组别重排,会出现「重启一次 = 重复备份一次」。
|
||||
* 故幂等键 = `${group}:${node}:${fireAtISO}` —— **时间点本身就是身份**:
|
||||
* · 管理员把考试时间改成新值 ⇒ fireAt 变了 ⇒ 是新计划,理应再备一次 ✅
|
||||
* · 进程重启 hydrate 出同一 fireAt ⇒ 键相同 ⇒ 覆盖同一计划,不重复 ❌→ ✅ 不触发
|
||||
* 另有一层持久化去重:`system_configs` 的 `node_backup_fired_<key>`
|
||||
* (哈希后落键),用于「重启后 fireAt 已在过去」的场景下仍不重复备份。
|
||||
*
|
||||
* ============================ 🔴 生产隔离铁律 ============================
|
||||
* 本模块**只在生产环境启用**(`config.isProduction === true`)。
|
||||
* 非生产一律 `enabled = false` 且 `schedule()` 直接返回空 —— 因为备份目标是
|
||||
* **生产库**(用户裁定②),本地/测试环境触发它等于让本机进程去 dump 生产库。
|
||||
* 这与 config.default.js 里 `DEPLOY_ENV_LOCAL_SKIP_PREFIXES = ['DB_', ...]`
|
||||
* 是同一条纪律的延伸:非生产进程**不得持有生产连接凭据**。
|
||||
*
|
||||
* ============================ 备份执行 ============================
|
||||
* 不自己实现 dump ⇒ **复用** `scripts/backup_db.py`(同一份 mysqldump + gzip +
|
||||
* 完整性校验 + 保留期治理逻辑,避免两套备份实现的校验口径漂移)。
|
||||
* 通过 `child_process.spawn` 异步执行,**绝不阻塞业务请求**:
|
||||
* · setTimes 写完库立即返回,备份在后台跑
|
||||
* · publishScores 同理
|
||||
* 失败只落日志 + 状态文件 + 告警日志,**绝不 throw 到调用方** ——
|
||||
* 备份失败不能把「设置考试时间」「发布成绩」这两个管理动作搞挂。
|
||||
*
|
||||
* ============================ 🔴 多 worker 必须处理 ============================
|
||||
* 🔴 R168 修正(生产取证):生产 `EGG_WORKERS=2`(deploy/.env.production:77)⇒ Egg 起
|
||||
* **2 个 app worker 进程**,而 `didReady` 在**每个 worker 里各跑一次** ⇒ 原实现每个
|
||||
* worker 各建一个调度器、各自 hydrate 出同一批计划 ⇒ **到点两个进程各 spawn 一次
|
||||
* mysqldump**(重复备份 + 双倍 IO + 竞态写同一目录)。
|
||||
* ⚠️ 本地 dev 单 worker(egg-bin 强制)⇒ 测试完全看不出来 —— 又一个
|
||||
* 「测试环境掩盖生产缺陷」的实例,故判据必须显式模拟多 worker。
|
||||
* 同理 `setTimes` 只落到「处理该请求的那一个 worker」⇒ 另一个 worker 的旧计划
|
||||
* **不会被清除** ⇒ 改期后残留定时器仍会在旧时刻误触发一次。
|
||||
*
|
||||
* 两道防线(缺一不可):
|
||||
* ① **只在 master 进程排程**(`isMaster` 门禁)—— 从根上消除重复源头;
|
||||
* ② **跨进程文件锁**(`_fire` 内)—— 兜底:worker 数配置漂移 / 时序异常时
|
||||
* 也不会并发 dump。
|
||||
* ============================================================================
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { spawn } = require('child_process');
|
||||
const crypto = require('crypto');
|
||||
|
||||
// ---- 三节点常量 ----
|
||||
const NODE_BEFORE_START = 'before-start';
|
||||
const NODE_AFTER_END = 'after-end';
|
||||
const NODE_SCORE_PUBLISHED = 'score-published';
|
||||
|
||||
/**
|
||||
* 计划键:节点身份 = 组别 + 节点 + 触发时刻
|
||||
* @param {string} group 组别
|
||||
* @param {string} node 节点常量
|
||||
* @param {Date} fireAt 触发时刻
|
||||
* @returns {string}
|
||||
*/
|
||||
function planKey(group, node, fireAt) {
|
||||
return `${group}:${node}:${new Date(fireAt).toISOString()}`;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 结果台账(P1「生产备份静默失败防护」的落地)
|
||||
// ===========================================================================
|
||||
// 🔴 为什么必须有台账,而不是「只写 logger」:
|
||||
// 台账 P1 的原始形态就是「失败只落日志,没人看 ⇒ 直到需要恢复时才发现备份
|
||||
// 根本不存在」。logger 是**运维异步消费**的通道,管理后台读不到它。
|
||||
// 故这里额外维护一份 **append-only JSONL**(`node-backup-history.jsonl`),
|
||||
// 管理端状态接口直接读它 ⇒ 失败在设置页立刻可见,不必 ssh 翻日志。
|
||||
//
|
||||
// 为什么用 JSONL 而不是一个 status.json:
|
||||
// · 生产 EGG_WORKERS=2,多进程并发写同一状态文件必定互相覆盖(读-改-写竞态);
|
||||
// · 单行 append + O_APPEND 是原子写(< PIPE_BUF),多进程安全;
|
||||
// · 天然保留**历史**,才能判「漏备」「连续失败」这类需要时间序列的结论。
|
||||
// 体量估算:4 组 × 2 节点 + 少量即时节点 ≈ 每天数十行(<10KB/天),
|
||||
// 90 天后仍 <1MB ⇒ 暂不做轮转(轮转需 mv 归档,等真的有量再说)。
|
||||
const HISTORY_FILE_NAME = 'node-backup-history.jsonl';
|
||||
|
||||
/** 台账文件路径 */
|
||||
function historyPath(logDir) {
|
||||
return path.join(logDir, HISTORY_FILE_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取台账尾部 N 条(**最新在前**)
|
||||
*
|
||||
* ⚠️ 单行损坏(进程在 append 中途被 kill)只跳过该行,绝不让整个读取失败 ——
|
||||
* 台账读不出来等于把可见性做回到「只能翻日志」。
|
||||
* @param {string} logDir
|
||||
* @param {number} limit
|
||||
* @returns {Array<object>}
|
||||
*/
|
||||
function readHistory(logDir, limit) {
|
||||
const n = Math.max(1, Math.min(Number(limit) || 50, 500));
|
||||
let text = '';
|
||||
try {
|
||||
text = fs.readFileSync(historyPath(logDir), 'utf8');
|
||||
} catch (e) {
|
||||
return []; // 文件不存在 = 从未触发过,是正常状态而非错误
|
||||
}
|
||||
const lines = String(text).split('\n');
|
||||
const out = [];
|
||||
for (let i = lines.length - 1; i >= 0 && out.length < n; i--) {
|
||||
const s = lines[i].trim();
|
||||
if (!s) continue;
|
||||
try { out.push(JSON.parse(s)); } catch (e) { /* 半行/损坏行跳过 */ }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出备份产物(按 mtime 倒序)
|
||||
* @param {string} outDir
|
||||
* @param {number} limit
|
||||
*/
|
||||
function listArtifacts(outDir, limit) {
|
||||
const n = Math.max(1, Math.min(Number(limit) || 10, 100));
|
||||
const items = [];
|
||||
try {
|
||||
for (const name of fs.readdirSync(outDir)) {
|
||||
if (!/\.(sql|gz|tgz|zip)$/i.test(name)) continue;
|
||||
try {
|
||||
const st = fs.statSync(path.join(outDir, name));
|
||||
if (!st.isFile()) continue;
|
||||
items.push({
|
||||
name,
|
||||
sizeMB: Math.round((st.size / 1048576) * 100) / 100,
|
||||
mtime: new Date(st.mtimeMs).toISOString(),
|
||||
});
|
||||
} catch (e) { /* 并发被清理 ⇒ 跳过 */ }
|
||||
}
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
items.sort((a, b) => (a.mtime < b.mtime ? 1 : a.mtime > b.mtime ? -1 : 0));
|
||||
return items.slice(0, n);
|
||||
}
|
||||
|
||||
/**
|
||||
* 健康判定(**纯函数**:不读磁盘、不看时钟以外的外部状态 ⇒ 可直接单测)
|
||||
*
|
||||
* 判据设计原则:每条都对应一种「备份实际没做,但界面上看不出来」的真实故障形态,
|
||||
* 而不是泛泛的「有错就红」:
|
||||
* H1 PROD_DISABLED 生产环境调度器没启用 ⇒ 三节点备份永远不会执行
|
||||
* H2 LAST_RUN_FAILED 最近一次有结果执行是失败(含连续失败)⇒ 需人工介入
|
||||
* H3 MISSED_RUN 计划已到点超宽限期,却既无成功也无失败也无跳过记录
|
||||
* ⇒ 定时器丢了(进程重启/hydrate 漏了/kill -9),最危险
|
||||
* H4 NO_ARTIFACT 台账说成功,但备份目录里一个产物都没有 ⇒ 假成功
|
||||
* H5 NO_PLAN 已启用却没有任何待触发计划 ⇒ 提示(可能是善意状态)
|
||||
*
|
||||
* ⚠️ 非生产环境不报 H1:本地/测试本来就不该排生产备份,报红会造成告警疲劳
|
||||
* (与 backup_daily.sh「不比对比报警」同一条纪律)。
|
||||
*
|
||||
* @param {object} input
|
||||
* @param {boolean} input.enabled
|
||||
* @param {boolean} input.isProduction
|
||||
* @param {Array} input.plans [{ group, node, fireAt, key }]
|
||||
* @param {Array} input.history readHistory() 的结果(最新在前)
|
||||
* @param {number} input.artifactCount
|
||||
* @param {number} [input.now]
|
||||
* @returns {{level:string,reasons:Array,stats:object}}
|
||||
*/
|
||||
function evaluateHealth(input) {
|
||||
const now = Number.isFinite(Number(input && input.now)) ? Number(input.now) : Date.now();
|
||||
const GRACE_MS = 10 * 60 * 1000; // 到点后 10 分钟内不判漏(dump 可能正在跑)
|
||||
|
||||
const enabled = !!(input && input.enabled === true);
|
||||
const isProduction = !!(input && input.isProduction === true);
|
||||
const plans = (input && Array.isArray(input.plans)) ? input.plans : [];
|
||||
const history = (input && Array.isArray(input.history)) ? input.history : [];
|
||||
const artifactCount = Number(input && input.artifactCount) || 0;
|
||||
|
||||
const reasons = [];
|
||||
const add = (level, code, message) => reasons.push({ level, code, message });
|
||||
|
||||
// ---- H1 生产未启用 ----
|
||||
if (isProduction && !enabled) {
|
||||
add('error', 'PROD_DISABLED', '生产环境节点备份调度器未启用:考试前/考试后/发布后三节点均不会执行');
|
||||
}
|
||||
|
||||
// ---- H2 最近有结果的执行是失败 ----
|
||||
let consecutiveFails = 0;
|
||||
let lastFailMsg = '';
|
||||
for (const h of history) {
|
||||
if (!h || typeof h !== 'object') continue;
|
||||
if (h.event === 'fail') {
|
||||
consecutiveFails++;
|
||||
if (!lastFailMsg) lastFailMsg = h.message || h.stage || '';
|
||||
} else if (h.event === 'success') {
|
||||
break; // 有成功兜底 ⇒ 连续失败链断裂
|
||||
}
|
||||
// start / plan / lock-skip / invalid-skip 不参与连续失败计数
|
||||
}
|
||||
if (consecutiveFails > 0) {
|
||||
add('error', 'LAST_RUN_FAILED',
|
||||
`最近 ${consecutiveFails} 次节点备份执行失败${lastFailMsg ? '(' + lastFailMsg + ')' : ''} ⇒ 需人工介入`);
|
||||
}
|
||||
|
||||
// ---- H3 已到点却无任何结果(漏备)----
|
||||
const settled = new Set();
|
||||
for (const h of history) {
|
||||
if (!h || !h.key) continue;
|
||||
if (h.event === 'success' || h.event === 'fail' || h.event === 'lock-skip' || h.event === 'invalid-skip') {
|
||||
settled.add(h.key);
|
||||
}
|
||||
}
|
||||
const seenPlan = new Set();
|
||||
const missed = [];
|
||||
for (const h of history) {
|
||||
if (!h || h.event !== 'plan' || !h.key) continue;
|
||||
if (seenPlan.has(h.key)) continue;
|
||||
seenPlan.add(h.key);
|
||||
const t = new Date(h.fireAt).getTime();
|
||||
if (!Number.isFinite(t)) continue;
|
||||
if (t + GRACE_MS > now) continue; // 还没到点 / 刚过点,仍在宽限期内
|
||||
if (settled.has(h.key)) continue;
|
||||
missed.push(h.key);
|
||||
}
|
||||
if (missed.length > 0) {
|
||||
add('error', 'MISSED_RUN',
|
||||
`有 ${missed.length} 个已到点计划既无成功也无失败记录(疑似漏备):${missed.slice(0, 3).join(' / ')}`);
|
||||
}
|
||||
|
||||
// ---- H4 假成功 ----
|
||||
if (history.some(h => h && h.event === 'success') && artifactCount === 0) {
|
||||
add('error', 'NO_ARTIFACT', '台账记录过备份成功,但备份目录中没有任何产物(疑似假成功或产物被清理)');
|
||||
}
|
||||
|
||||
// ---- H5 无待触发计划 ----
|
||||
if (enabled && plans.length === 0) {
|
||||
add('warn', 'NO_PLAN', '当前没有任何待触发计划:四组均未设置考试时间,或时间节点均已过期(重启后不会补备)');
|
||||
}
|
||||
|
||||
const level = reasons.some(r => r.level === 'error') ? 'error'
|
||||
: reasons.some(r => r.level === 'warn') ? 'warn' : 'ok';
|
||||
|
||||
return {
|
||||
level,
|
||||
reasons,
|
||||
stats: {
|
||||
consecutiveFails,
|
||||
missedCount: missed.length,
|
||||
artifactCount,
|
||||
planCount: plans.length,
|
||||
historyCount: history.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算某组某节点的触发时刻
|
||||
*
|
||||
* ⚠️ 口径纪律:
|
||||
* · before-start = start_time − 30min,**不得**早于「现在」(管理员可能把开始
|
||||
* 时间设成过去时间 ⇒ 减完仍在过去 ⇒ 必须钳到 now,否则会立刻触发一次
|
||||
* 「已经开考」的备份,语义错误且浪费 IO)。
|
||||
* · after-end = end_time + 10min,同样钳到 now 之后一点点(+1s),
|
||||
* 保证 setTimeout 拿到的是**未来**时刻而不是恰好相等(相等时行为依赖实现)。
|
||||
*
|
||||
* 🔴 opts.clamp = false 时**不钳制**,返回数学上的真实触发时刻。
|
||||
* 这不是可选优化,而是 hydrate 判据所必需:hydrate 必须能分辨
|
||||
* 「这个节点本来就还没到」vs「这个节点的时间设置在過去、早就该跳过了」。
|
||||
* 若 hydrate 也走钳制路径,过期节点会被伪造成 now+1s ⇒ 进程一重启就立刻
|
||||
* 误触发一次备份(2026-10-16 由 C28 判据 J4.2 实测抓到,非理论风险)。
|
||||
*
|
||||
* @param {string} group
|
||||
* @param {string} node
|
||||
* @param {Date|null} baseTime start_time 或 end_time
|
||||
* @param {object} opts
|
||||
* @param {boolean} [opts.clamp=true] 是否把过去时刻钳到 now+1s
|
||||
* @returns {Date|null} 触发时刻;不可调度时返回 null
|
||||
*/
|
||||
function computeFireAt(group, node, baseTime, opts) {
|
||||
if (!baseTime) return null;
|
||||
const t = new Date(baseTime).getTime();
|
||||
if (!Number.isFinite(t)) return null;
|
||||
|
||||
const leadMs = node === NODE_BEFORE_START
|
||||
? (opts.beforeStartBufferMin || 30) * 60 * 1000
|
||||
: (opts.afterEndDelayMin || 10) * 60 * 1000;
|
||||
|
||||
const fire = node === NODE_BEFORE_START ? t - leadMs : t + leadMs;
|
||||
|
||||
// ⛔ clamp=false ⇒ 返回数学时刻,交由调用方(hydrate)自行判断过期
|
||||
if (opts.clamp === false) return new Date(fire);
|
||||
|
||||
// 钳制:绝不让计划落在过去(否则 setTimeout 立刻 fire,语义错误)
|
||||
const now = Date.now();
|
||||
if (fire <= now) return new Date(now + 1000);
|
||||
return new Date(fire);
|
||||
}
|
||||
|
||||
/**
|
||||
* 节点备份调度器(挂在 app 上,全进程单例)
|
||||
*/
|
||||
class NodeBackupScheduler {
|
||||
/**
|
||||
* @param {object} app Egg app
|
||||
*/
|
||||
constructor(app) {
|
||||
this.app = app;
|
||||
this.logger = app.logger;
|
||||
this.config = (app.config && app.config.nodeBackup) || {};
|
||||
|
||||
// key → timeout 句柄
|
||||
this.timers = new Map();
|
||||
|
||||
// ⛔ 非生产直接封死:不排任何计划、不spawn 任何进程
|
||||
// ⚠️ fail-safe(不是 fail-open):配置段缺失 / 非对象 / enabled 未显式为 true
|
||||
// 时一律判为**关闭**。曾写成 `enabled !== false` ⇒ 配置段整个缺失时算出
|
||||
// true(fail-open),由 C28 判据 J3.6 实测抓到 —— 生产隔离绝不能靠默认值。
|
||||
this.enabled = !!(
|
||||
app.config &&
|
||||
app.config.isProduction &&
|
||||
this.config &&
|
||||
typeof this.config === 'object' &&
|
||||
this.config.enabled === true
|
||||
);
|
||||
|
||||
// 🔴 多 worker:这里**不能**按 isMaster 门禁关停 —— Egg 的 `didReady` 运行在
|
||||
// **app worker 进程**里,master 根本不执行 app.js 生命周期钩子
|
||||
// (曾一度想用 `EGG_SERVER_ENV === 'master'` 门禁,实测会<E6B58B><E4BC9A>整个调度器
|
||||
// 永远关闭 ⇒ 永不备份)。故改为「每个 worker 都排程」+ 触发时刻两道去重:
|
||||
// ① `_acquireLock` 跨进程文件锁(原子 mkdir)⇒ 并发到点只有一个真正 dump;
|
||||
// ② `_stillValid` 触发前回库核对当前时间配置 ⇒ 改期后其它 worker 的
|
||||
// **残留旧定时器**在触发时发现计划已作废 ⇒ 直接放弃(不误备份)。
|
||||
// 这样既不会重复备份,也不会漏备份,且对 worker 数配置漂移免疫。
|
||||
|
||||
if (!this.enabled) {
|
||||
this.logger.info(
|
||||
'[NodeBackup] 未启用(enabled=%s · isProduction=%s)—— 非生产环境禁止触发生产库备份',
|
||||
String(this.config.enabled !== false),
|
||||
String(!!(app.config && app.config.isProduction))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 排一个节点备份计划(幂等:同key 覆盖旧句柄)
|
||||
*
|
||||
* @param {object} p
|
||||
* @param {string} p.group
|
||||
* @param {string} p.node
|
||||
* @param {Date|null} p.baseTime start_time / end_time
|
||||
* @param {string} p.reason 人类可读原因(落日志)
|
||||
* @returns {string|null} 计划键;未启用/不可调度返回 null
|
||||
*/
|
||||
schedule(p) {
|
||||
if (!this.enabled) return null;
|
||||
|
||||
const { group, node, baseTime, reason } = p;
|
||||
const fireAt = computeFireAt(group, node, baseTime, this.config);
|
||||
if (!fireAt) {
|
||||
this.logger.warn(
|
||||
'[NodeBackup] 跳过排程(缺少有效时间):group=%s node=%s baseTime=%s',
|
||||
group, node, String(baseTime)
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const key = planKey(group, node, fireAt);
|
||||
|
||||
// 同 key 已在册 ⇒ 说明是 hydrate 撞车,不重复排
|
||||
if (this.timers.has(key)) {
|
||||
this.logger.info('[NodeBackup] 计划已存在,跳过重复排程:%s', key);
|
||||
return key;
|
||||
}
|
||||
|
||||
const delay = Math.max(0, fireAt.getTime() - Date.now());
|
||||
// setTimeout 上限 2^31-1 ms(约 24.8 天);超出则分段等待(考试时间被设到
|
||||
// 很远以后时会真实触发此分支 —— 不处理会让定时器立即 fire ⇒ 立即误备份)
|
||||
const MAX_DELAY = 2147483647;
|
||||
|
||||
const arm = ms => {
|
||||
const handle = setTimeout(async () => {
|
||||
this.timers.delete(key);
|
||||
if (ms <= MAX_DELAY) {
|
||||
await this._fire({ group, node, fireAt, key, reason });
|
||||
} else {
|
||||
// 分段续等
|
||||
const rest = ms - MAX_DELAY;
|
||||
this._rearm(key, { group, node, fireAt, reason }, rest);
|
||||
}
|
||||
}, ms);
|
||||
// ⛔ 定时器不应阻止进程退出(正常退出时无需等备份跑完)
|
||||
if (handle.unref) handle.unref();
|
||||
this.timers.set(key, handle);
|
||||
};
|
||||
|
||||
arm(delay);
|
||||
|
||||
// 台账:记下「曾经排过这个计划」—— 这是 H3「漏备」判定的唯一依据。
|
||||
// 若不记,重启后计划丢了就再也无从发现「该备却没备」。
|
||||
this._record('plan', {
|
||||
key, group, node, fireAt: fireAt.toISOString(), reason: reason || '-',
|
||||
});
|
||||
|
||||
this.logger.info(
|
||||
'[NodeBackup] 已排程:key=%s 触发=%s(%s 分钟后)原因=%s',
|
||||
key,
|
||||
fireAt.toISOString(),
|
||||
Math.round(delay / 60000),
|
||||
reason || '-'
|
||||
);
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* 追加一条台账事件(管理后台可见性的数据源)
|
||||
*
|
||||
* ⛔ 台账写失败**绝不能**影响备份本身:备份是保命的,台账是观测。
|
||||
* 故这里吞掉所有异常,只回落到 logger.warn 留痕。
|
||||
*
|
||||
* @param {string} event plan | start | success | fail | lock-skip | invalid-skip
|
||||
* @param {object} meta 计划/执行元信息
|
||||
* @param {object} [extra] 附加字段(rc / logFile / message / stage)
|
||||
*/
|
||||
_record(event, meta, extra) {
|
||||
try {
|
||||
const logDir = this.config && this.config.logDir;
|
||||
if (!logDir) return;
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
const row = Object.assign({
|
||||
ts: new Date().toISOString(),
|
||||
event,
|
||||
pid: process.pid,
|
||||
}, meta || {}, extra || {});
|
||||
fs.appendFileSync(historyPath(logDir), JSON.stringify(row) + '\n');
|
||||
} catch (e) {
|
||||
this.logger.warn('[NodeBackup] 台账写入失败(不影响备份):event=%s err=%s', event, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理端状态快照(供 GET /api/admin/node-backup/status)
|
||||
*
|
||||
* ⛔ 返回值**不含任何凭据**:只有路径、时间、计数、判定结论。
|
||||
* envFile 只给路径不给内容(内容里有 DB 口令)。
|
||||
*
|
||||
* @param {number} [limit] 台账取多少条
|
||||
* @returns {object}
|
||||
*/
|
||||
status(limit) {
|
||||
const cfg = this.config || {};
|
||||
const n = Math.max(1, Math.min(Number(limit) || 50, 200));
|
||||
const isProduction = !!(this.app && this.app.config && this.app.config.isProduction);
|
||||
|
||||
const history = cfg.logDir ? readHistory(cfg.logDir, n) : [];
|
||||
const plans = this.list()
|
||||
.map(p => {
|
||||
const t = new Date(p.fireAt).getTime();
|
||||
return Object.assign({}, p, {
|
||||
remainingSec: Number.isFinite(t) ? Math.max(0, Math.round((t - Date.now()) / 1000)) : null,
|
||||
});
|
||||
})
|
||||
.sort((a, b) => (a.fireAt < b.fireAt ? -1 : a.fireAt > b.fireAt ? 1 : 0));
|
||||
|
||||
const artifacts = cfg.outDir ? listArtifacts(cfg.outDir, 10) : [];
|
||||
const health = evaluateHealth({
|
||||
enabled: this.enabled,
|
||||
isProduction,
|
||||
plans,
|
||||
history,
|
||||
artifactCount: artifacts.length,
|
||||
});
|
||||
|
||||
return {
|
||||
enabled: this.enabled,
|
||||
isProduction,
|
||||
buffer: {
|
||||
beforeStartMin: cfg.beforeStartBufferMin || 30,
|
||||
afterEndMin: cfg.afterEndDelayMin || 10,
|
||||
},
|
||||
keepDays: cfg.keepDays || 90,
|
||||
plans,
|
||||
history,
|
||||
artifacts,
|
||||
health,
|
||||
paths: {
|
||||
outDir: cfg.outDir || null,
|
||||
logDir: cfg.logDir || null,
|
||||
historyFile: cfg.logDir ? historyPath(cfg.logDir) : null,
|
||||
script: cfg.scriptPath || null,
|
||||
envFile: cfg.envFile || null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 分段续等(>24.8 天的远期计划)
|
||||
*/
|
||||
_rearm(key, meta, restMs) {
|
||||
const handle = setTimeout(() => {
|
||||
this.timers.delete(key);
|
||||
if (restMs <= 2147483647) {
|
||||
this._fire(meta);
|
||||
} else {
|
||||
this._rearm(key, meta, restMs - 2147483647);
|
||||
}
|
||||
}, 2147483647);
|
||||
if (handle.unref) handle.unref();
|
||||
this.timers.set(key, handle);
|
||||
}
|
||||
|
||||
/**
|
||||
* 组别时间被修改/保存后重排该组的两个时间节点
|
||||
* @param {Array<{group:string,start_time?:Date,end_time?:Date}>} rows
|
||||
*/
|
||||
rescheduleGroupTimes(rows) {
|
||||
if (!this.enabled) return;
|
||||
for (const r of rows || []) {
|
||||
this.schedule({
|
||||
group: r.group, node: NODE_BEFORE_START, baseTime: r.start_time,
|
||||
reason: `考试开始前备份(管理员设置/修改了 ${r.group} 的开始时间)`,
|
||||
});
|
||||
this.schedule({
|
||||
group: r.group, node: NODE_AFTER_END, baseTime: r.end_time,
|
||||
reason: `考试结束后备份(管理员设置/修改了 ${r.group} 的结束时间)`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 成绩发布节点:立即异步备份(不等待)
|
||||
*
|
||||
* @param {string|null} group 缺省 = 全部组
|
||||
*/
|
||||
async triggerScorePublished(group) {
|
||||
if (!this.enabled) return;
|
||||
const key = `publish:${group || 'ALL'}:${new Date().toISOString()}`;
|
||||
this.logger.info('[NodeBackup] 触发节点备份:成绩已发布 group=%s key=%s', group || 'ALL', key);
|
||||
await this._fire({
|
||||
group: group || 'ALL', node: NODE_SCORE_PUBLISHED,
|
||||
fireAt: new Date(), key, reason: '成绩发布后备份',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 进程启动时从 competition_times 重建未到期的计划
|
||||
*
|
||||
* ⛔ 只重建**尚未到点**的计划:已过点的 fireAt 若 hydrate 出来会立刻触发,
|
||||
* 等于「重启一次 = 补备一次」,与幂等键设计相悖。
|
||||
* @param {Array} rows competition_times 行
|
||||
*/
|
||||
async hydrate(rows) {
|
||||
if (!this.enabled) return;
|
||||
const now = Date.now();
|
||||
let armed = 0;
|
||||
let skipped = 0;
|
||||
for (const r of rows || []) {
|
||||
for (const node of [ NODE_BEFORE_START, NODE_AFTER_END ]) {
|
||||
const base = node === NODE_BEFORE_START ? r.start_time : r.end_time;
|
||||
if (!base) continue;
|
||||
// 🔴 必须用 clamp=false 取**数学时刻**来判过期,不能用默认的钳制结果 ——
|
||||
// 钳制会把过期时刻伪造成 now+1s,于是过期节点也会被排上,
|
||||
// 结果是「进程一重启就立刻误备份一次」(C28 判据 J4.2 实测抓到)。
|
||||
const raw = computeFireAt(r.group, node, base, Object.assign({}, this.config, { clamp: false }));
|
||||
if (!raw) continue;
|
||||
if (raw.getTime() <= now) { skipped++; continue; }
|
||||
this.schedule({
|
||||
group: r.group, node, baseTime: base,
|
||||
reason: `进程启动重建计划(${r.group})`,
|
||||
});
|
||||
armed++;
|
||||
}
|
||||
}
|
||||
this.logger.info(
|
||||
'[NodeBackup] hydrate 完成:已排 %d 个未来计划,跳过 %d 个已过期节点(避免重启即误备份)',
|
||||
armed, skipped
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出当前在册计划(供管理端排障接口读取)
|
||||
*
|
||||
* 🔴 R171 修既有缺陷:原实现用 `k.split(/:(.*):(.*)/)` 解析,正则是贪婪的 ⇒
|
||||
* `zhongzhi:before-start:2026-11-01T08:30:00.000Z` 会被拆成
|
||||
* ['zhongzhi', 'before-start:2026-11-01T08:30', '00.000Z'],
|
||||
* 即 node 串进时间、fireAt 只剩碎片。group/node 本身不含 ':',
|
||||
* 而 ISO 时刻含 ':' ⇒ 只能「按前两个冒号切、剩余全部归还 fireAt」。
|
||||
*/
|
||||
list() {
|
||||
return Array.from(this.timers.keys()).map(k => {
|
||||
const parts = String(k).split(':');
|
||||
const group = parts[0] || '';
|
||||
const node = parts[1] || '';
|
||||
const at = parts.slice(2).join(':');
|
||||
return { key: k, group, node, fireAt: at };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 防线①:跨进程文件锁(原子 mkdir,与 scripts/backup_daily.sh 的锁同法)
|
||||
*
|
||||
* 为什么必须有:生产 EGG_WORKERS=2 ⇒ 两个 app worker 进程各自持有一份**相同**的
|
||||
* 计划(Egg 的 app worker 是独立进程,内存定时器**不共享**)⇒ 到点时两个进程
|
||||
* 会同时触发。文件锁保证同一时刻只有一个进程真正 dump。
|
||||
*
|
||||
* ⚠️ 锁目录用 `mv` 归档而非 `rm`(⛔ 全项目禁 rm)。
|
||||
* ⚠️ 残留锁(进程被 kill -9)会让后续备份永久拒绝执行 ⇒ 故设 TTL:
|
||||
* 超过 lockTtlSec(默认 2h,远大于一次 dump 的耗时)视为陈旧锁,可接管。
|
||||
* @returns {string|null} 持有则返回锁路径,未持有返回 null
|
||||
*/
|
||||
_acquireLock(lockKey) {
|
||||
const lockDir = path.join(this.config.logDir, '.lock-' + lockKey.replace(/[^\w.-]/g, '_'));
|
||||
try {
|
||||
fs.mkdirSync(lockDir); // 原子:并发下只有一个成功
|
||||
} catch (e) {
|
||||
if (e.code !== 'EEXIST') {
|
||||
this.logger.error('[NodeBackup] 创建锁失败(%s):%s', lockDir, e.message);
|
||||
return null;
|
||||
}
|
||||
// 已存在 ⇒ 判断是否陈旧(残留锁)
|
||||
const metaFile = path.join(lockDir, 'meta.json');
|
||||
let ageMs = 0;
|
||||
try {
|
||||
const st = fs.statSync(lockDir);
|
||||
ageMs = Date.now() - st.mtimeMs;
|
||||
} catch (statErr) {
|
||||
return null; // 锁刚被别人释放/改名 ⇒ 本次保守放弃
|
||||
}
|
||||
const ttlMs = (this.config.lockTtlSec || 7200) * 1000;
|
||||
if (ageMs < ttlMs) {
|
||||
this.logger.warn(
|
||||
'[NodeBackup] 已有进程持锁(age=%ds),本次跳过(多 worker 去重生效):%s',
|
||||
Math.round(ageMs / 1000), lockDir
|
||||
);
|
||||
return null;
|
||||
}
|
||||
// 陈旧锁 ⇒ 改名归档后重试一次(⛔ 不用 rm)
|
||||
const stale = lockDir + '.stale-' + Date.now();
|
||||
try {
|
||||
fs.renameSync(lockDir, stale);
|
||||
this.logger.warn('[NodeBackup] 清理陈旧锁(age=%ds)→ %s', Math.round(ageMs / 1000), stale);
|
||||
} catch (renErr) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
fs.mkdirSync(lockDir);
|
||||
} catch (e2) {
|
||||
return null; // 又被别人抢先 ⇒ 让给它
|
||||
}
|
||||
}
|
||||
try {
|
||||
fs.writeFileSync(path.join(lockDir, 'meta.json'),
|
||||
JSON.stringify({ pid: process.pid, started: new Date().toISOString(), key: lockKey }));
|
||||
} catch (e) { /* 锁本身已拿到,元信息写失败不阻塞 */ }
|
||||
return lockDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 防线②:触发前回库核对计划是否仍然有效
|
||||
*
|
||||
* 解决的问题:`setTimes` 请求只落到「处理它的那一个 worker」⇒ 其它 worker 里
|
||||
* 的**旧定时器不会被清除**。若管理员把考试时间改了,别的 worker 仍会在旧时刻
|
||||
* 触发一次备份(备份内容是新配置下的库,触发时刻却是错的)。
|
||||
* 修法:触发时按「当前 DB 里的时间」重算计划键;与本进程的旧键不符 ⇒ 已作废,放弃。
|
||||
* ⛔ 对 score-published 节点**不做**此核对:它是即时事件、无时间基线可比。
|
||||
*/
|
||||
async _stillValid(meta) {
|
||||
if (meta.node === NODE_SCORE_PUBLISHED) return true;
|
||||
const col = meta.node === NODE_BEFORE_START ? 'start_time' : 'end_time';
|
||||
try {
|
||||
const row = await this.app.model.CompetitionTime.findOne({
|
||||
where: { group: meta.group },
|
||||
attributes: [ 'start_time', 'end_time' ],
|
||||
raw: true,
|
||||
});
|
||||
if (!row || !row[col]) return false;
|
||||
const current = computeFireAt(meta.group, meta.node, row[col],
|
||||
Object.assign({}, this.config, { clamp: false }));
|
||||
const currentKey = planKey(meta.group, meta.node, current);
|
||||
if (currentKey !== meta.key) {
|
||||
this.logger.info(
|
||||
'[NodeBackup] 计划已作废(时间配置已被修改),放弃触发:旧=%s 新=%s', meta.key, currentKey
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
// 🔴 核对失败**不放行**:宁可漏一次备份,也不误备份(误备份会覆盖正确语义)。
|
||||
// 漏备份可由下一节点/每日备份兜底。
|
||||
this.logger.error('[NodeBackup] 计划有效性核对失败 ⇒ 放弃本次触发(防误备份):%s', e.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 实际执行备份(异步、不抛错)
|
||||
*/
|
||||
async _fire(meta) {
|
||||
const { group, node, key, reason } = meta;
|
||||
|
||||
// ---- 防线②:计划是否仍然有效 ----
|
||||
if (!(await this._stillValid(meta))) {
|
||||
// 计划已作废(管理员改过时间)⇒ 必须记一笔:否则 H3 会把它误判成「漏备」
|
||||
this._record('invalid-skip', { key, group, node, reason: '计划已作废(时间配置已被修改)' });
|
||||
return;
|
||||
}
|
||||
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const logDir = this.config.logDir;
|
||||
const outDir = this.config.outDir;
|
||||
|
||||
try {
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
} catch (e) {
|
||||
this.logger.error('[NodeBackup] 建目录失败,放弃本次备份:%s', e.message);
|
||||
// 目录建不出来 ⇒ 台账也写不进去,只能靠 logger;管理端会由 H3「漏备」暴露
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- 防线①:跨进程锁(多 worker 去重)----
|
||||
const lockDir = this._acquireLock(node === NODE_SCORE_PUBLISHED ? `publish-${group}` : key);
|
||||
if (!lockDir) {
|
||||
// 另一个 worker 正在做同一件事 ⇒ 记 lock-skip,H3 才不判成漏备
|
||||
this._record('lock-skip', { key, group, node, reason: '另一进程持锁(多 worker 去重)' });
|
||||
return;
|
||||
}
|
||||
const releaseLock = () => {
|
||||
try {
|
||||
fs.renameSync(lockDir, lockDir + '.done-' + Date.now());
|
||||
} catch (e) { /* ignore */ }
|
||||
};
|
||||
|
||||
const logFile = path.join(logDir, `node-backup-${stamp}.log`);
|
||||
const args = [
|
||||
this.config.scriptPath,
|
||||
'--env-file', this.config.envFile,
|
||||
'--out', outDir,
|
||||
'--keep-days', String(this.config.keepDays || 90),
|
||||
];
|
||||
if (this.config.mysqldump) args.push('--mysqldump', this.config.mysqldump);
|
||||
|
||||
const env = Object.assign({}, process.env);
|
||||
if (this.config.pythonPath) env.PYTHONPATH = this.config.pythonPath;
|
||||
if (this.config.python) env.PYTHON_BIN = this.config.python;
|
||||
|
||||
const header = [
|
||||
`===== 节点备份 ${stamp} =====`,
|
||||
`节点=${node} 组别=${group} 计划键=${key}`,
|
||||
`原因=${reason || '-'}`,
|
||||
`命令=python ${args.join(' ')}`,
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const out = fs.openSync(logFile, 'a');
|
||||
try {
|
||||
fs.writeSync(out, header);
|
||||
} finally {
|
||||
fs.closeSync(out);
|
||||
}
|
||||
|
||||
this.logger.info('[NodeBackup] 开始备份 → %s(日志:%s)', outDir, logFile);
|
||||
this._record('start', {
|
||||
key, group, node, logFile,
|
||||
reason: reason || '-',
|
||||
cmd: `python ${args.join(' ')}`,
|
||||
});
|
||||
|
||||
const child = spawn('python', args, { env, cwd: this.config.repoRoot });
|
||||
const fd = fs.openSync(logFile, 'a');
|
||||
child.stdout.on('data', d => { try { fs.writeSync(fd, d); } catch (e) { /* 日志失败不阻塞备份 */ } });
|
||||
child.stderr.on('data', d => { try { fs.writeSync(fd, d); } catch (e) { /* 同上 */ } });
|
||||
|
||||
// ⚠️ spawn 的 'error' 与 'close' 可能**同时**触发(如 ENOENT)⇒ 用 settled 保证
|
||||
// 只落一条结果,否则台账会出现「一次执行 = 一个 fail + 一个 success」的假象。
|
||||
let settled = false;
|
||||
const finish = (event, extra) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
this._record(event, { key, group, node, logFile }, extra);
|
||||
};
|
||||
|
||||
child.on('error', err => {
|
||||
try { fs.closeSync(fd); } catch (e) { /* ignore */ }
|
||||
releaseLock();
|
||||
// 🔴 备份失败必须**响亮**:这正是台账 P1「生产备份静默失败」的成因 ——
|
||||
// 旧实现失败只落文件没人看 ⇒ 直到需要恢复时才发现备份根本不存在。
|
||||
// R171:除 logger 外再落台账 ⇒ 管理后台「时间设置」页可直接看到红灯。
|
||||
this.logger.error(
|
||||
'[NodeBackup] 🔴 节点备份启动失败 group=%s node=%s key=%s err=%s(日志:%s)',
|
||||
group, node, key, err.message, logFile
|
||||
);
|
||||
finish('fail', { stage: 'spawn', message: err.message });
|
||||
});
|
||||
|
||||
child.on('close', code => {
|
||||
try { fs.closeSync(fd); } catch (e) { /* ignore */ }
|
||||
releaseLock(); // ⚠️ 必须在 close 后释放,否则同 key 的重排会被自己的锁挡住
|
||||
if (code === 0) {
|
||||
this.logger.info(
|
||||
'[NodeBackup] ✅ 节点备份成功 group=%s node=%s key=%s 日志=%s', group, node, key, logFile
|
||||
);
|
||||
finish('success', { rc: 0 });
|
||||
} else {
|
||||
this.logger.error(
|
||||
'[NodeBackup] 🔴 节点备份失败(rc=%s)group=%s node=%s key=%s ⇒ 需人工介入 日志=%s',
|
||||
code, group, node, key, logFile
|
||||
);
|
||||
finish('fail', { stage: 'exit', rc: code, message: `备份脚本退出码 ${code}` });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
NodeBackupScheduler,
|
||||
NODE_BEFORE_START,
|
||||
NODE_AFTER_END,
|
||||
NODE_SCORE_PUBLISHED,
|
||||
planKey,
|
||||
computeFireAt,
|
||||
// 台账 / 可见性(纯函数,便于 C28 判据独立验证)
|
||||
HISTORY_FILE_NAME,
|
||||
historyPath,
|
||||
readHistory,
|
||||
listArtifacts,
|
||||
evaluateHealth,
|
||||
};
|
||||
329
app/lib/passwordPolicy.js
Normal file
329
app/lib/passwordPolicy.js
Normal file
@@ -0,0 +1,329 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 口令强度策略 —— 全站**唯一事实来源(SoT)**
|
||||
* ============================================================
|
||||
*
|
||||
* 建立背景(2026-09-30 · R63)
|
||||
* ------------------------------------------------------------
|
||||
* R61 测试人员反馈:短信验证后进入「设置密码」页,输入框**已被浏览器密码管理器
|
||||
* 预填 `123456`**。R62 已从前端修掉两条根因(激活页不清残留登录态 + 密码框未声明
|
||||
* `autocomplete` 语义),但那是**前端便利性**层面的修复 —— 它只能阻止"自动填",
|
||||
* 不能阻止"考生自己真的输入 123456 并提交"。实测后端此前只校验「长度 ≥ 6」
|
||||
* (`app/validate/index.js` 的 activate / resetPassword),`123456` 完全合法
|
||||
* ⇒ 弱口令仍是**后端可接受**的状态。本模块补上这一层。
|
||||
*
|
||||
* 为什么必须只有一个实现
|
||||
* ------------------------------------------------------------
|
||||
* 口令校验天然会散落到多个入口(激活 / 忘记密码 / 未来新增的改密),一旦各自
|
||||
* 实现,必然出现「A 入口拦、B 入口放」的缺口(与项目里 `toClientUser` 单出口、
|
||||
* `OBJECTIVE_SCORE_PER_QUESTION` 单源同一类教训)。因此:
|
||||
* ⚠️ **任何新增的口令写入入口,必须调用本模块 `checkPassword()`,禁止自行判断。**
|
||||
*
|
||||
* 当前已接入(2/2):
|
||||
* · `app/controller/auth.js` → `activate()` (短信激活,设置初始口令)
|
||||
* · `app/controller/auth.js` → `resetPassword()` (忘记密码,重设口令)
|
||||
*
|
||||
* 另有一项**非强度**职责也收在本模块(保持「管理员默认凭据」口径单源,见文件末段):
|
||||
* · `app/controller/auth.js` → `adminLogin()` 的「默认口令守卫」读取本模块的
|
||||
* `DEFAULT_ADMIN_PASSWORDS` / `isDefaultAdminHash()` —— R112 · 候选㉒ 下沉。
|
||||
*
|
||||
* 不适用范围(重要,勿误改)
|
||||
* ------------------------------------------------------------
|
||||
* 以下链路**直写数据库、不经过本策略**,是**设计内**行为,不是绕过:
|
||||
* · `config.default.js` → `testDefaultPassword`(默认 `123456`):
|
||||
* 测试数据导入(`service/admin.js:240`)与 `resetTestData`(`admin.js:1582`)
|
||||
* 用它生成哈希写入 `test_flag=1` 的账号,供测试人员免激活重测。
|
||||
* · 生产数据导入(`test_flag=0`):初始口令为「身份证后 6 位」的 SHA256
|
||||
* (`service/admin.js:384`),且 `activated=false` —— 必须走激活流程,
|
||||
* 激活时由考生自填口令**覆盖**该初始值。
|
||||
* ⇒ 因此本策略的引入**不会破坏**测试数据便利性,也不会影响生产导入。
|
||||
* 唯一的行为变化:测试人员激活测试账号时不能再把口令设成 `123456` 这类弱口令
|
||||
* (这正是本次想要的效果,换个口令即可)。
|
||||
*
|
||||
* 黑名单为何放在独立 JSON 文件
|
||||
* ------------------------------------------------------------
|
||||
* `password_blacklist.json` 是纯数据,供两侧共用,避免出现两份会漂移的清单:
|
||||
* · 本模块(Node / Egg 运行时)
|
||||
* · `scripts/` 下的运维脚本(Python,用于扫描存量账号是否命中弱口令)
|
||||
* 加词只改 JSON,两侧同时生效。
|
||||
*
|
||||
* @module lib/passwordPolicy
|
||||
*/
|
||||
|
||||
const BLACKLIST = require('./password_blacklist.json');
|
||||
|
||||
/** 口令长度下限(与 `app/validate/index.js` 的 `min` 保持一致) */
|
||||
const MIN_LENGTH = 6;
|
||||
|
||||
/**
|
||||
* 口令长度上限。
|
||||
*
|
||||
* ⚠️ 此前**只有前端**限制 20 位(`Activate.vue` / `ForgotPassword.vue` 的
|
||||
* `max: 20` 规则),后端 `validate` 无上限 ⇒ 直接调接口可写入超长口令。
|
||||
* 本值使两侧口径一致;`app/validate/index.js` 同步补 `max: 20`。
|
||||
*
|
||||
* 选择 20 而非 72(bcrypt 有效上限)的原因:超长口令在考生端几乎只会来自
|
||||
* 粘贴误操作,限制上限可减少「以为自己设了长口令、实际记不住」的求助。
|
||||
*/
|
||||
const MAX_LENGTH = 20;
|
||||
|
||||
/** 面向考生的统一提示(**不泄露**内部规则细节,仅给可执行指引) */
|
||||
const STRENGTH_HINT = '密码强度不足,请使用 6-20 位且不易被猜到的组合(建议字母+数字+符号,避免连续或重复字符)';
|
||||
|
||||
/** 黑名单集合(小写归一化;比对时不区分大小写) */
|
||||
const BLACKLIST_SET = new Set(BLACKLIST.map(p => String(p).trim().toLowerCase()));
|
||||
|
||||
/**
|
||||
* 归一化:去首尾空白 + 小写。
|
||||
*
|
||||
* 说明:去空白是**双保险** —— `ctx.validate` 已用 `trim: true` 处理过一次,
|
||||
* 但本模块也会被非 HTTP 场景(脚本 / 单测)直接调用,不能假设上游已清理。
|
||||
*
|
||||
* @param {*} v
|
||||
* @returns {string}
|
||||
*/
|
||||
function normalize(v) {
|
||||
return String(v === null || v === undefined ? '' : v).trim().toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否「纯重复字符」——如 `aaaaaa` / `@@@@@@` / `111111`。
|
||||
*
|
||||
* 用正则 `^(.)\1*$` 一次判定:首字符捕获后必须整体重复。
|
||||
* 注意 `.` 在 JS 中不匹配换行符,口令含换行会自然落到"否",无副作用。
|
||||
*
|
||||
* @param {string} s 已归一化
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isRepeatedChar(s) {
|
||||
return s.length >= MIN_LENGTH && /^(.)\1*$/.test(s);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否「整串连续序列」——如 `123456`(递增)/ `654321`(递减)/
|
||||
* `abcdef`(字母递增)/ `fedcba`(字母递减)。
|
||||
*
|
||||
* 判据:每个相邻字符的字符码点差**恒为 +1 或恒为 -1**。
|
||||
* 刻意只判「整串」:`a1b2c3` / `abcd1234` 这类混合串**不**触发本规则
|
||||
* (它们的风险由黑名单显式覆盖),避免误伤正常口令。
|
||||
*
|
||||
* @param {string} s 已归一化
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isSequential(s) {
|
||||
if (s.length < MIN_LENGTH) return false;
|
||||
let up = true;
|
||||
let down = true;
|
||||
for (let i = 1; i < s.length; i++) {
|
||||
const diff = s.charCodeAt(i) - s.charCodeAt(i - 1);
|
||||
if (diff !== 1) up = false;
|
||||
if (diff !== -1) down = false;
|
||||
if (!up && !down) return false;
|
||||
}
|
||||
return up || down;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验口令强度。
|
||||
*
|
||||
* 规则(按执行顺序,命中即返回,**首个命中项**决定 reason):
|
||||
* 1. 非空且为字符串
|
||||
* 2. 长度在 [MIN_LENGTH, MAX_LENGTH] 区间
|
||||
* 3. 不含空白字符(防止「看起来 8 位、实际含空格」被前端 min 误判通过)
|
||||
* 4. 不在静态黑名单(大小写不敏感)
|
||||
* 5. 非纯重复字符
|
||||
* 6. 非整串连续序列
|
||||
* 7. 不等于**账号自身的可猜派生值**:完整身份证号 / 身份证号后 6 位 / 手机号
|
||||
* —— 生产账号的初始口令正是「身份证后 6 位」的 SHA256(`service/admin.js:384`),
|
||||
* 考生激活时若直接沿用该值,等于口令从未变更,必须拒绝。
|
||||
*
|
||||
* @param {string} password - 明文口令
|
||||
* @param {{idNumber?: string, phone?: string}} [context] - 账号上下文(用于派生值比对)
|
||||
* @returns {{ok: boolean, rule: string, reason: string}}
|
||||
* ok=false 时 `rule` 为命中的规则标识(供门禁/日志精确断言,**不下发前端**),
|
||||
* `reason` 为面向考生的中文文案(可直接返回给前端)。
|
||||
*/
|
||||
function checkPassword(password, context = {}) {
|
||||
const raw = String(password === null || password === undefined ? '' : password);
|
||||
|
||||
if (raw.length === 0) {
|
||||
return { ok: false, rule: 'empty', reason: '请输入密码' };
|
||||
}
|
||||
|
||||
const normalized = normalize(raw);
|
||||
|
||||
if (normalized.length < MIN_LENGTH) {
|
||||
return { ok: false, rule: 'too_short', reason: `密码长度不能少于 ${MIN_LENGTH} 位` };
|
||||
}
|
||||
if (normalized.length > MAX_LENGTH) {
|
||||
return { ok: false, rule: 'too_long', reason: `密码长度不能超过 ${MAX_LENGTH} 位` };
|
||||
}
|
||||
if (/\s/.test(raw)) {
|
||||
return { ok: false, rule: 'contains_space', reason: '密码不能包含空格' };
|
||||
}
|
||||
if (BLACKLIST_SET.has(normalized)) {
|
||||
return { ok: false, rule: 'blacklist', reason: STRENGTH_HINT };
|
||||
}
|
||||
if (isRepeatedChar(normalized)) {
|
||||
return { ok: false, rule: 'repeated_char', reason: STRENGTH_HINT };
|
||||
}
|
||||
if (isSequential(normalized)) {
|
||||
return { ok: false, rule: 'sequential', reason: STRENGTH_HINT };
|
||||
}
|
||||
|
||||
// 派生值比对:口令不得等于账号自身可猜出的标识
|
||||
const idNumber = normalize(context.idNumber);
|
||||
const phone = normalize(context.phone);
|
||||
if (idNumber) {
|
||||
if (normalized === idNumber) {
|
||||
return { ok: false, rule: 'equals_id_number', reason: '密码不能与身份证号相同' };
|
||||
}
|
||||
if (idNumber.length >= MIN_LENGTH && normalized === idNumber.slice(-MIN_LENGTH)) {
|
||||
return { ok: false, rule: 'equals_id_suffix', reason: '密码不能使用身份证号后 6 位' };
|
||||
}
|
||||
}
|
||||
if (phone && normalized === phone) {
|
||||
return { ok: false, rule: 'equals_phone', reason: '密码不能与手机号相同' };
|
||||
}
|
||||
|
||||
return { ok: true, rule: 'ok', reason: '' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析「测试数据默认口令」—— **唯一入口**(R106-D7,2026-10-12)。
|
||||
*
|
||||
* 背景:`config.testDefaultPassword` 原为无条件 `process.env.TEST_DEFAULT_PASSWORD || '123456'`,
|
||||
* 而 `deploy/.env.production` 的 `TEST_DEFAULT_PASSWORD` 一直是**注释状态** ⇒ 生产环境实际
|
||||
* 取到的就是弱口令 `'123456'`。一旦生产走到「批量写默认口令」的路径(重置测试数据 / 显式
|
||||
* `testFlag=true` 的 Excel 导入),一批账号会被设为 `'123456'` —— 这是**静默弱口令**,
|
||||
* 而不是"注入链路断了"的明确失败,与 `_secret.cjs` / `DB_PASSWORD` 的既有纪律不一致
|
||||
* (R104 结论:同族问题各自实现必然漂移)。
|
||||
*
|
||||
* 现约定:生产(`config.isProduction`)未显式注入 ⇒ `config.testDefaultPassword` 为 `null`
|
||||
* ⇒ 本函数**明确失败**并给出可行动指引(⛔ 不静默回退)。测试环境逐字不变(仍 `'123456'`)。
|
||||
*
|
||||
* ⚠️ 与 `controller.admin.resetTestData` 里 `process.env.NODE_ENV === 'production'` 的
|
||||
* 403 守卫**口径不同**(后者只看 NODE_ENV,本函数看 `config.isProduction`,即
|
||||
* `APP_ENV`/`appInfo.env`)⇒ 两者互为补充:守卫拦"明确的生产",本函数兜住
|
||||
* "`APP_ENV=production` 但 NODE_ENV 未设"这种分叉情形。
|
||||
*
|
||||
* @param {object} appConfig Egg `app.config`
|
||||
* @returns {string} 生效的测试默认口令
|
||||
*/
|
||||
function requireTestPassword(appConfig) {
|
||||
const pw = appConfig && appConfig.testDefaultPassword;
|
||||
if (pw) return pw;
|
||||
throw new Error(
|
||||
'[安全阻断] 生产环境未配置 TEST_DEFAULT_PASSWORD —— 拒绝把账号批量设为「默认口令」。\n' +
|
||||
' 该操作会写入 config.testDefaultPassword 的 bcrypt 哈希;若静默回退到内置弱口令\n' +
|
||||
" (原行为 '123456'),等价于在生产上批量设置弱口令。请任选其一:\n" +
|
||||
' ① 确实需要:在 deploy/.env.production 显式设置 TEST_DEFAULT_PASSWORD 后重启\n' +
|
||||
' (推荐 —— 让「生产要设默认口令」成为一个显式决定);\n' +
|
||||
' ② 若本就不该在生产重置测试数据,请在带 test_flag 的测试库执行。'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ── 内置默认管理员凭据(登录守卫的唯一真相源)──────────────────────
|
||||
*
|
||||
* 背景(BACKLOG 候选㉒ · R112)
|
||||
* ------------------------------------------------------------
|
||||
* `controller/auth.js` 的「默认口令守卫」原为**口令字面**判据(用户输入的明文
|
||||
* 是否命中一张硬编码清单)。该判据有两个结构缺口:
|
||||
* ① 清单**散落在 controller 内** —— 与 `database/schema.sql`、`backend/database/seeders/002-seed-admin.js`
|
||||
* 各持一份口径,属「多真相源」,任一处改动都不会带动其余(R111 血证:守卫曾漏 `admin`);
|
||||
* ② 只覆盖**已枚举**的默认口令 —— 任何未列出的内置默认口令(或清单被改动)即**静默绕过**。
|
||||
*
|
||||
* 治法:把「默认凭据」下沉到本模块(口令策略 SoT)成为**单一真相源**,并补一条
|
||||
* **与用户输入无关**的判据 —— 直接看 DB 里存的哈希是否即已发布的默认哈希。
|
||||
*
|
||||
* ⚠️ bcrypt 的盐是**随机**的:同一口令每次哈希结果都不同 ⇒ ⛔ 不能只比对哈希字符串。
|
||||
* 故本模块提供**两条互补通道**,由调用方 OR 合并(见 `controller/auth.js adminLogin`):
|
||||
* · 哈希通道 `isDefaultAdminHash(dbHash)` —— 覆盖「DB 里就是内置的那串哈希」
|
||||
* (R113 前 `schema.sql` 的无盐 SHA256;R113 起 `schema.sql` 与 `seeder 002`
|
||||
* 统一为**同一固定盐 bcrypt**),**与输入无关**;
|
||||
* · 口令通道 `DEFAULT_ADMIN_PASSWORDS.includes(明文)` —— 覆盖「同口令、不同盐」
|
||||
* 的**重新播种**场景(登录成功的明文必等于该默认口令,见 `002-seed-admin.js` 的
|
||||
* `ADMIN_INIT_PASSWORD` 分支会以随机盐重算哈希)。
|
||||
* 二者合并即覆盖「**已知**默认口令的任意存储形态」。
|
||||
* ⚠️ 未列入清单的默认口令仍是缺口 ⇒ 新增内置账号时**必须同步登记到下方清单**
|
||||
* (这是**刻意的显式维护点**,避免静默逃逸)。
|
||||
* ✅ R114 已收口(本条曾登记的「尚无仓内机检」缺口):断言已**下沉进仓**为常驻门禁 ——
|
||||
* `scripts/unit_password_policy.cjs` 第 [8] 段 + `scripts/unit_password_policy_mutation.cjs`
|
||||
* (+4 组变异体),接线为 `scripts/ci-gates.sh` 第 **25 步**(两条 `run_gate` 均阻断)⇒
|
||||
* 本清单被**清空 / 字面改写 / 判定整体禁用**时,电池即**自动报红**(判别力由变异测试自证)。
|
||||
* ⚠️ 机检覆盖的是「本清单**自身的形状与判定行为**」(非空 · 双通道命中 · 非默认不误伤);
|
||||
* 「本清单 ↔ `database/schema.sql` ↔ seeder 002」三处的**跨文件同值**仍靠**人工核对**。
|
||||
*/
|
||||
|
||||
/** 内置默认管理员口令(**唯一真相源**;登录守卫与文档口径均以此为准) */
|
||||
const DEFAULT_ADMIN_PASSWORDS = ['admin', 'admin123', 'admin123456'];
|
||||
|
||||
/**
|
||||
* 已发布默认管理员凭据的**字面哈希**(固定值,可精确比对)。
|
||||
* · `8c69…4918`:SHA256(无盐) —— **R113 前** `database/schema.sql` 建库 INSERT 的产物;
|
||||
* 自 **R113(候选㉔)** 起该文件已统一为下方 bcrypt ⇒ 本项作为**历史值保留**,
|
||||
* 用于拦截仍存该哈希的旧库 / 旧备份(⛔ 不删:保留即安全 —— 非默认口令不会命中)。
|
||||
* · `$2b$10$Nc6…co9.`:bcrypt(cost=10, 固定盐) —— 来源**两处已收敛为同值**:
|
||||
* `database/schema.sql` 的建库 INSERT + `backend/database/seeders/002-seed-admin.js`。
|
||||
*/
|
||||
const KNOWN_DEFAULT_ADMIN_HASHES = [
|
||||
'8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918',
|
||||
'$2b$10$Nc6PCJItIwuOzpM22dTiAezB.jZMI8mzkCbg3oRZkAWx.QaI4co9.',
|
||||
];
|
||||
|
||||
/** 字面哈希集合(小写归一化;bcrypt 前缀 `$2B$` 亦见于部分工具) */
|
||||
const KNOWN_DEFAULT_ADMIN_HASH_SET =
|
||||
new Set(KNOWN_DEFAULT_ADMIN_HASHES.map(h => h.toLowerCase()));
|
||||
|
||||
/**
|
||||
* 判定「DB 中存储的凭据哈希」是否为**已发布的默认值**(与用户输入无关)。
|
||||
*
|
||||
* 两级判定:
|
||||
* ① 字面命中 `KNOWN_DEFAULT_ADMIN_HASHES`(覆盖无盐 SHA256 与固定盐 bcrypt 两种存量形态);
|
||||
* ② 逐个候选默认口令做 bcrypt 校验(覆盖「同口令、**不同盐**」的重新播种 bcrypt 值 ——
|
||||
* 这正是「不能只比哈希字符串」的原因)。
|
||||
*
|
||||
* ⚠️ 降级语义:`bcrypt` 采用**延迟加载**。本模块会被脚本 / 离线上下文(如变异测试写到
|
||||
* 同目录的临时副本)加载,若该上下文取不到 `bcrypt`,则退化为「仅字面比对」(② 不可用),
|
||||
* 且**不抛异常**(守卫降级属可接受,⛔ 不得因此阻断进程启动)。
|
||||
*
|
||||
* @param {string} dbHash - `admins.password` 的存储值
|
||||
* @returns {boolean} 命中即 true
|
||||
*/
|
||||
function isDefaultAdminHash(dbHash) {
|
||||
if (!dbHash) return false;
|
||||
const h = String(dbHash).trim();
|
||||
if (!h) return false;
|
||||
if (KNOWN_DEFAULT_ADMIN_HASH_SET.has(h.toLowerCase())) return true;
|
||||
|
||||
let bcrypt = null;
|
||||
try {
|
||||
bcrypt = require('bcrypt');
|
||||
} catch (e) {
|
||||
return false; // 无 bcrypt ⇒ 退化为「仅字面比对」
|
||||
}
|
||||
for (const pw of DEFAULT_ADMIN_PASSWORDS) {
|
||||
try {
|
||||
if (bcrypt.compareSync(pw, h)) return true;
|
||||
} catch (e) {
|
||||
// 非法 bcrypt 形态(如 SHA256 十六进制串)⇒ 跳过,继续下一候选
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
checkPassword,
|
||||
requireTestPassword,
|
||||
MIN_LENGTH,
|
||||
MAX_LENGTH,
|
||||
STRENGTH_HINT,
|
||||
// 仅供门禁 / 运维脚本断言使用;业务代码请走 checkPassword()
|
||||
BLACKLIST,
|
||||
// 默认管理员凭据判据(登录守卫用;见上文「内置默认管理员凭据」段)
|
||||
DEFAULT_ADMIN_PASSWORDS,
|
||||
KNOWN_DEFAULT_ADMIN_HASHES,
|
||||
isDefaultAdminHash,
|
||||
};
|
||||
86
app/lib/password_blacklist.json
Normal file
86
app/lib/password_blacklist.json
Normal file
@@ -0,0 +1,86 @@
|
||||
[
|
||||
"123456",
|
||||
"1234567",
|
||||
"12345678",
|
||||
"123456789",
|
||||
"1234567890",
|
||||
"654321",
|
||||
"0987654321",
|
||||
"111111",
|
||||
"000000",
|
||||
"222222",
|
||||
"333333",
|
||||
"444444",
|
||||
"555555",
|
||||
"666666",
|
||||
"777777",
|
||||
"888888",
|
||||
"999999",
|
||||
"123123",
|
||||
"112233",
|
||||
"121212",
|
||||
"123321",
|
||||
"159357",
|
||||
"5201314",
|
||||
"1314520",
|
||||
"666888",
|
||||
"168168",
|
||||
"a123456",
|
||||
"123456a",
|
||||
"123456789a",
|
||||
"password",
|
||||
"passw0rd",
|
||||
"password1",
|
||||
"password123",
|
||||
"p@ssw0rd",
|
||||
"admin",
|
||||
"admin123",
|
||||
"admin888",
|
||||
"administrator",
|
||||
"root",
|
||||
"root123",
|
||||
"qwerty",
|
||||
"qwertyuiop",
|
||||
"qwerty123",
|
||||
"abc123",
|
||||
"abcd1234",
|
||||
"abcabc",
|
||||
"aa123456",
|
||||
"iloveyou",
|
||||
"letmein",
|
||||
"welcome",
|
||||
"monkey",
|
||||
"dragon",
|
||||
"master",
|
||||
"sunshine",
|
||||
"princess",
|
||||
"football",
|
||||
"baseball",
|
||||
"superman",
|
||||
"hello123",
|
||||
"test123",
|
||||
"test1234",
|
||||
"zxcvbnm",
|
||||
"asdfgh",
|
||||
"asdfghjkl",
|
||||
"qazwsx",
|
||||
"1qaz2wsx",
|
||||
"1qazxsw2",
|
||||
"qwe123",
|
||||
"qweasd",
|
||||
"q1w2e3",
|
||||
"pass123",
|
||||
"p123456",
|
||||
"123abc",
|
||||
"abcd123",
|
||||
"a1b2c3",
|
||||
"beidou",
|
||||
"beidouin",
|
||||
"brskills",
|
||||
"competition",
|
||||
"competition123",
|
||||
"contest",
|
||||
"contest2026",
|
||||
"hwasmart",
|
||||
"hwasmart123"
|
||||
]
|
||||
559
app/lib/publicitySheet.js
Normal file
559
app/lib/publicitySheet.js
Normal file
@@ -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 = /<row\b[^>]*\/>|<row\b[^>]*>[\s\S]*?<\/row>/g;
|
||||
const CELL_RE = /<c\b[^>]*\/>|<c\b[^>]*>[\s\S]*?<\/c>/g;
|
||||
|
||||
function rowNumber(rowXml) {
|
||||
const m = /<row\b[^>]*\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 `<c r="${ref}"${sAttr}/>`;
|
||||
}
|
||||
if (typeof value === 'number' && isFinite(value)) {
|
||||
return `<c r="${ref}"${sAttr}><v>${value}</v></c>`;
|
||||
}
|
||||
// 用 inlineStr 而不是 sharedStrings:新引入的字符串不用再去维护共享表下标
|
||||
return `<c r="${ref}"${sAttr} t="inlineStr"><is><t xml:space="preserve">${esc(value)}</t></is></c>`;
|
||||
}
|
||||
|
||||
/** 选「最常见的数据行样式」作为新行模板,避免拿模板里被手工改过的那一行当标准 */
|
||||
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<Object>} rows buildPublicityRows 的产物
|
||||
* @returns {{ xml: string, dataRows: number, tailRow: number, styles: Object }}
|
||||
*/
|
||||
function patchSheetData(sheetXml, rows) {
|
||||
const sheetDataMatch = /<sheetData\b[^>]*>([\s\S]*?)<\/sheetData>/.exec(sheetXml);
|
||||
if (!sheetDataMatch) throw new Error('模板缺少 <sheetData>');
|
||||
|
||||
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(`<row r="${r}" customHeight="1" spans="1:7">${cells.join('')}</row>`);
|
||||
}
|
||||
|
||||
const delta = (count + (DATA_FIRST_ROW - 1)) - DATA_LAST_ROW; // 新数据末行 - 旧数据末行
|
||||
const shiftRow = (xml, add) => {
|
||||
if (add === 0) return xml;
|
||||
return xml.replace(/(<row\b[^>]*\br=")(\d+)(")/, (m, p1, p2, p3) => p1 + (parseInt(p2, 10) + add) + p3)
|
||||
.replace(/(<c\b[^>]*\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 = `<sheetData>${out.join('')}</sheetData>`;
|
||||
let xml = sheetXml.replace(sheetDataMatch[0], newSheetData);
|
||||
|
||||
// dimension 同步(否则 Excel 认为表格只到旧末行,滚动区/打印区会截断)
|
||||
const tailRow = TEMPLATE_TAIL_ROW + delta;
|
||||
xml = xml.replace(/<dimension\b[^>]*\bref="[^"]*"/, `<dimension ref="A1:J${tailRow}"`);
|
||||
|
||||
return { xml, dataRows: count, tailRow, styles };
|
||||
}
|
||||
|
||||
/**
|
||||
* 替换标题里的赛项名与赛项编号。
|
||||
*
|
||||
* 标题是 A1 一个单元格里的三行文本(存在 sharedStrings 第 0 项),
|
||||
* 所以改 sharedStrings 比改 sheet1 更准(A1 是 t="s" 引用,改 sheet 只能整体替换)。
|
||||
*
|
||||
* @param {string} xml sharedStrings.xml 原文
|
||||
* @param {{itemName?: string, itemCode?: string}} meta
|
||||
* @returns {{ xml: string, replacedItem: boolean, replacedCode: boolean }}
|
||||
*/
|
||||
function patchSharedStringsTitle(xml, meta) {
|
||||
const itemName = meta && meta.itemName ? String(meta.itemName).trim() : '';
|
||||
const itemCode = meta && meta.itemCode ? String(meta.itemCode).trim() : '';
|
||||
|
||||
let replacedItem = false;
|
||||
let replacedCode = false;
|
||||
|
||||
// 定位第 1 个 <si>(标题)
|
||||
const siMatch = /<si>([\s\S]*?)<\/si>/.exec(xml);
|
||||
if (!siMatch) throw new Error('sharedStrings 里没有 <si>');
|
||||
|
||||
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 = `<si>${inner}</si>`;
|
||||
return { xml: xml.replace(siMatch[0], newSi), replacedItem, replacedCode };
|
||||
}
|
||||
|
||||
/**
|
||||
* 完整流程:模板字节 + 数据行 + 标题信息 → 新 xlsx 字节。
|
||||
* @param {Buffer} templateBuf
|
||||
* @param {Array<Object>} 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,
|
||||
};
|
||||
685
app/lib/scoreRankSheet.js
Normal file
685
app/lib/scoreRankSheet.js
Normal file
@@ -0,0 +1,685 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 成绩排名表导出引擎(R220)
|
||||
*
|
||||
* 目标:中职组 / 高职组考试结束后,按小组客观题分降序全量导出排名表,
|
||||
* 整行按分数区间上底色,表尾给出三区间的小组数统计。
|
||||
*
|
||||
* 🔴 为什么要自己在 zip 层动 styles.xml:
|
||||
* `@e965/xlsx`(SheetJS CE)写 xlsx 时**完全丢弃样式**(连 `s=` 属性都不写),
|
||||
* 实测 2026-10-19:模板 214 个样式引用写回后变 0、styles.xml 从 22773 → 1546 字节。
|
||||
* ⇒ 唯一能上色的做法 = 先用该库产出一个合法的 xlsx 骨架,
|
||||
* 再解压 → 改 `xl/styles.xml`(追加 fills + cellXfs)→ 重建 `sheetData`(写 `s=`)
|
||||
* → 按原始压缩字节重新打包。
|
||||
*
|
||||
* 🔴 零新依赖:zip 读写与 XML 片段工具全部复用 `lib/publicitySheet.js`
|
||||
* (该模块只用 Node 内置 zlib + 自实现 CRC32)⇒ 生产上线**不需要 npm install**,
|
||||
* 也就不必为此停机。
|
||||
*
|
||||
* 🔴 全量重建 sheetData 而不是「只补 s 属性」:该库把字符串写成 `t="str"` + `<v>文本</v>`
|
||||
* (这是「公式字符串」语义,无 `<f>` 时并不规范)。既然要逐行改样式,
|
||||
* 索性用 `cellXml()` 以 `inlineStr` 重建每一行 ⇒ 顺带消掉这个隐患,
|
||||
* 也保证「整行」A..H 每格都存在(该库会跳过空值单元格,底色会断成小块)。
|
||||
*
|
||||
* 🔴 分数口径:**必须由调用方传入实时口径**(客观题分 + 大题分)。
|
||||
* `contestants.score` 只有管理员「成绩管理」里保存过分数后才有值,
|
||||
* 考试刚结束恒为 0 —— 直接读它会导出「页面 78 分、表格 0 分」的假表。
|
||||
*/
|
||||
|
||||
const XLSX = require('@e965/xlsx');
|
||||
const {
|
||||
readZip,
|
||||
writeZip,
|
||||
entryText,
|
||||
replaceEntryText,
|
||||
cellXml,
|
||||
} = require('./publicitySheet');
|
||||
|
||||
// ==================== 常量 ====================
|
||||
|
||||
/**
|
||||
* 表头(6 列,顺序即写入顺序)。
|
||||
*
|
||||
* 🔴 R220-C 用户裁定:本表**按客观题分筛选**,故「大题分」「总分」两列不出现。
|
||||
* 删掉总分列有个强连带约束:底色/名次若仍按总分算,用户看不到总分就无法
|
||||
* 解释某行为什么是绿的 ⇒ 排序键、并列判据、区间判定**三者必须同为客观题分**。
|
||||
*/
|
||||
const HEADERS = ['名次', '小组编号', '学校', '参赛选手', '人数', '客观题分'];
|
||||
|
||||
/** 6 个数据列 */
|
||||
const DATA_COLS = ['A', 'B', 'C', 'D', 'E', 'F'];
|
||||
|
||||
/** 列宽(字符数) */
|
||||
const COL_WIDTHS = [
|
||||
{ wch: 7 }, // 名次
|
||||
{ wch: 18 }, // 小组编号
|
||||
{ wch: 34 }, // 学校
|
||||
{ wch: 34 }, // 参赛选手
|
||||
{ wch: 7 }, // 人数
|
||||
{ wch: 11 }, // 客观题分
|
||||
];
|
||||
|
||||
/** 默认统计口径:前 N 名 */
|
||||
const DEFAULT_TOP_N = 80;
|
||||
|
||||
/**
|
||||
* 客观题满分(R220-D 用户补充的事实)。
|
||||
*
|
||||
* ⚠️ 它**只用于说明行文字**,不参与任何判定:
|
||||
* 区间阈值仍是用户裁定的 60 / 50(70 分制下 60 分 = 86%,属高分段)。
|
||||
* ⛔ 不要因为满分变化就自动缩放阈值 —— 那是用户裁定过的业务规则。
|
||||
* 若某次考试的满分不是 70,由调用方传 fullScore 覆盖即可。
|
||||
*/
|
||||
const DEFAULT_FULL_SCORE = 70;
|
||||
|
||||
/**
|
||||
* 三个分数区间的**单一判定源**。
|
||||
* 顺序即优先级:先 ≥60,再 ≥50,其余归红。
|
||||
* 边界按用户裁定:≥60 绿 / 50–59.99 黄 / <50 红。
|
||||
*
|
||||
* 🔴 判定输入是**客观题分**(examScore),不是总分(R220-C 用户裁定)。
|
||||
* 阈值本身未变,只是被量的对象换了 —— 因为表格里已无总分列。
|
||||
*/
|
||||
const BANDS = [
|
||||
{ key: 'green', label: '≥60 分(绿)', min: 60, rgb: 'FFC6EFCE' },
|
||||
{ key: 'yellow', label: '50–59.99 分(黄)', min: 50, rgb: 'FFFFEB9C' },
|
||||
{ key: 'red', label: '<50 分(红)', min: -Infinity, rgb: 'FFFFC7CE' },
|
||||
];
|
||||
|
||||
/** 表头底色(灰) */
|
||||
const HEADER_RGB = 'FFD9D9D9';
|
||||
|
||||
/** 前 N 名「红框」的线色(R220-D) */
|
||||
const RED_BORDER_RGB = 'FFFF0000';
|
||||
|
||||
// ==================== 纯函数层 ====================
|
||||
|
||||
/** 转数字,非法值归 0 */
|
||||
function toNum(v) {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
/** 保留两位小数,消除浮点噪声(如 59.99999999) */
|
||||
function round2(v) {
|
||||
return Math.round(toNum(v) * 100) / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分数区间判定(唯一入口)。
|
||||
* @param {number} score 小组**客观题分**(examScore)
|
||||
* @returns {'green'|'yellow'|'red'}
|
||||
*/
|
||||
function scoreBand(score) {
|
||||
const n = toNum(score);
|
||||
for (const b of BANDS) {
|
||||
if (n >= b.min) return b.key;
|
||||
}
|
||||
return 'red';
|
||||
}
|
||||
|
||||
/**
|
||||
* 名次编排:按**客观题分**(examScore)降序,同分并列(1、2、2、4)。
|
||||
*
|
||||
* 🔴 排序键 = 客观题分,与「区间底色判定」同源(R220-C 用户裁定)。
|
||||
* ⛔ 不要改成 total:表格里已无总分列,按总分排序会让名次与可见分数矛盾
|
||||
* (出现「客观题 90 分排第 5、客观题 70 分排第 1」这种用户无法理解的结果)。
|
||||
*
|
||||
* 并列组内的先后顺序用「人数升序 → 小组编号升序」定,纯为了结果可复现,
|
||||
* 不影响名次数值本身。
|
||||
*
|
||||
* 注:返回项里仍保留 wordScore / total 字段供内部排查与既有断言使用,
|
||||
* 但 **不写入表格**(HEADERS 只有 6 列)。
|
||||
*
|
||||
* @param {Array<Object>} teams 每项 {teamId, org, players, memberCount, examScore, wordScore, total}
|
||||
* @returns {Array<Object>} 追加了 rank 字段的新数组(不改入参)
|
||||
*/
|
||||
function buildRankRows(teams) {
|
||||
const list = (teams || []).map(t => ({
|
||||
teamId: t && t.teamId != null ? String(t.teamId) : '',
|
||||
org: t && t.org != null ? String(t.org) : '',
|
||||
players: t && t.players != null ? String(t.players) : '',
|
||||
memberCount: Math.trunc(toNum(t && t.memberCount)),
|
||||
examScore: round2(t && t.examScore),
|
||||
wordScore: round2(t && t.wordScore),
|
||||
total: round2(t && t.total),
|
||||
}));
|
||||
|
||||
list.sort((a, b) => {
|
||||
if (b.examScore !== a.examScore) return b.examScore - a.examScore;
|
||||
if (a.memberCount !== b.memberCount) return a.memberCount - b.memberCount;
|
||||
return a.teamId < b.teamId ? -1 : (a.teamId > b.teamId ? 1 : 0);
|
||||
});
|
||||
|
||||
let prevScore = null;
|
||||
let prevRank = 0;
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
const cur = list[i].examScore;
|
||||
if (prevScore !== null && cur === prevScore) {
|
||||
list[i].rank = prevRank; // 并列:沿用上一个名次
|
||||
} else {
|
||||
list[i].rank = i + 1; // 跳号:1、2、2、4
|
||||
prevScore = cur;
|
||||
prevRank = list[i].rank;
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/**
|
||||
* 三区间小组数统计,两套口径(全量 / 全量排名前 N)。
|
||||
*
|
||||
* 🔴 「前 N 名」= 全部小组降序排序后**连续**取前 N 个(跨区间截取),
|
||||
* ⛔ 不是「名次 ≤ N」,⛔ 也不是「每个区间各取前 N 个」。
|
||||
* 保证该列合计恒 = min(总数, N),与「筛选出排名靠前的 80 个小组」的口径一致。
|
||||
* selfCheck() 里有对应断言,误实现成区间各取会在自检阶段直接抛错。
|
||||
*
|
||||
* @param {Array<Object>} rows buildRankRows 的产物
|
||||
* @param {number} topN 默认 80
|
||||
*/
|
||||
function buildStats(rows, topN) {
|
||||
const n = Math.max(0, Math.trunc(toNum(topN == null ? DEFAULT_TOP_N : topN)));
|
||||
const all = rows || [];
|
||||
const top = all.slice(0, n);
|
||||
const countOf = arr => {
|
||||
const c = { green: 0, yellow: 0, red: 0 };
|
||||
// 区间判定与排序键同源:客观题分(R220-C)
|
||||
for (const r of arr) c[scoreBand(r.examScore)]++;
|
||||
return c;
|
||||
};
|
||||
return {
|
||||
topN: n,
|
||||
total: all.length,
|
||||
topTotal: top.length,
|
||||
all: countOf(all),
|
||||
top: countOf(top),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计块下方的口径说明文字。
|
||||
*
|
||||
* 🔴 为什么需要它(R220 用户反馈):当小组总数 ≤ topN 时,「全量小组」与
|
||||
* 「前N名(总排名)」两列数字必然完全相同 —— 用户看到 7 / 7 会合理怀疑
|
||||
* 「是不是每个区间各取了 7 个」。列名再清楚也压不住这种猜疑,
|
||||
* 干脆用一行文字把口径写死,同时把「为何两列一样」当场解释掉。
|
||||
*/
|
||||
function buildScopeNote(stats, fullScore) {
|
||||
const n = stats.topN;
|
||||
const full = fullScore == null ? DEFAULT_FULL_SCORE : fullScore;
|
||||
const head = `说明:本表按客观题分(满分 ${full} 分)降序排列,名次、区间底色与红框范围均以客观题分为准;`;
|
||||
const boxed = Math.min(n, stats.total);
|
||||
const tail = `表格中前 ${boxed} 行已用红色外框圈出(跨区间连续,行间不画横线,以便看成一个整体)。`;
|
||||
if (stats.total <= n) {
|
||||
return `${head}本次共 ${stats.total} 个小组,未超过 ${n} 个,故「前${n}名(总排名)」列等于全量,两列数字相同属正常。`
|
||||
+ `前${n}名 = 全部小组按客观题分降序排列后连续取前 ${n} 个,不是各分数区间分别取前 ${n} 个。${tail}`;
|
||||
}
|
||||
return `${head}「前${n}名(总排名)」= 全部 ${stats.total} 个小组按客观题分降序排列后连续取前 ${n} 个,可跨分数区间截取;`
|
||||
+ `不是各分数区间分别取前 ${n} 个(若按区间各取,合计会远超 ${n})。${tail}`;
|
||||
}
|
||||
|
||||
// ==================== styles.xml 手术 ====================
|
||||
|
||||
/** 取某个元素块内的直接子元素个数(按标签名首段统计) */
|
||||
function countChildren(inner, tag) {
|
||||
const re = new RegExp(`<${tag}\\b`, 'g');
|
||||
return (inner.match(re) || []).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 styles.xml 末尾追加「表头灰底 + 三档底色 + 前 N 名红框」的
|
||||
* fills / fonts / borders / cellXfs。
|
||||
*
|
||||
* ⚠️ 不假定基线 count 的具体数值:一律按**实际子元素个数**取索引并回写 count,
|
||||
* 这样即使将来 @e965/xlsx 版本升级改了默认 fills 数量也不会把索引错位。
|
||||
*
|
||||
* 🔴 红框为什么要 4 个 border + 12 个 xf(R220-D):
|
||||
* 用户要「前 80 个小组被一个红框整体圈住」,而不是每行各画一个框。
|
||||
* 单格边框只能表达「这条边上有没有线」,所以整体外框得靠**按行位置分工**拼出来:
|
||||
* 首行 → 上边 + 左右;中间行 → 只有左右;末行 → 下边 + 左右。
|
||||
* 每行还有 3 种底色,故 3 底色 × 4 位置(含「只有 1 行」的四边全画)= 12 个 xf。
|
||||
* 若改成「每行四边全画」,相邻行之间的红线会叠成红色网格,视觉上不再是「框选」。
|
||||
*
|
||||
* @param {string} xml styles.xml 原文
|
||||
* @returns {{ xml: string, style: Object }} style 含 header/green/yellow/red 与 box[位置][颜色]
|
||||
*/
|
||||
function patchStyles(xml) {
|
||||
const fillsRe = /<fills\b([^>]*)>([\s\S]*?)<\/fills>/;
|
||||
const fontsRe = /<fonts\b([^>]*)>([\s\S]*?)<\/fonts>/;
|
||||
const bordersRe = /<borders\b([^>]*)>([\s\S]*?)<\/borders>/;
|
||||
const xfsRe = /<cellXfs\b([^>]*)>([\s\S]*?)<\/cellXfs>/;
|
||||
|
||||
const fillsM = fillsRe.exec(xml);
|
||||
const fontsM = fontsRe.exec(xml);
|
||||
const bordersM = bordersRe.exec(xml);
|
||||
const xfsM = xfsRe.exec(xml);
|
||||
if (!fillsM) throw new Error('styles.xml 缺少 <fills>');
|
||||
if (!fontsM) throw new Error('styles.xml 缺少 <fonts>');
|
||||
if (!bordersM) throw new Error('styles.xml 缺少 <borders>');
|
||||
if (!xfsM) throw new Error('styles.xml 缺少 <cellXfs>');
|
||||
|
||||
const baseFillCount = countChildren(fillsM[2], 'fill');
|
||||
const baseFontCount = countChildren(fontsM[2], 'font');
|
||||
const baseBorderCount = countChildren(bordersM[2], 'border');
|
||||
const baseXfCount = countChildren(xfsM[2], 'xf');
|
||||
if (baseFillCount < 2) throw new Error(`styles.xml fills 基线异常(${baseFillCount})`);
|
||||
if (baseFontCount < 1) throw new Error(`styles.xml fonts 基线异常(${baseFontCount})`);
|
||||
if (baseXfCount < 1) throw new Error(`styles.xml cellXfs 基线异常(${baseXfCount})`);
|
||||
|
||||
// ---- fills:表头灰 + 三档底色 ----
|
||||
const solid = rgb => `<fill><patternFill patternType="solid"><fgColor rgb="${rgb}"/><bgColor indexed="64"/></patternFill></fill>`;
|
||||
const addedFills = [HEADER_RGB, ...BANDS.map(b => b.rgb)].map(solid).join('');
|
||||
const fHeader = baseFillCount;
|
||||
const fBand = {};
|
||||
BANDS.forEach((b, i) => { fBand[b.key] = baseFillCount + 1 + i; });
|
||||
|
||||
let out = xml.replace(fillsM[0], () =>
|
||||
`<fills count="${baseFillCount + 1 + BANDS.length}">${fillsM[2]}${addedFills}</fills>`);
|
||||
|
||||
// ---- fonts:表头加粗 ----
|
||||
const boldFontId = baseFontCount;
|
||||
const boldFont = '<font><b/><sz val="12"/><color theme="1"/><name val="Calibri"/><family val="2"/><scheme val="minor"/></font>';
|
||||
out = out.replace(fontsM[0], () =>
|
||||
`<fonts count="${baseFontCount + 1}">${fontsM[2]}${boldFont}</fonts>`);
|
||||
|
||||
// ---- borders:4 种红框(顺序必须是 left/right/top/bottom/diagonal)----
|
||||
const side = name => `<${name} style="medium"><color rgb="${RED_BORDER_RGB}"/></${name}>`;
|
||||
const empty = name => `<${name}/>`;
|
||||
const borderXml = (top, bottom) =>
|
||||
`<border>${side('left')}${side('right')}${top ? side('top') : empty('top')}${bottom ? side('bottom') : empty('bottom')}<diagonal/></border>`;
|
||||
|
||||
// 位置 → [是否有上边, 是否有下边]
|
||||
const BOX_POS = {
|
||||
single: [true, true], // 前 N 名只有 1 行时,这一个格子四边都要有
|
||||
top: [true, false],
|
||||
mid: [false, false],
|
||||
bot: [false, true],
|
||||
};
|
||||
const BOX_KEYS = ['single', 'top', 'mid', 'bot'];
|
||||
const addedBorders = BOX_KEYS.map(k => borderXml(BOX_POS[k][0], BOX_POS[k][1])).join('');
|
||||
const bId = {};
|
||||
BOX_KEYS.forEach((k, i) => { bId[k] = baseBorderCount + i; });
|
||||
|
||||
out = out.replace(bordersM[0], () =>
|
||||
`<borders count="${baseBorderCount + BOX_KEYS.length}">${bordersM[2]}${addedBorders}</borders>`);
|
||||
|
||||
// ---- cellXfs ----
|
||||
const xf = (fillId, opt) => {
|
||||
const o = opt || {};
|
||||
const borderId = o.borderId == null ? 0 : o.borderId;
|
||||
const attrs = [
|
||||
'numFmtId="0"',
|
||||
`fontId="${o.bold ? boldFontId : 0}"`,
|
||||
`fillId="${fillId}"`,
|
||||
`borderId="${borderId}"`,
|
||||
'xfId="0"',
|
||||
];
|
||||
if (fillId !== 0) attrs.push('applyFill="1"');
|
||||
if (o.bold) attrs.push('applyFont="1"');
|
||||
if (borderId !== 0) attrs.push('applyBorder="1"');
|
||||
return `<xf ${attrs.join(' ')}/>`;
|
||||
};
|
||||
|
||||
const addedXfs = [xf(fHeader, { bold: true })];
|
||||
// 纯底色(不在前 N 名里的行)
|
||||
BANDS.forEach(b => addedXfs.push(xf(fBand[b.key])));
|
||||
// 前 N 名:底色 × 位置
|
||||
const xfIndex = {};
|
||||
BOX_KEYS.forEach(k => {
|
||||
xfIndex[k] = {};
|
||||
BANDS.forEach(b => {
|
||||
xfIndex[k][b.key] = baseXfCount + addedXfs.length;
|
||||
addedXfs.push(xf(fBand[b.key], { borderId: bId[k] }));
|
||||
});
|
||||
});
|
||||
|
||||
const style = {
|
||||
header: baseXfCount,
|
||||
box: xfIndex,
|
||||
};
|
||||
BANDS.forEach(b => { style[b.key] = baseXfCount + 1 + BANDS.indexOf(b); });
|
||||
|
||||
out = out.replace(xfsM[0], () =>
|
||||
`<cellXfs count="${baseXfCount + addedXfs.length}">${xfsM[2]}${addedXfs.join('')}</cellXfs>`);
|
||||
|
||||
return { xml: out, style };
|
||||
}
|
||||
|
||||
// ==================== sheetData 重建 ====================
|
||||
|
||||
function rowXml(r, cells) {
|
||||
return `<row r="${r}">${cells.join('')}</row>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成完整的 sheetData(含表头行、数据行、空行、统计块)。
|
||||
*
|
||||
* 行号布局:
|
||||
* 1 表头
|
||||
* 2 .. 1+N 数据(N = 小组数;N=0 时数据区为空)
|
||||
* 2+N 空行
|
||||
* 3+N 统计表头(区间统计 / 全量小组 / 前 K 名(总排名))
|
||||
* 4+N .. 6+N 三个区间
|
||||
* 7+N 合计
|
||||
* 8+N 口径说明(一行文字,解释「前 K 名」是全量跨区间截取)
|
||||
*
|
||||
* @returns {{ xml:string, lastRow:number }}
|
||||
*/
|
||||
function buildSheetData(rows, stats, style, fullScore) {
|
||||
const out = [];
|
||||
const R = k => k;
|
||||
|
||||
// 表头
|
||||
out.push(rowXml(R(1), HEADERS.map((h, i) => cellXml(DATA_COLS[i], 1, style.header, h))));
|
||||
|
||||
// 数据行(6 列:名次/小组编号/学校/参赛选手/人数/客观题分)
|
||||
// 🔴 底色取值按 examScore 判定 —— 与排序键同源,否则「分数高低」与「颜色」矛盾
|
||||
// 🔴 前 N 行额外套红框:红框范围与统计块「前N名」列**同一口径**(排序后前 N 行),
|
||||
// 保证「框住的」和「统计里算的」永远一致,不会一个框 80 行、一个算 80 组。
|
||||
const boxCount = Math.min(stats.topN, rows.length);
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = i + 2;
|
||||
const row = rows[i];
|
||||
const band = scoreBand(row.examScore);
|
||||
let s;
|
||||
if (i < boxCount) {
|
||||
// 首行画上边、末行画下边、中间只画左右 ⇒ 拼成一个整体外框(行间无横线)
|
||||
const pos = boxCount === 1 ? 'single' : (i === 0 ? 'top' : (i === boxCount - 1 ? 'bot' : 'mid'));
|
||||
s = style.box[pos][band];
|
||||
} else {
|
||||
s = style[band];
|
||||
}
|
||||
out.push(rowXml(r, [
|
||||
cellXml('A', r, s, row.rank),
|
||||
cellXml('B', r, s, row.teamId),
|
||||
cellXml('C', r, s, row.org),
|
||||
cellXml('D', r, s, row.players),
|
||||
cellXml('E', r, s, row.memberCount),
|
||||
cellXml('F', r, s, row.examScore),
|
||||
]));
|
||||
}
|
||||
|
||||
const n = rows.length;
|
||||
const blankRow = n + 2;
|
||||
const statHeadRow = n + 3;
|
||||
|
||||
// 统计块表头
|
||||
// 🔴 列标题曾写「其中前80名」,被用户读成「各个区间其中前80名」(R220 反馈)。
|
||||
// 实际口径是**全量排名**前 N,故列名改为「前N名(总排名)」消除歧义。
|
||||
out.push(rowXml(statHeadRow, [
|
||||
cellXml('A', statHeadRow, style.header, '区间统计'),
|
||||
cellXml('B', statHeadRow, style.header, '全量小组'),
|
||||
cellXml('C', statHeadRow, style.header, `前${stats.topN}名(总排名)`),
|
||||
]));
|
||||
|
||||
// 三个区间(A 列带该区间的底色,作为图例)
|
||||
BANDS.forEach((b, i) => {
|
||||
const r = statHeadRow + 1 + i;
|
||||
out.push(rowXml(r, [
|
||||
cellXml('A', r, style[b.key], b.label),
|
||||
cellXml('B', r, undefined, stats.all[b.key]),
|
||||
cellXml('C', r, undefined, stats.top[b.key]),
|
||||
]));
|
||||
});
|
||||
|
||||
// 合计
|
||||
const sumRow = statHeadRow + 1 + BANDS.length;
|
||||
out.push(rowXml(sumRow, [
|
||||
cellXml('A', sumRow, style.header, '合计'),
|
||||
cellXml('B', sumRow, undefined, stats.total),
|
||||
cellXml('C', sumRow, undefined, stats.topTotal),
|
||||
]));
|
||||
|
||||
// 口径说明行(无底色,A 列文字自然溢出到右侧空列)
|
||||
const noteRow = sumRow + 1;
|
||||
out.push(rowXml(noteRow, [
|
||||
cellXml('A', noteRow, undefined, buildScopeNote(stats, fullScore)),
|
||||
]));
|
||||
|
||||
return { xml: `<sheetData>${out.join('')}</sheetData>`, lastRow: noteRow, blankRow };
|
||||
}
|
||||
|
||||
// ==================== 组装 ====================
|
||||
|
||||
/** 造一个最小合法 xlsx 骨架(表头一行的空表),后续全部靠 zip 层替换 */
|
||||
function buildBaseBuffer(sheetName) {
|
||||
const ws = XLSX.utils.aoa_to_sheet([HEADERS.slice()]);
|
||||
ws['!cols'] = COL_WIDTHS.map(c => ({ wch: c.wch }));
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, String(sheetName || 'Sheet1').slice(0, 31));
|
||||
return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
|
||||
}
|
||||
|
||||
/** 从 zip 条目里取某文件文本 */
|
||||
function textOf(entries, name) {
|
||||
const e = entries.find(x => x.name === name);
|
||||
if (!e) throw new Error(`xlsx 缺少条目 ${name}`);
|
||||
return entryText(e);
|
||||
}
|
||||
|
||||
/**
|
||||
* 主入口:生成成绩排名表 xlsx。
|
||||
*
|
||||
* @param {Object} opt
|
||||
* @param {Array<Object>} opt.teams 原始小组数组(未经排序)
|
||||
* @param {string} [opt.sheetName] 工作表名
|
||||
* @param {number} [opt.topN=80] 统计口径里的「前 N 名」
|
||||
* @returns {{ buffer: Buffer, colored: boolean, rows: Array<Object>, stats: Object, lastRow: number }}
|
||||
* colored=false 表示上色链路失败已自动降级为无色版
|
||||
*/
|
||||
function buildXlsx(opt) {
|
||||
const options = opt || {};
|
||||
const rows = buildRankRows(options.teams);
|
||||
const topN = options.topN == null ? DEFAULT_TOP_N : options.topN;
|
||||
const fullScore = options.fullScore == null ? DEFAULT_FULL_SCORE : options.fullScore;
|
||||
const stats = buildStats(rows, topN);
|
||||
const sheetName = options.sheetName || '成绩排名';
|
||||
const base = buildBaseBuffer(sheetName);
|
||||
|
||||
// ---- 第一路:带底色 ----
|
||||
try {
|
||||
const entries = readZip(base);
|
||||
|
||||
const stylesPatched = patchStyles(textOf(entries, 'xl/styles.xml'));
|
||||
let out = replaceEntryText(entries, 'xl/styles.xml', stylesPatched.xml);
|
||||
|
||||
const built = buildSheetData(rows, stats, stylesPatched.style, fullScore);
|
||||
let sheetXml = textOf(out, 'xl/worksheets/sheet1.xml');
|
||||
|
||||
const sdRe = /<sheetData\b[^>]*>[\s\S]*?<\/sheetData>/;
|
||||
if (!sdRe.test(sheetXml)) throw new Error('sheet1.xml 缺少 <sheetData>');
|
||||
sheetXml = sheetXml.replace(sdRe, built.xml);
|
||||
|
||||
// dimension 必须同步,否则 Excel 的滚动区/打印区按旧范围截断
|
||||
sheetXml = sheetXml.replace(
|
||||
/<dimension\b[^>]*\bref="[^"]*"/,
|
||||
`<dimension ref="A1:${DATA_COLS[DATA_COLS.length - 1]}${built.lastRow}"`
|
||||
);
|
||||
|
||||
// 骨架带出来的 ignoredErrors 指向 A1:H1,范围已失真,直接删掉避免误报
|
||||
sheetXml = sheetXml.replace(/<ignoredErrors\b[^>]*\/>/g, '')
|
||||
.replace(/<ignoredErrors\b[^>]*>[\s\S]*?<\/ignoredErrors>/g, '');
|
||||
|
||||
out = replaceEntryText(out, 'xl/worksheets/sheet1.xml', sheetXml);
|
||||
|
||||
const buffer = writeZip(out);
|
||||
selfCheck(buffer, rows, stats, true);
|
||||
|
||||
return { buffer, colored: true, rows, stats, lastRow: built.lastRow };
|
||||
} catch (err) {
|
||||
// ---- 第二路:降级无色 ----
|
||||
const buffer = buildPlain(rows, stats, sheetName, fullScore);
|
||||
selfCheck(buffer, rows, stats, false);
|
||||
return {
|
||||
buffer,
|
||||
colored: false,
|
||||
rows,
|
||||
stats,
|
||||
lastRow: rows.length + 8,
|
||||
degradeReason: err && err.message ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** 降级版:不上任何底色,但数据/表头/统计块与带色版完全一致 */
|
||||
function buildPlain(rows, stats, sheetName, fullScore) {
|
||||
const aoa = [HEADERS.slice()];
|
||||
for (const row of rows) {
|
||||
aoa.push([row.rank, row.teamId, row.org, row.players, row.memberCount, row.examScore]);
|
||||
}
|
||||
aoa.push([]);
|
||||
aoa.push(['区间统计', '全量小组', `前${stats.topN}名(总排名)`]);
|
||||
for (const b of BANDS) aoa.push([b.label, stats.all[b.key], stats.top[b.key]]);
|
||||
aoa.push(['合计', stats.total, stats.topTotal]);
|
||||
aoa.push([buildScopeNote(stats, fullScore)]);
|
||||
|
||||
const ws = XLSX.utils.aoa_to_sheet(aoa);
|
||||
ws['!cols'] = COL_WIDTHS.map(c => ({ wch: c.wch }));
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, String(sheetName || '成绩排名').slice(0, 31));
|
||||
return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 自检:回读产物,确认「能读通 + 行数对 + 首行值对 +(带色版)styles 里真有底色」。
|
||||
* 任一条不满足就抛错,由上层降级。
|
||||
*/
|
||||
function selfCheck(buffer, rows, stats, expectColor) {
|
||||
const wb = XLSX.read(buffer, { type: 'buffer' });
|
||||
const name = wb.SheetNames[0];
|
||||
const ws = wb.Sheets[name];
|
||||
if (!ws) throw new Error('自检失败:产物无工作表');
|
||||
const aoa = XLSX.utils.sheet_to_json(ws, { header: 1, blankrows: true });
|
||||
if (!aoa.length) throw new Error('自检失败:产物为空');
|
||||
|
||||
const head = aoa[0] || [];
|
||||
for (let i = 0; i < HEADERS.length; i++) {
|
||||
if (String(head[i] == null ? '' : head[i]) !== HEADERS[i]) {
|
||||
throw new Error(`自检失败:表头第 ${i + 1} 列应为「${HEADERS[i]}」,实为「${head[i]}」`);
|
||||
}
|
||||
}
|
||||
|
||||
// 数据区行数
|
||||
const dataRows = aoa.slice(1, 1 + rows.length);
|
||||
if (dataRows.length !== rows.length) {
|
||||
throw new Error(`自检失败:数据行数 ${dataRows.length} ≠ 期望 ${rows.length}`);
|
||||
}
|
||||
// 首行关键值抽查(名次 / 小组编号 / 客观题分)
|
||||
if (rows.length) {
|
||||
const first = dataRows[0] || [];
|
||||
if (String(first[0]) !== String(rows[0].rank)) throw new Error('自检失败:首行名次不符');
|
||||
if (String(first[1]) !== String(rows[0].teamId)) throw new Error('自检失败:首行小组编号不符');
|
||||
if (Number(first[5]) !== Number(rows[0].examScore)) throw new Error('自检失败:首行客观题分不符');
|
||||
}
|
||||
|
||||
// 🔴 列数锁死:本表只出 6 列,「大题分」「总分」不得回流(R220-C 用户裁定)
|
||||
if (HEADERS.length !== 6) throw new Error(`自检失败:表头列数应为 6,实为 ${HEADERS.length}`);
|
||||
for (let i = 6; i < head.length; i++) {
|
||||
if (String(head[i] == null ? '' : head[i]).trim() !== '') {
|
||||
throw new Error(`自检失败:第 ${i + 1} 列应不存在,实为「${head[i]}」`);
|
||||
}
|
||||
}
|
||||
// 排序/名次必须由客观题分驱动:逐行校验「客观题分不得逆序」
|
||||
for (let i = 1; i < rows.length; i++) {
|
||||
if (rows[i - 1].examScore < rows[i].examScore) {
|
||||
throw new Error(`自检失败:第 ${i} 行客观题分逆序(${rows[i - 1].examScore} < ${rows[i].examScore})`);
|
||||
}
|
||||
}
|
||||
// 统计块
|
||||
const statHead = aoa[1 + rows.length + 1] || [];
|
||||
if (String(statHead[0] || '') !== '区间统计') throw new Error('自检失败:统计块表头缺失');
|
||||
|
||||
const expectC = `前${stats.topN}名(总排名)`;
|
||||
if (String(statHead[2] || '') !== expectC) {
|
||||
throw new Error(`自检失败:统计块 C 列表头应为「${expectC}」,实为「${statHead[2]}」`);
|
||||
}
|
||||
// 🔴 口径自证:「前N名」列合计必须 = min(总数, N)。
|
||||
// 若被误实现成「各分数区间分别取前 N 个」,总数 > N 时该值会超 N ⇒ 此处直接红。
|
||||
const expectTopTotal = Math.min(stats.total, stats.topN);
|
||||
if (stats.topTotal !== expectTopTotal) {
|
||||
throw new Error(
|
||||
`自检失败:前${stats.topN}名合计 ${stats.topTotal} ≠ min(总数 ${stats.total}, ${stats.topN})`
|
||||
);
|
||||
}
|
||||
const topSum = stats.top.green + stats.top.yellow + stats.top.red;
|
||||
if (topSum !== stats.topTotal) {
|
||||
throw new Error(`自检失败:三区间前N名之和 ${topSum} ≠ 合计 ${stats.topTotal}`);
|
||||
}
|
||||
for (const b of BANDS) {
|
||||
if (stats.top[b.key] > stats.all[b.key]) {
|
||||
throw new Error(`自检失败:${b.label} 的「前N名」数 ${stats.top[b.key]} 超过全量数 ${stats.all[b.key]}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (expectColor) {
|
||||
const entries = readZip(buffer);
|
||||
const styles = textOf(entries, 'xl/styles.xml');
|
||||
for (const b of BANDS) {
|
||||
if (styles.indexOf(b.rgb) < 0) throw new Error(`自检失败:styles.xml 缺底色 ${b.rgb}`);
|
||||
}
|
||||
const sheetXml = textOf(entries, 'xl/worksheets/sheet1.xml');
|
||||
if (!/\bs="\d+"/.test(sheetXml)) throw new Error('自检失败:sheet1.xml 无任何样式引用');
|
||||
|
||||
// ---- 红框校验(R220-D):从产物反推,不依赖内存里的 style 索引 ----
|
||||
// 先找「含红色 + medium 线宽」的 border 定义,再看哪些 xf 引用了它们,
|
||||
// 最后逐行核对「前 min(topN, 行数) 行必须是红框 xf,其余行必须不是」。
|
||||
const bordersBlock = /<borders\b[^>]*>([\s\S]*?)<\/borders>/.exec(styles);
|
||||
const borderList = bordersBlock ? (bordersBlock[1].match(/<border\b[\s\S]*?<\/border>/g) || []) : [];
|
||||
const redBorderIds = new Set();
|
||||
borderList.forEach((b, i) => {
|
||||
if (b.indexOf(RED_BORDER_RGB) >= 0 && /style="medium"/.test(b)) redBorderIds.add(i);
|
||||
});
|
||||
if (redBorderIds.size === 0) throw new Error('自检失败:styles.xml 里没有红色粗边框定义');
|
||||
|
||||
const xfsBlock = /<cellXfs\b[^>]*>([\s\S]*?)<\/cellXfs>/.exec(styles);
|
||||
const xfList = xfsBlock ? (xfsBlock[1].match(/<xf\b[^>]*\/>/g) || []) : [];
|
||||
const boxXfIds = new Set();
|
||||
xfList.forEach((x, i) => {
|
||||
const mm = /borderId="(\d+)"/.exec(x);
|
||||
if (mm && redBorderIds.has(Number(mm[1]))) boxXfIds.add(i);
|
||||
});
|
||||
if (boxXfIds.size === 0) throw new Error('自检失败:没有 cellXfs 引用红色边框');
|
||||
|
||||
const expectBox = Math.min(stats.topN, rows.length);
|
||||
const styleAt = k => {
|
||||
const rm = new RegExp(`<row r="${k}">([\\s\\S]*?)</row>`).exec(sheetXml);
|
||||
const sm = rm ? /\bs="(\d+)"/.exec(rm[1]) : null;
|
||||
return sm ? Number(sm[1]) : -1;
|
||||
};
|
||||
for (let k = 0; k < rows.length; k++) {
|
||||
const sx = styleAt(k + 2);
|
||||
const hasBox = boxXfIds.has(sx);
|
||||
const want = k < expectBox;
|
||||
if (hasBox !== want) {
|
||||
throw new Error(
|
||||
`自检失败:第 ${k + 2} 行红框归属错误(应为「${want ? '有框' : '无框'}」,`
|
||||
+ `实为「${hasBox ? '有框' : '无框'}」,s=${sx},前 ${expectBox} 行应有框)`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { rows: aoa.length, sheetName: name };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
HEADERS,
|
||||
DATA_COLS,
|
||||
COL_WIDTHS,
|
||||
DEFAULT_TOP_N,
|
||||
DEFAULT_FULL_SCORE,
|
||||
BANDS,
|
||||
HEADER_RGB,
|
||||
RED_BORDER_RGB,
|
||||
toNum,
|
||||
round2,
|
||||
scoreBand,
|
||||
buildRankRows,
|
||||
buildStats,
|
||||
buildScopeNote,
|
||||
patchStyles,
|
||||
buildSheetData,
|
||||
buildXlsx,
|
||||
buildPlain,
|
||||
selfCheck,
|
||||
};
|
||||
287
app/lib/teacherBackfill.js
Normal file
287
app/lib/teacherBackfill.js
Normal file
@@ -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<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<Object>
|
||||
* }}
|
||||
*/
|
||||
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,
|
||||
};
|
||||
191
app/lib/team_folder.js
Normal file
191
app/lib/team_folder.js
Normal file
@@ -0,0 +1,191 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 小组作品目录命名 —— **单一 SoT**
|
||||
*
|
||||
* 【业务来源】R190 管理人员反馈:
|
||||
* 「参赛小组上传作品时由于他们的作品分为两个文件且会自行命名,所以进行评分时
|
||||
* 两个文件分开下载经常出现混乱,现在提出了想法,当参赛小组上传作品时,后台
|
||||
* 自动生成一个以他们小组编号命名的文件夹,将他们的作品存放在这个文件夹中。」
|
||||
*
|
||||
* 【本模块的唯一职责】把「一个小组」映射成一个**安全、可读、稳定**的目录名。
|
||||
* · 上传落盘(service/upload.js)与管理端打包(service/admin.js)**必须**都调这里
|
||||
* —— 否则「上传写进 A 目录、下载从 B 目录找」的错位会在上线后立刻炸。
|
||||
* · 前端**不参与**命名,只展示后端回传的 folderName / teamNumber。
|
||||
*
|
||||
* 🔴 与既有口径的关系(⛔ 不得另立第二套):
|
||||
* 小组编号的展示值沿用 `service/admin.js#resolveTeamLabels` 的
|
||||
* `teams.team_number || teams.team_id`;本模块只负责把它**变成合法目录名**,
|
||||
* 不重新定义「哪个字段算编号」。
|
||||
*
|
||||
* 🔴 为什么必须净化:目录名直接参与 `fs.mkdir` / `path.join`。
|
||||
* `team_number` 来自报名表导入,历史上出现过带 `/` `\` 的值会被当成路径分隔符
|
||||
* ⇒ 文件落到意料之外的目录(甚至穿越到 works 之外)。故:
|
||||
* · 替换 Windows 非法字符 `\ / : * ? " < > |` 与控制字符
|
||||
* · 去掉首尾空白与首尾点(Windows 不允许尾点,且 `..` 是穿越向量)
|
||||
* · 限长 60 字符(避免超过单段 255 字节上限,也给中文留余量)
|
||||
*/
|
||||
|
||||
/** 单段目录名/文件名主体的最大长度(字符数,非字节) */
|
||||
const MAX_SEGMENT = 60;
|
||||
|
||||
/** 非法字符:Windows 保留字符 + 控制字符 */
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const ILLEGAL_CHARS = /[\\/:*?"<>|\u0000-\u001f\u007f]/g;
|
||||
|
||||
/** Windows 设备保留名(不区分大小写,含扩展名前的部分) */
|
||||
const RESERVED_NAMES = new Set([
|
||||
'con', 'prn', 'aux', 'nul',
|
||||
'com0', 'com1', 'com2', 'com3', 'com4', 'com5', 'com6', 'com7', 'com8', 'com9',
|
||||
'lpt0', 'lpt1', 'lpt2', 'lpt3', 'lpt4', 'lpt5', 'lpt6', 'lpt7', 'lpt8', 'lpt9',
|
||||
]);
|
||||
|
||||
/**
|
||||
* 净化单个路径段(目录名或文件名主体)。
|
||||
*
|
||||
* @param {*} raw - 原始值(任意类型,null/undefined 视为空)
|
||||
* @param {string} [fallback] - 净化后为空时的兜底值
|
||||
* @returns {string} 安全的单段名称
|
||||
*/
|
||||
function sanitizeSegment(raw, fallback = '未命名') {
|
||||
let s = String(raw === null || raw === undefined ? '' : raw);
|
||||
s = s.replace(ILLEGAL_CHARS, '_');
|
||||
// 折叠空白(含全角空格转半角后折叠),去掉首尾空白
|
||||
s = s.replace(/\u3000/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
// 去掉首部点(`.` / `..` / `...foo` 都会被误判)与尾部点
|
||||
s = s.replace(/^\.+/, '').replace(/\.+$/, '').trim();
|
||||
if (s.length > MAX_SEGMENT) s = s.slice(0, MAX_SEGMENT).trim();
|
||||
if (!s) return fallback;
|
||||
// 保留名兜底:`CON` → `CON_`
|
||||
if (RESERVED_NAMES.has(s.toLowerCase())) return s + '_';
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* 净化一个**文件名**(保留扩展名,主体走 sanitizeSegment)。
|
||||
*
|
||||
* @param {*} raw - 原始文件名(可能带路径)
|
||||
* @param {string} [fallbackStem] - 主体为空时的兜底
|
||||
* @param {string} [fallbackExt] - 无扩展名时的兜底扩展名(含点,如 '.pdf')
|
||||
* @returns {string} 安全文件名
|
||||
*/
|
||||
function sanitizeFileName(raw, fallbackStem = '作品', fallbackExt = '') {
|
||||
const original = String(raw === null || raw === undefined ? '' : raw);
|
||||
// 只取 basename:同时切开 `/` 与 `\`(path.basename 在 Linux 上不切 `\`)
|
||||
const base = original.replace(/^.*[\\/]/, '');
|
||||
const dot = base.lastIndexOf('.');
|
||||
const hasExt = dot > 0 && dot < base.length - 1;
|
||||
const rawStem = hasExt ? base.slice(0, dot) : base;
|
||||
const rawExt = hasExt ? base.slice(dot + 1) : '';
|
||||
|
||||
// 扩展名只允许字母数字,最长 10 位;出现异常字符即视为无扩展名
|
||||
const ext = /^[A-Za-z0-9]{1,10}$/.test(rawExt) ? '.' + rawExt.toLowerCase() : fallbackExt;
|
||||
const stem = sanitizeSegment(rawStem, fallbackStem);
|
||||
return stem + ext;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析「小组作品目录名」—— 上传落盘与管理端打包共用的唯一入口。
|
||||
*
|
||||
* 三态优先级(与 `resolveTeamLabels` 的 teamNumber 口径对齐):
|
||||
* ① `teamNumber`(= `teams.team_number || teams.team_id`,人类可读编号,如 BRICS2026-ST-027-94001)
|
||||
* ② 无编号但有队伍归属 ⇒ `未编组_<teamUid>`(同队成员仍归到同一目录)
|
||||
* ③ 连队伍归属都没有(单人参赛 / 历史脏数据)⇒ `未编组_<aggregationUid>`(每人独立,防撞车)
|
||||
*
|
||||
* @param {Object} p
|
||||
* @param {?string} p.teamNumber - 人类可读小组编号
|
||||
* @param {?number} p.teamUid - 队伍号(组内唯一)
|
||||
* @param {?number} p.aggregationUid - 聚合报名 ID(最终兜底)
|
||||
* @returns {string} 安全的目录名(保证非空、无路径分隔符)
|
||||
*/
|
||||
function resolveTeamFolderName({ teamNumber, teamUid, aggregationUid } = {}) {
|
||||
const label = teamNumber === null || teamNumber === undefined ? '' : String(teamNumber).trim();
|
||||
if (label) return sanitizeSegment(label, '未编组');
|
||||
|
||||
if (teamUid !== null && teamUid !== undefined && String(teamUid).trim() !== '') {
|
||||
return sanitizeSegment(`未编组_${teamUid}`, '未编组');
|
||||
}
|
||||
if (aggregationUid !== null && aggregationUid !== undefined && String(aggregationUid).trim() !== '') {
|
||||
return sanitizeSegment(`未编组_${aggregationUid}`, '未编组');
|
||||
}
|
||||
return '未编组';
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼接「小组作品目录」的相对路径(相对 `app/public`,正斜杠)。
|
||||
*
|
||||
* @param {string} group - 组别
|
||||
* @param {string} folderName - resolveTeamFolderName 的返回值
|
||||
* @returns {string} 如 `works/benke/BRICS2026-ST-027-94001`
|
||||
*/
|
||||
function teamFolderRelative(group, folderName) {
|
||||
return ['works', sanitizeSegment(group, 'unknown'), sanitizeSegment(folderName, '未编组')]
|
||||
.join('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载文件名主体(不含扩展名)的字符上限。
|
||||
*
|
||||
* 🔴 为什么要有上限:下载名 = 小组编号(≤60) + 姓名(≤60) + 原文件名(≤120),
|
||||
* 三者直接拼接可到 240 字符。Windows 资源管理器对单文件名上限 255 **字符**
|
||||
* (NTFS 255 UTF-16 单元),240 已贴边,再加上浏览器自动加的 `(1)` 后缀就会失败。
|
||||
* 故统一收敛到 150,保证「编号 + 姓名 + 关键文件名」都能看见,又不触系统上限。
|
||||
*/
|
||||
const MAX_DOWNLOAD_NAME = 150;
|
||||
|
||||
/**
|
||||
* 拼接「管理端下载文件名」—— 中职/高职大题(Word)单个下载与批量 ZIP 包内名共用。
|
||||
*
|
||||
* 格式:`<小组编号>-<姓名>-<原文件名>`(R195 需求:小组编号放最前)
|
||||
* 例:`BRICS2026-ST-027-94001-张三-大题作答.docx`
|
||||
*
|
||||
* 🔴 单一口径:小组编号三态(teamNumber → `未编组_<teamUid>` → `未编组_<aggUid>`)
|
||||
* 完全复用 resolveTeamFolderName,与本组上传落盘目录名**必然一致**
|
||||
* ⇒ 管理员把下载文件解压回工作目录时,文件名前缀可直接对上作品目录。
|
||||
* ⛔ 不得在 controller / service 各拼一套(尤其是「有队伍但 teams 表缺元信息」这一档,
|
||||
* 两处拼法分叉后会出现「同一个人下载出两个不一样的前缀」)。
|
||||
*
|
||||
* @param {Object} p
|
||||
* @param {?string} p.teamNumber - teams.team_number || teams.team_id
|
||||
* @param {?number} p.teamUid - 队伍号(组内唯一)
|
||||
* @param {?number} p.aggregationUid - 聚合报名 ID(最终兜底,防多人同名前缀重号)
|
||||
* @param {?string} p.personName - 选手姓名(缺省则跳过该段)
|
||||
* @param {?string} p.originalName - 原文件名(选手上传名)
|
||||
* @param {string} [p.fallbackStem] - 原文件名为空时的兜底主体
|
||||
* @returns {string} 安全文件名(非空、无路径分隔符、长度受限)
|
||||
*/
|
||||
function buildTeamDownloadName({
|
||||
teamNumber,
|
||||
teamUid,
|
||||
aggregationUid,
|
||||
personName,
|
||||
originalName,
|
||||
fallbackStem = '大题作答',
|
||||
} = {}) {
|
||||
const prefix = resolveTeamFolderName({ teamNumber, teamUid, aggregationUid });
|
||||
const person = personName === null || personName === undefined
|
||||
? ''
|
||||
: sanitizeSegment(personName, '');
|
||||
const base = sanitizeFileName(originalName, fallbackStem);
|
||||
|
||||
// 拆扩展名:截断只能砍主体,⛔ 不能把 `.docx` 砍掉(砍掉后浏览器会存成未知类型)
|
||||
const dot = base.lastIndexOf('.');
|
||||
const stem = dot > 0 ? base.slice(0, dot) : base;
|
||||
const ext = dot > 0 ? base.slice(dot) : '';
|
||||
|
||||
const head = [prefix, person].filter(Boolean).join('-');
|
||||
// 预留 1 个字符给分隔符 '-'
|
||||
const room = MAX_DOWNLOAD_NAME - head.length - ext.length - 1;
|
||||
const finalStem = stem.length > room ? stem.slice(0, Math.max(1, room)).trim() : stem;
|
||||
return `${head}-${finalStem}${ext}`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MAX_SEGMENT,
|
||||
MAX_DOWNLOAD_NAME,
|
||||
sanitizeSegment,
|
||||
sanitizeFileName,
|
||||
resolveTeamFolderName,
|
||||
teamFolderRelative,
|
||||
buildTeamDownloadName,
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
// 继续执行后续中间件和控制器
|
||||
|
||||
@@ -18,7 +18,29 @@
|
||||
// file_io_guard 限制「同时进行的文件流数(按流生命周期释放)」(防 fd/事件循环耗尽)。
|
||||
// 双保险,使单 worker 在极端并发下「降级(429)而非崩溃」。
|
||||
|
||||
// 🔴 单次流式上传(2026-09-29 · R43 补齐,原为**零护栏**)
|
||||
//
|
||||
// 背景:R42 排查 C5(作品上传错峰)时发现 —— **在用的**上传端点 `POST /api/upload/:group`
|
||||
// (router.js:101 → controller/upload.js 的 upload())**不在本表**,因此它既不经过本闸门,
|
||||
// 当时也未调用 file_io_guard ⇒ 真正承载考生作品上传的唯一路径**完全无护栏**。
|
||||
// 同表内 `chunk`/`merge` 反倒是**前端已无调用点的死路由**(Upload.vue 于 B2 删除后
|
||||
// 失去唯一调用方,见 api/upload.js 的死代码清理),护栏加在了无人走的路上。
|
||||
//
|
||||
// 性质判定:单次上传是**重 IO**(长连接 + 大文件流式落盘 + 边写边算 md5),
|
||||
// 与 chunk/merge 同类,故**共享同一配额 MAX**(不另开独立上限)——
|
||||
// 配额的意义是「让 worker 有余力 accept 新连接」(见本文件顶部背景),
|
||||
// 三者共享 20 比各自开 20 更能守住这个目的。
|
||||
//
|
||||
// ⚠️ 正则为何必须是**单段** `[^/]+$`(勿"顺手"放宽成 `/^\/api\/upload\//i`):
|
||||
// 同一前缀下还有三条**轻**路由,路径同前缀但**多一段**:
|
||||
// · GET /api/upload/:group/status (查状态,轻)
|
||||
// · GET /api/upload/:group/submissions (列表,轻)
|
||||
// · DELETE /api/upload/:group/:id (删作品,轻)
|
||||
// `[^/]+$` 要求「/api/upload/」之后**恰好一段且到串尾**,故上面三条(两段)**天然不命中**;
|
||||
// 放宽成前缀匹配会把它们一并计入配额,在考生端表现为「查状态也 429」——语义错误。
|
||||
// 本表的 `chunk`/`merge` 同理带 `$` 锚定(既有写法,勿动)。
|
||||
const HEAVY_PATTERNS = [
|
||||
/^\/api\/upload\/[^/]+$/i,
|
||||
/^\/api\/upload\/[^/]+\/chunk$/i,
|
||||
/^\/api\/upload\/[^/]+\/merge$/i,
|
||||
/^\/api\/exam\/[^/]+\/word$/i,
|
||||
@@ -26,19 +48,172 @@ const HEAVY_PATTERNS = [
|
||||
/^\/api\/admin\/submissions\/[^/]+\/[^/]+\/word$/i,
|
||||
];
|
||||
|
||||
// 🔴 交卷闸门(2026-09-21 并发压测结论)
|
||||
//
|
||||
// 背景:submit-all 不在 HEAVY_PATTERNS 内,因此交卷高峰**完全无闸门**。压测实测:
|
||||
// 400 并发同刻交卷 → 约 20% 考生在 TCP accept 层被拒(客户端 WinError 10061),
|
||||
// 服务端零业务错误、零崩溃。根因是单 worker 的 accept 循环被重任务(一次交卷≈百次
|
||||
// DB 往返)饿死 → 内核 listen 队列溢出 → **Windows 在队列满时回 RST**
|
||||
// (Linux 是静默丢 SYN,所以本机现象与 Linux 经验相反,极易误判为「网络问题」)。
|
||||
//
|
||||
// 纳入闸门的意义:过载时由「静默 RST(考生不知道是否交上)」变为「明确 429
|
||||
// + Retry-After(可识别、可重试)」,配合前端交卷自动重试即可收敛;
|
||||
// 同时闸门在**读取请求体之前**拒绝,避免请求体缓冲进一步挤占内存。
|
||||
//
|
||||
// ⚠️ 交卷使用**独立上限**,不与上传共用 20:
|
||||
// - 交卷是纯 DB 重任务、无文件流,与上传的大文件 IO 特性不同,共用会过紧;
|
||||
// - 但上限的作用是「让 worker 有余力 accept 新连接」,**不是**提高吞吐 ——
|
||||
// 实测单 worker 硬容量约 15 交卷/秒,把上限开到几百只会让每个请求都变慢,
|
||||
// 反而更容易饿死 accept。默认 24 ≈ 略高于单 worker 舒适并发,形成良性背压。
|
||||
// - 多 worker 部署时总上限 = worker 数 × SUBMIT_REQ_MAX(进程内计数)。
|
||||
const SUBMIT_PATTERNS = [
|
||||
/^\/api\/exam\/[^/]+\/submit-all$/i,
|
||||
];
|
||||
|
||||
// 🔴 登录闸门(2026-09-21 并发压测**第二轮**结论)
|
||||
//
|
||||
// 背景:submit-all 已于同日纳入闸门(见上),但**登录链路当时仍无闸门** ——
|
||||
// 压测实测(每组 400 人 = 800 人同刻登录):
|
||||
// · 仅 484/800 取得 token → **39.5% 在 TCP accept 层被拒**(WinError 10061);
|
||||
// · 连接级重试 1134 次;
|
||||
// · **P50 = 9957ms、P95 = 24350ms** —— 考生点「登录」后要干等十秒以上,
|
||||
// 且近四成直接失败(客户端只看到「无法连接」,不知道是否要重试)。
|
||||
// 根因与交卷侧**同源**:单 worker 的 accept 循环被重任务饿死 → accept 队列溢出
|
||||
// → Windows 回 RST(Linux 静默丢 SYN,故 Linux 生产下表现为客户端长时间超时)。
|
||||
// 按「同源族一并修」原则纳入闸门。
|
||||
//
|
||||
// 为何登录需要**独立**上限(不与交卷共用 24):
|
||||
// - 登录是 **CPU 型**重任务:单请求的绝大部分成本是口令哈希比对
|
||||
// (BCRYPT_ROUNDS=10,见 extend/helper.js),另加 2~3 次 DB 往返
|
||||
// (findByPhone + isScorePublished);
|
||||
// - 交卷是 **IO 型**重任务(约 23 条 SQL 往返),等待 DB 期间可让位给 accept;
|
||||
// CPU 密集任务的换手粒度更粗,对 accept 的挤压更直接;
|
||||
// - 故默认取 16(低于交卷的 24)。上限的作用是**保命**(让 worker 有余力
|
||||
// accept 新连接),**不是**提速 —— 把上限开大只会让每个请求都变慢。
|
||||
//
|
||||
// 🔴 R8(2026-09-21)后的状态变化 —— 闸门保留,但**理由已改变**(勿照旧文本理解):
|
||||
// 口令哈希实现已由 `bcryptjs`(纯 JS,**同步阻塞主线程**)换为**原生 bcrypt**
|
||||
// (N-API,走 libuv 线程池)。实测:16 并发 compare 由 1129ms → 265ms(4.20×);
|
||||
// 改动前**心跳达成率 0%**(主线程被冻结 1.1s,期间无法 accept 任何连接),
|
||||
// 改动后心跳持续(环境实测见 probes/_bench_eventloop.cjs)。
|
||||
// ⇒ "accept 被 CPU 计算饿死"这一**原始**理由已大幅减弱,但闸门**仍然必要**:
|
||||
// ① libuv 线程池默认仅 **4** 线程,16 并发仍需排队;长时间满载会挤压**其它**
|
||||
// 走线程池的工作(DNS / fs / crypto)—— 闸门把排队**限界**,避免无界堆积;
|
||||
// ② 每个登录请求另有 2~3 次 DB 往返,是**连接池与 DB 的真实压力源**,
|
||||
// 与哈希是否阻塞事件循环**无关**;
|
||||
// ③ 过载时给出**可识别**的 429 + Retry-After,优于 TCP 层静默 RST
|
||||
// (考生至少知道"要重试"而非"不知道登没登上")。
|
||||
// ⛔ 因此**不要**因为"换了原生 bcrypt"就顺手删掉本闸门 —— 理由②③与哈希实现无关。
|
||||
// - 多 worker 部署时总上限 = worker 数 × LOGIN_REQ_MAX(进程内计数)。
|
||||
//
|
||||
// ⛔ 为何**不**把 /api/auth/admin-login 一并纳入(重要设计决策,勿"顺手"补上):
|
||||
// 管理后台登录是极低频瞬时动作(并发个位数),**从不构成 accept 压力源**,
|
||||
// 因此它不属"同源缺陷族"(缺陷族 = 同一根因 **且** 同一压力形态);
|
||||
// 而一旦纳入共用/独立计数,考生入场洪峰会挤占管理员配额 →
|
||||
// 制造出「考试期间管理员登不进后台」这一**新**故障面,得不偿失。
|
||||
// 其防爆破由 router 级 loginRateLimit 单独负责(该中间件同时挂在两条登录路由上)。
|
||||
//
|
||||
// 🔴 与 loginRateLimit 的执行顺序(本改动的关键红线,勿破坏):
|
||||
// 本中间件是**全局**中间件(config/config.default.js 的 config.middleware),
|
||||
// 而 loginRateLimit 是 **router 级**(router.js 挂在 /api/auth/login 单条路由上);
|
||||
// Egg 的请求链为「全局中间件 → router 匹配 → router 级中间件」,
|
||||
// 因此本闸门**先于** loginRateLimit 执行 —— 一旦在此处返回 429,
|
||||
// loginRateLimit 的 `await next()` **根本不会执行**,其"按失败计数"逻辑不生效。
|
||||
// 这是**必须**的性质:否则考生每被背压拒绝一次就被记一次失败,
|
||||
// 重试 10 次即撞上 15 分钟账号锁(业务码 4290)—— 那比「静默 RST」更糟(锁死)。
|
||||
// ⚠️ 后人若把本中间件降为 router 级、或把登录判断挪到 `await next()` 之后,
|
||||
// 会立刻破坏该性质。门禁 scripts/verify_login_guard.py 对此有专项断言。
|
||||
const LOGIN_PATTERNS = [
|
||||
/^\/api\/auth\/login$/i,
|
||||
];
|
||||
|
||||
module.exports = (options, app) => {
|
||||
const MAX = parseInt(process.env.HEAVY_REQ_MAX || (options && options.max) || '20', 10);
|
||||
const SUBMIT_MAX = parseInt(
|
||||
process.env.SUBMIT_REQ_MAX || (options && options.submitMax) || '24',
|
||||
10
|
||||
);
|
||||
const LOGIN_MAX = parseInt(
|
||||
process.env.LOGIN_REQ_MAX || (options && options.loginMax) || '16',
|
||||
10
|
||||
);
|
||||
|
||||
let active = 0;
|
||||
// 🔴 Retry-After 取值依据(2026-09-21 R8 实测,勿凭直觉调大)
|
||||
//
|
||||
// 原为**三处各自硬编码 `'2'`** 的魔数(无依据、无同源保证)。经实测给出依据:
|
||||
//
|
||||
// 一个「闸门满载窗口」的服务时间 = 并发上限 × 单请求 compare 耗时:
|
||||
// · 改动前 bcryptjs(纯 JS,主线程**串行**):16 × ~85ms ≈ 1.11 s
|
||||
// (实测 16 并发 compare = 1092~1133 ms,期间**心跳达成率 0%**,主线程完全冻结)
|
||||
// · 改动后 原生 bcrypt(N-API + libuv 线程池):实测 265 ms
|
||||
//
|
||||
// 客户端应在**一个满载窗口之后**再回来:窗口结束时槽位集中释放,命中概率最高;
|
||||
// 过早回来只会撞墙并制造无谓往返(臂2 实测登录侧请求 +14%、重试 +28%)。
|
||||
//
|
||||
// ⇒ 取 **2s**:≈1.8 个满载窗口(改动前)/ ≈7.5 个(改动后),足以覆盖一次拥挤周期,
|
||||
// 又不过长以免挤掉前端后续的指数退避重试机会(前端 cap 15s / 总窗口 ~45s)。
|
||||
//
|
||||
// ⛔ **不要盲目调大(如 5–8s)**:本闸门是「**立即拒绝**」(无排队),槽位是**连续释放**的,
|
||||
// 等待过久会**错过本可成功的时机**,并让总收敛时间超过前端重试窗口 ⇒ 反而更多失败。
|
||||
// ⛔ **也不要 < 1s**:退化为忙等,把连接级背压变成新的请求风暴。
|
||||
// ⛔ **三处必须同源同值**:登录/交卷/上传的差异只体现在**配额**(LOGIN_MAX / SUBMIT_MAX / MAX),
|
||||
// 不体现在等待时长上。历史上此值散落三处,改一处漏两处即产生"同源族漂移"。
|
||||
const RETRY_AFTER = '2';
|
||||
|
||||
let active = 0; // 大文件/重 IO 并发数
|
||||
let submitActive = 0; // 交卷并发数(与上者独立计数,互不挤占配额)
|
||||
let loginActive = 0; // 登录并发数(独立计数:登录为 CPU 型重任务,与上两者特性均不同)
|
||||
|
||||
return async function concurrencyGuard(ctx, next) {
|
||||
if (!HEAVY_PATTERNS.some((re) => re.test(ctx.path))) {
|
||||
const path = ctx.path;
|
||||
|
||||
// ---- 登录(独立配额,2026-09-21 第二轮压测新增)----
|
||||
if (LOGIN_PATTERNS.some((re) => re.test(path))) {
|
||||
if (loginActive >= LOGIN_MAX) {
|
||||
// 连接级背压:把「TCP 层静默 RST(考生不知是否登录成功)」换为
|
||||
// 「明确 429 + Retry-After(可识别、客户端可定时重试)」。
|
||||
// 注意:此处直接 return,故 router 级 loginRateLimit 不执行 → 不计失败次数。
|
||||
ctx.status = 429;
|
||||
ctx.set('Retry-After', RETRY_AFTER);
|
||||
ctx.body = ctx.helper.error(429, '服务器繁忙,请稍后重试(登录高峰)');
|
||||
return;
|
||||
}
|
||||
loginActive++;
|
||||
try {
|
||||
await next();
|
||||
} finally {
|
||||
loginActive--;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const isSubmit = SUBMIT_PATTERNS.some((re) => re.test(path));
|
||||
|
||||
if (isSubmit) {
|
||||
if (submitActive >= SUBMIT_MAX) {
|
||||
// 连接级背压:明确告知「服务器忙 + 多久后重试」,而非让请求堆积到 accept 队列溢出。
|
||||
ctx.status = 429;
|
||||
ctx.set('Retry-After', RETRY_AFTER);
|
||||
ctx.body = ctx.helper.error(429, '服务器繁忙,请稍后重试(交卷高峰)');
|
||||
return;
|
||||
}
|
||||
submitActive++;
|
||||
try {
|
||||
await next();
|
||||
} finally {
|
||||
submitActive--;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!HEAVY_PATTERNS.some((re) => re.test(path))) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (active >= MAX) {
|
||||
// 🔴 连接级背压:直接拒绝,避免请求体被解析/缓冲占用内存
|
||||
ctx.status = 429;
|
||||
ctx.set('Retry-After', RETRY_AFTER);
|
||||
ctx.body = ctx.helper.error(429, '服务器繁忙,请稍后重试');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
module.exports = app => {
|
||||
const { STRING, INTEGER, BOOLEAN, DATE } = app.Sequelize;
|
||||
|
||||
// 分数列统一走共享工厂(DECIMAL(6,2) + Number 兜底,见 app/lib/decimal_score.js)
|
||||
const decimalScore = require('../lib/decimal_score')(app);
|
||||
|
||||
const Account = app.model.define('account', {
|
||||
// 主键,自增
|
||||
uid: {
|
||||
@@ -66,7 +69,7 @@ module.exports = app => {
|
||||
unique: true,
|
||||
comment: '身份证号',
|
||||
},
|
||||
// 登录密码(SHA256 哈希存储)
|
||||
// 登录密码(bcrypt 加盐哈希存储;兼容存量 SHA256 旧哈希,登录成功后透明升级)
|
||||
password: {
|
||||
type: STRING(100),
|
||||
allowNull: false,
|
||||
@@ -128,12 +131,7 @@ module.exports = app => {
|
||||
comment: '账号激活状态',
|
||||
},
|
||||
// 总分
|
||||
score: {
|
||||
type: INTEGER,
|
||||
allowNull: true,
|
||||
defaultValue: null,
|
||||
comment: '账号总分',
|
||||
},
|
||||
score: decimalScore('score', '账号总分', { defaultValue: null }),
|
||||
// 记录创建时间
|
||||
created_at: {
|
||||
type: DATE,
|
||||
|
||||
@@ -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,
|
||||
|
||||
72
app/model/adminAuditLog.js
Normal file
72
app/model/adminAuditLog.js
Normal file
@@ -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;
|
||||
};
|
||||
@@ -78,6 +78,25 @@ module.exports = app => {
|
||||
defaultValue: null,
|
||||
comment: '驳回原因',
|
||||
},
|
||||
// 所属单位 / 学校名称(报名表原始来源,R84 续六新增声明)
|
||||
// ⚠️ 该列在运行库中**一直存在**(`SHOW COLUMNS FROM aggregation` 可见),
|
||||
// 只是模型漏声明 ⇒ 任何 `attributes: ['organization_cn']` 查询都会
|
||||
// 被 Sequelize 当作未知列。此处补齐仅为让模型与运行库一致,
|
||||
// 声明为 allowNull:true,不改变既有写入路径的语义。
|
||||
organization_cn: {
|
||||
type: STRING(200),
|
||||
allowNull: true,
|
||||
defaultValue: null,
|
||||
comment: '所属单位/学校名称',
|
||||
},
|
||||
// 参赛组别(zhongzhi/gaozhi/benke/jiaoshi)—— 同为运行库既有列,模型漏声明。
|
||||
// ⚠️ `group` 是 MySQL 保留字,Sequelize 会自动加反引号;手写 SQL 时须自行反引号。
|
||||
group: {
|
||||
type: STRING(20),
|
||||
allowNull: true,
|
||||
defaultValue: null,
|
||||
comment: '参赛组别',
|
||||
},
|
||||
// 记录创建时间
|
||||
created_at: {
|
||||
type: DATE,
|
||||
@@ -114,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, {
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
module.exports = app => {
|
||||
const { STRING, INTEGER, DATE } = app.Sequelize;
|
||||
|
||||
// 分数列统一走共享工厂(DECIMAL(6,2) + Number 兜底,见 app/lib/decimal_score.js)
|
||||
const decimalScore = require('../lib/decimal_score')(app);
|
||||
|
||||
const BenkeSubmission = app.model.define('benke_submission', {
|
||||
// 主键 ID,自增
|
||||
id: {
|
||||
@@ -71,12 +74,7 @@ module.exports = app => {
|
||||
comment: '上传时间',
|
||||
},
|
||||
// 评分
|
||||
score: {
|
||||
type: INTEGER,
|
||||
allowNull: true,
|
||||
defaultValue: null,
|
||||
comment: '评分',
|
||||
},
|
||||
score: decimalScore('score', '评分(可含 2 位小数)', { defaultValue: null }),
|
||||
// 记录创建时间
|
||||
created_at: {
|
||||
type: DATE,
|
||||
|
||||
92
app/model/bigQuestion.js
Normal file
92
app/model/bigQuestion.js
Normal file
@@ -0,0 +1,92 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 综合大题模型 - BigQuestion
|
||||
*
|
||||
* 对应 big_questions 表(2026-09-21 新增)。
|
||||
* 中职组(zhongzhi) / 高职组(gaozhi) 取消 Word 文件上传题后,改为从本表随机抽取一道大题:
|
||||
* 题干 = 文字描述 + 富文本表格(HTML),下设三小问,每小问 score_per_sub 分(默认 10),
|
||||
* 大题总分 total_score(默认 30)。
|
||||
*
|
||||
* 说明:本模型为**新增隔离模型**,与既有 word_questions(已弃用)互不影响,
|
||||
* 也不参与 choice_questions / judge_questions 客观题链路。
|
||||
*/
|
||||
module.exports = app => {
|
||||
const { STRING, INTEGER, DATE, TEXT } = app.Sequelize;
|
||||
|
||||
// 分数列统一走共享工厂(DECIMAL(6,2) + Number 兜底,见 app/lib/decimal_score.js)
|
||||
const decimalScore = require('../lib/decimal_score')(app);
|
||||
|
||||
return app.model.define('BigQuestion', {
|
||||
id: {
|
||||
type: INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
comment: '大题ID',
|
||||
},
|
||||
question_uid: {
|
||||
type: STRING(64),
|
||||
allowNull: false,
|
||||
comment: '大题稳定标识(全局唯一,形如 big_12)',
|
||||
},
|
||||
group: {
|
||||
type: STRING(20),
|
||||
allowNull: false,
|
||||
comment: '组别:zhongzhi/gaozhi',
|
||||
},
|
||||
title: {
|
||||
type: STRING(200),
|
||||
allowNull: false,
|
||||
comment: '大题标题',
|
||||
},
|
||||
description: {
|
||||
type: TEXT('medium'),
|
||||
allowNull: true,
|
||||
comment: '题干富文本 HTML(含表格)',
|
||||
},
|
||||
sub_prompt_1: {
|
||||
type: TEXT,
|
||||
allowNull: true,
|
||||
comment: '第1小问题干',
|
||||
},
|
||||
sub_prompt_2: {
|
||||
type: TEXT,
|
||||
allowNull: true,
|
||||
comment: '第2小问题干',
|
||||
},
|
||||
sub_prompt_3: {
|
||||
type: TEXT,
|
||||
allowNull: true,
|
||||
comment: '第3小问题干',
|
||||
},
|
||||
score_per_sub: decimalScore('score_per_sub', '每小题分值', { allowNull: false, defaultValue: 10 }),
|
||||
total_score: decimalScore('total_score', '大题总分', { allowNull: false, defaultValue: 30 }),
|
||||
sort_order: {
|
||||
type: INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
comment: '排序',
|
||||
},
|
||||
status: {
|
||||
type: INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 1,
|
||||
comment: '状态:1=启用(参与抽题),0=停用',
|
||||
},
|
||||
created_at: {
|
||||
type: DATE,
|
||||
defaultValue: app.Sequelize.NOW,
|
||||
comment: '创建时间',
|
||||
},
|
||||
updated_at: {
|
||||
type: DATE,
|
||||
defaultValue: app.Sequelize.NOW,
|
||||
comment: '更新时间',
|
||||
},
|
||||
}, {
|
||||
tableName: 'big_questions',
|
||||
timestamps: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at',
|
||||
});
|
||||
};
|
||||
97
app/model/bigQuestionAnswer.js
Normal file
97
app/model/bigQuestionAnswer.js
Normal file
@@ -0,0 +1,97 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 综合大题作答模型 - BigQuestionAnswer
|
||||
*
|
||||
* 对应 big_question_answers 表(2026-09-21 新增)。
|
||||
*
|
||||
* 一对一行:UNIQUE(aggregation_uid) —— 一个考生一行数据(用户明确要求的存储形态),
|
||||
* 同时与「逐题一行」的客观题表 zhongzhi_submissions / gaozhi_submissions 完全隔离。
|
||||
*
|
||||
* question_snapshot:抽题那一刻的题干快照(JSON 文本)。因 MariaDB 5.5 无 JSON 列类型,
|
||||
* 以 MEDIUMTEXT 存储,由应用层 JSON.stringify / JSON.parse 处理。
|
||||
*/
|
||||
module.exports = app => {
|
||||
const { STRING, INTEGER, DATE, TEXT } = app.Sequelize;
|
||||
|
||||
// 分数列统一走共享工厂(DECIMAL(6,2) + Number 兜底,见 app/lib/decimal_score.js)
|
||||
const decimalScore = require('../lib/decimal_score')(app);
|
||||
|
||||
return app.model.define('BigQuestionAnswer', {
|
||||
id: {
|
||||
type: INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
comment: '主键ID',
|
||||
},
|
||||
aggregation_uid: {
|
||||
type: INTEGER,
|
||||
allowNull: false,
|
||||
comment: '聚合报名ID(中职/高职组 == contestants.id),唯一 → 一考生一行',
|
||||
},
|
||||
contestant_id: {
|
||||
type: INTEGER,
|
||||
allowNull: true,
|
||||
comment: '选手ID(冗余,便于管理端查证与排障)',
|
||||
},
|
||||
group: {
|
||||
type: STRING(20),
|
||||
allowNull: false,
|
||||
comment: '组别:zhongzhi/gaozhi',
|
||||
},
|
||||
big_question_id: {
|
||||
type: INTEGER,
|
||||
allowNull: true,
|
||||
comment: '抽中的大题ID',
|
||||
},
|
||||
big_question_uid: {
|
||||
type: STRING(64),
|
||||
allowNull: true,
|
||||
comment: '抽中的大题稳定标识',
|
||||
},
|
||||
question_snapshot: {
|
||||
type: TEXT('medium'),
|
||||
allowNull: true,
|
||||
comment: '抽题时刻的题干快照 JSON(防纠纷存证)',
|
||||
},
|
||||
answer_1: {
|
||||
type: TEXT,
|
||||
allowNull: true,
|
||||
comment: '第1小问作答(上限 2000 字)',
|
||||
},
|
||||
answer_2: {
|
||||
type: TEXT,
|
||||
allowNull: true,
|
||||
comment: '第2小问作答(上限 2000 字)',
|
||||
},
|
||||
answer_3: {
|
||||
type: TEXT,
|
||||
allowNull: true,
|
||||
comment: '第3小问作答(上限 2000 字)',
|
||||
},
|
||||
score_1: decimalScore('score_1', '第1小问得分(0-10,可含 2 位小数)'),
|
||||
score_2: decimalScore('score_2', '第2小问得分(0-10,可含 2 位小数)'),
|
||||
score_3: decimalScore('score_3', '第3小问得分(0-10,可含 2 位小数)'),
|
||||
total_score: decimalScore('total_score', '三小问得分合计(可含 2 位小数)'),
|
||||
submit_time: {
|
||||
type: DATE,
|
||||
allowNull: true,
|
||||
comment: '随整卷正式提交写入(未提交为 NULL)',
|
||||
},
|
||||
created_at: {
|
||||
type: DATE,
|
||||
defaultValue: app.Sequelize.NOW,
|
||||
comment: '创建时间',
|
||||
},
|
||||
updated_at: {
|
||||
type: DATE,
|
||||
defaultValue: app.Sequelize.NOW,
|
||||
comment: '更新时间',
|
||||
},
|
||||
}, {
|
||||
tableName: 'big_question_answers',
|
||||
timestamps: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at',
|
||||
});
|
||||
};
|
||||
@@ -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'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
module.exports = app => {
|
||||
const { STRING, INTEGER, BOOLEAN, DATE } = app.Sequelize;
|
||||
|
||||
// 分数列统一走共享工厂(DECIMAL(6,2) + Number 兜底,见 app/lib/decimal_score.js)
|
||||
const decimalScore = require('../lib/decimal_score')(app);
|
||||
|
||||
const Contestant = app.model.define('contestant', {
|
||||
// 主键 ID,自增
|
||||
id: {
|
||||
@@ -64,32 +67,22 @@ module.exports = app => {
|
||||
comment: '账号激活状态',
|
||||
},
|
||||
// 总分(管理员打分后更新,NULL 表示尚未评分)
|
||||
score: {
|
||||
type: INTEGER,
|
||||
allowNull: true,
|
||||
defaultValue: null,
|
||||
comment: '选手总分(exam_score + word_score)',
|
||||
},
|
||||
score: decimalScore('score', '选手总分(exam_score + word_score)'),
|
||||
// 卷面分(选择题+判断题自动计算)
|
||||
exam_score: {
|
||||
type: INTEGER,
|
||||
allowNull: true,
|
||||
defaultValue: null,
|
||||
comment: '卷面考试成绩',
|
||||
},
|
||||
// Word题分数(管理员手动打分)
|
||||
word_score: {
|
||||
type: INTEGER,
|
||||
allowNull: true,
|
||||
defaultValue: null,
|
||||
comment: 'Word题得分',
|
||||
},
|
||||
exam_score: decimalScore('exam_score', '卷面考试成绩'),
|
||||
// 综合大题 / Word 题分数(管理员手动打分,可含 2 位小数)
|
||||
word_score: decimalScore('word_score', 'Word题/综合大题得分(可含 2 位小数)'),
|
||||
// PPT分数(本科/教师组管理员手动打分)
|
||||
ppt_score: {
|
||||
type: INTEGER,
|
||||
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: 'PPT得分(本科/教师组)',
|
||||
comment: '指导教师姓名(同组多人用、分隔)',
|
||||
},
|
||||
// 所属团队 ID(外键关联 teams 表)
|
||||
team_id: {
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
module.exports = app => {
|
||||
const { STRING, INTEGER, DATE, TEXT, JSON: JSON_TYPE } = app.Sequelize;
|
||||
|
||||
// 分数列统一走共享工厂(DECIMAL(6,2) + Number 兜底,见 app/lib/decimal_score.js)
|
||||
const decimalScore = require('../lib/decimal_score')(app);
|
||||
|
||||
const ExamSubmission = app.model.define('exam_submission', {
|
||||
// 主键 ID,自增
|
||||
id: {
|
||||
@@ -64,12 +67,7 @@ module.exports = app => {
|
||||
comment: '提交时间(占位/未提交为 NULL,正式提交后为具体时间)',
|
||||
},
|
||||
// 得分(管理员评分后更新,NULL 表示尚未评分)
|
||||
score: {
|
||||
type: INTEGER,
|
||||
allowNull: true,
|
||||
defaultValue: null,
|
||||
comment: '试卷得分',
|
||||
},
|
||||
score: decimalScore('score', '试卷得分', { defaultValue: null }),
|
||||
// 创建时间
|
||||
created_at: {
|
||||
type: DATE,
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
module.exports = app => {
|
||||
const { STRING, INTEGER, TEXT, BOOLEAN, DATE } = app.Sequelize;
|
||||
|
||||
// 分数列统一走共享工厂(DECIMAL(6,2) + Number 兜底,见 app/lib/decimal_score.js)
|
||||
const decimalScore = require('../lib/decimal_score')(app);
|
||||
|
||||
const GaozhiSubmission = app.model.define('gaozhi_submission', {
|
||||
// 主键 ID,自增
|
||||
id: {
|
||||
@@ -54,12 +57,7 @@ module.exports = app => {
|
||||
comment: '是否判为正确',
|
||||
},
|
||||
// 得分
|
||||
score: {
|
||||
type: INTEGER,
|
||||
allowNull: true,
|
||||
defaultValue: null,
|
||||
comment: '得分',
|
||||
},
|
||||
score: decimalScore('score', '得分(可含 2 位小数)', { defaultValue: null }),
|
||||
// Word 文件存储路径
|
||||
word_file_path: {
|
||||
type: STRING(500),
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
module.exports = app => {
|
||||
const { STRING, INTEGER, DATE } = app.Sequelize;
|
||||
|
||||
// 分数列统一走共享工厂(DECIMAL(6,2) + Number 兜底,见 app/lib/decimal_score.js)
|
||||
const decimalScore = require('../lib/decimal_score')(app);
|
||||
|
||||
const JiaoshiSubmission = app.model.define('jiaoshi_submission', {
|
||||
// 主键 ID,自增
|
||||
id: {
|
||||
@@ -71,12 +74,7 @@ module.exports = app => {
|
||||
comment: '上传时间',
|
||||
},
|
||||
// 评分
|
||||
score: {
|
||||
type: INTEGER,
|
||||
allowNull: true,
|
||||
defaultValue: null,
|
||||
comment: '评分',
|
||||
},
|
||||
score: decimalScore('score', '评分(可含 2 位小数)', { defaultValue: null }),
|
||||
// 记录创建时间
|
||||
created_at: {
|
||||
type: DATE,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
module.exports = app => {
|
||||
const { STRING, INTEGER, DATE } = app.Sequelize;
|
||||
|
||||
// 分数列统一走共享工厂(DECIMAL(6,2) + Number 兜底,见 app/lib/decimal_score.js)
|
||||
const decimalScore = require('../lib/decimal_score')(app);
|
||||
|
||||
const WorkSubmission = app.model.define('work_submission', {
|
||||
// 主键 ID,自增
|
||||
id: {
|
||||
@@ -63,12 +66,7 @@ module.exports = app => {
|
||||
comment: '提交时间',
|
||||
},
|
||||
// 得分(管理员评分后更新,NULL 表示尚未评分)
|
||||
score: {
|
||||
type: INTEGER,
|
||||
allowNull: true,
|
||||
defaultValue: null,
|
||||
comment: '作品得分',
|
||||
},
|
||||
score: decimalScore('score', '作品得分', { defaultValue: null }),
|
||||
// 创建时间
|
||||
created_at: {
|
||||
type: DATE,
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
module.exports = app => {
|
||||
const { STRING, INTEGER, TEXT, BOOLEAN, DATE } = app.Sequelize;
|
||||
|
||||
// 分数列统一走共享工厂(DECIMAL(6,2) + Number 兜底,见 app/lib/decimal_score.js)
|
||||
const decimalScore = require('../lib/decimal_score')(app);
|
||||
|
||||
const ZhongzhiSubmission = app.model.define('zhongzhi_submission', {
|
||||
// 主键 ID,自增
|
||||
id: {
|
||||
@@ -54,12 +57,7 @@ module.exports = app => {
|
||||
comment: '是否判为正确',
|
||||
},
|
||||
// 得分
|
||||
score: {
|
||||
type: INTEGER,
|
||||
allowNull: true,
|
||||
defaultValue: null,
|
||||
comment: '得分',
|
||||
},
|
||||
score: decimalScore('score', '得分(可含 2 位小数)', { defaultValue: null }),
|
||||
// Word 文件存储路径
|
||||
word_file_path: {
|
||||
type: STRING(500),
|
||||
|
||||
0
app/public/templates/gaozhi/.gitkeep
Normal file
0
app/public/templates/gaozhi/.gitkeep
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
0
app/public/templates/zhongzhi/.gitkeep
Normal file
0
app/public/templates/zhongzhi/.gitkeep
Normal file
Binary file not shown.
Binary file not shown.
111
app/router.js
111
app/router.js
@@ -26,6 +26,21 @@ module.exports = app => {
|
||||
// 登录限流中间件(防暴力破解):15 分钟内同一 IP+账号最多 10 次失败
|
||||
const loginRateLimit = middleware.loginRateLimit({ windowMs: 15 * 60 * 1000, maxAttempts: 10 });
|
||||
|
||||
// 交卷限流(2026-09-21 并发压测结论)
|
||||
//
|
||||
// 🔴 只挂在 submit-all 这**单条**路由上,绝不整段挂 /api/exam:
|
||||
// 逐题保存 submit-answers 是「每答一题一次请求」(一场约 59 次),若整段挂
|
||||
// exam:30/分钟,正常考生在答题过程中就会被自己的保存请求打爆(429),
|
||||
// 直接造成丢答案 —— 这比不限流更糟。
|
||||
//
|
||||
// 🔴 必须排在 auth **之后**:rateLimit 的限流键优先取 ctx.state.currentUser.uid
|
||||
// (见 middleware/rateLimit.js getClientId),而 currentUser 由 auth 写入。
|
||||
// 若不排在 auth 之后,键会退化为 ip:<ctx.ip> —— 考场/学校普遍共用出口 IP,
|
||||
// 会把整间考场的考生一起限流拒掉,属灾难级误伤。
|
||||
//
|
||||
// 阈值取 LIMITS.exam = 30 次/60 秒(正常交卷 1 次 + 前端自动重试数次,余量充足)。
|
||||
const examSubmitLimit = middleware.rateLimit({ level: 'exam' });
|
||||
|
||||
// ==================== 公开接口(无需认证) ====================
|
||||
|
||||
// 健康检查(容器探针 / 负载均衡探活,无需认证)
|
||||
@@ -72,7 +87,14 @@ module.exports = app => {
|
||||
router.post('/api/exam/:group/submit-answers', auth, controller.exam.submitAnswers);
|
||||
router.post('/api/exam/:group/upload-word', auth, controller.exam.uploadWord);
|
||||
router.delete('/api/exam/:group/word', auth, controller.exam.deleteWord);
|
||||
router.post('/api/exam/:group/submit-all', auth, controller.exam.submitAll);
|
||||
// 交卷:全局闸门(config.middleware 的 concurrencyGuard 已纳入 submit-all,
|
||||
// 过载返回 429 + Retry-After)→ auth → 交卷限流(按 user,见上方 examSubmitLimit)
|
||||
router.post('/api/exam/:group/submit-all', auth, examSubmitLimit, controller.exam.submitAll);
|
||||
|
||||
// 综合大题(仅中职/高职组;2026-09-21 起替代原 Word 文件上传题)
|
||||
// 首次 GET 抽题并落库题干快照,后续 GET 一律返回同一道题(幂等防纠纷)。
|
||||
router.get('/api/exam/:group/big-question', auth, controller.exam.bigQuestion);
|
||||
router.post('/api/exam/:group/big-answer', auth, controller.exam.saveBigAnswer);
|
||||
|
||||
// ==================== 文件上传(需认证) ====================
|
||||
|
||||
@@ -88,24 +110,75 @@ 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);
|
||||
|
||||
// 账号管理
|
||||
router.get('/api/admin/accounts', adminAuth, controller.admin.accounts);
|
||||
// 小组(团队)激活状态清单(R84 续六):查看已激活 / 未激活小组,并携带所属学校名称。
|
||||
// ⚠️ 路径段固定、无通配,与既有路由无匹配冲突。
|
||||
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 段内唯一。
|
||||
// 破坏面定级:非破坏性(白名单字段编辑、无批量清除语义、改动可逆且带脱敏审计日志)
|
||||
// ⇒ 不登记进 DESTRUCTIVE_ENDPOINTS.json;登记册口径见该文件 note。
|
||||
router.put('/api/admin/contestants/:id', adminAuth, controller.admin.updateContestant);
|
||||
|
||||
// 提交管理
|
||||
router.get('/api/admin/submissions/:group', adminAuth, controller.admin.submissions);
|
||||
router.get('/api/admin/submission-file/:group/:id', adminAuth, controller.admin.downloadSubmissionFile);
|
||||
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);
|
||||
// 成绩排名表导出(R220):中职/高职按小组**客观题分**降序**全量**导出,整行按客观题分区间上底色。
|
||||
// ⚠️ 第三个产物,⛔ 不可与上面两个互换:export-excel = 内部核查表(含身份证号/手机号)、
|
||||
// export-publicity = 对外公示表(模板半成品、获奖等级留空)、
|
||||
// 本接口 = 排名速览表(一小组一行、含区间底色与区间统计、无任何敏感字段)。
|
||||
router.get('/api/admin/export-score-rank/:group', adminAuth, controller.admin.exportScoreRank);
|
||||
// 赛项信息(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);
|
||||
// 单小组作品压缩包(R190):zip 本身即小组目录,包内平铺该组文件。
|
||||
// ⚠️ 必须注册在 `/api/admin/works/:group` 之前不是硬要求(路径段数不同,不冲突),
|
||||
// 但保持「同一业务相邻登记」,避免后来者以为是两条无关通道。
|
||||
router.get('/api/admin/team-zip/:group', adminAuth, controller.admin.teamZip);
|
||||
router.get('/api/admin/works/:group', adminAuth, controller.admin.works);
|
||||
|
||||
// 时间设置
|
||||
router.get('/api/admin/times', adminAuth, controller.admin.getTimes);
|
||||
router.put('/api/admin/times', adminAuth, controller.admin.setTimes);
|
||||
|
||||
// 节点备份调度器状态(P1「生产备份静默失败」可见性):只读,不触发备份。
|
||||
// ⚠️ 路由段 node-backup 与 /api/admin/* 其余段无前缀冲突;GET 方法在此段唯一。
|
||||
router.get('/api/admin/node-backup/status', adminAuth, controller.admin.getNodeBackupStatus);
|
||||
|
||||
// 分数管理
|
||||
router.get('/api/admin/scores/:group', adminAuth, controller.admin.getScores);
|
||||
router.put('/api/admin/scores/:group', adminAuth, controller.admin.setScores);
|
||||
@@ -119,6 +192,31 @@ module.exports = app => {
|
||||
router.get('/api/admin/exam/:group/word-template', adminAuth, controller.admin.getWordTemplate);
|
||||
router.delete('/api/admin/exam/:group/word-template', adminAuth, controller.admin.deleteWordTemplate);
|
||||
|
||||
// 综合大题:题库管理(CRUD)+ 作答查证(防考生纠纷)+ 按小问评分
|
||||
router.get('/api/admin/big-questions/:group', adminAuth, controller.admin.listBigQuestions);
|
||||
router.post('/api/admin/big-questions/:group', adminAuth, controller.admin.createBigQuestion);
|
||||
// Word 导出:?ids=1,2,3 批量;缺省 = 该组全部。1 道 → .docx;多道 → .zip(内含多个 .docx)
|
||||
// ⚠️ 必须注册在 `:group/:id` 之前:Egg 按注册顺序匹配,否则一旦将来新增
|
||||
// `GET /:group/:id`,`export` 就会被当作 :id 吞掉(实测 2026-09-23 复核时
|
||||
// 本行曾被放在 :group/:id 之后,当时因 :group/:id 仅注册 PUT/DELETE 而未暴露问题)
|
||||
router.get('/api/admin/big-questions/:group/export', adminAuth, controller.admin.exportBigQuestions);
|
||||
// ⚠️ 注册顺序是硬约束:Egg 按注册序匹配,下面两条含**具体段**(template / import-excel),
|
||||
// 必须排在通配段 `/:id` 之前,否则会被它抢走(GET .../template → id='template')。
|
||||
router.get('/api/admin/big-questions/:group/template', adminAuth, controller.admin.downloadBigQuestionTemplate);
|
||||
router.post('/api/admin/big-questions/:group/import-excel', adminAuth, controller.admin.importBigQuestionsExcel);
|
||||
router.put('/api/admin/big-questions/:group/:id', adminAuth, controller.admin.updateBigQuestion);
|
||||
router.delete('/api/admin/big-questions/:group/:id', adminAuth, controller.admin.deleteBigQuestion);
|
||||
router.get('/api/admin/big-answers/:group', adminAuth, controller.admin.getBigAnswers);
|
||||
// 考生答卷导出(R28):3 段路径,与上方 2 段列表、下方 PUT /:uid/scores 均不冲突
|
||||
router.get('/api/admin/big-answers/:group/export', adminAuth, controller.admin.exportBigAnswers);
|
||||
// 成绩 Excel 导入(R172):两段式(预览零写入 → 确认导入),均为 3 段路径,与 PUT /:uid/scores 不冲突。
|
||||
// ⚠️ 具体段(import-template / import-preview / import-scores)不得与将来的 `/:uid` 通配段冲突 ⇒
|
||||
// 保持与 export 同等注册位置(在 PUT 之前)。
|
||||
router.get('/api/admin/big-answers/:group/import-template', adminAuth, controller.admin.downloadBigScoreTemplate);
|
||||
router.post('/api/admin/big-answers/:group/import-preview', adminAuth, controller.admin.previewBigScoreImport);
|
||||
router.post('/api/admin/big-answers/:group/import-scores', adminAuth, controller.admin.importBigScores);
|
||||
router.put('/api/admin/big-answers/:group/:uid/scores', adminAuth, controller.admin.setBigAnswerScores);
|
||||
|
||||
// 备赛资料管理
|
||||
router.get('/api/admin/materials/:group', adminAuth, controller.admin.getMaterials);
|
||||
router.post('/api/admin/materials/:group', adminAuth, controller.admin.uploadMaterial);
|
||||
@@ -144,6 +242,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);
|
||||
@@ -152,6 +253,14 @@ module.exports = app => {
|
||||
router.post('/api/admin/toggle-test-flag', adminAuth, controller.admin.toggleTestFlag);
|
||||
router.post('/api/admin/reset-test-data', adminAuth, controller.admin.resetTestData);
|
||||
|
||||
// ==================== 成绩复核(需管理员认证) ====================
|
||||
// 场景:考生对成绩有异议 → 按「姓名 / 身份证号 / 电话」任一检索 → 核对全卷题目与作答。
|
||||
// ⚠️ 本组会回传客观题正确答案,务必保持 adminAuth —— 选手端一旦可达即为成绩泄露。
|
||||
// 注册顺序:两条路径段数不同(3 段 / 5 段),不存在通配段互相吞并的问题;
|
||||
// 仍按「固定段在前」的惯例把 search 放前面。
|
||||
router.get('/api/admin/review/search', adminAuth, controller.admin.reviewSearch);
|
||||
router.get('/api/admin/review/score/:group/:id', adminAuth, controller.admin.contestantScore);
|
||||
|
||||
// ==================== 成绩查询(需认证) ====================
|
||||
|
||||
router.get('/api/score', auth, controller.score.index);
|
||||
|
||||
2695
app/service/admin.js
2695
app/service/admin.js
File diff suppressed because it is too large
Load Diff
270
app/service/adminUser.js
Normal file
270
app/service/adminUser.js
Normal file
@@ -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;
|
||||
@@ -37,6 +37,58 @@ class AuthService extends Service {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造「可下发给考生端」的用户信息(**唯一出口**)。
|
||||
*
|
||||
* 2026-09-21 修复(铁律15 成绩泄露全通道封堵 —— 登录通道缺口):
|
||||
* 此前 `POST /api/auth/login` 与 `GET /api/auth/current-user` 直接回传
|
||||
* `contestant.toJSON()`,其中 `score` 在成绩**未发布**状态下依然被下发
|
||||
* (实测未发布态 `score=27`),而同期 `GET /api/score` 正确返回 `null`。
|
||||
* 即门控只覆盖了 4 条已知通道,遗漏了 auth 这两条同源通道。
|
||||
*
|
||||
* 同源族(**三处**必须共用本出口,禁止各自直接 toJSON):
|
||||
* · controller/auth.js login()
|
||||
* · controller/auth.js currentUser()
|
||||
* · controller/auth.js refresh() (`POST /api/auth/refresh`; 前端 doSilentRefresh 会把它写入 localStorage 与 Pinia)
|
||||
*
|
||||
* 口径(与 GET /api/score 一致,复用 service.admin.isScorePublished 单一判定):
|
||||
* · 该组成绩**已发布** → 保留 score;
|
||||
* · 该组成绩**未发布 / 查询异常** → 剔除 score(安全默认:宁可不显示,不可泄露);
|
||||
* · 分项字段(exam_score / word_score / ppt_score)**任何情况下都不下发**
|
||||
* —— 它们只是总分的组成部分,前端从不使用,留在响应里等于给出第二条推算通道。
|
||||
*
|
||||
* @param {Model|Object} contestant - Contestant 模型实例或其普通对象
|
||||
* @returns {Promise<Object|null>} 已脱敏的用户信息
|
||||
*/
|
||||
async toClientUser(contestant) {
|
||||
const { service } = this;
|
||||
if (!contestant) return null;
|
||||
|
||||
const userData = typeof contestant.toJSON === 'function'
|
||||
? contestant.toJSON()
|
||||
: Object.assign({}, contestant);
|
||||
|
||||
delete userData.password;
|
||||
|
||||
// 成绩发布门控:查询失败按「未发布」处理,避免异常路径把分数漏出去
|
||||
let published = false;
|
||||
try {
|
||||
published = await service.admin.isScorePublished(userData.group);
|
||||
} catch (e) {
|
||||
published = false;
|
||||
}
|
||||
if (!published) {
|
||||
delete userData.score;
|
||||
}
|
||||
|
||||
// 分项成绩一律不下发(前端不使用;保留会造成「总分虽隐藏、分项可加总」的旁路)
|
||||
delete userData.exam_score;
|
||||
delete userData.word_score;
|
||||
delete userData.ppt_score;
|
||||
|
||||
return userData;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据用户名查找管理员
|
||||
*
|
||||
@@ -92,7 +144,8 @@ class AuthService extends Service {
|
||||
* 将指定 id 的选手设置为已激活状态,并更新密码。
|
||||
*
|
||||
* @param {number} id - 选手 id
|
||||
* @param {string} password - SHA256 哈希后的密码
|
||||
* @param {string} password - bcrypt 加盐哈希后的密码(由 helper.hashPassword 生成,
|
||||
* BCRYPT_ROUNDS=10;调用方 controller/auth.js 的 activate() 已保证此口径)
|
||||
* @returns {Promise<Model>} 更新后的 Contestant 模型对象
|
||||
*/
|
||||
async activateAccount(id, password) {
|
||||
|
||||
1604
app/service/bigQuestion.js
Normal file
1604
app/service/bigQuestion.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -255,6 +255,19 @@ class ExamService extends Service {
|
||||
|
||||
/**
|
||||
* 批量自动判分
|
||||
*
|
||||
* 🔴 批量化(2026-09-21 并发压测结论):原实现对每道答案 `await this.checkAnswer()`,
|
||||
* 而 checkAnswer 内部是 `findByPk` —— 单次交卷 59~70 题即 59~70 次**串行** DB 往返。
|
||||
* 这是「单 worker accept 循环被重任务饿死 → 监听队列溢出 → Windows 回 RST」的主因之一
|
||||
* (压测实测:单 worker 硬容量仅 15 交卷/秒,400 并发下 20% 考生连接被拒)。
|
||||
* 改为按题型分组,用**最多 2 次** whereIn 取回全部题目,再在内存中判分。
|
||||
*
|
||||
* 判分语义与逐题版**逐字等价**(不得漂移):
|
||||
* - 答案比对:`String(x).trim().toLowerCase()` 严格相等;
|
||||
* - 得分:答对取 question.score,答错取 0;
|
||||
* - 题目不存在:跳过该题、**不进 details**(与 checkAnswer 返回 question=null 时一致);
|
||||
* - details 的字段名与取值口径不变(question_id 保留调用方传入的原始 uid 形态)。
|
||||
*
|
||||
* @param {Array} answers - 答案数组 [{ question_id, question_type, answer_data }]
|
||||
* @returns {Promise<Object>} { autoScore, total, details }
|
||||
*/
|
||||
@@ -263,24 +276,49 @@ class ExamService extends Service {
|
||||
let autoScore = 0;
|
||||
let total = 0;
|
||||
|
||||
// 第一步:按题型收集原始自增 id(去重后一次查出)。
|
||||
const idsByType = { choice: [], judge: [] };
|
||||
for (const item of answers) {
|
||||
const result = await this.checkAnswer(
|
||||
item.question_id || item.question_uid,
|
||||
item.question_type,
|
||||
item.answer_data
|
||||
);
|
||||
if (result.question) {
|
||||
total += result.question.maxScore;
|
||||
autoScore += result.score;
|
||||
details.push({
|
||||
question_id: item.question_id || item.question_uid,
|
||||
question_type: item.question_type,
|
||||
answer_data: item.answer_data,
|
||||
is_correct: result.isCorrect,
|
||||
score: result.score,
|
||||
max_score: result.question.maxScore,
|
||||
});
|
||||
}
|
||||
const type = item.question_type;
|
||||
// 仅 choice/judge 有独立判分模型(word 走 WordQuestion,不在本链路)。
|
||||
// 用 hasOwnProperty 白名单而非直接属性取值,避免 'constructor' 之类字符串
|
||||
// 命中 Object 原型链而绕过题型判定。
|
||||
if (!Object.prototype.hasOwnProperty.call(idsByType, type)) continue;
|
||||
const rawId = this._extractRawId(item.question_id || item.question_uid);
|
||||
if (/^\d+$/.test(String(rawId))) idsByType[type].push(String(rawId));
|
||||
}
|
||||
|
||||
const questionMap = new Map(); // `${type}:${id}` → question 实例
|
||||
for (const type of [ 'choice', 'judge' ]) {
|
||||
const uniqueIds = Array.from(new Set(idsByType[type]));
|
||||
if (!uniqueIds.length) continue;
|
||||
const QuestionModel = this._getQuestionModel(type);
|
||||
const rows = await QuestionModel.findAll({ where: { id: uniqueIds } });
|
||||
for (const q of rows) questionMap.set(`${type}:${q.id}`, q);
|
||||
}
|
||||
|
||||
// 第二步:内存判分,保持与原实现相同的顺序(按 answers 入参顺序 push)。
|
||||
for (const item of answers) {
|
||||
const type = item.question_type;
|
||||
const rawId = this._extractRawId(item.question_id || item.question_uid);
|
||||
const question = questionMap.get(`${type}:${rawId}`);
|
||||
if (!question) continue; // 题目不存在:与原 checkAnswer 行为一致
|
||||
|
||||
const correctAnswer = String(question.answer).trim().toLowerCase();
|
||||
const userAnswer = String(item.answer_data).trim().toLowerCase();
|
||||
const isCorrect = correctAnswer === userAnswer;
|
||||
const score = isCorrect ? question.score : 0;
|
||||
|
||||
total += question.score;
|
||||
autoScore += score;
|
||||
details.push({
|
||||
question_id: item.question_id || item.question_uid,
|
||||
question_type: type,
|
||||
answer_data: item.answer_data,
|
||||
is_correct: isCorrect,
|
||||
score,
|
||||
max_score: question.score,
|
||||
});
|
||||
}
|
||||
|
||||
return { autoScore, total, details };
|
||||
@@ -364,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<Array>} 保存后的记录列表
|
||||
*/
|
||||
/**
|
||||
* 交卷冻结守卫(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<void>} 未交卷则静默返回
|
||||
* @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
|
||||
@@ -374,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
|
||||
@@ -394,31 +485,46 @@ class ExamService extends Service {
|
||||
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
await ctx.model.transaction(async (t) => {
|
||||
for (const answer of answers) {
|
||||
// 原子 upsert:依靠 (aggregation_uid, question_uid, question_type) 唯一约束,
|
||||
// 并发作答时 MySQL 的 INSERT ... ON DUPLICATE KEY UPDATE 会将竞争请求
|
||||
// 折叠为对同一行的更新,彻底杜绝重复落库(B12 修复,覆盖 B11 的联合键)。
|
||||
//
|
||||
// ⚠️ question_uid 列是 INT:必须先把前缀形态('choice_683')还原成原始数字 id,
|
||||
// 否则 MySQL 会把字符串强转为 0,导致整卷 70 条答案坍缩成 choice/judge 各一行
|
||||
// 互相覆盖(B13 修复:uid 全局唯一化引入的落库回归)。
|
||||
//
|
||||
// ⚠️ 注意:MariaDB/MySQL 不支持 RETURNING 子句,带 returning:true 时 Sequelize
|
||||
// 会生成不被支持的 SQL,导致 upsert 静默不落库却返回「成功实例」。故此处去掉
|
||||
// returning,仅依赖事务提交 + 调用方 COUNT 复核(见 submitAll)。
|
||||
const rawQuestionId = this._extractRawId(answer.question_uid);
|
||||
await SubmissionModel.upsert({
|
||||
aggregation_uid: aggregationUid,
|
||||
question_uid: rawQuestionId,
|
||||
question_type: answer.question_type,
|
||||
answer_data: answer.answer_data,
|
||||
is_correct: answer.is_correct,
|
||||
score: answer.score,
|
||||
}, {
|
||||
conflictFields: ['aggregation_uid', 'question_uid', 'question_type'],
|
||||
transaction: t,
|
||||
});
|
||||
}
|
||||
// 🔴 批量化(2026-09-21 并发压测结论):原实现在单事务内 for 循环**逐条** upsert,
|
||||
// 单次交卷 59~70 条即 59~70 次串行 INSERT —— 事务持锁时间被放大到百毫秒级,
|
||||
// 既直接拖慢交卷吞吐(实测单 worker 仅 15 交卷/秒),又显著放大不同考生
|
||||
// 抢同一唯一索引 uk_*_submission 的概率。改为一次 bulkCreate +
|
||||
// updateOnDuplicate(单条 INSERT ... ON DUPLICATE KEY UPDATE)。
|
||||
//
|
||||
// 语义保真:与原 upsert 的 SET 列表**逐列相同**(原实现未传 updateOnDuplicate,
|
||||
// Sequelize 取 values 的键集合作为更新列),故不新增/不遗漏任何列;
|
||||
// 特别是**不得**把 word_file_path / file_path 等未参与本链路的列写进更新列表,
|
||||
// 否则会用 NULL 覆盖考生已上传的作品文件信息。
|
||||
//
|
||||
// ⚠️ question_uid 列是 INT:必须先把前缀形态('choice_683')还原成原始数字 id,
|
||||
// 否则 MySQL 会把字符串强转为 0,导致整卷答案坍缩成 choice/judge 各一行互相覆盖
|
||||
// (B13 修复:uid 全局唯一化引入的落库回归)。
|
||||
//
|
||||
// ⚠️ 注意:MariaDB/MySQL 不支持 RETURNING 子句,故不使用 returning,
|
||||
// 仅依赖事务提交 + 调用方 COUNT 复核(见 submitAll)。
|
||||
const rows = answers.map((answer) => ({
|
||||
aggregation_uid: aggregationUid,
|
||||
question_uid: this._extractRawId(answer.question_uid),
|
||||
question_type: answer.question_type,
|
||||
answer_data: answer.answer_data,
|
||||
is_correct: answer.is_correct,
|
||||
score: answer.score,
|
||||
}));
|
||||
|
||||
// 空数组守卫:bulkCreate([]) 在部分 dialect 下会抛错,而原 for 循环是空转成功。
|
||||
if (!rows.length) return;
|
||||
|
||||
await SubmissionModel.bulkCreate(rows, {
|
||||
updateOnDuplicate: [
|
||||
'aggregation_uid',
|
||||
'question_uid',
|
||||
'question_type',
|
||||
'answer_data',
|
||||
'is_correct',
|
||||
'score',
|
||||
],
|
||||
transaction: t,
|
||||
});
|
||||
});
|
||||
return answers.length;
|
||||
} catch (err) {
|
||||
|
||||
@@ -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'],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,11 @@ const Service = require('egg').Service;
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const {
|
||||
sanitizeSegment,
|
||||
sanitizeFileName,
|
||||
resolveTeamFolderName,
|
||||
} = require('../lib/team_folder');
|
||||
|
||||
/**
|
||||
* 上传服务 - UploadService
|
||||
@@ -73,68 +78,384 @@ class UploadService extends Service {
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 小组(队伍)上下文 ====================
|
||||
|
||||
/**
|
||||
* 保存作品文件
|
||||
* 解析当前提交者所属「小组」的上下文 —— 上传落盘与管理端打包共用的唯一解析入口。
|
||||
*
|
||||
* 🔴 小组键 = (组别 group, 队伍号 teamUid),⛔ 不可单用 teamUid(跨组重号)。
|
||||
* teamUid 解析优先级与 `service/admin.js#resolveTeamLabels` 完全同源:
|
||||
* `aggregation.team_uid`(报名表聚合,权威)→ `contestants.team_id`(兜底)
|
||||
*
|
||||
* @param {Object} aggregation - getUserAggregation() 的返回值(含 include team)
|
||||
* @param {string} group - 组别
|
||||
* @returns {Promise<{teamUid:?number, teamNumber:?string, folderName:string, contestant:?Object}>}
|
||||
*/
|
||||
async resolveTeamContext(aggregation, group) {
|
||||
const { ctx } = this;
|
||||
if (!aggregation) {
|
||||
return { teamUid: null, teamNumber: null, folderName: resolveTeamFolderName({}), contestant: null };
|
||||
}
|
||||
|
||||
// 选手档案:既用于 team_id 兜底,也用于取姓名做提示文案
|
||||
const contestant = await ctx.model.Contestant.findByPk(aggregation.account_uid, {
|
||||
attributes: ['id', 'name', 'group', 'team_id'],
|
||||
raw: true,
|
||||
});
|
||||
|
||||
let teamUid = aggregation.team_uid;
|
||||
if (teamUid === null || teamUid === undefined) {
|
||||
teamUid = (contestant && contestant.team_id !== null && contestant.team_id !== undefined)
|
||||
? contestant.team_id
|
||||
: null;
|
||||
}
|
||||
|
||||
// 队伍元信息:优先用 include 进来的 team;缺失时按 (group, teamUid) 回查,
|
||||
// 避免「有归属无元信息」时把编号退化成兜底名(那会让同队两人的目录名不一致)。
|
||||
let team = aggregation.team || null;
|
||||
if (!team && teamUid !== null && teamUid !== undefined) {
|
||||
team = await ctx.model.Team.findOne({
|
||||
where: { team_uid: teamUid, group_name: group },
|
||||
attributes: ['team_uid', 'group_name', 'team_id', 'team_number'],
|
||||
raw: true,
|
||||
});
|
||||
}
|
||||
|
||||
const teamNumber = team ? (team.team_number || team.team_id || null) : null;
|
||||
const folderName = resolveTeamFolderName({
|
||||
teamNumber,
|
||||
teamUid,
|
||||
aggregationUid: aggregation.aggregation_uid,
|
||||
});
|
||||
|
||||
return { teamUid, teamNumber, folderName, contestant };
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询「组内上传状态」—— 本科/教师组的组内互斥判定唯一事实来源。
|
||||
*
|
||||
* 【业务规则(R190)】
|
||||
* ① 同一参赛小组内,只要有任一成员上传过作品,其他成员就不能再上传;
|
||||
* ② 「替换权」只归**组内最早提交者**(归属人),他在截止前可以随时替换;
|
||||
* ③ 无队伍归属的独立参赛者不参与组内互斥(自己传自己的)。
|
||||
*
|
||||
* ⚠️ 判据必须按 (group, teamUid) 取**全部成员**再数提交记录 —— 旧实现
|
||||
* `hasSubmitted()` 只数本人的 `aggregation_uid`,同组他人上传后自己仍可继续传,
|
||||
* 正是本次要修的缺陷。
|
||||
*
|
||||
* @param {Object} aggregation - getUserAggregation() 的返回值
|
||||
* @param {string} group - 组别
|
||||
* @returns {Promise<Object>} 见下方 result 结构
|
||||
*/
|
||||
async getTeamUploadState(aggregation, group) {
|
||||
const { ctx } = this;
|
||||
const Op = ctx.model.Sequelize.Op;
|
||||
const SubmissionModel = group === 'benke'
|
||||
? ctx.model.BenkeSubmission
|
||||
: ctx.model.JiaoshiSubmission;
|
||||
|
||||
const teamCtx = await this.resolveTeamContext(aggregation, group);
|
||||
const selfAggUid = aggregation.aggregation_uid;
|
||||
|
||||
const result = {
|
||||
teamUid: teamCtx.teamUid,
|
||||
teamNumber: teamCtx.teamNumber,
|
||||
folderName: teamCtx.folderName,
|
||||
memberCount: 1,
|
||||
hasAnySubmission: false,
|
||||
owner: null, // { aggregationUid, contestantId, name }
|
||||
isOwner: false,
|
||||
locked: false, // true = 组内别人已上传,本人不能再传
|
||||
lockMessage: null,
|
||||
teamFiles: [], // [{ fileName, fileType, uploaderName, isMine }]
|
||||
};
|
||||
|
||||
// ---- 无队伍归属:独立参赛,组内锁不成立 ----
|
||||
if (teamCtx.teamUid === null || teamCtx.teamUid === undefined) {
|
||||
const mine = await this.hasSubmitted(selfAggUid, group);
|
||||
result.hasAnySubmission = mine;
|
||||
result.isOwner = mine;
|
||||
if (mine) {
|
||||
result.owner = {
|
||||
aggregationUid: selfAggUid,
|
||||
contestantId: aggregation.account_uid,
|
||||
name: (teamCtx.contestant && teamCtx.contestant.name) || null,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---- 取同组同队的全部成员 ----
|
||||
// 🔴 必须先按组别过滤再认 teamUid:aggregation.team_uid 是全局序列的弱引用,
|
||||
// 不加组别限定会把别的组里同号队伍的人算进来(跨组串组,历史上已踩过)。
|
||||
const aggRows = await ctx.model.Aggregation.findAll({
|
||||
where: { team_uid: teamCtx.teamUid },
|
||||
attributes: ['aggregation_uid', 'account_uid'],
|
||||
raw: true,
|
||||
});
|
||||
const candIds = aggRows.map(a => a.account_uid).filter(v => v !== null && v !== undefined);
|
||||
const memberContestants = candIds.length
|
||||
? await ctx.model.Contestant.findAll({
|
||||
where: { id: { [Op.in]: candIds }, group },
|
||||
attributes: ['id', 'name'],
|
||||
raw: true,
|
||||
})
|
||||
: [];
|
||||
const nameOf = new Map(memberContestants.map(c => [String(c.id), c.name]));
|
||||
const groupMemberIds = new Set(memberContestants.map(c => String(c.id)));
|
||||
const members = aggRows.filter(a => groupMemberIds.has(String(a.account_uid)));
|
||||
result.memberCount = Math.max(members.length, 1);
|
||||
|
||||
// ---- 成员提交记录:按 aggregation_uid 取最早一条,再全局取最早者作归属人 ----
|
||||
const memberAggUids = members.map(m => m.aggregation_uid);
|
||||
let firstByAgg = new Map();
|
||||
if (memberAggUids.length > 0) {
|
||||
const subs = await SubmissionModel.findAll({
|
||||
where: { aggregation_uid: { [Op.in]: memberAggUids } },
|
||||
attributes: ['id', 'aggregation_uid', 'upload_time', 'file_name', 'file_type'],
|
||||
raw: true,
|
||||
});
|
||||
for (const s of subs) {
|
||||
const t = s.upload_time ? new Date(s.upload_time).getTime() : 0;
|
||||
const cur = firstByAgg.get(String(s.aggregation_uid));
|
||||
// 同一 aggregation 有多行(word/ppt)时取最早;同时刻用更小 id 保序,保证可复现
|
||||
if (!cur || t < cur.t || (t === cur.t && s.id < cur.id)) {
|
||||
firstByAgg.set(String(s.aggregation_uid), { t, id: s.id, aggUid: s.aggregation_uid });
|
||||
}
|
||||
// 组内已上传文件清单:让被锁成员**看得见**「组里已经有什么」,
|
||||
// 而不是只收到一句「你不能传」。
|
||||
result.teamFiles.push({
|
||||
fileName: s.file_name || '',
|
||||
fileType: s.file_type || 'word',
|
||||
uploaderName: nameOf.get(String(
|
||||
(members.find(m => String(m.aggregation_uid) === String(s.aggregation_uid)) || {}).account_uid
|
||||
)) || null,
|
||||
isMine: String(s.aggregation_uid) === String(selfAggUid),
|
||||
});
|
||||
}
|
||||
// 稳定排序:word 在前、ppt 在后,便于前端固定展示顺序
|
||||
result.teamFiles.sort((a, b) => String(a.fileType).localeCompare(String(b.fileType)));
|
||||
}
|
||||
|
||||
result.hasAnySubmission = firstByAgg.size > 0;
|
||||
|
||||
let best = null;
|
||||
for (const v of firstByAgg.values()) {
|
||||
if (!best || v.t < best.t || (v.t === best.t && v.id < best.id)) best = v;
|
||||
}
|
||||
|
||||
if (best) {
|
||||
const ownerMember = members.find(m => String(m.aggregation_uid) === String(best.aggUid));
|
||||
const ownerContestantId = ownerMember ? ownerMember.account_uid : null;
|
||||
result.owner = {
|
||||
aggregationUid: best.aggUid,
|
||||
contestantId: ownerContestantId,
|
||||
name: ownerContestantId !== null ? (nameOf.get(String(ownerContestantId)) || null) : null,
|
||||
};
|
||||
result.isOwner = String(best.aggUid) === String(selfAggUid);
|
||||
result.locked = !result.isOwner;
|
||||
if (result.locked) {
|
||||
const who = result.owner.name || '同组其他成员';
|
||||
result.lockMessage =
|
||||
`您的小组已由「${who}」上传作品。为保持组内作品唯一,`
|
||||
+ `只有「${who}」可在截止时间前替换文件,其他成员不能再上传。`
|
||||
+ `如需调整,请联系「${who}」处理。`;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 组内互斥守卫 —— 上传/合并两条路径**都必须**先过这里。
|
||||
*
|
||||
* @param {Object} aggregation
|
||||
* @param {string} group
|
||||
* @returns {Promise<{ok:boolean, message:string, state:Object}>}
|
||||
*/
|
||||
async assertTeamUploadAllowed(aggregation, group) {
|
||||
const state = await this.getTeamUploadState(aggregation, group);
|
||||
if (state.locked) {
|
||||
return { ok: false, message: state.lockMessage, state };
|
||||
}
|
||||
return { ok: true, message: '', state };
|
||||
}
|
||||
|
||||
// ==================== 作品落盘 ====================
|
||||
|
||||
/**
|
||||
* 确保小组作品目录存在并返回绝对路径。
|
||||
*
|
||||
* @param {string} group - 组别
|
||||
* @param {string} folderName - 小组目录名
|
||||
* @returns {string} 目录绝对路径
|
||||
*/
|
||||
ensureWorkDir(group, folderName) {
|
||||
const folder = sanitizeSegment(folderName, '未编组');
|
||||
const dir = path.join(this.app.baseDir, 'app/public/works', group, folder);
|
||||
try {
|
||||
// 无条件递归创建,避免父目录偶发缺失 / TOCTOU 导致 ENOENT
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
} catch (mkErr) {
|
||||
this.app.logger.error('[ensureWorkDir] mkdir failed', { dir, message: mkErr.message });
|
||||
throw mkErr;
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算作品落盘目标(目录 + 最终文件名)—— 上传与分片合并共用的**唯一**命名实现。
|
||||
*
|
||||
* 🔴 为什么必须收敛:改造前 `service/upload.js#saveWorkFile` 与
|
||||
* `controller/upload.js#mergeChunks` 各写了一份 `work_<ts>_<rand><ext>` 随机命名,
|
||||
* 两处一旦漂移就会出现「合并落 A、下载找 B」。现统一到本方法。
|
||||
*
|
||||
* 落点:`app/public/works/<group>/<小组编号>/<原始文件名>`
|
||||
*
|
||||
* @param {Object} p
|
||||
* @param {string} p.group - 组别
|
||||
* @param {string} p.folderName - 小组目录名(resolveTeamFolderName)
|
||||
* @param {string} p.filename - 上传时的原始文件名
|
||||
* @param {string} [p.fileType] - word / ppt
|
||||
* @param {?number} [p.aggregationUid] - 提交者聚合 ID(同名文件归属判定用)
|
||||
* @returns {Promise<{dir:string, folder:string, fileName:string, absPath:string, relPath:string}>}
|
||||
*/
|
||||
async prepareWorkTarget({ group, folderName, filename, fileType = 'word', aggregationUid = null }) {
|
||||
const dir = this.ensureWorkDir(group, folderName);
|
||||
const folder = sanitizeSegment(folderName, '未编组');
|
||||
|
||||
const fileName = await this._resolveTargetFileName({
|
||||
dir, group, folder, filename, fileType, aggregationUid,
|
||||
});
|
||||
const relPath = ['works', group, folder, fileName].join('/');
|
||||
|
||||
return { dir, folder, fileName, absPath: path.join(dir, fileName), relPath };
|
||||
}
|
||||
|
||||
/**
|
||||
* 决定小组目录内的最终文件名。
|
||||
*
|
||||
* 【规则】保留上传时的原始文件名(用户裁定);仅在**确实会覆盖他人文件**时才加序号。
|
||||
* ① 目标不存在 ⇒ 直接用原名
|
||||
* ② 目标存在,且 DB 里同路径记录属于**同一聚合、同一 file_type** ⇒ 是同一次提交的新版本
|
||||
* ⇒ 用原名(走原子 rename 覆盖,`saveSubmissionRecord` 会清理旧路径)
|
||||
* ③ 目标存在但查不到记录 / 属于别人 / 属不同 file_type ⇒ 加 `(2)(3)…` 序号,绝不覆盖
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
async _resolveTargetFileName({ dir, group, folder, filename, fileType, aggregationUid }) {
|
||||
const { ctx } = this;
|
||||
const baseName = sanitizeFileName(filename);
|
||||
const abs = path.join(dir, baseName);
|
||||
if (!fs.existsSync(abs)) return baseName;
|
||||
|
||||
const rel = ['works', group, folder, baseName].join('/');
|
||||
const SubmissionModel = group === 'benke'
|
||||
? ctx.model.BenkeSubmission
|
||||
: ctx.model.JiaoshiSubmission;
|
||||
const row = await SubmissionModel.findOne({
|
||||
where: { file_path: rel },
|
||||
attributes: ['id', 'aggregation_uid', 'file_type'],
|
||||
raw: true,
|
||||
});
|
||||
|
||||
const sameLogicalFile = row
|
||||
&& String(row.file_type || 'word') === String(fileType || 'word')
|
||||
&& (aggregationUid === null || String(row.aggregation_uid) === String(aggregationUid));
|
||||
if (sameLogicalFile) return baseName;
|
||||
|
||||
// 撞名且不属同一逻辑文件 ⇒ 加序号,保护已在使用的文件
|
||||
const dot = baseName.lastIndexOf('.');
|
||||
const stem = dot > 0 ? baseName.slice(0, dot) : baseName;
|
||||
const ext = dot > 0 ? baseName.slice(dot) : '';
|
||||
for (let i = 2; i <= 99; i += 1) {
|
||||
const candidate = `${stem}(${i})${ext}`;
|
||||
if (!fs.existsSync(path.join(dir, candidate))) return candidate;
|
||||
}
|
||||
// 极端兜底:用时间戳确保唯一(不抛错,宁可名字丑也不丢文件)
|
||||
return `${stem}_${Date.now()}${ext}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把临时文件原子地落到最终位置,并补齐 size / md5。
|
||||
*
|
||||
* 🔴 为什么要先写临时名再 rename:直接写最终文件时,若恰好有管理员在下载,
|
||||
* 会读到**半截文件**(zip 里就是损坏的 PDF)。同目录 rename 在同一文件系统内
|
||||
* 是原子操作,读方要么看到旧的完整文件、要么看到新的完整文件。
|
||||
*
|
||||
* @param {string} tmpPath - 临时文件绝对路径(必须与目标同目录)
|
||||
* @param {Object} target - prepareWorkTarget() 的返回值
|
||||
* @returns {Promise<{filePath:string, fileName:string, fileSize:number, md5:string}>}
|
||||
*/
|
||||
async commitWorkFile(tmpPath, target) {
|
||||
try {
|
||||
fs.renameSync(tmpPath, target.absPath);
|
||||
} catch (renameErr) {
|
||||
this.ctx.helper.safeCleanup(tmpPath, { label: 'commitWorkFile-tmp' });
|
||||
throw renameErr;
|
||||
}
|
||||
const stat = fs.statSync(target.absPath);
|
||||
const md5 = await this._calculateMd5(target.absPath);
|
||||
return {
|
||||
filePath: target.relPath,
|
||||
fileName: target.fileName,
|
||||
fileSize: stat.size,
|
||||
md5,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存作品文件(单次流式上传)
|
||||
*
|
||||
* @param {ReadableStream} stream - 文件流
|
||||
* @param {string} filename - 原始文件名
|
||||
* @param {string} group - 组别
|
||||
* @param {string} folderName - 小组目录名(resolveTeamFolderName 的返回值)
|
||||
* @param {Object} [opts]
|
||||
* @param {string} [opts.fileType] - word / ppt
|
||||
* @param {?number} [opts.aggregationUid] - 提交者聚合 ID
|
||||
* @returns {Promise<Object>} { filePath, fileName, fileSize, md5 }
|
||||
*/
|
||||
async saveWorkFile(stream, filename, group) {
|
||||
const { app } = this;
|
||||
|
||||
// 确保上传目录存在(无条件递归创建,避免父目录偶发缺失 / TOCTOU 导致 ENOENT)
|
||||
const uploadDir = path.join(app.baseDir, 'app/public/works', group);
|
||||
try {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
} catch (mkErr) {
|
||||
this.app.logger.error('[saveWorkFile] mkdir failed', { uploadDir, message: mkErr.message });
|
||||
throw mkErr;
|
||||
}
|
||||
|
||||
// 构建安全文件名
|
||||
const ext = path.extname(filename);
|
||||
const timestamp = Date.now();
|
||||
const safeFileName = `work_${timestamp}_${Math.random().toString(36).substring(2, 8)}${ext}`;
|
||||
const filePath = path.join(uploadDir, safeFileName);
|
||||
|
||||
// 将文件流写入磁盘(用 pipe 自动处理背压,避免大文件内存暴涨 / 写入被截断)
|
||||
const writeStream = fs.createWriteStream(filePath);
|
||||
let fileSize = 0;
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
stream.pipe(writeStream);
|
||||
stream.on('data', chunk => { fileSize += chunk.length; });
|
||||
writeStream.on('finish', resolve);
|
||||
stream.on('error', reject);
|
||||
writeStream.on('error', reject);
|
||||
async saveWorkFile(stream, filename, group, folderName, opts = {}) {
|
||||
const { fileType = 'word', aggregationUid = null } = opts;
|
||||
const target = await this.prepareWorkTarget({
|
||||
group, folderName, filename, fileType, aggregationUid,
|
||||
});
|
||||
|
||||
// 🔴 落盘完整性校验(F17-B 防「假成功」):
|
||||
// 文件必须真实存在且字节数与流读数一致,否则视为上传失败,
|
||||
// 绝不允许把「写了一半 / 没写出来的文件」记成成功——那会导致管理后台下载时 404。
|
||||
const stat = fs.statSync(filePath);
|
||||
if (stat.size !== fileSize) {
|
||||
// 🔴 清理半截文件用 best-effort(同源缺陷族第三处):
|
||||
// 裸 unlinkSync 一旦失败会抛出「删除失败」,把下面那条真正有诊断价值的
|
||||
// 「文件写入不完整:期望 X 实际 Y」**顶掉**,排障时被引向错误方向。
|
||||
// 清理成不成功都不影响结论——写入不完整这件事必须原样抛出。
|
||||
this.ctx.helper.safeCleanup(filePath, { label: 'partialFile' });
|
||||
throw new Error(`文件写入不完整:期望 ${fileSize} 字节,实际 ${stat.size} 字节`);
|
||||
// 临时名:以 `.` 开头 + 固定前缀,便于残留扫描识别;同目录保证 rename 原子
|
||||
const tmpName = `.upload_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`
|
||||
+ path.extname(target.fileName);
|
||||
const tmpPath = path.join(target.dir, tmpName);
|
||||
|
||||
const writeStream = fs.createWriteStream(tmpPath);
|
||||
let fileSize = 0;
|
||||
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
stream.pipe(writeStream);
|
||||
stream.on('data', chunk => { fileSize += chunk.length; });
|
||||
writeStream.on('finish', resolve);
|
||||
stream.on('error', reject);
|
||||
writeStream.on('error', reject);
|
||||
});
|
||||
|
||||
// 🔴 落盘完整性校验(F17-B 防「假成功」):
|
||||
// 文件必须真实存在且字节数与流读数一致,否则视为上传失败,
|
||||
// 绝不允许把「写了一半 / 没写出来的文件」记成成功——那会导致管理后台下载时 404。
|
||||
const stat = fs.statSync(tmpPath);
|
||||
if (stat.size !== fileSize) {
|
||||
// 🔴 清理半截文件用 best-effort:裸 unlinkSync 一旦失败会抛出「删除失败」,
|
||||
// 把下面那条真正有诊断价值的「文件写入不完整:期望 X 实际 Y」**顶掉**。
|
||||
this.ctx.helper.safeCleanup(tmpPath, { label: 'partialFile' });
|
||||
throw new Error(`文件写入不完整:期望 ${fileSize} 字节,实际 ${stat.size} 字节`);
|
||||
}
|
||||
} catch (err) {
|
||||
// 任何失败都不留半截临时文件(best-effort,不掩盖真实错误)
|
||||
this.ctx.helper.safeCleanup(tmpPath, { label: 'saveWorkFile-failed' });
|
||||
throw err;
|
||||
}
|
||||
|
||||
// 计算文件 MD5
|
||||
const md5 = await this._calculateMd5(filePath);
|
||||
|
||||
// 构建相对路径(相对于 app/public/)
|
||||
const relativePath = path.join('works', group, safeFileName).replace(/\\/g, '/');
|
||||
|
||||
return {
|
||||
filePath: relativePath,
|
||||
fileName: filename,
|
||||
fileSize,
|
||||
md5,
|
||||
};
|
||||
return await this.commitWorkFile(tmpPath, target);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -275,10 +596,34 @@ class UploadService extends Service {
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
// 🔴 C9 根因① 修复(2026-09-29 · R45):重传覆盖前,先记住旧物理文件路径,
|
||||
// 待 DB 更新**成功之后**再删旧文件 —— 否则每次重传都把旧文件永久留在磁盘上。
|
||||
// 实证:本次清理的 1003 个孤儿(9.62 GB)中,绝大多数正是这样累积出来的。
|
||||
//
|
||||
// ⚠️ 顺序不可颠倒:若先删旧文件、而 update 又失败,DB 仍然指向**已被删除**的旧路径
|
||||
// ⇒ 管理端/考生端下载 404(这是**数据丢失**,比留下一个孤儿严重得多)。
|
||||
// 先落库后删文件,最坏结果只是多一个可被兜底扫描捞回来的孤儿。
|
||||
const stalePath = (existing.file_path && existing.file_path !== fileInfo.filePath)
|
||||
? existing.file_path
|
||||
: null;
|
||||
|
||||
await existing.update({
|
||||
...payload,
|
||||
score: null, // 重新上传后清除评分
|
||||
});
|
||||
|
||||
if (stalePath) {
|
||||
// best-effort:删不掉只告警,绝不影响「上传成功」这个业务结果
|
||||
const r = this.removePublicFile(stalePath, 'overwrite-stale');
|
||||
// 🔴 R157:登记计数(⛔ 不抛错,保持 best-effort),使「旧文件清理失败」
|
||||
// 在运行期可被读取,而不只沉在日志里。
|
||||
this._noteDeleteResult(r, stalePath, 'overwrite-stale');
|
||||
if (r !== 'removed') {
|
||||
this.app.logger.warn(
|
||||
`[saveSubmissionRecord] 旧文件未能清理,已成为孤儿(等待兜底扫描 script/scan_orphan_files.py): ${stalePath} -> ${r}`
|
||||
);
|
||||
}
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
@@ -339,7 +684,7 @@ class UploadService extends Service {
|
||||
* @returns {Promise<boolean>} 删除成功返回 true,未找到(或无权限)返回 false
|
||||
*/
|
||||
async deleteSubmission(aggregationUid, group, id) {
|
||||
const { ctx, app } = this;
|
||||
const { ctx } = this;
|
||||
const SubmissionModel = group === 'benke'
|
||||
? ctx.model.BenkeSubmission
|
||||
: ctx.model.JiaoshiSubmission;
|
||||
@@ -351,18 +696,106 @@ class UploadService extends Service {
|
||||
if (!record) return false;
|
||||
|
||||
// 删除物理文件(file_path 为相对 app/public 的路径,如 works/benke/xxx.docx)
|
||||
// 复用 removePublicFile:统一走「前缀白名单 + best-effort」的安全删除路径,
|
||||
// 避免各处各写一份裸 unlink(历史上正是这种分散写法导致清理失败被静默吞掉)。
|
||||
//
|
||||
// 🔴 R157:删除结果**不再丢弃**。此前返回值被完全忽略 ⇒ 删除失败只进日志,
|
||||
// 运行期无计数、无暴露,孤儿文件在服务侧完全不可见(只能靠离线扫描发现,
|
||||
// 而离线扫描报出的孤儿无法回溯「是哪一次删除失败的」)。
|
||||
// 修法:累计到**服务实例计数器 + 暴露 getter**,⛔ 不抛错(保持 best-effort 语义)。
|
||||
if (record.file_path) {
|
||||
const absPath = path.join(app.baseDir, 'app/public', record.file_path);
|
||||
try {
|
||||
if (fs.existsSync(absPath)) fs.unlinkSync(absPath);
|
||||
} catch (e) {
|
||||
ctx.logger.error('[deleteSubmission] 删除物理文件失败:', e.message);
|
||||
}
|
||||
const r = this.removePublicFile(record.file_path, 'deleteSubmission');
|
||||
this._noteDeleteResult(r, record.file_path, 'deleteSubmission');
|
||||
}
|
||||
|
||||
await record.destroy();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 app/public 下的物理文件(统一的安全删除入口)
|
||||
*
|
||||
* 【为什么需要它】C9 根因治理(2026-09-29 · R45):此前「删作品文件」这件事在
|
||||
* service/upload.js、controller/admin.js、controller/exam.js 各写了一遍裸
|
||||
* `fs.unlinkSync`,既没有路径白名单、失败也只在个别处记日志 ⇒
|
||||
* 一旦删除失败就静默留下一份无人认领的文件(孤儿)。
|
||||
* 现收敛为**唯一入口**:所有「按 DB 里的相对路径删文件」都必须经过这里。
|
||||
*
|
||||
/**
|
||||
* 【安全闸门】只允许删除 `works/` 与 `tmp/` 前缀下的文件:
|
||||
* - DB 里的 file_path 天然是 `works/<group>/<file>`;前缀白名单可挡住
|
||||
* 「脏数据 / 手工改库 / SQL 注入」把 `file_path` 指向 `templates/`、`carousel/`
|
||||
* 等静态资源时被连带删除的风险。
|
||||
* - 含 `..` 的路径一律拒绝(防目录穿越)。
|
||||
*
|
||||
* 【失败语义】best-effort,**永不抛错**:删除是副作用,不能污染主业务结果
|
||||
* (详见 extend/helper.js:safeCleanup 的说明)。
|
||||
*
|
||||
* @param {string} relativePath - 相对 app/public 的路径,如 `works/benke/work_x.pdf`
|
||||
* @param {string} [label] - 日志场景名
|
||||
* @returns {'removed'|'missing'|'skipped'} 删除结果(不抛错)
|
||||
*/
|
||||
/**
|
||||
* 【R157】登记一次删除结果 —— 把「删除失败」从**只有日志**变成**可计数、可读取**。
|
||||
*
|
||||
* 【为什么不抛错】删除是副作用,失败不得污染主业务结果(best-effort 语义见
|
||||
* extend/helper.js:safeCleanup)。但「不抛错」⛔ 不等于「不可见」——
|
||||
* 此前 removed/skipped/missing 三态被调用方直接丢弃,导致孤儿文件在运行期
|
||||
* 完全不可见。这里累计计数并暴露 `delStats` / `getDeleteStats()`,
|
||||
* 供健康检查、探针与门禁读取。
|
||||
*
|
||||
* @param {'removed'|'missing'|'skipped'} r - removePublicFile 的返回值
|
||||
* @param {string} rel - 相对路径(仅用于日志定位)
|
||||
* @param {string} label - 场景名
|
||||
* @returns {{removed:number,skipped:number,missing:number,failed:number}} 累计计数(快照)
|
||||
*/
|
||||
_noteDeleteResult(r, rel, label = 'removePublicFile') {
|
||||
if (!this.delStats) {
|
||||
this.delStats = { removed: 0, skipped: 0, missing: 0, failed: 0, lastErrorPath: null };
|
||||
}
|
||||
const s = this.delStats;
|
||||
if (r === 'removed') s.removed += 1;
|
||||
else if (r === 'skipped') { s.skipped += 1; s.failed += 1; s.lastErrorPath = rel; }
|
||||
else if (r === 'missing') s.missing += 1;
|
||||
|
||||
if (s.failed > 0) {
|
||||
const logger = (this.ctx && this.ctx.logger) || (this.app && this.app.logger);
|
||||
if (logger && logger.warn) {
|
||||
logger.warn(
|
||||
`[delStats] 累计删除异常 ${s.failed} 次` +
|
||||
`(removed=${s.removed} skipped=${s.skipped} missing=${s.missing})` +
|
||||
` 最近失败路径=${s.lastErrorPath} ⇒ 疑似残留孤儿文件,` +
|
||||
` 可用 scan_orphan_files.py 离线复核`
|
||||
);
|
||||
}
|
||||
}
|
||||
return { ...s };
|
||||
}
|
||||
|
||||
/** 读取累计删除统计(健康检查 / 探针 / 门禁用;⛔ 不含任何副作用)。 */
|
||||
getDeleteStats() {
|
||||
return { ...(this.delStats || { removed: 0, skipped: 0, missing: 0, failed: 0, lastErrorPath: null }) };
|
||||
}
|
||||
|
||||
removePublicFile(relativePath, label = 'removePublicFile') {
|
||||
const { ctx, app } = this;
|
||||
const logger = (ctx && ctx.logger) || app.logger;
|
||||
try {
|
||||
if (!relativePath || typeof relativePath !== 'string') return 'missing';
|
||||
|
||||
const norm = relativePath.replace(/\\/g, '/').replace(/^\/+/, '');
|
||||
if (!/^(works|tmp)\//.test(norm) || norm.includes('..')) {
|
||||
logger.warn(`[removePublicFile:${label}] 拒绝删除非作品区路径(安全闸门): ${relativePath}`);
|
||||
return 'skipped';
|
||||
}
|
||||
|
||||
const absPath = path.join(app.baseDir, 'app/public', norm);
|
||||
return ctx.helper.safeCleanup(absPath, { label });
|
||||
} catch (e) {
|
||||
logger.warn(`[removePublicFile:${label}] 异常(已忽略,不影响主流程): ${relativePath} -> ${e && e.message}`);
|
||||
return 'skipped';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = UploadService;
|
||||
|
||||
@@ -32,13 +32,32 @@ async function sendFile(ctx, app, opts) {
|
||||
if (absolutePath) {
|
||||
absPath = absolutePath;
|
||||
} else {
|
||||
// 🔴 路径穿越防护:只允许 app/public 下的安全相对路径(字母/数字/_./- 且不含 ..)
|
||||
if (!relativePath || !/^[A-Za-z0-9_.\/-]+$/.test(relativePath) || relativePath.includes('..')) {
|
||||
// 🔴 路径穿越防护(2026-10-12 修复):改为「危险形态黑名单 + resolve 前缀校验」。
|
||||
// 原实现是全 ASCII 白名单 /^[A-Za-z0-9_.\/-]+$/,会把合法中文文件名(如 `测试.xlsx`)
|
||||
// 误判为非法路径 ⇒ materials(资料)/ exam(模板)/ admin(作品·导出)三个下载通道
|
||||
// 全部对中文名返回 400「非法文件路径」(生产实测 bug)。中文文件名是设计内支持的
|
||||
// (见下方 RFC 5987 分支),故白名单口径错误。
|
||||
// ① 形态拦截:控制字符/NUL · 反斜杠 · 绝对路径(/ 开头或盘符) · `..` 路径段
|
||||
const unsafeShape =
|
||||
!relativePath ||
|
||||
/[\u0000-\u001f\u007f\\]/.test(relativePath) ||
|
||||
relativePath.startsWith('/') ||
|
||||
/^[A-Za-z]:/.test(relativePath) ||
|
||||
relativePath.split('/').includes('..');
|
||||
if (unsafeShape) {
|
||||
ctx.status = 400;
|
||||
ctx.body = ctx.helper.error(400, '非法文件路径');
|
||||
return;
|
||||
}
|
||||
// ② 归一化后前缀校验(最硬兜底):解析结果必须仍落在 app/public 之内,
|
||||
// 即使 ① 被绕过也逃不出根目录(比字符白名单更可靠,且不误伤 Unicode 文件名)。
|
||||
const publicRoot = path.resolve(app.baseDir, 'app/public');
|
||||
absPath = path.resolve(publicRoot, relativePath);
|
||||
if (absPath !== publicRoot && !absPath.startsWith(publicRoot + path.sep)) {
|
||||
ctx.status = 400;
|
||||
ctx.body = ctx.helper.error(400, '非法文件路径');
|
||||
return;
|
||||
}
|
||||
absPath = path.join(app.baseDir, 'app/public', relativePath);
|
||||
}
|
||||
|
||||
// 🔴🔴 磁盘存在性校验(F17-B 防「假成功」· 2026-08-12 生产事故根因)
|
||||
@@ -77,16 +96,37 @@ async function sendFile(ctx, app, opts) {
|
||||
// 🔴 X-Accel-Redirect:把文件交给 nginx 内部 location 直接从磁盘流式吐出,
|
||||
// Node 只发响应头、不再持有文件流 → 极端并发下几乎零占用 worker。
|
||||
const internalPath = cfg.internalPrefix.replace(/\/+$/, '/') + relativePath.replace(/^\/+/, '');
|
||||
// 🔴 X-Accel-Redirect 是 HTTP 头值,只允许 ASCII:中文/全角文件名必须**逐段 URL 编码**
|
||||
// (保留 `/` 分隔符),否则 Node 的 res.setHeader 抛 ERR_INVALID_CHAR ⇒ 500。
|
||||
// 2026-10-12 生产实测:修复 :36 校验放行中文名后,本行成为新的失败点(400 → 500)。
|
||||
// nginx 按**解码后**的 URI 匹配 internal location,再由 alias 落到磁盘真实中文文件名。
|
||||
const internalUri = '/' + internalPath.replace(/^\/+/, '').split('/').map(encodeURIComponent).join('/');
|
||||
ctx.set('Content-Type', contentType);
|
||||
// 🔴 下载完整性哨兵:把「服务端认定的真实字节数」透出给前端。
|
||||
// X-Accel 分支下真正吐字节的是 nginx,Node 无从得知 nginx 是否吐对了东西
|
||||
// (内部 location 缺失时会吐前端 index.html)。前端会比对 blob.size 与本头,不一致即拒落盘。
|
||||
// nginx 的 X-Accel-Redirect 会保留上游自定义响应头,故此头可穿透到浏览器。
|
||||
// (内部 location 缺失时会吐前端 index.html)。前端会比对 blob.size 与哨兵,不一致即拒落盘。
|
||||
ctx.set('X-File-Size', String(fileStat.size));
|
||||
// 🔴🔴 2026-10-12(R103)生产实测修正 —— 原注释「nginx 的 X-Accel-Redirect 会保留上游自定义
|
||||
// 响应头,故此头可穿透到浏览器」**不成立**,已证伪:
|
||||
// 双通道对照实测(探针 .workbuddy/probes/2026-10-12-r103/prod_xfs_passthrough.js):
|
||||
// · 直连后端 :8023 ⇒ 204 空体,但响应带 `x-file-size: 19009`(后端确实发到了线上)
|
||||
// · 经 nginx :80 ⇒ 200 / 19009 字节(sha256 与磁盘逐字节一致,确由 nginx 吐出),
|
||||
// 而 **`x-file-size` 缺失**
|
||||
// · 对照 /api/health 经 nginx ⇒ 自定义头 `x-readtime` **保留**
|
||||
// ⇒ 丢弃只发生在 **X-Accel 内部重定向**这条路径(非「nginx 一概丢自定义头」)。
|
||||
// 影响:该分支下前端哨兵空转(下载完整性校验被静默跳过)。
|
||||
// 修法:改走「nginx 会保留的通道」—— Content-Disposition 实测能穿透(ARM B 仍在),
|
||||
// 故把同一份字节数写成 RFC 6266 §4.1 的 disposition 扩展参数 `size=`。
|
||||
// 前端按 `x-file-size` → CD `size=` 顺序回落(见 utils/download.js readDeclaredSize)。
|
||||
// 两通道同源同值;任一缺失只降级不误伤。
|
||||
// ⚠️ Node 流分支(absolutePath)无需此旁路:其响应走 nginx 常规转发,自定义头本就保留。
|
||||
// 中文名按 RFC 5987 编码(filename*=UTF-8''<encodeURIComponent>),浏览器自动解码;
|
||||
// 切勿再包 encodeURIComponent 造成双重编码。
|
||||
ctx.set('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(fileName)}`);
|
||||
ctx.set('X-Accel-Redirect', internalPath);
|
||||
// `size=` 追加在 filename* 之后:浏览器忽略未知 disposition 参数,不影响下载行为。
|
||||
ctx.set('Content-Disposition',
|
||||
`attachment; filename*=UTF-8''${encodeURIComponent(fileName)}; size=${fileStat.size}`);
|
||||
// ⚠️ 必须用编码后的 internalUri(纯 ASCII),不可直接用含中文的 internalPath。
|
||||
ctx.set('X-Accel-Redirect', internalUri);
|
||||
ctx.set('X-Accel-Buffering', 'no'); // 大文件边收边发,避免 nginx 缓冲撑爆内存
|
||||
ctx.status = 200;
|
||||
ctx.body = null;
|
||||
|
||||
@@ -10,8 +10,14 @@
|
||||
* 规则说明:
|
||||
* - type: 'string' + required: true + allowEmpty: false:字段必填且不可为空串/纯空白
|
||||
* - trim: true:校验前去除首尾空白
|
||||
* - min: 6:字符串最小长度(用于密码强度底线)
|
||||
* - min / max:字符串长度区间(口令口径 6-20,与前端 el-form 规则保持一致)
|
||||
* - message:为每个校验项提供中文错误文案,parameter 会在 errors[] 中携带对应 message
|
||||
*
|
||||
* ⚠️ 口令的**强度**(弱口令黑名单 / 纯重复字符 / 连续序列 / 账号派生值)**不在本文件**。
|
||||
* 本文件只负责「必填 + 长度格式」这一层;强度规则由 app/lib/passwordPolicy.js
|
||||
* 单源提供,调用点在 controller/auth.js 的 activate() 与 resetPassword()。
|
||||
* 分层理由:同一份强度规则需被多个口令写入口共用,并须能被运维脚本复用
|
||||
* (脚本要扫描存量账号是否命中弱口令),散落实现必然出现「一处拦、一处放」。
|
||||
*/
|
||||
|
||||
const required = (msg) => ({
|
||||
@@ -52,10 +58,12 @@ module.exports = {
|
||||
allowEmpty: false,
|
||||
trim: true,
|
||||
min: 6,
|
||||
max: 20,
|
||||
message: {
|
||||
required: '请输入密码',
|
||||
empty: '请输入密码',
|
||||
min: '密码长度不能少于 6 位',
|
||||
max: '密码长度不能超过 20 位',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -76,10 +84,12 @@ module.exports = {
|
||||
allowEmpty: false,
|
||||
trim: true,
|
||||
min: 6,
|
||||
max: 20,
|
||||
message: {
|
||||
required: '请输入密码',
|
||||
empty: '请输入密码',
|
||||
min: '密码长度不能少于 6 位',
|
||||
max: '密码长度不能超过 20 位',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -10,14 +10,62 @@
|
||||
// 向上查找,并支持 DEPLOY_ENV_FILE 显式指定),填充 process.env;仅当变量尚未设置时
|
||||
// 生效(外部注入优先),保证幂等。
|
||||
// 注意:APP_ENV / NODE_ENV 不从此处加载,避免本地开发误判为生产(真实生产由启动器注入)。
|
||||
//
|
||||
// 🔴 2026-09-23 修复(本地启动崩溃 1045,详见 memory/2026-09-23.md):
|
||||
// 原先本函数在【模块加载期】立即执行,且无条件把 .env.production 的全部键灌进
|
||||
// process.env —— 于是本地 `npm run dev` 也会拿到**生产库账号**(见 deploy/.env.production
|
||||
// 的 DB_USER),
|
||||
// 而本机 MySQL 并无该账号 ⇒ SequelizeAccessDeniedError(1045) ⇒ agent_worker 退出
|
||||
// ⇒ master 退出 ⇒ 7001 无监听。现改为两处收口:
|
||||
// ① 【执行时机】推迟到 module.exports(appInfo) 内部 —— 此时 appInfo.env 已确定;
|
||||
// ② 【作用范围】非生产环境跳过「连接类 / 凭据类 / 网关开关类」键(见下方常量)。
|
||||
// 生产(appInfo.env === 'prod' 或 NODE_ENV=production)**不跳过任何键**,行为逐键不变。
|
||||
// 生产必然满足判定的三重依据(2026-09-23 取证):
|
||||
// a) egg-scripts/lib/cmd/start.js:109 无条件 `env.NODE_ENV = 'production'`;
|
||||
// b) deploy/.env.production:17 `NODE_ENV=production`,且 start-backend.sh 以
|
||||
// `set -a; source "$ENV_FILE"` 注入 shell 后再 exec node;
|
||||
// c) start.js:126 注释:argv -> EGG_SERVER_ENV -> 兜底 egg 取 `prod`。
|
||||
// ---------------------------------------------------------------------------
|
||||
(function loadDeployEnv() {
|
||||
const DEPLOY_ENV_SKIP_KEYS = new Set(['APP_ENV', 'NODE_ENV']);
|
||||
|
||||
// 非生产环境额外跳过的键前缀:这些键一旦注入本机进程会造成**真实副作用**,
|
||||
// 或把生产凭据带进本地进程。逐类取证依据(2026-09-23):
|
||||
// DB_ —— 实证危害:注入生产库账号后本地必然 1045 崩溃(2026-09-23 已实测);
|
||||
// REDIS_ —— 当前全库无 app.redis 使用点(grep 零命中),但一旦启用即连生产 Redis;
|
||||
// ALIYUN_ —— 短信网关密钥;本地 realSend 恒 false(auth.js:54 `isProduction && realSend`),
|
||||
// 注入与否不改变行为,跳过仅为避免生产密钥落入本地进程(泄露面);
|
||||
// SMS_ —— SMS_ENABLED 全库零引用(config.sms.enabled 无使用点),跳过仅为口径一致。
|
||||
// 仍保留注入的键:EGG_WORKERS / STATIC_BASE_URL 等 —— 本地无副作用。
|
||||
// ⚠️ 2026-10-12(R106-D9)修正:JWT_SECRET / APP_KEYS **不再**保留注入。
|
||||
// 原判「本地无副作用」不成立 —— 本地会拿到**生产签名密钥** ⇒ 跨环境会话可互相冒充。
|
||||
// 现已并入 DEPLOY_ENV_LOCAL_SKIP_KEYS 精确跳过,本地改由 scripts/.creds.local.json 供值。
|
||||
const DEPLOY_ENV_LOCAL_SKIP_PREFIXES = ['DB_', 'REDIS_', 'ALIYUN_', 'SMS_'];
|
||||
|
||||
// 🔴 2026-10-12(R106-D9):非生产环境额外跳过的**签名密钥精确键**。
|
||||
// 此前注释写「仍保留注入的键:JWT_SECRET / APP_KEYS ... —— 本地无副作用」,该结论**不成立**:
|
||||
// · 本地 dev 于是直接继承 deploy/.env.production 里的**生产签名密钥** ⇒ 本地进程
|
||||
// 可签发**生产可接受**的 token(本地 token 能冒充生产用户,反之亦然),
|
||||
// 两套环境的会话边界实际上是打通的 —— 这是**安全属性**缺陷,不是"无副作用";
|
||||
// · 且它与 R105/R106 刚建立的纪律直接冲突:JWT_SECRET / APP_KEYS 已被要求
|
||||
// "缺凭据明确失败"(_secret.cjs),却仍允许本地静默拿到生产值 ⇒ 掩蔽注入故障。
|
||||
// 修法:与 DB_/REDIS_/ALIYUN_/SMS_ 同向收口,用**精确键**匹配(⛔ 不用前缀,
|
||||
// 避免误伤 APP_KEYS_* 之类的无关键)。非生产侧改由 scripts/.creds.local.json 供值
|
||||
// (_secret.cjs 的第 2 级),与 Python 侧 _fixtures.resolve_jwt_secret() 同源。
|
||||
// 生产(isProdEnv=true)**不跳过** ⇒ 本项零影响(生产本就由 systemd 注入)。
|
||||
const DEPLOY_ENV_LOCAL_SKIP_KEYS = new Set(['JWT_SECRET', 'APP_KEYS']);
|
||||
|
||||
function loadDeployEnv(appInfo) {
|
||||
try {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const SKIP_KEYS = new Set(['APP_ENV', 'NODE_ENV']);
|
||||
const targets = [];
|
||||
|
||||
// 生产判定:appInfo.env 为权威(Egg getServerEnv 的结果),NODE_ENV 为兼容兜底。
|
||||
// 两者皆不满足即视为非生产(本地/测试),采用**保守**方向跳过连接类键。
|
||||
const isProdEnv =
|
||||
process.env.NODE_ENV === 'production' ||
|
||||
(appInfo && (appInfo.env === 'prod' || appInfo.env === 'production'));
|
||||
|
||||
// 1) 显式指定(最高优先级)
|
||||
if (process.env.DEPLOY_ENV_FILE) targets.push(process.env.DEPLOY_ENV_FILE);
|
||||
|
||||
@@ -39,6 +87,7 @@
|
||||
}
|
||||
|
||||
let loadedFrom = null;
|
||||
let skipped = [];
|
||||
for (const file of targets) {
|
||||
let text;
|
||||
try {
|
||||
@@ -55,9 +104,20 @@
|
||||
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
||||
val = val.slice(1, -1);
|
||||
}
|
||||
if (key && !SKIP_KEYS.has(key) && process.env[key] === undefined) {
|
||||
process.env[key] = val;
|
||||
if (!key || DEPLOY_ENV_SKIP_KEYS.has(key) || process.env[key] !== undefined) continue;
|
||||
// 非生产环境:跳过连接类 / 凭据类 / 网关开关类键,避免「本地进程连到生产库」
|
||||
if (!isProdEnv && DEPLOY_ENV_LOCAL_SKIP_PREFIXES.some((p) => key.startsWith(p))) {
|
||||
skipped.push(key);
|
||||
continue;
|
||||
}
|
||||
// 非生产环境:跳过**签名密钥精确键**(JWT_SECRET / APP_KEYS)—— 避免本地进程持有
|
||||
// 生产签名密钥(跨环境会话可互相冒充),并让本地改由 scripts/.creds.local.json 供值,
|
||||
// 与 Python 侧 _fixtures.resolve_jwt_secret() 同源。见 DEPLOY_ENV_LOCAL_SKIP_KEYS(R106-D9)。
|
||||
if (!isProdEnv && DEPLOY_ENV_LOCAL_SKIP_KEYS.has(key)) {
|
||||
skipped.push(key);
|
||||
continue;
|
||||
}
|
||||
process.env[key] = val;
|
||||
}
|
||||
loadedFrom = file;
|
||||
break; // 第一个存在的文件即为权威来源
|
||||
@@ -68,14 +128,30 @@
|
||||
} else {
|
||||
console.warn('[deploy-env] 未找到 .env.production,将完全依赖进程环境变量注入(缺失生产密钥会触发安全守卫)');
|
||||
}
|
||||
// 非生产环境把「被跳过的键」显式打印出来。⚠️ 只打印键名,绝不打印值。
|
||||
// 注意:本行出现 ≠ 那些键被注入(恰恰相反,是被跳过了),排查时勿误读。
|
||||
if (skipped.length > 0) {
|
||||
console.log(
|
||||
'[deploy-env] 非生产环境(env=' +
|
||||
((appInfo && appInfo.env) || process.env.NODE_ENV || 'unknown') +
|
||||
'),已跳过 ' +
|
||||
skipped.length +
|
||||
' 个连接类/凭据类键(防止本地进程连到生产库或持有生产密钥):' +
|
||||
skipped.join(', ')
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[deploy-env] 加载部署环境变量失败:' + (e && e.message));
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
module.exports = (appInfo) => {
|
||||
const config = (exports = {});
|
||||
|
||||
// 🔴 必须在下文任何 process.env.* 读取之前执行(原本是模块级 IIFE,见函数头注释)。
|
||||
// 放在此处而非模块顶层,是为了拿到已确定的 appInfo.env 以区分生产/本地。
|
||||
loadDeployEnv(appInfo);
|
||||
|
||||
const isProd = process.env.NODE_ENV === 'production' || appInfo.env === 'prod';
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
@@ -96,13 +172,28 @@ module.exports = (appInfo) => {
|
||||
// 测试数据默认口令:重置测试数据 / 标记为测试数据导入 时,统一把密码设为该口令的
|
||||
// bcrypt 哈希,方便测试人员重置后、或用 Excel「标记为测试数据」导入后,直接以该口令登录重测
|
||||
// (无需再走激活流程)。仅作用于 test_flag=1 的测试数据,生产数据(test_flag=0)不受影响。
|
||||
// 【测试模拟值 '123456';如需变更可设环境变量 TEST_DEFAULT_PASSWORD】
|
||||
config.testDefaultPassword = process.env.TEST_DEFAULT_PASSWORD || '123456';
|
||||
// 🔴 2026-10-12(R106-D7):**生产环境取消弱口令兜底**。
|
||||
// 原实现无条件 `|| '123456'`,而 deploy/.env.production 的 TEST_DEFAULT_PASSWORD
|
||||
// 一直是**注释状态**(该文件 L88)⇒ 生产实际取到的就是弱口令 '123456'。
|
||||
// 一旦生产走到「批量写默认口令」的路径(重置测试数据 / 显式 testFlag=true 的导入),
|
||||
// 一批账号会被设为 '123456' —— 这是**静默弱口令**,而非"注入链路断了"的明确失败,
|
||||
// 与 _secret.cjs / DB_PASSWORD 的既有纪律不一致(R104 结论:同族问题各自实现必然漂移)。
|
||||
// 现约定:生产(config.isProduction)未显式注入 ⇒ 置 null,由使用点
|
||||
// app/lib/passwordPolicy.requireTestPassword() 明确失败(⛔ 不静默回退)。
|
||||
// 测试环境语义**逐字不变**(仍 '123456',既有测试前置脚本与门禁不受影响)。
|
||||
config.testDefaultPassword = process.env.TEST_DEFAULT_PASSWORD
|
||||
|| (config.isProduction ? null : '123456');
|
||||
|
||||
// Cookie 签名密钥:优先读取环境变量 APP_KEYS,缺省时回退到内置值(仅供本地开发)
|
||||
// 【测试模拟值,生产请通过环境变量 APP_KEYS 替换为随机字符串,否则启动被守卫阻断】
|
||||
const DEFAULT_APP_KEYS = appInfo.name + '_competition_website_secret_key_2026';
|
||||
config.keys = process.env.APP_KEYS || DEFAULT_APP_KEYS;
|
||||
// Cookie 签名密钥(Egg `config.keys`):唯一来源见 backend/scripts/_secret.cjs;⛔ 无兜底。
|
||||
// 🔴 2026-10-12(R106-D6):原实现为
|
||||
// const DEFAULT_APP_KEYS = appInfo.name + '_competition_website_secret_key_2026';
|
||||
// config.keys = process.env.APP_KEYS || DEFAULT_APP_KEYS;
|
||||
// —— 「派生兜底」同样是**可预测公开值**(由 appInfo.name 可推算),语义等价于
|
||||
// 「Cookie 签名密钥公开 ⇒ 任何人可伪造已签名 Cookie」。与 JWT_SECRET(R105-D1)
|
||||
// 同治法收敛到唯一解析入口:① env APP_KEYS → ② scripts/.creds.local.json:app_keys
|
||||
// → ③ 明确失败(⛔ 刻意不提供明文/派生兜底)。
|
||||
const { loadAppKeys } = require('../scripts/_secret.cjs');
|
||||
config.keys = loadAppKeys();
|
||||
|
||||
// add your config here
|
||||
config.middleware = ['concurrencyGuard'];
|
||||
@@ -114,8 +205,46 @@ module.exports = (appInfo) => {
|
||||
// 总上限 = worker 数 × HEAVY_REQ_MAX,可适当提高。
|
||||
config.concurrencyGuard = {
|
||||
max: parseInt(process.env.HEAVY_REQ_MAX, 10) || 20,
|
||||
// 交卷独立上限(2026-09-21 并发压测结论):submit-all 纳入闸门后,过载由
|
||||
// 「静默 RST(考生不知道是否交上)」变为「明确 429 + Retry-After(可重试)」。
|
||||
// 取值理由(为何不与上传共用 20、也不放大到数百)见 middleware/concurrency_guard.js。
|
||||
submitMax: parseInt(process.env.SUBMIT_REQ_MAX, 10) || 24,
|
||||
// 登录独立上限(2026-09-21 并发压测**第二轮**结论):/api/auth/login 此前无闸门,
|
||||
// 800 人同刻登录实测「仅 484/800 取得 token(39.5% 在 TCP accept 层被拒)、
|
||||
// P50=9957ms / P95=24350ms、连接级重试 1134 次」。纳入闸门后过载由
|
||||
// 「静默 RST(考生不知道要不要重试)」变为「明确 429 + Retry-After(可重试)」。
|
||||
// 取 16(低于交卷的 24):登录是 CPU 型重任务(bcrypt 10 rounds),
|
||||
// 换手粒度比 IO 型交卷更粗,对 accept 循环的挤压更直接。
|
||||
// 为何不纳入 admin-login、以及与 loginRateLimit 的顺序红线,
|
||||
// 见 middleware/concurrency_guard.js 的 LOGIN_PATTERNS 段注释。
|
||||
loginMax: parseInt(process.env.LOGIN_REQ_MAX, 10) || 16,
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// 🔴 集群 worker 数(2026-09-21 并发压测结论,必须显式设定)
|
||||
// -----------------------------------------------------------------
|
||||
// 背景:`egg-scripts start` 未传 --workers 时,egg-cluster 会按 os.cpus().length
|
||||
// 起同等数量的 app worker —— 核数越多起得越多(开发机 8 核 → 8 个 worker)。
|
||||
// 而每个 worker 的连接池上限是 DB_POOL_MAX(默认 20),8 × 20 = 160
|
||||
// **已经超过** MySQL max_connections(151)。高峰期会出现 connection acquire
|
||||
// timeout,在考生侧表现为「交卷失败」,且服务端不报业务错,极难定位。
|
||||
//
|
||||
// 落点:worker 数由环境变量 EGG_WORKERS 控制(egg-scripts start 把它作为
|
||||
// --workers 的默认值,见 node_modules/egg-scripts/lib/cmd/start.js:31)。
|
||||
// 生产值写在 deploy/.env.production(EGG_WORKERS=2,与服务器 2 核匹配;
|
||||
// 2026-09-29 R40 由 4 校正为 2),并由 start-backend.sh 显式传参作双保险。
|
||||
// 2 × 20 = 40 ≤ 151,为管理后台/统计等其余连接留足余量。
|
||||
//
|
||||
// ⚠️ 不要试图在此处写 config.cluster.workers —— egg-cluster 只读
|
||||
// startCluster(options.workers)(options.js:24 → master.js:267),
|
||||
// 该值在 master 进程由 egg-scripts 的命令行/EGG_WORKERS 决定;
|
||||
// config 阶段的配置对 worker **数量**无效。此处仅作说明,避免后人误配。
|
||||
//
|
||||
// ⚠️ 多 worker 的两个已知副作用(均为进程内状态,非缺陷但需知悉):
|
||||
// ① concurrencyGuard 的 active 是**进程内**计数,总上限 = worker 数 × max;
|
||||
// ② loginRateLimit 的失败计数同为进程内 Map,实际阈值 = worker 数 × maxAttempts,
|
||||
// 仅影响「防爆破」强度,不影响正确性(登录成功即清零该 key)。
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// sequelize - MariaDB/MySQL 数据库配置
|
||||
@@ -123,12 +252,23 @@ module.exports = (appInfo) => {
|
||||
config.sequelize = {
|
||||
dialect: 'mysql',
|
||||
database: process.env.DB_NAME || 'competition_db',
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
// 🔴 2026-10-12(R106-D8)修复存量缺陷:兜底 host 由字面量 'localhost' 改为 '127.0.0.1'。
|
||||
// 根因(实测):Node ≥ 18 的 DNS 结果顺序默认 `verbatim`(不再强制 IPv4 优先),
|
||||
// 而 'localhost' 在本机通常同时解析出 ::1 与 127.0.0.1 ⇒ 可能先连 IPv6 `::1`;
|
||||
// 本机 MariaDB 默认 `bind-address` 只监听 IPv4 ⇒ 连接落到 ::1 后**建连超时
|
||||
// (ETIMEDOUT)**,既不报 1045 也不报 ECONNREFUSED,现象是「后端启动卡在 DB
|
||||
// 连接」——极易被误判为「服务没起来 / 端口被占」,而真正的故障点是地址族。
|
||||
// 取证一致性问题:本仓 `scripts/_db.cjs:43` 的兜底早已是 '127.0.0.1',唯独此处
|
||||
// 是 'localhost' ⇒ 同一子系统两套默认值,必然漂移。收敛到 IPv4 字面量。
|
||||
// 影响面:生产 `.env.production` 本就显式注入 DB_HOST ⇒ 逐字不变;仅影响兜底路径。
|
||||
host: process.env.DB_HOST || '127.0.0.1',
|
||||
port: parseInt(process.env.DB_PORT, 10) || 3306,
|
||||
// 兼容 docker-compose/.env.example 中的 DB_USER 与代码历史使用的 DB_USERNAME
|
||||
username: process.env.DB_USERNAME || process.env.DB_USER || 'root',
|
||||
// 【测试模拟值 '74191221',仅供本地/测试部署;生产请通过 DB_PASSWORD 环境变量注入真实密码】
|
||||
password: process.env.DB_PASSWORD || '74191221',
|
||||
// 🔴 口令不再内置明文兜底(2026-09-23 入库口令处置 P3):
|
||||
// 生产 —— 必须由 .env.production 注入 DB_PASSWORD(缺失则由下方生产安全守卫阻断启动);
|
||||
// 本地 —— 由 config/config.local.js(已 gitignore)深合并覆盖为真实本机口令。
|
||||
password: process.env.DB_PASSWORD,
|
||||
timezone: '+08:00',
|
||||
define: {
|
||||
underscored: true,
|
||||
@@ -144,14 +284,21 @@ module.exports = (appInfo) => {
|
||||
// 由上方 define.collate 负责。此处传 collate 会触发
|
||||
// "Ignoring invalid configuration option passed to Connection: collate" 警告。
|
||||
charset: 'utf8mb4',
|
||||
// 🔴 DECIMAL 必须显式转 Number(2026-09-23,一位小数评分)
|
||||
// mysql2 默认把 DECIMAL 读成**字符串**,会导致:① 前端出现
|
||||
// "7.5" + 0 → "7.50" 之类的字符串拼接;② `n > per` 退化为字符串比较。
|
||||
// 开启后 DECIMAL 以 Number 返回,与 INTEGER 行为一致;全库原无 DECIMAL 列
|
||||
// (迁移 025 首次引入),故对既有功能零影响。
|
||||
decimalNumbers: true,
|
||||
},
|
||||
pool: {
|
||||
// 🔴 压测调优(2026-08-11):单 worker 默认 10 连接,在「无带宽限制 + 极端并发」
|
||||
// 下分片合并/写入会短时占用大量连接。提高到 20(可用 DB_POOL_MAX 覆盖),
|
||||
// 并放宽 acquire 到 45s,避免瞬时峰值触发 "connection acquire timeout"。
|
||||
// 注意:这是「每 worker」上限;生产 egg-cluster 按 os.cpus() 起多 worker,
|
||||
// 总连接数 ≈ worker 数 × max,需确保 ≤ MySQL max_connections(默认 151,
|
||||
// 启动期由 app.js didReady 自举断言兜底,超限只 WARN 不阻断)。
|
||||
// 注意:这是「每 worker」上限;worker 数由 EGG_WORKERS 显式控制(生产 = 2,
|
||||
// 见本文件上方「集群 worker 数」段),总连接数 = worker 数 × max = 2 × 20 = 40,
|
||||
// 须 ≤ MySQL max_connections(默认 151)。启动期由 app.js didReady 自举断言
|
||||
// 按**实际 worker 数**复核(不再用 os.cpus() 误估),超限只 WARN 不阻断。
|
||||
max: parseInt(process.env.DB_POOL_MAX, 10) || 20,
|
||||
min: 2,
|
||||
acquire: 45000,
|
||||
@@ -188,7 +335,8 @@ module.exports = (appInfo) => {
|
||||
client: {
|
||||
port: parseInt(process.env.REDIS_PORT, 10) || 6379,
|
||||
host: process.env.REDIS_HOST || '127.0.0.1',
|
||||
password: process.env.REDIS_PASSWORD || '74191221',
|
||||
// 🔴 同上:不再内置明文兜底(2026-09-23 P3)。Redis 当前全库无使用点,留空为零影响。
|
||||
password: process.env.REDIS_PASSWORD || '',
|
||||
db: 0,
|
||||
},
|
||||
};
|
||||
@@ -196,11 +344,19 @@ module.exports = (appInfo) => {
|
||||
// -----------------------------------------------------------------
|
||||
// JWT 鉴权配置
|
||||
// -----------------------------------------------------------------
|
||||
const DEFAULT_JWT_SECRET = 'competition_jwt_secret_key_2026_br!cs';
|
||||
// 🔴 2026-10-12(R105-D1,用户授权):移除内置明文兜底密钥。
|
||||
// 原实现:`const DEFAULT_JWT_SECRET = '<明文串>'` + `process.env.JWT_SECRET || DEFAULT_JWT_SECRET`
|
||||
// —— 密钥缺失时静默回退到一个**已入库、任何人可读**的常量,且该常量与
|
||||
// scripts/.creds.local.json 的 jwt_fallback_secret 同值
|
||||
// ⇒ 等价于「JWT 签名密钥公开 ⇒ 任意人可伪造 token 冒充任意身份」。
|
||||
// 现收敛到唯一解析入口 `backend/scripts/_secret.cjs`(与 _db.cjs / _creds.py 同一纪律):
|
||||
// ① env JWT_SECRET → ② scripts/.creds.local.json:jwt_fallback_secret → ③ 明确失败
|
||||
// 行为等价性:生产与本地均由注入链路提供密钥(deploy/.env.production 的 JWT_SECRET
|
||||
// 经 loadDeployEnv 注入 process.env)⇒ 正常运行路径逐字不变,仅消除了兜底分支。
|
||||
const { loadJwtSecret } = require('../scripts/_secret.cjs');
|
||||
config.jwt = {
|
||||
// JWT 签名密钥:优先读取环境变量 JWT_SECRET,缺省时回退到内置值(仅供本地开发)
|
||||
// 【测试模拟值,生产请通过环境变量 JWT_SECRET 替换为 64 位以上随机串,否则启动被守卫阻断】
|
||||
secret: process.env.JWT_SECRET || DEFAULT_JWT_SECRET,
|
||||
// JWT 签名密钥:唯一来源见 _secret.cjs 头部说明;⛔ 无明文兜底,缺失即明确失败
|
||||
secret: loadJwtSecret(),
|
||||
enable: true,
|
||||
// Token 有效期(秒),默认 7 天
|
||||
expiresIn: 7 * 24 * 60 * 60,
|
||||
@@ -223,7 +379,16 @@ module.exports = (appInfo) => {
|
||||
// -----------------------------------------------------------------
|
||||
config.multipart = {
|
||||
mode: 'stream',
|
||||
fileSize: '100mb',
|
||||
// 🔴 2026-10-23 R225:'100mb' -> '150mb'
|
||||
// 缘由:教师组(jiaoshi)作品上传唯一通道 = POST /api/upload/:group 单次流式整包
|
||||
// (分片通道前端已于 R43 删除),故全组共用这一个硬闸门。原值与前端
|
||||
// uploadCombo.ZIP_MAX=200MB 长期不一致 ⇒ 100~200MB 的 zip 前端放行、后端拒收
|
||||
// (MultipartFileTooLargeError),多位教师报障。
|
||||
// ⚠️ 本值是**所有** multipart 通道的唯一上限,调整时须与下列三处同步:
|
||||
// frontend/src/utils/uploadCombo.js (ZIP_MAX / OTHER_MAX)
|
||||
// frontend/src/views/Competition.vue (同上常量 + 文案)
|
||||
// deploy/competition_website.conf (client_max_body_size 200m,须 > 本值)
|
||||
fileSize: '150mb',
|
||||
whitelist: [
|
||||
// 文档类
|
||||
'.pdf',
|
||||
@@ -343,14 +508,91 @@ module.exports = (appInfo) => {
|
||||
maxDays: 14,
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// 节点备份调度(考试开始前 / 考试结束后 / 成绩发布后)
|
||||
// -----------------------------------------------------------------
|
||||
// 需求来源:用户 2026-10-16「系统在考试开始前,考试结束后,成绩发布后这三个
|
||||
// 节点应该执行数据备份」。触发层用**后端钩子 + Node 定时器**(用户裁定①:
|
||||
// 巡检脚本会占额外内存),实现见 app/lib/nodeBackupScheduler.js。
|
||||
//
|
||||
// 🔴 生产隔离:备份目标是**生产库**(用户裁定②)⇒ 本段仅在生产环境生效,
|
||||
// 非生产 enabled=false 且调度器构造时即封死。这是与上方
|
||||
// DEPLOY_ENV_LOCAL_SKIP_PREFIXES=['DB_',...] 同一纪律的延伸:
|
||||
// 非生产进程不得持有生产连接凭据、不得去 dump 生产库。
|
||||
//
|
||||
// ⛔ 与 scripts/backup_daily.sh 的分工:那个脚本被自身硬门禁锁死在**本地库**
|
||||
// (端口须 ∈ {3306,3307,33060}、BACKUP_ENV_FILE 匹配 *production* 一律拒绝),
|
||||
// 故本节点备份**不复用它的入口**,只复用它底层的 scripts/backup_db.py
|
||||
// (同一份 mysqldump + gzip + 完整性校验 + 保留期治理,避免两套校验口径漂移)。
|
||||
// ⛔ 绝不「放宽 backup_daily.sh 的门禁来复用」—— 那是把本地防线拆给生产用。
|
||||
// 🔴 路径纪律:生产机的目录布局**与本地仓库不一致**,⛔ 不许用 `__dirname` 推导
|
||||
// (R168 实测踩坑)。取证依据(2026-08-10 / 2026-09-24 生产实证):
|
||||
// 后端 baseDir = /home/root/production/agent-code/back-end/competition-website
|
||||
// (注意是 `back-end` 连字符,不是仓库里的 `backend`)
|
||||
// 脚本目录 = /home/root/production/agent-code/scripts/ ← 与 deploy/ 同级
|
||||
// 环境文件 = /home/root/production/agent-code/deploy/competition-website/.env.production
|
||||
// (systemd EnvironmentFile,见 deploy/competition-backend.service)
|
||||
// ⇒ `repoRoot = resolve(__dirname,'../..')` 在本地指向仓库根、在生产指向
|
||||
// `.../agent-code/back-end` ⇒ `join(root,'deploy','competition-website','scripts')`
|
||||
// 在生产解析成 `.../back-end/deploy/competition-website/scripts` =**不存在**。
|
||||
// 解法 = 全部走环境变量、可显式覆盖,默认值只覆盖本地开发:
|
||||
// BACKUP_SCRIPT / BACKUP_ENV_FILE / BACKUP_OUT_DIR / BACKUP_LOG_DIR。
|
||||
// ⚠️ 这四项须在 deploy/.env.production 中显式声明(部署检查清单会逐条核对),
|
||||
// 否则生产会落到下面的默认值 ⇒ **路径错 ⇒ 备份静默失败**(P1 级)。
|
||||
const nbPath = require('path');
|
||||
const localRepo = nbPath.resolve(__dirname, '..', '..');
|
||||
config.nodeBackup = {
|
||||
enabled: true,
|
||||
// 触发缓冲(用户裁定①的「留出缓冲」口径)
|
||||
beforeStartBufferMin: Number(process.env.BACKUP_BEFORE_START_MIN) || 30, // 考试开始前 30 分钟
|
||||
afterEndDelayMin: Number(process.env.BACKUP_AFTER_END_MIN) || 10, // 考试结束后 10 分钟
|
||||
// 🔴 备份脚本路径:⛔ 本地默认值在生产不存在 ⇒ 生产必须由 BACKUP_SCRIPT 覆盖
|
||||
scriptPath: process.env.BACKUP_SCRIPT
|
||||
|| nbPath.join(localRepo, 'scripts', 'backup_db.py'),
|
||||
// 环境文件(生产库凭据来源)
|
||||
envFile: process.env.BACKUP_ENV_FILE
|
||||
|| nbPath.join(localRepo, 'deploy', '.env.production'),
|
||||
// 节点备份独立目录:⛔ 不复用 backups/db —— 否则会被每日备份的 keep-days
|
||||
// 清理误删(每日 14 天 vs 节点备份需长期留存以备「赛后复盘/申诉」回溯)
|
||||
outDir: process.env.BACKUP_OUT_DIR
|
||||
|| nbPath.join(localRepo, 'backups', 'db-nodes'),
|
||||
logDir: process.env.BACKUP_LOG_DIR
|
||||
|| nbPath.join(localRepo, 'logs', 'backup', 'nodes'),
|
||||
keepDays: Number(process.env.BACKUP_KEEP_DAYS) || 90,
|
||||
// 跨进程锁 TTL(秒):残留锁(kill -9)会永久挡住后续备份 ⇒ 需能接管。
|
||||
// 取 2h:远大于一次 dump 的正常耗时(分钟级),又不会让崩溃残留锁永久生效。
|
||||
lockTtlSec: Number(process.env.BACKUP_LOCK_TTL_SEC) || 7200,
|
||||
// 服务器 Py 3.6.8 无 pymysql/openpyxl ⇒ 既有约定走 /opt/pytools/lib
|
||||
pythonPath: process.env.BACKUP_PYTHON_PATH || '/opt/pytools/lib',
|
||||
mysqldump: process.env.BACKUP_MYSQLDUMP || '',
|
||||
// ⚠️ spawn 的 cwd:只影响相对路径解析,backup_db.py 内部路径均基于 __dirname
|
||||
repoRoot: process.env.BACKUP_CWD || localRepo,
|
||||
};
|
||||
// 非生产强制关闭(即使误设 enabled=true 也不生效)
|
||||
if (!config.isProduction) {
|
||||
config.nodeBackup.enabled = false;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// 生产环境安全守卫:禁止使用内置默认密钥上线
|
||||
// (由 APP_ENV=production 触发;测试环境允许使用模拟密钥)
|
||||
// (由 APP_ENV=production 触发)
|
||||
// -----------------------------------------------------------------
|
||||
if (config.isProduction) {
|
||||
const insecure = [];
|
||||
if (config.jwt.secret === DEFAULT_JWT_SECRET) insecure.push('JWT_SECRET');
|
||||
if (config.keys === DEFAULT_APP_KEYS) insecure.push('APP_KEYS');
|
||||
// 🔴 2026-10-12(R105-D1):JWT_SECRET 不再在此比对 —— 内置兜底常量已移除,
|
||||
// 解析统一走 backend/scripts/_secret.cjs;密钥缺失时 loadJwtSecret() 在配置
|
||||
// 加载期即抛错 ⇒ 任何环境都不可能"静默使用公开密钥",本守卫无需(也无法)
|
||||
// 再做「是否等于内置值」的判断。
|
||||
// 🔴 2026-10-12(R106-D6):APP_KEYS 同 JWT_SECRET —— 内置**派生兜底常量**
|
||||
// (appInfo.name + '_competition_website_secret_key_2026')已移除,解析统一走
|
||||
// backend/scripts/_secret.cjs;密钥缺失时 loadAppKeys() 在配置加载期即抛错
|
||||
// ⇒ 任何环境都不可能"静默使用可预测的 Cookie 签名密钥",本守卫无需(也无法)
|
||||
// 再做「是否等于内置值」的判断。
|
||||
// 🔴 2026-09-23(入库口令处置 P3):口令缺失同样阻断。
|
||||
// 原实现在此处会静默回退到公开的开发口令,等价于「生产用公开口令连库」。
|
||||
// 现要求生产必须显式注入 DB_PASSWORD(deploy/.env.production 本就含该键,
|
||||
// 故现网永不触发本分支;一旦触发说明注入链路断了,明确阻断优于连不上库)。
|
||||
if (!config.sequelize.password) insecure.push('DB_PASSWORD');
|
||||
if (insecure.length > 0) {
|
||||
// 生产环境仍在使用硬编码默认密钥属于严重安全隐患,直接阻断启动
|
||||
throw new Error(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
{
|
||||
"development": {
|
||||
"username": "root",
|
||||
"password": "74191221",
|
||||
"password": "CHANGE_ME_local_db_password",
|
||||
"database": "competition_db",
|
||||
"host": "localhost",
|
||||
"port": 3306,
|
||||
@@ -24,7 +24,7 @@
|
||||
},
|
||||
"test": {
|
||||
"username": "root",
|
||||
"password": "74191221",
|
||||
"password": "CHANGE_ME_local_db_password",
|
||||
"database": "competition_db_test",
|
||||
"host": "localhost",
|
||||
"port": 3306,
|
||||
80
database/migrations/023-024-big-question-tables.sql
Normal file
80
database/migrations/023-024-big-question-tables.sql
Normal file
@@ -0,0 +1,80 @@
|
||||
-- ============================================================================
|
||||
-- 023 / 024 · 综合大题改造(中职组 / 高职组取消 Word 上传题 → 随机抽取大题在线作答)
|
||||
-- 日期:2026-09-21
|
||||
-- 目标库:MariaDB 5.5.68 competition_db (字符集 utf8mb4 / utf8mb4_general_ci)
|
||||
--
|
||||
-- 本文件是**服务器同步用**的可直接执行 DDL(与 023-*.js / 024-*.js 迁移等价)。
|
||||
-- 本改造为纯 ADD TABLE:
|
||||
-- · 不改动任何现有表结构
|
||||
-- · 不动 zhongzhi_submissions / gaozhi_submissions(客观题链路)
|
||||
-- · 不动 benke_submissions / jiaoshi_submissions(本科/教师组)
|
||||
-- · 不动成绩发布门控与总分公式(中职/高职总分仍 = exam_score + word_score)
|
||||
--
|
||||
-- 注意:MariaDB 5.5 无 JSON 列类型(JSON 一律用 TEXT / MEDIUMTEXT 存文本);
|
||||
-- DATETIME 不支持 DEFAULT CURRENT_TIMESTAMP,时间戳由应用层写入。
|
||||
-- ============================================================================
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 023 · 综合大题题库
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `big_questions` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '大题ID',
|
||||
`question_uid` varchar(64) NOT NULL COMMENT '大题稳定标识(全局唯一,形如 big_12)',
|
||||
`group` varchar(20) NOT NULL COMMENT '组别:zhongzhi/gaozhi',
|
||||
`title` varchar(200) NOT NULL COMMENT '大题标题',
|
||||
`description` mediumtext DEFAULT NULL COMMENT '题干富文本 HTML(含表格)',
|
||||
`sub_prompt_1` text DEFAULT NULL COMMENT '第1小问题干',
|
||||
`sub_prompt_2` text DEFAULT NULL COMMENT '第2小问题干',
|
||||
`sub_prompt_3` text DEFAULT NULL COMMENT '第3小问题干',
|
||||
`score_per_sub` int(11) NOT NULL DEFAULT '10' COMMENT '每小题分值',
|
||||
`total_score` int(11) NOT NULL DEFAULT '30' COMMENT '大题总分',
|
||||
`sort_order` int(11) NOT NULL DEFAULT '0' COMMENT '排序',
|
||||
`status` int(11) NOT NULL DEFAULT '1' COMMENT '状态:1=启用(参与抽题),0=停用',
|
||||
`created_at` datetime DEFAULT NULL COMMENT '创建时间',
|
||||
`updated_at` datetime DEFAULT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_big_question_uid` (`question_uid`),
|
||||
KEY `idx_big_q_group_status` (`group`,`status`,`sort_order`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
|
||||
COMMENT='综合大题题库(中职/高职组,替代原 Word 上传题)';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 024 · 综合大题作答(一考生一行)
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `big_question_answers` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
`aggregation_uid` int(11) NOT NULL COMMENT '聚合报名ID(中职/高职组 == contestants.id),唯一 → 一考生一行',
|
||||
`contestant_id` int(11) DEFAULT NULL COMMENT '选手ID(冗余,便于管理端查证与排障)',
|
||||
`group` varchar(20) NOT NULL COMMENT '组别:zhongzhi/gaozhi',
|
||||
`big_question_id` int(11) DEFAULT NULL COMMENT '抽中的大题ID',
|
||||
`big_question_uid` varchar(64) DEFAULT NULL COMMENT '抽中的大题稳定标识',
|
||||
`question_snapshot` mediumtext DEFAULT NULL COMMENT '抽题时刻的题干快照 JSON(防纠纷存证)',
|
||||
`answer_1` text DEFAULT NULL COMMENT '第1小问作答(上限2000字,后端同步校验)',
|
||||
`answer_2` text DEFAULT NULL COMMENT '第2小问作答(上限2000字)',
|
||||
`answer_3` text DEFAULT NULL COMMENT '第3小问作答(上限2000字)',
|
||||
`score_1` int(11) DEFAULT NULL COMMENT '第1小问得分(0-10,人工阅卷)',
|
||||
`score_2` int(11) DEFAULT NULL COMMENT '第2小问得分(0-10,人工阅卷)',
|
||||
`score_3` int(11) DEFAULT NULL COMMENT '第3小问得分(0-10,人工阅卷)',
|
||||
`total_score` int(11) DEFAULT NULL COMMENT '三小问得分合计(回写 contestants.word_score 的依据)',
|
||||
`submit_time` datetime DEFAULT NULL COMMENT '随整卷正式提交写入(未提交为 NULL)',
|
||||
`created_at` datetime DEFAULT NULL COMMENT '创建时间',
|
||||
`updated_at` datetime DEFAULT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_big_answer_agg` (`aggregation_uid`),
|
||||
KEY `idx_big_answer_group` (`group`),
|
||||
KEY `idx_big_answer_q` (`big_question_uid`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
|
||||
COMMENT='综合大题作答(一考生一行,含题干快照)';
|
||||
|
||||
-- ============================================================================
|
||||
-- 回滚(仅在确认弃用时执行)
|
||||
-- DROP TABLE IF EXISTS `big_question_answers`;
|
||||
-- DROP TABLE IF EXISTS `big_questions`;
|
||||
--
|
||||
-- 历史数据清理(2026-09-21 用户确认:已弃用,可全部删除)
|
||||
-- DELETE FROM zhongzhi_submissions; -- 84 行(孤儿遗留,aggregation_uid 与现有选手不匹配)
|
||||
-- DELETE FROM gaozhi_submissions; -- 480 行
|
||||
-- UPDATE competition_times SET word_template = NULL WHERE `group` IN ('zhongzhi','gaozhi');
|
||||
-- -- 并删除磁盘旧文件:app/public/answers/{zhongzhi,gaozhi}/、app/public/templates/{zhongzhi,gaozhi}/
|
||||
-- -- ⚠️ 仅限中职/高职;本科/教师组的 word_template 与作品文件一律不得触碰。
|
||||
-- ============================================================================
|
||||
110
database/migrations/023-create-big-questions.js
Normal file
110
database/migrations/023-create-big-questions.js
Normal file
@@ -0,0 +1,110 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 023 - 综合大题题库表 big_questions
|
||||
*
|
||||
* 背景(2026-09-21 需求变更)
|
||||
* 中职组 / 高职组取消「Word 文件上传题」,改为从题库随机抽取一道大题在线作答;
|
||||
* 大题含「文字描述 + 富文本表格」,下设三小问,每小问 10 分,大题总分 30 分。
|
||||
*
|
||||
* 隔离原则(硬约束:除本次功能外不得影响其他功能)
|
||||
* 本表为**新增隔离表**,不改动任何现有表结构与既有客观题链路
|
||||
* (choice_questions / judge_questions / zhongzhi_submissions / gaozhi_submissions
|
||||
* 以及本科/教师组 benke_submissions / jiaoshi_submissions 全部零改动)。
|
||||
*
|
||||
* 环境说明
|
||||
* 目标库为 MariaDB 5.5.68,**不支持 JSON 列类型**,故富文本 / 序列化数据一律用
|
||||
* TEXT / MEDIUMTEXT 存储;亦不支持 DATETIME 的 DEFAULT CURRENT_TIMESTAMP,
|
||||
* 时间戳列交由应用层(Sequelize timestamps)写入,与既有表(word_questions 等)一致。
|
||||
*/
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.createTable('big_questions', {
|
||||
id: {
|
||||
type: Sequelize.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false,
|
||||
comment: '大题ID',
|
||||
},
|
||||
question_uid: {
|
||||
type: Sequelize.STRING(64),
|
||||
allowNull: false,
|
||||
comment: '大题稳定标识(全局唯一,形如 big_12)',
|
||||
},
|
||||
group: {
|
||||
type: Sequelize.STRING(20),
|
||||
allowNull: false,
|
||||
comment: '组别:zhongzhi/gaozhi',
|
||||
},
|
||||
title: {
|
||||
type: Sequelize.STRING(200),
|
||||
allowNull: false,
|
||||
comment: '大题标题',
|
||||
},
|
||||
description: {
|
||||
type: Sequelize.TEXT('medium'),
|
||||
allowNull: true,
|
||||
comment: '题干富文本 HTML(含表格)',
|
||||
},
|
||||
sub_prompt_1: {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true,
|
||||
comment: '第1小问题干',
|
||||
},
|
||||
sub_prompt_2: {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true,
|
||||
comment: '第2小问题干',
|
||||
},
|
||||
sub_prompt_3: {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true,
|
||||
comment: '第3小问题干',
|
||||
},
|
||||
score_per_sub: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 10,
|
||||
comment: '每小题分值',
|
||||
},
|
||||
total_score: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 30,
|
||||
comment: '大题总分',
|
||||
},
|
||||
sort_order: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
comment: '排序',
|
||||
},
|
||||
status: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 1,
|
||||
comment: '状态:1=启用(参与抽题),0=停用',
|
||||
},
|
||||
created_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: true,
|
||||
comment: '创建时间',
|
||||
},
|
||||
updated_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: true,
|
||||
comment: '更新时间',
|
||||
},
|
||||
}).then(() => {
|
||||
return Promise.all([
|
||||
queryInterface.addIndex('big_questions', ['question_uid'], { name: 'uk_big_question_uid', unique: true }),
|
||||
queryInterface.addIndex('big_questions', ['group', 'status', 'sort_order'], { name: 'idx_big_q_group_status' }),
|
||||
]);
|
||||
});
|
||||
},
|
||||
|
||||
down: async (queryInterface) => {
|
||||
await queryInterface.dropTable('big_questions');
|
||||
},
|
||||
};
|
||||
127
database/migrations/024-create-big-question-answers.js
Normal file
127
database/migrations/024-create-big-question-answers.js
Normal file
@@ -0,0 +1,127 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 024 - 综合大题作答表 big_question_answers
|
||||
*
|
||||
* 背景(2026-09-21 需求变更)
|
||||
* 中职组 / 高职组取消 Word 上传题,改为随机抽取一道大题、在页面内直接作答三小问。
|
||||
*
|
||||
* 设计要点
|
||||
* 1. **一考生一行**:UNIQUE(aggregation_uid) —— 满足「一个考生一行数据保存」的目标,
|
||||
* 且与题量配置解耦(客观题题量由 competition_times.choice_count/judge_count 配置,
|
||||
* 属逐题表 zhongzhi/gaozhi_submissions,与本表互不干扰)。
|
||||
* 2. **题干快照 question_snapshot**:抽题那一刻把题干(标题/描述/三小问/分值)序列化落库,
|
||||
* 题目后续被管理员修改也不影响存证 → 防考生纠纷。
|
||||
* 3. **不存储每题一列**:客观题 50+20=70 题且题量可配,逐题建列会导致配置每变更一次
|
||||
* 就要 ALTER TABLE(反扩展性);大题固定 3 小问,故 answer_1/2/3 列式存储是安全的。
|
||||
*
|
||||
* 隔离原则
|
||||
* 新增隔离表,zhongzhi_submissions / gaozhi_submissions 零改动、零迁移。
|
||||
*
|
||||
* 环境说明
|
||||
* MariaDB 5.5.68:无 JSON 列类型(快照用 MEDIUMTEXT 存 JSON 文本);
|
||||
* DATETIME 不支持 DEFAULT CURRENT_TIMESTAMP,时间戳交给应用层写入。
|
||||
*/
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.createTable('big_question_answers', {
|
||||
id: {
|
||||
type: Sequelize.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false,
|
||||
comment: '主键ID',
|
||||
},
|
||||
aggregation_uid: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false,
|
||||
comment: '聚合报名ID(中职/高职组 == contestants.id),唯一 → 一考生一行',
|
||||
},
|
||||
contestant_id: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: true,
|
||||
comment: '选手ID(冗余,便于管理端查证与排障)',
|
||||
},
|
||||
group: {
|
||||
type: Sequelize.STRING(20),
|
||||
allowNull: false,
|
||||
comment: '组别:zhongzhi/gaozhi',
|
||||
},
|
||||
big_question_id: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: true,
|
||||
comment: '抽中的大题ID',
|
||||
},
|
||||
big_question_uid: {
|
||||
type: Sequelize.STRING(64),
|
||||
allowNull: true,
|
||||
comment: '抽中的大题稳定标识',
|
||||
},
|
||||
question_snapshot: {
|
||||
type: Sequelize.TEXT('medium'),
|
||||
allowNull: true,
|
||||
comment: '抽题时刻的题干快照 JSON(防纠纷存证)',
|
||||
},
|
||||
answer_1: {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true,
|
||||
comment: '第1小问作答(上限 2000 字,后端同步校验)',
|
||||
},
|
||||
answer_2: {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true,
|
||||
comment: '第2小问作答(上限 2000 字)',
|
||||
},
|
||||
answer_3: {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true,
|
||||
comment: '第3小问作答(上限 2000 字)',
|
||||
},
|
||||
score_1: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: true,
|
||||
comment: '第1小问得分(0-10,人工阅卷)',
|
||||
},
|
||||
score_2: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: true,
|
||||
comment: '第2小问得分(0-10,人工阅卷)',
|
||||
},
|
||||
score_3: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: true,
|
||||
comment: '第3小问得分(0-10,人工阅卷)',
|
||||
},
|
||||
total_score: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: true,
|
||||
comment: '三小问得分合计(阅卷后回写 contestants.word_score 取数依据)',
|
||||
},
|
||||
submit_time: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: true,
|
||||
comment: '随整卷正式提交写入(占位/未提交为 NULL)',
|
||||
},
|
||||
created_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: true,
|
||||
comment: '创建时间',
|
||||
},
|
||||
updated_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: true,
|
||||
comment: '更新时间',
|
||||
},
|
||||
}).then(() => {
|
||||
return Promise.all([
|
||||
queryInterface.addIndex('big_question_answers', ['aggregation_uid'], { name: 'uk_big_answer_agg', unique: true }),
|
||||
queryInterface.addIndex('big_question_answers', ['group'], { name: 'idx_big_answer_group' }),
|
||||
queryInterface.addIndex('big_question_answers', ['big_question_uid'], { name: 'idx_big_answer_q' }),
|
||||
]);
|
||||
});
|
||||
},
|
||||
|
||||
down: async (queryInterface) => {
|
||||
await queryInterface.dropTable('big_question_answers');
|
||||
},
|
||||
};
|
||||
99
database/migrations/025-scores-decimal-one-place.sql
Normal file
99
database/migrations/025-scores-decimal-one-place.sql
Normal file
@@ -0,0 +1,99 @@
|
||||
-- ============================================================================
|
||||
-- 025 一位小数评分支持:分数列 int(11) → DECIMAL(6,1)
|
||||
--
|
||||
-- 背景(2026-09-23)
|
||||
-- 需求:中职 / 高职「综合大题」的人工评分允许保留小数点后一位(如 7.5)。
|
||||
-- 现状:全库 23 个 score 类列**全部**为 int(11) ⇒ 小数落库即被静默截断;
|
||||
-- 且 service/bigQuestion.js:337 另有一处 Math.round() 主动取整。
|
||||
-- 本迁移把「得分」语义的列放宽为 DECIMAL(6,1)(上限 99999.9,保留 1 位小数)。
|
||||
--
|
||||
-- 范围(17 列)
|
||||
-- 改(得分 / 分数落点):
|
||||
-- big_question_answers.score_1 / score_2 / score_3 / total_score
|
||||
-- contestants.exam_score / word_score / ppt_score / score
|
||||
-- accounts.score
|
||||
-- zhongzhi_submissions.score / gaozhi_submissions.score / exam_submissions.score
|
||||
-- benke_submissions.score / jiaoshi_submissions.score / work_submissions.score
|
||||
-- big_questions.score_per_sub / total_score(大题分值配置,随评分口径)
|
||||
-- 不改:
|
||||
-- choice_questions.score / judge_questions.score
|
||||
-- —— 客观题口径已固定为每题 1 分(service/admin.js OBJECTIVE_SCORE_PER_QUESTION),
|
||||
-- 且 scripts/check_question_bank_health.py 门禁断言其恒为 1;
|
||||
-- word_questions.score、exam_questions/zhongzhi_questions/gaozhi_questions.score
|
||||
-- —— 属题目分值配置或历史遗留题表,无小数需求。
|
||||
--
|
||||
-- 兼容性
|
||||
-- INT → DECIMAL(6,1) 是**放宽**:既有整数值原样保留,读写语义不变。
|
||||
-- ⚠️ 应用侧必须同步:
|
||||
-- ① config/config.default.js 的 sequelize.dialectOptions.decimalNumbers = true
|
||||
-- (否则 mysql2 把 DECIMAL 读成**字符串**);
|
||||
-- ② 相关 model 字段类型声明为 DECIMAL,且 getter 转 Number
|
||||
-- (否则 Sequelize 的 INTEGER.parse 会把 7.5 二次截断成 7)。
|
||||
--
|
||||
-- 执行(在 database/ 目录下):
|
||||
-- mysql -uroot -p competition_db < migrations/025-scores-decimal-one-place.sql
|
||||
-- 幂等:MODIFY 到同一类型可重复执行。
|
||||
-- ============================================================================
|
||||
|
||||
-- ---------------------------------------------------------------- 大题作答
|
||||
ALTER TABLE `big_question_answers`
|
||||
MODIFY COLUMN `score_1` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '第 1 小问得分(可含 1 位小数)',
|
||||
MODIFY COLUMN `score_2` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '第 2 小问得分(可含 1 位小数)',
|
||||
MODIFY COLUMN `score_3` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '第 3 小问得分(可含 1 位小数)',
|
||||
MODIFY COLUMN `total_score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '大题合计得分(可含 1 位小数)';
|
||||
|
||||
-- ---------------------------------------------------------------- 大题配置
|
||||
ALTER TABLE `big_questions`
|
||||
MODIFY COLUMN `score_per_sub` DECIMAL(6,1) NOT NULL DEFAULT 10 COMMENT '每小题分值',
|
||||
MODIFY COLUMN `total_score` DECIMAL(6,1) NOT NULL DEFAULT 30 COMMENT '大题总分 = 每小题分值 × 小问数';
|
||||
|
||||
-- ---------------------------------------------------------------- 选手成绩
|
||||
ALTER TABLE `contestants`
|
||||
MODIFY COLUMN `exam_score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '卷面考试成绩',
|
||||
MODIFY COLUMN `word_score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT 'Word题/综合大题得分(可含 1 位小数)',
|
||||
MODIFY COLUMN `ppt_score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT 'PPT得分(本科/教师组,可含 1 位小数)',
|
||||
MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '选手总分(exam_score + word_score / word_score + ppt_score)';
|
||||
|
||||
-- ---------------------------------------------------------------- 账号分(历史兼容)
|
||||
ALTER TABLE `accounts`
|
||||
MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '总分(历史兼容字段)';
|
||||
|
||||
-- ---------------------------------------------------------------- 提交表分项
|
||||
ALTER TABLE `zhongzhi_submissions`
|
||||
MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '本题得分';
|
||||
ALTER TABLE `gaozhi_submissions`
|
||||
MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '本题得分';
|
||||
ALTER TABLE `exam_submissions`
|
||||
MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '卷面得分';
|
||||
ALTER TABLE `benke_submissions`
|
||||
MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '作品得分';
|
||||
ALTER TABLE `jiaoshi_submissions`
|
||||
MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '作品得分';
|
||||
ALTER TABLE `work_submissions`
|
||||
MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL COMMENT '作品得分';
|
||||
|
||||
-- ============================================================================
|
||||
-- 回滚(⚠️ DECIMAL → INT 会**丢失**既有小数位,执行前务必确认无小数值:
|
||||
-- SELECT COUNT(*) FROM contestants WHERE score <> FLOOR(score); -- 应为 0)
|
||||
--
|
||||
-- ALTER TABLE `big_question_answers`
|
||||
-- MODIFY COLUMN `score_1` INT(11) NULL DEFAULT NULL,
|
||||
-- MODIFY COLUMN `score_2` INT(11) NULL DEFAULT NULL,
|
||||
-- MODIFY COLUMN `score_3` INT(11) NULL DEFAULT NULL,
|
||||
-- MODIFY COLUMN `total_score` INT(11) NULL DEFAULT NULL;
|
||||
-- ALTER TABLE `big_questions`
|
||||
-- MODIFY COLUMN `score_per_sub` INT(11) NOT NULL DEFAULT 10,
|
||||
-- MODIFY COLUMN `total_score` INT(11) NOT NULL DEFAULT 30;
|
||||
-- ALTER TABLE `contestants`
|
||||
-- MODIFY COLUMN `exam_score` INT(11) NULL DEFAULT NULL,
|
||||
-- MODIFY COLUMN `word_score` INT(11) NULL DEFAULT NULL,
|
||||
-- MODIFY COLUMN `ppt_score` INT(11) NULL DEFAULT NULL,
|
||||
-- MODIFY COLUMN `score` INT(11) NULL DEFAULT NULL;
|
||||
-- ALTER TABLE `accounts` MODIFY COLUMN `score` INT(11) NULL DEFAULT NULL;
|
||||
-- ALTER TABLE `zhongzhi_submissions` MODIFY COLUMN `score` INT(11) NULL DEFAULT NULL;
|
||||
-- ALTER TABLE `gaozhi_submissions` MODIFY COLUMN `score` INT(11) NULL DEFAULT NULL;
|
||||
-- ALTER TABLE `exam_submissions` MODIFY COLUMN `score` INT(11) NULL DEFAULT NULL;
|
||||
-- ALTER TABLE `benke_submissions` MODIFY COLUMN `score` INT(11) NULL DEFAULT NULL;
|
||||
-- ALTER TABLE `jiaoshi_submissions` MODIFY COLUMN `score` INT(11) NULL DEFAULT NULL;
|
||||
-- ALTER TABLE `work_submissions` MODIFY COLUMN `score` INT(11) NULL DEFAULT NULL;
|
||||
-- ============================================================================
|
||||
98
database/migrations/026-scores-decimal-two-places.sql
Normal file
98
database/migrations/026-scores-decimal-two-places.sql
Normal file
@@ -0,0 +1,98 @@
|
||||
-- ============================================================================
|
||||
-- 026 两位小数评分支持:分数列 DECIMAL(6,1) → DECIMAL(6,2)
|
||||
--
|
||||
-- 背景(2026-09-28 测试人员反馈)
|
||||
-- 需求:中职 / 高职「综合大题」的人工评分需精确到**小数点后两位**(如 8.25)。
|
||||
-- 现状:025 已把得分列由 INT(11) 放宽为 DECIMAL(6,1),但仍只有 1 位小数
|
||||
-- ⇒ 8.25 会被静默收敛为 8.3 / 8.2,测试人员的评分意图落不了库。
|
||||
--
|
||||
-- 范围(17 列,与 025 完全一致,仅精度 1 → 2 位)
|
||||
-- 大题作答:big_question_answers.score_1 / score_2 / score_3 / total_score
|
||||
-- 大题配置:big_questions.score_per_sub / total_score(随评分口径)
|
||||
-- 选手成绩:contestants.exam_score / word_score / ppt_score / score
|
||||
-- 账号分: accounts.score
|
||||
-- 提交表: zhongzhi_submissions.score / gaozhi_submissions.score /
|
||||
-- exam_submissions.score / benke_submissions.score /
|
||||
-- jiaoshi_submissions.score / work_submissions.score
|
||||
-- 不改: choice_questions.score / judge_questions.score
|
||||
-- —— 客观题口径已固定每题 1 分(service/admin.js OBJECTIVE_SCORE_PER_QUESTION),
|
||||
-- 且 scripts/check_question_bank_health.py 门禁断言其恒为 1。
|
||||
--
|
||||
-- 兼容性
|
||||
-- DECIMAL(6,1) → DECIMAL(6,2) 是**放宽**:既有值原样保留(7.5 读出来仍是 7.5),
|
||||
-- 读写语义不变。取值范围 [−9999.99, 9999.99],足够覆盖总分 / 单题分。
|
||||
-- ⚠️ 应用侧必须同步(本次已改):
|
||||
-- ① lib/decimal_score.js → DECIMAL(6,2)(否则 Sequelize 会把 8.25 按 (6,1) 截断)
|
||||
-- ② service/bigQuestion.js 的逐问校验与合计收敛 = ×100 / 100
|
||||
-- ③ service/admin.js 的复核汇总收敛 = ×100 / 100
|
||||
-- ④ 前端 Admin.vue 评分输入 :precision="2" :step="0.25"
|
||||
--
|
||||
-- 执行(在 database/ 目录下):
|
||||
-- mysql -uroot -p competition_db < migrations/026-scores-decimal-two-places.sql
|
||||
-- 幂等:MODIFY 到同一类型可重复执行。
|
||||
-- ============================================================================
|
||||
|
||||
-- ---------------------------------------------------------------- 大题作答
|
||||
ALTER TABLE `big_question_answers`
|
||||
MODIFY COLUMN `score_1` DECIMAL(6,2) NULL DEFAULT NULL COMMENT '第 1 小问得分(可含 2 位小数)',
|
||||
MODIFY COLUMN `score_2` DECIMAL(6,2) NULL DEFAULT NULL COMMENT '第 2 小问得分(可含 2 位小数)',
|
||||
MODIFY COLUMN `score_3` DECIMAL(6,2) NULL DEFAULT NULL COMMENT '第 3 小问得分(可含 2 位小数)',
|
||||
MODIFY COLUMN `total_score` DECIMAL(6,2) NULL DEFAULT NULL COMMENT '大题合计得分(可含 2 位小数)';
|
||||
|
||||
-- ---------------------------------------------------------------- 大题配置
|
||||
ALTER TABLE `big_questions`
|
||||
MODIFY COLUMN `score_per_sub` DECIMAL(6,2) NOT NULL DEFAULT 10 COMMENT '每小题分值',
|
||||
MODIFY COLUMN `total_score` DECIMAL(6,2) NOT NULL DEFAULT 30 COMMENT '大题总分 = 每小题分值 × 小问数';
|
||||
|
||||
-- ---------------------------------------------------------------- 选手成绩
|
||||
ALTER TABLE `contestants`
|
||||
MODIFY COLUMN `exam_score` DECIMAL(6,2) NULL DEFAULT NULL COMMENT '卷面考试成绩',
|
||||
MODIFY COLUMN `word_score` DECIMAL(6,2) NULL DEFAULT NULL COMMENT 'Word题/综合大题得分(可含 2 位小数)',
|
||||
MODIFY COLUMN `ppt_score` DECIMAL(6,2) NULL DEFAULT NULL COMMENT 'PPT得分(本科/教师组,可含 2 位小数)',
|
||||
MODIFY COLUMN `score` DECIMAL(6,2) NULL DEFAULT NULL COMMENT '选手总分(exam_score + word_score / word_score + ppt_score)';
|
||||
|
||||
-- ---------------------------------------------------------------- 账号分(历史兼容)
|
||||
ALTER TABLE `accounts`
|
||||
MODIFY COLUMN `score` DECIMAL(6,2) NULL DEFAULT NULL COMMENT '总分(历史兼容字段)';
|
||||
|
||||
-- ---------------------------------------------------------------- 提交表分项
|
||||
ALTER TABLE `zhongzhi_submissions`
|
||||
MODIFY COLUMN `score` DECIMAL(6,2) NULL DEFAULT NULL COMMENT '本题得分';
|
||||
ALTER TABLE `gaozhi_submissions`
|
||||
MODIFY COLUMN `score` DECIMAL(6,2) NULL DEFAULT NULL COMMENT '本题得分';
|
||||
ALTER TABLE `exam_submissions`
|
||||
MODIFY COLUMN `score` DECIMAL(6,2) NULL DEFAULT NULL COMMENT '卷面得分';
|
||||
ALTER TABLE `benke_submissions`
|
||||
MODIFY COLUMN `score` DECIMAL(6,2) NULL DEFAULT NULL COMMENT '作品得分';
|
||||
ALTER TABLE `jiaoshi_submissions`
|
||||
MODIFY COLUMN `score` DECIMAL(6,2) NULL DEFAULT NULL COMMENT '作品得分';
|
||||
ALTER TABLE `work_submissions`
|
||||
MODIFY COLUMN `score` DECIMAL(6,2) NULL DEFAULT NULL COMMENT '作品得分';
|
||||
|
||||
-- ============================================================================
|
||||
-- 回滚(DECIMAL(6,2) → DECIMAL(6,1) 会**丢失**第 2 位小数,执行前须确认:
|
||||
-- SELECT COUNT(*) FROM big_question_answers
|
||||
-- WHERE score_1 <> ROUND(score_1,1) OR score_2 <> ROUND(score_2,1)
|
||||
-- OR score_3 <> ROUND(score_3,1) OR total_score <> ROUND(total_score,1); -- 应为 0)
|
||||
--
|
||||
-- ALTER TABLE `big_question_answers`
|
||||
-- MODIFY COLUMN `score_1` DECIMAL(6,1) NULL DEFAULT NULL,
|
||||
-- MODIFY COLUMN `score_2` DECIMAL(6,1) NULL DEFAULT NULL,
|
||||
-- MODIFY COLUMN `score_3` DECIMAL(6,1) NULL DEFAULT NULL,
|
||||
-- MODIFY COLUMN `total_score` DECIMAL(6,1) NULL DEFAULT NULL;
|
||||
-- ALTER TABLE `big_questions`
|
||||
-- MODIFY COLUMN `score_per_sub` DECIMAL(6,1) NOT NULL DEFAULT 10,
|
||||
-- MODIFY COLUMN `total_score` DECIMAL(6,1) NOT NULL DEFAULT 30;
|
||||
-- ALTER TABLE `contestants`
|
||||
-- MODIFY COLUMN `exam_score` DECIMAL(6,1) NULL DEFAULT NULL,
|
||||
-- MODIFY COLUMN `word_score` DECIMAL(6,1) NULL DEFAULT NULL,
|
||||
-- MODIFY COLUMN `ppt_score` DECIMAL(6,1) NULL DEFAULT NULL,
|
||||
-- MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL;
|
||||
-- ALTER TABLE `accounts` MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL;
|
||||
-- ALTER TABLE `zhongzhi_submissions` MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL;
|
||||
-- ALTER TABLE `gaozhi_submissions` MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL;
|
||||
-- ALTER TABLE `exam_submissions` MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL;
|
||||
-- ALTER TABLE `benke_submissions` MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL;
|
||||
-- ALTER TABLE `jiaoshi_submissions` MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL;
|
||||
-- ALTER TABLE `work_submissions` MODIFY COLUMN `score` DECIMAL(6,1) NULL DEFAULT NULL;
|
||||
-- ============================================================================
|
||||
@@ -1,36 +1,41 @@
|
||||
'use strict';
|
||||
|
||||
// 初始管理员账号。
|
||||
// 口令:下方内置 bcrypt 哈希仅为**开发/演示**默认值(对应公开弱口令,仅供本机初始化)。
|
||||
// 正式初始化(生产等)时应通过环境变量 ADMIN_INIT_PASSWORD 注入真实口令 ——
|
||||
// 本文件⛔不落盘任何明文口令(铁律 25)。
|
||||
//
|
||||
// ⚠️ 【R111 更正 · 实测】本 seeder 原先 `bulkInsert` 到 **`accounts`** 表(row 含
|
||||
// role/name_cn/salt/id_number 等列);但登录链路读的是 **`admins`** 表
|
||||
// (`service/auth.js:101 findAdmin()` → `model/admin.js:49 tableName='admins'`)
|
||||
// ⇒ 原写法属**死写入**(种下的那行永远不会被登录读取)。
|
||||
// 已改为写入 `admins`,并按该表真实列契约(`id/username/password/created_at/updated_at`,
|
||||
// 见 `database/migrations/002-create-admins.js:17-46` 与 `database/schema.sql:20-29`)重写 row。
|
||||
// 详见 `.workbuddy/memory/BACKLOG.md` 候选⑳。
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
const now = new Date();
|
||||
await queryInterface.bulkInsert('accounts', [
|
||||
{
|
||||
role: 'admin',
|
||||
name_cn: '系统管理员',
|
||||
name_en: 'Administrator',
|
||||
gender: null,
|
||||
phone: null,
|
||||
email: 'admin@competition.com',
|
||||
id_number: '000000000000000000',
|
||||
password: '$2b$10$Nc6PCJItIwuOzpM22dTiAezB.jZMI8mzkCbg3oRZkAWx.QaI4co9.',
|
||||
salt: '$2b$10$Nc6PCJItIwuOzpM22dTiAe',
|
||||
position: '系统管理员',
|
||||
major: null,
|
||||
country: '中国',
|
||||
region: null,
|
||||
organization_cn: '赛事组委会',
|
||||
organization_en: 'Competition Committee',
|
||||
activated: 1,
|
||||
score: null,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
], {});
|
||||
// `admins` 表列契约:id(自增主键,勿传)/ username / password / created_at / updated_at。
|
||||
// ⛔ 该表**无** salt / role / name_cn / id_number / activated / score 等列。
|
||||
const row = {
|
||||
username: 'admin',
|
||||
// bcrypt(cost=10) 哈希 = 开发默认弱口令(公开值,仅供本机初始化;生产须经 ADMIN_INIT_PASSWORD 覆盖)。
|
||||
// 60 字符 < `admins.password VARCHAR(100)` ⇒ 列宽容得下。
|
||||
password: '$2b$10$Nc6PCJItIwuOzpM22dTiAezB.jZMI8mzkCbg3oRZkAWx.QaI4co9.',
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
// 若提供 ADMIN_INIT_PASSWORD,则以之为准重算 bcrypt 哈希(明文只经过程环境,⛔ 不落盘)。
|
||||
const initPwd = process.env.ADMIN_INIT_PASSWORD;
|
||||
if (initPwd) {
|
||||
row.password = require('bcrypt').hashSync(String(initPwd), 10);
|
||||
}
|
||||
await queryInterface.bulkInsert('admins', [row], {});
|
||||
},
|
||||
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.bulkDelete('accounts', {
|
||||
id_number: '000000000000000000',
|
||||
await queryInterface.bulkDelete('admins', {
|
||||
username: 'admin',
|
||||
}, {});
|
||||
},
|
||||
};
|
||||
|
||||
143
package-lock.json
generated
143
package-lock.json
generated
@@ -13,9 +13,10 @@
|
||||
"@alicloud/openapi-client": "^0.4.15",
|
||||
"@alicloud/tea-util": "^1.4.11",
|
||||
"@artus/core": "^2.2.4",
|
||||
"@e965/xlsx": "^0.20.3",
|
||||
"@eggjs/router": "^3.0.6",
|
||||
"archiver": "^8.0.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"bcrypt": "^6.0.0",
|
||||
"cliui": "^9.0.1",
|
||||
"egg": "^3.34.0",
|
||||
"egg-cors": "^2.2.4",
|
||||
@@ -29,7 +30,6 @@
|
||||
"mz-modules": "^2.1.0",
|
||||
"svg-captcha": "^1.4.0",
|
||||
"tslib": "^2.8.1",
|
||||
"xlsx": "^0.18.0",
|
||||
"ylru": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -372,6 +372,18 @@
|
||||
"xml2js": "^0.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@e965/xlsx": {
|
||||
"version": "0.20.3",
|
||||
"resolved": "https://registry.npmmirror.com/@e965/xlsx/-/xlsx-0.20.3.tgz",
|
||||
"integrity": "sha512-703RN/3OdsRD5mtse2HBX7Um7xwaP9tlswEG6svOtjqokXoX7rJdQj7DyabD2I+xk22RgaIIU+R6BHgkpZGB/w==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"xlsx": "bin/xlsx.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@eggjs/ip": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/@eggjs/ip/-/ip-2.1.0.tgz",
|
||||
@@ -1212,15 +1224,6 @@
|
||||
"node": ">= 16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/adler-32": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmmirror.com/adler-32/-/adler-32-1.3.1.tgz",
|
||||
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-6.0.2.tgz",
|
||||
@@ -1606,13 +1609,17 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bcryptjs": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/bcryptjs/-/bcryptjs-3.0.3.tgz",
|
||||
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"bin": {
|
||||
"bcrypt": "bin/bcrypt"
|
||||
"node_modules/bcrypt": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/bcrypt/-/bcrypt-6.0.0.tgz",
|
||||
"integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"node-addon-api": "^8.3.0",
|
||||
"node-gyp-build": "^4.8.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
@@ -1901,19 +1908,6 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/cfb": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmmirror.com/cfb/-/cfb-1.2.2.tgz",
|
||||
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"crc-32": "~1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/cfork": {
|
||||
"version": "1.11.0",
|
||||
"resolved": "https://registry.npmmirror.com/cfork/-/cfork-1.11.0.tgz",
|
||||
@@ -2205,15 +2199,6 @@
|
||||
"node": ">= 14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/codepage": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmmirror.com/codepage/-/codepage-1.15.0.tgz",
|
||||
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/coffee": {
|
||||
"version": "5.5.1",
|
||||
"resolved": "https://registry.npmmirror.com/coffee/-/coffee-5.5.1.tgz",
|
||||
@@ -4752,15 +4737,6 @@
|
||||
"pause-stream": "~0.0.11"
|
||||
}
|
||||
},
|
||||
"node_modules/frac": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/frac/-/frac-1.1.2.tgz",
|
||||
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/fresh": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmmirror.com/fresh/-/fresh-0.5.2.tgz",
|
||||
@@ -7748,6 +7724,14 @@
|
||||
"lower-case": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/node-addon-api": {
|
||||
"version": "8.9.2",
|
||||
"resolved": "https://registry.npmmirror.com/node-addon-api/-/node-addon-api-8.9.2.tgz",
|
||||
"integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==",
|
||||
"engines": {
|
||||
"node": "^18 || ^20 || >= 21"
|
||||
}
|
||||
},
|
||||
"node_modules/node-domexception": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/node-domexception/-/node-domexception-1.0.0.tgz",
|
||||
@@ -7767,6 +7751,16 @@
|
||||
"node": ">=10.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp-build": {
|
||||
"version": "4.8.4",
|
||||
"resolved": "https://registry.npmmirror.com/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
|
||||
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
|
||||
"bin": {
|
||||
"node-gyp-build": "bin.js",
|
||||
"node-gyp-build-optional": "optional.js",
|
||||
"node-gyp-build-test": "build-test.js"
|
||||
}
|
||||
},
|
||||
"node_modules/node-hex": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/node-hex/-/node-hex-1.0.1.tgz",
|
||||
@@ -9596,18 +9590,6 @@
|
||||
"url": "https://github.com/mysqljs/sql-escaper?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/ssf": {
|
||||
"version": "0.11.2",
|
||||
"resolved": "https://registry.npmmirror.com/ssf/-/ssf-0.11.2.tgz",
|
||||
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"frac": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/stack-trace": {
|
||||
"version": "0.0.10",
|
||||
"resolved": "https://registry.npmmirror.com/stack-trace/-/stack-trace-0.0.10.tgz",
|
||||
@@ -10840,24 +10822,6 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/wmf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/wmf/-/wmf-1.0.2.tgz",
|
||||
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/word": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/word/-/word-0.3.0.tgz",
|
||||
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/wordwrapjs": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/wordwrapjs/-/wordwrapjs-4.0.1.tgz",
|
||||
@@ -11077,27 +11041,6 @@
|
||||
"get-ready": "~1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xlsx": {
|
||||
"version": "0.18.5",
|
||||
"resolved": "https://registry.npmmirror.com/xlsx/-/xlsx-0.18.5.tgz",
|
||||
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"cfb": "~1.2.1",
|
||||
"codepage": "~1.15.0",
|
||||
"crc-32": "~1.2.1",
|
||||
"ssf": "~0.11.2",
|
||||
"wmf": "~1.0.1",
|
||||
"word": "~0.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"xlsx": "bin/xlsx.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/xml2js": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmmirror.com/xml2js/-/xml2js-0.6.2.tgz",
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"@artus/core": "^2.2.4",
|
||||
"@eggjs/router": "^3.0.6",
|
||||
"archiver": "^8.0.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"bcrypt": "^6.0.0",
|
||||
"cliui": "^9.0.1",
|
||||
"egg": "^3.34.0",
|
||||
"egg-cors": "^2.2.4",
|
||||
@@ -28,7 +28,7 @@
|
||||
"mz-modules": "^2.1.0",
|
||||
"svg-captcha": "^1.4.0",
|
||||
"tslib": "^2.8.1",
|
||||
"xlsx": "^0.18.0",
|
||||
"@e965/xlsx": "^0.20.3",
|
||||
"ylru": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
61
scripts/_db.cjs
Normal file
61
scripts/_db.cjs
Normal file
@@ -0,0 +1,61 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 后端运维脚本共用:数据库连接配置解析(**唯一入口**)
|
||||
* ============================================================
|
||||
*
|
||||
* 为什么单独成文件
|
||||
* ------------------------------------------------------------
|
||||
* 任何"需要连库"的运维脚本(弱口令扫描 / 口令策略接口验证 / 后续新增)都必须
|
||||
* 用同一套优先级与同一条凭据纪律。各自实现必然漂移 —— 例如某处"缺口令时兜底成
|
||||
* 空串"就能静默连上无口令的库,而另一处明确失败,两者行为不一致却都"看起来正常"。
|
||||
*
|
||||
* 读取优先级(与 scripts/_creds.py 完全同源)
|
||||
* ------------------------------------------------------------
|
||||
* 1. 环境变量 DB_HOST / DB_PORT / DB_USER / DB_PASSWORD / DB_NAME(CI / 服务器注入优先)
|
||||
* 2. 仓库根 `scripts/.creds.local.json`(本机开发,已被 .gitignore 排除)
|
||||
* 3. 明确失败 —— ⛔ **刻意不提供明文兜底**:口令为空时直接抛错,
|
||||
* 绝不静默尝试空口令连库(那会掩盖"凭据没注入"这一真实故障)。
|
||||
*
|
||||
* ⚠️ Python 侧对应实现见 `scripts/_creds.py`,两侧口径必须一致;
|
||||
* 如需增删键,**两边同时改**。
|
||||
*
|
||||
* @returns {{host: string, port: number, user: string, password: string, database: string}}
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
function loadDbConfig() {
|
||||
const env = process.env;
|
||||
let local = {};
|
||||
// backend/scripts/ → 仓库根/scripts/.creds.local.json
|
||||
const localFile = path.join(__dirname, '..', '..', 'scripts', '.creds.local.json');
|
||||
try {
|
||||
local = JSON.parse(fs.readFileSync(localFile, 'utf8'));
|
||||
} catch (e) {
|
||||
// 文件不存在是允许的(走环境变量);但**损坏**必须报错,不静默降级
|
||||
if (e.code !== 'ENOENT') {
|
||||
throw new Error(`凭据文件解析失败 ${localFile}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const cfg = {
|
||||
host: env.DB_HOST || local.db_host || '127.0.0.1',
|
||||
port: parseInt(env.DB_PORT || local.db_port || 3306, 10),
|
||||
user: env.DB_USER || local.db_user || 'root',
|
||||
password: env.DB_PASSWORD || local.db_password || '',
|
||||
database: env.DB_NAME || local.db_name || 'competition_db',
|
||||
};
|
||||
|
||||
if (!cfg.password) {
|
||||
throw new Error(
|
||||
'未取到数据库口令。请任选其一:\n' +
|
||||
' ① 设置环境变量 DB_PASSWORD\n' +
|
||||
' ② 创建 scripts/.creds.local.json(形如 {"db_password":"..."})\n' +
|
||||
'⛔ 本模块刻意不提供明文兜底:缺凭据时明确失败,不静默连库。'
|
||||
);
|
||||
}
|
||||
return cfg;
|
||||
}
|
||||
|
||||
module.exports = { loadDbConfig };
|
||||
112
scripts/_secret.cjs
Normal file
112
scripts/_secret.cjs
Normal file
@@ -0,0 +1,112 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 后端配置共用:**配置密钥解析(唯一入口)**
|
||||
* ============================================================
|
||||
* 当前覆盖两个同族密钥:
|
||||
* · `JWT_SECRET` —— JWT 签名密钥(`loadJwtSecret`)
|
||||
* · `APP_KEYS` —— Cookie 签名密钥(`loadAppKeys`,Egg `config.keys`)
|
||||
*
|
||||
* 为什么单独成文件(R105-D1 + R106-D6,2026-10-12)
|
||||
* ------------------------------------------------------------
|
||||
* 原实现(config.default.js 旧 L300/L304 与 L157/L158)各自带**可预测的兜底值**:
|
||||
* const DEFAULT_JWT_SECRET = '<32 位明文串>';
|
||||
* secret: process.env.JWT_SECRET || DEFAULT_JWT_SECRET
|
||||
* const DEFAULT_APP_KEYS = appInfo.name + '_competition_website_secret_key_2026';
|
||||
* config.keys = process.env.APP_KEYS || DEFAULT_APP_KEYS
|
||||
* 两者都是「**已入库、任何人可读或可推算**」的值 ⇒ 等价于「密钥公开」:
|
||||
* · JWT_SECRET 公开 ⇒ 任意人可伪造 token 冒充任意身份;
|
||||
* · APP_KEYS 公开 ⇒ 任意人可伪造已签名 Cookie(session / 防篡改校验)。
|
||||
* 且它们与仓库既有纪律直接冲突:
|
||||
* · `scripts/_creds.py` / `backend/scripts/_db.cjs` 早已统一为「缺凭据明确失败」;
|
||||
* · config.default.js 对 `DB_PASSWORD` 也已改为「缺失即阻断」。
|
||||
* 同族问题若各自实现必然漂移(R104 的结论)⇒ 收敛到**唯一解析源**,不留兜底。
|
||||
*
|
||||
* 读取优先级(与 `_db.cjs` / `_creds.py` 同一纪律)
|
||||
* ------------------------------------------------------------
|
||||
* 1. 对应的**环境变量**(生产由 systemd `EnvironmentFile=` / 启动器注入;本地由
|
||||
* config.default.js 顶部的 `loadDeployEnv()` 注入 —— 注意 **R106-D9 起
|
||||
* `JWT_SECRET` / `APP_KEYS` 属"非生产跳过键"**,本地开发不再继承生产值)
|
||||
* 2. 仓库根 `scripts/.creds.local.json` 的对应键(本机开发用;`.gitignore` 已排除;
|
||||
* `jwt_fallback_secret` 同时是 `scripts/_fixtures.py` 的末级来源,两侧同源 ⇒
|
||||
* 自签 token 与后端验签必然一致)
|
||||
* 3. **明确失败** —— ⛔ **刻意不提供明文兜底**:抛错并给出可行动的修复指引。
|
||||
* 理由:密钥缺失属"注入链路断了"的真实故障,明确阻断远优于静默用公开值签名。
|
||||
*
|
||||
* ⚠️ 与 Python 侧 `scripts/_creds.py`(`JWT_FALLBACK_SECRET` / `APP_KEYS`)口径必须一致;
|
||||
* 如需增删键,两边同时改。
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const LOCAL_FILE = path.join(__dirname, '..', '..', 'scripts', '.creds.local.json');
|
||||
|
||||
function _loadLocal() {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(LOCAL_FILE, 'utf8'));
|
||||
} catch (e) {
|
||||
if (e.code === 'ENOENT') return {}; // 文件不存在合法(走环境变量)
|
||||
// 损坏必须报错,不静默降级
|
||||
throw new Error(`凭据文件解析失败 ${LOCAL_FILE}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用解析:env → `.creds.local.json` → 明确失败。
|
||||
*
|
||||
* @param {object} o
|
||||
* @param {string} o.envKey 环境变量名(如 'JWT_SECRET')
|
||||
* @param {string} o.localKey `.creds.local.json` 中的键名(如 'jwt_fallback_secret')
|
||||
* @param {string} o.purpose 失败时的用途说明(如 '签名/校验登录态')
|
||||
* @param {string} o.builtin 被移除的兜底物名称(如 '内置密钥')
|
||||
* @param {string} o.whyWrong 该兜底值为何不可接受的后果(如 'JWT 可被任意伪造')
|
||||
* @returns {string} 生效中的密钥
|
||||
*/
|
||||
function _loadSecret(o) {
|
||||
const { envKey, localKey, purpose, builtin, whyWrong } = o;
|
||||
|
||||
const fromEnv = process.env[envKey];
|
||||
if (fromEnv) return fromEnv;
|
||||
|
||||
const local = _loadLocal();
|
||||
const fromFile = local[localKey];
|
||||
if (fromFile) return fromFile;
|
||||
|
||||
throw new Error(
|
||||
`[配置缺失] 未取到 ${envKey} —— 无法安全地${purpose}。请任选其一:\n` +
|
||||
` ① 设置环境变量 ${envKey}(生产/CI 推荐,deploy/.env.production 已含该键);\n` +
|
||||
` ② 在 ${LOCAL_FILE} 写入键 "${localKey}"(本机开发用,该文件已被 .gitignore 排除);\n` +
|
||||
`⛔ 本模块刻意不提供明文兜底:原 config.default.js 的${builtin}已移除,\n` +
|
||||
` 因为「可预测的公开密钥」等价于「${whyWrong}」。`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT 签名密钥。
|
||||
* @returns {string}
|
||||
*/
|
||||
function loadJwtSecret() {
|
||||
return _loadSecret({
|
||||
envKey: 'JWT_SECRET',
|
||||
localKey: 'jwt_fallback_secret',
|
||||
purpose: '签名/校验登录态',
|
||||
builtin: '内置密钥',
|
||||
whyWrong: 'JWT 可被任意伪造',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cookie 签名密钥(Egg `config.keys`)。
|
||||
* @returns {string}
|
||||
*/
|
||||
function loadAppKeys() {
|
||||
return _loadSecret({
|
||||
envKey: 'APP_KEYS',
|
||||
localKey: 'app_keys',
|
||||
purpose: '签发/校验 Cookie 签名',
|
||||
builtin: '内置兜底值',
|
||||
whyWrong: 'Cookie 可被任意伪造',
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { loadJwtSecret, loadAppKeys };
|
||||
@@ -12,7 +12,7 @@
|
||||
* cd <生产 backend 目录>
|
||||
* node scripts/diagnose_upload_download.cjs
|
||||
* # 如需抽查数据库(可选):
|
||||
* DB_HOST=127.0.0.1 DB_USER=root DB_PASS=74191221 DB_NAME=competition_db \
|
||||
* DB_HOST=127.0.0.1 DB_USER=root DB_PASS='<本机 DB 口令>' DB_NAME=competition_db \
|
||||
* node scripts/diagnose_upload_download.cjs
|
||||
*
|
||||
* 不依赖 Egg 启动,纯 Node + fs 即可;DB 部分为可选(拿不到模块/连接失败则跳过)。
|
||||
@@ -114,7 +114,7 @@ console.log('--- 4. 数据库 submission.file_path 真实性抽查(可选)--
|
||||
const dbCfg = {
|
||||
host: process.env.DB_HOST || '127.0.0.1',
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASS || '74191221',
|
||||
password: process.env.DB_PASS,
|
||||
database: process.env.DB_NAME || 'competition_db',
|
||||
};
|
||||
|
||||
|
||||
274
scripts/scan_weak_password_accounts.cjs
Normal file
274
scripts/scan_weak_password_accounts.cjs
Normal file
@@ -0,0 +1,274 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 弱口令存量账号扫描器(R63 · C2)
|
||||
* ============================================================
|
||||
*
|
||||
* 用途
|
||||
* ------------------------------------------------------------
|
||||
* R61 测试人员实测:进入「设置密码」页时浏览器密码管理器**预填 `123456`**,
|
||||
* 若考生**直接提交**,该口令就成为其账号的真实口令。R62 已从前端修掉预填
|
||||
* (激活页清残留登录态 + 密码框声明 autocomplete 语义),但**存量**账号里
|
||||
* 可能已经存在这种口令。本脚本负责**精确识别**它们。
|
||||
*
|
||||
* ⛔ 本脚本是**只读**的
|
||||
* ------------------------------------------------------------
|
||||
* 全程只执行 `SELECT`。**不做任何 UPDATE / DELETE / INSERT**,也不删文件。
|
||||
* 识别结果仅用于人工决策(是否重设口令、如何通知考生)。
|
||||
* —— 口令哈希不可逆,唯一可靠的识别方式就是"拿候选口令逐条 bcrypt 比对"。
|
||||
*
|
||||
* 为什么用 Node 而不是 Python
|
||||
* ------------------------------------------------------------
|
||||
* 必须与后端**同一个 bcrypt 实现**(`backend/node_modules/bcrypt`,原生模块)比对,
|
||||
* 避免跨语言实现差异带来的假阴性;且本机 Python 无 bcrypt 依赖(服务器端有
|
||||
* `/opt/pytools/lib`,但"同一实现"比"能跑"更重要)。黑名单直接从后端策略的
|
||||
* JSON 读取,**不复制一份**(复制必然漂移)。
|
||||
*
|
||||
* 判据设计(避免把"没扫到"误读成"没问题")
|
||||
* ------------------------------------------------------------
|
||||
* · 命中即短路:每行按候选顺序比对,一旦命中就停止(`123456` 排在最前,
|
||||
* 它是本次风险的核心)。
|
||||
* · 未激活账号默认跳过:其口令来自导入时的派生值(生产 = 身份证后 6 位 SHA256,
|
||||
* 见 `app/service/admin.js:384`),**不是**考生自己设置的,扫描无意义。
|
||||
* 如需连未激活一起看,加 `--all`。
|
||||
* · 输出末尾打印**扫描覆盖面**(扫了多少行 / 比对多少次 / 跳过多少行),
|
||||
* 让"零命中"成为一个有依据的结论,而不是空输出。
|
||||
*
|
||||
* 用法
|
||||
* ------------------------------------------------------------
|
||||
* # 本地库(凭据取自 scripts/.creds.local.json,或环境变量)
|
||||
* node backend/scripts/scan_weak_password_accounts.cjs
|
||||
*
|
||||
* # 追加自定义候选口令
|
||||
* node backend/scripts/scan_weak_password_accounts.cjs --extra MyPwd123
|
||||
*
|
||||
* # 连未激活账号一起扫
|
||||
* node backend/scripts/scan_weak_password_accounts.cjs --all
|
||||
*
|
||||
* # 机器可读输出
|
||||
* node backend/scripts/scan_weak_password_accounts.cjs --json
|
||||
*
|
||||
* 退出码
|
||||
* 0 = 扫描完成(无论是否命中;命中数在输出中提示)
|
||||
* 2 = 参数错误 / 连接失败
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const mysql = require('mysql2/promise');
|
||||
const bcrypt = require('bcrypt');
|
||||
|
||||
/** 黑名单 —— 与后端策略共用同一份 JSON(单一事实来源,勿另存副本) */
|
||||
const BLACKLIST = require('../app/lib/password_blacklist.json');
|
||||
|
||||
/** bcrypt 哈希识别(与 extend/helper.js 的 isBcryptHash 同口径) */
|
||||
function isBcryptHash(h) {
|
||||
return typeof h === 'string' && /^\$2[aby]\$/.test(h);
|
||||
}
|
||||
|
||||
/** 存量 SHA256(无盐)哈希 */
|
||||
function sha256(s) {
|
||||
return crypto.createHash('sha256').update(s).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* DB 配置解析 —— 与其它运维脚本共用同一入口。
|
||||
* ⚠️ 不在此处另写一份:优先级与「缺口令即明确失败」的纪律必须全脚本一致。
|
||||
*/
|
||||
const { loadDbConfig } = require('./_db.cjs');
|
||||
|
||||
/** 身份证号脱敏:保留前 6 后 4 */
|
||||
function maskIdNumber(v) {
|
||||
const s = String(v || '');
|
||||
if (s.length <= 10) return s ? s[0] + '***' : '';
|
||||
return `${s.slice(0, 6)}****${s.slice(-4)}`;
|
||||
}
|
||||
|
||||
/** 命令行参数解析(极简,无需依赖) */
|
||||
function parseArgs(argv) {
|
||||
const opts = { extra: [], all: false, json: false, quiet: false };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--all') opts.all = true;
|
||||
else if (a === '--json') opts.json = true;
|
||||
else if (a === '--quiet') opts.quiet = true;
|
||||
else if (a === '--extra') opts.extra.push(argv[++i]);
|
||||
else if (a === '--help' || a === '-h') opts.help = true;
|
||||
else {
|
||||
console.error(`未知参数:${a}(用 --help 查看用法)`);
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
/**
|
||||
* 逐行比对:命中即返回命中的候选口令,未命中返回 null。
|
||||
*
|
||||
* @param {object} row - 含 password 的账号行
|
||||
* @param {string[]} candidates - 候选口令(已去重、已排序)
|
||||
* @returns {Promise<string|null>}
|
||||
*/
|
||||
async function findWeakPassword(row, candidates) {
|
||||
const hash = row.password;
|
||||
if (!hash) return null;
|
||||
const bcryptHash = isBcryptHash(hash);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (bcryptHash) {
|
||||
// 慢路径(每条约 0.1-0.3s,取决于 cost)
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
if (await bcrypt.compare(candidate, hash)) return candidate;
|
||||
} else {
|
||||
// 存量 SHA256:瞬时
|
||||
if (sha256(candidate) === hash) return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const opts = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (opts.help) {
|
||||
console.log(`
|
||||
弱口令存量账号扫描器(只读)
|
||||
|
||||
用法:
|
||||
node backend/scripts/scan_weak_password_accounts.cjs [选项]
|
||||
|
||||
选项:
|
||||
--extra <pwd> 追加自定义候选口令(可重复)
|
||||
--all 连未激活账号一并扫描(默认只扫 activated=1)
|
||||
--json 以 JSON 输出结果
|
||||
--quiet 只输出汇总
|
||||
--help 显示本帮助
|
||||
`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 候选口令:123456 优先(本次风险核心),其余按黑名单顺序;去重
|
||||
const seen = new Set();
|
||||
const candidates = [];
|
||||
for (const p of ['123456', ...BLACKLIST, ...opts.extra]) {
|
||||
const s = String(p);
|
||||
if (!seen.has(s)) {
|
||||
seen.add(s);
|
||||
candidates.push(s);
|
||||
}
|
||||
}
|
||||
|
||||
const cfg = loadDbConfig();
|
||||
const conn = await mysql.createConnection({
|
||||
host: cfg.host,
|
||||
port: cfg.port,
|
||||
user: cfg.user,
|
||||
password: cfg.password,
|
||||
database: cfg.database,
|
||||
});
|
||||
|
||||
try {
|
||||
// ── 只读扫描 ────────────────────────────────────────
|
||||
// 列名以 model 定义为准(contestants):id / name / id_number / phone / group /
|
||||
// activated / test_flag / password。⛔ 不可跨表套用其它表的列名。
|
||||
const sql = [
|
||||
'SELECT id, name, id_number, phone, `group`, activated, test_flag, password',
|
||||
'FROM contestants',
|
||||
opts.all ? '' : 'WHERE activated = 1',
|
||||
'ORDER BY id',
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
const [rows] = await conn.query(sql);
|
||||
|
||||
const total = rows.length;
|
||||
const skipped = rows.filter(r => !r.password).length;
|
||||
let comparisons = 0;
|
||||
const hits = [];
|
||||
let bcryptRows = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
if (!row.password) continue;
|
||||
if (isBcryptHash(row.password)) bcryptRows++;
|
||||
comparisons += candidates.length;
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const matched = await findWeakPassword(row, candidates);
|
||||
if (matched) {
|
||||
hits.push({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
id_number: maskIdNumber(row.id_number),
|
||||
group: row.group,
|
||||
activated: row.activated,
|
||||
test_flag: row.test_flag,
|
||||
matched_password: matched,
|
||||
hash_type: isBcryptHash(row.password) ? 'bcrypt' : 'sha256',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── 输出 ────────────────────────────────────────────
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({
|
||||
scanned: total,
|
||||
skipped_no_password: skipped,
|
||||
bcrypt_rows: bcryptRows,
|
||||
candidates: candidates.length,
|
||||
comparisons,
|
||||
hits_count: hits.length,
|
||||
hits,
|
||||
}, null, 2));
|
||||
} else {
|
||||
console.log('');
|
||||
console.log('=== 弱口令存量账号扫描(只读)===');
|
||||
console.log(`数据库 : ${cfg.user}@${cfg.host}:${cfg.port}/${cfg.database}`);
|
||||
console.log(`扫描范围 : ${opts.all ? '全部账号' : '仅 activated=1(已激活)'}`);
|
||||
console.log(`账号行数 : ${total}(其中无口令 ${skipped} 行已跳过;bcrypt 哈希 ${bcryptRows} 行)`);
|
||||
console.log(`候选口令 : ${candidates.length} 条 × ${total - skipped} 行 = ${comparisons} 次比对`);
|
||||
console.log('');
|
||||
|
||||
if (hits.length === 0) {
|
||||
console.log('✓ 零命中:没有任何账号的口令命中候选弱口令清单');
|
||||
console.log(' (本结论的覆盖面见上方「账号行数 / 候选口令」两行,非空输出)');
|
||||
} else {
|
||||
console.log(`⚠️ 命中 ${hits.length} 个账号(口令为弱口令):`);
|
||||
console.log('');
|
||||
console.log(' id 组别 测试? 已激活 姓名 身份证(脱敏) 口令 哈希');
|
||||
console.log(' ------ -------------- ------ ------ ---------------- -------------------- ----------- --------');
|
||||
for (const h of hits) {
|
||||
console.log(
|
||||
' ' +
|
||||
String(h.id).padEnd(7) +
|
||||
String(h.group || '').padEnd(15) +
|
||||
(h.test_flag ? '是 ' : '否 ') +
|
||||
(h.activated ? '是 ' : '否 ') +
|
||||
String(h.name || '').padEnd(17) +
|
||||
String(h.id_number || '').padEnd(21) +
|
||||
String(h.matched_password).padEnd(12) +
|
||||
h.hash_type
|
||||
);
|
||||
}
|
||||
console.log('');
|
||||
console.log(' 判读提示(重要,勿误判):');
|
||||
console.log(' · test_flag=1 且口令=123456:通常是**设计内的测试便利口令**');
|
||||
console.log(' (config.default.js:153 testDefaultPassword;由 resetTestData / 测试数据导入直写库,');
|
||||
console.log(' 不经口令策略),**不是**被浏览器预填污染的考生账号。');
|
||||
console.log(' · 真正的风险信号 = **test_flag=0(正式考生)且口令命中弱口令清单**。');
|
||||
console.log('');
|
||||
console.log(' ⇒ 处置建议:重设其口令为随机强口令,并提示考生走「忘记密码」自行设置。');
|
||||
console.log(' ⚠️ 不要直接把口令告知考生本人以外的人;本表含 PII,勿外发。');
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
return 0;
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.then(code => process.exit(code))
|
||||
.catch(e => {
|
||||
console.error('✗ 扫描失败:' + e.message);
|
||||
process.exit(2);
|
||||
});
|
||||
422
scripts/verify_password_policy_api.cjs
Normal file
422
scripts/verify_password_policy_api.cjs
Normal file
@@ -0,0 +1,422 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 口令策略 —— 真接口验证(R63 · C1)
|
||||
* ============================================================
|
||||
*
|
||||
* 目的:在**真实 HTTP 链路**上验证口令强度策略生效(单测只证明纯函数正确,
|
||||
* 不能证明它被正确挂到了接口上;控制器里的调用点缺失,单测照样全绿)。
|
||||
*
|
||||
* 判别力设计(**这是本脚本的核心价值**,勿简化)
|
||||
* ------------------------------------------------------------
|
||||
* 对同一账号、同一 `smsCode`、同一接口路径,先提交**弱口令**、再提交**强口令**,
|
||||
* 要求:
|
||||
* · 弱口令 → `code = 2001`(activate)/ `7001`(reset-password)
|
||||
* · 强口令 → `code = 0`
|
||||
* **两者必须同时成立**才计通过。理由:如果链路本身坏了(例如短信码校验失败),
|
||||
* 两个请求会**同时**返回 2005/7004 —— 那样"弱口令被拒"是**假绿**(它其实是被
|
||||
* 短信校验拦下的,与口令策略无关)。因此断言必须落在**具体的业务码**上,
|
||||
* 且必须有"强口令通过"作为链路通畅的正向对照。
|
||||
*
|
||||
* 数据安全(重要)
|
||||
* ------------------------------------------------------------
|
||||
* 本脚本会**真实写库**(激活账号 / 改口令),因此:
|
||||
* · 只操作**测试库**。脚本启动时若检测到 URL 指向非本机,要求显式 `--allow-remote`。
|
||||
* · 写前对每个受影响账号做**快照**(activated + password 原值)。
|
||||
* · `finally` 中**无条件恢复**,并用**第二条独立连接**复核恢复结果
|
||||
* (不依赖同连接的可见性语义)。
|
||||
*
|
||||
* 用法
|
||||
* ------------------------------------------------------------
|
||||
* node backend/scripts/verify_password_policy_api.cjs
|
||||
* API_PORT=7001 API_HOST=127.0.0.1 node backend/scripts/verify_password_policy_api.cjs
|
||||
*
|
||||
* 退出码
|
||||
* 0 = 全部通过;1 = 存在 FAIL;2 = 环境错误(无可用账号 / 连不上后端)
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const mysql = require('mysql2/promise');
|
||||
const bcrypt = require('bcrypt');
|
||||
const { loadDbConfig } = require('./_db.cjs');
|
||||
|
||||
const API_HOST = process.env.API_HOST || '127.0.0.1';
|
||||
const API_PORT = parseInt(process.env.API_PORT || '7001', 10);
|
||||
|
||||
/** 弱口令样本(覆盖策略的各条规则) */
|
||||
const WEAK_SAMPLES = [
|
||||
{ pwd: '123456', note: '黑名单命中(R61 实测的浏览器预填值)' },
|
||||
{ pwd: 'aaaaaa', note: '纯重复字符' },
|
||||
{ pwd: 'abcdef', note: '整串连续序列' },
|
||||
{ pwd: 'password', note: '英文弱口令' },
|
||||
];
|
||||
|
||||
/** 强口令样本(保证能通过策略) */
|
||||
const STRONG = 'Zx9#kQ2m';
|
||||
const STRONG2 = 'Qw8@zL4n';
|
||||
|
||||
// ── 断言 ────────────────────────────────────────────────────
|
||||
let pass = 0;
|
||||
const failures = [];
|
||||
|
||||
function ok(name, cond, detail) {
|
||||
if (cond) {
|
||||
pass++;
|
||||
console.log(` ✓ ${name}`);
|
||||
} else {
|
||||
failures.push(`${name} —— ${detail}`);
|
||||
console.log(` ✗ ${name} ${detail}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 极简 HTTP 客户端(带 cookie 罐;Egg session 依赖 EGG_SESS cookie)──
|
||||
class ApiClient {
|
||||
constructor() {
|
||||
this.cookie = '';
|
||||
}
|
||||
|
||||
_mergeCookies(setCookie) {
|
||||
if (!setCookie || !setCookie.length) return;
|
||||
const jar = {};
|
||||
for (const part of String(this.cookie).split('; ')) {
|
||||
if (!part) continue;
|
||||
const i = part.indexOf('=');
|
||||
if (i > 0) jar[part.slice(0, i)] = part.slice(i + 1);
|
||||
}
|
||||
for (const sc of setCookie) {
|
||||
const kv = String(sc).split(';')[0];
|
||||
const i = kv.indexOf('=');
|
||||
if (i > 0) jar[kv.slice(0, i)] = kv.slice(i + 1);
|
||||
}
|
||||
this.cookie = Object.entries(jar).map(([k, v]) => `${k}=${v}`).join('; ');
|
||||
}
|
||||
|
||||
request(method, path, body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const data = body === undefined ? null : JSON.stringify(body);
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (data) headers['Content-Length'] = Buffer.byteLength(data);
|
||||
if (this.cookie) headers.Cookie = this.cookie;
|
||||
|
||||
const req = http.request({ host: API_HOST, port: API_PORT, path, method, headers }, res => {
|
||||
let buf = '';
|
||||
res.setEncoding('utf8');
|
||||
res.on('data', c => { buf += c; });
|
||||
res.on('end', () => {
|
||||
this._mergeCookies(res.headers['set-cookie']);
|
||||
let json = null;
|
||||
try { json = JSON.parse(buf); } catch (e) { /* 非 JSON(如 SVG / 500 页面) */ }
|
||||
resolve({ status: res.statusCode, json, raw: buf });
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
if (data) req.write(data);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
get(p) { return this.request('GET', p); }
|
||||
post(p, body) { return this.request('POST', p, body); }
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析图形验证码的点击顺序。
|
||||
*
|
||||
* `GET/POST /api/auth/captcha` 返回 `{chars: [...打乱], hint: '请依次点击:天 → 地 → 人 → 和'}`,
|
||||
* 而服务端 session 存的是 `selected.join('')`(**未打乱**的顺序)。
|
||||
* ⇒ 从 hint 里按 "→" 拆开再拼起来,恰好等于服务端存的正确答案。
|
||||
* (这是测试环境可用的快速通道;生产前端靠用户点击。)
|
||||
*
|
||||
* @param {string} hint
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function parseClickOrder(hint) {
|
||||
const m = String(hint || '').match(/请依次点击\s*[::]\s*(.+)$/);
|
||||
if (!m) return null;
|
||||
const parts = m[1].split(/\s*→\s*/).map(s => s.trim()).filter(Boolean);
|
||||
if (!parts.length) return null;
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 走完「图形验证码 → 短信验证码」两步,返回 smsCode。
|
||||
* @param {ApiClient} client
|
||||
* @param {string} idNumber 身份证号(sms 接口的 phone 参数实际填身份证号)
|
||||
* @param {string} label 场景标签(用于断言命名)
|
||||
*/
|
||||
async function fetchSmsCode(client, idNumber, label, endpoint) {
|
||||
const cap = await client.post('/api/auth/captcha', {});
|
||||
const order = parseClickOrder(cap.json && cap.json.data && cap.json.data.hint);
|
||||
if (!order) {
|
||||
return { err: `无法解析图形验证码 hint(status=${cap.status})` };
|
||||
}
|
||||
const sms = await client.post(endpoint, { phone: idNumber, captcha: order, id_number: idNumber });
|
||||
const j = sms.json || {};
|
||||
if (j.code !== 0) {
|
||||
return { err: `${label} 取码失败:code=${j.code} message=${j.message}` };
|
||||
}
|
||||
return {
|
||||
smsCode: j.data && j.data.smsCode,
|
||||
realPhone: j.data && j.data.phone,
|
||||
hint: cap.json.data.hint,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('');
|
||||
console.log('=== 口令策略真接口验证(R63 · C1)===');
|
||||
console.log(`后端: http://${API_HOST}:${API_PORT}`);
|
||||
console.log('');
|
||||
|
||||
// 保险:只允许对本机后端做"会写库"的验证
|
||||
if (API_HOST !== '127.0.0.1' && API_HOST !== 'localhost' && !process.argv.includes('--allow-remote')) {
|
||||
console.log(`✗ 拒绝执行:API_HOST=${API_HOST} 非本机。本脚本会真实写库,`);
|
||||
console.log(' 如确需对远端执行请显式加 --allow-remote(并确认那是测试库)。');
|
||||
return 2;
|
||||
}
|
||||
|
||||
const cfg = loadDbConfig();
|
||||
const conn = await mysql.createConnection(cfg);
|
||||
|
||||
/** 受影响账号快照,用于 finally 恢复 */
|
||||
const backups = [];
|
||||
/**
|
||||
* 退出码。
|
||||
* ⚠️ 刻意**不用** `return expr`:`return` 的表达式在 finally **之前**求值,
|
||||
* 而"账号恢复失败"是在 finally 内部才发现的 —— 用 return 会静默漏掉它。
|
||||
*/
|
||||
let exitCode = 1;
|
||||
|
||||
try {
|
||||
// ── 健康检查 ────────────────────────────────────
|
||||
const hc = new ApiClient();
|
||||
const health = await hc.get('/api/health');
|
||||
const healthOk = health.json && health.json.code === 0;
|
||||
ok('后端可达且 /api/health code=0', healthOk, `status=${health.status} body=${health.raw.slice(0, 120)}`);
|
||||
if (!healthOk) {
|
||||
console.log(' ⇒ 后端不可用,后续用例无意义,直接结束');
|
||||
return 2;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// 通道 A:POST /api/auth/activate
|
||||
// ════════════════════════════════════════════════
|
||||
console.log('');
|
||||
console.log('[通道 A] POST /api/auth/activate(短信激活,设置初始口令)');
|
||||
|
||||
const [pendingRows] = await conn.query(
|
||||
"SELECT id, id_number, phone, activated, password FROM contestants " +
|
||||
"WHERE activated = 0 AND phone IS NOT NULL AND phone <> '' ORDER BY id LIMIT 2"
|
||||
);
|
||||
const pending = pendingRows[0];
|
||||
|
||||
if (!pending) {
|
||||
console.log(' ⚠️ 库中没有「未激活且有手机号」的账号,跳过通道 A');
|
||||
console.log(' (如需验证,请先把某个测试账号置为 activated=0)');
|
||||
} else {
|
||||
backups.push({ id: pending.id, activated: pending.activated, password: pending.password, tag: 'A' });
|
||||
|
||||
const cA = new ApiClient();
|
||||
const smsInfo = await fetchSmsCode(cA, pending.id_number, 'activate', '/api/auth/sms');
|
||||
ok('A1 图形验证码 + 短信验证码链路可用', !smsInfo.err, smsInfo.err || '');
|
||||
if (!smsInfo.err) {
|
||||
ok('A2 测试环境回传 smsCode(非生产模式)', !!smsInfo.smsCode,
|
||||
`data=${JSON.stringify(smsInfo).slice(0, 160)}`);
|
||||
|
||||
// A3-A6:弱口令样本逐一被拒,且必须是「策略码 2001」而非其它失败
|
||||
for (const s of WEAK_SAMPLES) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const r = await cA.post('/api/auth/activate', {
|
||||
id_number: pending.id_number,
|
||||
phone: smsInfo.realPhone,
|
||||
smsCode: smsInfo.smsCode,
|
||||
password: s.pwd,
|
||||
});
|
||||
const code = r.json && r.json.code;
|
||||
ok(`A·弱口令被拒 code=2001 → ${JSON.stringify(s.pwd)}(${s.note})`,
|
||||
code === 2001,
|
||||
`实际 code=${code} message=${r.json && r.json.message}`);
|
||||
}
|
||||
|
||||
// A7:21 位 —— 由 validate 的 max 拦下(400 参数校验失败),不是策略码
|
||||
{
|
||||
const longPwd = 'Zx9#kQ2mNp5$vLr8Tb1!a'; // 21 位
|
||||
const r = await cA.post('/api/auth/activate', {
|
||||
id_number: pending.id_number,
|
||||
phone: smsInfo.realPhone,
|
||||
smsCode: smsInfo.smsCode,
|
||||
password: longPwd,
|
||||
});
|
||||
const code = r.json && r.json.code;
|
||||
ok('A7 超长口令(21 位)被 validate 拒绝(code=400)', code === 400,
|
||||
`实际 code=${code} message=${r.json && r.json.message}`);
|
||||
}
|
||||
|
||||
// A8:强口令通过(正向对照 —— 证明链路本身通畅,否则前面的"被拒"是假绿)
|
||||
{
|
||||
const r = await cA.post('/api/auth/activate', {
|
||||
id_number: pending.id_number,
|
||||
phone: smsInfo.realPhone,
|
||||
smsCode: smsInfo.smsCode,
|
||||
password: STRONG,
|
||||
});
|
||||
const code = r.json && r.json.code;
|
||||
ok(`A8 强口令通过(code=0)—— 正向对照,证明 A·弱口令被拒非假绿`, code === 0,
|
||||
`实际 code=${code} message=${r.json && r.json.message}`);
|
||||
}
|
||||
|
||||
// A9-A10:落库核对(真写进去了,且写的是强口令)
|
||||
{
|
||||
const [after] = await conn.query(
|
||||
'SELECT activated, password FROM contestants WHERE id = ?', [pending.id]
|
||||
);
|
||||
const row = after[0] || {};
|
||||
ok('A9 DB 已激活(activated=1)', Number(row.activated) === 1,
|
||||
`实际 activated=${row.activated}`);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const matched = row.password ? await bcrypt.compare(STRONG, row.password) : false;
|
||||
ok('A10 DB 中口令哈希与强口令匹配(bcrypt 比对)', matched,
|
||||
'哈希与强口令不匹配,说明落库内容并非提交的强口令');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// 通道 B:POST /api/auth/reset-password
|
||||
// ════════════════════════════════════════════════
|
||||
console.log('');
|
||||
console.log('[通道 B] POST /api/auth/reset-password(忘记密码,重设口令)');
|
||||
|
||||
const target = pending || pendingRows[1];
|
||||
if (!target) {
|
||||
console.log(' ⚠️ 没有可用账号,跳过通道 B');
|
||||
} else {
|
||||
// 若该账号尚未在快照中(通道 A 跳过的情形),补一条
|
||||
if (!backups.some(b => b.id === target.id)) {
|
||||
const [rs] = await conn.query(
|
||||
'SELECT id, id_number, phone, activated, password FROM contestants WHERE id = ?', [target.id]
|
||||
);
|
||||
if (rs[0]) backups.push({ id: rs[0].id, activated: rs[0].activated, password: rs[0].password, tag: 'B' });
|
||||
}
|
||||
// ⚠️ 必须**实时查库**判断激活态:`target` 是通道 A 开始前的内存快照,
|
||||
// 不会随通道 A 的激活动作更新 —— 用它判断会得出"仍未激活"的错误结论
|
||||
// (曾因此重复走一遍激活、多发一条短信,且日志内容与事实相反、误导排障)。
|
||||
const [tRows] = await conn.query(
|
||||
'SELECT activated FROM contestants WHERE id = ?', [target.id]
|
||||
);
|
||||
const tActivated = Number((tRows[0] || {}).activated) === 1;
|
||||
if (!tActivated) {
|
||||
// 通道 B 要求账号已激活(forgotPassword 会拦未激活账号,报 6004)
|
||||
console.log(` ℹ️ 账号 id=${target.id} 当前未激活,先激活以便测试重置通道`);
|
||||
const cAct = new ApiClient();
|
||||
const si = await fetchSmsCode(cAct, target.id_number, 'pre-activate', '/api/auth/sms');
|
||||
if (!si.err) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await cAct.post('/api/auth/activate', {
|
||||
id_number: target.id_number, phone: si.realPhone, smsCode: si.smsCode, password: STRONG,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const cB = new ApiClient();
|
||||
const smsB = await fetchSmsCode(cB, target.id_number, 'forgot-password', '/api/auth/forgot-password');
|
||||
ok('B1 忘记密码取码链路可用', !smsB.err, smsB.err || '');
|
||||
if (!smsB.err) {
|
||||
for (const s of WEAK_SAMPLES) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const r = await cB.post('/api/auth/reset-password', {
|
||||
id_number: target.id_number,
|
||||
smsCode: smsB.smsCode,
|
||||
password: s.pwd,
|
||||
});
|
||||
const code = r.json && r.json.code;
|
||||
ok(`B·弱口令被拒 code=7001 → ${JSON.stringify(s.pwd)}(${s.note})`,
|
||||
code === 7001,
|
||||
`实际 code=${code} message=${r.json && r.json.message}`);
|
||||
}
|
||||
|
||||
{
|
||||
const r = await cB.post('/api/auth/reset-password', {
|
||||
id_number: target.id_number,
|
||||
smsCode: smsB.smsCode,
|
||||
password: STRONG2,
|
||||
});
|
||||
const code = r.json && r.json.code;
|
||||
ok('B2 强口令通过(code=0)—— 正向对照', code === 0,
|
||||
`实际 code=${code} message=${r.json && r.json.message}`);
|
||||
}
|
||||
|
||||
{
|
||||
const [after] = await conn.query(
|
||||
'SELECT password FROM contestants WHERE id = ?', [target.id]
|
||||
);
|
||||
const row = after[0] || {};
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const matched = row.password ? await bcrypt.compare(STRONG2, row.password) : false;
|
||||
ok('B3 DB 中口令哈希与新口令匹配(bcrypt 比对)', matched, '哈希与新口令不匹配');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exitCode = failures.length === 0 ? 0 : 1;
|
||||
} finally {
|
||||
// ── 无条件的账号恢复 ──────────────────────────────
|
||||
console.log('');
|
||||
console.log('[收尾] 恢复被本脚本改动的账号');
|
||||
if (!backups.length) {
|
||||
console.log(' ℹ️ 无账号被改动,无需恢复');
|
||||
} else {
|
||||
for (const b of backups) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await conn.query(
|
||||
'UPDATE contestants SET activated = ?, password = ? WHERE id = ?',
|
||||
[b.activated, b.password, b.id]
|
||||
);
|
||||
}
|
||||
// 用**第二条独立连接**复核(不依赖同连接可见性语义;项目 R27 同类教训)
|
||||
const verifyConn = await mysql.createConnection(cfg);
|
||||
try {
|
||||
let allOk = true;
|
||||
for (const b of backups) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const [vr] = await verifyConn.query(
|
||||
'SELECT activated, password FROM contestants WHERE id = ?', [b.id]
|
||||
);
|
||||
const row = vr[0] || {};
|
||||
const same = Number(row.activated) === Number(b.activated)
|
||||
&& String(row.password) === String(b.password);
|
||||
if (!same) {
|
||||
allOk = false;
|
||||
failures.push(`恢复失败 id=${b.id}(通道 ${b.tag})`);
|
||||
}
|
||||
}
|
||||
ok(`已恢复 ${backups.length} 个账号并独立复核一致`, allOk,
|
||||
'存在未恢复一致的账号,请人工核查');
|
||||
} finally {
|
||||
await verifyConn.end();
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
console.log('────────────────────────────────────────');
|
||||
if (failures.length === 0) {
|
||||
console.log(`✓ 全部通过:${pass} 项`);
|
||||
} else {
|
||||
console.log(`✗ ${failures.length} 项失败 / 共 ${pass + failures.length} 项`);
|
||||
for (const f of failures) console.log(` · ${f}`);
|
||||
}
|
||||
console.log('');
|
||||
await conn.end();
|
||||
// 恢复阶段新增的失败也必须反映到退出码(见 exitCode 处的注释)
|
||||
if (failures.length > 0) exitCode = 1;
|
||||
}
|
||||
|
||||
return exitCode;
|
||||
}
|
||||
|
||||
main()
|
||||
.then(code => process.exit(code))
|
||||
.catch(e => {
|
||||
console.error('✗ 执行异常:' + (e && e.stack || e));
|
||||
process.exit(2);
|
||||
});
|
||||
@@ -1,8 +1,17 @@
|
||||
const { Sequelize } = require('./node_modules/sequelize');
|
||||
const sequelize = new Sequelize('competition_db', 'root', '74191221', {
|
||||
host: 'localhost', port: 3306, dialect: 'mysql',
|
||||
logging: false
|
||||
});
|
||||
// 🔴 口令不再内置明文(2026-09-23 入库口令处置 P2)。运行前请注入 DB_PASSWORD:
|
||||
// DB_PASSWORD='<本机 DB 口令>' node seed-carousel.js
|
||||
const sequelize = new Sequelize(
|
||||
process.env.DB_NAME || 'competition_db',
|
||||
process.env.DB_USER || 'root',
|
||||
process.env.DB_PASSWORD,
|
||||
{
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: parseInt(process.env.DB_PORT, 10) || 3306,
|
||||
dialect: 'mysql',
|
||||
logging: false,
|
||||
}
|
||||
);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
const mysql = require('mysql2/promise');
|
||||
(async () => {
|
||||
// 🔴 口令不再内置明文(2026-09-23 入库口令处置 P2)。运行前请注入 DB_PASSWORD:
|
||||
// DB_PASSWORD='<本机 DB 口令>' node show_tables.js
|
||||
const c = await mysql.createConnection({
|
||||
host: 'localhost', user: 'root', password: '74191221', database: 'competition_db'
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME || 'competition_db',
|
||||
});
|
||||
const [t] = await c.query('SHOW TABLES');
|
||||
t.forEach(r => console.log(Object.values(r)[0]));
|
||||
|
||||
168
test/lib.test.js
Normal file
168
test/lib.test.js
Normal file
@@ -0,0 +1,168 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* backend 最小可跑单测集(P1 ㉚a)
|
||||
* ================================================================
|
||||
* 背景:`backend/package.json` 声明了 `test-local: egg-bin test`,但 `backend/test/`
|
||||
* 长期是**空目录** ⇒ 「配置存在、用例为零」——跑 `npm run test-local` 恒绿(0 个用例),
|
||||
* 属 R95 根因①「最危险假绿族」。本文件填充**真实、无外部依赖(无 DB / 无网络 /
|
||||
* 无 egg 启动)、有判别力**的最小单测集,使该 script 不再空转。
|
||||
*
|
||||
* 取材原则(为什么选这三个纯函数模块):
|
||||
* ① 业务核心:aggregationKey = 铁律13/14 归集键 SoT;idCard = 导入通道判据 SoT;
|
||||
* filename = R108g 上传落盘名清洗。
|
||||
* ② **判别力**:三者的关键判据都有「退化后必然出错」的形态——
|
||||
* · aggregationKey 若退回「无条件双键归集」(历史 bug)⇒ 跨编号空间重叠用例必红;
|
||||
* · idCard 若校验位算法改动 ⇒ 已知公开有效号 `11010519491231002X` 用例必红;
|
||||
* · filename 若漏控制字符 / 路径分隔符清洗 ⇒ 穿越与 ENOENT 用例必红。
|
||||
* ③ 零副作用:全部为纯函数,不触盘、不连库。
|
||||
*
|
||||
* ⚠️ 运行:`cd backend && node_modules/.bin/egg-bin test`(或裸 mocha 直跑本文件)。
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const aggKey = require('../app/lib/aggregationKey');
|
||||
const idCard = require('../app/lib/idCard');
|
||||
const { sanitizeUploadFileName } = require('../app/utils/filename');
|
||||
|
||||
describe('app/lib/aggregationKey —— 归集键 SoT(铁律13/14)', () => {
|
||||
it('考试组:主键 = contestant.id,兜底键 = 真实聚合 uid', () => {
|
||||
assert.strictEqual(aggKey.primaryKeyOf('gaozhi', 5, 9), '5');
|
||||
assert.strictEqual(aggKey.primaryKeyOf('zhongzhi', 5, 9), '5');
|
||||
assert.strictEqual(aggKey.fallbackKeyOf('gaozhi', 5, 9), '9');
|
||||
});
|
||||
|
||||
it('考试组:缺真实聚合 uid ⇒ 无兜底键(null),而非拼一个假键', () => {
|
||||
assert.strictEqual(aggKey.fallbackKeyOf('gaozhi', 5, null), null);
|
||||
assert.strictEqual(aggKey.fallbackKeyOf('gaozhi', 5, ''), null);
|
||||
});
|
||||
|
||||
it('作品组:主键 = 真实聚合 uid,兜底键 = cid', () => {
|
||||
assert.strictEqual(aggKey.primaryKeyOf('benke', 5, 9), '9');
|
||||
assert.strictEqual(aggKey.fallbackKeyOf('benke', 5, 9), '5');
|
||||
});
|
||||
|
||||
it('作品组:缺真实聚合 uid ⇒ 主键退化为 cid(不再引入第二个键)', () => {
|
||||
assert.strictEqual(aggKey.primaryKeyOf('benke', 5, null), '5');
|
||||
assert.strictEqual(aggKey.primaryKeyOf('benke', 5, ''), '5');
|
||||
});
|
||||
|
||||
it('isExamGroup 只认 zhongzhi / gaozhi', () => {
|
||||
assert.strictEqual(aggKey.isExamGroup('zhongzhi'), true);
|
||||
assert.strictEqual(aggKey.isExamGroup('gaozhi'), true);
|
||||
assert.strictEqual(aggKey.isExamGroup('benke'), false);
|
||||
assert.strictEqual(aggKey.isExamGroup('jiaoshi'), false);
|
||||
});
|
||||
|
||||
// ---- 判别力核心:跨编号空间重叠 ----
|
||||
// 生产实测(2026-10-08)283 对碰撞的根因就是「无条件双键归集」。
|
||||
it('buildAggregationKeyMap:兜底键撞上同组他人主键 ⇒ 必须被丢弃', () => {
|
||||
// 考试组:cid=1 的真实聚合 uid = 2,而 cid=2 的主键恰为 '2' ⇒ 二者重叠
|
||||
const contestants = [{ id: 1 }, { id: 2 }];
|
||||
const realAgg = { 1: 2 };
|
||||
const map = aggKey.buildAggregationKeyMap('gaozhi', contestants, realAgg);
|
||||
assert.deepStrictEqual(map['1'], ['1'], 'cid=1 的兜底键 2 会命中 cid=2 ⇒ 必须丢弃');
|
||||
assert.deepStrictEqual(map['2'], ['2']);
|
||||
});
|
||||
|
||||
it('buildAggregationKeyMap:安全兜底键须保留(不因噎废食)', () => {
|
||||
const contestants = [{ id: 1 }, { id: 2 }];
|
||||
const realAgg = { 1: 7 }; // 7 未被任何人认领主键
|
||||
const map = aggKey.buildAggregationKeyMap('gaozhi', contestants, realAgg);
|
||||
assert.deepStrictEqual(map['1'], ['1', '7']);
|
||||
assert.deepStrictEqual(map['2'], ['2']);
|
||||
});
|
||||
|
||||
it('buildAggregationKeyMap:空输入 / 非数组不抛错', () => {
|
||||
assert.deepStrictEqual(aggKey.buildAggregationKeyMap('gaozhi', null, null), {});
|
||||
assert.deepStrictEqual(aggKey.buildAggregationKeyMap('gaozhi', [], {}), {});
|
||||
});
|
||||
|
||||
it('buildAggregationKeyMap:首元素恒为主键且键不重复', () => {
|
||||
const contestants = [{ id: 3 }];
|
||||
const map = aggKey.buildAggregationKeyMap('benke', contestants, { 3: 3 }); // 兜底=cid=主键
|
||||
assert.deepStrictEqual(map['3'], ['3'], '主键与兜底相等时不得重复');
|
||||
});
|
||||
});
|
||||
|
||||
describe('app/lib/idCard —— 导入通道判据 SoT', () => {
|
||||
it('normalizeIdNumber:trim + 末位 x 归一为大写 X', () => {
|
||||
assert.strictEqual(idCard.normalizeIdNumber(' 11010519491231002x '), '11010519491231002X');
|
||||
assert.strictEqual(idCard.normalizeIdNumber(null), '');
|
||||
assert.strictEqual(idCard.normalizeIdNumber(undefined), '');
|
||||
});
|
||||
|
||||
it('expectedCheckCode:对 GB11643 公开样例号算出正确校验位(独立已知答案)', () => {
|
||||
// `11010519491231002X` 为公开引用的有效样例;校验位算法若被改动此处必红。
|
||||
assert.strictEqual(idCard.expectedCheckCode('11010519491231002'), 'X');
|
||||
// 入参不足 17 位或含非数字 ⇒ 返回 ''
|
||||
assert.strictEqual(idCard.expectedCheckCode('123'), '');
|
||||
assert.strictEqual(idCard.expectedCheckCode('1101051949123100A'), '');
|
||||
});
|
||||
|
||||
it('inspectIdNumber:合法号 ⇒ importable=true 且 level=null', () => {
|
||||
const r = idCard.inspectIdNumber('11010519491231002X');
|
||||
assert.strictEqual(r.importable, true);
|
||||
assert.strictEqual(r.level, null);
|
||||
});
|
||||
|
||||
it('inspectIdNumber:空 ⇒ error 且不可导入', () => {
|
||||
const r = idCard.inspectIdNumber('');
|
||||
assert.strictEqual(r.importable, false);
|
||||
assert.strictEqual(r.level, 'error');
|
||||
});
|
||||
|
||||
it('inspectIdNumber:长度≠18 ⇒ error 且不可导入(模型层硬约束前置为可读错误)', () => {
|
||||
const r = idCard.inspectIdNumber('11010519491231002'); // 17 位
|
||||
assert.strictEqual(r.importable, false);
|
||||
assert.strictEqual(r.level, 'error');
|
||||
assert.ok(r.message.includes('18'));
|
||||
});
|
||||
|
||||
it('inspectIdNumber:18 位但校验位不符 ⇒ warn(不阻断,防误伤在册考生)', () => {
|
||||
const r = idCard.inspectIdNumber('110105194912310020'); // 末位应为 X
|
||||
assert.strictEqual(r.importable, true);
|
||||
assert.strictEqual(r.level, 'warn');
|
||||
assert.ok(r.message.includes('校验位'));
|
||||
});
|
||||
|
||||
it('inspectIdNumber:18 位但含非法字符 ⇒ warn(模型只查长度)', () => {
|
||||
const r = idCard.inspectIdNumber('1101051949123100AX');
|
||||
assert.strictEqual(r.importable, true);
|
||||
assert.strictEqual(r.level, 'warn');
|
||||
});
|
||||
});
|
||||
|
||||
describe('app/utils/filename —— 上传落盘名清洗(R108g)', () => {
|
||||
it('去除控制字符(防 stat 失败 / ENOENT)', () => {
|
||||
assert.strictEqual(sanitizeUploadFileName('a\x00b\x1fc.txt'), 'abc.txt');
|
||||
});
|
||||
|
||||
it('路径分隔符替换为下划线(防目录穿越)', () => {
|
||||
assert.strictEqual(sanitizeUploadFileName('a/b\\c.txt'), 'a_b_c.txt');
|
||||
});
|
||||
|
||||
it('折叠多余空白并 trim(修「前导空格 + 乱码」类 ENOENT)', () => {
|
||||
assert.strictEqual(sanitizeUploadFileName(' a b .txt'), 'a b .txt');
|
||||
});
|
||||
|
||||
it('去除首尾点(防 .name / .. 类)', () => {
|
||||
assert.strictEqual(sanitizeUploadFileName('.hidden'), 'hidden');
|
||||
});
|
||||
|
||||
it('超长名保留扩展名并截断到上限(120)', () => {
|
||||
const out = sanitizeUploadFileName('a'.repeat(200) + '.txt');
|
||||
assert.strictEqual(out.length, 120);
|
||||
assert.ok(out.endsWith('.txt'), '扩展名必须保留');
|
||||
});
|
||||
|
||||
it('客户端预编码(%XX)先解码', () => {
|
||||
assert.strictEqual(sanitizeUploadFileName('%E5%89%8D.txt'), '前.txt');
|
||||
});
|
||||
|
||||
it('空值 / 非字符串 ⇒ 返回空串(调用方回退时间戳名)', () => {
|
||||
assert.strictEqual(sanitizeUploadFileName(''), '');
|
||||
assert.strictEqual(sanitizeUploadFileName(null), '');
|
||||
assert.strictEqual(sanitizeUploadFileName(123), '');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user