import { Injectable, OnModuleInit } from '@nestjs/common'; import { writeFileSync } from 'fs'; import type { SystemConfigFormResponse, SystemConfigSyncResult, SystemConfigUpdateRequest, } from '@dukang/shared-types'; import { loadAppConfig, parseMiniHomeBanners, serializeMiniHomeBanners } from '@dukang/shared-types'; import { PrismaService } from '../prisma/prisma.module'; import { SYSTEM_CONFIG_FIELDS, SYSTEM_CONFIG_GROUPS, SYSTEM_CONFIG_KEY_SET, SYSTEM_CONFIG_RETIRED_KEYS, getSystemConfigField, } from './system-config.registry'; import { applyEnvOverlay, resolveEnvFilePath } from './system-config.env'; import { MockSmsCodeService } from '../mock-sms-code/mock-sms-code.service'; const SECRET_PLACEHOLDER = '********'; function isMissingSystemConfigTable(error: unknown): boolean { return ( typeof error === 'object' && error !== null && 'code' in error && (error as { code?: string }).code === 'P2021' ); } @Injectable() export class SystemConfigService implements OnModuleInit { private lastUpdatedAt: Date | null = null; private tableReady = true; constructor( private readonly prisma: PrismaService, private readonly mockSmsCodes: MockSmsCodeService, ) {} async onModuleInit() { try { await this.purgeRetiredKeys(); await this.seedMissingFromProcessEnv(); const rows = await this.prisma.systemConfig.findMany(); applyEnvOverlay(Object.fromEntries(rows.map((r) => [r.configKey, r.value]))); this.lastUpdatedAt = rows.reduce( (max, r) => (!max || r.updatedAt > max ? r.updatedAt : max), null, ); } catch (error) { if (!isMissingSystemConfigTable(error)) throw error; this.tableReady = false; console.warn('[config] system_config 表不存在,请执行 npx prisma db push'); } } getMergedEnv(): Record { return { ...process.env }; } getAppConfig() { return loadAppConfig(this.getMergedEnv()); } async getForm(): Promise { if (!this.tableReady) { throw new Error('system_config 表未就绪,请在 server/dukang-api 执行 npx prisma db push'); } const rows = await this.prisma.systemConfig.findMany(); const dbMap = new Map(rows.map((r) => [r.configKey, r.value])); const values: Record = {}; const configuredSecrets: string[] = []; for (const field of SYSTEM_CONFIG_FIELDS) { const fromDb = dbMap.get(field.key); const fromEnv = process.env[field.key]; const raw = fromDb ?? fromEnv ?? ''; if (field.secret) { if (raw) configuredSecrets.push(field.key); values[field.key] = ''; } else { values[field.key] = raw; } } return { groups: SYSTEM_CONFIG_GROUPS, fields: SYSTEM_CONFIG_FIELDS, values, configuredSecrets, envFilePath: resolveEnvFilePath(), updatedAt: this.lastUpdatedAt?.toISOString() ?? null, mockSmsCodes: await this.mockSmsCodes.listRecent(), }; } async update(dto: SystemConfigUpdateRequest): Promise<{ updatedKeys: string[]; requiresRestartKeys: string[]; }> { const updatedKeys: string[] = []; const requiresRestartKeys: string[] = []; const overlay: Record = {}; for (const [key, rawValue] of Object.entries(dto.values ?? {})) { if (!SYSTEM_CONFIG_KEY_SET.has(key)) continue; const meta = getSystemConfigField(key); if (!meta) continue; let value = String(rawValue ?? '').trim(); if (meta.secret && (!value || value === SECRET_PLACEHOLDER)) { continue; } value = this.normalizeByMeta(meta, value); await this.prisma.systemConfig.upsert({ where: { configKey: key }, create: { configKey: key, value }, update: { value }, }); overlay[key] = value; updatedKeys.push(key); if (meta.requiresRestart) requiresRestartKeys.push(key); } if (updatedKeys.length) { applyEnvOverlay(overlay); const latest = await this.prisma.systemConfig.findFirst({ orderBy: { updatedAt: 'desc' }, select: { updatedAt: true }, }); this.lastUpdatedAt = latest?.updatedAt ?? new Date(); } return { updatedKeys, requiresRestartKeys: [...new Set(requiresRestartKeys)] }; } async syncToEnvFile(): Promise { const rows = await this.prisma.systemConfig.findMany(); const path = resolveEnvFilePath(); const lines: string[] = [ '# 由 HQ 系统设置同步生成,请勿手工删改键名', `# synced_at=${new Date().toISOString()}`, '', ]; let currentGroup = ''; for (const field of SYSTEM_CONFIG_FIELDS) { if (field.group !== currentGroup) { currentGroup = field.group; const groupLabel = SYSTEM_CONFIG_GROUPS.find((g) => g.key === currentGroup)?.label ?? currentGroup; lines.push(`# --- ${groupLabel} ---`); } const row = rows.find((r) => r.configKey === field.key); const value = row?.value ?? process.env[field.key] ?? ''; lines.push(formatEnvLine(field.key, value)); } lines.push(''); writeFileSync(path, lines.join('\n'), 'utf8'); const requiresRestartKeys = SYSTEM_CONFIG_FIELDS.filter((f) => f.requiresRestart).map((f) => f.key); return { envFilePath: path, writtenKeys: SYSTEM_CONFIG_FIELDS.length, requiresRestartKeys, message: `已写入 ${path},共 ${SYSTEM_CONFIG_FIELDS.length} 项。修改「需重启」类配置后请重启 API 进程。`, }; } async importFromProcessEnv(): Promise<{ imported: number }> { let imported = 0; for (const field of SYSTEM_CONFIG_FIELDS) { const envVal = process.env[field.key]; if (envVal === undefined || envVal === '') continue; const existing = await this.prisma.systemConfig.findUnique({ where: { configKey: field.key } }); if (existing) continue; await this.prisma.systemConfig.create({ data: { configKey: field.key, value: this.normalizeByMeta(field, envVal) }, }); imported += 1; } await this.onModuleInit(); return { imported }; } private async purgeRetiredKeys() { await this.prisma.systemConfig.deleteMany({ where: { configKey: { in: [...SYSTEM_CONFIG_RETIRED_KEYS] }, }, }); } private async seedMissingFromProcessEnv() { for (const field of SYSTEM_CONFIG_FIELDS) { const existing = await this.prisma.systemConfig.findUnique({ where: { configKey: field.key } }); if (existing) continue; const envVal = process.env[field.key]; if (envVal === undefined || envVal === '') continue; await this.prisma.systemConfig.create({ data: { configKey: field.key, value: this.normalizeByMeta(field, envVal) }, }); } } private normalizeByMeta(meta: { key?: string; type: string }, raw: string): string { if (meta.type === 'boolean') { return raw === 'true' || raw === '1' ? 'true' : 'false'; } if (meta.type === 'imageList') { return serializeMiniHomeBanners(parseMiniHomeBanners(raw)); } if (meta.type === 'image') { return raw.trim(); } if (meta.key === 'WX_MCH_PRIVATE_KEY' || meta.key === 'WX_PLATFORM_CERT') { let value = raw.trim(); if ( (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")) ) { value = value.slice(1, -1).trim(); } return value.replace(/\\r\\n/g, '\n').replace(/\\n/g, '\n').replace(/\r\n/g, '\n').trim(); } return raw; } } function formatEnvLine(key: string, value: string): string { const needsQuote = /[\s#"'\\]/.test(value) || value.includes('\n') || value.includes('\r'); if (!needsQuote) return `${key}=${value}`; const escaped = value .replace(/\\/g, '\\\\') .replace(/\r\n/g, '\\n') .replace(/\n/g, '\\n') .replace(/"/g, '\\"'); return `${key}="${escaped}"`; }