chore: 后端首次基线提交

运行必须源码:app/(controller/model/service/middleware/lib/utils/validate/router)、config/、database/(migrations/seeders/sql)、启动脚本、app/public/templates 模板。
已忽略运行非必须:node_modules/、logs/、run/、app/public 运行时上传数据、.env*、构建/缓存产物。
This commit is contained in:
WorkBuddy
2026-08-17 12:25:42 +08:00
commit a303cf2d53
109 changed files with 25904 additions and 0 deletions

409
config/config.default.js Normal file
View File

@@ -0,0 +1,409 @@
'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;
};