A1/A2 客观题:
- 清理题库占位题(答案恒 A/true、分值恒 5),客观题分值硬约束为 1 分
- 新增 scripts/check_question_bank_health.py(带变异注入自检,已验证判别力)
- ci-gates.sh 由 9 步扩为 10 步,纳入题库健康门禁
B 大题评分一位小数:
- 新增 app/lib/decimal_score.js:DECIMAL(6,1) + getter 兜底,单点收口
「DB 列 / model 声明 / dialectOptions.decimalNumbers」三处必须同时成立
- 10 个 model 的 score 字段改走该工厂
- database/migrations/025:17 列 INT → DECIMAL(6,1)(6 列题目分值保持 int)
- service/bigQuestion.js:setSubScores 用 Math.round(n*10)/10 收敛,不再抹成整数
C 页面状态刷新:
- 修复 onQuestionTypeChange 的 `bigQuestions.length === 0` 守卫(切组后旧数组仍非空
⇒ 永不重新拉取)
- 修复评分后只刷一半(saveBigScores / saveExamScore 各只刷一侧列表)
- 收敛为唯一刷新入口 refreshQuestionData();ensureBigData 改按「数据属于哪个组」
判定;fetchBigQuestions/fetchBigAnswers 增加组别竞态守卫
D 大题导出 Word(单题 / 批量 / 全部):
- 新增 app/lib/docx.js:零新依赖手写 OOXML(复用已有 archiver@8),支持 p/strong/
span/a/br/table/ul-ol 子集,具名捕获组词法解析,pPr 严格排在 runs 之前
- service.bigQuestion.exportQuestions + controller.exportBigQuestions +
GET /api/admin/big-questions/:group/export
- 前端导出选中/全部两按钮 + 表格多选(reserve-selection)
计划外发现并修复(已在报告中披露):
- 管理端跨组写分越权:setSubScores 定位作答行时缺 group 约束,可用任意组的路径
改写他组考生成绩(读写不对称:读取侧有 where:{group},写入侧没有)
⇒ 已补 group 约束,并区分「不属于本组」与「尚未抽题」两种文案
- 导出路由注册顺序:export 原注册在 :group/:id 之后(当时因后者仅 PUT/DELETE 而
未暴露问题),已前移以消除「将来新增 GET /:group/:id 会吞掉 export」的隐患
验证(全部实测,非仅看退出码):
- ci-gates.sh 10/10 全绿;四连门禁 52/0 · 22/0 · 20/0 · 46/0
- 端到端探针 probe_d_03(导出+小数)ALL PASS;probe_e_01(切组刷新)22/22 ALL PASS
- 前端构建 VITE_EXIT=0
未 push:须先上线验证通过再单独授权。
515 lines
27 KiB
JavaScript
515 lines
27 KiB
JavaScript
'use strict';
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 部署环境变量自加载(兜底)
|
||
// 背景:Egg.js 默认不会读取任何 .env 文件,生产密钥(JWT_SECRET / APP_KEYS 等)必须
|
||
// 靠 systemd EnvironmentFile= 或 shell export 注入。当注入环节出错(文件解析失败、
|
||
// 路径不匹配、或根本不是用 systemd 启动)时,process.env 拿不到值,会触发下方 config
|
||
// 里的生产安全守卫误报「未配置的密钥」,且极难排查。
|
||
// 这里在配置加载前主动从磁盘读取 deploy/.env.production(沿 __dirname / process.cwd()
|
||
// 向上查找,并支持 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`。
|
||
// ---------------------------------------------------------------------------
|
||
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 无使用点),跳过仅为口径一致。
|
||
// 仍保留注入的键:JWT_SECRET / APP_KEYS / EGG_WORKERS / STATIC_BASE_URL 等 —— 本地无副作用。
|
||
const DEPLOY_ENV_LOCAL_SKIP_PREFIXES = ['DB_', 'REDIS_', 'ALIYUN_', 'SMS_'];
|
||
|
||
function loadDeployEnv(appInfo) {
|
||
try {
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
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);
|
||
|
||
// 2) 已知裸机部署绝对路径
|
||
targets.push('/home/root/production/agent-code/deploy/competition-website/.env.production');
|
||
|
||
// 3) 从 __dirname 与 process.cwd() 向上查找
|
||
const starts = [__dirname, process.cwd()];
|
||
for (const start of starts) {
|
||
let dir = start;
|
||
while (dir && dir !== path.dirname(dir)) {
|
||
targets.push(
|
||
path.join(dir, 'deploy', 'competition-website', '.env.production'),
|
||
path.join(dir, 'deploy', '.env.production'),
|
||
path.join(dir, '.env.production'),
|
||
);
|
||
dir = path.dirname(dir);
|
||
}
|
||
}
|
||
|
||
let loadedFrom = null;
|
||
let skipped = [];
|
||
for (const file of targets) {
|
||
let text;
|
||
try {
|
||
text = fs.readFileSync(file, 'utf8');
|
||
} catch (_) {
|
||
continue; // 该候选不存在,尝试下一个
|
||
}
|
||
for (const raw of text.split('\n')) {
|
||
const line = raw.trim();
|
||
if (!line || line.startsWith('#') || !line.includes('=')) continue;
|
||
const eq = line.indexOf('=');
|
||
const key = line.slice(0, eq).trim();
|
||
let val = line.slice(eq + 1).trim();
|
||
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
||
val = val.slice(1, -1);
|
||
}
|
||
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;
|
||
}
|
||
process.env[key] = val;
|
||
}
|
||
loadedFrom = file;
|
||
break; // 第一个存在的文件即为权威来源
|
||
}
|
||
|
||
if (loadedFrom) {
|
||
console.log('[deploy-env] 已从 ' + loadedFrom + ' 加载部署环境变量');
|
||
} 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';
|
||
|
||
// -----------------------------------------------------------------
|
||
// 运行模式(测试 / 生产)—— 单一环境变量 APP_ENV 控制,用于最小化
|
||
// 测试版本与生产版本的行为差异:两个版本使用同一套代码,仅通过 APP_ENV
|
||
// 与环境变量切换。
|
||
// - 生产部署:APP_ENV=production
|
||
// - 测试 / 本地部署:APP_ENV=test(缺省值,未设置且非 egg prod 环境时默认 test)
|
||
// 作用:① 驱动生产安全守卫;② 驱动短信行为(test=不真实发送+前端弹窗,
|
||
// production=真实下发且不回传验证码)。
|
||
// -----------------------------------------------------------------
|
||
const runMode = process.env.APP_ENV
|
||
|| (appInfo.env === 'prod' ? 'production' : 'test');
|
||
config.runMode = runMode;
|
||
config.isProduction = runMode === 'production';
|
||
config.isTest = runMode !== 'production';
|
||
|
||
// 测试数据默认口令:重置测试数据 / 标记为测试数据导入 时,统一把密码设为该口令的
|
||
// bcrypt 哈希,方便测试人员重置后、或用 Excel「标记为测试数据」导入后,直接以该口令登录重测
|
||
// (无需再走激活流程)。仅作用于 test_flag=1 的测试数据,生产数据(test_flag=0)不受影响。
|
||
// 【测试模拟值 '123456';如需变更可设环境变量 TEST_DEFAULT_PASSWORD】
|
||
config.testDefaultPassword = process.env.TEST_DEFAULT_PASSWORD || '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;
|
||
|
||
// add your config here
|
||
config.middleware = ['concurrencyGuard'];
|
||
|
||
// 🔴 请求级并发闸门(大文件上传/下载):在读取请求体之前限制「被接受的并发重请求数」,
|
||
// 极端并发下超出上限立即回 429(连接级背压),防止单 worker 被海量请求体缓冲压垮 OOM。
|
||
// 与 app/lib/file_io_guard 互补(后者按文件流生命周期限流)。可按压测/生产调参:
|
||
// 单 worker 极端并发场景建议保持较低值(20);生产多 worker 下每 worker 独立计数,
|
||
// 总上限 = 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=4),并由 start-backend.sh
|
||
// 显式传参作双保险。4 × 20 = 80 ≤ 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 数据库配置
|
||
// -----------------------------------------------------------------
|
||
config.sequelize = {
|
||
dialect: 'mysql',
|
||
database: process.env.DB_NAME || 'competition_db',
|
||
host: process.env.DB_HOST || 'localhost',
|
||
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',
|
||
// 🔴 口令不再内置明文兜底(2026-09-23 入库口令处置 P3):
|
||
// 生产 —— 必须由 .env.production 注入 DB_PASSWORD(缺失则由下方生产安全守卫阻断启动);
|
||
// 本地 —— 由 config/config.local.js(已 gitignore)深合并覆盖为真实本机口令。
|
||
password: process.env.DB_PASSWORD,
|
||
timezone: '+08:00',
|
||
define: {
|
||
underscored: true,
|
||
freezeTableName: true,
|
||
timestamps: true,
|
||
createdAt: 'created_at',
|
||
updatedAt: 'updated_at',
|
||
charset: 'utf8mb4',
|
||
collate: 'utf8mb4_unicode_ci',
|
||
},
|
||
dialectOptions: {
|
||
// 注:mysql2 连接级只接受 charset,不接受 collate;collate 是表/模型级选项,
|
||
// 由上方 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」上限;worker 数由 EGG_WORKERS 显式控制(生产 = 4,
|
||
// 见本文件上方「集群 worker 数」段),总连接数 = worker 数 × max = 4 × 20 = 80,
|
||
// 须 ≤ MySQL max_connections(默认 151)。启动期由 app.js didReady 自举断言
|
||
// 按**实际 worker 数**复核(不再用 os.cpus() 误估),超限只 WARN 不阻断。
|
||
max: parseInt(process.env.DB_POOL_MAX, 10) || 20,
|
||
min: 2,
|
||
acquire: 45000,
|
||
idle: 10000,
|
||
// 🔴 连接回收(2026-08-13 推进 #16):maxUses 限制单连接累计查询次数,
|
||
// 归还池时物理关闭并新建,避免 MySQL wait_timeout(默认 8h)静默杀掉长期空闲
|
||
// 连接后被复用 → 生产偶发 "Connection lost / ECONNRESET / Cannot enqueue Query
|
||
// after fatal error"。5000 次 ≈ 中小赛事体量下数小时寿命,远小于 wait_timeout。
|
||
// 可用 DB_POOL_MAX_USES 覆盖。
|
||
maxUses: parseInt(process.env.DB_POOL_MAX_USES, 10) || 5000,
|
||
},
|
||
// 仅在开发环境开启 SQL 日志,生产环境自动关闭
|
||
logging: process.env.NODE_ENV === 'production' ? false : console.log,
|
||
};
|
||
|
||
// -----------------------------------------------------------------
|
||
// X-Accel-Redirect 内部下载开关(根治管理后台大文件下载压垮 Node)
|
||
// -----------------------------------------------------------------
|
||
// 生产(NODE_ENV=production)默认开启:管理端作品/Word 下载改由后端只回一个
|
||
// X-Accel-Redirect 内部重定向头,nginx 直接从磁盘流式吐文件,Node 不再持有文件流,
|
||
// 极端并发下几乎零占用 worker fd/事件循环。
|
||
// 开发环境默认关闭(回退为 Node 直接流式,便于本地调试);可用 X_ACCEL_REDIRECT=1
|
||
// 临时开启以验证重定向头是否正确。
|
||
config.xAccelRedirect = {
|
||
enabled: process.env.NODE_ENV === 'production' || process.env.X_ACCEL_REDIRECT === '1',
|
||
// nginx 内部 location 前缀(须与 deploy/competition_website.conf 的一致)
|
||
internalPrefix: '/internal-downloads/',
|
||
};
|
||
|
||
// -----------------------------------------------------------------
|
||
// Redis 缓存配置
|
||
// -----------------------------------------------------------------
|
||
config.redis = {
|
||
client: {
|
||
port: parseInt(process.env.REDIS_PORT, 10) || 6379,
|
||
host: process.env.REDIS_HOST || '127.0.0.1',
|
||
// 🔴 同上:不再内置明文兜底(2026-09-23 P3)。Redis 当前全库无使用点,留空为零影响。
|
||
password: process.env.REDIS_PASSWORD || '',
|
||
db: 0,
|
||
},
|
||
};
|
||
|
||
// -----------------------------------------------------------------
|
||
// JWT 鉴权配置
|
||
// -----------------------------------------------------------------
|
||
const DEFAULT_JWT_SECRET = 'competition_jwt_secret_key_2026_br!cs';
|
||
config.jwt = {
|
||
// JWT 签名密钥:优先读取环境变量 JWT_SECRET,缺省时回退到内置值(仅供本地开发)
|
||
// 【测试模拟值,生产请通过环境变量 JWT_SECRET 替换为 64 位以上随机串,否则启动被守卫阻断】
|
||
secret: process.env.JWT_SECRET || DEFAULT_JWT_SECRET,
|
||
enable: true,
|
||
// Token 有效期(秒),默认 7 天
|
||
expiresIn: 7 * 24 * 60 * 60,
|
||
// 静默刷新宽限期(秒):token 过期后在此窗口内仍可被 /api/auth/refresh 续期,
|
||
// 用于实现"活跃用户永不被意外踢出"的滑动会话;超过则必须重新登录。
|
||
refreshGrace: 7 * 24 * 60 * 60,
|
||
// 匹配需要鉴权的路由规则(排除登录/注册等公开接口)
|
||
match: (ctx) => {
|
||
const publicPaths = [
|
||
'/api/auth/login',
|
||
'/api/auth/register',
|
||
'/api/public/',
|
||
];
|
||
return !publicPaths.some((p) => ctx.path.startsWith(p));
|
||
},
|
||
};
|
||
|
||
// -----------------------------------------------------------------
|
||
// multipart 文件上传配置
|
||
// -----------------------------------------------------------------
|
||
config.multipart = {
|
||
mode: 'stream',
|
||
fileSize: '100mb',
|
||
whitelist: [
|
||
// 文档类
|
||
'.pdf',
|
||
'.doc',
|
||
'.docx',
|
||
'.ppt',
|
||
'.pptx',
|
||
'.xls',
|
||
'.xlsx',
|
||
'.zip',
|
||
// 图片类
|
||
'.png',
|
||
'.jpg',
|
||
'.jpeg',
|
||
'.gif',
|
||
'.webp',
|
||
],
|
||
// 自定义文件名正则,允许中文、字母、数字、空格、连字符、下划线、括号
|
||
// Egg 默认不允许 hyphen in Chinese filename, fix this
|
||
filenameRegex: /^[\u4e00-\u9fa5a-zA-Z0-9\s\-_\(\)]+\.[a-z0-9]+$/i,
|
||
// 上传文件临时存放目录
|
||
tmpdir: require('path').join(appInfo.baseDir, 'app/public/tmp'),
|
||
// 清理临时文件的间隔时间
|
||
cleanSchedule: {
|
||
cron: '0 30 4 * * *',
|
||
},
|
||
};
|
||
|
||
// -----------------------------------------------------------------
|
||
// CORS 跨域配置(白名单收敛)
|
||
// -----------------------------------------------------------------
|
||
// 通过环境变量 CORS_ORIGINS 配置允许的来源(逗号分隔),例如:
|
||
// CORS_ORIGINS=https://competition.example.com,https://admin.example.com
|
||
// 规则:
|
||
// - 配置了白名单:仅反射白名单内的 origin;
|
||
// - 未配置白名单:开发环境反射请求来源(便于本地联调);
|
||
// 生产环境不反射任意源(生产走 nginx 同源代理 /api,无需跨域)。
|
||
const corsOrigins = (process.env.CORS_ORIGINS || '')
|
||
.split(',')
|
||
.map((s) => s.trim())
|
||
.filter(Boolean);
|
||
config.cors = {
|
||
origin: (ctx) => {
|
||
const reqOrigin = ctx.get('origin');
|
||
if (corsOrigins.length > 0) {
|
||
return corsOrigins.includes(reqOrigin) ? reqOrigin : '';
|
||
}
|
||
return isProd ? '' : reqOrigin;
|
||
},
|
||
allowMethods: 'GET,HEAD,PUT,POST,DELETE,PATCH',
|
||
credentials: true,
|
||
};
|
||
|
||
// -----------------------------------------------------------------
|
||
// Security 安全配置
|
||
// -----------------------------------------------------------------
|
||
// CSRF 说明:本项目前后端分离,鉴权全部通过 Authorization: Bearer <JWT>
|
||
// 请求头携带(不依赖 Cookie 会话),浏览器跨站请求无法自动附带该头,
|
||
// 因此传统 CSRF 攻击面不成立,保持关闭;若未来引入 Cookie 会话需重新启用。
|
||
config.security = {
|
||
csrf: {
|
||
enable: false,
|
||
},
|
||
// 跳转/资源域名白名单与 CORS 白名单保持一致(未配置时为空 = 仅同源)
|
||
domainWhiteList: corsOrigins,
|
||
};
|
||
|
||
// -----------------------------------------------------------------
|
||
// 静态资源服务配置
|
||
// -----------------------------------------------------------------
|
||
config.static = {
|
||
prefix: '/public/',
|
||
dir: require('path').join(appInfo.baseDir, 'app/public'),
|
||
maxAge: 31536000,
|
||
};
|
||
|
||
// 静态资源外部访问基础 URL(用于 helper.fileUrl() 拼接完整 URL)
|
||
// 生产环境通过环境变量 STATIC_BASE_URL 配置为实际域名,如 'https://competition.example.com'
|
||
// 未配置时回退为 null,helper.fileUrl() 会退化到 http://127.0.0.1:{port}
|
||
config.staticBaseUrl = process.env.STATIC_BASE_URL || null;
|
||
|
||
// -----------------------------------------------------------------
|
||
// bodyParser 配置
|
||
// -----------------------------------------------------------------
|
||
config.bodyParser = {
|
||
jsonLimit: '10mb',
|
||
formLimit: '10mb',
|
||
};
|
||
|
||
// -----------------------------------------------------------------
|
||
// 日志配置
|
||
// -----------------------------------------------------------------
|
||
config.logger = {
|
||
dir: require('path').join(appInfo.baseDir, 'logs'),
|
||
appLogName: 'competition-web.log',
|
||
coreLogName: 'egg-web.log',
|
||
agentLogName: 'egg-agent.log',
|
||
errorLogName: 'common-error.log',
|
||
level: 'INFO',
|
||
};
|
||
|
||
// -----------------------------------------------------------------
|
||
// 日志轮转配置(egg-logrotator 内置插件)
|
||
// - 按体积轮转:单文件超过 maxFileSize 即滚动
|
||
// - maxDays:保留最近 N 天日志,到期后自动清理(设为 0 则永久保留)
|
||
// 说明:默认已按天轮转;此处显式收口为"按体积 + 保留 14 天",
|
||
// 避免长期运行后日志无限增长占满磁盘。
|
||
// -----------------------------------------------------------------
|
||
config.rotator = {
|
||
filesRotateBySize: [
|
||
'competition-web.log',
|
||
'common-error.log',
|
||
'egg-web.log',
|
||
'egg-agent.log',
|
||
],
|
||
maxFileSize: 50 * 1024 * 1024, // 50MB
|
||
maxDays: 14,
|
||
};
|
||
|
||
// -----------------------------------------------------------------
|
||
// 生产环境安全守卫:禁止使用内置默认密钥上线
|
||
// (由 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-09-23(入库口令处置 P3):口令缺失同样阻断。
|
||
// 原实现在此处会静默回退到公开的开发口令,等价于「生产用公开口令连库」。
|
||
// 现要求生产必须显式注入 DB_PASSWORD(deploy/.env.production 本就含该键,
|
||
// 故现网永不触发本分支;一旦触发说明注入链路断了,明确阻断优于连不上库)。
|
||
if (!config.sequelize.password) insecure.push('DB_PASSWORD');
|
||
if (insecure.length > 0) {
|
||
// 生产环境仍在使用硬编码默认密钥属于严重安全隐患,直接阻断启动
|
||
throw new Error(
|
||
`[安全阻断] 生产环境检测到未配置的密钥: ${insecure.join(', ')}。` +
|
||
'请通过环境变量设置这些值后再启动(参见 deploy/.env.example)。'
|
||
);
|
||
}
|
||
}
|
||
|
||
// -----------------------------------------------------------------
|
||
// 短信网关配置(阿里云 Dysmsapi)
|
||
// -----------------------------------------------------------------
|
||
// 设计要点(测试 / 生产差异最小化):
|
||
// - 验证码的校验仍走 ctx.session(与现有激活/重置流程一致,已被 26 项边界测试覆盖),
|
||
// 短信网关只负责"把服务端已生成的 6 位验证码下发给手机号",不改动校验契约。
|
||
// - 所有网关参数均通过环境变量注入(SMS_ENABLED / ALIYUN_SMS_*)。
|
||
// - 阿里云 accessKeyId / accessKeySecret 为敏感凭证,源码不再保留任何硬编码值,
|
||
// 必须且只能通过环境变量注入;生产开启短信时若缺失将触发安全守卫阻断启动。
|
||
// - endpoint / signName / templateCode 为非敏感配置,保留合理默认值以便本地/测试快速起服。
|
||
// - realSend(真实下发开关)= 生产环境 且 SMS_ENABLED=1。
|
||
// · 测试环境(APP_ENV=test):即使配置了网关也【不真实发送】,后端随响应回传
|
||
// 验证码(字段 smsCode),由前端弹窗展示,便于测试人员直接获取。
|
||
// · 生产环境且 realSend=true:真实下发,绝不回传/打印验证码明文。
|
||
// · 生产环境且 realSend=false:安全起见不下发、不回传,仅记录告警。
|
||
const smsEnabled = process.env.SMS_ENABLED === '1' || process.env.SMS_ENABLED === 'true';
|
||
config.sms = {
|
||
enabled: smsEnabled,
|
||
// 真实下发:仅生产环境 + 网关开启时成立;测试环境恒为 false(不真发)
|
||
realSend: config.isProduction && smsEnabled,
|
||
provider: 'aliyun',
|
||
aliyun: {
|
||
// 敏感凭证:仅从环境变量读取,源码零硬编码(已轮换,杜绝泄露)
|
||
accessKeyId: process.env.ALIYUN_SMS_ACCESS_KEY_ID,
|
||
accessKeySecret: process.env.ALIYUN_SMS_ACCESS_KEY_SECRET,
|
||
endpoint: process.env.ALIYUN_SMS_ENDPOINT || 'dysmsapi.aliyuncs.com',
|
||
// 非敏感配置,保留合理默认值
|
||
signName: process.env.ALIYUN_SMS_SIGN_NAME || '华云智联',
|
||
templateCode: process.env.ALIYUN_SMS_TEMPLATE_CODE || 'SMS_487315108',
|
||
},
|
||
};
|
||
|
||
// 生产环境 + 开启短信时,阿里云密钥必须来自环境变量(源码已无硬编码凭证)
|
||
if (config.isProduction && smsEnabled) {
|
||
const missing = [];
|
||
if (!config.sms.aliyun.accessKeyId) missing.push('ALIYUN_SMS_ACCESS_KEY_ID');
|
||
if (!config.sms.aliyun.accessKeySecret) missing.push('ALIYUN_SMS_ACCESS_KEY_SECRET');
|
||
if (missing.length > 0) {
|
||
throw new Error(
|
||
`[安全阻断] 生产环境开启短信但缺少阿里云密钥: ${missing.join(', ')}。` +
|
||
'请通过环境变量注入真实密钥后再启动(参见 deploy/.env.production)。'
|
||
);
|
||
}
|
||
}
|
||
|
||
return config;
|
||
};
|