webadmin系统设置

This commit is contained in:
2026-07-12 22:52:40 +08:00
parent 236a2a87a5
commit 1bd152073a
41 changed files with 1413 additions and 151 deletions
@@ -54,6 +54,9 @@ export const HqOperationAction = {
REDEEM_PENDING_COMPLETE: 'REDEEM_PENDING_COMPLETE',
REDEEM_PENDING_REJECT: 'REDEEM_PENDING_REJECT',
DEPLOY_TRIGGER: 'DEPLOY_TRIGGER',
SYSTEM_CONFIG_UPDATE: 'SYSTEM_CONFIG_UPDATE',
SYSTEM_CONFIG_SYNC_ENV: 'SYSTEM_CONFIG_SYNC_ENV',
SYSTEM_CONFIG_IMPORT_ENV: 'SYSTEM_CONFIG_IMPORT_ENV',
} as const;
export type HqOperationActionCode = (typeof HqOperationAction)[keyof typeof HqOperationAction];
@@ -113,6 +116,9 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
[HqOperationAction.REDEEM_PENDING_COMPLETE]: '弱网待处理单-补核销',
[HqOperationAction.REDEEM_PENDING_REJECT]: '弱网待处理单-驳回',
[HqOperationAction.DEPLOY_TRIGGER]: '触发系统发布',
[HqOperationAction.SYSTEM_CONFIG_UPDATE]: '更新系统配置',
[HqOperationAction.SYSTEM_CONFIG_SYNC_ENV]: '同步系统配置到 env 文件',
[HqOperationAction.SYSTEM_CONFIG_IMPORT_ENV]: '从当前环境导入配置',
STORE_PAYOUT: '门店打款确认',
};
@@ -0,0 +1,30 @@
import { Injectable } from '@nestjs/common';
import type { MockSmsCodeItem } from '@dukang/shared-types';
import { PrismaService } from '../prisma/prisma.module';
const LIST_LIMIT = 50;
@Injectable()
export class MockSmsCodeService {
constructor(private readonly prisma: PrismaService) {}
async record(phone: string, scene: string, code: string) {
await this.prisma.mockSmsCode.create({
data: { phone, scene, code },
});
}
async listRecent(limit = LIST_LIMIT): Promise<MockSmsCodeItem[]> {
const rows = await this.prisma.mockSmsCode.findMany({
orderBy: { createdAt: 'desc' },
take: limit,
});
return rows.map((row) => ({
id: String(row.id),
phone: row.phone,
scene: row.scene,
code: row.code,
createdAt: row.createdAt.toISOString(),
}));
}
}
@@ -0,0 +1,47 @@
import { resolve } from 'path';
import { PrismaClient } from '@prisma/client';
import { SYSTEM_CONFIG_KEY_SET } from './system-config.registry';
function apiRoot() {
return resolve(__dirname, '..', '..');
}
export function resolveEnvFilePath() {
const isProduction = (process.env.NODE_ENV ?? 'development') === 'production';
return resolve(apiRoot(), isProduction ? '.env.production' : '.env');
}
/** 启动前从 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] = 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] = 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,122 @@
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: 'oss', label: '对象存储 OSS' },
{ key: 'courier', label: '同城配送' },
{ key: 'app', label: '应用链接' },
{ key: 'deploy', label: '发布部署' },
];
const G = {
feature: 'feature',
sms: 'sms',
wechat: 'wechat',
oss: 'oss',
courier: 'courier',
app: 'app',
deploy: 'deploy',
} 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: '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: '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: 'COURIER_PROVIDER', label: '配送服务商', group: G.courier, type: 'string', requiresRestart: true, placeholder: 'xiaofeixia' },
{ key: 'XIAOFEIXIA_API_URL', label: '小飞侠 API 地址', group: G.courier, type: 'string', requiresRestart: false },
{ key: 'XIAOFEIXIA_MCH_ID', label: '小飞侠商户号', group: G.courier, type: 'string', requiresRestart: false },
{ key: 'XIAOFEIXIA_API_KEY', label: '小飞侠 API Key', group: G.courier, type: 'password', secret: true, requiresRestart: false },
{ key: 'XIAOFEIXIA_SIGN_TYPE', label: '小飞侠签名类型', group: G.courier, type: 'string', requiresRestart: false, placeholder: 'MD5' },
{ key: 'XIAOFEIXIA_APP_ID', label: '小飞侠 AppID', group: G.courier, type: 'string', requiresRestart: false },
{ key: 'SHIP_FROM_NAME', label: '默认寄件人', group: G.courier, type: 'string', requiresRestart: false },
{ key: 'SHIP_FROM_MOBILE', label: '默认寄件手机', group: G.courier, type: 'string', requiresRestart: false },
{ key: 'SHIP_FROM_ADDRESS', label: '默认寄件地址', group: G.courier, type: 'string', requiresRestart: false },
{ key: 'SHIP_FROM_ADDRESS_DETAIL', label: '默认寄件门牌', group: G.courier, type: 'string', requiresRestart: false },
{ key: 'SHIP_FROM_LNG', label: '默认寄件经度', group: G.courier, type: 'string', requiresRestart: false },
{ key: 'SHIP_FROM_LAT', label: '默认寄件纬度', group: G.courier, type: 'string', 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 },
{ 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 },
];
/** 已从 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',
] 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,221 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { writeFileSync } from 'fs';
import type {
SystemConfigFormResponse,
SystemConfigSyncResult,
SystemConfigUpdateRequest,
} from '@dukang/shared-types';
import { loadAppConfig } 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: { type: string }, raw: string): string {
if (meta.type === 'boolean') {
return raw === 'true' || raw === '1' ? 'true' : 'false';
}
return raw;
}
}
function formatEnvLine(key: string, value: string): string {
if (/[\s#"'\\]/.test(value)) {
return `${key}="${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
}
return `${key}=${value}`;
}