feat: 技术支持工单/企微权限/开发版本管理/消息推送等迭代
This commit is contained in:
@@ -1,263 +0,0 @@
|
||||
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(allowedGroups?: string[] | null): Promise<SystemConfigFormResponse> {
|
||||
if (!this.tableReady) {
|
||||
throw new Error('system_config 表未就绪,请在 server/dukang-api 执行 npx prisma db push');
|
||||
}
|
||||
const groups =
|
||||
allowedGroups == null
|
||||
? SYSTEM_CONFIG_GROUPS
|
||||
: SYSTEM_CONFIG_GROUPS.filter((g) => allowedGroups.includes(g.key));
|
||||
const allowedGroupSet = new Set(groups.map((g) => g.key));
|
||||
const fields = SYSTEM_CONFIG_FIELDS.filter((f) => allowedGroupSet.has(f.group));
|
||||
|
||||
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 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,
|
||||
fields,
|
||||
values,
|
||||
configuredSecrets,
|
||||
envFilePath: resolveEnvFilePath(),
|
||||
updatedAt: this.lastUpdatedAt?.toISOString() ?? null,
|
||||
mockSmsCodes: await this.mockSmsCodes.listRecent(),
|
||||
};
|
||||
}
|
||||
|
||||
async update(
|
||||
dto: SystemConfigUpdateRequest,
|
||||
allowedGroups?: string[] | null,
|
||||
): Promise<{
|
||||
updatedKeys: string[];
|
||||
requiresRestartKeys: string[];
|
||||
}> {
|
||||
const updatedKeys: string[] = [];
|
||||
const requiresRestartKeys: string[] = [];
|
||||
const overlay: Record<string, string> = {};
|
||||
const allowedGroupSet =
|
||||
allowedGroups == null ? null : new Set(allowedGroups);
|
||||
|
||||
for (const [key, rawValue] of Object.entries(dto.values ?? {})) {
|
||||
if (!SYSTEM_CONFIG_KEY_SET.has(key)) continue;
|
||||
const meta = getSystemConfigField(key);
|
||||
if (!meta) continue;
|
||||
if (allowedGroupSet && !allowedGroupSet.has(meta.group)) 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;
|
||||
const overlay: Record<string, string> = {};
|
||||
for (const field of SYSTEM_CONFIG_FIELDS) {
|
||||
const envVal = process.env[field.key];
|
||||
if (envVal === undefined || envVal === '') continue;
|
||||
const normalized = this.normalizeByMeta(field, envVal);
|
||||
const existing = await this.prisma.systemConfig.findUnique({ where: { configKey: field.key } });
|
||||
if (existing?.value?.trim()) continue;
|
||||
await this.prisma.systemConfig.upsert({
|
||||
where: { configKey: field.key },
|
||||
create: { configKey: field.key, value: normalized },
|
||||
update: { value: normalized },
|
||||
});
|
||||
overlay[field.key] = normalized;
|
||||
imported += 1;
|
||||
}
|
||||
if (imported) applyEnvOverlay(overlay);
|
||||
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 envVal = process.env[field.key];
|
||||
if (envVal === undefined || envVal === '') continue;
|
||||
const existing = await this.prisma.systemConfig.findUnique({ where: { configKey: field.key } });
|
||||
if (existing?.value?.trim()) continue;
|
||||
const value = this.normalizeByMeta(field, envVal);
|
||||
await this.prisma.systemConfig.upsert({
|
||||
where: { configKey: field.key },
|
||||
create: { configKey: field.key, value },
|
||||
update: { value },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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}"`;
|
||||
}
|
||||
Reference in New Issue
Block a user