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

527 lines
28 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
docs(backend): 同步 worker 数注释(生产 4 -> 2,与 2 核匹配) 仅注释变更,**无行为变更**。EGG_WORKERS 实际由 deploy/.env.production 注入 + deploy/start-backend.sh 兜底决定,本次把注释里写死的旧口径同步为 2,避免 「注释与配置漂移」再次误导后人。 - app.js: · 「生产显式设为 4」-> 2 · 「4×20=80(安全)」->「2×20=40(安全)」 · 「本地 dev 也会加载 .env.production,其中写着 4」-> 2 · 「dev 下真 worker=1 却报 4×20=80」-> 2×20=40 - config/config.default.js: · 「生产值写在 deploy/.env.production(EGG_WORKERS=4)」-> 2(并注明 2026-09-29 R40 由 4 校正为 2) · 「4 × 20 = 80」->「2 × 20 = 40」 · pool 段「worker 数由 EGG_WORKERS 显式控制(生产 = 4)」-> 2 · 「本机/多数服务器为 8 核 → 8 个 worker」->「核数越多起得越多(开发机 8 核 → 8 个)」 (原文把"8 核"当成多数服务器的事实,与实测的 2 核服务器不符) 背景(R40 · BACKLOG C2):worker 数 ≈ CPU 核数。worker 数 > 核数对 CPU 型路径 (bcrypt 登录)**零收益** —— 加 worker 不加核(native bcrypt 走 libuv threadpool), 只增加 context switch 与内存(单 worker 空闲 RSS ≈108MB)。登录容量由物理核数 决定(实测 ≈12.6 次/核·秒)⇒ 2 与 4 的登录容量相同。连接池 2×20=40 ≤ 151-20 ✅ 验证:node --check app.js / config/config.default.js 通过; verify-c1c2.py 含「五处文档/代码注释已无旧口径「4」残留」断言(24/24 通过)。
2026-09-29 12:34:38 +08:00
// 起同等数量的 app worker —— 核数越多起得越多(开发机 8 核 → 8 个 worker)。
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 的连接池上限是 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)。
docs(backend): 同步 worker 数注释(生产 4 -> 2,与 2 核匹配) 仅注释变更,**无行为变更**。EGG_WORKERS 实际由 deploy/.env.production 注入 + deploy/start-backend.sh 兜底决定,本次把注释里写死的旧口径同步为 2,避免 「注释与配置漂移」再次误导后人。 - app.js: · 「生产显式设为 4」-> 2 · 「4×20=80(安全)」->「2×20=40(安全)」 · 「本地 dev 也会加载 .env.production,其中写着 4」-> 2 · 「dev 下真 worker=1 却报 4×20=80」-> 2×20=40 - config/config.default.js: · 「生产值写在 deploy/.env.production(EGG_WORKERS=4)」-> 2(并注明 2026-09-29 R40 由 4 校正为 2) · 「4 × 20 = 80」->「2 × 20 = 40」 · pool 段「worker 数由 EGG_WORKERS 显式控制(生产 = 4)」-> 2 · 「本机/多数服务器为 8 核 → 8 个 worker」->「核数越多起得越多(开发机 8 核 → 8 个)」 (原文把"8 核"当成多数服务器的事实,与实测的 2 核服务器不符) 背景(R40 · BACKLOG C2):worker 数 ≈ CPU 核数。worker 数 > 核数对 CPU 型路径 (bcrypt 登录)**零收益** —— 加 worker 不加核(native bcrypt 走 libuv threadpool), 只增加 context switch 与内存(单 worker 空闲 RSS ≈108MB)。登录容量由物理核数 决定(实测 ≈12.6 次/核·秒)⇒ 2 与 4 的登录容量相同。连接池 2×20=40 ≤ 151-20 ✅ 验证:node --check app.js / config/config.default.js 通过; verify-c1c2.py 含「五处文档/代码注释已无旧口径「4」残留」断言(24/24 通过)。
2026-09-29 12:34:38 +08:00
// 生产值写在 deploy/.env.production(EGG_WORKERS=2,与服务器 2 核匹配;
// 2026-09-29 R40 由 4 校正为 2),并由 start-backend.sh 显式传参作双保险。
// 2 × 20 = 40 ≤ 151,为管理后台/统计等其余连接留足余量。
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
//
// ⚠️ 不要试图在此处写 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',
chore(security): 处置已入库的本地 DB 口令(P1-P3;P4 改历史不做) 背景(R14 登记的 §16.3): git grep -l "74191221" HEAD 曾命中 7 个文件,全为本地 MariaDB root 口令 (已核实无生产凭据)。本次按登记项执行 P1-P3;P4(git filter-repo 改写 历史)明确不做。 P1 database/config.json(dev/test 两段明文口令) - 加入 .gitignore - 新增 database/config.json.example(口令为 CHANGE_ME 占位符) - git rm --cached(保留磁盘文件,本地 sequelize-cli 不受影响) - 注意:这是对共享仓库的文件移除。push 后其他克隆需 cp database/config.json.example database/config.json 并填入口令。 P2 五个脚本口令环境变量化(去明文兜底) - add_word_cols.js / add_wx_link.js / show_tables.js / seed-carousel.js 内联 '74191221' 改为 process.env.DB_PASSWORD(另支持 DB_HOST/DB_USER/DB_NAME) - scripts/diagnose_upload_download.cjs:process.env.DB_PASS || '74191221' 改为 process.env.DB_PASS;用法注释中的示例口令改为占位符 - 有意的行为变更:未注入 DB_PASSWORD 时明确失败,不再静默使用公开口令 P3 config/config.default.js 消除「生产静默回退公开口令」 - sequelize.password 去掉 || '74191221' - redis.client.password 去掉 || '74191221' - 生产安全守卫新增 DB_PASSWORD 缺失阻断(与 JWT_SECRET/APP_KEYS 同源同形) - deploy/.env.production 本就含 DB_PASSWORD ⇒ 现网零影响 验证(全部实测): - git grep 74191221 工作树零命中(HEAD 仍有,属 P4 不做范围) - 裸启动(无任何 DB 变量)成功:本地口令由 config.local.js 提供 - 端到端:health db:ok + admin-login code=0 - MySQL 侧独立取证:processlist = root @ competition_db - 变异测试(改坏 config.local.js 口令)⇒ 报 1045 且用户名仍为 root ⇒ 同证 P3 兜底确已移除 + R18 的生产账号过滤仍生效 - P2 双向验证:无 DB_PASSWORD 失败 / 注入后正常列 27 张表 - 门禁四连:verify_scoring_e2e 17/0、verify_exam_submit_integrity 46/0、 verify_big_question --cleanup(清理+窗口还原)、check_no_proxy_guard 5 变异全检出
2026-09-23 16:34:08 +08:00
// 🔴 口令不再内置明文兜底(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',
R20:客观题分值口径 + 大题一位小数评分 + 页面刷新修复 + 大题导出 Word 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:须先上线验证通过再单独授权。
2026-09-23 18:29:50 +08:00
// 🔴 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"。
docs(backend): 同步 worker 数注释(生产 4 -> 2,与 2 核匹配) 仅注释变更,**无行为变更**。EGG_WORKERS 实际由 deploy/.env.production 注入 + deploy/start-backend.sh 兜底决定,本次把注释里写死的旧口径同步为 2,避免 「注释与配置漂移」再次误导后人。 - app.js: · 「生产显式设为 4」-> 2 · 「4×20=80(安全)」->「2×20=40(安全)」 · 「本地 dev 也会加载 .env.production,其中写着 4」-> 2 · 「dev 下真 worker=1 却报 4×20=80」-> 2×20=40 - config/config.default.js: · 「生产值写在 deploy/.env.production(EGG_WORKERS=4)」-> 2(并注明 2026-09-29 R40 由 4 校正为 2) · 「4 × 20 = 80」->「2 × 20 = 40」 · pool 段「worker 数由 EGG_WORKERS 显式控制(生产 = 4)」-> 2 · 「本机/多数服务器为 8 核 → 8 个 worker」->「核数越多起得越多(开发机 8 核 → 8 个)」 (原文把"8 核"当成多数服务器的事实,与实测的 2 核服务器不符) 背景(R40 · BACKLOG C2):worker 数 ≈ CPU 核数。worker 数 > 核数对 CPU 型路径 (bcrypt 登录)**零收益** —— 加 worker 不加核(native bcrypt 走 libuv threadpool), 只增加 context switch 与内存(单 worker 空闲 RSS ≈108MB)。登录容量由物理核数 决定(实测 ≈12.6 次/核·秒)⇒ 2 与 4 的登录容量相同。连接池 2×20=40 ≤ 151-20 ✅ 验证:node --check app.js / config/config.default.js 通过; verify-c1c2.py 含「五处文档/代码注释已无旧口径「4」残留」断言(24/24 通过)。
2026-09-29 12:34:38 +08:00
// 注意:这是「每 worker」上限;worker 数由 EGG_WORKERS 显式控制(生产 = 2,
// 见本文件上方「集群 worker 数」段),总连接数 = worker 数 × max = 2 × 20 = 40,
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
// 须 ≤ 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',
chore(security): 处置已入库的本地 DB 口令(P1-P3;P4 改历史不做) 背景(R14 登记的 §16.3): git grep -l "74191221" HEAD 曾命中 7 个文件,全为本地 MariaDB root 口令 (已核实无生产凭据)。本次按登记项执行 P1-P3;P4(git filter-repo 改写 历史)明确不做。 P1 database/config.json(dev/test 两段明文口令) - 加入 .gitignore - 新增 database/config.json.example(口令为 CHANGE_ME 占位符) - git rm --cached(保留磁盘文件,本地 sequelize-cli 不受影响) - 注意:这是对共享仓库的文件移除。push 后其他克隆需 cp database/config.json.example database/config.json 并填入口令。 P2 五个脚本口令环境变量化(去明文兜底) - add_word_cols.js / add_wx_link.js / show_tables.js / seed-carousel.js 内联 '74191221' 改为 process.env.DB_PASSWORD(另支持 DB_HOST/DB_USER/DB_NAME) - scripts/diagnose_upload_download.cjs:process.env.DB_PASS || '74191221' 改为 process.env.DB_PASS;用法注释中的示例口令改为占位符 - 有意的行为变更:未注入 DB_PASSWORD 时明确失败,不再静默使用公开口令 P3 config/config.default.js 消除「生产静默回退公开口令」 - sequelize.password 去掉 || '74191221' - redis.client.password 去掉 || '74191221' - 生产安全守卫新增 DB_PASSWORD 缺失阻断(与 JWT_SECRET/APP_KEYS 同源同形) - deploy/.env.production 本就含 DB_PASSWORD ⇒ 现网零影响 验证(全部实测): - git grep 74191221 工作树零命中(HEAD 仍有,属 P4 不做范围) - 裸启动(无任何 DB 变量)成功:本地口令由 config.local.js 提供 - 端到端:health db:ok + admin-login code=0 - MySQL 侧独立取证:processlist = root @ competition_db - 变异测试(改坏 config.local.js 口令)⇒ 报 1045 且用户名仍为 root ⇒ 同证 P3 兜底确已移除 + R18 的生产账号过滤仍生效 - P2 双向验证:无 DB_PASSWORD 失败 / 注入后正常列 27 张表 - 门禁四连:verify_scoring_e2e 17/0、verify_exam_submit_integrity 46/0、 verify_big_question --cleanup(清理+窗口还原)、check_no_proxy_guard 5 变异全检出
2026-09-23 16:34:08 +08:00
// 🔴 同上:不再内置明文兜底(2026-09-23 P3)。Redis 当前全库无使用点,留空为零影响。
password: process.env.REDIS_PASSWORD || '',
db: 0,
},
};
// -----------------------------------------------------------------
// JWT 鉴权配置
// -----------------------------------------------------------------
security(config): 移除内置 JWT 密钥明文兜底,收敛到唯一解析入口(R105-D1) 问题(R104 卫生清理 H2 分类的唯二受版本控制命中): config.default.js 原为 const DEFAULT_JWT_SECRET = 'competition_jwt_secret_key_2026_br!cs'; secret: process.env.JWT_SECRET || DEFAULT_JWT_SECRET 该常量为**已入库、任何人可读**的明文,且与 scripts/.creds.local.json 的 jwt_fallback_secret 同值 ⇒ 等价于「JWT 签名密钥公开 ⇒ 任意人可伪造 token 冒充任意身份」。它与仓库既有纪律(_creds.py / _db.cjs / 本文件:459 对 DB_PASSWORD 的「缺失即阻断」)不一致 —— 同族问题各自实现必然漂移。 修法: · config/config.default.js - 删除 DEFAULT_JWT_SECRET 常量与 `||` 兜底,改调唯一解析入口; - 生产安全守卫中同步移除 `jwt.secret === DEFAULT_JWT_SECRET` 比对分支 (不再存在可比的兜底值;缺失时解析期即抛错)。 · 新增 scripts/_secret.cjs(与 _db.cjs / _creds.py 同一纪律) ① env JWT_SECRET → ② scripts/.creds.local.json:jwt_fallback_secret → ③ 明确失败(⛔ 刻意不提供明文兜底) 行为等价性:生产经 systemd EnvironmentFile、本地经 loadDeployEnv 从 deploy/.env.production 注入 JWT_SECRET,正常运行路径逐字不变;仅消除了 「密钥缺失时静默使用公开值」这一分支。 验证: · 后端真实重启 ⇒ 配置加载成功,生效 jwt.secret 指纹与注入值一致; · 端到端判别力(本地 7001 与生产 8023 各一次):新密钥签发 ⇒ 200, 旧密钥签发 ⇒ 401(auth.js jwt.verify 分支); · scripts/verify_no_secrets.py:HIGH 0,判别力自证 17/17; · scripts/verify_script_health.py:H1–H4 PASS。
2026-10-12 10:26:03 +08:00
// 🔴 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 = {
security(config): 移除内置 JWT 密钥明文兜底,收敛到唯一解析入口(R105-D1) 问题(R104 卫生清理 H2 分类的唯二受版本控制命中): config.default.js 原为 const DEFAULT_JWT_SECRET = 'competition_jwt_secret_key_2026_br!cs'; secret: process.env.JWT_SECRET || DEFAULT_JWT_SECRET 该常量为**已入库、任何人可读**的明文,且与 scripts/.creds.local.json 的 jwt_fallback_secret 同值 ⇒ 等价于「JWT 签名密钥公开 ⇒ 任意人可伪造 token 冒充任意身份」。它与仓库既有纪律(_creds.py / _db.cjs / 本文件:459 对 DB_PASSWORD 的「缺失即阻断」)不一致 —— 同族问题各自实现必然漂移。 修法: · config/config.default.js - 删除 DEFAULT_JWT_SECRET 常量与 `||` 兜底,改调唯一解析入口; - 生产安全守卫中同步移除 `jwt.secret === DEFAULT_JWT_SECRET` 比对分支 (不再存在可比的兜底值;缺失时解析期即抛错)。 · 新增 scripts/_secret.cjs(与 _db.cjs / _creds.py 同一纪律) ① env JWT_SECRET → ② scripts/.creds.local.json:jwt_fallback_secret → ③ 明确失败(⛔ 刻意不提供明文兜底) 行为等价性:生产经 systemd EnvironmentFile、本地经 loadDeployEnv 从 deploy/.env.production 注入 JWT_SECRET,正常运行路径逐字不变;仅消除了 「密钥缺失时静默使用公开值」这一分支。 验证: · 后端真实重启 ⇒ 配置加载成功,生效 jwt.secret 指纹与注入值一致; · 端到端判别力(本地 7001 与生产 8023 各一次):新密钥签发 ⇒ 200, 旧密钥签发 ⇒ 401(auth.js jwt.verify 分支); · scripts/verify_no_secrets.py:HIGH 0,判别力自证 17/17; · scripts/verify_script_health.py:H1–H4 PASS。
2026-10-12 10:26:03 +08:00
// 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,
};
// -----------------------------------------------------------------
// 生产环境安全守卫:禁止使用内置默认密钥上线
security(config): 移除内置 JWT 密钥明文兜底,收敛到唯一解析入口(R105-D1) 问题(R104 卫生清理 H2 分类的唯二受版本控制命中): config.default.js 原为 const DEFAULT_JWT_SECRET = 'competition_jwt_secret_key_2026_br!cs'; secret: process.env.JWT_SECRET || DEFAULT_JWT_SECRET 该常量为**已入库、任何人可读**的明文,且与 scripts/.creds.local.json 的 jwt_fallback_secret 同值 ⇒ 等价于「JWT 签名密钥公开 ⇒ 任意人可伪造 token 冒充任意身份」。它与仓库既有纪律(_creds.py / _db.cjs / 本文件:459 对 DB_PASSWORD 的「缺失即阻断」)不一致 —— 同族问题各自实现必然漂移。 修法: · config/config.default.js - 删除 DEFAULT_JWT_SECRET 常量与 `||` 兜底,改调唯一解析入口; - 生产安全守卫中同步移除 `jwt.secret === DEFAULT_JWT_SECRET` 比对分支 (不再存在可比的兜底值;缺失时解析期即抛错)。 · 新增 scripts/_secret.cjs(与 _db.cjs / _creds.py 同一纪律) ① env JWT_SECRET → ② scripts/.creds.local.json:jwt_fallback_secret → ③ 明确失败(⛔ 刻意不提供明文兜底) 行为等价性:生产经 systemd EnvironmentFile、本地经 loadDeployEnv 从 deploy/.env.production 注入 JWT_SECRET,正常运行路径逐字不变;仅消除了 「密钥缺失时静默使用公开值」这一分支。 验证: · 后端真实重启 ⇒ 配置加载成功,生效 jwt.secret 指纹与注入值一致; · 端到端判别力(本地 7001 与生产 8023 各一次):新密钥签发 ⇒ 200, 旧密钥签发 ⇒ 401(auth.js jwt.verify 分支); · scripts/verify_no_secrets.py:HIGH 0,判别力自证 17/17; · scripts/verify_script_health.py:H1–H4 PASS。
2026-10-12 10:26:03 +08:00
// (由 APP_ENV=production 触发)
// -----------------------------------------------------------------
if (config.isProduction) {
const insecure = [];
security(config): 移除内置 JWT 密钥明文兜底,收敛到唯一解析入口(R105-D1) 问题(R104 卫生清理 H2 分类的唯二受版本控制命中): config.default.js 原为 const DEFAULT_JWT_SECRET = 'competition_jwt_secret_key_2026_br!cs'; secret: process.env.JWT_SECRET || DEFAULT_JWT_SECRET 该常量为**已入库、任何人可读**的明文,且与 scripts/.creds.local.json 的 jwt_fallback_secret 同值 ⇒ 等价于「JWT 签名密钥公开 ⇒ 任意人可伪造 token 冒充任意身份」。它与仓库既有纪律(_creds.py / _db.cjs / 本文件:459 对 DB_PASSWORD 的「缺失即阻断」)不一致 —— 同族问题各自实现必然漂移。 修法: · config/config.default.js - 删除 DEFAULT_JWT_SECRET 常量与 `||` 兜底,改调唯一解析入口; - 生产安全守卫中同步移除 `jwt.secret === DEFAULT_JWT_SECRET` 比对分支 (不再存在可比的兜底值;缺失时解析期即抛错)。 · 新增 scripts/_secret.cjs(与 _db.cjs / _creds.py 同一纪律) ① env JWT_SECRET → ② scripts/.creds.local.json:jwt_fallback_secret → ③ 明确失败(⛔ 刻意不提供明文兜底) 行为等价性:生产经 systemd EnvironmentFile、本地经 loadDeployEnv 从 deploy/.env.production 注入 JWT_SECRET,正常运行路径逐字不变;仅消除了 「密钥缺失时静默使用公开值」这一分支。 验证: · 后端真实重启 ⇒ 配置加载成功,生效 jwt.secret 指纹与注入值一致; · 端到端判别力(本地 7001 与生产 8023 各一次):新密钥签发 ⇒ 200, 旧密钥签发 ⇒ 401(auth.js jwt.verify 分支); · scripts/verify_no_secrets.py:HIGH 0,判别力自证 17/17; · scripts/verify_script_health.py:H1–H4 PASS。
2026-10-12 10:26:03 +08:00
// 🔴 2026-10-12(R105-D1):JWT_SECRET 不再在此比对 —— 内置兜底常量已移除,
// 解析统一走 backend/scripts/_secret.cjs;密钥缺失时 loadJwtSecret() 在配置
// 加载期即抛错 ⇒ 任何环境都不可能"静默使用公开密钥",本守卫无需(也无法)
// 再做「是否等于内置值」的判断。
if (config.keys === DEFAULT_APP_KEYS) insecure.push('APP_KEYS');
chore(security): 处置已入库的本地 DB 口令(P1-P3;P4 改历史不做) 背景(R14 登记的 §16.3): git grep -l "74191221" HEAD 曾命中 7 个文件,全为本地 MariaDB root 口令 (已核实无生产凭据)。本次按登记项执行 P1-P3;P4(git filter-repo 改写 历史)明确不做。 P1 database/config.json(dev/test 两段明文口令) - 加入 .gitignore - 新增 database/config.json.example(口令为 CHANGE_ME 占位符) - git rm --cached(保留磁盘文件,本地 sequelize-cli 不受影响) - 注意:这是对共享仓库的文件移除。push 后其他克隆需 cp database/config.json.example database/config.json 并填入口令。 P2 五个脚本口令环境变量化(去明文兜底) - add_word_cols.js / add_wx_link.js / show_tables.js / seed-carousel.js 内联 '74191221' 改为 process.env.DB_PASSWORD(另支持 DB_HOST/DB_USER/DB_NAME) - scripts/diagnose_upload_download.cjs:process.env.DB_PASS || '74191221' 改为 process.env.DB_PASS;用法注释中的示例口令改为占位符 - 有意的行为变更:未注入 DB_PASSWORD 时明确失败,不再静默使用公开口令 P3 config/config.default.js 消除「生产静默回退公开口令」 - sequelize.password 去掉 || '74191221' - redis.client.password 去掉 || '74191221' - 生产安全守卫新增 DB_PASSWORD 缺失阻断(与 JWT_SECRET/APP_KEYS 同源同形) - deploy/.env.production 本就含 DB_PASSWORD ⇒ 现网零影响 验证(全部实测): - git grep 74191221 工作树零命中(HEAD 仍有,属 P4 不做范围) - 裸启动(无任何 DB 变量)成功:本地口令由 config.local.js 提供 - 端到端:health db:ok + admin-login code=0 - MySQL 侧独立取证:processlist = root @ competition_db - 变异测试(改坏 config.local.js 口令)⇒ 报 1045 且用户名仍为 root ⇒ 同证 P3 兜底确已移除 + R18 的生产账号过滤仍生效 - P2 双向验证:无 DB_PASSWORD 失败 / 注入后正常列 27 张表 - 门禁四连:verify_scoring_e2e 17/0、verify_exam_submit_integrity 46/0、 verify_big_question --cleanup(清理+窗口还原)、check_no_proxy_guard 5 变异全检出
2026-09-23 16:34:08 +08:00
// 🔴 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;
};