328 lines
9.4 KiB
TypeScript
328 lines
9.4 KiB
TypeScript
/**
|
||
* 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',
|
||
'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 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
|
||
}
|