Files
competition-website/config/config.default.js

501 lines
26 KiB
JavaScript
Raw Normal View History

'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 不从此处加载,避免本地开发误判为生产(真实生产由启动器注入)。
fix(config): 本地启动不再被生产 DB 凭据污染(loadDeployEnv 收口 + config.local 兜底) 问题(2026-09-23 用户报障): 本地 `npm run dev` 启动即崩: SequelizeAccessDeniedError: Access denied for user '<生产库账号>'@'localhost' (using password: YES) → [agent_worker] start error, exiting with code:1 → master 退出 → 7001 无监听。 根因: config/config.default.js 顶部的 loadDeployEnv() 在**模块加载期**无条件把 deploy/.env.production 的全部 27 个键灌进 process.env,其中 DB_USER 指向生产库账号, 而本机 MySQL 并无该账号。且 deploy/ 不在任何 git 仓库内 ⇒ 该文件变更无 diff、无 review。 改动 A —— 新增 config/config.local.js(已由 .gitignore 排除,不入库): 仅在 serverEnv=local 时被 Egg 加载并「覆盖」config.default.js。 合并语义已取证:egg-core/lib/loader/mixin/config.js:61-71 `_preloadAppConfig()` 对 ['config.default', `config.${serverEnv}`] 逐项执行 extend(true, ...) ⇒ 深合并、后者覆盖前者。 本文件只覆盖 sequelize.username / password 两个字段, dialect/database/host/port/timezone/define/dialectOptions/pool/logging 全部沿用 default。 生产 serverEnv=prod ⇒ Egg 只找 config.prod.js ⇒ 本文件不加载 ⇒ 零生产影响。 改动 B —— config/config.default.js: ① 执行时机:模块级 IIFE → module.exports(appInfo) 内部调用(此时 appInfo.env 已确定); ② 作用范围:非生产环境跳过 DB_ / REDIS_ / ALIYUN_ / SMS_ 前缀键 (连接类 / 凭据类 / 网关开关类),并在日志打印被跳过的键名(绝不打印值)。 生产判定 = appInfo.env==='prod' || NODE_ENV==='production'。生产必然成立的三重依据: 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。 ⇒ 生产**不跳过任何键**,行为逐键不变。外部显式注入的键仍优先(判定在 `!== undefined` 之后)。 改动 C —— .gitignore:排除 config/config.local.js(含本地口令)。 验证(全部实测): · 双向对照探针:本地跳过 15 键、生产跳过 0 键且 DB_USER 正常注入; 合并后 pool.max=20 / define.charset=utf8mb4 / dialectOptions.charset 均保留 ⇒ 深合并成立。 · 纯裸启动(不带任何 DB 变量):egg started on :7001;health db:ok;admin-login code=0; MySQL 侧 information_schema.processlist 独立取证 user=root / db=competition_db。 · 变异测试(config.local 口令改为错值):报 1045 且报错用户名仍是 root ⇒ 一次实验同时证明 A 被加载且优先生效、B 已挡住生产账号。 · 门禁回归:verify_scoring_e2e 17/0、verify_big_question --cleanup 52/0、 verify_exam_submit_integrity 46/0、check_no_proxy_guard 5 变异全检出。 不变量(本次未改,勿顺手改): · deploy/.env.production 一字未动 —— README L57 明确它是**拷往服务器的权威源**,改它生产连不上库; · sequelize 的 dialect/database/host/port/timezone/define/pool/logging 全部沿用 config.default.js; · 环境变量优先级不变:外部显式注入 > .env.production 兜底 > 代码默认值。
2026-09-23 15:59:41 +08:00
//
// 🔴 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`。
// ---------------------------------------------------------------------------
fix(config): 本地启动不再被生产 DB 凭据污染(loadDeployEnv 收口 + config.local 兜底) 问题(2026-09-23 用户报障): 本地 `npm run dev` 启动即崩: SequelizeAccessDeniedError: Access denied for user '<生产库账号>'@'localhost' (using password: YES) → [agent_worker] start error, exiting with code:1 → master 退出 → 7001 无监听。 根因: config/config.default.js 顶部的 loadDeployEnv() 在**模块加载期**无条件把 deploy/.env.production 的全部 27 个键灌进 process.env,其中 DB_USER 指向生产库账号, 而本机 MySQL 并无该账号。且 deploy/ 不在任何 git 仓库内 ⇒ 该文件变更无 diff、无 review。 改动 A —— 新增 config/config.local.js(已由 .gitignore 排除,不入库): 仅在 serverEnv=local 时被 Egg 加载并「覆盖」config.default.js。 合并语义已取证:egg-core/lib/loader/mixin/config.js:61-71 `_preloadAppConfig()` 对 ['config.default', `config.${serverEnv}`] 逐项执行 extend(true, ...) ⇒ 深合并、后者覆盖前者。 本文件只覆盖 sequelize.username / password 两个字段, dialect/database/host/port/timezone/define/dialectOptions/pool/logging 全部沿用 default。 生产 serverEnv=prod ⇒ Egg 只找 config.prod.js ⇒ 本文件不加载 ⇒ 零生产影响。 改动 B —— config/config.default.js: ① 执行时机:模块级 IIFE → module.exports(appInfo) 内部调用(此时 appInfo.env 已确定); ② 作用范围:非生产环境跳过 DB_ / REDIS_ / ALIYUN_ / SMS_ 前缀键 (连接类 / 凭据类 / 网关开关类),并在日志打印被跳过的键名(绝不打印值)。 生产判定 = appInfo.env==='prod' || NODE_ENV==='production'。生产必然成立的三重依据: 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。 ⇒ 生产**不跳过任何键**,行为逐键不变。外部显式注入的键仍优先(判定在 `!== undefined` 之后)。 改动 C —— .gitignore:排除 config/config.local.js(含本地口令)。 验证(全部实测): · 双向对照探针:本地跳过 15 键、生产跳过 0 键且 DB_USER 正常注入; 合并后 pool.max=20 / define.charset=utf8mb4 / dialectOptions.charset 均保留 ⇒ 深合并成立。 · 纯裸启动(不带任何 DB 变量):egg started on :7001;health db:ok;admin-login code=0; MySQL 侧 information_schema.processlist 独立取证 user=root / db=competition_db。 · 变异测试(config.local 口令改为错值):报 1045 且报错用户名仍是 root ⇒ 一次实验同时证明 A 被加载且优先生效、B 已挡住生产账号。 · 门禁回归:verify_scoring_e2e 17/0、verify_big_question --cleanup 52/0、 verify_exam_submit_integrity 46/0、check_no_proxy_guard 5 变异全检出。 不变量(本次未改,勿顺手改): · deploy/.env.production 一字未动 —— README L57 明确它是**拷往服务器的权威源**,改它生产连不上库; · sequelize 的 dialect/database/host/port/timezone/define/pool/logging 全部沿用 config.default.js; · 环境变量优先级不变:外部显式注入 > .env.production 兜底 > 代码默认值。
2026-09-23 15:59:41 +08:00
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 = [];
fix(config): 本地启动不再被生产 DB 凭据污染(loadDeployEnv 收口 + config.local 兜底) 问题(2026-09-23 用户报障): 本地 `npm run dev` 启动即崩: SequelizeAccessDeniedError: Access denied for user '<生产库账号>'@'localhost' (using password: YES) → [agent_worker] start error, exiting with code:1 → master 退出 → 7001 无监听。 根因: config/config.default.js 顶部的 loadDeployEnv() 在**模块加载期**无条件把 deploy/.env.production 的全部 27 个键灌进 process.env,其中 DB_USER 指向生产库账号, 而本机 MySQL 并无该账号。且 deploy/ 不在任何 git 仓库内 ⇒ 该文件变更无 diff、无 review。 改动 A —— 新增 config/config.local.js(已由 .gitignore 排除,不入库): 仅在 serverEnv=local 时被 Egg 加载并「覆盖」config.default.js。 合并语义已取证:egg-core/lib/loader/mixin/config.js:61-71 `_preloadAppConfig()` 对 ['config.default', `config.${serverEnv}`] 逐项执行 extend(true, ...) ⇒ 深合并、后者覆盖前者。 本文件只覆盖 sequelize.username / password 两个字段, dialect/database/host/port/timezone/define/dialectOptions/pool/logging 全部沿用 default。 生产 serverEnv=prod ⇒ Egg 只找 config.prod.js ⇒ 本文件不加载 ⇒ 零生产影响。 改动 B —— config/config.default.js: ① 执行时机:模块级 IIFE → module.exports(appInfo) 内部调用(此时 appInfo.env 已确定); ② 作用范围:非生产环境跳过 DB_ / REDIS_ / ALIYUN_ / SMS_ 前缀键 (连接类 / 凭据类 / 网关开关类),并在日志打印被跳过的键名(绝不打印值)。 生产判定 = appInfo.env==='prod' || NODE_ENV==='production'。生产必然成立的三重依据: 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。 ⇒ 生产**不跳过任何键**,行为逐键不变。外部显式注入的键仍优先(判定在 `!== undefined` 之后)。 改动 C —— .gitignore:排除 config/config.local.js(含本地口令)。 验证(全部实测): · 双向对照探针:本地跳过 15 键、生产跳过 0 键且 DB_USER 正常注入; 合并后 pool.max=20 / define.charset=utf8mb4 / dialectOptions.charset 均保留 ⇒ 深合并成立。 · 纯裸启动(不带任何 DB 变量):egg started on :7001;health db:ok;admin-login code=0; MySQL 侧 information_schema.processlist 独立取证 user=root / db=competition_db。 · 变异测试(config.local 口令改为错值):报 1045 且报错用户名仍是 root ⇒ 一次实验同时证明 A 被加载且优先生效、B 已挡住生产账号。 · 门禁回归:verify_scoring_e2e 17/0、verify_big_question --cleanup 52/0、 verify_exam_submit_integrity 46/0、check_no_proxy_guard 5 变异全检出。 不变量(本次未改,勿顺手改): · deploy/.env.production 一字未动 —— README L57 明确它是**拷往服务器的权威源**,改它生产连不上库; · sequelize 的 dialect/database/host/port/timezone/define/pool/logging 全部沿用 config.default.js; · 环境变量优先级不变:外部显式注入 > .env.production 兜底 > 代码默认值。
2026-09-23 15:59:41 +08:00
// 生产判定: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;
fix(config): 本地启动不再被生产 DB 凭据污染(loadDeployEnv 收口 + config.local 兜底) 问题(2026-09-23 用户报障): 本地 `npm run dev` 启动即崩: SequelizeAccessDeniedError: Access denied for user '<生产库账号>'@'localhost' (using password: YES) → [agent_worker] start error, exiting with code:1 → master 退出 → 7001 无监听。 根因: config/config.default.js 顶部的 loadDeployEnv() 在**模块加载期**无条件把 deploy/.env.production 的全部 27 个键灌进 process.env,其中 DB_USER 指向生产库账号, 而本机 MySQL 并无该账号。且 deploy/ 不在任何 git 仓库内 ⇒ 该文件变更无 diff、无 review。 改动 A —— 新增 config/config.local.js(已由 .gitignore 排除,不入库): 仅在 serverEnv=local 时被 Egg 加载并「覆盖」config.default.js。 合并语义已取证:egg-core/lib/loader/mixin/config.js:61-71 `_preloadAppConfig()` 对 ['config.default', `config.${serverEnv}`] 逐项执行 extend(true, ...) ⇒ 深合并、后者覆盖前者。 本文件只覆盖 sequelize.username / password 两个字段, dialect/database/host/port/timezone/define/dialectOptions/pool/logging 全部沿用 default。 生产 serverEnv=prod ⇒ Egg 只找 config.prod.js ⇒ 本文件不加载 ⇒ 零生产影响。 改动 B —— config/config.default.js: ① 执行时机:模块级 IIFE → module.exports(appInfo) 内部调用(此时 appInfo.env 已确定); ② 作用范围:非生产环境跳过 DB_ / REDIS_ / ALIYUN_ / SMS_ 前缀键 (连接类 / 凭据类 / 网关开关类),并在日志打印被跳过的键名(绝不打印值)。 生产判定 = appInfo.env==='prod' || NODE_ENV==='production'。生产必然成立的三重依据: 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。 ⇒ 生产**不跳过任何键**,行为逐键不变。外部显式注入的键仍优先(判定在 `!== undefined` 之后)。 改动 C —— .gitignore:排除 config/config.local.js(含本地口令)。 验证(全部实测): · 双向对照探针:本地跳过 15 键、生产跳过 0 键且 DB_USER 正常注入; 合并后 pool.max=20 / define.charset=utf8mb4 / dialectOptions.charset 均保留 ⇒ 深合并成立。 · 纯裸启动(不带任何 DB 变量):egg started on :7001;health db:ok;admin-login code=0; MySQL 侧 information_schema.processlist 独立取证 user=root / db=competition_db。 · 变异测试(config.local 口令改为错值):报 1045 且报错用户名仍是 root ⇒ 一次实验同时证明 A 被加载且优先生效、B 已挡住生产账号。 · 门禁回归:verify_scoring_e2e 17/0、verify_big_question --cleanup 52/0、 verify_exam_submit_integrity 46/0、check_no_proxy_guard 5 变异全检出。 不变量(本次未改,勿顺手改): · deploy/.env.production 一字未动 —— README L57 明确它是**拷往服务器的权威源**,改它生产连不上库; · sequelize 的 dialect/database/host/port/timezone/define/pool/logging 全部沿用 config.default.js; · 环境变量优先级不变:外部显式注入 > .env.production 兜底 > 代码默认值。
2026-09-23 15:59:41 +08:00
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);
}
fix(config): 本地启动不再被生产 DB 凭据污染(loadDeployEnv 收口 + config.local 兜底) 问题(2026-09-23 用户报障): 本地 `npm run dev` 启动即崩: SequelizeAccessDeniedError: Access denied for user '<生产库账号>'@'localhost' (using password: YES) → [agent_worker] start error, exiting with code:1 → master 退出 → 7001 无监听。 根因: config/config.default.js 顶部的 loadDeployEnv() 在**模块加载期**无条件把 deploy/.env.production 的全部 27 个键灌进 process.env,其中 DB_USER 指向生产库账号, 而本机 MySQL 并无该账号。且 deploy/ 不在任何 git 仓库内 ⇒ 该文件变更无 diff、无 review。 改动 A —— 新增 config/config.local.js(已由 .gitignore 排除,不入库): 仅在 serverEnv=local 时被 Egg 加载并「覆盖」config.default.js。 合并语义已取证:egg-core/lib/loader/mixin/config.js:61-71 `_preloadAppConfig()` 对 ['config.default', `config.${serverEnv}`] 逐项执行 extend(true, ...) ⇒ 深合并、后者覆盖前者。 本文件只覆盖 sequelize.username / password 两个字段, dialect/database/host/port/timezone/define/dialectOptions/pool/logging 全部沿用 default。 生产 serverEnv=prod ⇒ Egg 只找 config.prod.js ⇒ 本文件不加载 ⇒ 零生产影响。 改动 B —— config/config.default.js: ① 执行时机:模块级 IIFE → module.exports(appInfo) 内部调用(此时 appInfo.env 已确定); ② 作用范围:非生产环境跳过 DB_ / REDIS_ / ALIYUN_ / SMS_ 前缀键 (连接类 / 凭据类 / 网关开关类),并在日志打印被跳过的键名(绝不打印值)。 生产判定 = appInfo.env==='prod' || NODE_ENV==='production'。生产必然成立的三重依据: 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。 ⇒ 生产**不跳过任何键**,行为逐键不变。外部显式注入的键仍优先(判定在 `!== undefined` 之后)。 改动 C —— .gitignore:排除 config/config.local.js(含本地口令)。 验证(全部实测): · 双向对照探针:本地跳过 15 键、生产跳过 0 键且 DB_USER 正常注入; 合并后 pool.max=20 / define.charset=utf8mb4 / dialectOptions.charset 均保留 ⇒ 深合并成立。 · 纯裸启动(不带任何 DB 变量):egg started on :7001;health db:ok;admin-login code=0; MySQL 侧 information_schema.processlist 独立取证 user=root / db=competition_db。 · 变异测试(config.local 口令改为错值):报 1045 且报错用户名仍是 root ⇒ 一次实验同时证明 A 被加载且优先生效、B 已挡住生产账号。 · 门禁回归:verify_scoring_e2e 17/0、verify_big_question --cleanup 52/0、 verify_exam_submit_integrity 46/0、check_no_proxy_guard 5 变异全检出。 不变量(本次未改,勿顺手改): · deploy/.env.production 一字未动 —— README L57 明确它是**拷往服务器的权威源**,改它生产连不上库; · sequelize 的 dialect/database/host/port/timezone/define/pool/logging 全部沿用 config.default.js; · 环境变量优先级不变:外部显式注入 > .env.production 兜底 > 代码默认值。
2026-09-23 15:59:41 +08:00
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;
}
fix(config): 本地启动不再被生产 DB 凭据污染(loadDeployEnv 收口 + config.local 兜底) 问题(2026-09-23 用户报障): 本地 `npm run dev` 启动即崩: SequelizeAccessDeniedError: Access denied for user '<生产库账号>'@'localhost' (using password: YES) → [agent_worker] start error, exiting with code:1 → master 退出 → 7001 无监听。 根因: config/config.default.js 顶部的 loadDeployEnv() 在**模块加载期**无条件把 deploy/.env.production 的全部 27 个键灌进 process.env,其中 DB_USER 指向生产库账号, 而本机 MySQL 并无该账号。且 deploy/ 不在任何 git 仓库内 ⇒ 该文件变更无 diff、无 review。 改动 A —— 新增 config/config.local.js(已由 .gitignore 排除,不入库): 仅在 serverEnv=local 时被 Egg 加载并「覆盖」config.default.js。 合并语义已取证:egg-core/lib/loader/mixin/config.js:61-71 `_preloadAppConfig()` 对 ['config.default', `config.${serverEnv}`] 逐项执行 extend(true, ...) ⇒ 深合并、后者覆盖前者。 本文件只覆盖 sequelize.username / password 两个字段, dialect/database/host/port/timezone/define/dialectOptions/pool/logging 全部沿用 default。 生产 serverEnv=prod ⇒ Egg 只找 config.prod.js ⇒ 本文件不加载 ⇒ 零生产影响。 改动 B —— config/config.default.js: ① 执行时机:模块级 IIFE → module.exports(appInfo) 内部调用(此时 appInfo.env 已确定); ② 作用范围:非生产环境跳过 DB_ / REDIS_ / ALIYUN_ / SMS_ 前缀键 (连接类 / 凭据类 / 网关开关类),并在日志打印被跳过的键名(绝不打印值)。 生产判定 = appInfo.env==='prod' || NODE_ENV==='production'。生产必然成立的三重依据: 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。 ⇒ 生产**不跳过任何键**,行为逐键不变。外部显式注入的键仍优先(判定在 `!== undefined` 之后)。 改动 C —— .gitignore:排除 config/config.local.js(含本地口令)。 验证(全部实测): · 双向对照探针:本地跳过 15 键、生产跳过 0 键且 DB_USER 正常注入; 合并后 pool.max=20 / define.charset=utf8mb4 / dialectOptions.charset 均保留 ⇒ 深合并成立。 · 纯裸启动(不带任何 DB 变量):egg started on :7001;health db:ok;admin-login code=0; MySQL 侧 information_schema.processlist 独立取证 user=root / db=competition_db。 · 变异测试(config.local 口令改为错值):报 1045 且报错用户名仍是 root ⇒ 一次实验同时证明 A 被加载且优先生效、B 已挡住生产账号。 · 门禁回归:verify_scoring_e2e 17/0、verify_big_question --cleanup 52/0、 verify_exam_submit_integrity 46/0、check_no_proxy_guard 5 变异全检出。 不变量(本次未改,勿顺手改): · deploy/.env.production 一字未动 —— README L57 明确它是**拷往服务器的权威源**,改它生产连不上库; · sequelize 的 dialect/database/host/port/timezone/define/pool/logging 全部沿用 config.default.js; · 环境变量优先级不变:外部显式注入 > .env.production 兜底 > 代码默认值。
2026-09-23 15:59:41 +08:00
process.env[key] = val;
}
loadedFrom = file;
break; // 第一个存在的文件即为权威来源
}
if (loadedFrom) {
console.log('[deploy-env] 已从 ' + loadedFrom + ' 加载部署环境变量');
} else {
console.warn('[deploy-env] 未找到 .env.production,将完全依赖进程环境变量注入(缺失生产密钥会触发安全守卫)');
}
fix(config): 本地启动不再被生产 DB 凭据污染(loadDeployEnv 收口 + config.local 兜底) 问题(2026-09-23 用户报障): 本地 `npm run dev` 启动即崩: SequelizeAccessDeniedError: Access denied for user '<生产库账号>'@'localhost' (using password: YES) → [agent_worker] start error, exiting with code:1 → master 退出 → 7001 无监听。 根因: config/config.default.js 顶部的 loadDeployEnv() 在**模块加载期**无条件把 deploy/.env.production 的全部 27 个键灌进 process.env,其中 DB_USER 指向生产库账号, 而本机 MySQL 并无该账号。且 deploy/ 不在任何 git 仓库内 ⇒ 该文件变更无 diff、无 review。 改动 A —— 新增 config/config.local.js(已由 .gitignore 排除,不入库): 仅在 serverEnv=local 时被 Egg 加载并「覆盖」config.default.js。 合并语义已取证:egg-core/lib/loader/mixin/config.js:61-71 `_preloadAppConfig()` 对 ['config.default', `config.${serverEnv}`] 逐项执行 extend(true, ...) ⇒ 深合并、后者覆盖前者。 本文件只覆盖 sequelize.username / password 两个字段, dialect/database/host/port/timezone/define/dialectOptions/pool/logging 全部沿用 default。 生产 serverEnv=prod ⇒ Egg 只找 config.prod.js ⇒ 本文件不加载 ⇒ 零生产影响。 改动 B —— config/config.default.js: ① 执行时机:模块级 IIFE → module.exports(appInfo) 内部调用(此时 appInfo.env 已确定); ② 作用范围:非生产环境跳过 DB_ / REDIS_ / ALIYUN_ / SMS_ 前缀键 (连接类 / 凭据类 / 网关开关类),并在日志打印被跳过的键名(绝不打印值)。 生产判定 = appInfo.env==='prod' || NODE_ENV==='production'。生产必然成立的三重依据: 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。 ⇒ 生产**不跳过任何键**,行为逐键不变。外部显式注入的键仍优先(判定在 `!== undefined` 之后)。 改动 C —— .gitignore:排除 config/config.local.js(含本地口令)。 验证(全部实测): · 双向对照探针:本地跳过 15 键、生产跳过 0 键且 DB_USER 正常注入; 合并后 pool.max=20 / define.charset=utf8mb4 / dialectOptions.charset 均保留 ⇒ 深合并成立。 · 纯裸启动(不带任何 DB 变量):egg started on :7001;health db:ok;admin-login code=0; MySQL 侧 information_schema.processlist 独立取证 user=root / db=competition_db。 · 变异测试(config.local 口令改为错值):报 1045 且报错用户名仍是 root ⇒ 一次实验同时证明 A 被加载且优先生效、B 已挡住生产账号。 · 门禁回归:verify_scoring_e2e 17/0、verify_big_question --cleanup 52/0、 verify_exam_submit_integrity 46/0、check_no_proxy_guard 5 变异全检出。 不变量(本次未改,勿顺手改): · deploy/.env.production 一字未动 —— README L57 明确它是**拷往服务器的权威源**,改它生产连不上库; · sequelize 的 dialect/database/host/port/timezone/define/pool/logging 全部沿用 config.default.js; · 环境变量优先级不变:外部显式注入 > .env.production 兜底 > 代码默认值。
2026-09-23 15:59:41 +08:00
// 非生产环境把「被跳过的键」显式打印出来。⚠️ 只打印键名,绝不打印值。
// 注意:本行出现 ≠ 那些键被注入(恰恰相反,是被跳过了),排查时勿误读。
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));
}
fix(config): 本地启动不再被生产 DB 凭据污染(loadDeployEnv 收口 + config.local 兜底) 问题(2026-09-23 用户报障): 本地 `npm run dev` 启动即崩: SequelizeAccessDeniedError: Access denied for user '<生产库账号>'@'localhost' (using password: YES) → [agent_worker] start error, exiting with code:1 → master 退出 → 7001 无监听。 根因: config/config.default.js 顶部的 loadDeployEnv() 在**模块加载期**无条件把 deploy/.env.production 的全部 27 个键灌进 process.env,其中 DB_USER 指向生产库账号, 而本机 MySQL 并无该账号。且 deploy/ 不在任何 git 仓库内 ⇒ 该文件变更无 diff、无 review。 改动 A —— 新增 config/config.local.js(已由 .gitignore 排除,不入库): 仅在 serverEnv=local 时被 Egg 加载并「覆盖」config.default.js。 合并语义已取证:egg-core/lib/loader/mixin/config.js:61-71 `_preloadAppConfig()` 对 ['config.default', `config.${serverEnv}`] 逐项执行 extend(true, ...) ⇒ 深合并、后者覆盖前者。 本文件只覆盖 sequelize.username / password 两个字段, dialect/database/host/port/timezone/define/dialectOptions/pool/logging 全部沿用 default。 生产 serverEnv=prod ⇒ Egg 只找 config.prod.js ⇒ 本文件不加载 ⇒ 零生产影响。 改动 B —— config/config.default.js: ① 执行时机:模块级 IIFE → module.exports(appInfo) 内部调用(此时 appInfo.env 已确定); ② 作用范围:非生产环境跳过 DB_ / REDIS_ / ALIYUN_ / SMS_ 前缀键 (连接类 / 凭据类 / 网关开关类),并在日志打印被跳过的键名(绝不打印值)。 生产判定 = appInfo.env==='prod' || NODE_ENV==='production'。生产必然成立的三重依据: 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。 ⇒ 生产**不跳过任何键**,行为逐键不变。外部显式注入的键仍优先(判定在 `!== undefined` 之后)。 改动 C —— .gitignore:排除 config/config.local.js(含本地口令)。 验证(全部实测): · 双向对照探针:本地跳过 15 键、生产跳过 0 键且 DB_USER 正常注入; 合并后 pool.max=20 / define.charset=utf8mb4 / dialectOptions.charset 均保留 ⇒ 深合并成立。 · 纯裸启动(不带任何 DB 变量):egg started on :7001;health db:ok;admin-login code=0; MySQL 侧 information_schema.processlist 独立取证 user=root / db=competition_db。 · 变异测试(config.local 口令改为错值):报 1045 且报错用户名仍是 root ⇒ 一次实验同时证明 A 被加载且优先生效、B 已挡住生产账号。 · 门禁回归:verify_scoring_e2e 17/0、verify_big_question --cleanup 52/0、 verify_exam_submit_integrity 46/0、check_no_proxy_guard 5 变异全检出。 不变量(本次未改,勿顺手改): · deploy/.env.production 一字未动 —— README L57 明确它是**拷往服务器的权威源**,改它生产连不上库; · sequelize 的 dialect/database/host/port/timezone/define/pool/logging 全部沿用 config.default.js; · 环境变量优先级不变:外部显式注入 > .env.production 兜底 > 代码默认值。
2026-09-23 15:59:41 +08:00
}
module.exports = (appInfo) => {
const config = (exports = {});
fix(config): 本地启动不再被生产 DB 凭据污染(loadDeployEnv 收口 + config.local 兜底) 问题(2026-09-23 用户报障): 本地 `npm run dev` 启动即崩: SequelizeAccessDeniedError: Access denied for user '<生产库账号>'@'localhost' (using password: YES) → [agent_worker] start error, exiting with code:1 → master 退出 → 7001 无监听。 根因: config/config.default.js 顶部的 loadDeployEnv() 在**模块加载期**无条件把 deploy/.env.production 的全部 27 个键灌进 process.env,其中 DB_USER 指向生产库账号, 而本机 MySQL 并无该账号。且 deploy/ 不在任何 git 仓库内 ⇒ 该文件变更无 diff、无 review。 改动 A —— 新增 config/config.local.js(已由 .gitignore 排除,不入库): 仅在 serverEnv=local 时被 Egg 加载并「覆盖」config.default.js。 合并语义已取证:egg-core/lib/loader/mixin/config.js:61-71 `_preloadAppConfig()` 对 ['config.default', `config.${serverEnv}`] 逐项执行 extend(true, ...) ⇒ 深合并、后者覆盖前者。 本文件只覆盖 sequelize.username / password 两个字段, dialect/database/host/port/timezone/define/dialectOptions/pool/logging 全部沿用 default。 生产 serverEnv=prod ⇒ Egg 只找 config.prod.js ⇒ 本文件不加载 ⇒ 零生产影响。 改动 B —— config/config.default.js: ① 执行时机:模块级 IIFE → module.exports(appInfo) 内部调用(此时 appInfo.env 已确定); ② 作用范围:非生产环境跳过 DB_ / REDIS_ / ALIYUN_ / SMS_ 前缀键 (连接类 / 凭据类 / 网关开关类),并在日志打印被跳过的键名(绝不打印值)。 生产判定 = appInfo.env==='prod' || NODE_ENV==='production'。生产必然成立的三重依据: 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。 ⇒ 生产**不跳过任何键**,行为逐键不变。外部显式注入的键仍优先(判定在 `!== undefined` 之后)。 改动 C —— .gitignore:排除 config/config.local.js(含本地口令)。 验证(全部实测): · 双向对照探针:本地跳过 15 键、生产跳过 0 键且 DB_USER 正常注入; 合并后 pool.max=20 / define.charset=utf8mb4 / dialectOptions.charset 均保留 ⇒ 深合并成立。 · 纯裸启动(不带任何 DB 变量):egg started on :7001;health db:ok;admin-login code=0; MySQL 侧 information_schema.processlist 独立取证 user=root / db=competition_db。 · 变异测试(config.local 口令改为错值):报 1045 且报错用户名仍是 root ⇒ 一次实验同时证明 A 被加载且优先生效、B 已挡住生产账号。 · 门禁回归:verify_scoring_e2e 17/0、verify_big_question --cleanup 52/0、 verify_exam_submit_integrity 46/0、check_no_proxy_guard 5 变异全检出。 不变量(本次未改,勿顺手改): · deploy/.env.production 一字未动 —— README L57 明确它是**拷往服务器的权威源**,改它生产连不上库; · sequelize 的 dialect/database/host/port/timezone/define/pool/logging 全部沿用 config.default.js; · 环境变量优先级不变:外部显式注入 > .env.production 兜底 > 代码默认值。
2026-09-23 15:59:41 +08:00
// 🔴 必须在下文任何 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,
feat(exam/auth): 登录闸门 + 交卷稳定性加固(R4-R8) R4 登录/交卷背压闸门 - app/middleware/concurrency_guard.js: 新增。超限即 429 + Retry-After(立即拒绝式, 不排队;实测 429 P50=34ms)。RETRY_AFTER 收敛为单一有依据常量 = '2' (≈1.8 个满载窗口;实测 bcryptjs 1129ms / 原生 265ms 每窗口)。 配额:LOGIN_MAX=16 / SUBMIT_MAX=24 / HEAVY_REQ_MAX=20,各 *REQ_MAX 覆盖。 - config/config.default.js: 闸门配额与连接池容量配置;DB dialect 锁 mysql。 R7 顺序红线 + R8 Retry-After 同源 + 原生 bcrypt - 3 处硬编码 '2' 收敛为单一常量;补"勿因换实现而删闸门"红线注释。 - bcryptjs -> bcrypt@6(N-API,Windows/Linux 均免编译;drop-in,存量哈希 $2b$10$ 兼容)。 - app/model/account.js / app/service/auth.js: 同步哈希实现注释(D4 全族 5 处陈旧 SHA256 注释修正)。 答题链路与综合大题 - app/controller/exam.js: 中职/高职综合大题接口(GET big-question / POST big-answer, 逐问 2000 字上限校验);uploadWord 对中职/高职封堵(400)。 - app/service/exam.js: 抽题快照 question_snapshot 落库、答案归集键修正。 - app/controller/auth.js / app/service/auth.js: toClientUser() 唯一出口, 未发布态按组剔除 score,exam_score/word_score/ppt_score 任何情况下不下发。 - app/router.js: 新增大题与管理端路由。 - app/extend/helper.js: 下载文件名 RFC5987 编码(禁双重编码)。 - app.js: 池容量断言按 env 区分单/多 worker(防 dev 假日志)。 验证:ci-gates.sh 9/9 全绿;verify_login_guard.py 28/28(含 D 组变异 9/9); 真实登录/抽题/交卷端到端 code=0;前端构建 BUILD_RC=0。
2026-09-22 09:16:55 +08:00
// 交卷独立上限(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,
};
feat(exam/auth): 登录闸门 + 交卷稳定性加固(R4-R8) R4 登录/交卷背压闸门 - app/middleware/concurrency_guard.js: 新增。超限即 429 + Retry-After(立即拒绝式, 不排队;实测 429 P50=34ms)。RETRY_AFTER 收敛为单一有依据常量 = '2' (≈1.8 个满载窗口;实测 bcryptjs 1129ms / 原生 265ms 每窗口)。 配额:LOGIN_MAX=16 / SUBMIT_MAX=24 / HEAVY_REQ_MAX=20,各 *REQ_MAX 覆盖。 - config/config.default.js: 闸门配额与连接池容量配置;DB dialect 锁 mysql。 R7 顺序红线 + R8 Retry-After 同源 + 原生 bcrypt - 3 处硬编码 '2' 收敛为单一常量;补"勿因换实现而删闸门"红线注释。 - bcryptjs -> bcrypt@6(N-API,Windows/Linux 均免编译;drop-in,存量哈希 $2b$10$ 兼容)。 - app/model/account.js / app/service/auth.js: 同步哈希实现注释(D4 全族 5 处陈旧 SHA256 注释修正)。 答题链路与综合大题 - app/controller/exam.js: 中职/高职综合大题接口(GET big-question / POST big-answer, 逐问 2000 字上限校验);uploadWord 对中职/高职封堵(400)。 - app/service/exam.js: 抽题快照 question_snapshot 落库、答案归集键修正。 - app/controller/auth.js / app/service/auth.js: toClientUser() 唯一出口, 未发布态按组剔除 score,exam_score/word_score/ppt_score 任何情况下不下发。 - app/router.js: 新增大题与管理端路由。 - app/extend/helper.js: 下载文件名 RFC5987 编码(禁双重编码)。 - app.js: 池容量断言按 env 区分单/多 worker(防 dev 假日志)。 验证:ci-gates.sh 9/9 全绿;verify_login_guard.py 28/28(含 D 组变异 9/9); 真实登录/抽题/交卷端到端 code=0;前端构建 BUILD_RC=0。
2026-09-22 09:16:55 +08:00
// -----------------------------------------------------------------
// 🔴 集群 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',
// 【测试模拟值 '74191221',仅供本地/测试部署;生产请通过 DB_PASSWORD 环境变量注入真实密码】
password: process.env.DB_PASSWORD || '74191221',
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',
},
pool: {
// 🔴 压测调优(2026-08-11):单 worker 默认 10 连接,在「无带宽限制 + 极端并发」
// 下分片合并/写入会短时占用大量连接。提高到 20(可用 DB_POOL_MAX 覆盖),
// 并放宽 acquire 到 45s,避免瞬时峰值触发 "connection acquire timeout"。
feat(exam/auth): 登录闸门 + 交卷稳定性加固(R4-R8) R4 登录/交卷背压闸门 - app/middleware/concurrency_guard.js: 新增。超限即 429 + Retry-After(立即拒绝式, 不排队;实测 429 P50=34ms)。RETRY_AFTER 收敛为单一有依据常量 = '2' (≈1.8 个满载窗口;实测 bcryptjs 1129ms / 原生 265ms 每窗口)。 配额:LOGIN_MAX=16 / SUBMIT_MAX=24 / HEAVY_REQ_MAX=20,各 *REQ_MAX 覆盖。 - config/config.default.js: 闸门配额与连接池容量配置;DB dialect 锁 mysql。 R7 顺序红线 + R8 Retry-After 同源 + 原生 bcrypt - 3 处硬编码 '2' 收敛为单一常量;补"勿因换实现而删闸门"红线注释。 - bcryptjs -> bcrypt@6(N-API,Windows/Linux 均免编译;drop-in,存量哈希 $2b$10$ 兼容)。 - app/model/account.js / app/service/auth.js: 同步哈希实现注释(D4 全族 5 处陈旧 SHA256 注释修正)。 答题链路与综合大题 - app/controller/exam.js: 中职/高职综合大题接口(GET big-question / POST big-answer, 逐问 2000 字上限校验);uploadWord 对中职/高职封堵(400)。 - app/service/exam.js: 抽题快照 question_snapshot 落库、答案归集键修正。 - app/controller/auth.js / app/service/auth.js: toClientUser() 唯一出口, 未发布态按组剔除 score,exam_score/word_score/ppt_score 任何情况下不下发。 - app/router.js: 新增大题与管理端路由。 - app/extend/helper.js: 下载文件名 RFC5987 编码(禁双重编码)。 - app.js: 池容量断言按 env 区分单/多 worker(防 dev 假日志)。 验证:ci-gates.sh 9/9 全绿;verify_login_guard.py 28/28(含 D 组变异 9/9); 真实登录/抽题/交卷端到端 code=0;前端构建 BUILD_RC=0。
2026-09-22 09:16:55 +08:00
// 注意:这是「每 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',
password: process.env.REDIS_PASSWORD || '74191221',
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');
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;
};