d11757c854
CI / verify (pull_request) Has been cancelled
Fix mini home media form persistence; float save with unsaved leave prompt; promo detail QR on the right. Co-authored-by: Cursor <cursoragent@cursor.com>
242 lines
7.9 KiB
TypeScript
242 lines
7.9 KiB
TypeScript
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<Date | null>(
|
|
(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<string, string | undefined> {
|
|
return { ...process.env };
|
|
}
|
|
|
|
getAppConfig() {
|
|
return loadAppConfig(this.getMergedEnv());
|
|
}
|
|
|
|
async getForm(): Promise<SystemConfigFormResponse> {
|
|
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<string, string> = {};
|
|
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<string, string> = {};
|
|
|
|
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<SystemConfigSyncResult> {
|
|
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}"`;
|
|
}
|