/** * R47修复:环境变量启动验证 * * 目的:在应用启动时验证所有必需的环境变量,提供清晰的错误提示 * 时机:在 lib/prisma.ts 和其他关键模块中调用 */ /** * 环境变量验证错误 */ export class EnvValidationError extends Error { public missingVars: string[] constructor(missingVars: string[]) { const varList = missingVars.map(v => ` - ${v}`).join('\n') super( `缺少必需的环境变量:\n${varList}\n\n` + `请在 .env 文件或 docker-compose.yml 中配置这些变量。\n` + `详情请参考:.env.example 或项目文档。` ) this.name = 'EnvValidationError' this.missingVars = missingVars } } /** * 必需的环境变量列表 */ const REQUIRED_ENV_VARS = [ // 数据库 'DATABASE_URL', // NextAuth 'NEXTAUTH_SECRET', // NEXTAUTH_URL - 改为可选,让 NextAuth 根据请求自动检测(支持内网/公网自适应) // S3/MinIO(如果使用S3存储) // 'S3_ENDPOINT', // 'S3_BUCKET', // 'S3_ACCESS_KEY_ID', // 'S3_SECRET_ACCESS_KEY', // Redis(如果使用Redis) // 'REDIS_URL', // 'USE_REDIS', // 管理员API密钥 - 改为可选,不阻塞应用启动 // 'ADMIN_API_KEY', ] /** * 可选但建议配置的环境变量 */ const OPTIONAL_ENV_VARS = [ // NextAuth URL(可选,不设置时 NextAuth 会自动检测) 'NEXTAUTH_URL', // S3/MinIO 'S3_ENDPOINT', 'S3_BUCKET', 'S3_ACCESS_KEY_ID', 'S3_SECRET_ACCESS_KEY', // Redis 'REDIS_URL', 'USE_REDIS', 'DINGTALK_APP_KEY', 'DINGTALK_APP_SECRET', 'DINGTALK_CLIENT_ID', 'DINGTALK_CLIENT_SECRET', 'DINGTALK_CORP_ID', 'DINGTALK_CORPID', 'DINGTALK_WORK_NOTICE_CLIENT_ID', 'DINGTALK_WORK_NOTICE_CLIENT_SECRET', 'DINGTALK_WORK_NOTICE_APP_KEY', 'DINGTALK_WORK_NOTICE_APP_SECRET', 'DINGTALK_WORK_NOTICE_AGENT_ID', 'DINGTALK_AGENT_ID', 'DINGTALK_ROBOT_CODE', 'DINGTALK_PERFORMANCE_SURVEY_NOTIFICATION_CHANNEL', 'DINGTALK_ROBOT_OTO_TEST_USER_IDS', 'YYD_NOTIFICATION_CENTER_URL', 'YYD_NOTIFICATION_CENTER_PROJECT_CODE', 'YYD_NOTIFICATION_CENTER_PROJECT_SECRET', 'YYD_NOTIFICATION_CENTER_TEST_USER_IDS', 'YYD_NOTIFICATION_CENTER_ADMIN_URL', 'YYD_NOTIFICATION_CENTER_ADMIN_API_SECRET', 'YYD_NOTIFICATION_CENTER_ADMIN_TIMEOUT_MS', 'API_KEY_ENCRYPTION_KEY', 'REPORT_DB_SERVER', 'REPORT_DB_DATABASE', 'REPORT_DB_USER', 'REPORT_DB_PASSWORD', 'REPORT_DB_PORT', 'REPORT_DB_ENCRYPT', 'REPORT_DB_TRUST_SERVER_CERTIFICATE', 'REPORT_DB_EMPLOYEE_SCHEMA', 'REPORT_DB_EMPLOYEE_TABLE', 'REPORT_DB_REGISTER_SCHEMA', 'REPORT_DB_REGISTER_TABLE', 'REPORT_DB_FLOWER_NAME_SCHEMA', 'REPORT_DB_FLOWER_NAME_TABLE', 'REPORT_DB_WORKFLOW_SCHEMA', 'REPORT_DB_WORKFLOW_TABLE', 'REPORT_DB_WORKFLOW_SCHEME_TABLE', 'REPORT_DB_WORKFLOW_TASK_TABLE', 'DING_WORKFLOW_SYNC_INTERVAL_HOURS', 'PERFORMANCE_SURVEY_PROFILE_SYNC_HOURS', 'PERFORMANCE_SURVEY_DAILY_SYNC_INTERVAL_HOURS', 'PERFORMANCE_SURVEY_DAILY_SYNC_INITIAL_DELAY_SECONDS', 'PERFORMANCE_SURVEY_DAILY_SYNC_LIMIT', 'DOWNLOAD_TOKEN_SECRET', 'WORK_REVIEW_SINGLE_REVIEW_TEST_PROJECT_ID', 'WORK_REVIEW_SINGLE_REVIEW_TEST_MANAGER_EMPLOYEE_ID', 'WORK_REVIEW_SINGLE_REVIEW_TEST_EXPIRES_AT', 'DISABLE_CACHE', 'ADMIN_API_KEY', // 管理员API密钥(用于外部API调用验证) ] /** * 验证所有必需的环境变量 * @throws {EnvValidationError} 如果缺少必需的环境变量 */ export function validateRequiredEnvVars(): void { const missing: string[] = [] for (const varName of REQUIRED_ENV_VARS) { if (!process.env[varName]) { missing.push(varName) } } if (missing.length > 0) { throw new EnvValidationError(missing) } console.log('[Env] ✅ 所有必需的环境变量已配置') } /** * 验证环境变量的格式 * @throws {Error} 如果环境变量格式不正确 */ export function validateEnvFormats(): void { const errors: string[] = [] // 验证 DATABASE_URL 格式 const databaseUrl = process.env.DATABASE_URL if (databaseUrl) { try { const url = new URL(databaseUrl) if (url.protocol !== 'postgresql:' && url.protocol !== 'postgres:') { errors.push(`DATABASE_URL 格式错误:应该使用 postgresql:// 或 postgres:// 协议`) } } catch (e) { errors.push(`DATABASE_URL 格式错误:${e}`) } } // 验证 NEXTAUTH_URL 格式 const nextauthUrl = process.env.NEXTAUTH_URL if (nextauthUrl) { try { new URL(nextauthUrl) } catch (e) { errors.push(`NEXTAUTH_URL 格式错误:${e}`) } } // 验证 REDIS_URL 格式(如果配置了) const redisUrl = process.env.REDIS_URL if (redisUrl) { try { const url = new URL(redisUrl) if (url.protocol !== 'redis:') { errors.push(`REDIS_URL 格式错误:应该使用 redis:// 协议`) } } catch (e) { errors.push(`REDIS_URL 格式错误:${e}`) } } // 验证 S3_ENDPOINT 格式(如果配置了) const s3Endpoint = process.env.S3_ENDPOINT if (s3Endpoint) { try { const url = new URL(s3Endpoint) if (url.protocol !== 'http:' && url.protocol !== 'https:') { errors.push(`S3_ENDPOINT 格式错误:应该使用 http:// 或 https:// 协议`) } } catch (e) { errors.push(`S3_ENDPOINT 格式错误:${e}`) } } const notificationCenterAdminUrl = process.env.YYD_NOTIFICATION_CENTER_ADMIN_URL if (notificationCenterAdminUrl) { try { const url = new URL(notificationCenterAdminUrl) if (url.protocol !== 'http:' && url.protocol !== 'https:') { errors.push('YYD_NOTIFICATION_CENTER_ADMIN_URL 格式错误:应该使用 http:// 或 https:// 协议') } } catch (e) { errors.push(`YYD_NOTIFICATION_CENTER_ADMIN_URL 格式错误:${e}`) } } const notificationCenterAdminTimeoutMs = process.env.YYD_NOTIFICATION_CENTER_ADMIN_TIMEOUT_MS if ( notificationCenterAdminTimeoutMs && (!/^\d+$/.test(notificationCenterAdminTimeoutMs.trim()) || Number(notificationCenterAdminTimeoutMs) <= 0) ) { errors.push('YYD_NOTIFICATION_CENTER_ADMIN_TIMEOUT_MS 必须是正整数毫秒值') } const apiKeyEncryptionKey = process.env.API_KEY_ENCRYPTION_KEY?.trim() if (apiKeyEncryptionKey && Buffer.byteLength(apiKeyEncryptionKey, 'utf8') < 32) { errors.push('API_KEY_ENCRYPTION_KEY 至少需要 32 个字符') } const singleReviewVars = [ 'WORK_REVIEW_SINGLE_REVIEW_TEST_PROJECT_ID', 'WORK_REVIEW_SINGLE_REVIEW_TEST_MANAGER_EMPLOYEE_ID', 'WORK_REVIEW_SINGLE_REVIEW_TEST_EXPIRES_AT', ] as const const configuredSingleReviewVars = singleReviewVars.filter((name) => Boolean(process.env[name]?.trim())) if (configuredSingleReviewVars.length > 0 && configuredSingleReviewVars.length !== singleReviewVars.length) { errors.push(`作品过稿单评审测试配置必须同时设置:${singleReviewVars.join(', ')}`) } if (configuredSingleReviewVars.length === singleReviewVars.length) { const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i if (!uuidPattern.test(process.env.WORK_REVIEW_SINGLE_REVIEW_TEST_PROJECT_ID!.trim())) { errors.push('WORK_REVIEW_SINGLE_REVIEW_TEST_PROJECT_ID 必须是有效 UUID') } if (!uuidPattern.test(process.env.WORK_REVIEW_SINGLE_REVIEW_TEST_MANAGER_EMPLOYEE_ID!.trim())) { errors.push('WORK_REVIEW_SINGLE_REVIEW_TEST_MANAGER_EMPLOYEE_ID 必须是有效 UUID') } if (!Number.isFinite(Date.parse(process.env.WORK_REVIEW_SINGLE_REVIEW_TEST_EXPIRES_AT!.trim()))) { errors.push('WORK_REVIEW_SINGLE_REVIEW_TEST_EXPIRES_AT 必须是有效日期时间') } } if (errors.length > 0) { const errorList = errors.map(e => ` - ${e}`).join('\n') throw new Error( `环境变量格式错误:\n${errorList}\n\n` + `请检查 .env 文件或 docker-compose.yml 中的配置。` ) } console.log('[Env] ✅ 环境变量格式验证通过') } /** * 检查可选环境变量并给出提示 */ export function checkOptionalEnvVars(): void { const configured: string[] = [] const notConfigured: string[] = [] for (const varName of OPTIONAL_ENV_VARS) { if (process.env[varName]) { configured.push(varName) } else { notConfigured.push(varName) } } if (configured.length > 0) { console.log(`[Env] ℹ️ 已配置的可选变量:${configured.join(', ')}`) } if (notConfigured.length > 0) { console.log(`[Env] ⚠️ 未配置的可选变量:${notConfigured.join(', ')}`) } } /** * 检测是否在 Next.js 构建阶段 * 构建时不应该验证环境变量,因为构建环境没有运行时环境变量 */ function isBuildPhase(): boolean { // 方式1:检测 NEXT_PHASE 环境变量 if (process.env.NEXT_PHASE === 'phase-production-build') { return true } // 方式2:检测 npm_lifecycle_event(构建脚本中会设置) if (process.env.npm_lifecycle_event === 'build') { return true } // 方式3:检测 __NEXT_PRIVATE_PREBUNDLED_REACT(Next.js 内部变量) if (process.env.__NEXT_PRIVATE_PREBUNDLED_REACT) { return true } return false } /** * 完整的环境变量验证(在应用启动时调用) * @throws {Error} 如果验证失败且在生产运行时环境 */ export function validateEnvironment(): void { // 构建阶段跳过验证 if (isBuildPhase()) { return } console.log('[Env] 🔍 开始验证环境变量...') try { validateRequiredEnvVars() validateEnvFormats() checkOptionalEnvVars() console.log('[Env] ✅ 环境变量验证完成') } catch (error) { console.error('[Env] ❌ 环境变量验证失败:', error) // 只在生产运行时抛出错误,开发环境只记录警告 if (process.env.NODE_ENV === 'production') { throw error } console.warn('[Env] ⚠️ 非 production 环境,跳过环境变量验证错误') } } /** * 安全获取环境变量(带默认值和类型转换) */ export function getEnvVar(key: string, defaultValue?: string): string | undefined { const value = process.env[key] if (value === undefined || value === '') { return defaultValue } return value } /** * 获取布尔型环境变量 */ export function getBoolEnvVar(key: string, defaultValue: boolean = false): boolean { const value = process.env[key] if (value === undefined || value === '') { return defaultValue } return value === 'true' || value === '1' } /** * 获取数字型环境变量 */ export function getNumberEnvVar(key: string, defaultValue: number = 0): number { const value = process.env[key] if (value === undefined || value === '') { return defaultValue } const num = parseInt(value, 10) return isNaN(num) ? defaultValue : num }