Files
order_site/apps/backend/src/config/runtime-validation.ts
T
2026-05-21 19:29:00 +08:00

215 lines
5.9 KiB
TypeScript

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 })
requireInteger(issues, 'database.maxConnections', config.database?.maxConnections, { min: 1 })
requireInteger(issues, 'orders.tokenTtlHours', config.orders?.tokenTtlHours, { min: 1 })
requireInteger(issues, 'admin.sessionTtlHours', config.admin?.sessionTtlHours, { min: 1 })
requireInteger(issues, 'platforms.agiso.tradeDetail.timeoutMs', config.platforms?.agiso?.tradeDetail?.timeoutMs, { min: 1 })
requireInteger(issues, 'platforms.cloudtentacles.timeoutMs', config.platforms?.cloudtentacles?.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)
validateRequiredString(issues, 'data.root', config.data?.root)
if (productionLike) {
validateRequiredString(issues, 'database.url', config.database?.url)
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 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 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')}`
}