From 4f1e28d6439fe8337344c9175e6448d8937e35ec Mon Sep 17 00:00:00 2001 From: qubiaobiao <3294694717@qq.com> Date: Wed, 23 Sep 2026 15:59:41 +0800 Subject: [PATCH] =?UTF-8?q?fix(config):=20=E6=9C=AC=E5=9C=B0=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E4=B8=8D=E5=86=8D=E8=A2=AB=E7=94=9F=E4=BA=A7=20DB=20?= =?UTF-8?q?=E5=87=AD=E6=8D=AE=E6=B1=A1=E6=9F=93=EF=BC=88loadDeployEnv=20?= =?UTF-8?q?=E6=94=B6=E5=8F=A3=20+=20config.local=20=E5=85=9C=E5=BA=95?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题(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 兜底 > 代码默认值。 --- .gitignore | 4 +++ config/config.default.js | 63 ++++++++++++++++++++++++++++++++++++---- 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index b0dba07..8bf35c1 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,10 @@ run/ .env* !.env.example +# 本地开发配置(Egg 仅在 serverEnv=local 时加载,生产不加载) +# 含本地 DB 口令,故排除入库;如需团队共享请提交不含口令的 config.local.example.js +config/config.local.js + # Coverage / cache coverage/ .cache/ diff --git a/config/config.default.js b/config/config.default.js index ecc1cb5..2af3a2d 100644 --- a/config/config.default.js +++ b/config/config.default.js @@ -10,14 +10,46 @@ // 向上查找,并支持 DEPLOY_ENV_FILE 显式指定),填充 process.env;仅当变量尚未设置时 // 生效(外部注入优先),保证幂等。 // 注意:APP_ENV / NODE_ENV 不从此处加载,避免本地开发误判为生产(真实生产由启动器注入)。 +// +// 🔴 2026-09-23 修复(本地启动崩溃 1045,详见 memory/2026-09-23.md): +// 原先本函数在【模块加载期】立即执行,且无条件把 .env.production 的全部键灌进 +// process.env —— 于是本地 `npm run dev` 也会拿到**生产库账号**(见 deploy/.env.production +// 的 DB_USER), +// 而本机 MySQL 并无该账号 ⇒ SequelizeAccessDeniedError(1045) ⇒ agent_worker 退出 +// ⇒ master 退出 ⇒ 7001 无监听。现改为两处收口: +// ① 【执行时机】推迟到 module.exports(appInfo) 内部 —— 此时 appInfo.env 已确定; +// ② 【作用范围】非生产环境跳过「连接类 / 凭据类 / 网关开关类」键(见下方常量)。 +// 生产(appInfo.env === 'prod' 或 NODE_ENV=production)**不跳过任何键**,行为逐键不变。 +// 生产必然满足判定的三重依据(2026-09-23 取证): +// a) egg-scripts/lib/cmd/start.js:109 无条件 `env.NODE_ENV = 'production'`; +// b) deploy/.env.production:17 `NODE_ENV=production`,且 start-backend.sh 以 +// `set -a; source "$ENV_FILE"` 注入 shell 后再 exec node; +// c) start.js:126 注释:argv -> EGG_SERVER_ENV -> 兜底 egg 取 `prod`。 // --------------------------------------------------------------------------- -(function loadDeployEnv() { +const DEPLOY_ENV_SKIP_KEYS = new Set(['APP_ENV', 'NODE_ENV']); + +// 非生产环境额外跳过的键前缀:这些键一旦注入本机进程会造成**真实副作用**, +// 或把生产凭据带进本地进程。逐类取证依据(2026-09-23): +// DB_ —— 实证危害:注入生产库账号后本地必然 1045 崩溃(2026-09-23 已实测); +// REDIS_ —— 当前全库无 app.redis 使用点(grep 零命中),但一旦启用即连生产 Redis; +// ALIYUN_ —— 短信网关密钥;本地 realSend 恒 false(auth.js:54 `isProduction && realSend`), +// 注入与否不改变行为,跳过仅为避免生产密钥落入本地进程(泄露面); +// SMS_ —— SMS_ENABLED 全库零引用(config.sms.enabled 无使用点),跳过仅为口径一致。 +// 仍保留注入的键: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 SKIP_KEYS = new Set(['APP_ENV', 'NODE_ENV']); const targets = []; + // 生产判定:appInfo.env 为权威(Egg getServerEnv 的结果),NODE_ENV 为兼容兜底。 + // 两者皆不满足即视为非生产(本地/测试),采用**保守**方向跳过连接类键。 + const isProdEnv = + process.env.NODE_ENV === 'production' || + (appInfo && (appInfo.env === 'prod' || appInfo.env === 'production')); + // 1) 显式指定(最高优先级) if (process.env.DEPLOY_ENV_FILE) targets.push(process.env.DEPLOY_ENV_FILE); @@ -39,6 +71,7 @@ } let loadedFrom = null; + let skipped = []; for (const file of targets) { let text; try { @@ -55,9 +88,13 @@ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) { val = val.slice(1, -1); } - if (key && !SKIP_KEYS.has(key) && process.env[key] === undefined) { - process.env[key] = val; + if (!key || DEPLOY_ENV_SKIP_KEYS.has(key) || process.env[key] !== undefined) continue; + // 非生产环境:跳过连接类 / 凭据类 / 网关开关类键,避免「本地进程连到生产库」 + if (!isProdEnv && DEPLOY_ENV_LOCAL_SKIP_PREFIXES.some((p) => key.startsWith(p))) { + skipped.push(key); + continue; } + process.env[key] = val; } loadedFrom = file; break; // 第一个存在的文件即为权威来源 @@ -68,14 +105,30 @@ } else { console.warn('[deploy-env] 未找到 .env.production,将完全依赖进程环境变量注入(缺失生产密钥会触发安全守卫)'); } + // 非生产环境把「被跳过的键」显式打印出来。⚠️ 只打印键名,绝不打印值。 + // 注意:本行出现 ≠ 那些键被注入(恰恰相反,是被跳过了),排查时勿误读。 + if (skipped.length > 0) { + console.log( + '[deploy-env] 非生产环境(env=' + + ((appInfo && appInfo.env) || process.env.NODE_ENV || 'unknown') + + '),已跳过 ' + + skipped.length + + ' 个连接类/凭据类键(防止本地进程连到生产库或持有生产密钥):' + + skipped.join(', ') + ); + } } catch (e) { console.warn('[deploy-env] 加载部署环境变量失败:' + (e && e.message)); } -})(); +} module.exports = (appInfo) => { const config = (exports = {}); + // 🔴 必须在下文任何 process.env.* 读取之前执行(原本是模块级 IIFE,见函数头注释)。 + // 放在此处而非模块顶层,是为了拿到已确定的 appInfo.env 以区分生产/本地。 + loadDeployEnv(appInfo); + const isProd = process.env.NODE_ENV === 'production' || appInfo.env === 'prod'; // -----------------------------------------------------------------