410 lines
19 KiB
JavaScript
410 lines
19 KiB
JavaScript
|
|
'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 不从此处加载,避免本地开发误判为生产(真实生产由启动器注入)。
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
(function loadDeployEnv() {
|
|||
|
|
try {
|
|||
|
|
const fs = require('fs');
|
|||
|
|
const path = require('path');
|
|||
|
|
const SKIP_KEYS = new Set(['APP_ENV', 'NODE_ENV']);
|
|||
|
|
const targets = [];
|
|||
|
|
|
|||
|
|
// 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;
|
|||
|
|
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 && !SKIP_KEYS.has(key) && process.env[key] === undefined) {
|
|||
|
|
process.env[key] = val;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
loadedFrom = file;
|
|||
|
|
break; // 第一个存在的文件即为权威来源
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (loadedFrom) {
|
|||
|
|
console.log('[deploy-env] 已从 ' + loadedFrom + ' 加载部署环境变量');
|
|||
|
|
} else {
|
|||
|
|
console.warn('[deploy-env] 未找到 .env.production,将完全依赖进程环境变量注入(缺失生产密钥会触发安全守卫)');
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
console.warn('[deploy-env] 加载部署环境变量失败:' + (e && e.message));
|
|||
|
|
}
|
|||
|
|
})();
|
|||
|
|
|
|||
|
|
module.exports = (appInfo) => {
|
|||
|
|
const config = (exports = {});
|
|||
|
|
|
|||
|
|
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,
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
|
|||
|
|
// -----------------------------------------------------------------
|
|||
|
|
// 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"。
|
|||
|
|
// 注意:这是「每 worker」上限;生产 egg-cluster 按 os.cpus() 起多 worker,
|
|||
|
|
// 总连接数 ≈ worker 数 × max,需确保 ≤ MySQL max_connections(默认 151,
|
|||
|
|
// 启动期由 app.js didReady 自举断言兜底,超限只 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;
|
|||
|
|
};
|