feat: multi-module iteration

This commit is contained in:
2026-08-04 21:38:49 +08:00
parent 9d96c73246
commit 71f508e02b
1366 changed files with 202004 additions and 0 deletions
@@ -0,0 +1,55 @@
import { resolve } from 'path';
import { PrismaClient } from '@prisma/client';
import { normalizePemEnv } from '../../integrations/wechat/wechat-pay.util';
import { SYSTEM_CONFIG_KEY_SET } from './system-config.registry';
const PEM_ENV_KEYS = new Set(['WX_MCH_PRIVATE_KEY', 'WX_PLATFORM_CERT']);
function apiRoot() {
return resolve(__dirname, '..', '..');
}
export function resolveEnvFilePath() {
const isProduction = (process.env.NODE_ENV ?? 'development') === 'production';
return resolve(apiRoot(), isProduction ? '.env.production' : '.env');
}
function normalizeConfigValue(key: string, value: string): string {
if (PEM_ENV_KEYS.has(key)) return normalizePemEnv(value);
return value;
}
/** 启动前从 DB 覆盖 process.env(在 Nest 创建前调用) */
export async function preloadSystemConfigEnv(): Promise<number> {
const prisma = new PrismaClient();
try {
const rows = await prisma.systemConfig.findMany();
for (const row of rows) {
if (SYSTEM_CONFIG_KEY_SET.has(row.configKey)) {
process.env[row.configKey] = normalizeConfigValue(row.configKey, row.value);
}
}
return rows.length;
} catch (error) {
if (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code?: string }).code === 'P2021'
) {
console.warn('[config] system_config 表不存在,跳过 DB 预加载');
return 0;
}
throw error;
} finally {
await prisma.$disconnect();
}
}
export function applyEnvOverlay(values: Record<string, string>) {
for (const [key, value] of Object.entries(values)) {
if (SYSTEM_CONFIG_KEY_SET.has(key)) {
process.env[key] = normalizeConfigValue(key, value);
}
}
}
@@ -0,0 +1,10 @@
import { Global, Module } from '@nestjs/common';
import { MockSmsCodeService } from '../mock-sms-code/mock-sms-code.service';
import { SystemConfigService } from './system-config.service';
@Global()
@Module({
providers: [SystemConfigService, MockSmsCodeService],
exports: [SystemConfigService, MockSmsCodeService],
})
export class SystemConfigModule {}
@@ -0,0 +1,243 @@
import type { SystemConfigFieldMeta, SystemConfigGroupMeta } from '@dukang/shared-types';
/** 运行环境、安全与鉴权仅保留在 .env,不在 HQ 系统设置中维护 */
export const SYSTEM_CONFIG_GROUPS: SystemConfigGroupMeta[] = [
{ key: 'feature', label: '功能开关' },
{ key: 'sms', label: '短信' },
{ key: 'wechat', label: '微信' },
{ key: 'wechat_mini', label: '微信小程序配置' },
{ key: 'oss', label: '对象存储 OSS' },
{ key: 'app', label: '应用链接' },
{ key: 'deploy', label: '发布部署' },
{ key: 'winery_bank', label: '酒厂银行账户' },
{ key: 'finance', label: '财务结算' },
];
const G = {
feature: 'feature',
sms: 'sms',
wechat: 'wechat',
wechat_mini: 'wechat_mini',
oss: 'oss',
app: 'app',
deploy: 'deploy',
winery_bank: 'winery_bank',
finance: 'finance',
} as const;
/** HQ 可维护字段(不含 NODE_ENV / DATABASE_URL / JWT 等基础设施项) */
export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
{
key: 'MOCK_SMS',
label: 'Mock 短信',
group: G.feature,
type: 'boolean',
requiresRestart: false,
description: '开启后不发真实短信,验证码为随机 6 位数字,并记录在下方列表',
},
{
key: 'MOCK_PAY',
label: 'Mock 支付',
group: G.feature,
type: 'boolean',
requiresRestart: false,
description: '关闭且已配置微信商户参数时走真实 JSAPI 支付',
},
{
key: 'MOCK_WECHAT',
label: 'Mock 微信授权',
group: G.feature,
type: 'boolean',
requiresRestart: false,
description: '开启走 Mock OAuth/登录;关闭且已配置 WX_APP_ID/SECRET 时走真实微信',
},
{ key: 'MOCK_DELIVERY_AUTO', label: 'Mock 配送自动完成', group: G.feature, type: 'boolean', requiresRestart: false },
{ key: 'AUTO_APPROVE_STORE', label: '门店自动审核通过', group: G.feature, type: 'boolean', requiresRestart: false },
{
key: 'WECOM_AIBOT_ENABLED',
label: '启用企微机器人长连接',
group: G.feature,
type: 'boolean',
requiresRestart: false,
description: '总开关。开启后连接 HQ「企微机器人 → 智能机器人」中已启用且配置完整的 Bot(每 Bot 同时仅 1 条长连接)',
},
{ key: 'ALIYUN_SMS_SIGN_NAME', label: '短信签名', group: G.sms, type: 'string', requiresRestart: false },
{ key: 'ALIYUN_SMS_TEMPLATE_CODE', label: '默认短信模板', group: G.sms, type: 'string', requiresRestart: false },
{ key: 'ALIYUN_SMS_TEMPLATE_CONFIRM_REDEEM', label: '核销确认模板', group: G.sms, type: 'string', requiresRestart: false },
{ key: 'ALIYUN_SMS_TEMPLATE_PROXY_ORDER', label: '代下单确认模板', group: G.sms, type: 'string', requiresRestart: false },
{ key: 'ALIYUN_SMS_ACCESS_KEY_ID', label: '短信 AccessKey ID', group: G.sms, type: 'password', secret: true, requiresRestart: true },
{ key: 'ALIYUN_SMS_ACCESS_KEY_SECRET', label: '短信 AccessKey Secret', group: G.sms, type: 'password', secret: true, requiresRestart: true },
{ key: 'WX_APP_ID', label: '服务号 AppID', group: G.wechat, type: 'string', requiresRestart: true },
{ key: 'WX_APP_SECRET', label: '服务号 AppSecret', group: G.wechat, type: 'password', secret: true, requiresRestart: true },
{ key: 'WX_MINI_APP_ID', label: '小程序 AppID', group: G.wechat, type: 'string', requiresRestart: true },
{ key: 'WX_MINI_APP_SECRET', label: '小程序 AppSecret', group: G.wechat, type: 'password', secret: true, requiresRestart: true },
{ key: 'WX_MCH_ID', label: '微信商户号', group: G.wechat, type: 'string', requiresRestart: true },
{ key: 'WX_MCH_SERIAL_NO', label: '商户证书序列号', group: G.wechat, type: 'string', requiresRestart: true },
{ key: 'WX_MCH_PRIVATE_KEY', label: '商户私钥 PEM', group: G.wechat, type: 'textarea', secret: true, requiresRestart: true },
{ key: 'WX_API_V3_KEY', label: 'APIv3 密钥', group: G.wechat, type: 'password', secret: true, requiresRestart: true },
{ key: 'WX_PLATFORM_CERT', label: '微信平台公钥证书', group: G.wechat, type: 'textarea', secret: true, requiresRestart: true },
{ key: 'WX_PAY_NOTIFY_URL', label: '支付回调 URL', group: G.wechat, type: 'string', requiresRestart: false },
{ key: 'WX_REFUND_NOTIFY_URL', label: '退款回调 URL', group: G.wechat, type: 'string', requiresRestart: false },
{
key: 'WX_MINI_MSG_TOKEN',
label: '小程序消息推送 Token',
group: G.wechat,
type: 'password',
secret: true,
requiresRestart: true,
description:
'小程序后台「开发-开发管理-消息推送」Token;回调 URL=/api/v1/callbacks/wechat/message',
},
{
key: 'WX_MINI_MSG_AES_KEY',
label: '小程序消息推送 EncodingAESKey',
group: G.wechat,
type: 'password',
secret: true,
requiresRestart: true,
description: '消息推送安全模式 EncodingAESKey43 位);明文模式可留空',
},
{
key: 'MINI_HOME_BANNERS',
label: '首页轮播图',
group: G.wechat_mini,
type: 'imageList',
requiresRestart: false,
description: '小程序商品首页顶部轮播,建议比例 15:8,最多 8 张;上传后需点击右上角「保存」',
},
{
key: 'MINI_HOME_FOOTER_URL',
label: '首页底部图',
group: G.wechat_mini,
type: 'image',
requiresRestart: false,
description: '小程序商品首页底部 footer,建议比例 15:4;上传后需点击右上角「保存」',
},
{ key: 'OSS_ACCESS_KEY_ID', label: 'OSS AccessKey ID', group: G.oss, type: 'password', secret: true, requiresRestart: true },
{ key: 'OSS_ACCESS_KEY_SECRET', label: 'OSS AccessKey Secret', group: G.oss, type: 'password', secret: true, requiresRestart: true },
{ key: 'OSS_BUCKET', label: 'OSS Bucket', group: G.oss, type: 'string', requiresRestart: true },
{ key: 'OSS_REGION', label: 'OSS Region', group: G.oss, type: 'string', requiresRestart: true, placeholder: 'oss-cn-hangzhou' },
{ key: 'OSS_ENDPOINT', label: 'OSS Endpoint', group: G.oss, type: 'string', requiresRestart: true, description: '可选,内网 endpoint' },
{ key: 'OSS_AUTHORIZATION_V4', label: 'OSS V4 签名', group: G.oss, type: 'boolean', requiresRestart: true },
{ key: 'OSS_CDN_BASE', label: 'OSS 公网域名', group: G.oss, type: 'string', requiresRestart: false },
{ key: 'OSS_UPLOAD_PREFIX', label: '上传前缀', group: G.oss, type: 'string', requiresRestart: false, placeholder: 'uploads' },
{ key: 'OSS_UPLOAD_EXPIRE_SECONDS', label: '直传凭证有效期(秒)', group: G.oss, type: 'number', requiresRestart: false },
{ key: 'OSS_MAX_UPLOAD_BYTES', label: '单文件上限(字节)', group: G.oss, type: 'number', requiresRestart: false },
{ key: 'USER_H5_URL', label: 'C 端 H5 落地页', group: G.app, type: 'string', requiresRestart: false },
{
key: 'TENCENT_LBS_KEY',
label: '腾讯位置服务 Key',
group: G.app,
type: 'password',
secret: true,
requiresRestart: false,
description: '须开启 WebServiceAPI;服务端地理编码/地点搜索与前端选点组件共用',
},
{
key: 'TENCENT_LBS_SECRET_KEY',
label: '腾讯位置服务 SecretKeySK',
group: G.app,
type: 'password',
secret: true,
requiresRestart: false,
description:
'控制台开启 WebServiceAPI「签名校验」后生成;仅服务端计算 sig,勿泄露。配置后接口请求自动附带签名',
},
{ key: 'DEPLOY_WEBHOOK_URL', label: '发布 Webhook URL', group: G.deploy, type: 'string', requiresRestart: false },
{ key: 'DEPLOY_WEBHOOK_SECRET', label: '发布 Webhook Secret', group: G.deploy, type: 'password', secret: true, requiresRestart: false },
{
key: 'SENTRY_DSN',
label: 'Sentry DSN',
group: G.deploy,
type: 'password',
secret: true,
requiresRestart: true,
description: '后端错误聚合;留空不启用。配置后需重启 API 生效',
},
{
key: 'WINERY_BANK_ACCOUNT_NAME',
label: '户名',
group: G.winery_bank,
type: 'string',
requiresRestart: false,
description: '酒厂收款账户户名,打款时对照',
},
{
key: 'WINERY_BANK_NAME',
label: '开户银行',
group: G.winery_bank,
type: 'string',
requiresRestart: false,
},
{
key: 'WINERY_BANK_BRANCH',
label: '开户支行',
group: G.winery_bank,
type: 'string',
requiresRestart: false,
placeholder: '可选',
},
{
key: 'WINERY_BANK_ACCOUNT_NO',
label: '银行账号',
group: G.winery_bank,
type: 'string',
requiresRestart: false,
},
{
key: 'STORE_WITHDRAW_DAILY_LIMIT',
label: '门店未出账提现单日上限(元)',
group: G.finance,
type: 'number',
requiresRestart: false,
description: 'FIN-002:单店单日提现上限,默认 5000',
placeholder: '5000',
},
];
/** 已从 HQ 配置移除、仅保留在 .env 的键(启动时从 DB 清理) */
export const SYSTEM_CONFIG_RETIRED_KEYS = [
'NODE_ENV',
'DATABASE_URL',
'REDIS_URL',
'PORT',
'TRUST_PROXY',
'JWT_SECRET',
'JWT_EXPIRES_IN',
'MOCK_SMS_CODE',
'WX_AUTHORIZE',
'WECHAT_AUTH_ENABLED',
'WECHAT_PAY_ENABLED',
'OSS_ENABLED',
'WECOM_AIBOT_BOT_ID',
'WECOM_AIBOT_SECRET',
'WECOM_AIBOT_WELCOME',
'WECOM_BOT_CS_ENABLED',
'WECOM_BOT_CS_BOT_ID',
'WECOM_BOT_CS_SECRET',
'WECOM_BOT_CS_WELCOME',
'WECOM_BOT_CS_PERMISSIONS',
'WECOM_BOT_TECH_ENABLED',
'WECOM_BOT_TECH_BOT_ID',
'WECOM_BOT_TECH_SECRET',
'WECOM_BOT_TECH_WELCOME',
'WECOM_BOT_TECH_PERMISSIONS',
'WECOM_BOT_TEAM_ENABLED',
'WECOM_BOT_TEAM_BOT_ID',
'WECOM_BOT_TEAM_SECRET',
'WECOM_BOT_TEAM_WELCOME',
'WECOM_BOT_TEAM_PERMISSIONS',
] as const;
export const SYSTEM_CONFIG_KEY_SET = new Set(SYSTEM_CONFIG_FIELDS.map((f) => f.key));
export function getSystemConfigField(key: string): SystemConfigFieldMeta | undefined {
return SYSTEM_CONFIG_FIELDS.find((f) => f.key === key);
}
@@ -0,0 +1,263 @@
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}"`;
}