gongxiangp2/lib/env-validation.ts

357 lines
11 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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
}