webadmin系统设置
This commit is contained in:
@@ -10,7 +10,6 @@ JWT_SECRET="dukang-prev1-dev-secret-change-in-prod"
|
||||
JWT_EXPIRES_IN="7d"
|
||||
PORT=3000
|
||||
MOCK_SMS=true
|
||||
MOCK_SMS_CODE=123456
|
||||
# MOCK_SMS=false 时必填(可与 OSS 共用 RAM)
|
||||
ALIYUN_SMS_SIGN_NAME=
|
||||
ALIYUN_SMS_TEMPLATE_CODE=
|
||||
@@ -24,10 +23,8 @@ MOCK_PAY=true
|
||||
MOCK_DELIVERY_AUTO=true
|
||||
AUTO_APPROVE_STORE=true
|
||||
# 微信 OAuth Mock(preV1 本地联调):须在微信内置浏览器内打开 H5,走 OAuth 回跳带 mock code;
|
||||
# 非微信浏览器不会发起 /login/wechat 请求。生产请 MOCK_WECHAT=false 且 WECHAT_AUTH_ENABLED=true。
|
||||
# 非微信浏览器不会发起 /login/wechat 请求。生产请 MOCK_WECHAT=false 并配置 WX_APP_ID / WX_APP_SECRET。
|
||||
MOCK_WECHAT=true
|
||||
# 登录后是否走微信 SDK OAuth 授权(本地 false 可仅用短信登录/核销,不影响支付 Mock)
|
||||
WX_AUTHORIZE=false
|
||||
|
||||
# C 端 H5 落地页(推广码二维码链接前缀,USER_H5_URL)
|
||||
# 未配置时默认 https://user.runxian.top/user;本地开发可设为 http://localhost:5173/user
|
||||
@@ -36,7 +33,7 @@ WX_AUTHORIZE=false
|
||||
# 反向代理后提取真实客户端 IP(下单 IP 定位)
|
||||
TRUST_PROXY=true
|
||||
|
||||
# 微信 SDK(生产:WECHAT_AUTH_ENABLED=true,配置 WX_APP_ID / WX_APP_SECRET)
|
||||
# 微信 SDK(生产:MOCK_WECHAT=false,配置 WX_APP_ID / WX_APP_SECRET;支付关闭 MOCK_PAY 并配置商户号)
|
||||
# OAuth 授权页由 /common/wechat/oauth-url 生成;三端统一入口 user.runxian.top/{user,shop,partner}。
|
||||
# 微信服务号「网页授权域名」「JS 接口安全域名」均配置 user.runxian.top(仅 1 个名额)。
|
||||
WX_APP_ID=
|
||||
@@ -45,8 +42,6 @@ WX_APP_SECRET=
|
||||
# 未配置时回退 WX_APP_ID,若与小程序 appid 不同会导致 invalid code
|
||||
WX_MINI_APP_ID=
|
||||
WX_MINI_APP_SECRET=
|
||||
WECHAT_AUTH_ENABLED=false
|
||||
WECHAT_PAY_ENABLED=false
|
||||
WX_MCH_ID=
|
||||
WX_MCH_SERIAL_NO=
|
||||
WX_MCH_PRIVATE_KEY=
|
||||
@@ -58,10 +53,9 @@ WX_PAY_NOTIFY_URL=https://dkapi.runxian.top/api/v1/callbacks/wechat/pay
|
||||
# 腾讯位置服务(逆地理编码,微信定位展示城市)
|
||||
TENCENT_LBS_KEY=
|
||||
|
||||
# 阿里云 OSS(ali-oss@6.x;OSS_ENABLED=true 且下方密钥齐全时生效;否则 Mock 占位 URL)
|
||||
# 阿里云 OSS(ali-oss@6.x;凭证齐全时直传,缺失则服务端报错)
|
||||
# RAM 用户需具备 PutObject 权限;可在控制台 Bucket 授权策略中为该 RAM UID 授予读写
|
||||
# 文档:https://help.aliyun.com/zh/oss/user-guide/use-bucket-policy-to-grant-permission-to-access-oss/
|
||||
OSS_ENABLED=false
|
||||
OSS_ACCESS_KEY_ID=
|
||||
OSS_ACCESS_KEY_SECRET=
|
||||
OSS_BUCKET=
|
||||
|
||||
@@ -11,7 +11,6 @@ JWT_EXPIRES_IN="7d"
|
||||
PORT=8090
|
||||
|
||||
MOCK_SMS=false
|
||||
MOCK_SMS_CODE=
|
||||
ALIYUN_SMS_SIGN_NAME=
|
||||
ALIYUN_SMS_TEMPLATE_CODE=
|
||||
# 核销确认(REDEEM_PHONE_CONFIRM)
|
||||
@@ -29,10 +28,7 @@ TRUST_PROXY=true
|
||||
# C 端 H5 落地页(推广码二维码;生产统一入口)
|
||||
USER_H5_URL=https://user.runxian.top/user
|
||||
|
||||
WECHAT_AUTH_ENABLED=true
|
||||
WECHAT_PAY_ENABLED=true
|
||||
# 登录后走微信 SDK OAuth 授权(生产/预发建议 true)
|
||||
WX_AUTHORIZE=true
|
||||
MOCK_WECHAT=false
|
||||
WX_APP_ID=
|
||||
WX_APP_SECRET=
|
||||
WX_MCH_ID=
|
||||
@@ -42,7 +38,6 @@ WX_API_V3_KEY=
|
||||
WX_PLATFORM_CERT=
|
||||
WX_PAY_NOTIFY_URL=https://dkapi.runxian.top/api/v1/callbacks/wechat/pay
|
||||
|
||||
OSS_ENABLED=true
|
||||
OSS_ACCESS_KEY_ID=
|
||||
OSS_ACCESS_KEY_SECRET=
|
||||
OSS_BUCKET=dukang-dev
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"start": "node dist/main",
|
||||
"lint": "eslint src",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:push": "prisma db push",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:validate": "prisma validate",
|
||||
"prisma:seed": "ts-node --transpile-only prisma/seed-v31.ts",
|
||||
|
||||
@@ -277,6 +277,27 @@ model SystemVersion {
|
||||
@@map("system_version")
|
||||
}
|
||||
|
||||
model SystemConfig {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
configKey String @unique @map("config_key") @db.VarChar(128)
|
||||
value String @db.Text
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
@@map("system_config")
|
||||
}
|
||||
|
||||
model MockSmsCode {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
phone String @db.VarChar(20)
|
||||
scene String @db.VarChar(64)
|
||||
code String @db.VarChar(8)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
|
||||
@@index([createdAt])
|
||||
@@map("mock_sms_code")
|
||||
}
|
||||
|
||||
model CommonWxAppConfig {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
clientApp ClientApp @unique @map("client_app")
|
||||
|
||||
@@ -19,6 +19,7 @@ import { PromoModule } from './modules/promo/promo.module';
|
||||
import { CityScopeModule } from './modules/city-scope/city-scope.module';
|
||||
import { CommonModule } from './modules/common/common.module';
|
||||
import { HqOperationModule } from './common/hq-operation/hq-operation.module';
|
||||
import { SystemConfigModule } from './common/system-config/system-config.module';
|
||||
import { CallbacksModule } from './callbacks/callbacks.module';
|
||||
|
||||
@Module({
|
||||
@@ -30,6 +31,7 @@ import { CallbacksModule } from './callbacks/callbacks.module';
|
||||
},
|
||||
}),
|
||||
PrismaModule,
|
||||
SystemConfigModule,
|
||||
GeoModule,
|
||||
RedisModule,
|
||||
HealthModule,
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -1,16 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { loadAppConfig } from '@dukang/shared-types';
|
||||
import { SmsCodeStore } from './sms/sms-code.store';
|
||||
import { SmsMockProvider } from './sms/sms.mock.provider';
|
||||
import { SmsAliyunProvider } from './sms/sms.aliyun.provider';
|
||||
import { SmsRouterProvider } from './sms/sms.router.provider';
|
||||
import { PayMockProvider } from './pay/pay.mock.provider';
|
||||
import { PayWechatProvider } from './pay/pay.wechat.provider';
|
||||
import { PayRouterProvider } from './pay/pay.router.provider';
|
||||
import { DeliveryMockProvider } from './delivery/delivery.mock.provider';
|
||||
import { WechatApiProvider } from './wechat/wechat.api.provider';
|
||||
import { WechatDisabledProvider } from './wechat/wechat.disabled.provider';
|
||||
import { WechatMockProvider } from './wechat/wechat.mock.provider';
|
||||
import { OssMockProvider } from './oss/oss.mock.provider';
|
||||
import { WechatRouterProvider } from './wechat/wechat.router.provider';
|
||||
import { OssAliyunProvider } from './oss/oss.aliyun.provider';
|
||||
import { TencentLbsProvider } from './map/tencent-lbs.provider';
|
||||
import {
|
||||
@@ -23,10 +24,6 @@ import {
|
||||
} from './integrations.constants';
|
||||
import { CourierModule } from './courier/courier.module';
|
||||
import { DELIVERY_QUEUE } from '../jobs/jobs.constants';
|
||||
import type { IWechatProvider } from './wechat/wechat.interface';
|
||||
import type { IPayProvider } from './pay/pay.interface';
|
||||
import type { IOssProvider } from './oss/oss.interface';
|
||||
import type { ISmsProvider } from './sms/sms.interface';
|
||||
|
||||
@Module({
|
||||
imports: [BullModule.registerQueue({ name: DELIVERY_QUEUE }), CourierModule],
|
||||
@@ -34,60 +31,20 @@ import type { ISmsProvider } from './sms/sms.interface';
|
||||
SmsCodeStore,
|
||||
SmsMockProvider,
|
||||
SmsAliyunProvider,
|
||||
{
|
||||
provide: SMS_PROVIDER,
|
||||
useFactory: (mock: SmsMockProvider, aliyun: SmsAliyunProvider): ISmsProvider => {
|
||||
const cfg = loadAppConfig();
|
||||
if (cfg.mockSms) return mock;
|
||||
if (!aliyun.isEnabled()) {
|
||||
throw new Error('MOCK_SMS=false but Aliyun SMS credentials are missing');
|
||||
}
|
||||
return aliyun;
|
||||
},
|
||||
inject: [SmsMockProvider, SmsAliyunProvider],
|
||||
},
|
||||
SmsRouterProvider,
|
||||
{ provide: SMS_PROVIDER, useExisting: SmsRouterProvider },
|
||||
WechatApiProvider,
|
||||
WechatDisabledProvider,
|
||||
WechatMockProvider,
|
||||
{
|
||||
provide: WECHAT_PROVIDER,
|
||||
useFactory: (
|
||||
api: WechatApiProvider,
|
||||
disabled: WechatDisabledProvider,
|
||||
mock: WechatMockProvider,
|
||||
): IWechatProvider => {
|
||||
const cfg = loadAppConfig();
|
||||
const enabled =
|
||||
(cfg.wechatAuthEnabled || cfg.wechatPayEnabled) &&
|
||||
(!!cfg.wxAppId || !!process.env.WX_MCH_ID);
|
||||
if (enabled) return api;
|
||||
// preV1:真实微信未配置但开启 Mock 授权登录
|
||||
if (cfg.mockWechat) return mock;
|
||||
return disabled;
|
||||
},
|
||||
inject: [WechatApiProvider, WechatDisabledProvider, WechatMockProvider],
|
||||
},
|
||||
WechatRouterProvider,
|
||||
{ provide: WECHAT_PROVIDER, useExisting: WechatRouterProvider },
|
||||
PayMockProvider,
|
||||
PayWechatProvider,
|
||||
{
|
||||
provide: PAY_PROVIDER,
|
||||
useFactory: (mock: PayMockProvider, wechat: PayWechatProvider): IPayProvider => {
|
||||
const cfg = loadAppConfig();
|
||||
return cfg.mockPay ? mock : wechat;
|
||||
},
|
||||
inject: [PayMockProvider, PayWechatProvider],
|
||||
},
|
||||
PayRouterProvider,
|
||||
{ provide: PAY_PROVIDER, useExisting: PayRouterProvider },
|
||||
{ provide: DELIVERY_PROVIDER, useClass: DeliveryMockProvider },
|
||||
OssMockProvider,
|
||||
OssAliyunProvider,
|
||||
{
|
||||
provide: OSS_PROVIDER,
|
||||
useFactory: (mock: OssMockProvider, aliyun: OssAliyunProvider): IOssProvider => {
|
||||
const cfg = loadAppConfig();
|
||||
return cfg.ossEnabled && aliyun.isEnabled() ? aliyun : mock;
|
||||
},
|
||||
inject: [OssMockProvider, OssAliyunProvider],
|
||||
},
|
||||
{ provide: OSS_PROVIDER, useExisting: OssAliyunProvider },
|
||||
DeliveryMockProvider,
|
||||
TencentLbsProvider,
|
||||
{ provide: MAP_PROVIDER, useExisting: TencentLbsProvider },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, InternalServerErrorException } from '@nestjs/common';
|
||||
import type {
|
||||
IOssProvider,
|
||||
OssPutObjectInput,
|
||||
@@ -30,10 +30,15 @@ export class OssAliyunProvider implements IOssProvider {
|
||||
return !!(this.accessKeyId && this.accessKeySecret && this.bucket);
|
||||
}
|
||||
|
||||
private assertConfigured() {
|
||||
if (this.isEnabled()) return;
|
||||
throw new InternalServerErrorException(
|
||||
'OSS 未配置:请设置 OSS_ACCESS_KEY_ID、OSS_ACCESS_KEY_SECRET、OSS_BUCKET(及 OSS_REGION)',
|
||||
);
|
||||
}
|
||||
|
||||
private getClient() {
|
||||
if (!this.isEnabled()) {
|
||||
throw new Error('OSS credentials are not configured');
|
||||
}
|
||||
this.assertConfigured();
|
||||
if (!this.client) {
|
||||
this.client = createAliyunOssClient({
|
||||
accessKeyId: this.accessKeyId,
|
||||
@@ -49,14 +54,8 @@ export class OssAliyunProvider implements IOssProvider {
|
||||
|
||||
buildPublicUrl(ossKey: string) {
|
||||
const key = ossKey.replace(/^\//, '');
|
||||
const client = this.isEnabled() ? this.getClient() : null;
|
||||
if (client) {
|
||||
return client.generateObjectUrl(key, this.cdnBase || undefined);
|
||||
}
|
||||
if (this.cdnBase) {
|
||||
return `${this.cdnBase.replace(/\/$/, '')}/${key}`;
|
||||
}
|
||||
return `${resolveOssUploadHost(this.bucket, this.region)}/${key}`;
|
||||
const client = this.getClient();
|
||||
return client.generateObjectUrl(key, this.cdnBase || undefined);
|
||||
}
|
||||
|
||||
getUploadToken(dto: OssUploadTokenInput): OssUploadTokenResult {
|
||||
|
||||
@@ -4,10 +4,8 @@ import type { IPayProvider, PayOrderResult } from './pay.interface';
|
||||
|
||||
@Injectable()
|
||||
export class PayMockProvider implements IPayProvider {
|
||||
private readonly config = loadAppConfig();
|
||||
|
||||
async payOrder(_orderId: bigint, _openId?: string): Promise<PayOrderResult> {
|
||||
if (!this.config.mockPay) {
|
||||
if (!loadAppConfig().mockPay) {
|
||||
throw new Error('Real WeChat pay requires PayWechatProvider');
|
||||
}
|
||||
return { mode: 'mock', externalNo: `MOCK-${Date.now()}` };
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { loadAppConfig } from '@dukang/shared-types';
|
||||
import type { IPayProvider, PayOrderResult } from './pay.interface';
|
||||
import { PayMockProvider } from './pay.mock.provider';
|
||||
import { PayWechatProvider } from './pay.wechat.provider';
|
||||
|
||||
/** 按当前 process.env 动态选择 Mock / 微信 JSAPI 支付 */
|
||||
@Injectable()
|
||||
export class PayRouterProvider implements IPayProvider {
|
||||
constructor(
|
||||
private readonly mock: PayMockProvider,
|
||||
private readonly wechat: PayWechatProvider,
|
||||
) {}
|
||||
|
||||
private resolve(): IPayProvider {
|
||||
return loadAppConfig().mockPay ? this.mock : this.wechat;
|
||||
}
|
||||
|
||||
payOrder(orderId: bigint, openId?: string): Promise<PayOrderResult> {
|
||||
return this.resolve().payOrder(orderId, openId);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import type { IPayProvider, PayOrderResult } from './pay.interface';
|
||||
@Injectable()
|
||||
export class PayWechatProvider implements IPayProvider {
|
||||
private readonly logger = new Logger(PayWechatProvider.name);
|
||||
private readonly config = loadAppConfig();
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@@ -16,11 +15,11 @@ export class PayWechatProvider implements IPayProvider {
|
||||
) {}
|
||||
|
||||
async payOrder(orderId: bigint, openId?: string): Promise<PayOrderResult> {
|
||||
if (this.config.mockPay) {
|
||||
if (loadAppConfig().mockPay) {
|
||||
return { mode: 'mock', externalNo: `MOCK-${Date.now()}` };
|
||||
}
|
||||
if (!this.wechat.isPayEnabled()) {
|
||||
throw new Error('微信支付未配置:请设置 WECHAT_PAY_ENABLED=true 与 WX_MCH_ID 等商户参数');
|
||||
throw new Error('微信支付未配置:请关闭 MOCK_PAY 并配置 WX_MCH_ID 等商户参数');
|
||||
}
|
||||
if (!openId) {
|
||||
throw new Error('微信支付需要用户 openId,请先完成微信授权登录');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { SMS_CODE_TTL_SECONDS, loadAppConfig } from '@dukang/shared-types';
|
||||
import { SMS_CODE_TTL_SECONDS } from '@dukang/shared-types';
|
||||
import { RedisService } from '../../common/redis/redis.service';
|
||||
|
||||
const RATE_TTL_SECONDS = 60;
|
||||
@@ -18,8 +18,6 @@ function randomSixDigitCode() {
|
||||
|
||||
@Injectable()
|
||||
export class SmsCodeStore {
|
||||
private readonly config = loadAppConfig();
|
||||
|
||||
constructor(private readonly redis: RedisService) {}
|
||||
|
||||
async assertSendCooldown(phone: string) {
|
||||
@@ -34,7 +32,7 @@ export class SmsCodeStore {
|
||||
}
|
||||
|
||||
async generateAndStore(phone: string, scene: string): Promise<string> {
|
||||
const code = this.config.mockSms ? this.config.mockSmsCode : randomSixDigitCode();
|
||||
const code = randomSixDigitCode();
|
||||
await this.redis.client.set(codeKey(phone, scene), code, 'EX', SMS_CODE_TTL_SECONDS);
|
||||
return code;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { MockSmsCodeService } from '../../common/mock-sms-code/mock-sms-code.service';
|
||||
import type { ISmsProvider, SmsActorRef, SmsSendResult } from './sms.interface';
|
||||
import { SmsCodeStore } from './sms-code.store';
|
||||
|
||||
@@ -14,10 +15,12 @@ export class SmsMockProvider implements ISmsProvider {
|
||||
constructor(
|
||||
private readonly smsCodeStore: SmsCodeStore,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly mockSmsCodeService: MockSmsCodeService,
|
||||
) {}
|
||||
|
||||
async send(phone: string, scene: string, actorRef?: SmsActorRef): Promise<SmsSendResult> {
|
||||
const code = await this.smsCodeStore.generateAndStore(phone, scene);
|
||||
await this.mockSmsCodeService.record(phone, scene, code);
|
||||
const masked = maskPhone(phone);
|
||||
this.logger.log(`Mock SMS → ${masked} scene=${scene} code=${code}`);
|
||||
|
||||
@@ -28,7 +31,7 @@ export class SmsMockProvider implements ISmsProvider {
|
||||
refType: actorRef?.refType,
|
||||
refId: actorRef?.refId,
|
||||
requestBody: { phone: masked, mode: 'MOCK', scene },
|
||||
responseBody: { mock: true, hint: 'use MOCK_SMS_CODE or check server log' },
|
||||
responseBody: { mock: true, hint: 'see HQ system settings mock SMS list' },
|
||||
status: 'SUCCESS',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { loadAppConfig } from '@dukang/shared-types';
|
||||
import type { ISmsProvider, SmsActorRef, SmsSendResult } from './sms.interface';
|
||||
import { SmsMockProvider } from './sms.mock.provider';
|
||||
import { SmsAliyunProvider } from './sms.aliyun.provider';
|
||||
|
||||
/** 按当前 process.env 动态选择 Mock / 阿里云短信 */
|
||||
@Injectable()
|
||||
export class SmsRouterProvider implements ISmsProvider {
|
||||
constructor(
|
||||
private readonly mock: SmsMockProvider,
|
||||
private readonly aliyun: SmsAliyunProvider,
|
||||
) {}
|
||||
|
||||
private resolve(): ISmsProvider {
|
||||
const cfg = loadAppConfig();
|
||||
if (cfg.mockSms) return this.mock;
|
||||
if (!this.aliyun.isEnabled()) {
|
||||
throw new Error('MOCK_SMS=false but Aliyun SMS credentials are missing');
|
||||
}
|
||||
return this.aliyun;
|
||||
}
|
||||
|
||||
send(phone: string, scene: string, actorRef?: SmsActorRef): Promise<SmsSendResult> {
|
||||
return this.resolve().send(phone, scene, actorRef);
|
||||
}
|
||||
|
||||
verify(phone: string, code: string, scene: string): Promise<void> {
|
||||
return this.resolve().verify(phone, code, scene);
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,6 @@ const JSAPI_TICKET_KEY = 'wechat:jsapi_ticket';
|
||||
@Injectable()
|
||||
export class WechatApiProvider implements IWechatProvider {
|
||||
private readonly logger = new Logger(WechatApiProvider.name);
|
||||
private readonly config = loadAppConfig();
|
||||
private readonly appId = process.env.WX_APP_ID ?? '';
|
||||
private readonly appSecret = process.env.WX_APP_SECRET ?? '';
|
||||
/** 小程序独立凭证;未配置时回退公众号/H5 的 WX_APP_ID(须与开发者工具 appid 一致) */
|
||||
@@ -39,7 +38,8 @@ export class WechatApiProvider implements IWechatProvider {
|
||||
) {}
|
||||
|
||||
isEnabled() {
|
||||
return this.config.wechatAuthEnabled && !!this.appId && !!this.appSecret;
|
||||
const cfg = loadAppConfig();
|
||||
return !cfg.mockWechat && !!this.appId && !!this.appSecret;
|
||||
}
|
||||
|
||||
isMock() {
|
||||
@@ -47,8 +47,9 @@ export class WechatApiProvider implements IWechatProvider {
|
||||
}
|
||||
|
||||
isPayEnabled() {
|
||||
const cfg = loadAppConfig();
|
||||
return (
|
||||
this.config.wechatPayEnabled &&
|
||||
!cfg.mockPay &&
|
||||
!!this.appId &&
|
||||
!!this.mchId &&
|
||||
!!this.mchSerialNo &&
|
||||
@@ -290,7 +291,7 @@ export class WechatApiProvider implements IWechatProvider {
|
||||
}) {
|
||||
if (!this.isPayEnabled()) {
|
||||
throw new InternalServerErrorException(
|
||||
'微信支付未配置:请设置 WECHAT_PAY_ENABLED=true、WX_MCH_ID、WX_MCH_SERIAL_NO、WX_MCH_PRIVATE_KEY、WX_API_V3_KEY',
|
||||
'微信支付未配置:请关闭 MOCK_PAY 并配置 WX_MCH_ID、WX_MCH_SERIAL_NO、WX_MCH_PRIVATE_KEY、WX_API_V3_KEY',
|
||||
);
|
||||
}
|
||||
const notifyUrl = params.notifyUrl || this.notifyUrl;
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { loadAppConfig, needsRealWechatApi } from '@dukang/shared-types';
|
||||
import type { IWechatProvider } from './wechat.interface';
|
||||
import { WechatApiProvider } from './wechat.api.provider';
|
||||
import { WechatDisabledProvider } from './wechat.disabled.provider';
|
||||
import { WechatMockProvider } from './wechat.mock.provider';
|
||||
|
||||
/** 按当前 process.env 动态选择 Mock / 真实微信 / 禁用 */
|
||||
@Injectable()
|
||||
export class WechatRouterProvider implements IWechatProvider {
|
||||
constructor(
|
||||
private readonly api: WechatApiProvider,
|
||||
private readonly disabled: WechatDisabledProvider,
|
||||
private readonly mock: WechatMockProvider,
|
||||
) {}
|
||||
|
||||
private resolve(): IWechatProvider {
|
||||
const cfg = loadAppConfig();
|
||||
if (needsRealWechatApi(cfg)) return this.api;
|
||||
if (cfg.mockWechat) return this.mock;
|
||||
return this.disabled;
|
||||
}
|
||||
|
||||
isEnabled() {
|
||||
return this.resolve().isEnabled();
|
||||
}
|
||||
|
||||
isMock() {
|
||||
return this.resolve().isMock();
|
||||
}
|
||||
|
||||
isPayEnabled() {
|
||||
return this.resolve().isPayEnabled();
|
||||
}
|
||||
|
||||
getMchId() {
|
||||
return this.resolve().getMchId();
|
||||
}
|
||||
|
||||
code2Session(code: string, actorRef?: { refType: string; refId: bigint }) {
|
||||
return this.resolve().code2Session(code, actorRef);
|
||||
}
|
||||
|
||||
oauth2AccessToken(code: string, actorRef?: { refType: string; refId: bigint }) {
|
||||
return this.resolve().oauth2AccessToken(code, actorRef);
|
||||
}
|
||||
|
||||
fetchOAuthUserInfo(
|
||||
accessToken: string,
|
||||
openId: string,
|
||||
actorRef?: { refType: string; refId: bigint },
|
||||
) {
|
||||
return this.resolve().fetchOAuthUserInfo(accessToken, openId, actorRef);
|
||||
}
|
||||
|
||||
createJssdkConfig(url: string, actorRef?: { refType: string; refId: bigint }) {
|
||||
return this.resolve().createJssdkConfig(url, actorRef);
|
||||
}
|
||||
|
||||
buildOAuthUrl(redirectUri: string, state: string, scope?: string) {
|
||||
return this.resolve().buildOAuthUrl(redirectUri, state, scope);
|
||||
}
|
||||
|
||||
getPhoneNumberByCode(
|
||||
code: string,
|
||||
platform: 'mini' | 'h5',
|
||||
actorRef?: { refType: string; refId: bigint },
|
||||
) {
|
||||
return this.resolve().getPhoneNumberByCode(code, platform, actorRef);
|
||||
}
|
||||
|
||||
createJsapiPrepay(params: {
|
||||
orderNo: string;
|
||||
description: string;
|
||||
amountFen: number;
|
||||
openId: string;
|
||||
notifyUrl: string;
|
||||
}) {
|
||||
return this.resolve().createJsapiPrepay(params);
|
||||
}
|
||||
|
||||
parsePayNotification(
|
||||
headers: Record<string, string | string[] | undefined>,
|
||||
rawBody: string,
|
||||
) {
|
||||
return this.resolve().parsePayNotification(headers, rawBody);
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,17 @@ import { json } from 'express';
|
||||
import { AppModule } from './app.module';
|
||||
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
|
||||
import { preloadSystemConfigEnv } from './common/system-config/system-config.env';
|
||||
|
||||
async function bootstrap() {
|
||||
const preloaded = await preloadSystemConfigEnv().catch((e) => {
|
||||
console.warn('[config] system_config preload skipped:', e instanceof Error ? e.message : e);
|
||||
return 0;
|
||||
});
|
||||
if (preloaded > 0) {
|
||||
console.log(`[config] loaded ${preloaded} keys from system_config`);
|
||||
}
|
||||
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, { bodyParser: false });
|
||||
app.setGlobalPrefix('api/v1');
|
||||
app.set('trust proxy', true);
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { loadAppConfig } from '@dukang/shared-types';
|
||||
import { SystemConfigService } from '../../common/system-config/system-config.service';
|
||||
|
||||
@Controller('common')
|
||||
export class ClientConfigController {
|
||||
constructor(private readonly systemConfig: SystemConfigService) {}
|
||||
|
||||
@Get('client-config')
|
||||
clientConfig() {
|
||||
const cfg = loadAppConfig();
|
||||
const cfg = this.systemConfig.getAppConfig();
|
||||
return {
|
||||
mockPay: cfg.mockPay,
|
||||
wechatPayEnabled: cfg.wechatPayEnabled,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Module, forwardRef } from '@nestjs/common';
|
||||
import { IamModule } from '../iam/iam.module';
|
||||
import { AnalyticsModule } from '../analytics/analytics.module';
|
||||
import { IntegrationsModule } from '../../integrations/integrations.module';
|
||||
import { SystemConfigModule } from '../../common/system-config/system-config.module';
|
||||
import { ResourceService } from './resource.service';
|
||||
import { EventService } from './event.service';
|
||||
import { TicketService } from './ticket.service';
|
||||
@@ -15,7 +16,7 @@ import { ClientConfigController } from './client-config.controller';
|
||||
import { WechatLocationService } from './wechat-location.service';
|
||||
|
||||
@Module({
|
||||
imports: [IamModule, IntegrationsModule, forwardRef(() => AnalyticsModule)],
|
||||
imports: [IamModule, IntegrationsModule, SystemConfigModule, forwardRef(() => AnalyticsModule)],
|
||||
controllers: [
|
||||
ResourceController,
|
||||
EventController,
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Body, Controller, Get, Post, Put, UseGuards } from '@nestjs/common';
|
||||
import type { SystemConfigUpdateRequest } from '@dukang/shared-types';
|
||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||
import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
|
||||
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
||||
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
||||
import { SystemConfigService } from '../../common/system-config/system-config.service';
|
||||
|
||||
@Controller('admin/system-config')
|
||||
@UseGuards(HqAuthGuard, SuperAdminGuard)
|
||||
export class AdminSystemConfigController {
|
||||
constructor(private readonly systemConfig: SystemConfigService) {}
|
||||
|
||||
@Get()
|
||||
getForm() {
|
||||
return this.systemConfig.getForm();
|
||||
}
|
||||
|
||||
@Put()
|
||||
@HqOperation({
|
||||
action: HqOperationAction.SYSTEM_CONFIG_UPDATE,
|
||||
refType: 'SYSTEM_CONFIG',
|
||||
refIdField: 'id',
|
||||
batch: true,
|
||||
includeBody: true,
|
||||
})
|
||||
update(@Body() dto: SystemConfigUpdateRequest) {
|
||||
return this.systemConfig.update(dto);
|
||||
}
|
||||
|
||||
@Post('sync-env')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.SYSTEM_CONFIG_SYNC_ENV,
|
||||
refType: 'SYSTEM_CONFIG',
|
||||
batch: true,
|
||||
})
|
||||
syncEnv() {
|
||||
return this.systemConfig.syncToEnvFile();
|
||||
}
|
||||
|
||||
@Post('import-env')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.SYSTEM_CONFIG_IMPORT_ENV,
|
||||
refType: 'SYSTEM_CONFIG',
|
||||
batch: true,
|
||||
})
|
||||
importEnv() {
|
||||
return this.systemConfig.importFromProcessEnv();
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,7 @@ import { AdminHqPermissionsController } from './admin-hq-permissions.controller'
|
||||
import { AdminHqPermissionsService } from './admin-hq-permissions.service';
|
||||
import { AdminDeployController } from './admin-deploy.controller';
|
||||
import { AdminDeployService } from './admin-deploy.service';
|
||||
import { AdminSystemConfigController } from './admin-system-config.controller';
|
||||
|
||||
@Module({
|
||||
imports: [CityScopeModule, IamModule, TradeModule, BenefitModule, CommonModule, IntegrationsModule, RedeemModule],
|
||||
@@ -87,6 +88,7 @@ import { AdminDeployService } from './admin-deploy.service';
|
||||
AdminRedeemPendingController,
|
||||
AdminWechatBindingsController,
|
||||
AdminHqPermissionsController,
|
||||
AdminSystemConfigController,
|
||||
],
|
||||
providers: [
|
||||
AdminDashboardService,
|
||||
|
||||
Reference in New Issue
Block a user