import process from 'node:process' import type { AdminDefaultUser, RuntimeConfig } from '../types/runtime-config.js' type RuntimeEnvironment = { NODE_ENV?: string CI?: string } type ValidationOptions = { env?: RuntimeEnvironment } export type RuntimeConfigValidationIssue = { path: string message: string } export class RuntimeConfigValidationError extends Error { issues: RuntimeConfigValidationIssue[] constructor(issues: RuntimeConfigValidationIssue[]) { super(formatRuntimeConfigValidationMessage(issues)) this.name = 'RuntimeConfigValidationError' this.issues = issues } } export function assertRuntimeConfigValid( config: RuntimeConfig, options: ValidationOptions = {}, ): void { const issues = validateRuntimeConfig(config, options) if (issues.length > 0) { throw new RuntimeConfigValidationError(issues) } } export function validateRuntimeConfig( config: RuntimeConfig, { env = process.env }: ValidationOptions = {}, ): RuntimeConfigValidationIssue[] { const issues: RuntimeConfigValidationIssue[] = [] const productionLike = isProductionLike(env) requireInteger(issues, 'server.port', config.server?.port, { min: 1, max: 65535 }) const trustProxy = config.server?.trustProxy if (trustProxy !== undefined && trustProxy !== null && !isValidTrustProxy(trustProxy)) { issues.push({ path: 'server.trustProxy', message: 'server.trustProxy 仅支持 true/false、非负整数或 loopback/uniquelocal/linklocal', }) } requireInteger(issues, 'database.maxConnections', config.database?.maxConnections, { min: 1 }) requireOptionalInteger(issues, 'database.idleTimeoutMs', config.database?.idleTimeoutMs, { min: 1, }) requireOptionalInteger( issues, 'database.connectionTimeoutMs', config.database?.connectionTimeoutMs, { min: 1 }, ) requireOptionalInteger( issues, 'database.statementTimeoutMs', config.database?.statementTimeoutMs, { min: 1, }, ) requireOptionalInteger( issues, 'database.slowQueryThresholdMs', config.database?.slowQueryThresholdMs, { min: 0 }, ) requireInteger(issues, 'storage.maxUploadSizeMb', config.storage?.maxUploadSizeMb, { min: 1, max: 100, }) requireInteger(issues, 'orders.tokenTtlHours', config.orders?.tokenTtlHours, { min: 0 }) requireInteger(issues, 'admin.sessionTtlHours', config.admin?.sessionTtlHours, { min: 1 }) requireInteger( issues, 'platforms.cloudtentacles.timeoutMs', config.platforms?.cloudtentacles?.timeoutMs, { min: 1 }, ) if (config.platforms?.kuaishouFeifei) { requireInteger( issues, 'platforms.kuaishouFeifei.timeoutMs', config.platforms.kuaishouFeifei.timeoutMs, { min: 1 }, ) } requireInteger( issues, 'platforms.cloudtentacles.bindUrlTtlSeconds', config.platforms?.cloudtentacles?.bindUrlTtlSeconds, { min: 1 }, ) requireInteger( issues, 'platforms.cloudtentacles.bindUrlProbeIntervalSeconds', config.platforms?.cloudtentacles?.bindUrlProbeIntervalSeconds, { min: 1 }, ) requireInteger( issues, 'platforms.cloudtentacles.bindUrlProbeTimeoutMs', config.platforms?.cloudtentacles?.bindUrlProbeTimeoutMs, { min: 1 }, ) validateOptionalHttpUrl(issues, 'orders.claimBaseUrl', config.orders?.claimBaseUrl) validateOptionalStorageEndpoint(issues, 'storage.endpoint', config.storage?.endpoint) validateRequiredString(issues, 'data.root', config.data?.root) if (productionLike) { validateRequiredString(issues, 'database.url', config.database?.url) validateRequiredString(issues, 'storage.endpoint', config.storage?.endpoint) validateOptionalStorageEndpoint(issues, 'storage.endpoint', config.storage?.endpoint) validateRequiredString(issues, 'storage.bucket', config.storage?.bucket) validateRequiredString(issues, 'storage.accessKeyId', config.storage?.accessKeyId) validateSecret(issues, 'storage.secretAccessKey', config.storage?.secretAccessKey, { minLength: 8, }) validateRequiredHttpUrl(issues, 'orders.claimBaseUrl', config.orders?.claimBaseUrl) validateSecret(issues, 'admin.sessionSecret', config.admin?.sessionSecret, { minLength: 32 }) validateAdminDefaultUsers(issues, config.admin?.defaultUsers) } return issues } export function isProductionLike(env: RuntimeEnvironment = process.env): boolean { return env.NODE_ENV === 'production' } function validateAdminDefaultUsers( issues: RuntimeConfigValidationIssue[], users: AdminDefaultUser[] | undefined, ): void { if (!Array.isArray(users) || users.length === 0) { issues.push({ path: 'admin.defaultUsers', message: '生产环境必须配置至少一个后台默认用户', }) return } users.forEach((user, index) => { validateRequiredString(issues, `admin.defaultUsers[${index}].username`, user?.username) validateSecret(issues, `admin.defaultUsers[${index}].password`, user?.password, { minLength: 8, }) validateRequiredString(issues, `admin.defaultUsers[${index}].role`, user?.role) }) } function validateRequiredString( issues: RuntimeConfigValidationIssue[], configPath: string, value: unknown, ): void { if (!String(value || '').trim()) { issues.push({ path: configPath, message: '必须配置非空字符串', }) } } function validateSecret( issues: RuntimeConfigValidationIssue[], configPath: string, value: unknown, { minLength }: { minLength: number }, ): void { const text = String(value || '').trim() if (!text) { issues.push({ path: configPath, message: '必须配置密钥', }) return } if (text.length < minLength) { issues.push({ path: configPath, message: `长度不能少于 ${minLength} 个字符`, }) } if (/change-me|example|password|secret/i.test(text)) { issues.push({ path: configPath, message: '不能使用示例占位值', }) } } function validateOptionalHttpUrl( issues: RuntimeConfigValidationIssue[], configPath: string, value: unknown, ): void { const text = String(value || '').trim() if (!text) { return } if (!isHttpUrl(text)) { issues.push({ path: configPath, message: '必须是 http 或 https URL', }) } } function validateRequiredHttpUrl( issues: RuntimeConfigValidationIssue[], configPath: string, value: unknown, ): void { validateRequiredString(issues, configPath, value) validateOptionalHttpUrl(issues, configPath, value) } function validateOptionalStorageEndpoint( issues: RuntimeConfigValidationIssue[], configPath: string, value: unknown, ): void { const text = String(value || '').trim() if (!text) { return } if (/^https?:\/\//i.test(text)) { validateOptionalHttpUrl(issues, configPath, text) return } if (!/^[a-z0-9.-]+(?::\d{1,5})?$/i.test(text)) { issues.push({ path: configPath, message: '必须是 http/https URL 或 host:port', }) } } function requireInteger( issues: RuntimeConfigValidationIssue[], configPath: string, value: unknown, { min, max }: { min?: number; max?: number } = {}, ): void { const numberValue = Number(value) if (!Number.isInteger(numberValue)) { issues.push({ path: configPath, message: '必须是整数', }) return } if (typeof min === 'number' && numberValue < min) { issues.push({ path: configPath, message: `不能小于 ${min}`, }) } if (typeof max === 'number' && numberValue > max) { issues.push({ path: configPath, message: `不能大于 ${max}`, }) } } function requireOptionalInteger( issues: RuntimeConfigValidationIssue[], configPath: string, value: unknown, options: { min?: number; max?: number } = {}, ): void { if (value === undefined || value === null || value === '') { return } requireInteger(issues, configPath, value, options) } function isValidTrustProxy(value: unknown): boolean { if (typeof value === 'boolean') return true if (typeof value === 'number') return Number.isInteger(value) && value >= 0 return ['loopback', 'uniquelocal', 'linklocal'].includes(String(value)) } function isHttpUrl(value: string): boolean { try { const url = new URL(value) return url.protocol === 'http:' || url.protocol === 'https:' } catch { return false } } function formatRuntimeConfigValidationMessage(issues: RuntimeConfigValidationIssue[]): string { const lines = issues.map((issue) => `- ${issue.path}: ${issue.message}`) return `运行时配置校验失败:\n${lines.join('\n')}` }