'use strict'; // --------------------------------------------------------------------------- // 部署环境变量自加载(兜底) // 背景:Egg.js 默认不会读取任何 .env 文件,生产密钥(JWT_SECRET / APP_KEYS 等)必须 // 靠 systemd EnvironmentFile= 或 shell export 注入。当注入环节出错(文件解析失败、 // 路径不匹配、或根本不是用 systemd 启动)时,process.env 拿不到值,会触发下方 config // 里的生产安全守卫误报「未配置的密钥」,且极难排查。 // 这里在配置加载前主动从磁盘读取 deploy/.env.production(沿 __dirname / process.cwd() // 向上查找,并支持 DEPLOY_ENV_FILE 显式指定),填充 process.env;仅当变量尚未设置时 // 生效(外部注入优先),保证幂等。 // 注意:APP_ENV / NODE_ENV 不从此处加载,避免本地开发误判为生产(真实生产由启动器注入)。 // // 🔴 2026-09-23 修复(本地启动崩溃 1045,详见 memory/2026-09-23.md): // 原先本函数在【模块加载期】立即执行,且无条件把 .env.production 的全部键灌进 // process.env —— 于是本地 `npm run dev` 也会拿到**生产库账号**(见 deploy/.env.production // 的 DB_USER), // 而本机 MySQL 并无该账号 ⇒ SequelizeAccessDeniedError(1045) ⇒ agent_worker 退出 // ⇒ master 退出 ⇒ 7001 无监听。现改为两处收口: // ① 【执行时机】推迟到 module.exports(appInfo) 内部 —— 此时 appInfo.env 已确定; // ② 【作用范围】非生产环境跳过「连接类 / 凭据类 / 网关开关类」键(见下方常量)。 // 生产(appInfo.env === 'prod' 或 NODE_ENV=production)**不跳过任何键**,行为逐键不变。 // 生产必然满足判定的三重依据(2026-09-23 取证): // a) egg-scripts/lib/cmd/start.js:109 无条件 `env.NODE_ENV = 'production'`; // b) deploy/.env.production:17 `NODE_ENV=production`,且 start-backend.sh 以 // `set -a; source "$ENV_FILE"` 注入 shell 后再 exec node; // c) start.js:126 注释:argv -> EGG_SERVER_ENV -> 兜底 egg 取 `prod`。 // --------------------------------------------------------------------------- const DEPLOY_ENV_SKIP_KEYS = new Set(['APP_ENV', 'NODE_ENV']); // 非生产环境额外跳过的键前缀:这些键一旦注入本机进程会造成**真实副作用**, // 或把生产凭据带进本地进程。逐类取证依据(2026-09-23): // DB_ —— 实证危害:注入生产库账号后本地必然 1045 崩溃(2026-09-23 已实测); // REDIS_ —— 当前全库无 app.redis 使用点(grep 零命中),但一旦启用即连生产 Redis; // ALIYUN_ —— 短信网关密钥;本地 realSend 恒 false(auth.js:54 `isProduction && realSend`), // 注入与否不改变行为,跳过仅为避免生产密钥落入本地进程(泄露面); // SMS_ —— SMS_ENABLED 全库零引用(config.sms.enabled 无使用点),跳过仅为口径一致。 // 仍保留注入的键:JWT_SECRET / APP_KEYS / EGG_WORKERS / STATIC_BASE_URL 等 —— 本地无副作用。 const DEPLOY_ENV_LOCAL_SKIP_PREFIXES = ['DB_', 'REDIS_', 'ALIYUN_', 'SMS_']; function loadDeployEnv(appInfo) { try { const fs = require('fs'); const path = require('path'); const targets = []; // 生产判定:appInfo.env 为权威(Egg getServerEnv 的结果),NODE_ENV 为兼容兜底。 // 两者皆不满足即视为非生产(本地/测试),采用**保守**方向跳过连接类键。 const isProdEnv = process.env.NODE_ENV === 'production' || (appInfo && (appInfo.env === 'prod' || appInfo.env === 'production')); // 1) 显式指定(最高优先级) if (process.env.DEPLOY_ENV_FILE) targets.push(process.env.DEPLOY_ENV_FILE); // 2) 已知裸机部署绝对路径 targets.push('/home/root/production/agent-code/deploy/competition-website/.env.production'); // 3) 从 __dirname 与 process.cwd() 向上查找 const starts = [__dirname, process.cwd()]; for (const start of starts) { let dir = start; while (dir && dir !== path.dirname(dir)) { targets.push( path.join(dir, 'deploy', 'competition-website', '.env.production'), path.join(dir, 'deploy', '.env.production'), path.join(dir, '.env.production'), ); dir = path.dirname(dir); } } let loadedFrom = null; let skipped = []; for (const file of targets) { let text; try { text = fs.readFileSync(file, 'utf8'); } catch (_) { continue; // 该候选不存在,尝试下一个 } for (const raw of text.split('\n')) { const line = raw.trim(); if (!line || line.startsWith('#') || !line.includes('=')) continue; const eq = line.indexOf('='); const key = line.slice(0, eq).trim(); let val = line.slice(eq + 1).trim(); if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) { val = val.slice(1, -1); } if (!key || DEPLOY_ENV_SKIP_KEYS.has(key) || process.env[key] !== undefined) continue; // 非生产环境:跳过连接类 / 凭据类 / 网关开关类键,避免「本地进程连到生产库」 if (!isProdEnv && DEPLOY_ENV_LOCAL_SKIP_PREFIXES.some((p) => key.startsWith(p))) { skipped.push(key); continue; } process.env[key] = val; } loadedFrom = file; break; // 第一个存在的文件即为权威来源 } if (loadedFrom) { console.log('[deploy-env] 已从 ' + loadedFrom + ' 加载部署环境变量'); } else { console.warn('[deploy-env] 未找到 .env.production,将完全依赖进程环境变量注入(缺失生产密钥会触发安全守卫)'); } // 非生产环境把「被跳过的键」显式打印出来。⚠️ 只打印键名,绝不打印值。 // 注意:本行出现 ≠ 那些键被注入(恰恰相反,是被跳过了),排查时勿误读。 if (skipped.length > 0) { console.log( '[deploy-env] 非生产环境(env=' + ((appInfo && appInfo.env) || process.env.NODE_ENV || 'unknown') + '),已跳过 ' + skipped.length + ' 个连接类/凭据类键(防止本地进程连到生产库或持有生产密钥):' + skipped.join(', ') ); } } catch (e) { console.warn('[deploy-env] 加载部署环境变量失败:' + (e && e.message)); } } module.exports = (appInfo) => { const config = (exports = {}); // 🔴 必须在下文任何 process.env.* 读取之前执行(原本是模块级 IIFE,见函数头注释)。 // 放在此处而非模块顶层,是为了拿到已确定的 appInfo.env 以区分生产/本地。 loadDeployEnv(appInfo); const isProd = process.env.NODE_ENV === 'production' || appInfo.env === 'prod'; // ----------------------------------------------------------------- // 运行模式(测试 / 生产)—— 单一环境变量 APP_ENV 控制,用于最小化 // 测试版本与生产版本的行为差异:两个版本使用同一套代码,仅通过 APP_ENV // 与环境变量切换。 // - 生产部署:APP_ENV=production // - 测试 / 本地部署:APP_ENV=test(缺省值,未设置且非 egg prod 环境时默认 test) // 作用:① 驱动生产安全守卫;② 驱动短信行为(test=不真实发送+前端弹窗, // production=真实下发且不回传验证码)。 // ----------------------------------------------------------------- const runMode = process.env.APP_ENV || (appInfo.env === 'prod' ? 'production' : 'test'); config.runMode = runMode; config.isProduction = runMode === 'production'; config.isTest = runMode !== 'production'; // 测试数据默认口令:重置测试数据 / 标记为测试数据导入 时,统一把密码设为该口令的 // bcrypt 哈希,方便测试人员重置后、或用 Excel「标记为测试数据」导入后,直接以该口令登录重测 // (无需再走激活流程)。仅作用于 test_flag=1 的测试数据,生产数据(test_flag=0)不受影响。 // 【测试模拟值 '123456';如需变更可设环境变量 TEST_DEFAULT_PASSWORD】 config.testDefaultPassword = process.env.TEST_DEFAULT_PASSWORD || '123456'; // Cookie 签名密钥:优先读取环境变量 APP_KEYS,缺省时回退到内置值(仅供本地开发) // 【测试模拟值,生产请通过环境变量 APP_KEYS 替换为随机字符串,否则启动被守卫阻断】 const DEFAULT_APP_KEYS = appInfo.name + '_competition_website_secret_key_2026'; config.keys = process.env.APP_KEYS || DEFAULT_APP_KEYS; // add your config here config.middleware = ['concurrencyGuard']; // 🔴 请求级并发闸门(大文件上传/下载):在读取请求体之前限制「被接受的并发重请求数」, // 极端并发下超出上限立即回 429(连接级背压),防止单 worker 被海量请求体缓冲压垮 OOM。 // 与 app/lib/file_io_guard 互补(后者按文件流生命周期限流)。可按压测/生产调参: // 单 worker 极端并发场景建议保持较低值(20);生产多 worker 下每 worker 独立计数, // 总上限 = worker 数 × HEAVY_REQ_MAX,可适当提高。 config.concurrencyGuard = { max: parseInt(process.env.HEAVY_REQ_MAX, 10) || 20, // 交卷独立上限(2026-09-21 并发压测结论):submit-all 纳入闸门后,过载由 // 「静默 RST(考生不知道是否交上)」变为「明确 429 + Retry-After(可重试)」。 // 取值理由(为何不与上传共用 20、也不放大到数百)见 middleware/concurrency_guard.js。 submitMax: parseInt(process.env.SUBMIT_REQ_MAX, 10) || 24, // 登录独立上限(2026-09-21 并发压测**第二轮**结论):/api/auth/login 此前无闸门, // 800 人同刻登录实测「仅 484/800 取得 token(39.5% 在 TCP accept 层被拒)、 // P50=9957ms / P95=24350ms、连接级重试 1134 次」。纳入闸门后过载由 // 「静默 RST(考生不知道要不要重试)」变为「明确 429 + Retry-After(可重试)」。 // 取 16(低于交卷的 24):登录是 CPU 型重任务(bcrypt 10 rounds), // 换手粒度比 IO 型交卷更粗,对 accept 循环的挤压更直接。 // 为何不纳入 admin-login、以及与 loginRateLimit 的顺序红线, // 见 middleware/concurrency_guard.js 的 LOGIN_PATTERNS 段注释。 loginMax: parseInt(process.env.LOGIN_REQ_MAX, 10) || 16, }; // ----------------------------------------------------------------- // 🔴 集群 worker 数(2026-09-21 并发压测结论,必须显式设定) // ----------------------------------------------------------------- // 背景:`egg-scripts start` 未传 --workers 时,egg-cluster 会按 os.cpus().length // 起同等数量的 app worker —— 核数越多起得越多(开发机 8 核 → 8 个 worker)。 // 而每个 worker 的连接池上限是 DB_POOL_MAX(默认 20),8 × 20 = 160 // **已经超过** MySQL max_connections(151)。高峰期会出现 connection acquire // timeout,在考生侧表现为「交卷失败」,且服务端不报业务错,极难定位。 // // 落点:worker 数由环境变量 EGG_WORKERS 控制(egg-scripts start 把它作为 // --workers 的默认值,见 node_modules/egg-scripts/lib/cmd/start.js:31)。 // 生产值写在 deploy/.env.production(EGG_WORKERS=2,与服务器 2 核匹配; // 2026-09-29 R40 由 4 校正为 2),并由 start-backend.sh 显式传参作双保险。 // 2 × 20 = 40 ≤ 151,为管理后台/统计等其余连接留足余量。 // // ⚠️ 不要试图在此处写 config.cluster.workers —— egg-cluster 只读 // startCluster(options.workers)(options.js:24 → master.js:267), // 该值在 master 进程由 egg-scripts 的命令行/EGG_WORKERS 决定; // config 阶段的配置对 worker **数量**无效。此处仅作说明,避免后人误配。 // // ⚠️ 多 worker 的两个已知副作用(均为进程内状态,非缺陷但需知悉): // ① concurrencyGuard 的 active 是**进程内**计数,总上限 = worker 数 × max; // ② loginRateLimit 的失败计数同为进程内 Map,实际阈值 = worker 数 × maxAttempts, // 仅影响「防爆破」强度,不影响正确性(登录成功即清零该 key)。 // ----------------------------------------------------------------- // ----------------------------------------------------------------- // sequelize - MariaDB/MySQL 数据库配置 // ----------------------------------------------------------------- config.sequelize = { dialect: 'mysql', database: process.env.DB_NAME || 'competition_db', host: process.env.DB_HOST || 'localhost', port: parseInt(process.env.DB_PORT, 10) || 3306, // 兼容 docker-compose/.env.example 中的 DB_USER 与代码历史使用的 DB_USERNAME username: process.env.DB_USERNAME || process.env.DB_USER || 'root', // 🔴 口令不再内置明文兜底(2026-09-23 入库口令处置 P3): // 生产 —— 必须由 .env.production 注入 DB_PASSWORD(缺失则由下方生产安全守卫阻断启动); // 本地 —— 由 config/config.local.js(已 gitignore)深合并覆盖为真实本机口令。 password: process.env.DB_PASSWORD, timezone: '+08:00', define: { underscored: true, freezeTableName: true, timestamps: true, createdAt: 'created_at', updatedAt: 'updated_at', charset: 'utf8mb4', collate: 'utf8mb4_unicode_ci', }, dialectOptions: { // 注:mysql2 连接级只接受 charset,不接受 collate;collate 是表/模型级选项, // 由上方 define.collate 负责。此处传 collate 会触发 // "Ignoring invalid configuration option passed to Connection: collate" 警告。 charset: 'utf8mb4', // 🔴 DECIMAL 必须显式转 Number(2026-09-23,一位小数评分) // mysql2 默认把 DECIMAL 读成**字符串**,会导致:① 前端出现 // "7.5" + 0 → "7.50" 之类的字符串拼接;② `n > per` 退化为字符串比较。 // 开启后 DECIMAL 以 Number 返回,与 INTEGER 行为一致;全库原无 DECIMAL 列 // (迁移 025 首次引入),故对既有功能零影响。 decimalNumbers: true, }, pool: { // 🔴 压测调优(2026-08-11):单 worker 默认 10 连接,在「无带宽限制 + 极端并发」 // 下分片合并/写入会短时占用大量连接。提高到 20(可用 DB_POOL_MAX 覆盖), // 并放宽 acquire 到 45s,避免瞬时峰值触发 "connection acquire timeout"。 // 注意:这是「每 worker」上限;worker 数由 EGG_WORKERS 显式控制(生产 = 2, // 见本文件上方「集群 worker 数」段),总连接数 = worker 数 × max = 2 × 20 = 40, // 须 ≤ MySQL max_connections(默认 151)。启动期由 app.js didReady 自举断言 // 按**实际 worker 数**复核(不再用 os.cpus() 误估),超限只 WARN 不阻断。 max: parseInt(process.env.DB_POOL_MAX, 10) || 20, min: 2, acquire: 45000, idle: 10000, // 🔴 连接回收(2026-08-13 推进 #16):maxUses 限制单连接累计查询次数, // 归还池时物理关闭并新建,避免 MySQL wait_timeout(默认 8h)静默杀掉长期空闲 // 连接后被复用 → 生产偶发 "Connection lost / ECONNRESET / Cannot enqueue Query // after fatal error"。5000 次 ≈ 中小赛事体量下数小时寿命,远小于 wait_timeout。 // 可用 DB_POOL_MAX_USES 覆盖。 maxUses: parseInt(process.env.DB_POOL_MAX_USES, 10) || 5000, }, // 仅在开发环境开启 SQL 日志,生产环境自动关闭 logging: process.env.NODE_ENV === 'production' ? false : console.log, }; // ----------------------------------------------------------------- // X-Accel-Redirect 内部下载开关(根治管理后台大文件下载压垮 Node) // ----------------------------------------------------------------- // 生产(NODE_ENV=production)默认开启:管理端作品/Word 下载改由后端只回一个 // X-Accel-Redirect 内部重定向头,nginx 直接从磁盘流式吐文件,Node 不再持有文件流, // 极端并发下几乎零占用 worker fd/事件循环。 // 开发环境默认关闭(回退为 Node 直接流式,便于本地调试);可用 X_ACCEL_REDIRECT=1 // 临时开启以验证重定向头是否正确。 config.xAccelRedirect = { enabled: process.env.NODE_ENV === 'production' || process.env.X_ACCEL_REDIRECT === '1', // nginx 内部 location 前缀(须与 deploy/competition_website.conf 的一致) internalPrefix: '/internal-downloads/', }; // ----------------------------------------------------------------- // Redis 缓存配置 // ----------------------------------------------------------------- config.redis = { client: { port: parseInt(process.env.REDIS_PORT, 10) || 6379, host: process.env.REDIS_HOST || '127.0.0.1', // 🔴 同上:不再内置明文兜底(2026-09-23 P3)。Redis 当前全库无使用点,留空为零影响。 password: process.env.REDIS_PASSWORD || '', db: 0, }, }; // ----------------------------------------------------------------- // JWT 鉴权配置 // ----------------------------------------------------------------- // 🔴 2026-10-12(R105-D1,用户授权):移除内置明文兜底密钥。 // 原实现:`const DEFAULT_JWT_SECRET = '<明文串>'` + `process.env.JWT_SECRET || DEFAULT_JWT_SECRET` // —— 密钥缺失时静默回退到一个**已入库、任何人可读**的常量,且该常量与 // scripts/.creds.local.json 的 jwt_fallback_secret 同值 // ⇒ 等价于「JWT 签名密钥公开 ⇒ 任意人可伪造 token 冒充任意身份」。 // 现收敛到唯一解析入口 `backend/scripts/_secret.cjs`(与 _db.cjs / _creds.py 同一纪律): // ① env JWT_SECRET → ② scripts/.creds.local.json:jwt_fallback_secret → ③ 明确失败 // 行为等价性:生产与本地均由注入链路提供密钥(deploy/.env.production 的 JWT_SECRET // 经 loadDeployEnv 注入 process.env)⇒ 正常运行路径逐字不变,仅消除了兜底分支。 const { loadJwtSecret } = require('../scripts/_secret.cjs'); config.jwt = { // JWT 签名密钥:唯一来源见 _secret.cjs 头部说明;⛔ 无明文兜底,缺失即明确失败 secret: loadJwtSecret(), enable: true, // Token 有效期(秒),默认 7 天 expiresIn: 7 * 24 * 60 * 60, // 静默刷新宽限期(秒):token 过期后在此窗口内仍可被 /api/auth/refresh 续期, // 用于实现"活跃用户永不被意外踢出"的滑动会话;超过则必须重新登录。 refreshGrace: 7 * 24 * 60 * 60, // 匹配需要鉴权的路由规则(排除登录/注册等公开接口) match: (ctx) => { const publicPaths = [ '/api/auth/login', '/api/auth/register', '/api/public/', ]; return !publicPaths.some((p) => ctx.path.startsWith(p)); }, }; // ----------------------------------------------------------------- // multipart 文件上传配置 // ----------------------------------------------------------------- config.multipart = { mode: 'stream', fileSize: '100mb', whitelist: [ // 文档类 '.pdf', '.doc', '.docx', '.ppt', '.pptx', '.xls', '.xlsx', '.zip', // 图片类 '.png', '.jpg', '.jpeg', '.gif', '.webp', ], // 自定义文件名正则,允许中文、字母、数字、空格、连字符、下划线、括号 // Egg 默认不允许 hyphen in Chinese filename, fix this filenameRegex: /^[\u4e00-\u9fa5a-zA-Z0-9\s\-_\(\)]+\.[a-z0-9]+$/i, // 上传文件临时存放目录 tmpdir: require('path').join(appInfo.baseDir, 'app/public/tmp'), // 清理临时文件的间隔时间 cleanSchedule: { cron: '0 30 4 * * *', }, }; // ----------------------------------------------------------------- // CORS 跨域配置(白名单收敛) // ----------------------------------------------------------------- // 通过环境变量 CORS_ORIGINS 配置允许的来源(逗号分隔),例如: // CORS_ORIGINS=https://competition.example.com,https://admin.example.com // 规则: // - 配置了白名单:仅反射白名单内的 origin; // - 未配置白名单:开发环境反射请求来源(便于本地联调); // 生产环境不反射任意源(生产走 nginx 同源代理 /api,无需跨域)。 const corsOrigins = (process.env.CORS_ORIGINS || '') .split(',') .map((s) => s.trim()) .filter(Boolean); config.cors = { origin: (ctx) => { const reqOrigin = ctx.get('origin'); if (corsOrigins.length > 0) { return corsOrigins.includes(reqOrigin) ? reqOrigin : ''; } return isProd ? '' : reqOrigin; }, allowMethods: 'GET,HEAD,PUT,POST,DELETE,PATCH', credentials: true, }; // ----------------------------------------------------------------- // Security 安全配置 // ----------------------------------------------------------------- // CSRF 说明:本项目前后端分离,鉴权全部通过 Authorization: Bearer // 请求头携带(不依赖 Cookie 会话),浏览器跨站请求无法自动附带该头, // 因此传统 CSRF 攻击面不成立,保持关闭;若未来引入 Cookie 会话需重新启用。 config.security = { csrf: { enable: false, }, // 跳转/资源域名白名单与 CORS 白名单保持一致(未配置时为空 = 仅同源) domainWhiteList: corsOrigins, }; // ----------------------------------------------------------------- // 静态资源服务配置 // ----------------------------------------------------------------- config.static = { prefix: '/public/', dir: require('path').join(appInfo.baseDir, 'app/public'), maxAge: 31536000, }; // 静态资源外部访问基础 URL(用于 helper.fileUrl() 拼接完整 URL) // 生产环境通过环境变量 STATIC_BASE_URL 配置为实际域名,如 'https://competition.example.com' // 未配置时回退为 null,helper.fileUrl() 会退化到 http://127.0.0.1:{port} config.staticBaseUrl = process.env.STATIC_BASE_URL || null; // ----------------------------------------------------------------- // bodyParser 配置 // ----------------------------------------------------------------- config.bodyParser = { jsonLimit: '10mb', formLimit: '10mb', }; // ----------------------------------------------------------------- // 日志配置 // ----------------------------------------------------------------- config.logger = { dir: require('path').join(appInfo.baseDir, 'logs'), appLogName: 'competition-web.log', coreLogName: 'egg-web.log', agentLogName: 'egg-agent.log', errorLogName: 'common-error.log', level: 'INFO', }; // ----------------------------------------------------------------- // 日志轮转配置(egg-logrotator 内置插件) // - 按体积轮转:单文件超过 maxFileSize 即滚动 // - maxDays:保留最近 N 天日志,到期后自动清理(设为 0 则永久保留) // 说明:默认已按天轮转;此处显式收口为"按体积 + 保留 14 天", // 避免长期运行后日志无限增长占满磁盘。 // ----------------------------------------------------------------- config.rotator = { filesRotateBySize: [ 'competition-web.log', 'common-error.log', 'egg-web.log', 'egg-agent.log', ], maxFileSize: 50 * 1024 * 1024, // 50MB maxDays: 14, }; // ----------------------------------------------------------------- // 生产环境安全守卫:禁止使用内置默认密钥上线 // (由 APP_ENV=production 触发) // ----------------------------------------------------------------- if (config.isProduction) { const insecure = []; // 🔴 2026-10-12(R105-D1):JWT_SECRET 不再在此比对 —— 内置兜底常量已移除, // 解析统一走 backend/scripts/_secret.cjs;密钥缺失时 loadJwtSecret() 在配置 // 加载期即抛错 ⇒ 任何环境都不可能"静默使用公开密钥",本守卫无需(也无法) // 再做「是否等于内置值」的判断。 if (config.keys === DEFAULT_APP_KEYS) insecure.push('APP_KEYS'); // 🔴 2026-09-23(入库口令处置 P3):口令缺失同样阻断。 // 原实现在此处会静默回退到公开的开发口令,等价于「生产用公开口令连库」。 // 现要求生产必须显式注入 DB_PASSWORD(deploy/.env.production 本就含该键, // 故现网永不触发本分支;一旦触发说明注入链路断了,明确阻断优于连不上库)。 if (!config.sequelize.password) insecure.push('DB_PASSWORD'); if (insecure.length > 0) { // 生产环境仍在使用硬编码默认密钥属于严重安全隐患,直接阻断启动 throw new Error( `[安全阻断] 生产环境检测到未配置的密钥: ${insecure.join(', ')}。` + '请通过环境变量设置这些值后再启动(参见 deploy/.env.example)。' ); } } // ----------------------------------------------------------------- // 短信网关配置(阿里云 Dysmsapi) // ----------------------------------------------------------------- // 设计要点(测试 / 生产差异最小化): // - 验证码的校验仍走 ctx.session(与现有激活/重置流程一致,已被 26 项边界测试覆盖), // 短信网关只负责"把服务端已生成的 6 位验证码下发给手机号",不改动校验契约。 // - 所有网关参数均通过环境变量注入(SMS_ENABLED / ALIYUN_SMS_*)。 // - 阿里云 accessKeyId / accessKeySecret 为敏感凭证,源码不再保留任何硬编码值, // 必须且只能通过环境变量注入;生产开启短信时若缺失将触发安全守卫阻断启动。 // - endpoint / signName / templateCode 为非敏感配置,保留合理默认值以便本地/测试快速起服。 // - realSend(真实下发开关)= 生产环境 且 SMS_ENABLED=1。 // · 测试环境(APP_ENV=test):即使配置了网关也【不真实发送】,后端随响应回传 // 验证码(字段 smsCode),由前端弹窗展示,便于测试人员直接获取。 // · 生产环境且 realSend=true:真实下发,绝不回传/打印验证码明文。 // · 生产环境且 realSend=false:安全起见不下发、不回传,仅记录告警。 const smsEnabled = process.env.SMS_ENABLED === '1' || process.env.SMS_ENABLED === 'true'; config.sms = { enabled: smsEnabled, // 真实下发:仅生产环境 + 网关开启时成立;测试环境恒为 false(不真发) realSend: config.isProduction && smsEnabled, provider: 'aliyun', aliyun: { // 敏感凭证:仅从环境变量读取,源码零硬编码(已轮换,杜绝泄露) accessKeyId: process.env.ALIYUN_SMS_ACCESS_KEY_ID, accessKeySecret: process.env.ALIYUN_SMS_ACCESS_KEY_SECRET, endpoint: process.env.ALIYUN_SMS_ENDPOINT || 'dysmsapi.aliyuncs.com', // 非敏感配置,保留合理默认值 signName: process.env.ALIYUN_SMS_SIGN_NAME || '华云智联', templateCode: process.env.ALIYUN_SMS_TEMPLATE_CODE || 'SMS_487315108', }, }; // 生产环境 + 开启短信时,阿里云密钥必须来自环境变量(源码已无硬编码凭证) if (config.isProduction && smsEnabled) { const missing = []; if (!config.sms.aliyun.accessKeyId) missing.push('ALIYUN_SMS_ACCESS_KEY_ID'); if (!config.sms.aliyun.accessKeySecret) missing.push('ALIYUN_SMS_ACCESS_KEY_SECRET'); if (missing.length > 0) { throw new Error( `[安全阻断] 生产环境开启短信但缺少阿里云密钥: ${missing.join(', ')}。` + '请通过环境变量注入真实密钥后再启动(参见 deploy/.env.production)。' ); } } return config; };