Files
competition-website/config/config.default.js
qubiaobiao 76ac1c7b6b security(config): 四个同族兜底缺陷一次收口 —— APP_KEYS / 弱口令 / IPv6 兜底 / 本地生产密钥同值(R106-D6~D9)
共同根因(R104 结论的延续):**同族问题各自实现必然漂移**。
本仓 DB_PASSWORD 与 JWT_SECRET(R105-D1)已统一为「缺凭据明确失败」,
但同族的另三处仍停在「可预测的公开兜底」上,另新增一处跨环境密钥同值。

D6 · APP_KEYS 派生兜底(原 config.default.js:157)
  原:const DEFAULT_APP_KEYS = appInfo.name + '_competition_website_secret_key_2026';
      config.keys = process.env.APP_KEYS || DEFAULT_APP_KEYS;
  「派生」不等于安全:由 appInfo.name 可推算 ⇒ 等价于「Cookie 签名密钥公开
  ⇒ 任何人可伪造已签名 Cookie」。收口到 _secret.cjs 唯一解析入口:
    · scripts/_secret.cjs 抽出通用 _loadSecret(),新增 loadAppKeys()
      (① env → ② scripts/.creds.local.json:app_keys → ③ 明确失败,⛔ 无兜底)
    · 生产安全守卫移除 config.keys === DEFAULT_APP_KEYS 比对分支(已无可比兜底值)
    · scripts/_creds.py 与 .creds.local.example.json 同步键面(两侧同源,永不漂移)

D7 · 生产弱口令兜底(原 config.default.js:176)
  原:config.testDefaultPassword = process.env.TEST_DEFAULT_PASSWORD || '123456';
  而 deploy/.env.production 的 TEST_DEFAULT_PASSWORD 一直是**注释状态** ⇒ 生产
  实际取到的就是弱口令 '123456'。一旦走到「批量写默认口令」的路径(重置测试数据 /
  显式 testFlag=true 的导入)会批量设弱口令 —— 属**静默弱口令**而非明确失败。
  修法:生产(config.isProduction)未显式注入 ⇒ 置 null;使用点改走新增的
  app/lib/passwordPolicy.requireTestPassword() 明确失败。测试环境语义逐字不变。

D8 · 本地 DB 兜底 host 引发 IPv6 建连超时(原 config.default.js:217)
  原:host: process.env.DB_HOST || 'localhost'。Node >= 18 的 verbatim DNS 可能把
  localhost 解析到 ::1,而本机 MariaDB 只听 IPv4 ⇒ **建连 ETIMEDOUT**(不报 1045、
  不报 ECONNREFUSED),现象是「后端启动卡在 DB」,极易误判为「服务没起来」。
  且本仓 scripts/_db.cjs:43 的兜底早已是 '127.0.0.1' —— 同子系统两套默认值必然漂移。
  修法:兜底改为 IPv4 字面量(生产显式注入 DB_HOST ⇒ 逐字不变)。
  配套(config.local.js 为 gitignored 本地文件,不入库):补 host/port 覆盖 ——
  「第二道保险」原本只保账号/口令,反而漏掉了真正会出问题的连接目标。

D9 · 本地 dev 与生产共用同一 JWT/APP_KEYS(loadDeployEnv)
  非生产跳过清单原本只含 DB_/REDIS_/ALIYUN_/SMS_,注释称「仍保留
  JWT_SECRET / APP_KEYS …… 本地无副作用」—— 该结论不成立:本地进程持有
  **生产签名密钥** ⇒ 本地 token 可冒充生产用户(反之亦然),两套环境的会话边界
  实际被打通,属**安全属性**缺陷。
  修法:新增 DEPLOY_ENV_LOCAL_SKIP_KEYS 精确跳过(⛔ 不用前缀以免误伤),本地改由
  scripts/.creds.local.json 供值;Python 侧 scripts/_fixtures.py resolve_jwt_secret()
  同步为「非生产优先本仓凭据文件」—— 否则脚本会用生产密钥签 token 交给本地后端
  验签,必然 401(正是该函数注释里记录过的 23 项级联假红)。

行为等价性(生产逐字不变,已逐项实测):
  · D6/D8:生产显式注入 APP_KEYS / DB_HOST ⇒ 取值路径不变
  · D7:生产 resetTestData 本有 403 守卫、导入默认 testFlag=false ⇒ 原不可达
  · D9:isProdEnv=true ⇒ 不跳过任何键 ⇒ 生产加载路径不变

验证:
  · 配置层双环境断言:本地 jwt=8066e07d5683 / keys=e4cfe40ff3ea(≠ 生产
    a05591750093 / ae0d155385c1);生产两值逐字不变;生产 testDefaultPassword=null
  · 本地后端真实重启(仅给 DB_PORT=3307、**不给 DB_HOST**)⇒ 启动成功,
    /api/health {"code":0,...,"db":"ok"};日志显示跳过 16 个键(含 JWT_SECRET/APP_KEYS)
  · D9 判决点 d9_verify_split.py:本地密钥签 token ⇒ 200;生产密钥 ⇒ **401**
  · 生产部署后 r106_prod_sentinel.js:pass=11 fail=0;/api/health 200
  · 门禁:verify_no_secrets.py VERIFY_ALL_PASS(HIGH 0,判别力自证 17/17);
    verify_script_health.py H1–H4 PASS
2026-10-12 11:06:01 +08:00

578 lines
33 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
// ---------------------------------------------------------------------------
// 部署环境变量自加载(兜底)
// 背景: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 无使用点),跳过仅为口径一致。
// 仍保留注入的键: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 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;
}
// 非生产环境:跳过**签名密钥精确键**(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; // 第一个存在的文件即为权威来源
}
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)不受影响。
// 🔴 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 签名密钥(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'];
// 🔴 请求级并发闸门(大文件上传/下载):在读取请求体之前限制「被接受的并发重请求数」,
// 极端并发下超出上限立即回 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=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 数据库配置
// -----------------------------------------------------------------
config.sequelize = {
dialect: 'mysql',
database: process.env.DB_NAME || 'competition_db',
// 🔴 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',
// 🔴 口令不再内置明文兜底(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 显式控制(生产 = 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,
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 鉴权配置
// -----------------------------------------------------------------
// 🔴 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 签名密钥:唯一来源见 _secret.cjs 头部说明;⛔ 无明文兜底,缺失即明确失败
secret: loadJwtSecret(),
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 = [];
// 🔴 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(
`[安全阻断] 生产环境检测到未配置的密钥: ${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;
};