diff --git a/= b/=
deleted file mode 100644
index e69de29..0000000
diff --git a/apps/admin-web/src/App.tsx b/apps/admin-web/src/App.tsx
index e345e66..9b78d8b 100644
--- a/apps/admin-web/src/App.tsx
+++ b/apps/admin-web/src/App.tsx
@@ -36,6 +36,7 @@ import StoreLogsPage from './pages/StoreLogsPage';
import PartnerLogsPage from './pages/PartnerLogsPage';
import WechatBindingsPage from './pages/WechatBindingsPage';
import HqPermissionsPage from './pages/HqPermissionsPage';
+import SystemSettingsPage from './pages/SystemSettingsPage';
function RequireAuth({ children }: { children: React.ReactNode }) {
if (!getToken()) return ;
@@ -90,6 +91,7 @@ export default function App() {
} />
} />
} />
+ } />
} />
} />
diff --git a/apps/admin-web/src/components/OssUpload.tsx b/apps/admin-web/src/components/OssUpload.tsx
index 7196b8e..363d1f3 100644
--- a/apps/admin-web/src/components/OssUpload.tsx
+++ b/apps/admin-web/src/components/OssUpload.tsx
@@ -45,11 +45,7 @@ export default function OssUpload({
const result = await uploadFileToOss(raw, { bizType, mediaType });
onChange?.(result.url);
onUploaded?.(result);
- if (result.mock) {
- message.info('当前为 Mock OSS,已使用占位 URL');
- } else {
- message.success('上传成功');
- }
+ message.success('上传成功');
onSuccess?.(result);
} catch (e) {
const err = e instanceof Error ? e : new Error('上传失败');
diff --git a/apps/admin-web/src/layouts/AdminLayout.tsx b/apps/admin-web/src/layouts/AdminLayout.tsx
index b841023..2c72049 100644
--- a/apps/admin-web/src/layouts/AdminLayout.tsx
+++ b/apps/admin-web/src/layouts/AdminLayout.tsx
@@ -15,6 +15,7 @@ import {
CloudUploadOutlined,
FileTextOutlined,
LockOutlined,
+ SettingOutlined,
} from '@ant-design/icons';
import { clearAuth, request, type HqProfile } from '../lib/api';
import { bindAdminEllipsisTitle } from '../lib/ellipsis-title';
@@ -94,6 +95,7 @@ const MENU_ITEMS: MenuProps['items'] = [
],
},
{ key: '/hq-permissions', icon: , label: '权限分配' },
+ { key: '/system-settings', icon: , label: '系统设置' },
{ key: '/hq-accounts', icon: , label: 'HQ账户' },
];
diff --git a/apps/admin-web/src/pages/SystemSettingsPage.tsx b/apps/admin-web/src/pages/SystemSettingsPage.tsx
new file mode 100644
index 0000000..d5ca185
--- /dev/null
+++ b/apps/admin-web/src/pages/SystemSettingsPage.tsx
@@ -0,0 +1,573 @@
+import { useEffect, useMemo, useState, type ReactNode } from 'react';
+
+import {
+
+ Alert,
+
+ Button,
+
+ Card,
+
+ Collapse,
+
+ Form,
+
+ Input,
+
+ InputNumber,
+
+ Space,
+
+ Switch,
+
+ Table,
+
+ Tag,
+
+ Typography,
+
+ message,
+
+} from 'antd';
+
+import type { MockSmsCodeItem, SystemConfigFieldMeta, SystemConfigFormResponse } from '@dukang/shared-types';
+
+import { request } from '../lib/api';
+
+
+
+const { TextArea } = Input;
+
+
+
+function MockSmsCodePanel({ codes, loading }: { codes: MockSmsCodeItem[]; loading?: boolean }) {
+
+ return (
+
+
+
+
+
+ 最近 Mock 验证码(写入数据库,最新 50 条)
+
+
+
+
+
+ size="small"
+
+ rowKey="id"
+
+ loading={loading}
+
+ pagination={false}
+
+ scroll={{ y: 240 }}
+
+ locale={{ emptyText: '暂无记录,触发短信发送后将显示在此' }}
+
+ columns={[
+
+ {
+
+ title: '时间',
+
+ dataIndex: 'createdAt',
+
+ width: 168,
+
+ render: (v: string) => new Date(v).toLocaleString(),
+
+ },
+
+ { title: '手机号', dataIndex: 'phone', width: 120 },
+
+ { title: '场景', dataIndex: 'scene', width: 160 },
+
+ {
+
+ title: '验证码',
+
+ dataIndex: 'code',
+
+ width: 88,
+
+ render: (code: string) => (
+
+
+
+ {code}
+
+
+
+ ),
+
+ },
+
+ ]}
+
+ dataSource={codes}
+
+ />
+
+
+
+ );
+
+}
+
+
+
+function renderField(
+
+ field: SystemConfigFieldMeta,
+
+ configuredSecrets: string[],
+
+ extra?: ReactNode,
+
+) {
+
+ const isConfiguredSecret = field.secret && configuredSecrets.includes(field.key);
+
+ if (field.type === 'boolean') {
+
+ return (
+
+
+
+
+
+ {field.label}
+
+
+
+ {field.key}
+
+
+
+ {field.requiresRestart ? 需重启 : 即时}
+
+
+
+ }
+
+ tooltip={field.description}
+
+ valuePropName="checked"
+
+ getValueFromEvent={(checked: boolean) => (checked ? 'true' : 'false')}
+
+ getValueProps={(v: string) => ({ checked: v === 'true' || v === '1' })}
+
+ >
+
+
+
+
+
+ {extra}
+
+
+
+ );
+
+ }
+
+
+
+ const input =
+
+ field.type === 'textarea' ? (
+
+
+
+ ) : field.type === 'number' ? (
+
+
+
+ ) : field.secret ? (
+
+
+
+ ) : (
+
+
+
+ );
+
+
+
+ return (
+
+
+
+ {field.label}
+
+
+
+ {field.key}
+
+
+
+ {field.requiresRestart ? 需重启 : 即时}
+
+ {isConfiguredSecret ? 已配置 : null}
+
+
+
+ }
+
+ tooltip={field.description}
+
+ >
+
+ {input}
+
+
+
+ );
+
+}
+
+
+
+export default function SystemSettingsPage() {
+
+ const [form] = Form.useForm>();
+
+ const [meta, setMeta] = useState(null);
+
+ const [loading, setLoading] = useState(true);
+
+ const [saving, setSaving] = useState(false);
+
+ const [syncing, setSyncing] = useState(false);
+
+ const mockSmsEnabled = Form.useWatch('MOCK_SMS', form) === 'true';
+
+
+
+ async function load(silent = false) {
+
+ if (!silent) setLoading(true);
+
+ try {
+
+ const data = await request('/admin/system-config');
+
+ setMeta(data);
+
+ form.setFieldsValue(data.values);
+
+ } catch (e) {
+
+ if (!silent) message.error(e instanceof Error ? e.message : '加载失败');
+
+ } finally {
+
+ if (!silent) setLoading(false);
+
+ }
+
+ }
+
+
+
+ useEffect(() => {
+
+ void load();
+
+ }, []);
+
+
+
+ useEffect(() => {
+
+ if (!mockSmsEnabled) return;
+
+ const timer = window.setInterval(() => void load(true), 5000);
+
+ return () => window.clearInterval(timer);
+
+ }, [mockSmsEnabled]);
+
+
+
+ const collapseItems = useMemo(() => {
+
+ if (!meta) return [];
+
+ return meta.groups.map((group) => ({
+
+ key: group.key,
+
+ label: group.label,
+
+ children: (
+
+
+
+ {meta.fields
+
+ .filter((f) => f.group === group.key)
+
+ .map((f) =>
+
+ renderField(
+
+ f,
+
+ meta.configuredSecrets,
+
+ f.key === 'MOCK_SMS' && mockSmsEnabled ? (
+
+
+
+ ) : undefined,
+
+ ),
+
+ )}
+
+
+
+ ),
+
+ }));
+
+ }, [meta, mockSmsEnabled, loading]);
+
+
+
+ async function onSave() {
+
+ const values = await form.validateFields();
+
+ const payload: Record = {};
+
+ for (const [k, v] of Object.entries(values)) {
+
+ payload[k] = v === undefined || v === null ? '' : String(v);
+
+ }
+
+ setSaving(true);
+
+ try {
+
+ const res = await request<{ updatedKeys: string[]; requiresRestartKeys: string[] }>(
+
+ '/admin/system-config',
+
+ { method: 'PUT', body: JSON.stringify({ values: payload }) },
+
+ );
+
+ message.success(`已保存 ${res.updatedKeys.length} 项`);
+
+ if (res.requiresRestartKeys.length) {
+
+ message.warning(`以下配置需重启 API 后生效:${res.requiresRestartKeys.join(', ')}`);
+
+ }
+
+ await load();
+
+ } catch (e) {
+
+ message.error(e instanceof Error ? e.message : '保存失败');
+
+ } finally {
+
+ setSaving(false);
+
+ }
+
+ }
+
+
+
+ async function onSyncEnv() {
+
+ setSyncing(true);
+
+ try {
+
+ const res = await request<{ message: string; envFilePath: string }>(
+
+ '/admin/system-config/sync-env',
+
+ { method: 'POST' },
+
+ );
+
+ message.success(res.message || '已同步到 env 文件');
+
+ } catch (e) {
+
+ message.error(e instanceof Error ? e.message : '同步失败');
+
+ } finally {
+
+ setSyncing(false);
+
+ }
+
+ }
+
+
+
+ async function onImportEnv() {
+
+ try {
+
+ const res = await request<{ imported: number }>('/admin/system-config/import-env', {
+
+ method: 'POST',
+
+ });
+
+ message.success(`已从当前进程环境导入 ${res.imported} 项`);
+
+ await load();
+
+ } catch (e) {
+
+ message.error(e instanceof Error ? e.message : '导入失败');
+
+ }
+
+ }
+
+
+
+ return (
+
+
+
+
+
+
+
+
+
+ 系统设置
+
+
+
+
+
+ 配置存于 system_config 表;保存后写入进程环境。可同步到{' '}
+
+ {meta?.envFilePath ?? '.env'} 以便部署持久化。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 即时:保存后写入 process.env,Mock 开关、短信模板等可立即生效。
+
+
+
+
+
+ 需重启:微信/OSS 密钥等集成凭证变更后,建议重启 API 进程。
+
+
+
+ OSS 始终走阿里云配置;凭证缺失时上传接口将直接报错。
+
+
+
+ }
+
+ />
+
+
+
+
+
+
+
+ {meta?.updatedAt ? (
+
+
+
+ 最近更新:{new Date(meta.updatedAt).toLocaleString()}
+
+
+
+ ) : null}
+
+
+
+
+
+ );
+
+}
+
+
diff --git a/package.json b/package.json
index 8bc8f1f..79d6fed 100644
--- a/package.json
+++ b/package.json
@@ -23,6 +23,7 @@
"typecheck": "pnpm -r exec tsc --noEmit",
"test": "pnpm -r test",
"db:generate": "pnpm --filter @dukang/api prisma:generate",
+ "db:push": "pnpm --filter @dukang/api prisma:push",
"db:migrate": "pnpm --filter @dukang/api prisma:migrate",
"db:seed": "pnpm --filter @dukang/api prisma:seed",
"db:sync-benefit": "pnpm --filter @dukang/api prisma:sync-benefit",
diff --git a/packages/shared-types/src/config.ts b/packages/shared-types/src/config.ts
index f280a01..15591ef 100644
--- a/packages/shared-types/src/config.ts
+++ b/packages/shared-types/src/config.ts
@@ -1,21 +1,18 @@
export interface AppConfig {
mockSms: boolean;
- mockSmsCode: string;
mockPay: boolean;
mockDeliveryAuto: boolean;
autoApproveStore: boolean;
- /** preV1 Mock 微信授权登录:点击授权按钮走 Mock 流程直接登录,不接真实微信 */
+ /** Mock 微信 OAuth/登录/绑定;关闭且凭证齐全时走真实微信 */
mockWechat: boolean;
- /** 登录后是否走微信 SDK OAuth 授权(WX_AUTHORIZE=false 时三端跳过授权流程) */
+ /** 前端是否展示微信授权入口(由 mockWechat 或真实凭证推导) */
wxAuthorize: boolean;
- wechatAuthEnabled: boolean;
+ /** 是否可走真实 JSAPI 支付(由 MOCK_PAY 与商户凭证推导) */
wechatPayEnabled: boolean;
wxAppId: string;
/** 小程序 AppID(code2session 与小程序支付;未配置时回退 wxAppId) */
wxMiniAppId: string;
wxMchId: string;
- /** OSS_ENABLED=true 且 AccessKey/Bucket 齐全时走阿里云直传 */
- ossEnabled: boolean;
aliyunSmsSignName: string;
aliyunSmsTemplateCode: string;
/** 核销确认短信模板(REDEEM_PHONE_CONFIRM);env: ALIYUN_SMS_TEMPLATE_CONFIRM_REDEEM */
@@ -48,25 +45,60 @@ export const BRAND_LOGO_MARK_URL = `${BRAND_LOGO_OSS_BASE}logo2.png`;
/** 总部客服电话(C 端联系客服) */
export const CUSTOMER_SERVICE_PHONE = '400-888-1234';
-export function loadAppConfig(env?: Record): AppConfig {
- const e =
+function readEnv(env?: Record) {
+ return (
env ??
(globalThis as { process?: { env: Record } }).process?.env ??
- {};
- return {
- mockSms: e.MOCK_SMS !== 'false',
- mockSmsCode: e.MOCK_SMS_CODE ?? '123456',
- mockPay: e.MOCK_PAY !== 'false',
+ {}
+ );
+}
+
+export function hasWechatAuthCredentials(env?: Record): boolean {
+ const e = readEnv(env);
+ return !!(e.WX_APP_ID?.trim() && e.WX_APP_SECRET?.trim());
+}
+
+export function hasWechatPayCredentials(env?: Record): boolean {
+ const e = readEnv(env);
+ return !!(
+ e.WX_APP_ID?.trim() &&
+ e.WX_MCH_ID?.trim() &&
+ e.WX_MCH_SERIAL_NO?.trim() &&
+ e.WX_MCH_PRIVATE_KEY?.trim() &&
+ e.WX_API_V3_KEY?.trim()
+ );
+}
+
+/** 是否应使用真实微信 API(授权或支付任一需要) */
+export function needsRealWechatApi(cfg: Pick, env?: Record): boolean {
+ return (
+ (!cfg.mockWechat && hasWechatAuthCredentials(env)) ||
+ (!cfg.mockPay && hasWechatPayCredentials(env))
+ );
+}
+
+export function resolveWxAuthorize(cfg: Pick, env?: Record): boolean {
+ return cfg.mockWechat || hasWechatAuthCredentials(env);
+}
+
+export function resolveWechatPayEnabled(cfg: Pick, env?: Record): boolean {
+ return !cfg.mockPay && hasWechatPayCredentials(env);
+}
+
+export function loadAppConfig(env?: Record): AppConfig {
+ const e = readEnv(env);
+ const mockSms = e.MOCK_SMS !== 'false';
+ const mockPay = e.MOCK_PAY !== 'false';
+ const mockWechat = e.MOCK_WECHAT === 'true';
+ const base = {
+ mockSms,
+ mockPay,
mockDeliveryAuto: e.MOCK_DELIVERY_AUTO !== 'false',
autoApproveStore: e.AUTO_APPROVE_STORE !== 'false',
- mockWechat: e.MOCK_WECHAT === 'true',
- wxAuthorize: e.WX_AUTHORIZE !== 'false',
- wechatAuthEnabled: e.WECHAT_AUTH_ENABLED === 'true',
- wechatPayEnabled: e.WECHAT_PAY_ENABLED === 'true' || e.MOCK_PAY === 'false',
+ mockWechat,
wxAppId: e.WX_APP_ID ?? '',
wxMiniAppId: e.WX_MINI_APP_ID ?? e.WX_APP_ID ?? '',
wxMchId: e.WX_MCH_ID ?? '',
- ossEnabled: e.OSS_ENABLED === 'true',
aliyunSmsSignName: e.ALIYUN_SMS_SIGN_NAME ?? '',
aliyunSmsTemplateCode: e.ALIYUN_SMS_TEMPLATE_CODE ?? '',
aliyunSmsRedeemConfirmTemplateCode:
@@ -82,4 +114,9 @@ export function loadAppConfig(env?: Record): AppConf
tencentLbsKey: e.TENCENT_LBS_KEY ?? '',
userH5Url: (e.USER_H5_URL || DEFAULT_USER_H5_URL).replace(/\/$/, ''),
};
+ return {
+ ...base,
+ wxAuthorize: resolveWxAuthorize(base, e),
+ wechatPayEnabled: resolveWechatPayEnabled(base, e),
+ };
}
diff --git a/packages/shared-types/src/hq-permissions.ts b/packages/shared-types/src/hq-permissions.ts
index 8565a4b..073a93b 100644
--- a/packages/shared-types/src/hq-permissions.ts
+++ b/packages/shared-types/src/hq-permissions.ts
@@ -13,6 +13,7 @@ export const HQ_PERMISSION_CATALOG = [
{ key: 'logs', label: '日志' },
{ key: 'hq_permissions', label: '权限分配' },
{ key: 'hq_accounts', label: 'HQ 账户' },
+ { key: 'system_settings', label: '系统设置' },
] as const;
export type HqPermissionKey = (typeof HQ_PERMISSION_CATALOG)[number]['key'];
diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts
index cc88604..57a5dcf 100644
--- a/packages/shared-types/src/index.ts
+++ b/packages/shared-types/src/index.ts
@@ -18,3 +18,4 @@ export * from './partner';
export * from './shop';
export * from './city-partner';
export * from './city-warehouse';
+export * from './system-config';
diff --git a/packages/shared-types/src/system-config.ts b/packages/shared-types/src/system-config.ts
new file mode 100644
index 0000000..1a23189
--- /dev/null
+++ b/packages/shared-types/src/system-config.ts
@@ -0,0 +1,53 @@
+export type SystemConfigFieldType = 'string' | 'boolean' | 'number' | 'password' | 'textarea';
+
+export interface SystemConfigFieldMeta {
+ key: string;
+ label: string;
+ group: string;
+ type: SystemConfigFieldType;
+ secret?: boolean;
+ requiresRestart: boolean;
+ description?: string;
+ placeholder?: string;
+}
+
+export interface SystemConfigGroupMeta {
+ key: string;
+ label: string;
+}
+
+export interface SystemConfigFormField extends SystemConfigFieldMeta {
+ value: string;
+ /** 密钥类字段:是否已配置(不返回明文) */
+ configured?: boolean;
+}
+
+export interface SystemConfigFormResponse {
+ groups: SystemConfigGroupMeta[];
+ fields: SystemConfigFieldMeta[];
+ values: Record;
+ configuredSecrets: string[];
+ envFilePath: string;
+ updatedAt: string | null;
+ /** Mock 短信最近生成的验证码(仅 HQ 系统设置展示) */
+ mockSmsCodes: MockSmsCodeItem[];
+}
+
+export interface MockSmsCodeItem {
+ id: string;
+ phone: string;
+ scene: string;
+ code: string;
+ createdAt: string;
+}
+
+export interface SystemConfigUpdateRequest {
+ values: Record;
+}
+
+export interface SystemConfigSyncResult {
+ envFilePath: string;
+ writtenKeys: number;
+ requiresRestartKeys: string[];
+ message: string;
+}
diff --git a/packages/shared-types/src/wechat.ts b/packages/shared-types/src/wechat.ts
index 40c5fa0..04901d6 100644
--- a/packages/shared-types/src/wechat.ts
+++ b/packages/shared-types/src/wechat.ts
@@ -29,11 +29,11 @@ export type ClientRuntimeConfig = {
wechatPayEnabled: boolean;
mockSms: boolean;
mockWechat?: boolean;
- /** false 时三端跳过微信 SDK OAuth 授权 */
+ /** false 时三端跳过微信 SDK OAuth 授权(由 MOCK_WECHAT 或真实凭证推导) */
wxAuthorize?: boolean;
};
-/** 是否启用登录后微信 SDK 授权(默认 true,仅 WX_AUTHORIZE=false 时关闭) */
+/** 是否展示微信授权入口 */
export function isWxAuthorizeEnabled(config?: Pick | null): boolean {
return config?.wxAuthorize !== false;
}
diff --git a/scripts/smoke-prev1.mjs b/scripts/smoke-prev1.mjs
index 8e6157e..25946ea 100644
--- a/scripts/smoke-prev1.mjs
+++ b/scripts/smoke-prev1.mjs
@@ -1,5 +1,6 @@
+import { loginWithSms, resolveSmsCode } from './sms-test-helper.mjs';
+
const API = 'http://localhost:3000/api/v1';
-const MOCK_SMS_CODE = process.env.MOCK_SMS_CODE ?? '123456';
async function req(clientApp, path, options = {}) {
const headers = {
@@ -21,11 +22,7 @@ async function sendSms(clientApp, phone, scene, sendPath = '/auth/sms/send') {
}
async function loginSms(clientApp, phone, scene, loginPath = '/auth/login/sms', sendPath = '/auth/sms/send') {
- await sendSms(clientApp, phone, scene, sendPath);
- return req(clientApp, loginPath, {
- method: 'POST',
- body: JSON.stringify({ phone, code: MOCK_SMS_CODE }),
- });
+ return loginWithSms(clientApp, phone, scene, loginPath, sendPath);
}
async function main() {
diff --git a/scripts/smoke-v3.mjs b/scripts/smoke-v3.mjs
index 98afb2a..266d560 100644
--- a/scripts/smoke-v3.mjs
+++ b/scripts/smoke-v3.mjs
@@ -1,3 +1,5 @@
+import { loginWithSms } from './sms-test-helper.mjs';
+
const API = process.env.SMOKE_API ?? 'http://localhost:3000/api/v1';
async function req(clientApp, path, options = {}) {
@@ -24,7 +26,6 @@ async function expectFail(clientApp, path, options = {}) {
return json.message;
}
-const MOCK_SMS_CODE = process.env.MOCK_SMS_CODE ?? '123456';
async function sendSms(clientApp, phone, scene, sendPath = '/auth/sms/send') {
await req(clientApp, sendPath, {
@@ -34,11 +35,7 @@ async function sendSms(clientApp, phone, scene, sendPath = '/auth/sms/send') {
}
async function loginSms(clientApp, phone, scene, loginPath = '/auth/login/sms', sendPath = '/auth/sms/send') {
- await sendSms(clientApp, phone, scene, sendPath);
- return req(clientApp, loginPath, {
- method: 'POST',
- body: JSON.stringify({ phone, code: MOCK_SMS_CODE }),
- });
+ return loginWithSms(clientApp, phone, scene, loginPath, sendPath);
}
async function adminLogin() {
diff --git a/scripts/sms-test-helper.mjs b/scripts/sms-test-helper.mjs
index e8e854b..5b64865 100644
--- a/scripts/sms-test-helper.mjs
+++ b/scripts/sms-test-helper.mjs
@@ -1,7 +1,6 @@
import { execSync } from 'node:child_process';
const API = process.env.SMOKE_API ?? 'http://localhost:3000/api/v1';
-const DEFAULT_MOCK_CODE = process.env.MOCK_SMS_CODE ?? '123456';
const REDIS_CONTAINER = process.env.REDIS_CONTAINER ?? 'dukang-v1-redis';
function sleep(ms) {
@@ -33,14 +32,11 @@ export async function loadClientConfig() {
}
export async function resolveSmsCode(phone, scene) {
- const cfg = await loadClientConfig();
- if (cfg.mockSms) return DEFAULT_MOCK_CODE;
-
const fromRedis = readSmsCodeFromRedis(phone, scene);
if (fromRedis) return fromRedis;
throw new Error(
- `SMS code not found for ${phone} (${scene}); enable MOCK_SMS or ensure Redis is reachable`,
+ `SMS code not found for ${phone} (${scene}); send SMS first and ensure Redis is reachable`,
);
}
diff --git a/scripts/test-shop-auth.mjs b/scripts/test-shop-auth.mjs
index f5e02d6..910c4ee 100644
--- a/scripts/test-shop-auth.mjs
+++ b/scripts/test-shop-auth.mjs
@@ -2,8 +2,9 @@
* 门店登录 / 建店短信校验专项冒烟(需 API 已启动且 MOCK_SMS 开启)
* 用法: node scripts/test-shop-auth.mjs
*/
+import { resolveSmsCode, sendSmsWithCooldown } from './sms-test-helper.mjs';
+
const API = process.env.SMOKE_API ?? 'http://localhost:3000/api/v1';
-const MOCK_SMS_CODE = process.env.MOCK_SMS_CODE ?? '123456';
async function req(clientApp, path, options = {}) {
const headers = {
@@ -38,13 +39,11 @@ async function main() {
if (!unbound.includes('未绑定门店')) throw new Error(`unexpected: ${unbound}`);
console.log('2. STORE_LOGIN bound phone');
- await req('SHOP_H5', '/shop/auth/sms/send', {
- method: 'POST',
- body: JSON.stringify({ phone: '13910000001', scene: 'STORE_LOGIN' }),
- });
+ await sendSmsWithCooldown('SHOP_H5', '/shop/auth/sms/send', '13910000001', 'STORE_LOGIN');
+ const code = await resolveSmsCode('13910000001', 'STORE_LOGIN');
const login = await req('SHOP_H5', '/shop/auth/login/sms', {
method: 'POST',
- body: JSON.stringify({ phone: '13910000001', code: MOCK_SMS_CODE }),
+ body: JSON.stringify({ phone: '13910000001', code }),
});
if (!login.accessToken || !login.refreshToken) throw new Error('login missing tokens');
@@ -58,13 +57,11 @@ async function main() {
if (!refreshed.accessToken) throw new Error('refresh failed');
console.log('4. Admin create store rejects occupied phone');
- await req('HQ_WEB', '/admin/auth/sms/send', {
- method: 'POST',
- body: JSON.stringify({ phone: '13600000001', scene: 'HQ_LOGIN' }),
- });
+ await sendSmsWithCooldown('HQ_WEB', '/admin/auth/sms/send', '13600000001', 'HQ_LOGIN');
+ const adminCode = await resolveSmsCode('13600000001', 'HQ_LOGIN');
const admin = await req('HQ_WEB', '/admin/auth/login/sms', {
method: 'POST',
- body: JSON.stringify({ phone: '13600000001', code: MOCK_SMS_CODE }),
+ body: JSON.stringify({ phone: '13600000001', code: adminCode }),
});
const occupied = await expectFail('HQ_WEB', '/admin/stores', {
method: 'POST',
diff --git a/server/dukang-api/.env.example b/server/dukang-api/.env.example
index e1239ea..49a6534 100644
--- a/server/dukang-api/.env.example
+++ b/server/dukang-api/.env.example
@@ -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=
diff --git a/server/dukang-api/.env.production.example b/server/dukang-api/.env.production.example
index c26909a..233dd22 100644
--- a/server/dukang-api/.env.production.example
+++ b/server/dukang-api/.env.production.example
@@ -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
diff --git a/server/dukang-api/package.json b/server/dukang-api/package.json
index 97a1ca1..a074540 100644
--- a/server/dukang-api/package.json
+++ b/server/dukang-api/package.json
@@ -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",
diff --git a/server/dukang-api/prisma/schema.prisma b/server/dukang-api/prisma/schema.prisma
index 013c58c..f292182 100644
--- a/server/dukang-api/prisma/schema.prisma
+++ b/server/dukang-api/prisma/schema.prisma
@@ -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")
diff --git a/server/dukang-api/src/app.module.ts b/server/dukang-api/src/app.module.ts
index 6ce6eda..12486ab 100644
--- a/server/dukang-api/src/app.module.ts
+++ b/server/dukang-api/src/app.module.ts
@@ -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,
diff --git a/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts b/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts
index 5f2d9ad..5f96cf4 100644
--- a/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts
+++ b/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts
@@ -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 = {
[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: '门店打款确认',
};
diff --git a/server/dukang-api/src/common/mock-sms-code/mock-sms-code.service.ts b/server/dukang-api/src/common/mock-sms-code/mock-sms-code.service.ts
new file mode 100644
index 0000000..7528ad4
--- /dev/null
+++ b/server/dukang-api/src/common/mock-sms-code/mock-sms-code.service.ts
@@ -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 {
+ 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(),
+ }));
+ }
+}
diff --git a/server/dukang-api/src/common/system-config/system-config.env.ts b/server/dukang-api/src/common/system-config/system-config.env.ts
new file mode 100644
index 0000000..3d8b484
--- /dev/null
+++ b/server/dukang-api/src/common/system-config/system-config.env.ts
@@ -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 {
+ 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) {
+ for (const [key, value] of Object.entries(values)) {
+ if (SYSTEM_CONFIG_KEY_SET.has(key)) {
+ process.env[key] = value;
+ }
+ }
+}
diff --git a/server/dukang-api/src/common/system-config/system-config.module.ts b/server/dukang-api/src/common/system-config/system-config.module.ts
new file mode 100644
index 0000000..c47649c
--- /dev/null
+++ b/server/dukang-api/src/common/system-config/system-config.module.ts
@@ -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 {}
diff --git a/server/dukang-api/src/common/system-config/system-config.registry.ts b/server/dukang-api/src/common/system-config/system-config.registry.ts
new file mode 100644
index 0000000..b78d739
--- /dev/null
+++ b/server/dukang-api/src/common/system-config/system-config.registry.ts
@@ -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);
+}
diff --git a/server/dukang-api/src/common/system-config/system-config.service.ts b/server/dukang-api/src/common/system-config/system-config.service.ts
new file mode 100644
index 0000000..7f1ec1e
--- /dev/null
+++ b/server/dukang-api/src/common/system-config/system-config.service.ts
@@ -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(
+ (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 {
+ return { ...process.env };
+ }
+
+ getAppConfig() {
+ return loadAppConfig(this.getMergedEnv());
+ }
+
+ async getForm(): Promise {
+ 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 = {};
+ 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 = {};
+
+ 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 {
+ 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}`;
+}
diff --git a/server/dukang-api/src/integrations/integrations.module.ts b/server/dukang-api/src/integrations/integrations.module.ts
index 224b37d..0484a81 100644
--- a/server/dukang-api/src/integrations/integrations.module.ts
+++ b/server/dukang-api/src/integrations/integrations.module.ts
@@ -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 },
diff --git a/server/dukang-api/src/integrations/oss/oss.aliyun.provider.ts b/server/dukang-api/src/integrations/oss/oss.aliyun.provider.ts
index 7f31f26..6d68be4 100644
--- a/server/dukang-api/src/integrations/oss/oss.aliyun.provider.ts
+++ b/server/dukang-api/src/integrations/oss/oss.aliyun.provider.ts
@@ -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 {
diff --git a/server/dukang-api/src/integrations/pay/pay.mock.provider.ts b/server/dukang-api/src/integrations/pay/pay.mock.provider.ts
index ce7e8d4..c720360 100644
--- a/server/dukang-api/src/integrations/pay/pay.mock.provider.ts
+++ b/server/dukang-api/src/integrations/pay/pay.mock.provider.ts
@@ -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 {
- if (!this.config.mockPay) {
+ if (!loadAppConfig().mockPay) {
throw new Error('Real WeChat pay requires PayWechatProvider');
}
return { mode: 'mock', externalNo: `MOCK-${Date.now()}` };
diff --git a/server/dukang-api/src/integrations/pay/pay.router.provider.ts b/server/dukang-api/src/integrations/pay/pay.router.provider.ts
new file mode 100644
index 0000000..6494ec8
--- /dev/null
+++ b/server/dukang-api/src/integrations/pay/pay.router.provider.ts
@@ -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 {
+ return this.resolve().payOrder(orderId, openId);
+ }
+}
diff --git a/server/dukang-api/src/integrations/pay/pay.wechat.provider.ts b/server/dukang-api/src/integrations/pay/pay.wechat.provider.ts
index 3aae1cf..4106e4e 100644
--- a/server/dukang-api/src/integrations/pay/pay.wechat.provider.ts
+++ b/server/dukang-api/src/integrations/pay/pay.wechat.provider.ts
@@ -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 {
- 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,请先完成微信授权登录');
diff --git a/server/dukang-api/src/integrations/sms/sms-code.store.ts b/server/dukang-api/src/integrations/sms/sms-code.store.ts
index 3f6c23e..d137a23 100644
--- a/server/dukang-api/src/integrations/sms/sms-code.store.ts
+++ b/server/dukang-api/src/integrations/sms/sms-code.store.ts
@@ -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 {
- 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;
}
diff --git a/server/dukang-api/src/integrations/sms/sms.mock.provider.ts b/server/dukang-api/src/integrations/sms/sms.mock.provider.ts
index 600babe..7372780 100644
--- a/server/dukang-api/src/integrations/sms/sms.mock.provider.ts
+++ b/server/dukang-api/src/integrations/sms/sms.mock.provider.ts
@@ -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 {
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',
},
});
diff --git a/server/dukang-api/src/integrations/sms/sms.router.provider.ts b/server/dukang-api/src/integrations/sms/sms.router.provider.ts
new file mode 100644
index 0000000..b34e09b
--- /dev/null
+++ b/server/dukang-api/src/integrations/sms/sms.router.provider.ts
@@ -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 {
+ return this.resolve().send(phone, scene, actorRef);
+ }
+
+ verify(phone: string, code: string, scene: string): Promise {
+ return this.resolve().verify(phone, code, scene);
+ }
+}
diff --git a/server/dukang-api/src/integrations/wechat/wechat.api.provider.ts b/server/dukang-api/src/integrations/wechat/wechat.api.provider.ts
index 1b62994..47ead6e 100644
--- a/server/dukang-api/src/integrations/wechat/wechat.api.provider.ts
+++ b/server/dukang-api/src/integrations/wechat/wechat.api.provider.ts
@@ -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;
diff --git a/server/dukang-api/src/integrations/wechat/wechat.router.provider.ts b/server/dukang-api/src/integrations/wechat/wechat.router.provider.ts
new file mode 100644
index 0000000..5ec7a9b
--- /dev/null
+++ b/server/dukang-api/src/integrations/wechat/wechat.router.provider.ts
@@ -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,
+ rawBody: string,
+ ) {
+ return this.resolve().parsePayNotification(headers, rawBody);
+ }
+}
diff --git a/server/dukang-api/src/main.ts b/server/dukang-api/src/main.ts
index 07609a9..0e9a2e7 100644
--- a/server/dukang-api/src/main.ts
+++ b/server/dukang-api/src/main.ts
@@ -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(AppModule, { bodyParser: false });
app.setGlobalPrefix('api/v1');
app.set('trust proxy', true);
diff --git a/server/dukang-api/src/modules/common/client-config.controller.ts b/server/dukang-api/src/modules/common/client-config.controller.ts
index 87a5b23..226e5f2 100644
--- a/server/dukang-api/src/modules/common/client-config.controller.ts
+++ b/server/dukang-api/src/modules/common/client-config.controller.ts
@@ -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,
diff --git a/server/dukang-api/src/modules/common/common.module.ts b/server/dukang-api/src/modules/common/common.module.ts
index a093d71..b6d91ad 100644
--- a/server/dukang-api/src/modules/common/common.module.ts
+++ b/server/dukang-api/src/modules/common/common.module.ts
@@ -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,
diff --git a/server/dukang-api/src/modules/ops/admin-system-config.controller.ts b/server/dukang-api/src/modules/ops/admin-system-config.controller.ts
new file mode 100644
index 0000000..b893192
--- /dev/null
+++ b/server/dukang-api/src/modules/ops/admin-system-config.controller.ts
@@ -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();
+ }
+}
diff --git a/server/dukang-api/src/modules/ops/ops.module.ts b/server/dukang-api/src/modules/ops/ops.module.ts
index 07ed17f..5561259 100644
--- a/server/dukang-api/src/modules/ops/ops.module.ts
+++ b/server/dukang-api/src/modules/ops/ops.module.ts
@@ -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,