diff --git a/apps/h5-partner/src/pages/WeeklyReportPage.tsx b/apps/h5-partner/src/pages/WeeklyReportPage.tsx
new file mode 100644
index 0000000..ea22d50
--- /dev/null
+++ b/apps/h5-partner/src/pages/WeeklyReportPage.tsx
@@ -0,0 +1,43 @@
+import { useNavigate } from 'react-router-dom';
+import PageHeader from '@dukang/shared-ui/PageHeader';
+
+function fmtMoney(n: number) {
+ return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
+}
+
+/** BUWR-25:经营周报(preV1 占位,待 `/partner/reports/weekly` 接入) */
+export default function WeeklyReportPage() {
+ const navigate = useNavigate();
+
+ return (
+
+
navigate('/')} />
+
+
+
+ 近 7 日 GMV (CNY)
+ {fmtMoney(128450)}
+
+
+
+
+
+ 本周新签门店
+ 3 家
+ 详细趋势图待 API 接入后展示
+
+
+
+ );
+}
diff --git a/apps/h5-partner/src/styles.css b/apps/h5-partner/src/styles.css
index 6d40409..95de32b 100644
--- a/apps/h5-partner/src/styles.css
+++ b/apps/h5-partner/src/styles.css
@@ -581,6 +581,106 @@ html, body, #root {
border-left: 1px solid rgba(226, 190, 188, 0.2);
}
+.partner-form-error {
+ margin: 0 20px 12px;
+ padding: 10px 12px;
+ border-radius: 8px;
+ background: var(--color-error-container);
+ color: var(--color-error);
+ font-size: 14px;
+}
+
+.partner-store-name-list {
+ list-style: none;
+ margin: 0 0 16px;
+ padding: 0;
+ border-bottom: 1px solid rgba(226, 190, 188, 0.15);
+ padding-bottom: 12px;
+}
+
+.partner-store-name-list li {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 8px 0;
+ gap: 12px;
+}
+
+.partner-store-name-list-header {
+ padding-bottom: 4px !important;
+ border-bottom: 1px solid rgba(226, 190, 188, 0.15);
+}
+
+.partner-store-name-list--compact li {
+ padding: 4px 0;
+}
+
+.partner-pending-stores {
+ margin-bottom: 12px;
+ padding-bottom: 12px;
+ border-bottom: 1px solid rgba(226, 190, 188, 0.15);
+}
+
+.partner-field-input--block {
+ width: 100%;
+ padding: 0 16px;
+ min-height: 48px;
+ border: 1px solid rgba(226, 190, 188, 0.3);
+ border-radius: 8px;
+ background: var(--color-background);
+}
+
+.partner-leaderboard-section {
+ padding-top: 12px;
+}
+
+.partner-leaderboard-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 8px;
+}
+
+.partner-leaderboard-row {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 10px 0;
+ border-bottom: 1px solid rgba(226, 190, 188, 0.1);
+}
+
+.partner-leaderboard-row--compact {
+ padding: 8px 0;
+}
+
+.partner-leaderboard-rank {
+ width: 28px;
+ height: 28px;
+ border-radius: 50%;
+ background: var(--color-surface-container-high);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 12px;
+ font-weight: 700;
+ flex-shrink: 0;
+}
+
+.partner-leaderboard-row:first-of-type .partner-leaderboard-rank {
+ background: var(--color-aged-amber);
+ color: #fff;
+}
+
+.partner-leaderboard-info {
+ flex: 1;
+ min-width: 0;
+}
+
+.partner-leaderboard-stat {
+ text-align: right;
+ flex-shrink: 0;
+}
+
/* ── Partner bottom tabbar ── */
.app-tabbar {
display: grid;
diff --git a/apps/h5-shop/package.json b/apps/h5-shop/package.json
index ea6033a..62b7619 100644
--- a/apps/h5-shop/package.json
+++ b/apps/h5-shop/package.json
@@ -11,6 +11,7 @@
"dependencies": {
"@dukang/shared-ui": "workspace:*",
"@dukang/shared-types": "workspace:*",
+ "@dukang/weixin-sdk": "workspace:*",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.0"
diff --git a/apps/h5-shop/src/lib/weixin.ts b/apps/h5-shop/src/lib/weixin.ts
new file mode 100644
index 0000000..48349a4
--- /dev/null
+++ b/apps/h5-shop/src/lib/weixin.ts
@@ -0,0 +1,9 @@
+import { createWeixinSdk, isWechatEnv } from '@dukang/weixin-sdk';
+
+export const weixinSdk = createWeixinSdk({
+ apiBase: '/api/v1',
+ clientApp: 'SHOP_H5',
+ getAccessToken: () => localStorage.getItem('accessToken'),
+});
+
+export { isWechatEnv };
diff --git a/apps/h5-shop/src/pages/HomePage.tsx b/apps/h5-shop/src/pages/HomePage.tsx
index 2e6dc85..4ec2df6 100644
--- a/apps/h5-shop/src/pages/HomePage.tsx
+++ b/apps/h5-shop/src/pages/HomePage.tsx
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { isLoggedIn, request } from '../lib/api';
+import { isWechatEnv, weixinSdk } from '../lib/weixin';
function formatMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
@@ -27,6 +28,22 @@ export default function HomePage() {
const openTime = String(store?.openTime || '10:00');
const closeTime = String(store?.closeTime || '22:00');
+ async function handleScan() {
+ if (isWechatEnv()) {
+ try {
+ await weixinSdk.init();
+ const token = await weixinSdk.scanQrCode();
+ if (token) {
+ navigate(`/redeem?token=${encodeURIComponent(token)}`);
+ return;
+ }
+ } catch {
+ /* fall through to manual redeem page */
+ }
+ }
+ navigate('/redeem');
+ }
+
return (
@@ -55,7 +72,7 @@ export default function HomePage() {
-
diff --git a/apps/h5-user/src/pages/StoreListPage.tsx b/apps/h5-user/src/pages/StoreListPage.tsx
index 858a8a1..8e39532 100644
--- a/apps/h5-user/src/pages/StoreListPage.tsx
+++ b/apps/h5-user/src/pages/StoreListPage.tsx
@@ -11,6 +11,12 @@ import {
type RegionSelection,
} from '../lib/region-data';
+type OpenCity = {
+ code: string;
+ name: string;
+ province: string;
+};
+
type StoreItem = {
id: string;
name: string;
@@ -25,6 +31,11 @@ type StoreItem = {
category?: { name: string } | null;
};
+function resolveCityCode(region: RegionSelection, cities: OpenCity[]): string | undefined {
+ if (region.province === REGION_ALL || region.city === REGION_ALL) return undefined;
+ return cities.find((c) => c.province === region.province && c.name === region.city)?.code;
+}
+
const CATEGORY_TABS = ['全部', '火锅', '地方菜', '高端餐饮', '烧烤烤肉', '西餐'] as const;
const MOCK_DISTANCES = ['800m', '1.2km', '3.5km', '1.5km', '2.0km'];
@@ -43,29 +54,56 @@ function formatHours(store: StoreItem) {
export default function StoreListPage() {
const navigate = useNavigate();
+ const [cities, setCities] = useState
([]);
const [stores, setStores] = useState([]);
+ const [loading, setLoading] = useState(false);
const [categoryTab, setCategoryTab] = useState('全部');
const [keyword, setKeyword] = useState('');
const [region, setRegion] = useState(DEFAULT_REGION);
const [regionPickerOpen, setRegionPickerOpen] = useState(false);
+ const cityCode = useMemo(() => resolveCityCode(region, cities), [region, cities]);
+
useEffect(() => {
- request('USER_H5', '/stores?cityCode=410100').then(setStores);
+ request('USER_H5', '/catalog/cities').then(setCities);
}, []);
+ useEffect(() => {
+ let cancelled = false;
+ setLoading(true);
+ const qs = cityCode ? `?cityCode=${encodeURIComponent(cityCode)}` : '';
+ request('USER_H5', `/stores${qs}`)
+ .then((data) => {
+ if (!cancelled) setStores(data);
+ })
+ .catch(() => {
+ if (!cancelled) setStores([]);
+ })
+ .finally(() => {
+ if (!cancelled) setLoading(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [cityCode]);
+
const filtered = useMemo(() => {
let list = stores;
if (categoryTab !== '全部') {
list = list.filter((s) => s.category?.name === categoryTab);
}
- list = list.filter((s) => {
- const province = s.province ?? '河南省';
- const city = s.cityName ?? '郑州市';
- if (region.province !== REGION_ALL && province !== region.province) return false;
- if (region.city !== REGION_ALL && city !== region.city) return false;
- if (region.district !== REGION_ALL && s.district !== region.district) return false;
- return true;
- });
+ if (!cityCode) {
+ list = list.filter((s) => {
+ const province = s.province ?? '河南省';
+ const city = s.cityName ?? '郑州市';
+ if (region.province !== REGION_ALL && province !== region.province) return false;
+ if (region.city !== REGION_ALL && city !== region.city) return false;
+ if (region.district !== REGION_ALL && s.district !== region.district) return false;
+ return true;
+ });
+ } else if (region.district !== REGION_ALL) {
+ list = list.filter((s) => s.district === region.district);
+ }
const q = keyword.trim().toLowerCase();
if (q) {
list = list.filter(
@@ -76,7 +114,7 @@ export default function StoreListPage() {
);
}
return list;
- }, [stores, categoryTab, keyword, region]);
+ }, [stores, categoryTab, keyword, region, cityCode]);
const regionLabel = formatRegion(region.province, region.city, region.district);
@@ -123,7 +161,8 @@ export default function StoreListPage() {
- {filtered.map((s, index) => (
+ {loading && 加载门店中...
}
+ {!loading && filtered.map((s, index) => (
))}
- {filtered.length === 0 && (
+ {!loading && filtered.length === 0 && (
{stores.length === 0 ? '暂无门店' : '未找到匹配门店'}
)}
- {filtered.length > 0 && (
+ {!loading && filtered.length > 0 && (
没有更多门店了
)}
diff --git a/apps/h5-user/src/styles.css b/apps/h5-user/src/styles.css
index 166bdba..daaeb2e 100644
--- a/apps/h5-user/src/styles.css
+++ b/apps/h5-user/src/styles.css
@@ -1157,6 +1157,42 @@
font-size: 40px;
}
+.pay-wechat-auth-card {
+ margin-top: 24px;
+ padding: 16px;
+ border-radius: 12px;
+ background: #fff;
+ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
+ text-align: center;
+}
+
+.pay-wechat-auth-title {
+ font-size: 16px;
+ font-weight: 600;
+ color: var(--color-on-surface);
+ margin: 0 0 8px;
+}
+
+.pay-wechat-auth-desc {
+ font-size: 13px;
+ color: var(--color-muted, #999);
+ margin: 0 0 16px;
+ line-height: 1.5;
+}
+
+.pay-wechat-auth-btn {
+ width: 100%;
+ max-width: 280px;
+ margin: 0 auto;
+}
+
+.pay-wechat-auth-msg {
+ margin-top: 16px;
+ font-size: 13px;
+ color: var(--color-primary, #a02d30);
+ line-height: 1.5;
+}
+
.success-icon {
width: 64px;
height: 64px;
diff --git a/conventions.md b/conventions.md
index 8978c8f..365e30b 100644
--- a/conventions.md
+++ b/conventions.md
@@ -7,21 +7,28 @@
## 1. 代码所有权
-见 [`.github/CODEOWNERS`](./.github/CODEOWNERS)。端与后端模块边界:
+见 [`.github/CODEOWNERS`](./.github/CODEOWNERS)。**2 人团队**(Git 账号):
-| 目录 | Owner | 说明 |
-|------|-------|------|
-| `apps/mini-user` | @dev-a | C端小程序 |
-| `apps/mini-partner` | @dev-b | 城市合伙人小程序 |
-| `apps/mini-hq` | @dev-c | 总部管理小程序 |
-| `apps/h5-shop` | @dev-d | 门店 H5 |
-| `server/.../modules/{iam,trade,benefit,analytics}` | @dev-a | 交易与权益域 |
-| `server/.../modules/store` | @dev-b | 门店域 |
-| `server/.../modules/{catalog,settlement,ops}` | @dev-c | 开城与结算域 |
-| `server/.../modules/redeem` | @dev-d | 核销域 |
-| `server/.../callbacks`, `jobs`, `common` | @backend-lead | 回调与任务 |
-| `packages/*` | 全员 + @tech-lead | 公共契约,破坏性改动谨慎 |
-| `server/dukang-api/prisma` | 全部 Owner | 迁移必须多人 Review |
+| Git 账号 | 角色 | 主责 |
+|----------|------|------|
+| `jacy-dukang` | 管理员 + 主责开发 | C 端、admin-web、后端主模块、横切 |
+| `刘京尧` | 开发 | 合伙人 H5、门店 H5、store/redeem 模块 |
+
+端与后端模块边界:
+
+| 目录 | 负责人 | 说明 |
+|------|--------|------|
+| `apps/h5-user` | jacy-dukang | C 端 H5(V2 → mini-user) |
+| `apps/admin-web` | jacy-dukang | preV1 总部替代 |
+| `apps/h5-partner` | 刘京尧 | 合伙人 H5 |
+| `apps/h5-shop` | 刘京尧 | 门店 H5 |
+| `server/.../modules/{iam,trade,benefit,analytics}` | jacy-dukang | 交易与权益域 |
+| `server/.../modules/{catalog,settlement,ops}` | jacy-dukang | 开城与结算域 |
+| `server/.../modules/store` | 刘京尧 | 门店域 |
+| `server/.../modules/redeem` | 刘京尧 | 核销域 |
+| `server/.../callbacks`, `jobs`, `common`, `integrations` | jacy-dukang | 回调与任务 |
+| `packages/*` | jacy-dukang 主责;破坏性改动 @刘京尧 | 公共契约 |
+| `server/dukang-api/prisma` | jacy-dukang + 刘京尧(涉及 store/redeem 表时) | 迁移 Review |
---
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..11f019c
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,13 @@
+{
+ "name": "dukang-haoke",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "dukang-haoke",
+ "engines": {
+ "node": ">=20"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
index 23a82a8..26aed1c 100644
--- a/package.json
+++ b/package.json
@@ -16,6 +16,7 @@
"db:seed": "pnpm --filter @dukang/api prisma:seed",
"db:sync-benefit": "pnpm --filter @dukang/api prisma:sync-benefit",
"db:validate": "pnpm --filter @dukang/api prisma:validate",
+ "oss:cors": "node scripts/configure-oss-cors.mjs",
"sync:stitch": "node scripts/sync-stitch.mjs"
},
"engines": {
diff --git a/packages/shared-types/src/config.ts b/packages/shared-types/src/config.ts
index b4c0027..d96c5f8 100644
--- a/packages/shared-types/src/config.ts
+++ b/packages/shared-types/src/config.ts
@@ -4,6 +4,12 @@ export interface AppConfig {
mockPay: boolean;
mockDeliveryAuto: boolean;
autoApproveStore: boolean;
+ wechatAuthEnabled: boolean;
+ wechatPayEnabled: boolean;
+ wxAppId: string;
+ wxMchId: string;
+ /** OSS_ENABLED=true 且 AccessKey/Bucket 齐全时走阿里云直传 */
+ ossEnabled: boolean;
}
export function loadAppConfig(env?: Record): AppConfig {
@@ -17,5 +23,10 @@ export function loadAppConfig(env?: Record): AppConf
mockPay: e.MOCK_PAY !== 'false',
mockDeliveryAuto: e.MOCK_DELIVERY_AUTO !== 'false',
autoApproveStore: e.AUTO_APPROVE_STORE !== 'false',
+ wechatAuthEnabled: e.WECHAT_AUTH_ENABLED === 'true',
+ wechatPayEnabled: e.WECHAT_PAY_ENABLED === 'true' || e.MOCK_PAY === 'false',
+ wxAppId: e.WX_APP_ID ?? '',
+ wxMchId: e.WX_MCH_ID ?? '',
+ ossEnabled: e.OSS_ENABLED === 'true',
};
}
diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts
index 4973f95..9eadd82 100644
--- a/packages/shared-types/src/index.ts
+++ b/packages/shared-types/src/index.ts
@@ -1,3 +1,4 @@
export * from './enums';
export * from './api';
export * from './config';
+export * from './wechat';
diff --git a/packages/shared-types/src/wechat.ts b/packages/shared-types/src/wechat.ts
new file mode 100644
index 0000000..c020aa5
--- /dev/null
+++ b/packages/shared-types/src/wechat.ts
@@ -0,0 +1,53 @@
+/** 微信 JSSDK 初始化参数(后端签名下发) */
+export interface WechatJssdkConfig {
+ appId: string;
+ timestamp: number;
+ nonceStr: string;
+ signature: string;
+ jsApiList: string[];
+}
+
+/** JSAPI 调起支付参数 */
+export interface WechatJsapiPrepayParams {
+ appId: string;
+ timeStamp: string;
+ nonceStr: string;
+ package: string;
+ signType: 'RSA' | 'MD5';
+ paySign: string;
+}
+
+export type WechatPayOrderResult =
+ | { mode: 'mock'; externalNo: string; order?: Record }
+ | { mode: 'jsapi'; prepay: WechatJsapiPrepayParams; orderId: string };
+
+/** 业务错误码:微信支付前需完成微信授权 */
+export const WECHAT_AUTH_REQUIRED = 'WECHAT_AUTH_REQUIRED';
+
+export type ClientRuntimeConfig = {
+ mockPay: boolean;
+ wechatPayEnabled: boolean;
+};
+
+export interface WechatLoginResult {
+ accessToken?: string;
+ refreshToken?: string;
+ deviceKey?: string;
+ actorType?: string;
+ actorId?: string;
+ phoneVerified?: boolean;
+ needBindPhone?: boolean;
+ wxSessionKey?: string;
+ user?: Record;
+ store?: Record;
+ partner?: Record;
+}
+
+export type WechatLoginPlatform = 'h5' | 'mini';
+
+export interface WechatGpsLocation {
+ latitude: number;
+ longitude: number;
+ speed?: number;
+ accuracy?: number;
+}
diff --git a/packages/weixin-sdk/package.json b/packages/weixin-sdk/package.json
new file mode 100644
index 0000000..71d0dd8
--- /dev/null
+++ b/packages/weixin-sdk/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "@dukang/weixin-sdk",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "exports": {
+ ".": "./src/index.ts"
+ },
+ "dependencies": {
+ "@dukang/shared-types": "workspace:*"
+ },
+ "devDependencies": {
+ "typescript": "^5.4.5"
+ }
+}
diff --git a/packages/weixin-sdk/src/auth.ts b/packages/weixin-sdk/src/auth.ts
new file mode 100644
index 0000000..468951a
--- /dev/null
+++ b/packages/weixin-sdk/src/auth.ts
@@ -0,0 +1,145 @@
+import type { WechatLoginPlatform, WechatLoginResult } from '@dukang/shared-types';
+import { getRuntimePlatform, isWechatBrowser } from './env';
+import type { WeixinSdkConfig } from './types';
+
+const OAUTH_STATE_KEY = 'dukang_wx_oauth_state';
+
+function randomState() {
+ return `${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
+}
+
+async function apiRequest(
+ config: WeixinSdkConfig,
+ path: string,
+ options: RequestInit = {},
+): Promise {
+ const headers: Record = {
+ 'Content-Type': 'application/json',
+ 'X-Client-App': config.clientApp,
+ ...(options.headers as Record),
+ };
+ const token = config.getAccessToken?.();
+ if (token) headers.Authorization = `Bearer ${token}`;
+ const apiBase = config.apiBase ?? '/api/v1';
+ const res = await fetch(`${apiBase}${path}`, { ...options, headers });
+ const json = await res.json();
+ if (json.code !== 0) throw new Error(json.message || '请求失败');
+ return json.data as T;
+}
+
+/** 获取公众号 OAuth 授权跳转 URL */
+export async function getWechatOAuthUrl(
+ config: WeixinSdkConfig,
+ redirectUri: string,
+ scope: 'snsapi_base' | 'snsapi_userinfo' = 'snsapi_userinfo',
+): Promise {
+ const state = randomState();
+ sessionStorage.setItem(OAUTH_STATE_KEY, state);
+ const qs = new URLSearchParams({ redirectUri, scope, state });
+ const data = await apiRequest<{ url: string }>(config, `/common/wechat/oauth-url?${qs}`);
+ return data.url;
+}
+
+/** 发起微信 OAuth 登录(H5 公众号内跳转授权) */
+export async function startWechatOAuthLogin(config: WeixinSdkConfig, redirectUri?: string): Promise {
+ if (!isWechatBrowser()) {
+ throw new Error('请在微信内打开');
+ }
+ const uri = redirectUri ?? window.location.href.split('#')[0];
+ const url = await getWechatOAuthUrl(config, uri);
+ window.location.href = url;
+}
+
+/** 小程序 wx.login 获取 code */
+export function getMiniProgramLoginCode(): Promise {
+ return new Promise((resolve, reject) => {
+ if (!window.wx?.login) {
+ reject(new Error('wx.login 不可用'));
+ return;
+ }
+ window.wx.login({
+ success: (res) => (res.code ? resolve(res.code) : reject(new Error('未获取到 code'))),
+ fail: (err) => reject(new Error(err.errMsg || 'wx.login 失败')),
+ });
+ });
+}
+
+/** 用 code 完成微信登录(自动识别 H5 / 小程序) */
+export async function loginWithWechatCode(
+ config: WeixinSdkConfig,
+ code: string,
+ platform?: WechatLoginPlatform,
+): Promise {
+ const resolvedPlatform = platform ?? (getRuntimePlatform() === 'mini' ? 'mini' : 'h5');
+ return apiRequest(config, '/auth/login/wechat', {
+ method: 'POST',
+ body: JSON.stringify({ code, platform: resolvedPlatform }),
+ });
+}
+
+/** 处理 OAuth 回调 URL 中的 code 参数并登录 */
+export async function handleWechatOAuthCallback(
+ config: WeixinSdkConfig,
+ searchParams?: URLSearchParams,
+): Promise {
+ const params = searchParams ?? new URLSearchParams(window.location.search);
+ const code = params.get('code');
+ if (!code) return null;
+
+ const state = params.get('state');
+ const saved = sessionStorage.getItem(OAUTH_STATE_KEY);
+ if (saved && state && saved !== state) {
+ throw new Error('OAuth state 校验失败');
+ }
+ sessionStorage.removeItem(OAUTH_STATE_KEY);
+
+ return loginWithWechatCode(config, code, 'h5');
+}
+
+/** 微信首登绑定手机号 */
+export async function bindWechatPhone(
+ config: WeixinSdkConfig,
+ payload: { wxSessionKey: string; phone: string; code: string },
+): Promise {
+ return apiRequest(config, '/auth/wechat/bind-phone', {
+ method: 'POST',
+ body: JSON.stringify(payload),
+ });
+}
+
+/** 获取微信手机号(小程序 getPhoneNumber 返回的 code,需后端解密) */
+export async function getWechatPhoneNumber(config: WeixinSdkConfig): Promise {
+ const platform = getRuntimePlatform();
+ if (platform !== 'mini') {
+ throw new Error('H5 环境请使用短信验证码绑定手机号');
+ }
+ const phoneCode = await new Promise((resolve, reject) => {
+ if (!window.wx?.getPhoneNumber) {
+ reject(new Error('getPhoneNumber 不可用'));
+ return;
+ }
+ window.wx.getPhoneNumber({
+ success: (res) => (res.code ? resolve(res.code) : reject(new Error('未获取到手机号 code'))),
+ fail: (err) => reject(new Error(err.errMsg || '获取手机号失败')),
+ });
+ });
+ const data = await apiRequest<{ phone: string }>(config, '/common/wechat/phone-number', {
+ method: 'POST',
+ body: JSON.stringify({ code: phoneCode, platform: 'mini' }),
+ });
+ return data.phone;
+}
+
+/** 一键微信登录(自动选择 OAuth 或 wx.login) */
+export async function wechatLogin(config: WeixinSdkConfig): Promise {
+ const platform = getRuntimePlatform();
+ if (platform === 'mini') {
+ const code = await getMiniProgramLoginCode();
+ return loginWithWechatCode(config, code, 'mini');
+ }
+ if (platform === 'wechat-h5') {
+ await startWechatOAuthLogin(config);
+ return;
+ }
+ throw new Error('请在微信内打开以使用微信登录');
+}
diff --git a/packages/weixin-sdk/src/env.ts b/packages/weixin-sdk/src/env.ts
new file mode 100644
index 0000000..d6e4882
--- /dev/null
+++ b/packages/weixin-sdk/src/env.ts
@@ -0,0 +1,18 @@
+/** 是否微信内置浏览器 */
+export function isWechatBrowser(): boolean {
+ if (typeof navigator === 'undefined') return false;
+ return /MicroMessenger/i.test(navigator.userAgent);
+}
+
+/** 是否微信小程序 web-view 或独立小程序环境 */
+export function isMiniProgram(): boolean {
+ if (typeof window === 'undefined') return false;
+ const ua = navigator.userAgent.toLowerCase();
+ return ua.includes('miniprogram') || (window as Window & { __wxjs_environment?: string }).__wxjs_environment === 'miniprogram';
+}
+
+export function getRuntimePlatform(): 'mini' | 'wechat-h5' | 'browser' {
+ if (isMiniProgram()) return 'mini';
+ if (isWechatBrowser()) return 'wechat-h5';
+ return 'browser';
+}
diff --git a/packages/weixin-sdk/src/index.ts b/packages/weixin-sdk/src/index.ts
new file mode 100644
index 0000000..c7c0b5b
--- /dev/null
+++ b/packages/weixin-sdk/src/index.ts
@@ -0,0 +1,49 @@
+export { isWechatBrowser, isMiniProgram, getRuntimePlatform } from './env';
+export { initWechatJssdk, ensureJssdkReady, isJssdkReady } from './jssdk';
+export {
+ getWechatLocation,
+ canUseWechatLocation,
+ isWechatEnv,
+} from './location';
+export { scanQrCode } from './scan';
+export { invokeWechatPay } from './pay';
+export {
+ getWechatOAuthUrl,
+ startWechatOAuthLogin,
+ getMiniProgramLoginCode,
+ loginWithWechatCode,
+ handleWechatOAuthCallback,
+ bindWechatPhone,
+ getWechatPhoneNumber,
+ wechatLogin,
+} from './auth';
+export type { WeixinSdkConfig, WxApi, MiniProgramWx } from './types';
+export { DEFAULT_JS_API_LIST } from './types';
+
+import type { WeixinSdkConfig } from './types';
+import { initWechatJssdk } from './jssdk';
+import { getWechatLocation } from './location';
+import { scanQrCode } from './scan';
+import { invokeWechatPay } from './pay';
+import {
+ wechatLogin,
+ handleWechatOAuthCallback,
+ bindWechatPhone,
+ getWechatPhoneNumber,
+} from './auth';
+
+/** 微信 SDK 门面(按端注入 apiBase / clientApp) */
+export function createWeixinSdk(config: WeixinSdkConfig) {
+ return {
+ init: () => initWechatJssdk({ ...config, apiBase: config.apiBase ?? '/api/v1' }),
+ login: () => wechatLogin(config),
+ handleOAuthCallback: (params?: URLSearchParams) => handleWechatOAuthCallback(config, params),
+ bindPhone: (payload: { wxSessionKey: string; phone: string; code: string }) =>
+ bindWechatPhone(config, payload),
+ getPhoneNumber: () => getWechatPhoneNumber(config),
+ getLocation: () => getWechatLocation(config),
+ scanQrCode: () => scanQrCode(config),
+ pay: (prepay: Parameters[0]) =>
+ invokeWechatPay(prepay, { apiBase: config.apiBase, clientApp: config.clientApp }),
+ };
+}
diff --git a/packages/weixin-sdk/src/jssdk.ts b/packages/weixin-sdk/src/jssdk.ts
new file mode 100644
index 0000000..1f239fc
--- /dev/null
+++ b/packages/weixin-sdk/src/jssdk.ts
@@ -0,0 +1,84 @@
+import type { WechatJssdkConfig } from '@dukang/shared-types';
+import { DEFAULT_JS_API_LIST } from './types';
+
+const JSSDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
+
+let scriptPromise: Promise | null = null;
+let configured = false;
+
+function loadScript(): Promise {
+ if (typeof document === 'undefined') return Promise.reject(new Error('非浏览器环境'));
+ if (window.wx) return Promise.resolve();
+ if (scriptPromise) return scriptPromise;
+
+ scriptPromise = new Promise((resolve, reject) => {
+ const existing = document.querySelector('script[data-dukang-wx-jssdk]');
+ if (existing) {
+ existing.addEventListener('load', () => resolve());
+ existing.addEventListener('error', () => reject(new Error('微信 JSSDK 加载失败')));
+ return;
+ }
+ const script = document.createElement('script');
+ script.src = JSSDK_URL;
+ script.async = true;
+ script.dataset.dukangWxJssdk = '1';
+ script.onload = () => resolve();
+ script.onerror = () => reject(new Error('微信 JSSDK 加载失败'));
+ document.head.appendChild(script);
+ });
+ return scriptPromise;
+}
+
+async function fetchJssdkConfig(apiBase: string, clientApp: string, url: string, token?: string | null) {
+ const headers: Record = { 'X-Client-App': clientApp };
+ if (token) headers.Authorization = `Bearer ${token}`;
+ const qs = new URLSearchParams({ url });
+ const res = await fetch(`${apiBase}/common/wechat/jssdk-config?${qs}`, { headers });
+ const json = await res.json();
+ if (json.code !== 0) throw new Error(json.message || '获取 JSSDK 配置失败');
+ return json.data as WechatJssdkConfig;
+}
+
+/** 初始化微信 JSSDK(H5 公众号内) */
+export async function initWechatJssdk(options: {
+ apiBase: string;
+ clientApp: string;
+ url?: string;
+ getAccessToken?: () => string | null;
+ jsApiList?: string[];
+}): Promise {
+ const { apiBase, clientApp, getAccessToken } = options;
+ const pageUrl = options.url ?? (typeof window !== 'undefined' ? window.location.href.split('#')[0] : '');
+ await loadScript();
+ if (!window.wx) throw new Error('微信 JSSDK 不可用');
+
+ const config = await fetchJssdkConfig(apiBase, clientApp, pageUrl, getAccessToken?.());
+ const jsApiList = options.jsApiList ?? [...DEFAULT_JS_API_LIST];
+
+ await new Promise((resolve, reject) => {
+ window.wx!.config({
+ ...config,
+ jsApiList,
+ debug: false,
+ });
+ window.wx!.ready(() => {
+ configured = true;
+ resolve();
+ });
+ window.wx!.error((err) => reject(new Error(err.errMsg || 'wx.config 失败')));
+ });
+}
+
+export function isJssdkReady(): boolean {
+ return configured && !!window.wx;
+}
+
+export async function ensureJssdkReady(options: {
+ apiBase: string;
+ clientApp: string;
+ getAccessToken?: () => string | null;
+}): Promise {
+ if (!isJssdkReady()) {
+ await initWechatJssdk(options);
+ }
+}
diff --git a/packages/weixin-sdk/src/location.ts b/packages/weixin-sdk/src/location.ts
new file mode 100644
index 0000000..4d68a59
--- /dev/null
+++ b/packages/weixin-sdk/src/location.ts
@@ -0,0 +1,62 @@
+import type { WechatGpsLocation } from '@dukang/shared-types';
+import { getRuntimePlatform, isWechatBrowser } from './env';
+import { ensureJssdkReady } from './jssdk';
+import type { WeixinSdkConfig } from './types';
+
+/** 获取 GPS 定位(微信 JSSDK / 小程序优先,否则 H5 Geolocation) */
+export async function getWechatLocation(config?: WeixinSdkConfig): Promise {
+ const platform = getRuntimePlatform();
+
+ if (platform === 'mini' && window.wx?.getLocation) {
+ return new Promise((resolve) => {
+ window.wx!.getLocation!({
+ type: 'gcj02',
+ success: (res) => resolve(res),
+ fail: () => resolve(null),
+ });
+ });
+ }
+
+ if (platform === 'wechat-h5' && config) {
+ try {
+ await ensureJssdkReady({
+ apiBase: config.apiBase ?? '/api/v1',
+ clientApp: config.clientApp,
+ getAccessToken: config.getAccessToken,
+ });
+ if (window.wx?.getLocation) {
+ return new Promise((resolve) => {
+ window.wx!.getLocation!({
+ type: 'gcj02',
+ success: (res) => resolve(res),
+ fail: () => resolve(null),
+ });
+ });
+ }
+ } catch {
+ /* fall through */
+ }
+ }
+
+ if (typeof navigator === 'undefined' || !navigator.geolocation) return null;
+ return new Promise((resolve) => {
+ navigator.geolocation.getCurrentPosition(
+ (pos) =>
+ resolve({
+ latitude: pos.coords.latitude,
+ longitude: pos.coords.longitude,
+ accuracy: pos.coords.accuracy,
+ }),
+ () => resolve(null),
+ { enableHighAccuracy: false, timeout: 8000, maximumAge: 60_000 },
+ );
+ });
+}
+
+export function canUseWechatLocation(): boolean {
+ return getRuntimePlatform() !== 'browser' || typeof navigator?.geolocation !== 'undefined';
+}
+
+export function isWechatEnv(): boolean {
+ return isWechatBrowser() || getRuntimePlatform() === 'mini';
+}
diff --git a/packages/weixin-sdk/src/pay.ts b/packages/weixin-sdk/src/pay.ts
new file mode 100644
index 0000000..922a045
--- /dev/null
+++ b/packages/weixin-sdk/src/pay.ts
@@ -0,0 +1,83 @@
+import type { WechatJsapiPrepayParams } from '@dukang/shared-types';
+import { getRuntimePlatform } from './env';
+import { ensureJssdkReady } from './jssdk';
+
+function invokeBridgePay(params: WechatJsapiPrepayParams): Promise {
+ return new Promise((resolve, reject) => {
+ const bridge = window.WeixinJSBridge;
+ if (!bridge) {
+ reject(new Error('WeixinJSBridge 不可用'));
+ return;
+ }
+ bridge.invoke(
+ 'getBrandWCPayRequest',
+ {
+ appId: params.appId,
+ timeStamp: params.timeStamp,
+ nonceStr: params.nonceStr,
+ package: params.package,
+ signType: params.signType,
+ paySign: params.paySign,
+ },
+ (res) => {
+ const msg = res.err_msg ?? '';
+ if (msg.includes('ok')) resolve();
+ else if (msg.includes('cancel')) reject(new Error('用户取消支付'));
+ else reject(new Error(msg || '支付失败'));
+ },
+ );
+ });
+}
+
+/** 调起微信支付 */
+export async function invokeWechatPay(
+ prepay: WechatJsapiPrepayParams,
+ config?: { apiBase?: string; clientApp?: string },
+): Promise {
+ const platform = getRuntimePlatform();
+
+ if (platform === 'mini' && window.wx?.requestPayment) {
+ return new Promise((resolve, reject) => {
+ window.wx!.requestPayment!({
+ timeStamp: prepay.timeStamp,
+ nonceStr: prepay.nonceStr,
+ package: prepay.package,
+ signType: prepay.signType,
+ paySign: prepay.paySign,
+ success: () => resolve(),
+ fail: (err) => reject(new Error(err.errMsg || '支付失败')),
+ });
+ });
+ }
+
+ if (platform === 'wechat-h5') {
+ if (config?.clientApp) {
+ await ensureJssdkReady({
+ apiBase: config.apiBase ?? '/api/v1',
+ clientApp: config.clientApp,
+ });
+ }
+ if (window.wx?.chooseWXPay) {
+ return new Promise((resolve, reject) => {
+ window.wx!.chooseWXPay!({
+ ...prepay,
+ success: () => resolve(),
+ fail: (err) => reject(new Error(err.errMsg || '支付失败')),
+ cancel: () => reject(new Error('用户取消支付')),
+ });
+ });
+ }
+ if (window.WeixinJSBridge) {
+ return invokeBridgePay(prepay);
+ }
+ if (typeof document !== 'undefined') {
+ await new Promise((resolve) => {
+ if (window.WeixinJSBridge) resolve();
+ else document.addEventListener('WeixinJSBridgeReady', () => resolve(), { once: true });
+ });
+ return invokeBridgePay(prepay);
+ }
+ }
+
+ throw new Error('当前环境不支持微信支付');
+}
diff --git a/packages/weixin-sdk/src/scan.ts b/packages/weixin-sdk/src/scan.ts
new file mode 100644
index 0000000..9844df2
--- /dev/null
+++ b/packages/weixin-sdk/src/scan.ts
@@ -0,0 +1,40 @@
+import { getRuntimePlatform } from './env';
+import { ensureJssdkReady } from './jssdk';
+import type { WeixinSdkConfig } from './types';
+
+/** 调起扫码(返回二维码/条码内容,失败返回 null) */
+export async function scanQrCode(config: WeixinSdkConfig): Promise {
+ const platform = getRuntimePlatform();
+
+ if (platform === 'mini' && window.wx?.scanCode) {
+ return new Promise((resolve) => {
+ window.wx!.scanCode!({
+ onlyFromCamera: true,
+ scanType: ['qrCode', 'barCode'],
+ success: (res) => resolve(res.result || null),
+ fail: () => resolve(null),
+ });
+ });
+ }
+
+ if (platform === 'wechat-h5') {
+ await ensureJssdkReady({
+ apiBase: config.apiBase ?? '/api/v1',
+ clientApp: config.clientApp,
+ getAccessToken: config.getAccessToken,
+ });
+ if (window.wx?.scanQRCode) {
+ return new Promise((resolve) => {
+ window.wx!.scanQRCode!({
+ needResult: 1,
+ scanType: ['qrCode', 'barCode'],
+ success: (res) => resolve(res.resultStr || null),
+ fail: () => resolve(null),
+ });
+ });
+ }
+ }
+
+ const manual = typeof window !== 'undefined' ? window.prompt('当前环境无法调起微信扫码,请手动输入核销码') : null;
+ return manual?.trim() || null;
+}
diff --git a/packages/weixin-sdk/src/types.ts b/packages/weixin-sdk/src/types.ts
new file mode 100644
index 0000000..2041494
--- /dev/null
+++ b/packages/weixin-sdk/src/types.ts
@@ -0,0 +1,85 @@
+import type { WechatGpsLocation, WechatJsapiPrepayParams, WechatJssdkConfig } from '@dukang/shared-types';
+
+export type WeixinSdkConfig = {
+ /** API 根路径,默认 /api/v1 */
+ apiBase?: string;
+ /** X-Client-App 请求头 */
+ clientApp: string;
+ /** 获取 access token(可选,登录后自动带) */
+ getAccessToken?: () => string | null;
+};
+
+export type WxInvokeResult = {
+ errMsg: string;
+} & T;
+
+export type WxApi = {
+ config: (options: WechatJssdkConfig & { debug?: boolean }) => void;
+ ready: (cb: () => void) => void;
+ error: (cb: (res: { errMsg: string }) => void) => void;
+ checkJsApi: (options: { jsApiList: string[]; success?: (res: Record) => void }) => void;
+ getLocation: (options: {
+ type?: string;
+ success?: (res: WechatGpsLocation) => void;
+ fail?: (res: { errMsg: string }) => void;
+ }) => void;
+ scanQRCode: (options: {
+ needResult?: 0 | 1;
+ scanType?: Array<'qrCode' | 'barCode'>;
+ success?: (res: { resultStr: string }) => void;
+ fail?: (res: { errMsg: string }) => void;
+ }) => void;
+ chooseWXPay: (options: WechatJsapiPrepayParams & {
+ success?: () => void;
+ fail?: (res: { errMsg: string }) => void;
+ cancel?: () => void;
+ }) => void;
+};
+
+export type MiniProgramWx = {
+ login: (options: {
+ success?: (res: { code: string }) => void;
+ fail?: (res: { errMsg: string }) => void;
+ }) => void;
+ getPhoneNumber: (options: {
+ success?: (res: { code: string }) => void;
+ fail?: (res: { errMsg: string }) => void;
+ }) => void;
+ getLocation: WxApi['getLocation'];
+ scanCode: (options: {
+ onlyFromCamera?: boolean;
+ scanType?: Array<'qrCode' | 'barCode'>;
+ success?: (res: { result: string }) => void;
+ fail?: (res: { errMsg: string }) => void;
+ }) => void;
+ requestPayment: (options: {
+ timeStamp: string;
+ nonceStr: string;
+ package: string;
+ signType: string;
+ paySign: string;
+ success?: () => void;
+ fail?: (res: { errMsg: string }) => void;
+ }) => void;
+};
+
+declare global {
+ interface Window {
+ wx?: WxApi & MiniProgramWx;
+ WeixinJSBridge?: {
+ invoke: (
+ api: string,
+ params: Record,
+ cb: (res: { err_msg?: string }) => void,
+ ) => void;
+ };
+ }
+}
+
+export const DEFAULT_JS_API_LIST = [
+ 'getLocation',
+ 'scanQRCode',
+ 'chooseWXPay',
+ 'updateAppMessageShareData',
+ 'updateTimelineShareData',
+] as const;
diff --git a/packages/weixin-sdk/tsconfig.json b/packages/weixin-sdk/tsconfig.json
new file mode 100644
index 0000000..4a42960
--- /dev/null
+++ b/packages/weixin-sdk/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "strict": true,
+ "skipLibCheck": true,
+ "lib": ["ES2020", "DOM"]
+ },
+ "include": ["src"]
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index fa3363b..ac4c679 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -22,6 +22,9 @@ importers:
dayjs:
specifier: ^1.11.13
version: 1.11.21
+ element-china-area-data:
+ specifier: ^6.1.0
+ version: 6.1.0
react:
specifier: ^18.3.1
version: 18.3.1
@@ -90,6 +93,9 @@ importers:
'@dukang/shared-ui':
specifier: workspace:*
version: link:../../packages/shared-ui
+ '@dukang/weixin-sdk':
+ specifier: workspace:*
+ version: link:../../packages/weixin-sdk
react:
specifier: ^18.3.1
version: 18.3.1
@@ -124,6 +130,9 @@ importers:
'@dukang/shared-ui':
specifier: workspace:*
version: link:../../packages/shared-ui
+ '@dukang/weixin-sdk':
+ specifier: workspace:*
+ version: link:../../packages/weixin-sdk
element-china-area-data:
specifier: ^6.1.0
version: 6.1.0
@@ -180,6 +189,16 @@ importers:
specifier: ^5.4.5
version: 5.9.3
+ packages/weixin-sdk:
+ dependencies:
+ '@dukang/shared-types':
+ specifier: workspace:*
+ version: link:../shared-types
+ devDependencies:
+ typescript:
+ specifier: ^5.4.5
+ version: 5.9.3
+
server/dukang-api:
dependencies:
'@dukang/domain':
@@ -209,6 +228,9 @@ importers:
'@prisma/client':
specifier: ^5.18.0
version: 5.22.0(prisma@5.22.0)
+ ali-oss:
+ specifier: ^6.23.0
+ version: 6.23.0
bullmq:
specifier: ^5.12.0
version: 5.79.2
@@ -237,9 +259,15 @@ importers:
'@nestjs/schematics':
specifier: ^10.1.0
version: 10.2.3(chokidar@3.6.0)(typescript@5.9.3)
+ '@types/ali-oss':
+ specifier: ^6.23.3
+ version: 6.23.3
'@types/express':
specifier: ^4.17.21
version: 4.17.25
+ '@types/multer':
+ specifier: ^2.1.0
+ version: 2.1.0
'@types/node':
specifier: ^20.14.0
version: 20.19.43
@@ -956,6 +984,9 @@ packages:
'@tsconfig/node16@1.0.4':
resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==}
+ '@types/ali-oss@6.23.3':
+ resolution: {integrity: sha512-huSf6njkqhSeR37OMJTGMCyUg2d9Zp2AioefhYuUMeh0vNM+WybctrBor7bO/RcCbLCQI6sHn6NtPBIWALYVJg==}
+
'@types/babel__core@7.20.5':
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
@@ -1001,6 +1032,9 @@ packages:
'@types/mime@1.3.5':
resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==}
+ '@types/multer@2.1.0':
+ resolution: {integrity: sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==}
+
'@types/node@20.19.43':
resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==}
@@ -1118,6 +1152,14 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
+ address@1.2.2:
+ resolution: {integrity: sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA==}
+ engines: {node: '>= 10.0.0'}
+
+ agentkeepalive@3.5.3:
+ resolution: {integrity: sha512-yqXL+k5rr8+ZRpOAntkaaRgWgE5o8ESAj5DyRmVTCSoZxXmqemb9Dd7T4i5UzwuERdLAJUy6XzR9zFVuf0kzkw==}
+ engines: {node: '>= 4.0.0'}
+
ajv-formats@2.1.1:
resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==}
peerDependencies:
@@ -1145,6 +1187,10 @@ packages:
ajv@8.20.0:
resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
+ ali-oss@6.23.0:
+ resolution: {integrity: sha512-FipRmyd16Pr/tEey/YaaQ/24Pc3HEpLM9S1DRakEuXlSLXNIJnu1oJtHM53eVYpvW3dXapSjrip3xylZUTIZVQ==}
+ engines: {node: '>=8'}
+
ansi-colors@4.1.3:
resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==}
engines: {node: '>=6'}
@@ -1179,6 +1225,9 @@ packages:
react: '>=16.9.0'
react-dom: '>=16.9.0'
+ any-promise@1.3.0:
+ resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
+
anymatch@3.1.3:
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
engines: {node: '>= 8'}
@@ -1223,6 +1272,9 @@ packages:
resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==}
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
+ bowser@1.9.4:
+ resolution: {integrity: sha512-9IdMmj2KjigRq6oWhmwv1W36pDuA4STQZ8q6YO9um+x07xgYNCD3Oou+WP/3L1HNz7iqythGet3/p4wvc8AAwQ==}
+
brace-expansion@1.1.15:
resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==}
@@ -1247,6 +1299,9 @@ packages:
buffer@5.7.1:
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
+ builtin-status-codes@3.0.0:
+ resolution: {integrity: sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ==}
+
bullmq@5.79.2:
resolution: {integrity: sha512-FebD+8XCZl/hnS1R4to24L4EAN70XSndKZO0776M36vGRk5MKVhKlNFM8/34zXLXKyYB4QaeIPFhVXSYYGTHpQ==}
engines: {node: '>=12.22.0'}
@@ -1412,6 +1467,9 @@ packages:
copy-to-clipboard@3.3.3:
resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==}
+ copy-to@2.0.1:
+ resolution: {integrity: sha512-3DdaFaU/Zf1AnpLiFDeNCD4TOWe3Zl2RZaTzUvWiIk5ERzcCodOE20Vqq4fzCbNoHURFHT4/us/Lfq+S2zyY4w==}
+
core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
@@ -1442,6 +1500,9 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+ dateformat@2.2.0:
+ resolution: {integrity: sha512-GODcnWq3YGoTnygPfi02ygEiRxqUxpJwuRHjdhJYuxpcZmDq4rjBiXYmbCCzStxo176ixfLT6i4NPwQooRySnw==}
+
dayjs@1.11.21:
resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==}
@@ -1470,6 +1531,10 @@ packages:
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
engines: {node: '>=0.10.0'}
+ default-user-agent@1.0.0:
+ resolution: {integrity: sha512-bDF7bg6OSNcSwFWPu4zYKpVkJZQYVrAANMYB8bc9Szem1D0yKdm4sa/rOCs2aC9+2GMqQ7KnwtZRvDhmLF0dXw==}
+ engines: {node: '>= 0.10.0'}
+
defaults@1.0.4:
resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==}
@@ -1501,6 +1566,10 @@ packages:
resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==}
engines: {node: '>=0.3.1'}
+ digest-header@1.1.0:
+ resolution: {integrity: sha512-glXVh42vz40yZb9Cq2oMOt70FIoWiv+vxNvdKdU8CwjLad25qHM3trLxhl9bVjdr6WaslIXhWpn0NO8T/67Qjg==}
+ engines: {node: '>= 8.0.0'}
+
dotenv-expand@10.0.0:
resolution: {integrity: sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==}
engines: {node: '>=12'}
@@ -1538,6 +1607,13 @@ packages:
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
engines: {node: '>= 0.8'}
+ end-of-stream@1.4.5:
+ resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
+
+ end-or-error@1.0.1:
+ resolution: {integrity: sha512-OclLMSug+k2A0JKuf494im25ANRBVW8qsjmwbgX7lQ8P82H21PQ1PWkoYwb9y5yMBS69BPlwtzdIFClo3+7kOQ==}
+ engines: {node: '>= 0.11.14'}
+
enhanced-resolve@5.24.1:
resolution: {integrity: sha512-7DdUaTjmNwMcH2gLr1qycesKII3BK4RLy/mdAb7x10Lq7bR4aNKHt1BR1ZALSv0rPM/hF5wYF0PhGop/rJm8vw==}
engines: {node: '>=10.13.0'}
@@ -1616,6 +1692,10 @@ packages:
resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==}
engines: {node: '>= 0.10.0'}
+ extend-shallow@2.0.1:
+ resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==}
+ engines: {node: '>=0.10.0'}
+
external-editor@3.1.0:
resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==}
engines: {node: '>=4'}
@@ -1662,6 +1742,9 @@ packages:
typescript: '>3.6.0'
webpack: ^5.11.0
+ formstream@1.5.2:
+ resolution: {integrity: sha512-NASf0lgxC1AyKNXQIrXTEYkiX99LhCEXTkiGObXAkpBui86a4u8FjH1o2bGb3PpqI3kafC+yw4zWeK6l6VHTgg==}
+
forwarded@0.2.0:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'}
@@ -1700,6 +1783,9 @@ packages:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
engines: {node: '>= 0.4'}
+ get-ready@1.0.0:
+ resolution: {integrity: sha512-mFXCZPJIlcYcth+N8267+mghfYN9h3EhsDa6JSnbA3Wrhh/XFpuowviFcsDeYZtKspQyWyJqfs4O6P8CHeTwzw==}
+
get-stream@8.0.1:
resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==}
engines: {node: '>=16'}
@@ -1750,10 +1836,17 @@ packages:
resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==}
engines: {node: '>=16.17.0'}
+ humanize-ms@1.2.1:
+ resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==}
+
iconv-lite@0.4.24:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
engines: {node: '>=0.10.0'}
+ iconv-lite@0.6.3:
+ resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
+ engines: {node: '>=0.10.0'}
+
ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
@@ -1796,6 +1889,13 @@ packages:
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
engines: {node: '>=8'}
+ is-class-hotfix@0.0.6:
+ resolution: {integrity: sha512-0n+pzCC6ICtVr/WXnN2f03TK/3BfXY7me4cjCAqT8TYXEl0+JBRoqBo94JJHXcyDSLUeWbNX8Fvy5g5RJdAstQ==}
+
+ is-extendable@0.1.1:
+ resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==}
+ engines: {node: '>=0.10.0'}
+
is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
@@ -1820,13 +1920,22 @@ packages:
resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+ is-type-of@1.4.0:
+ resolution: {integrity: sha512-EddYllaovi5ysMLMEN7yzHEKh8A850cZ7pykrY1aNRQGn/CDjRDE9qEWbIdt7xGEVJmjBXzU/fNnC4ABTm8tEQ==}
+
is-unicode-supported@0.1.0:
resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==}
engines: {node: '>=10'}
+ isarray@1.0.0:
+ resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
+
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
+ isstream@0.1.2:
+ resolution: {integrity: sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==}
+
iterare@1.2.1:
resolution: {integrity: sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==}
engines: {node: '>=6'}
@@ -1838,6 +1947,9 @@ packages:
resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==}
engines: {node: '>= 10.13.0'}
+ js-base64@2.6.4:
+ resolution: {integrity: sha512-pZe//GGmwJndub7ZghVHz7vjb2LgC1m8B07Au3eYqeqv9emhESByMXxaEgkUkEqJe87oBbSniGYoQNIBklc7IQ==}
+
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -1883,6 +1995,9 @@ packages:
resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==}
engines: {node: '>=12', npm: '>=6'}
+ jstoxml@2.2.9:
+ resolution: {integrity: sha512-OYWlK0j+roh+eyaMROlNbS5cd5R25Y+IUpdl7cNdB8HNrkgwQzIS7L9MegxOiWNBj9dQhA/yAxiMwCC5mwNoBw==}
+
jwa@1.4.2:
resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==}
@@ -2002,6 +2117,11 @@ packages:
engines: {node: '>=4'}
hasBin: true
+ mime@2.6.0:
+ resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==}
+ engines: {node: '>=4.0.0'}
+ hasBin: true
+
mimic-fn@2.1.0:
resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==}
engines: {node: '>=6'}
@@ -2055,6 +2175,9 @@ packages:
resolution: {integrity: sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
+ mz@2.7.0:
+ resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
+
nanoid@3.3.15:
resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -2086,6 +2209,10 @@ packages:
resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==}
hasBin: true
+ node-hex@1.0.1:
+ resolution: {integrity: sha512-iwpZdvW6Umz12ICmu9IYPRxg0tOLGmU3Tq2tKetejCj3oZd7b2nUXwP3a7QA5M9glWy8wlPS1G3RwM/CdsUbdQ==}
+ engines: {node: '>=8.0.0'}
+
node-releases@2.0.50:
resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==}
engines: {node: '>=18'}
@@ -2110,6 +2237,9 @@ packages:
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
engines: {node: '>= 0.8'}
+ once@1.4.0:
+ resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
+
onetime@5.1.2:
resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
engines: {node: '>=6'}
@@ -2122,10 +2252,20 @@ packages:
resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==}
engines: {node: '>=10'}
+ os-name@1.0.3:
+ resolution: {integrity: sha512-f5estLO2KN8vgtTRaILIgEGBoBrMnZ3JQ7W9TMZCnOIGwHe8TRGSpcagnWDo+Dfhd/z08k9Xe75hvciJJ8Qaew==}
+ engines: {node: '>=0.10.0'}
+ hasBin: true
+
os-tmpdir@1.0.2:
resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==}
engines: {node: '>=0.10.0'}
+ osx-release@1.1.0:
+ resolution: {integrity: sha512-ixCMMwnVxyHFQLQnINhmIpWqXIfS2YOXchwQrk+OFzmo6nDjQ0E4KXAyyUh0T0MZgV4bUhkRrAbVqlE4yLVq4A==}
+ engines: {node: '>=0.10.0'}
+ hasBin: true
+
p-limit@5.0.0:
resolution: {integrity: sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==}
engines: {node: '>=18'}
@@ -2176,6 +2316,9 @@ packages:
pathval@1.1.1:
resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==}
+ pause-stream@0.0.11:
+ resolution: {integrity: sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==}
+
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -2190,6 +2333,9 @@ packages:
pkg-types@1.3.1:
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
+ platform@1.3.6:
+ resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==}
+
pluralize@8.0.0:
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
engines: {node: '>=4'}
@@ -2207,10 +2353,16 @@ packages:
engines: {node: '>=16.13'}
hasBin: true
+ process-nextick-args@2.0.1:
+ resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
+
proxy-addr@2.0.7:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'}
+ pump@3.0.4:
+ resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==}
+
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
@@ -2484,6 +2636,9 @@ packages:
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
engines: {node: '>=0.10.0'}
+ readable-stream@2.3.8:
+ resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
+
readable-stream@3.6.2:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'}
@@ -2541,12 +2696,19 @@ packages:
rxjs@7.8.2:
resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
+ safe-buffer@5.1.2:
+ resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
+
safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
+ sax@1.6.0:
+ resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==}
+ engines: {node: '>=11.0.0'}
+
scheduler@0.23.2:
resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
@@ -2561,6 +2723,13 @@ packages:
scroll-into-view-if-needed@3.1.0:
resolution: {integrity: sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==}
+ sdk-base@2.0.1:
+ resolution: {integrity: sha512-eeG26wRwhtwYuKGCDM3LixCaxY27Pa/5lK4rLKhQa7HBjJ3U3Y+f81MMZQRsDw/8SC2Dao/83yJTXJ8aULuN8Q==}
+
+ semver@5.7.2:
+ resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==}
+ hasBin: true
+
semver@6.3.1:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
@@ -2640,6 +2809,10 @@ packages:
standard-as-callback@2.1.0:
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
+ statuses@1.5.0:
+ resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==}
+ engines: {node: '>= 0.6'}
+
statuses@2.0.2:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
@@ -2647,6 +2820,13 @@ packages:
std-env@3.10.0:
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
+ stream-http@2.8.2:
+ resolution: {integrity: sha512-QllfrBhqF1DPcz46WxKTs6Mz1Bpc+8Qm6vbqOpVav5odAXwbyzwnEczoWqtxrsmlO+cJqtPrp/8gWKWjaKLLlA==}
+
+ stream-wormhole@1.1.0:
+ resolution: {integrity: sha512-gHFfL3px0Kctd6Po0M8TzEvt3De/xu6cnRrjlfYNhwbhLPLwigI2t1nc6jrzNuaYg5C4YF78PPFuQPzRiqn9ew==}
+ engines: {node: '>=4.0.0'}
+
streamsearch@1.1.0:
resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==}
engines: {node: '>=10.0.0'}
@@ -2662,6 +2842,9 @@ packages:
resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==}
engines: {node: '>=12'}
+ string_decoder@1.1.1:
+ resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
+
string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
@@ -2755,6 +2938,13 @@ packages:
engines: {node: '>=10'}
hasBin: true
+ thenify-all@1.6.0:
+ resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
+ engines: {node: '>=0.8'}
+
+ thenify@3.3.1:
+ resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
+
throttle-debounce@5.0.2:
resolution: {integrity: sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==}
engines: {node: '>=12.22'}
@@ -2777,6 +2967,9 @@ packages:
resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==}
engines: {node: '>=0.6.0'}
+ to-arraybuffer@1.0.1:
+ resolution: {integrity: sha512-okFlQcoGTi4LQBG/PgSYblw9VOyptsz2KJZqc6qtgGdes8VktzUQkj4BI2blit072iS8VODNcMA+tvnS9dnuMA==}
+
to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
@@ -2863,6 +3056,10 @@ packages:
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
+ unescape@1.0.1:
+ resolution: {integrity: sha512-O0+af1Gs50lyH1nUu3ZyYS1cRh01Q/kUKatTOkSs7jukXE6/NebucDVxyiDsA9AQ4JC1V1jUH9EO8JX2nMDgGQ==}
+ engines: {node: '>=0.10.0'}
+
universalify@2.0.1:
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
engines: {node: '>= 10.0.0'}
@@ -2880,9 +3077,22 @@ packages:
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
+ urllib@2.44.1:
+ resolution: {integrity: sha512-vreOVvFizoiIz5NK9IYMgUknkriHHBVccn2VFfJhgKz6O2qwm0SgjFk4OpXFRDXpdrTx8EzM1DB0/pejrqXwPA==}
+ engines: {node: '>= 0.10.0'}
+ peerDependencies:
+ proxy-agent: ^5.0.0
+ peerDependenciesMeta:
+ proxy-agent:
+ optional: true
+
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
+ utility@1.18.0:
+ resolution: {integrity: sha512-PYxZDA+6QtvRvm//++aGdmKG/cI07jNwbROz0Ql+VzFV1+Z0Dy55NI4zZ7RHc9KKpBePNFwoErqIuqQv/cjiTA==}
+ engines: {node: '>= 0.12.0'}
+
utils-merge@1.0.1:
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
engines: {node: '>= 0.4.0'}
@@ -3000,6 +3210,10 @@ packages:
engines: {node: '>=8'}
hasBin: true
+ win-release@1.1.1:
+ resolution: {integrity: sha512-iCRnKVvGxOQdsKhcQId2PXV1vV3J/sDPXKA4Oe9+Eti2nb2ESEsYHRYls/UjoUW3bIc5ZDO8dTH50A/5iVN+bw==}
+ engines: {node: '>=0.10.0'}
+
wrap-ansi@6.2.0:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
@@ -3012,6 +3226,17 @@ packages:
resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
engines: {node: '>=12'}
+ wrappy@1.0.2:
+ resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
+
+ xml2js@0.6.2:
+ resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==}
+ engines: {node: '>=4.0.0'}
+
+ xmlbuilder@11.0.1:
+ resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==}
+ engines: {node: '>=4.0'}
+
xtend@4.0.2:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
@@ -3709,6 +3934,8 @@ snapshots:
'@tsconfig/node16@1.0.4': {}
+ '@types/ali-oss@6.23.3': {}
+
'@types/babel__core@7.20.5':
dependencies:
'@babel/parser': 7.29.7
@@ -3775,6 +4002,10 @@ snapshots:
'@types/mime@1.3.5': {}
+ '@types/multer@2.1.0':
+ dependencies:
+ '@types/express': 4.17.25
+
'@types/node@20.19.43':
dependencies:
undici-types: 6.21.0
@@ -3943,6 +4174,12 @@ snapshots:
acorn@8.17.0: {}
+ address@1.2.2: {}
+
+ agentkeepalive@3.5.3:
+ dependencies:
+ humanize-ms: 1.2.1
+
ajv-formats@2.1.1(ajv@8.12.0):
optionalDependencies:
ajv: 8.12.0
@@ -3981,6 +4218,37 @@ snapshots:
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
+ ali-oss@6.23.0:
+ dependencies:
+ address: 1.2.2
+ agentkeepalive: 3.5.3
+ bowser: 1.9.4
+ copy-to: 2.0.1
+ dateformat: 2.2.0
+ debug: 4.4.3
+ destroy: 1.2.0
+ end-or-error: 1.0.1
+ get-ready: 1.0.0
+ humanize-ms: 1.2.1
+ is-type-of: 1.4.0
+ js-base64: 2.6.4
+ jstoxml: 2.2.9
+ lodash: 4.18.1
+ merge-descriptors: 1.0.3
+ mime: 2.6.0
+ platform: 1.3.6
+ pump: 3.0.4
+ qs: 6.14.2
+ sdk-base: 2.0.1
+ stream-http: 2.8.2
+ stream-wormhole: 1.1.0
+ urllib: 2.44.1
+ utility: 1.18.0
+ xml2js: 0.6.2
+ transitivePeerDependencies:
+ - proxy-agent
+ - supports-color
+
ansi-colors@4.1.3: {}
ansi-escapes@4.3.2:
@@ -4057,6 +4325,8 @@ snapshots:
- luxon
- moment
+ any-promise@1.3.0: {}
+
anymatch@3.1.3:
dependencies:
normalize-path: 3.0.0
@@ -4105,6 +4375,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ bowser@1.9.4: {}
+
brace-expansion@1.1.15:
dependencies:
balanced-match: 1.0.2
@@ -4135,6 +4407,8 @@ snapshots:
base64-js: 1.5.1
ieee754: 1.2.1
+ builtin-status-codes@3.0.0: {}
+
bullmq@5.79.2:
dependencies:
cron-parser: 4.9.0
@@ -4295,6 +4569,8 @@ snapshots:
dependencies:
toggle-selection: 1.0.6
+ copy-to@2.0.1: {}
+
core-util-is@1.0.3: {}
cors@2.8.5:
@@ -4325,6 +4601,8 @@ snapshots:
csstype@3.2.3: {}
+ dateformat@2.2.0: {}
+
dayjs@1.11.21: {}
debug@2.6.9:
@@ -4341,6 +4619,10 @@ snapshots:
deepmerge@4.3.1: {}
+ default-user-agent@1.0.0:
+ dependencies:
+ os-name: 1.0.3
+
defaults@1.0.4:
dependencies:
clone: 1.0.4
@@ -4364,6 +4646,8 @@ snapshots:
diff@4.0.4: {}
+ digest-header@1.1.0: {}
+
dotenv-expand@10.0.0: {}
dotenv@16.4.5: {}
@@ -4394,6 +4678,12 @@ snapshots:
encodeurl@2.0.0: {}
+ end-of-stream@1.4.5:
+ dependencies:
+ once: 1.4.0
+
+ end-or-error@1.0.1: {}
+
enhanced-resolve@5.24.1:
dependencies:
graceful-fs: 4.2.11
@@ -4516,6 +4806,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ extend-shallow@2.0.1:
+ dependencies:
+ is-extendable: 0.1.1
+
external-editor@3.1.0:
dependencies:
chardet: 0.7.0
@@ -4583,6 +4877,13 @@ snapshots:
typescript: 5.7.2
webpack: 5.97.1
+ formstream@1.5.2:
+ dependencies:
+ destroy: 1.2.0
+ mime: 2.6.0
+ node-hex: 1.0.1
+ pause-stream: 0.0.11
+
forwarded@0.2.0: {}
fresh@0.5.2: {}
@@ -4622,6 +4923,8 @@ snapshots:
dunder-proto: 1.0.1
es-object-atoms: 1.1.2
+ get-ready@1.0.0: {}
+
get-stream@8.0.1: {}
glob-parent@5.1.2:
@@ -4667,10 +4970,18 @@ snapshots:
human-signals@5.0.0: {}
+ humanize-ms@1.2.1:
+ dependencies:
+ ms: 2.1.3
+
iconv-lite@0.4.24:
dependencies:
safer-buffer: 2.1.2
+ iconv-lite@0.6.3:
+ dependencies:
+ safer-buffer: 2.1.2
+
ieee754@1.2.1: {}
import-fresh@3.3.1:
@@ -4754,6 +5065,10 @@ snapshots:
dependencies:
binary-extensions: 2.3.0
+ is-class-hotfix@0.0.6: {}
+
+ is-extendable@0.1.1: {}
+
is-extglob@2.1.1: {}
is-fullwidth-code-point@3.0.0: {}
@@ -4768,10 +5083,20 @@ snapshots:
is-stream@3.0.0: {}
+ is-type-of@1.4.0:
+ dependencies:
+ core-util-is: 1.0.3
+ is-class-hotfix: 0.0.6
+ isstream: 0.1.2
+
is-unicode-supported@0.1.0: {}
+ isarray@1.0.0: {}
+
isexe@2.0.0: {}
+ isstream@0.1.2: {}
+
iterare@1.2.1: {}
jackspeak@3.4.3:
@@ -4786,6 +5111,8 @@ snapshots:
merge-stream: 2.0.0
supports-color: 8.1.1
+ js-base64@2.6.4: {}
+
js-tokens@4.0.0: {}
js-tokens@9.0.1: {}
@@ -4831,6 +5158,8 @@ snapshots:
ms: 2.1.3
semver: 7.8.5
+ jstoxml@2.2.9: {}
+
jwa@1.4.2:
dependencies:
buffer-equal-constant-time: 1.0.1
@@ -4928,6 +5257,8 @@ snapshots:
mime@1.6.0: {}
+ mime@2.6.0: {}
+
mimic-fn@2.1.0: {}
mimic-fn@4.0.0: {}
@@ -4989,6 +5320,12 @@ snapshots:
mute-stream@1.0.0: {}
+ mz@2.7.0:
+ dependencies:
+ any-promise: 1.3.0
+ object-assign: 4.1.1
+ thenify-all: 1.6.0
+
nanoid@3.3.15: {}
negotiator@0.6.3: {}
@@ -5010,6 +5347,8 @@ snapshots:
detect-libc: 2.1.2
optional: true
+ node-hex@1.0.1: {}
+
node-releases@2.0.50: {}
normalize-path@3.0.0: {}
@@ -5026,6 +5365,10 @@ snapshots:
dependencies:
ee-first: 1.1.1
+ once@1.4.0:
+ dependencies:
+ wrappy: 1.0.2
+
onetime@5.1.2:
dependencies:
mimic-fn: 2.1.0
@@ -5046,8 +5389,17 @@ snapshots:
strip-ansi: 6.0.1
wcwidth: 1.0.1
+ os-name@1.0.3:
+ dependencies:
+ osx-release: 1.1.0
+ win-release: 1.1.1
+
os-tmpdir@1.0.2: {}
+ osx-release@1.1.0:
+ dependencies:
+ minimist: 1.2.8
+
p-limit@5.0.0:
dependencies:
yocto-queue: 1.2.2
@@ -5088,6 +5440,10 @@ snapshots:
pathval@1.1.1: {}
+ pause-stream@0.0.11:
+ dependencies:
+ through: 2.3.8
+
picocolors@1.1.1: {}
picomatch@2.3.2: {}
@@ -5100,6 +5456,8 @@ snapshots:
mlly: 1.8.2
pathe: 2.0.3
+ platform@1.3.6: {}
+
pluralize@8.0.0: {}
postcss@8.5.15:
@@ -5120,11 +5478,18 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
+ process-nextick-args@2.0.1: {}
+
proxy-addr@2.0.7:
dependencies:
forwarded: 0.2.0
ipaddr.js: 1.9.1
+ pump@3.0.4:
+ dependencies:
+ end-of-stream: 1.4.5
+ once: 1.4.0
+
punycode@2.3.1: {}
qs@6.14.2:
@@ -5486,6 +5851,16 @@ snapshots:
dependencies:
loose-envify: 1.4.0
+ readable-stream@2.3.8:
+ dependencies:
+ core-util-is: 1.0.3
+ inherits: 2.0.4
+ isarray: 1.0.0
+ process-nextick-args: 2.0.1
+ safe-buffer: 5.1.2
+ string_decoder: 1.1.1
+ util-deprecate: 1.0.2
+
readable-stream@3.6.2:
dependencies:
inherits: 2.0.4
@@ -5560,10 +5935,14 @@ snapshots:
dependencies:
tslib: 2.8.1
+ safe-buffer@5.1.2: {}
+
safe-buffer@5.2.1: {}
safer-buffer@2.1.2: {}
+ sax@1.6.0: {}
+
scheduler@0.23.2:
dependencies:
loose-envify: 1.4.0
@@ -5585,6 +5964,12 @@ snapshots:
dependencies:
compute-scroll-into-view: 3.1.1
+ sdk-base@2.0.1:
+ dependencies:
+ get-ready: 1.0.0
+
+ semver@5.7.2: {}
+
semver@6.3.1: {}
semver@7.8.5: {}
@@ -5682,10 +6067,22 @@ snapshots:
standard-as-callback@2.1.0: {}
+ statuses@1.5.0: {}
+
statuses@2.0.2: {}
std-env@3.10.0: {}
+ stream-http@2.8.2:
+ dependencies:
+ builtin-status-codes: 3.0.0
+ inherits: 2.0.4
+ readable-stream: 2.3.8
+ to-arraybuffer: 1.0.1
+ xtend: 4.0.2
+
+ stream-wormhole@1.1.0: {}
+
streamsearch@1.1.0: {}
string-convert@0.2.1: {}
@@ -5702,6 +6099,10 @@ snapshots:
emoji-regex: 9.2.2
strip-ansi: 7.2.0
+ string_decoder@1.1.1:
+ dependencies:
+ safe-buffer: 5.1.2
+
string_decoder@1.3.0:
dependencies:
safe-buffer: 5.2.1
@@ -5755,6 +6156,14 @@ snapshots:
commander: 2.20.3
source-map-support: 0.5.21
+ thenify-all@1.6.0:
+ dependencies:
+ thenify: 3.3.1
+
+ thenify@3.3.1:
+ dependencies:
+ any-promise: 1.3.0
+
throttle-debounce@5.0.2: {}
through@2.3.8: {}
@@ -5769,6 +6178,8 @@ snapshots:
dependencies:
os-tmpdir: 1.0.2
+ to-arraybuffer@1.0.1: {}
+
to-regex-range@5.0.1:
dependencies:
is-number: 7.0.0
@@ -5845,6 +6256,10 @@ snapshots:
undici-types@6.21.0: {}
+ unescape@1.0.1:
+ dependencies:
+ extend-shallow: 2.0.1
+
universalify@2.0.1: {}
unpipe@1.0.0: {}
@@ -5859,8 +6274,31 @@ snapshots:
dependencies:
punycode: 2.3.1
+ urllib@2.44.1:
+ dependencies:
+ any-promise: 1.3.0
+ content-type: 1.0.5
+ default-user-agent: 1.0.0
+ digest-header: 1.1.0
+ ee-first: 1.1.1
+ formstream: 1.5.2
+ humanize-ms: 1.2.1
+ iconv-lite: 0.6.3
+ pump: 3.0.4
+ qs: 6.14.2
+ statuses: 1.5.0
+ utility: 1.18.0
+
util-deprecate@1.0.2: {}
+ utility@1.18.0:
+ dependencies:
+ copy-to: 2.0.1
+ escape-html: 1.0.3
+ mkdirp: 0.5.6
+ mz: 2.7.0
+ unescape: 1.0.1
+
utils-merge@1.0.1: {}
v8-compile-cache-lib@3.0.1: {}
@@ -5998,6 +6436,10 @@ snapshots:
siginfo: 2.0.0
stackback: 0.0.2
+ win-release@1.1.1:
+ dependencies:
+ semver: 5.7.2
+
wrap-ansi@6.2.0:
dependencies:
ansi-styles: 4.3.0
@@ -6016,6 +6458,15 @@ snapshots:
string-width: 5.1.2
strip-ansi: 7.2.0
+ wrappy@1.0.2: {}
+
+ xml2js@0.6.2:
+ dependencies:
+ sax: 1.6.0
+ xmlbuilder: 11.0.1
+
+ xmlbuilder@11.0.1: {}
+
xtend@4.0.2: {}
yallist@3.1.1: {}
diff --git a/scripts/configure-oss-cors.mjs b/scripts/configure-oss-cors.mjs
new file mode 100644
index 0000000..c760789
--- /dev/null
+++ b/scripts/configure-oss-cors.mjs
@@ -0,0 +1,85 @@
+/**
+ * 为 OSS Bucket 配置浏览器直传所需的 CORS 规则。
+ * 使用 server/dukang-api/.env 中的 OSS 凭证。
+ *
+ * 用法:pnpm oss:cors
+ * 可选环境变量 OSS_CORS_ORIGINS(逗号分隔),默认包含本地 H5 / admin 端口。
+ */
+import { readFileSync, existsSync } from 'node:fs';
+import { resolve, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { createRequire } from 'node:module';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const apiRoot = resolve(__dirname, '../server/dukang-api');
+const require = createRequire(resolve(apiRoot, 'package.json'));
+const OSS = require('ali-oss');
+const envPath = resolve(apiRoot, '.env');
+
+function loadEnvFile(path: string) {
+ if (!existsSync(path)) return;
+ for (const line of readFileSync(path, 'utf8').split(/\r?\n/)) {
+ const trimmed = line.trim();
+ if (!trimmed || trimmed.startsWith('#')) continue;
+ const eq = trimmed.indexOf('=');
+ if (eq <= 0) continue;
+ const key = trimmed.slice(0, eq).trim();
+ let value = trimmed.slice(eq + 1).trim();
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
+ value = value.slice(1, -1);
+ }
+ if (process.env[key] === undefined) process.env[key] = value;
+ }
+}
+
+loadEnvFile(envPath);
+
+const {
+ OSS_ACCESS_KEY_ID: accessKeyId,
+ OSS_ACCESS_KEY_SECRET: accessKeySecret,
+ OSS_BUCKET: bucket,
+ OSS_REGION: region = 'oss-cn-hangzhou',
+ OSS_CORS_ORIGINS,
+} = process.env;
+
+if (!accessKeyId || !accessKeySecret || !bucket) {
+ console.error('请在 server/dukang-api/.env 配置 OSS_ACCESS_KEY_ID、OSS_ACCESS_KEY_SECRET、OSS_BUCKET');
+ process.exit(1);
+}
+
+const defaultOrigins = [
+ 'http://localhost:5173',
+ 'http://localhost:5174',
+ 'http://localhost:5175',
+ 'http://127.0.0.1:5173',
+ 'http://127.0.0.1:5174',
+ 'http://127.0.0.1:5175',
+];
+
+const allowedOrigin = (OSS_CORS_ORIGINS ?? defaultOrigins.join(','))
+ .split(',')
+ .map((s) => s.trim())
+ .filter(Boolean);
+
+const client = new OSS({
+ region,
+ accessKeyId,
+ accessKeySecret,
+ bucket,
+});
+
+const rules = [
+ {
+ allowedOrigin,
+ allowedMethod: ['GET', 'POST', 'PUT', 'HEAD'],
+ allowedHeader: ['*'],
+ exposeHeader: ['ETag', 'x-oss-request-id'],
+ maxAgeSeconds: 600,
+ },
+];
+
+console.log(`配置 Bucket「${bucket}」CORS,允许来源:`);
+for (const origin of allowedOrigin) console.log(` - ${origin}`);
+
+await client.putBucketCORS(bucket, rules);
+console.log('CORS 规则已写入。若 H5 端仍直传 OSS,请确认来源域名已包含在列表中。');
diff --git a/server/dukang-api/.env.example b/server/dukang-api/.env.example
index 405da35..cb14b88 100644
--- a/server/dukang-api/.env.example
+++ b/server/dukang-api/.env.example
@@ -10,4 +10,40 @@ MOCK_DELIVERY_AUTO=true
AUTO_APPROVE_STORE=true
# 反向代理后提取真实客户端 IP(下单 IP 定位)
-# TRUST_PROXY=true
+TRUST_PROXY=true
+
+# 微信SDK(WECHAT_AUTH_ENABLED=true 时生效)
+WX_APP_ID=
+WX_APP_SECRET=
+WECHAT_AUTH_ENABLED=false
+WECHAT_PAY_ENABLED=false
+WX_MCH_ID=
+WX_MCH_SERIAL_NO=
+WX_MCH_PRIVATE_KEY=
+WX_API_V3_KEY=
+# 微信平台公钥证书 PEM(生产环境必填,用于回调验签;开发可暂留空)
+WX_PLATFORM_CERT=
+WX_PAY_NOTIFY_URL=https://your-domain.com/api/v1/callbacks/wechat/pay
+
+# 阿里云 OSS(ali-oss@6.x;OSS_ENABLED=true 且下方密钥齐全时生效;否则 Mock 占位 URL)
+# 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=
+OSS_REGION=oss-cn-hangzhou
+# 可选:自定义 Endpoint(如内网 oss-cn-hangzhou-internal.aliyuncs.com)
+OSS_ENDPOINT=
+# 可选:开启 V4 签名(需同步调整前端 PostObject 字段,默认 false 使用 V1)
+OSS_AUTHORIZATION_V4=false
+# 公网访问域名:自定义 CDN 或 https://{bucket}.{region}.aliyuncs.com
+OSS_CDN_BASE=
+# 直传对象前缀(默认 uploads)
+OSS_UPLOAD_PREFIX=uploads
+# 直传凭证有效期(秒,默认 900)
+OSS_UPLOAD_EXPIRE_SECONDS=900
+# 单文件大小上限(字节,默认 10MB)
+OSS_MAX_UPLOAD_BYTES=10485760
+# 浏览器直传 OSS 时需配置 Bucket CORS;运行 pnpm oss:cors 或控制台手动添加
+# OSS_CORS_ORIGINS=http://localhost:5173,http://localhost:5174,http://localhost:5175,https://your-domain.com
\ No newline at end of file
diff --git a/server/dukang-api/AGENTS.md b/server/dukang-api/AGENTS.md
new file mode 100644
index 0000000..cb43a60
--- /dev/null
+++ b/server/dukang-api/AGENTS.md
@@ -0,0 +1,75 @@
+# 杜康好客 · 后端 AGENTS.md
+
+> 父级:[`../AGENTS.md`](../AGENTS.md) · API 契约:V2 手册 §六 · 表结构:§五
+
+## 范围
+
+本目录 = 唯一后端进程 `dukang-api`(NestJS 10 + Prisma 5)。
+
+## 模块 → 表 OWNER(写权限)
+
+| Module | 主要 Prisma Model | 负责人 |
+|--------|-------------------|--------|
+| **iam** | User, UserAddress, … | jacy-dukang |
+| **catalog** | CommonCity, CommonProductItem, … | jacy-dukang |
+| **store** | Store, Partner | 刘京尧 |
+| **trade** | Order, OrderDelivery | jacy-dukang |
+| **benefit** | BenefitCoupon | jacy-dukang |
+| **redeem** | RedeemRecord, StoreRating | 刘京尧 |
+| **settlement** | StorePayout, PartnerBill | jacy-dukang |
+| **ops** | 只读聚合 | jacy-dukang |
+| **analytics** | LogUserAnalytics | jacy-dukang |
+| **common** | CommonResource, CommonEvent, CommonTicket | jacy-dukang |
+| **integrations** | 无表 | jacy-dukang |
+
+**log_***:`LogThirdParty` 由写入方 Module 负责(支付→trade,短信→iam/notify)。
+
+## 允许依赖(摘要)
+
+```
+trade → catalog, store, benefit, iam, notify, common, domain
+benefit → iam, common, domain(禁止 → trade/redeem)
+redeem → benefit, store, settlement, iam, notify, common, domain
+store → catalog, iam, common, domain(禁止 → trade/benefit)
+settlement → trade, redeem, store, catalog, iam, notify, common, domain
+callbacks → trade, benefit, settlement, common(薄层,无业务表)
+jobs → trade, settlement, notify, common
+```
+
+完整矩阵见 V2 手册 §四 §2.5.2。
+
+## 新接口 checklist
+
+1. 在 **OWNER 模块** 建 `dto/`、`*.controller.ts`、`*.service.ts`
+2. Service 只写本模块表;跨域 inject exported Service
+3. Guard:`JwtAuthGuard` + `actorType` 校验(User/Store/Partner/HQ)
+4. 响应 `{ code: 0, message: 'ok', data }`(`ResponseInterceptor`)
+5. DTO/枚举同步 `packages/shared-types`
+6. preV1 Mock:改 `integrations/*`,不在 Controller 散落 `if (mock)`
+
+## Mock 集成(preV1)
+
+```
+integrations/
+├── sms/sms.mock.provider.ts
+├── pay/pay.mock.provider.ts
+└── delivery/delivery.mock.provider.ts
+```
+
+开关见 `packages/shared-types/src/config.ts`(`MOCK_SMS`, `MOCK_PAY`, `MOCK_DELIVERY_AUTO`)。
+
+## 常用命令
+
+```bash
+pnpm dev:api
+pnpm db:validate
+pnpm db:seed
+cd server/dukang-api && npx prisma studio
+```
+
+## 禁止
+
+- 在 `redeem` 里 `prisma.order.update` → 调 `TradeService`
+- 在 `benefit` 里 inject `TradeService` / `RedeemService`
+- 多个 Module 注册同一 callback 路由
+- 复制起购/核销规则到 Controller → 用 `packages/domain`
diff --git a/server/dukang-api/package.json b/server/dukang-api/package.json
index 98f7f25..639fa59 100644
--- a/server/dukang-api/package.json
+++ b/server/dukang-api/package.json
@@ -10,7 +10,8 @@
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:validate": "prisma validate",
- "prisma:seed": "ts-node --transpile-only prisma/seed-prev1.ts",
+ "prisma:seed": "ts-node --transpile-only prisma/seed-v31.ts",
+ "prisma:seed-legacy": "ts-node --transpile-only prisma/seed-prev1.ts",
"prisma:sync-benefit": "ts-node --transpile-only prisma/sync-benefit-to-price.ts"
},
"dependencies": {
@@ -23,6 +24,7 @@
"@nestjs/jwt": "^10.2.0",
"@nestjs/platform-express": "^10.4.0",
"@prisma/client": "^5.18.0",
+ "ali-oss": "^6.23.0",
"bullmq": "^5.12.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
@@ -34,7 +36,9 @@
"devDependencies": {
"@nestjs/cli": "^10.4.0",
"@nestjs/schematics": "^10.1.0",
+ "@types/ali-oss": "^6.23.3",
"@types/express": "^4.17.21",
+ "@types/multer": "^2.1.0",
"@types/node": "^20.14.0",
"prisma": "^5.18.0",
"ts-node": "^10.9.2",
diff --git a/server/dukang-api/prisma/V31_MIGRATION.md b/server/dukang-api/prisma/V31_MIGRATION.md
new file mode 100644
index 0000000..650d419
--- /dev/null
+++ b/server/dukang-api/prisma/V31_MIGRATION.md
@@ -0,0 +1,282 @@
+# V3.1 Schema 迁移清单
+
+> 生成自 `init_v3.sql` → `schema.v31.prisma`(已通过 `prisma validate`)
+> 旧版备份:`schema.legacy-v21.prisma`(当前运行中的 `schema.prisma`)
+
+## 激活步骤(P0)
+
+```bash
+cd server/dukang-api
+# 1. 备份并切换 schema
+cp prisma/schema.prisma prisma/schema.legacy-v21.prisma # 若尚未备份
+cp prisma/schema.v31.prisma prisma/schema.prisma
+
+# 2. 开发库建议删库重建
+mysql -u root -p -e "DROP DATABASE IF EXISTS dukang_haoke; CREATE DATABASE dukang_haoke ..."
+# 或:mysql < prisma/init_v3.sql
+
+# 3. 生成 Client
+pnpm db:generate
+npx prisma db push # 或 prisma migrate dev --name v31_init
+
+# 4. 新 seed(待编写 seed-v31.ts)
+pnpm prisma:seed
+```
+
+---
+
+## 表对照(27 张 v3.1)
+
+| v3.1 物理表 | Prisma Model | 旧表/Model | 变化 |
+|-------------|--------------|------------|------|
+| `common_wx_app_config` | `CommonWxAppConfig` | `wx_app_configs` / `WxAppConfig` | 重命名 |
+| `common_resource` | `CommonResource` | — | **新增**(替代 `store_media`、裸 URL) |
+| `common_event` | `CommonEvent` | `benefit_ledgers`, `order_status_logs`, `store_audits`, `event_logs`, `operation_logs` | **合并** |
+| `common_ticket` | `CommonTicket` | `after_sale_tickets`, `refunds`, `delivery_intercepts`, `alerts` | **合并** |
+| `common_product_item` | `CommonProductItem` | `products` / `Product` | 重命名 + `cover_resource_id` |
+| `common_store_category` | `CommonStoreCategory` | `store_categories` / `StoreCategory` | 重命名 |
+| `common_promo_code` | `CommonPromoCode` | `promo_codes` / `PromoCode` | 重命名 + `qrcode_resource_id` |
+| `common_city` | `CommonCity` | `cities` / `City` | 重命名 |
+| `common_city_commission_rule` | `CommonCityCommissionRule` | `city_commission_rules` | 重命名 |
+| `partner_partner` | `Partner` | `partners` | 重命名 + 合同字段内联 |
+| `partner_account` | `PartnerAccount` | `partner_accounts` | 重命名 |
+| `partner_bill` | `PartnerBill` | `partner_bills` | 状态枚举精简 |
+| `hq_account` | `HqAccount` | `hq_accounts` | 重命名 |
+| `user_user` | `User` | `users` | 重命名;`phone` 可空;含 `deviceKey`/合并字段 |
+| `user_address` | `UserAddress` | `user_addresses` | 重命名 |
+| `user_city_preference` | `UserCityPreference` | `user_city_preferences` | 重命名 |
+| `user_promo_attribution` | `UserPromoAttribution` | `user_promo_attributions` | 重命名 |
+| `store_store` | `Store` | `stores` | `cover_url` → `cover_resource_id` |
+| `store_account` | `StoreAccount` | `store_accounts` | 重命名 |
+| `user_order` | `Order` | `orders` | **无 order_items**;商品快照内嵌 |
+| `user_order_delivery` | `OrderDelivery` | `order_deliveries` | 重命名 + `sign_photo_resource_id` |
+| `user_benefit_coupon` | `BenefitCoupon` | `benefit_coupons` | 重命名 |
+| `user_redeem_record` | `RedeemRecord` | `redeem_records` | 重命名 |
+| `user_store_rating` | `StoreRating` | `store_ratings` | 重命名 |
+| `store_payout` | `StorePayout` | `store_payouts` | 重命名 |
+| `log_third_party` | `LogThirdParty` | `payments`, `sms_logs` | **合并** |
+| `log_user_analytics` | `LogUserAnalytics` | `event_logs`(埋点部分) | **拆分** |
+
+### 删除的表(v3.1 不再存在)
+
+| 旧表 | 替代方案 |
+|------|----------|
+| `store_media` | `common_resource`(`owner_type=STORE`) |
+| `partner_contracts` | `partner_partner.contract_*` + `common_resource` CONTRACT |
+| `order_items` | `user_order` 内嵌快照字段 |
+| `order_status_logs` | `common_event(ORDER_STATUS)` |
+| `payments` | `log_third_party` + `user_order.pay_*` |
+| `refunds` | `common_ticket(REFUND)` |
+| `delivery_intercepts` | `common_ticket(ALERT)` 或 RESHIPMENT |
+| `benefit_ledgers` | `common_event(BENEFIT_LEDGER)` |
+| `redeem_tokens` | **仅 Redis** |
+| `order_commissions` | `partner_bill` 汇总(无明细表) |
+| `partner_withdrawals` | 手册 v3.1 未包含(后续按需) |
+| `store_audits` | `common_event(STORE_AUDIT)` |
+| `after_sale_tickets` | `common_ticket` |
+| `alerts` | `common_ticket(ALERT)` |
+| `operation_logs` | `common_event(HQ_OPERATION)` |
+| `event_logs` | `common_event` + `log_user_analytics` |
+
+### preV1 扩展字段(已并入 v3.1)
+
+| 字段 | 表 | 说明 |
+|------|-----|------|
+| `device_key` / `phone_verified_at` / `merged_into_user_id` | `user_user` | 访客 JWT + 验机 + 账号合并 |
+| `client_ip` / `ip_*` / `gps_*` | `user_order` | 下单位置快照 |
+
+~~以下字段在切换后丢失~~ → **已保留**
+
+---
+
+## Prisma Client 调用变更速查
+
+| 旧调用 | v3.1 调用 |
+|--------|-----------|
+| `prisma.product` | `prisma.commonProductItem` |
+| `prisma.city` | `prisma.commonCity` |
+| `prisma.cityCommissionRule` | `prisma.commonCityCommissionRule` |
+| `prisma.storeCategory` | `prisma.commonStoreCategory` |
+| `prisma.promoCode` | `prisma.commonPromoCode` |
+| `prisma.orderDelivery` | `prisma.orderDelivery`(表名变 `user_order_delivery`) |
+| `prisma.benefitCoupon` | `prisma.benefitCoupon`(表 `user_benefit_coupon`) |
+| `prisma.benefitLedger` | `prisma.commonEvent`(`eventType=BENEFIT_LEDGER`) |
+| `prisma.redeemToken` | **删除**,改 Redis |
+| `prisma.storeMedia` | `prisma.commonResource` |
+| `prisma.storeAudit` | `prisma.commonEvent`(`eventType=STORE_AUDIT`) |
+| `prisma.payment` | `prisma.logThirdParty` |
+| `prisma.orderStatusLog` | `prisma.commonEvent`(`eventType=ORDER_STATUS`) |
+| `prisma.orderItem` | **删除**,读写 `order` 快照字段 |
+
+> `Partner`、`Store`、`User`、`Order` 等 Model 名保留,仅 `@@map` 物理表名变化。
+
+---
+
+## 后端模块影响面
+
+### P0 — 基础设施
+
+| 路径 | 影响 | 工作量 |
+|------|------|--------|
+| `prisma/schema.prisma` | 已切换为 v3.1 | ✅ |
+| `prisma/schema.v31.prisma` | 与 `schema.prisma` 同步源 | ✅ |
+| `prisma/seed-prev1.ts` | 保留为 `seed-legacy`;主 seed 为 `seed-v31.ts` | ✅ |
+| `prisma/sync-benefit-to-price.ts` | `product` → `commonProductItem` | 低 |
+| `packages/shared-types` | `ClientApp` 去掉 `USER_H5` 等;新增 Resource/Event 枚举 | 中 |
+
+### P1 — common 模块(新建)
+
+| 路径 | 说明 |
+|------|------|
+| `src/modules/common/common.module.ts` | **新建** |
+| `src/modules/common/resource.service.ts` | OSS 凭证、登记、CRUD |
+| `src/modules/common/resource.controller.ts` | `/common/resources/*` |
+| `src/modules/common/event.service.ts` | 事件写入/查询/时间线 |
+| `src/modules/common/ticket.service.ts` | 工单 CRUD |
+
+### P2 — IAM
+
+| 路径 | 关键改动 |
+|------|----------|
+| `modules/iam/auth.service.ts` | 去掉访客 `deviceKey` 流程;`user.phone` 必填;`prisma.user` 字段变更 |
+| `modules/iam/user-address.service.ts` | 表名映射,逻辑基本不变 |
+| `common/guards/phone-verified.guard.ts` | 适配新 User 模型 |
+| `common/guards/super-admin.guard.ts` | 无大变 |
+
+### P3 — catalog
+
+| 路径 | 关键改动 |
+|------|----------|
+| `modules/catalog/catalog.service.ts` | `city`→`commonCity`,`product`→`commonProductItem`;返回 `coverResource.url` |
+
+### P4 — trade(改动最大)
+
+| 路径 | 关键改动 |
+|------|----------|
+| `modules/trade/trade.service.ts` | 下单写 `user_order` 快照(无 `orderItem`);支付写 `log_third_party`;状态变更写 `common_event`;去掉 IP/GPS 字段或扩展 |
+| `integrations/pay/*` | 回调改查 `log_third_party` |
+| `jobs/*`(配送 Mock) | `orderDelivery` 字段对齐 |
+
+### P5 — benefit
+
+| 路径 | 关键改动 |
+|------|----------|
+| `modules/benefit/benefit.service.ts` | 发券逻辑保留;流水从 `benefitLedger.create` → `commonEvent.create(BENEFIT_LEDGER)`;读明细改查 `commonEvent` |
+
+### P6 — redeem
+
+| 路径 | 关键改动 |
+|------|----------|
+| `modules/redeem/redeem.service.ts` | **删除** `redeemToken` DB 写入,仅 Redis;`cityCommissionRule`→`commonCityCommissionRule` |
+
+### P7 — store
+
+| 路径 | 关键改动 |
+|------|----------|
+| `modules/store/store.service.ts` | `storeAudit`→`commonEvent`;封面改 `coverResourceId`;`city`→`commonCity` |
+
+### P8 — settlement
+
+| 路径 | 关键改动 |
+|------|----------|
+| `modules/settlement/settlement.service.ts` | `partnerBill` 状态枚举变更;去掉 `orderCommission` 明细 |
+
+### P9 — analytics
+
+| 路径 | 关键改动 |
+|------|----------|
+| `modules/analytics/analytics.service.ts` | `eventLog`→`logUserAnalytics` |
+
+### P10 — ops(HQ 后台)
+
+| 路径 | 关键改动 |
+|------|----------|
+| `modules/ops/admin-stores.service.ts` | `storeMedia`→`commonResource`;`coverUrl`→`coverResourceId`;`city`→`commonCity` |
+| `modules/ops/admin-benefit.service.ts` | 流水列表改查 `commonEvent` |
+| `modules/ops/admin-orders.service.ts` | 订单含内嵌商品快照;无 `items` include |
+| `modules/ops/admin-dashboard.service.ts` | 统计字段:去掉 guest/merged 用户计数 |
+| `modules/ops/admin-cities.service.ts` | `city`→`commonCity` |
+| `modules/ops/admin-partners.service.ts` | 订单关联 `city.partnerId` 不变 |
+| `modules/ops/admin-redeem.service.ts` | 表名映射 |
+| `modules/ops/admin-users.service.ts` | 去掉 merged/guest 相关 |
+| `admin-stores.controller.ts` | `/admin/store-media` → `/admin/resources` 或复用 common API |
+
+---
+
+## 前端影响面
+
+| 应用 | 影响 |
+|------|------|
+| `apps/h5-user` | 登录流(phone 必填);商品图 URL 来源;订单详情无 items 数组 |
+| `apps/h5-shop` | 门店详情封面 URL |
+| `apps/h5-partner` | 录店上传走 `/common/resources` |
+| `apps/admin-web` | 门店资源页改 `common_resource`;权益流水改 event;订单详情结构调整 |
+| `packages/shared-types` | 枚举与 DTO 同步 |
+
+---
+
+## P1 进度(common 模块)
+
+| 项 | 状态 |
+|----|------|
+| `modules/common/` Resource/Event/Ticket/ThirdPartyLog | ✅ |
+| `common/event/event.helpers.ts` 权益/订单事件 | ✅ |
+| 业务层改用 `commonEvent` / `commonResource` / `logThirdParty` | ✅ |
+| `pnpm run build` | ✅ |
+
+## P2–P6 进度(业务层 + 兼容层 + 后台)
+
+| 项 | 状态 |
+|----|------|
+| P2 IAM + catalog 适配 v3.1 | ✅ |
+| P3 trade + benefit(下单/发券/支付日志/事件) | ✅ |
+| P4 redeem + store(Redis token + 封面资源) | ✅ |
+| P5 ops 后台(订单/门店/权益/城市/合伙人) | ✅ |
+| P6 兼容层 `v31-compat.ts`(订单 items、门店 coverUrl、流水/状态日志) | ✅ |
+| `admin/products` CRUD + HQ 商品页 | ✅ |
+| `sync-benefit-to-price.ts` → `commonProductItem` | ✅ |
+| `schema.prisma` ↔ `schema.v31.prisma` 同步 | ✅ |
+| smoke 脚本 `scripts/smoke-prev1.mjs` | ✅ |
+
+### 新增 HQ API
+
+| 路径 | 说明 |
+|------|------|
+| `GET/POST /admin/products` | 商品列表/新建 |
+| `GET/PUT /admin/products/:id` | 商品详情/更新 |
+
+### 新增 API(`/api/v1/common/*`)
+
+| 路径 | 说明 |
+|------|------|
+| `POST /common/resources/upload-token` | Mock OSS 直传凭证 |
+| `POST/GET/PUT/DELETE /common/resources` | 资源 CRUD |
+| `POST/GET /common/events` | 事件写入/查询 |
+| `GET /common/events/timeline` | 时间线 |
+| `POST/GET/PUT /common/tickets` | 工单 |
+| `GET /common/third-party-logs` | HQ 只读支付/第三方日志 |
+
+## 建议实施顺序
+
+```
+P0 schema 切换 + seed-v31
+ → P1 common 模块(resource + event)
+ → P2 IAM + catalog(可登录、可看商品)
+ → P3 trade + benefit(可下单发券)
+ → P4 redeem + store(可核销)
+ → P5 ops 后台 + settlement
+ → P6 前端对齐 + smoke
+```
+
+**冻结规则**:P0~P1 期间不新增业务功能,只修迁移阻塞项。
+
+---
+
+## 文件索引
+
+| 文件 | 说明 |
+|------|------|
+| `prisma/init_v3.sql` | DDL 源 |
+| `prisma/schema.v31.prisma` | **新生成**,待激活 |
+| `prisma/schema.legacy-v21.prisma` | 旧版备份 |
+| `prisma/schema.prisma` | 当前运行版(**v3.1 已激活**) |
diff --git a/server/dukang-api/prisma/init_v3.sql b/server/dukang-api/prisma/init_v3.sql
index ec7d61b..0812a01 100644
--- a/server/dukang-api/prisma/init_v3.sql
+++ b/server/dukang-api/prisma/init_v3.sql
@@ -266,7 +266,10 @@ DROP TABLE IF EXISTS user_user;
CREATE TABLE user_user (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_no VARCHAR(20) NOT NULL,
- phone VARCHAR(20) NOT NULL,
+ device_key VARCHAR(36) DEFAULT NULL COMMENT '访客设备标识',
+ phone VARCHAR(20) DEFAULT NULL COMMENT '验机后必填;访客可为NULL',
+ phone_verified_at DATETIME(3) DEFAULT NULL,
+ merged_into_user_id BIGINT UNSIGNED DEFAULT NULL COMMENT '合并入主账号',
wx_open_id VARCHAR(64) DEFAULT NULL,
wx_union_id VARCHAR(64) DEFAULT NULL,
nickname VARCHAR(64) DEFAULT NULL,
@@ -281,8 +284,10 @@ CREATE TABLE user_user (
PRIMARY KEY (id),
UNIQUE KEY uk_user_user_phone (phone),
UNIQUE KEY uk_user_user_no (user_no),
+ UNIQUE KEY uk_user_user_device_key (device_key),
KEY idx_user_user_source (source_type, source_ref_id),
KEY idx_user_user_referrer (referrer_user_id),
+ KEY idx_user_user_merged (merged_into_user_id),
KEY idx_user_user_wx_open (wx_open_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='C端用户';
@@ -426,6 +431,16 @@ CREATE TABLE user_order (
receiver_province VARCHAR(32) NOT NULL,
receiver_city VARCHAR(32) NOT NULL,
receiver_district VARCHAR(32) NOT NULL,
+ client_ip VARCHAR(45) DEFAULT NULL COMMENT '下单时客户端IP',
+ ip_province VARCHAR(32) DEFAULT NULL COMMENT 'IP解析省',
+ ip_city VARCHAR(32) DEFAULT NULL COMMENT 'IP解析市',
+ ip_district VARCHAR(32) DEFAULT NULL COMMENT 'IP解析区县',
+ gps_province VARCHAR(32) DEFAULT NULL COMMENT 'GPS解析省',
+ gps_city VARCHAR(32) DEFAULT NULL COMMENT 'GPS解析市',
+ gps_district VARCHAR(32) DEFAULT NULL COMMENT 'GPS解析区县',
+ gps_latitude DECIMAL(10,7) DEFAULT NULL,
+ gps_longitude DECIMAL(10,7) DEFAULT NULL,
+ gps_address VARCHAR(256) DEFAULT NULL COMMENT 'GPS逆地理地址',
pay_external_no VARCHAR(64) DEFAULT NULL COMMENT '微信交易号(冗余)',
paid_at DATETIME(3) DEFAULT NULL COMMENT '支付时间',
shipped_at DATETIME(3) DEFAULT NULL COMMENT '发货时间(冗余=user_order_delivery.shipping_at)',
@@ -442,6 +457,8 @@ CREATE TABLE user_order (
KEY idx_user_order_product (product_id),
KEY idx_user_order_barcode (barcode_69),
KEY idx_user_order_pay_external (pay_external_no),
+ KEY idx_user_order_ip_city (ip_city),
+ KEY idx_user_order_gps_city (gps_city),
CONSTRAINT fk_user_order_user FOREIGN KEY (user_id) REFERENCES user_user(id) ON DELETE RESTRICT,
CONSTRAINT fk_user_order_city FOREIGN KEY (city_id) REFERENCES common_city(id) ON DELETE RESTRICT,
CONSTRAINT fk_user_order_origin FOREIGN KEY (origin_order_id) REFERENCES user_order(id) ON DELETE SET NULL,
diff --git a/server/dukang-api/prisma/schema.legacy-v21.prisma b/server/dukang-api/prisma/schema.legacy-v21.prisma
new file mode 100644
index 0000000..36ea989
--- /dev/null
+++ b/server/dukang-api/prisma/schema.legacy-v21.prisma
@@ -0,0 +1,1008 @@
+// 杜康好客 · V2.1 数据模型
+// 约束说明见 doc/数据库设计2-杜康好客.md
+
+generator client {
+ provider = "prisma-client-js"
+}
+
+datasource db {
+ provider = "mysql"
+ url = env("DATABASE_URL")
+}
+
+// ─── 枚举 ─────────────────────────────────────────────
+
+enum ClientApp {
+ USER_MINI
+ USER_H5
+ PARTNER_MINI
+ PARTNER_H5
+ HQ_MINI
+ SHOP_H5
+}
+
+enum AccountStatus {
+ ACTIVE
+ DISABLED
+}
+
+enum HqAdminRole {
+ SUPER_ADMIN
+ OPS
+ FINANCE
+ CUSTOMER_SERVICE
+}
+
+enum PartnerStaffRole {
+ PARTNER
+ INTERNAL
+ PROMOTER
+}
+
+enum CityStatus {
+ PENDING
+ ACTIVE
+ PAUSED
+}
+
+enum AromaType {
+ QINGXIANG
+ JIANGXIANG
+ NONGXIANG
+}
+
+enum ProductStatus {
+ DRAFT
+ ON_SALE
+ OFF_SALE
+}
+
+enum StoreStatus {
+ OPEN
+ PAUSED
+ CLOSED
+}
+
+enum StoreAuditType {
+ NEW
+ UPDATE
+}
+
+enum StoreAuditStatus {
+ PENDING
+ APPROVED
+ REJECTED
+}
+
+enum DeliveryType {
+ LOCAL
+ CROSS_CITY
+}
+
+enum OrderType {
+ NORMAL
+ RESHIPMENT
+}
+
+enum OrderStatus {
+ PENDING_PAY
+ PENDING_SHIP
+ OUT_WAREHOUSE
+ SHIPPING
+ PENDING_RECEIVE
+ COMPLETED
+ CANCELLED
+ REFUNDING
+ REFUNDED
+}
+
+enum PaymentStatus {
+ PENDING
+ SUCCESS
+ FAILED
+ CLOSED
+}
+
+enum RefundStatus {
+ PENDING
+ APPROVED
+ REJECTED
+ PROCESSING
+ SUCCESS
+ FAILED
+}
+
+enum InterceptStatus {
+ PENDING
+ INTERCEPTING
+ SUCCESS
+ FAILED
+ REDELIVERING
+ COMPLETED
+}
+
+enum BenefitCouponStatus {
+ ACTIVE
+ USED_UP
+ VOID
+}
+
+enum BenefitLedgerType {
+ GRANT
+ REDEEM
+ REFUND_VOID
+ ADJUST
+}
+
+enum RedeemTokenStatus {
+ ACTIVE
+ USED
+ EXPIRED
+ CANCELLED
+}
+
+enum StorePayoutStatus {
+ PENDING
+ PAID
+}
+
+enum PartnerBillStatus {
+ DRAFT
+ PENDING_CONFIRM
+ CONFIRMED
+ PAID
+ REJECTED
+}
+
+enum WithdrawalStatus {
+ PENDING
+ APPROVED
+ REJECTED
+ PAID
+}
+
+enum PromoCodeStatus {
+ ACTIVE
+ DISABLED
+}
+
+enum AlertType {
+ ORDER_TIMEOUT
+ DELIVERY_DELAY
+ OTHER
+}
+
+enum AlertStatus {
+ OPEN
+ RESOLVED
+}
+
+enum AfterSaleType {
+ REFUND
+ RESHIPMENT
+}
+
+enum AfterSaleStatus {
+ PENDING
+ PROCESSING
+ COMPLETED
+ REJECTED
+}
+
+// ─── 微信应用配置 ─────────────────────────────────────
+
+model WxAppConfig {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ clientApp ClientApp @unique @map("client_app")
+ appId String @map("app_id") @db.VarChar(64)
+ appSecret String @map("app_secret") @db.VarChar(128)
+ mchId String? @map("mch_id") @db.VarChar(32)
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+
+ @@map("wx_app_configs")
+}
+
+// ─── C端用户(phone 唯一主键,微信字段辅助)──────────
+
+model User {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ userNo String @unique @map("user_no") @db.VarChar(20)
+ deviceKey String? @unique @map("device_key") @db.VarChar(36)
+ phone String? @unique @db.VarChar(20)
+ phoneVerifiedAt DateTime? @map("phone_verified_at") @db.DateTime(3)
+ mergedIntoUserId BigInt? @map("merged_into_user_id") @db.UnsignedBigInt
+ wxOpenId String? @map("wx_open_id") @db.VarChar(64)
+ wxUnionId String? @map("wx_union_id") @db.VarChar(64)
+ nickname String? @db.VarChar(64)
+ avatarUrl String? @map("avatar_url") @db.VarChar(512)
+ status Int @default(1) @db.TinyInt
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+
+ mergedInto User? @relation("UserMerge", fields: [mergedIntoUserId], references: [id], onDelete: SetNull)
+ mergedFrom User[] @relation("UserMerge")
+ addresses UserAddress[]
+ cityPref UserCityPreference?
+ orders Order[]
+ benefitCoupons BenefitCoupon[]
+ benefitLedgers BenefitLedger[]
+ redeemRecords RedeemRecord[]
+ promoTouch UserPromoAttribution?
+ eventLogs EventLog[]
+
+ @@index([wxOpenId])
+ @@index([wxUnionId])
+ @@index([mergedIntoUserId])
+ @@map("users")
+}
+
+model UserAddress {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ userId BigInt @map("user_id") @db.UnsignedBigInt
+ receiverName String @map("receiver_name") @db.VarChar(32)
+ phone String @db.VarChar(20)
+ province String @db.VarChar(32)
+ city String @db.VarChar(32)
+ district String @db.VarChar(32)
+ detail String @db.VarChar(256)
+ latitude Decimal? @db.Decimal(10, 7)
+ longitude Decimal? @db.Decimal(10, 7)
+ isDefault Int @default(0) @map("is_default") @db.TinyInt
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ @@index([userId])
+ @@map("user_addresses")
+}
+
+model UserCityPreference {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ userId BigInt @unique @map("user_id") @db.UnsignedBigInt
+ selectedCityCode String? @map("selected_city_code") @db.VarChar(16)
+ selectedDistrict String? @map("selected_district") @db.VarChar(32)
+ locateCityCode String? @map("locate_city_code") @db.VarChar(16)
+ locateDistrict String? @map("locate_district") @db.VarChar(32)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ @@map("user_city_preferences")
+}
+
+// ─── B端账号(三表分离)──────────────────────────────
+
+model StoreAccount {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ storeId BigInt @unique @map("store_id") @db.UnsignedBigInt
+ phone String @unique @db.VarChar(20)
+ name String @db.VarChar(64)
+ wxOpenId String? @map("wx_open_id") @db.VarChar(64)
+ wxUnionId String? @map("wx_union_id") @db.VarChar(64)
+ status AccountStatus @default(ACTIVE)
+ lastLoginAt DateTime? @map("last_login_at") @db.DateTime(3)
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+
+ store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
+
+ @@index([wxOpenId])
+ @@map("store_accounts")
+}
+
+model PartnerAccount {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ partnerId BigInt @map("partner_id") @db.UnsignedBigInt
+ phone String @unique @db.VarChar(20)
+ name String @db.VarChar(64)
+ wxOpenId String? @map("wx_open_id") @db.VarChar(64)
+ wxUnionId String? @map("wx_union_id") @db.VarChar(64)
+ isPrimary Int @default(0) @map("is_primary") @db.TinyInt
+ parentAccountId BigInt? @map("parent_account_id") @db.UnsignedBigInt
+ staffRole PartnerStaffRole? @map("staff_role")
+ status AccountStatus @default(ACTIVE)
+ lastLoginAt DateTime? @map("last_login_at") @db.DateTime(3)
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+
+ partner Partner @relation(fields: [partnerId], references: [id], onDelete: Restrict)
+ parent PartnerAccount? @relation("PartnerAccountHierarchy", fields: [parentAccountId], references: [id], onDelete: SetNull)
+ children PartnerAccount[] @relation("PartnerAccountHierarchy")
+
+ @@index([partnerId])
+ @@index([parentAccountId])
+ @@index([wxOpenId])
+ @@map("partner_accounts")
+}
+
+model HqAccount {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ phone String @unique @db.VarChar(20)
+ name String @db.VarChar(64)
+ adminRole HqAdminRole @default(OPS) @map("admin_role")
+ wxOpenId String? @map("wx_open_id") @db.VarChar(64)
+ wxUnionId String? @map("wx_union_id") @db.VarChar(64)
+ status AccountStatus @default(ACTIVE)
+ lastLoginAt DateTime? @map("last_login_at") @db.DateTime(3)
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+
+ storeAudits StoreAudit[]
+ operationLogs OperationLog[]
+ afterSales AfterSaleTicket[]
+ refunds Refund[]
+
+ @@index([wxOpenId])
+ @@index([adminRole])
+ @@map("hq_accounts")
+}
+
+// ─── 开城与合伙人 ─────────────────────────────────────
+
+model Partner {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ companyName String @map("company_name") @db.VarChar(128)
+ address String @db.VarChar(256)
+ contactPhone String @map("contact_phone") @db.VarChar(20)
+ bankAccountName String? @map("bank_account_name") @db.VarChar(64)
+ bankAccountNo String? @map("bank_account_no") @db.VarChar(32)
+ bankBranch String? @map("bank_branch") @db.VarChar(128)
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+
+ cities City[]
+ accounts PartnerAccount[]
+ stores Store[]
+ bills PartnerBill[]
+ withdrawals PartnerWithdrawal[]
+ contracts PartnerContract[]
+ orderCommissions OrderCommission[]
+
+ @@index([contactPhone])
+ @@map("partners")
+}
+
+model City {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ code String @unique @db.VarChar(16)
+ name String @db.VarChar(64)
+ province String @db.VarChar(32)
+ status CityStatus @default(PENDING)
+ partnerId BigInt? @map("partner_id") @db.UnsignedBigInt
+ localMinQty Int @default(2) @map("local_min_qty")
+ crossMinQty Int @default(6) @map("cross_min_qty")
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+
+ partner Partner? @relation(fields: [partnerId], references: [id], onDelete: SetNull)
+ commissionRule CityCommissionRule?
+ stores Store[]
+ orders Order[]
+
+ @@index([partnerId])
+ @@index([status])
+ @@map("cities")
+}
+
+model CityCommissionRule {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ cityId BigInt @unique @map("city_id") @db.UnsignedBigInt
+ orderCommissionRate Decimal @map("order_commission_rate") @db.Decimal(5, 4)
+ redeemCommissionRate Decimal @map("redeem_commission_rate") @db.Decimal(5, 4)
+ partnerProfitRate Decimal @default(0.35) @map("partner_profit_rate") @db.Decimal(5, 4)
+ storeSettlementRate Decimal @default(0.60) @map("store_settlement_rate") @db.Decimal(5, 4)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+
+ city City @relation(fields: [cityId], references: [id], onDelete: Cascade)
+
+ @@map("city_commission_rules")
+}
+
+model PartnerContract {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ partnerId BigInt @map("partner_id") @db.UnsignedBigInt
+ contractNo String @map("contract_no") @db.VarChar(64)
+ fileUrl String @map("file_url") @db.VarChar(512)
+ signedAt DateTime @map("signed_at") @db.DateTime(3)
+ expireAt DateTime? @map("expire_at") @db.DateTime(3)
+ status String @default("ACTIVE") @db.VarChar(16)
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+
+ partner Partner @relation(fields: [partnerId], references: [id], onDelete: Restrict)
+
+ @@index([partnerId])
+ @@map("partner_contracts")
+}
+
+// ─── 商品 ─────────────────────────────────────────────
+
+model Product {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ skuCode String @unique @map("sku_code") @db.VarChar(32)
+ name String @db.VarChar(128)
+ subtitle String? @db.VarChar(256)
+ aromaType AromaType @map("aroma_type")
+ spec String @db.VarChar(128)
+ price Decimal @db.Decimal(10, 2)
+ benefitAmount Decimal? @map("benefit_amount") @db.Decimal(10, 2)
+ status ProductStatus @default(DRAFT)
+ sortOrder Int @default(0) @map("sort_order")
+ mainImageUrl String @map("main_image_url") @db.VarChar(512)
+ detailContent Json? @map("detail_content")
+ carouselUrls Json? @map("carousel_urls")
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+
+ orderItems OrderItem[]
+
+ @@index([status, aromaType])
+ @@map("products")
+}
+
+// ─── 门店 ─────────────────────────────────────────────
+
+model StoreCategory {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ code String @unique @db.VarChar(32)
+ name String @db.VarChar(64)
+ sort Int @default(0)
+
+ stores Store[]
+
+ @@map("store_categories")
+}
+
+model Store {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ cityId BigInt @map("city_id") @db.UnsignedBigInt
+ partnerId BigInt @map("partner_id") @db.UnsignedBigInt
+ categoryId BigInt? @map("category_id") @db.UnsignedBigInt
+ name String @db.VarChar(128)
+ phone String @db.VarChar(20)
+ province String @db.VarChar(32)
+ cityName String @map("city") @db.VarChar(32)
+ district String @db.VarChar(32)
+ address String @db.VarChar(256)
+ latitude Decimal? @db.Decimal(10, 7)
+ longitude Decimal? @db.Decimal(10, 7)
+ intro String? @db.Text
+ coverUrl String? @map("cover_url") @db.VarChar(512)
+ avgPrice Decimal? @map("avg_price") @db.Decimal(10, 2)
+ rating Decimal? @db.Decimal(3, 2)
+ tags Json?
+ status StoreStatus @default(PAUSED)
+ openTime String? @map("open_time") @db.VarChar(8)
+ closeTime String? @map("close_time") @db.VarChar(8)
+ bankAccountName String? @map("bank_account_name") @db.VarChar(64)
+ bankAccountNo String? @map("bank_account_no") @db.VarChar(32)
+ bankBranch String? @map("bank_branch") @db.VarChar(128)
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+
+ cityRef City @relation(fields: [cityId], references: [id], onDelete: Restrict)
+ partner Partner @relation(fields: [partnerId], references: [id], onDelete: Restrict)
+ category StoreCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
+ account StoreAccount?
+ media StoreMedia[]
+ audits StoreAudit[]
+ redeemRecords RedeemRecord[]
+ storePayouts StorePayout[]
+ ratings StoreRating[]
+
+ @@index([cityId, status])
+ @@index([partnerId])
+ @@map("stores")
+}
+
+model StoreMedia {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ storeId BigInt @map("store_id") @db.UnsignedBigInt
+ mediaType String @map("media_type") @db.VarChar(16)
+ url String @db.VarChar(512)
+ sortOrder Int @default(0) @map("sort_order")
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+
+ store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
+
+ @@index([storeId])
+ @@map("store_media")
+}
+
+model StoreAudit {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ storeId BigInt @map("store_id") @db.UnsignedBigInt
+ auditType StoreAuditType @map("audit_type")
+ status StoreAuditStatus @default(PENDING)
+ submitData Json? @map("submit_data")
+ rejectReason String? @map("reject_reason") @db.VarChar(512)
+ reviewerId BigInt? @map("reviewer_id") @db.UnsignedBigInt
+ submittedAt DateTime @default(now()) @map("submitted_at") @db.DateTime(3)
+ reviewedAt DateTime? @map("reviewed_at") @db.DateTime(3)
+
+ store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
+ reviewer HqAccount? @relation(fields: [reviewerId], references: [id], onDelete: SetNull)
+
+ @@index([storeId, status])
+ @@index([reviewerId])
+ @@map("store_audits")
+}
+
+// ─── 推广码 ───────────────────────────────────────────
+
+model PromoCode {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ code String @unique @db.VarChar(32)
+ name String @db.VarChar(128)
+ status PromoCodeStatus @default(ACTIVE)
+ wxQrcodeUrl String? @map("wx_qrcode_url") @db.VarChar(512)
+ scanCount Int @default(0) @map("scan_count")
+ orderCount Int @default(0) @map("order_count")
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+
+ orders Order[]
+ attributions UserPromoAttribution[]
+
+ @@map("promo_codes")
+}
+
+model UserPromoAttribution {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ userId BigInt @unique @map("user_id") @db.UnsignedBigInt
+ promoCodeId BigInt @map("promo_code_id") @db.UnsignedBigInt
+ channelName String @map("channel_name") @db.VarChar(128)
+ firstTouchAt DateTime @map("first_touch_at") @db.DateTime(3)
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ promoCode PromoCode @relation(fields: [promoCodeId], references: [id], onDelete: Restrict)
+
+ @@index([promoCodeId])
+ @@map("user_promo_attributions")
+}
+
+// ─── 订单与支付 ─────────────────────────────────────
+
+model Order {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ orderNo String @unique @map("order_no") @db.VarChar(32)
+ orderType OrderType @default(NORMAL) @map("order_type")
+ userId BigInt @map("user_id") @db.UnsignedBigInt
+ cityId BigInt @map("city_id") @db.UnsignedBigInt
+ status OrderStatus @default(PENDING_PAY)
+ deliveryType DeliveryType @map("delivery_type")
+ originOrderId BigInt? @map("origin_order_id") @db.UnsignedBigInt
+ promoCodeId BigInt? @map("promo_code_id") @db.UnsignedBigInt
+ channelSource String? @map("channel_source") @db.VarChar(128)
+ receiverName String @map("receiver_name") @db.VarChar(32)
+ receiverPhone String @map("receiver_phone") @db.VarChar(20)
+ receiverAddress String @map("receiver_address") @db.Text
+ receiverProvince String @map("receiver_province") @db.VarChar(32)
+ receiverCity String @map("receiver_city") @db.VarChar(32)
+ receiverDistrict String @map("receiver_district") @db.VarChar(32)
+ clientIp String? @map("client_ip") @db.VarChar(45)
+ ipProvince String? @map("ip_province") @db.VarChar(32)
+ ipCity String? @map("ip_city") @db.VarChar(32)
+ ipDistrict String? @map("ip_district") @db.VarChar(32)
+ gpsProvince String? @map("gps_province") @db.VarChar(32)
+ gpsCity String? @map("gps_city") @db.VarChar(32)
+ gpsDistrict String? @map("gps_district") @db.VarChar(32)
+ gpsLatitude Decimal? @map("gps_latitude") @db.Decimal(10, 7)
+ gpsLongitude Decimal? @map("gps_longitude") @db.Decimal(10, 7)
+ gpsAddress String? @map("gps_address") @db.VarChar(256)
+ productAmount Decimal @map("product_amount") @db.Decimal(10, 2)
+ freightAmount Decimal @default(0) @map("freight_amount") @db.Decimal(10, 2)
+ freightPayType String? @map("freight_pay_type") @db.VarChar(8)
+ payAmount Decimal @map("pay_amount") @db.Decimal(10, 2)
+ benefitAmount Decimal @default(0) @map("benefit_amount") @db.Decimal(10, 2)
+ paidAt DateTime? @map("paid_at") @db.DateTime(3)
+ shippedAt DateTime? @map("shipped_at") @db.DateTime(3)
+ completedAt DateTime? @map("completed_at") @db.DateTime(3)
+ cancelledAt DateTime? @map("cancelled_at") @db.DateTime(3)
+ payExpireAt DateTime? @map("pay_expire_at") @db.DateTime(3)
+ remark String? @db.VarChar(512)
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+
+ user User @relation(fields: [userId], references: [id], onDelete: Restrict)
+ city City @relation(fields: [cityId], references: [id], onDelete: Restrict)
+ originOrder Order? @relation("ReshipmentOrder", fields: [originOrderId], references: [id], onDelete: SetNull)
+ reshipments Order[] @relation("ReshipmentOrder")
+ promoCode PromoCode? @relation(fields: [promoCodeId], references: [id], onDelete: SetNull)
+ items OrderItem[]
+ statusLogs OrderStatusLog[]
+ delivery OrderDelivery?
+ payment Payment?
+ refunds Refund[]
+ benefitCoupons BenefitCoupon[]
+ intercepts DeliveryIntercept[]
+ afterSales AfterSaleTicket[]
+ commissions OrderCommission[]
+
+ @@index([userId, status])
+ @@index([cityId, createdAt])
+ @@index([receiverPhone])
+ @@index([originOrderId])
+ @@index([ipCity])
+ @@index([gpsCity])
+ @@map("orders")
+}
+
+model OrderItem {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ orderId BigInt @map("order_id") @db.UnsignedBigInt
+ productId BigInt @map("product_id") @db.UnsignedBigInt
+ productName String @map("product_name") @db.VarChar(128)
+ productSpec String @map("product_spec") @db.VarChar(128)
+ productImage String @map("product_image") @db.VarChar(512)
+ unitPrice Decimal @map("unit_price") @db.Decimal(10, 2)
+ quantity Int
+ subtotal Decimal @db.Decimal(10, 2)
+
+ order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
+ product Product @relation(fields: [productId], references: [id], onDelete: Restrict)
+
+ @@index([orderId])
+ @@map("order_items")
+}
+
+model OrderStatusLog {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ orderId BigInt @map("order_id") @db.UnsignedBigInt
+ fromStatus String? @map("from_status") @db.VarChar(32)
+ toStatus String @map("to_status") @db.VarChar(32)
+ operator String? @db.VarChar(64)
+ remark String? @db.VarChar(256)
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+
+ order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
+
+ @@index([orderId])
+ @@map("order_status_logs")
+}
+
+model OrderDelivery {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ orderId BigInt @unique @map("order_id") @db.UnsignedBigInt
+ provider String @db.VarChar(16)
+ providerOrderNo String? @map("provider_order_no") @db.VarChar(64)
+ trackingNo String? @map("tracking_no") @db.VarChar(64)
+ outWarehouseAt DateTime? @map("out_warehouse_at") @db.DateTime(3)
+ shippingAt DateTime? @map("shipping_at") @db.DateTime(3)
+ deliveredAt DateTime? @map("delivered_at") @db.DateTime(3)
+ rawPayload Json? @map("raw_payload")
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+
+ order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
+
+ @@map("order_deliveries")
+}
+
+model Payment {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ orderId BigInt @unique @map("order_id") @db.UnsignedBigInt
+ paymentNo String @unique @map("payment_no") @db.VarChar(32)
+ amount Decimal @db.Decimal(10, 2)
+ status PaymentStatus @default(PENDING)
+ wxTransactionId String? @unique @map("wx_transaction_id") @db.VarChar(64)
+ wxPrepayId String? @map("wx_prepay_id") @db.VarChar(64)
+ paidAt DateTime? @map("paid_at") @db.DateTime(3)
+ notifyPayload Json? @map("notify_payload")
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+
+ order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
+
+ @@map("payments")
+}
+
+model Refund {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ orderId BigInt @map("order_id") @db.UnsignedBigInt
+ refundNo String @unique @map("refund_no") @db.VarChar(32)
+ amount Decimal @db.Decimal(10, 2)
+ reason String? @db.VarChar(512)
+ status RefundStatus @default(PENDING)
+ wxRefundId String? @map("wx_refund_id") @db.VarChar(64)
+ benefitAdjust Decimal? @map("benefit_adjust") @db.Decimal(10, 2)
+ operatorId BigInt? @map("operator_id") @db.UnsignedBigInt
+ approvedAt DateTime? @map("approved_at") @db.DateTime(3)
+ refundedAt DateTime? @map("refunded_at") @db.DateTime(3)
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+
+ order Order @relation(fields: [orderId], references: [id], onDelete: Restrict)
+ operator HqAccount? @relation(fields: [operatorId], references: [id], onDelete: SetNull)
+
+ @@index([orderId])
+ @@index([operatorId])
+ @@map("refunds")
+}
+
+model DeliveryIntercept {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ orderId BigInt @map("order_id") @db.UnsignedBigInt
+ status InterceptStatus @default(PENDING)
+ oldAddress Json @map("old_address")
+ newAddress Json @map("new_address")
+ partnerId BigInt? @map("partner_id") @db.UnsignedBigInt
+ interceptAt DateTime? @map("intercept_at") @db.DateTime(3)
+ redeliveryOrderId BigInt? @map("redelivery_order_id") @db.UnsignedBigInt
+ remark String? @db.VarChar(512)
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+
+ order Order @relation(fields: [orderId], references: [id], onDelete: Restrict)
+
+ @@index([orderId, status])
+ @@map("delivery_intercepts")
+}
+
+// ─── 好客权益 ─────────────────────────────────────────
+
+model BenefitCoupon {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ couponNo String @unique @map("coupon_no") @db.VarChar(32)
+ userId BigInt @map("user_id") @db.UnsignedBigInt
+ orderId BigInt @map("order_id") @db.UnsignedBigInt
+ totalAmount Decimal @map("total_amount") @db.Decimal(10, 2)
+ usedAmount Decimal @default(0) @map("used_amount") @db.Decimal(10, 2)
+ balance Decimal @db.Decimal(10, 2)
+ status BenefitCouponStatus @default(ACTIVE)
+ sourceProduct String @map("source_product") @db.VarChar(128)
+ version Int @default(0)
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+
+ user User @relation(fields: [userId], references: [id], onDelete: Restrict)
+ order Order @relation(fields: [orderId], references: [id], onDelete: Restrict)
+ ledgers BenefitLedger[]
+ redeemRecords RedeemRecord[]
+ redeemTokens RedeemToken[]
+
+ @@index([userId, status])
+ @@index([orderId])
+ @@map("benefit_coupons")
+}
+
+model BenefitLedger {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ userId BigInt @map("user_id") @db.UnsignedBigInt
+ couponId BigInt @map("coupon_id") @db.UnsignedBigInt
+ type BenefitLedgerType
+ amount Decimal @db.Decimal(10, 2)
+ balanceAfter Decimal @map("balance_after") @db.Decimal(10, 2)
+ refType String? @map("ref_type") @db.VarChar(32)
+ refId BigInt? @map("ref_id") @db.UnsignedBigInt
+ remark String? @db.VarChar(256)
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+
+ user User @relation(fields: [userId], references: [id], onDelete: Restrict)
+ coupon BenefitCoupon @relation(fields: [couponId], references: [id], onDelete: Restrict)
+
+ @@index([userId, createdAt])
+ @@index([couponId])
+ @@map("benefit_ledgers")
+}
+
+// ─── 核销 ─────────────────────────────────────────────
+
+model RedeemToken {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ token String @unique @db.VarChar(64)
+ userId BigInt @map("user_id") @db.UnsignedBigInt
+ couponId BigInt @map("coupon_id") @db.UnsignedBigInt
+ storeId BigInt? @map("store_id") @db.UnsignedBigInt
+ amount Decimal @db.Decimal(10, 2)
+ status RedeemTokenStatus @default(ACTIVE)
+ expireAt DateTime @map("expire_at") @db.DateTime(3)
+ usedAt DateTime? @map("used_at") @db.DateTime(3)
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+
+ coupon BenefitCoupon @relation(fields: [couponId], references: [id], onDelete: Restrict)
+
+ @@index([couponId])
+ @@map("redeem_tokens")
+}
+
+model RedeemRecord {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ redeemNo String @unique @map("redeem_no") @db.VarChar(32)
+ userId BigInt @map("user_id") @db.UnsignedBigInt
+ couponId BigInt @map("coupon_id") @db.UnsignedBigInt
+ storeId BigInt @map("store_id") @db.UnsignedBigInt
+ amount Decimal @db.Decimal(10, 2)
+ settleAmount Decimal @map("settle_amount") @db.Decimal(10, 2)
+ tokenId BigInt? @map("token_id") @db.UnsignedBigInt
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+
+ user User @relation(fields: [userId], references: [id], onDelete: Restrict)
+ coupon BenefitCoupon @relation(fields: [couponId], references: [id], onDelete: Restrict)
+ store Store @relation(fields: [storeId], references: [id], onDelete: Restrict)
+ payout StorePayout?
+ rating StoreRating?
+ commissions OrderCommission[]
+
+ @@index([storeId, createdAt])
+ @@index([userId])
+ @@map("redeem_records")
+}
+
+model StoreRating {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ redeemRecordId BigInt @unique @map("redeem_record_id") @db.UnsignedBigInt
+ storeId BigInt @map("store_id") @db.UnsignedBigInt
+ serviceScore Int @map("service_score") @db.TinyInt
+ envScore Int @map("env_score") @db.TinyInt
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+
+ redeemRecord RedeemRecord @relation(fields: [redeemRecordId], references: [id], onDelete: Cascade)
+ store Store @relation(fields: [storeId], references: [id], onDelete: Restrict)
+
+ @@map("store_ratings")
+}
+
+// ─── 结算 ─────────────────────────────────────────────
+
+model StorePayout {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ redeemRecordId BigInt @unique @map("redeem_record_id") @db.UnsignedBigInt
+ storeId BigInt @map("store_id") @db.UnsignedBigInt
+ redeemAmount Decimal @map("redeem_amount") @db.Decimal(10, 2)
+ payoutAmount Decimal @map("payout_amount") @db.Decimal(10, 2)
+ settlementRate Decimal @map("settlement_rate") @db.Decimal(5, 4)
+ status StorePayoutStatus @default(PENDING)
+ expectedPayAt DateTime @map("expected_pay_at") @db.DateTime(3)
+ paidAt DateTime? @map("paid_at") @db.DateTime(3)
+ batchNo String? @map("batch_no") @db.VarChar(32)
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+
+ redeemRecord RedeemRecord @relation(fields: [redeemRecordId], references: [id], onDelete: Restrict)
+ store Store @relation(fields: [storeId], references: [id], onDelete: Restrict)
+
+ @@index([storeId, status])
+ @@index([expectedPayAt])
+ @@map("store_payouts")
+}
+
+model OrderCommission {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ orderId BigInt? @map("order_id") @db.UnsignedBigInt
+ redeemId BigInt? @map("redeem_id") @db.UnsignedBigInt
+ partnerId BigInt @map("partner_id") @db.UnsignedBigInt
+ commissionType String @map("commission_type") @db.VarChar(16)
+ baseAmount Decimal @map("base_amount") @db.Decimal(10, 2)
+ rate Decimal @db.Decimal(5, 4)
+ amount Decimal @db.Decimal(10, 2)
+ billId BigInt? @map("bill_id") @db.UnsignedBigInt
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+
+ order Order? @relation(fields: [orderId], references: [id], onDelete: SetNull)
+ redeem RedeemRecord? @relation(fields: [redeemId], references: [id], onDelete: SetNull)
+ partner Partner @relation(fields: [partnerId], references: [id], onDelete: Restrict)
+ bill PartnerBill? @relation(fields: [billId], references: [id], onDelete: SetNull)
+
+ @@index([partnerId])
+ @@index([billId])
+ @@map("order_commissions")
+}
+
+model PartnerBill {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ billNo String @unique @map("bill_no") @db.VarChar(32)
+ partnerId BigInt @map("partner_id") @db.UnsignedBigInt
+ periodStart DateTime @map("period_start") @db.DateTime(3)
+ periodEnd DateTime @map("period_end") @db.DateTime(3)
+ orderCommission Decimal @map("order_commission") @db.Decimal(10, 2)
+ redeemCommission Decimal @map("redeem_commission") @db.Decimal(10, 2)
+ totalAmount Decimal @map("total_amount") @db.Decimal(10, 2)
+ status PartnerBillStatus @default(DRAFT)
+ confirmedAt DateTime? @map("confirmed_at") @db.DateTime(3)
+ paidAt DateTime? @map("paid_at") @db.DateTime(3)
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+
+ partner Partner @relation(fields: [partnerId], references: [id], onDelete: Restrict)
+ commissions OrderCommission[]
+
+ @@index([partnerId, status])
+ @@map("partner_bills")
+}
+
+model PartnerWithdrawal {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ partnerId BigInt @map("partner_id") @db.UnsignedBigInt
+ amount Decimal @db.Decimal(10, 2)
+ status WithdrawalStatus @default(PENDING)
+ remark String? @db.VarChar(256)
+ appliedAt DateTime @default(now()) @map("applied_at") @db.DateTime(3)
+ processedAt DateTime? @map("processed_at") @db.DateTime(3)
+
+ partner Partner @relation(fields: [partnerId], references: [id], onDelete: Restrict)
+
+ @@index([partnerId])
+ @@map("partner_withdrawals")
+}
+
+// ─── 埋点 ─────────────────────────────────────────────
+
+model EventLog {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ userId BigInt? @map("user_id") @db.UnsignedBigInt
+ eventName String @map("event_name") @db.VarChar(64)
+ params Json?
+ clientApp String? @map("client_app") @db.VarChar(32)
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+
+ user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
+
+ @@index([eventName, createdAt])
+ @@index([userId])
+ @@map("event_logs")
+}
+
+// ─── 运营与售后 ─────────────────────────────────────
+
+model AfterSaleTicket {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ ticketNo String @unique @map("ticket_no") @db.VarChar(32)
+ type AfterSaleType
+ status AfterSaleStatus @default(PENDING)
+ orderId BigInt @map("order_id") @db.UnsignedBigInt
+ reshipmentOrderId BigInt? @map("reshipment_order_id") @db.UnsignedBigInt
+ refundId BigInt? @map("refund_id") @db.UnsignedBigInt
+ reason String? @db.VarChar(512)
+ operatorId BigInt? @map("operator_id") @db.UnsignedBigInt
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ completedAt DateTime? @map("completed_at") @db.DateTime(3)
+
+ order Order @relation(fields: [orderId], references: [id], onDelete: Restrict)
+ operator HqAccount? @relation(fields: [operatorId], references: [id], onDelete: SetNull)
+
+ @@index([orderId])
+ @@index([status, type])
+ @@index([operatorId])
+ @@map("after_sale_tickets")
+}
+
+model Alert {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ alertType AlertType @map("alert_type")
+ status AlertStatus @default(OPEN)
+ refType String @map("ref_type") @db.VarChar(32)
+ refId BigInt @map("ref_id") @db.UnsignedBigInt
+ title String @db.VarChar(128)
+ content String @db.Text
+ resolvedAt DateTime? @map("resolved_at") @db.DateTime(3)
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+
+ @@index([status, alertType])
+ @@map("alerts")
+}
+
+model OperationLog {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ hqAccountId BigInt @map("hq_account_id") @db.UnsignedBigInt
+ action String @db.VarChar(64)
+ refType String? @map("ref_type") @db.VarChar(32)
+ refId BigInt? @map("ref_id") @db.UnsignedBigInt
+ detail Json?
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+
+ hqAccount HqAccount @relation(fields: [hqAccountId], references: [id], onDelete: Restrict)
+
+ @@index([hqAccountId, createdAt])
+ @@map("operation_logs")
+}
+
+model SmsLog {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ phone String @db.VarChar(20)
+ template String @db.VarChar(64)
+ content String @db.Text
+ status String @db.VarChar(16)
+ refType String? @map("ref_type") @db.VarChar(32)
+ refId BigInt? @map("ref_id") @db.UnsignedBigInt
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+
+ @@index([phone, createdAt])
+ @@map("sms_logs")
+}
diff --git a/server/dukang-api/prisma/schema.prisma b/server/dukang-api/prisma/schema.prisma
index 36ea989..0a8334e 100644
--- a/server/dukang-api/prisma/schema.prisma
+++ b/server/dukang-api/prisma/schema.prisma
@@ -1,5 +1,6 @@
-// 杜康好客 · V2.1 数据模型
-// 约束说明见 doc/数据库设计2-杜康好客.md
+// 杜康好客 · V3.1 数据模型(由 init_v3.sql 生成)
+// 激活方式:确认后替换 schema.prisma,执行 prisma migrate / db push + seed-v31
+// 旧版备份:schema.legacy-v21.prisma
generator client {
provider = "prisma-client-js"
@@ -10,7 +11,7 @@ datasource db {
url = env("DATABASE_URL")
}
-// ─── 枚举 ─────────────────────────────────────────────
+// ─── 枚举(与 init_v3.sql COMMENT 对齐)────────────────
enum ClientApp {
USER_MINI
@@ -18,31 +19,63 @@ enum ClientApp {
PARTNER_MINI
PARTNER_H5
HQ_MINI
+ HQ_WEB
SHOP_H5
}
-enum AccountStatus {
- ACTIVE
- DISABLED
-}
-
-enum HqAdminRole {
- SUPER_ADMIN
- OPS
- FINANCE
- CUSTOMER_SERVICE
-}
-
-enum PartnerStaffRole {
+enum ResourceOwnerType {
+ PRODUCT
+ STORE
PARTNER
- INTERNAL
- PROMOTER
+ USER
+ ORDER
+ PROMO
+ HQ
}
-enum CityStatus {
- PENDING
+enum ResourceBizType {
+ COVER
+ ENV
+ CONTRACT
+ CAROUSEL
+ DETAIL
+ AVATAR
+ QRCODE
+ SIGN_PHOTO
+ VIDEO
+}
+
+enum ResourceMediaType {
+ IMAGE
+ VIDEO
+ FILE
+}
+
+enum ResourceStatus {
ACTIVE
- PAUSED
+ DELETED
+}
+
+enum EventType {
+ STORE_AUDIT
+ ORDER_STATUS
+ BENEFIT_LEDGER
+ HQ_OPERATION
+ PROMO_TOUCH
+}
+
+enum ActorType {
+ USER
+ STORE
+ PARTNER
+ HQ
+ SYSTEM
+}
+
+enum TicketType {
+ REFUND
+ RESHIPMENT
+ ALERT
}
enum AromaType {
@@ -57,26 +90,54 @@ enum ProductStatus {
OFF_SALE
}
+enum PromoCodeStatus {
+ ACTIVE
+ DISABLED
+}
+
+enum CityStatus {
+ PENDING
+ ACTIVE
+ PAUSED
+}
+
+enum PartnerStaffRole {
+ PARTNER
+ INTERNAL
+ PROMOTER
+}
+
+enum AccountStatus {
+ ACTIVE
+ DISABLED
+}
+
+enum HqAdminRole {
+ SUPER_ADMIN
+ OPS
+ FINANCE
+ CUSTOMER_SERVICE
+}
+
+enum PartnerBillStatus {
+ DRAFT
+ CONFIRMED
+ PAID
+}
+
enum StoreStatus {
OPEN
PAUSED
CLOSED
}
-enum StoreAuditType {
- NEW
- UPDATE
-}
-
-enum StoreAuditStatus {
- PENDING
- APPROVED
- REJECTED
-}
-
-enum DeliveryType {
- LOCAL
- CROSS_CITY
+enum UserSourceType {
+ ORGANIC
+ PROMO_CODE
+ SHARE_LINK
+ FRIEND_REFERRAL
+ OFFLINE_EVENT
+ OTHER
}
enum OrderType {
@@ -96,29 +157,28 @@ enum OrderStatus {
REFUNDED
}
-enum PaymentStatus {
- PENDING
- SUCCESS
- FAILED
- CLOSED
+enum PayStatus {
+ UNPAID
+ PAYING
+ PAID
+ REFUNDING
+ REFUNDED
}
-enum RefundStatus {
- PENDING
- APPROVED
- REJECTED
- PROCESSING
- SUCCESS
- FAILED
+enum DeliveryType {
+ LOCAL
+ CROSS_CITY
}
-enum InterceptStatus {
- PENDING
- INTERCEPTING
- SUCCESS
- FAILED
- REDELIVERING
- COMPLETED
+enum FreightPayType {
+ FREE
+ COD
+}
+
+enum DeliveryProvider {
+ XFX
+ LOGISTICS
+ MANUAL
}
enum BenefitCouponStatus {
@@ -127,6 +187,27 @@ enum BenefitCouponStatus {
VOID
}
+enum StorePayoutStatus {
+ PENDING
+ PAID
+}
+
+enum ThirdPartyProvider {
+ WECHAT_PAY
+ WECHAT_REFUND
+ WECHAT_AUTH
+ WECHAT_MAP
+ XFX
+ SMS
+ LOGISTICS
+}
+
+enum ThirdPartyLogStatus {
+ PENDING
+ SUCCESS
+ FAILED
+}
+
enum BenefitLedgerType {
GRANT
REDEEM
@@ -134,64 +215,9 @@ enum BenefitLedgerType {
ADJUST
}
-enum RedeemTokenStatus {
- ACTIVE
- USED
- EXPIRED
- CANCELLED
-}
+// ─── COMMON ───────────────────────────────────────────
-enum StorePayoutStatus {
- PENDING
- PAID
-}
-
-enum PartnerBillStatus {
- DRAFT
- PENDING_CONFIRM
- CONFIRMED
- PAID
- REJECTED
-}
-
-enum WithdrawalStatus {
- PENDING
- APPROVED
- REJECTED
- PAID
-}
-
-enum PromoCodeStatus {
- ACTIVE
- DISABLED
-}
-
-enum AlertType {
- ORDER_TIMEOUT
- DELIVERY_DELAY
- OTHER
-}
-
-enum AlertStatus {
- OPEN
- RESOLVED
-}
-
-enum AfterSaleType {
- REFUND
- RESHIPMENT
-}
-
-enum AfterSaleStatus {
- PENDING
- PROCESSING
- COMPLETED
- REJECTED
-}
-
-// ─── 微信应用配置 ─────────────────────────────────────
-
-model WxAppConfig {
+model CommonWxAppConfig {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
clientApp ClientApp @unique @map("client_app")
appId String @map("app_id") @db.VarChar(64)
@@ -200,96 +226,198 @@ model WxAppConfig {
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
- @@map("wx_app_configs")
+ @@map("common_wx_app_config")
}
-// ─── C端用户(phone 唯一主键,微信字段辅助)──────────
+model CommonResource {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ ownerType ResourceOwnerType @map("owner_type")
+ ownerId BigInt @map("owner_id") @db.UnsignedBigInt
+ bizType ResourceBizType @map("biz_type")
+ mediaType ResourceMediaType @default(IMAGE) @map("media_type")
+ ossBucket String @map("oss_bucket") @db.VarChar(64)
+ ossKey String @map("oss_key") @db.VarChar(256)
+ url String @db.VarChar(512)
+ fileName String? @map("file_name") @db.VarChar(128)
+ fileSize BigInt? @map("file_size") @db.UnsignedBigInt
+ mimeType String? @map("mime_type") @db.VarChar(64)
+ sortOrder Int @default(0) @map("sort_order")
+ status ResourceStatus @default(ACTIVE)
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
-model User {
+ productCovers CommonProductItem[] @relation("ProductCover")
+ promoQrcodes CommonPromoCode[] @relation("PromoQrcode")
+ userAvatars User[] @relation("UserAvatar")
+ storeCovers Store[] @relation("StoreCover")
+ orderImages Order[] @relation("OrderProductImage")
+ deliveryPhotos OrderDelivery[] @relation("DeliverySignPhoto")
+
+ @@index([ownerType, ownerId, bizType])
+ @@index([status])
+ @@map("common_resource")
+}
+
+model CommonEvent {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ eventType EventType @map("event_type")
+ refType String @map("ref_type") @db.VarChar(32)
+ refId BigInt @map("ref_id") @db.UnsignedBigInt
+ actorType ActorType? @map("actor_type")
+ actorId BigInt? @map("actor_id") @db.UnsignedBigInt
+ status String? @db.VarChar(32)
+ param1 String? @db.VarChar(128)
+ param1Desc String? @map("param1_desc") @db.VarChar(64)
+ param2 String? @db.VarChar(128)
+ param2Desc String? @map("param2_desc") @db.VarChar(64)
+ param3 String? @db.VarChar(128)
+ param3Desc String? @map("param3_desc") @db.VarChar(64)
+ amount1 Decimal? @db.Decimal(10, 2)
+ amount2 Decimal? @db.Decimal(10, 2)
+ remark String? @db.VarChar(512)
+ extraJson Json? @map("extra_json")
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+
+ @@index([refType, refId, eventType])
+ @@index([eventType, createdAt])
+ @@index([actorType, actorId])
+ @@map("common_event")
+}
+
+model CommonTicket {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ ticketNo String @unique @map("ticket_no") @db.VarChar(32)
+ ticketType TicketType @map("ticket_type")
+ status String @default("PENDING") @db.VarChar(32)
+ refType String @map("ref_type") @db.VarChar(32)
+ refId BigInt @map("ref_id") @db.UnsignedBigInt
+ operatorType ActorType? @map("operator_type")
+ operatorId BigInt? @map("operator_id") @db.UnsignedBigInt
+ param1 String? @db.VarChar(128)
+ param1Desc String? @map("param1_desc") @db.VarChar(64)
+ param2 String? @db.VarChar(128)
+ param2Desc String? @map("param2_desc") @db.VarChar(64)
+ param3 String? @db.VarChar(128)
+ param3Desc String? @map("param3_desc") @db.VarChar(64)
+ remark String? @db.VarChar(512)
+ extraJson Json? @map("extra_json")
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ completedAt DateTime? @map("completed_at") @db.DateTime(3)
+
+ @@index([refType, refId])
+ @@index([ticketType, status])
+ @@map("common_ticket")
+}
+
+model CommonProductItem {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ skuCode String @unique @map("sku_code") @db.VarChar(32)
+ barcode69 String @unique @map("barcode_69") @db.VarChar(32)
+ name String @db.VarChar(128)
+ subtitle String? @db.VarChar(256)
+ aromaType AromaType @map("aroma_type")
+ spec String @db.VarChar(128)
+ price Decimal @db.Decimal(10, 2)
+ benefitAmount Decimal? @map("benefit_amount") @db.Decimal(10, 2)
+ status ProductStatus @default(DRAFT)
+ sortOrder Int @default(0) @map("sort_order")
+ coverResourceId BigInt? @map("cover_resource_id") @db.UnsignedBigInt
+ detailContent Json? @map("detail_content")
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+
+ coverResource CommonResource? @relation("ProductCover", fields: [coverResourceId], references: [id], onDelete: SetNull)
+ orders Order[]
+
+ @@index([status, aromaType])
+ @@map("common_product_item")
+}
+
+model CommonStoreCategory {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ code String @unique @db.VarChar(32)
+ name String @db.VarChar(64)
+ sort Int @default(0)
+ stores Store[]
+
+ @@map("common_store_category")
+}
+
+model CommonPromoCode {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ code String @unique @db.VarChar(32)
+ name String @db.VarChar(128)
+ status PromoCodeStatus @default(ACTIVE)
+ qrcodeResourceId BigInt? @map("qrcode_resource_id") @db.UnsignedBigInt
+ scanCount Int @default(0) @map("scan_count")
+ orderCount Int @default(0) @map("order_count")
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+
+ qrcodeResource CommonResource? @relation("PromoQrcode", fields: [qrcodeResourceId], references: [id], onDelete: SetNull)
+ attributions UserPromoAttribution[]
+ orders Order[]
+
+ @@map("common_promo_code")
+}
+
+model CommonCity {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ code String @unique @db.VarChar(16)
+ name String @db.VarChar(64)
+ province String @db.VarChar(32)
+ status CityStatus @default(PENDING)
+ partnerId BigInt? @map("partner_id") @db.UnsignedBigInt
+ localMinQty Int @default(2) @map("local_min_qty")
+ crossMinQty Int @default(6) @map("cross_min_qty")
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+
+ partner Partner? @relation(fields: [partnerId], references: [id], onDelete: SetNull)
+ commissionRule CommonCityCommissionRule?
+ stores Store[]
+ orders Order[]
+
+ @@index([partnerId])
+ @@map("common_city")
+}
+
+model CommonCityCommissionRule {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ cityId BigInt @unique @map("city_id") @db.UnsignedBigInt
+ orderCommissionRate Decimal @default(0) @map("order_commission_rate") @db.Decimal(5, 4)
+ redeemCommissionRate Decimal @default(0) @map("redeem_commission_rate") @db.Decimal(5, 4)
+ partnerProfitRate Decimal @default(0.35) @map("partner_profit_rate") @db.Decimal(5, 4)
+ storeSettlementRate Decimal @default(0.60) @map("store_settlement_rate") @db.Decimal(5, 4)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+
+ city CommonCity @relation(fields: [cityId], references: [id], onDelete: Cascade)
+
+ @@map("common_city_commission_rule")
+}
+
+// ─── PARTNER ──────────────────────────────────────────
+
+model Partner {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
- userNo String @unique @map("user_no") @db.VarChar(20)
- deviceKey String? @unique @map("device_key") @db.VarChar(36)
- phone String? @unique @db.VarChar(20)
- phoneVerifiedAt DateTime? @map("phone_verified_at") @db.DateTime(3)
- mergedIntoUserId BigInt? @map("merged_into_user_id") @db.UnsignedBigInt
- wxOpenId String? @map("wx_open_id") @db.VarChar(64)
- wxUnionId String? @map("wx_union_id") @db.VarChar(64)
- nickname String? @db.VarChar(64)
- avatarUrl String? @map("avatar_url") @db.VarChar(512)
- status Int @default(1) @db.TinyInt
+ companyName String @map("company_name") @db.VarChar(128)
+ address String @db.VarChar(256)
+ contactPhone String @map("contact_phone") @db.VarChar(20)
+ contractNo String? @map("contract_no") @db.VarChar(64)
+ contractSignedAt DateTime? @map("contract_signed_at") @db.DateTime(3)
+ contractExpireAt DateTime? @map("contract_expire_at") @db.DateTime(3)
+ bankAccountName String? @map("bank_account_name") @db.VarChar(64)
+ bankAccountNo String? @map("bank_account_no") @db.VarChar(32)
+ bankBranch String? @map("bank_branch") @db.VarChar(128)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
- mergedInto User? @relation("UserMerge", fields: [mergedIntoUserId], references: [id], onDelete: SetNull)
- mergedFrom User[] @relation("UserMerge")
- addresses UserAddress[]
- cityPref UserCityPreference?
- orders Order[]
- benefitCoupons BenefitCoupon[]
- benefitLedgers BenefitLedger[]
- redeemRecords RedeemRecord[]
- promoTouch UserPromoAttribution?
- eventLogs EventLog[]
+ cities CommonCity[]
+ accounts PartnerAccount[]
+ stores Store[]
+ bills PartnerBill[]
- @@index([wxOpenId])
- @@index([wxUnionId])
- @@index([mergedIntoUserId])
- @@map("users")
-}
-
-model UserAddress {
- id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
- userId BigInt @map("user_id") @db.UnsignedBigInt
- receiverName String @map("receiver_name") @db.VarChar(32)
- phone String @db.VarChar(20)
- province String @db.VarChar(32)
- city String @db.VarChar(32)
- district String @db.VarChar(32)
- detail String @db.VarChar(256)
- latitude Decimal? @db.Decimal(10, 7)
- longitude Decimal? @db.Decimal(10, 7)
- isDefault Int @default(0) @map("is_default") @db.TinyInt
- createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
- updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
-
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
-
- @@index([userId])
- @@map("user_addresses")
-}
-
-model UserCityPreference {
- id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
- userId BigInt @unique @map("user_id") @db.UnsignedBigInt
- selectedCityCode String? @map("selected_city_code") @db.VarChar(16)
- selectedDistrict String? @map("selected_district") @db.VarChar(32)
- locateCityCode String? @map("locate_city_code") @db.VarChar(16)
- locateDistrict String? @map("locate_district") @db.VarChar(32)
- updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
-
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
-
- @@map("user_city_preferences")
-}
-
-// ─── B端账号(三表分离)──────────────────────────────
-
-model StoreAccount {
- id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
- storeId BigInt @unique @map("store_id") @db.UnsignedBigInt
- phone String @unique @db.VarChar(20)
- name String @db.VarChar(64)
- wxOpenId String? @map("wx_open_id") @db.VarChar(64)
- wxUnionId String? @map("wx_union_id") @db.VarChar(64)
- status AccountStatus @default(ACTIVE)
- lastLoginAt DateTime? @map("last_login_at") @db.DateTime(3)
- createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
- updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
-
- store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
-
- @@index([wxOpenId])
- @@map("store_accounts")
+ @@index([contactPhone])
+ @@map("partner_partner")
}
model PartnerAccount {
@@ -314,9 +442,31 @@ model PartnerAccount {
@@index([partnerId])
@@index([parentAccountId])
@@index([wxOpenId])
- @@map("partner_accounts")
+ @@map("partner_account")
}
+model PartnerBill {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ billNo String @unique @map("bill_no") @db.VarChar(32)
+ partnerId BigInt @map("partner_id") @db.UnsignedBigInt
+ periodStart DateTime @map("period_start") @db.DateTime(3)
+ periodEnd DateTime @map("period_end") @db.DateTime(3)
+ orderCommission Decimal @default(0) @map("order_commission") @db.Decimal(10, 2)
+ redeemCommission Decimal @default(0) @map("redeem_commission") @db.Decimal(10, 2)
+ totalAmount Decimal @map("total_amount") @db.Decimal(10, 2)
+ status PartnerBillStatus @default(DRAFT)
+ confirmedAt DateTime? @map("confirmed_at") @db.DateTime(3)
+ paidAt DateTime? @map("paid_at") @db.DateTime(3)
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+
+ partner Partner @relation(fields: [partnerId], references: [id], onDelete: Restrict)
+
+ @@index([partnerId, status])
+ @@map("partner_bill")
+}
+
+// ─── HQ ───────────────────────────────────────────────
+
model HqAccount {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
phone String @unique @db.VarChar(20)
@@ -329,130 +479,99 @@ model HqAccount {
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
- storeAudits StoreAudit[]
- operationLogs OperationLog[]
- afterSales AfterSaleTicket[]
- refunds Refund[]
-
- @@index([wxOpenId])
- @@index([adminRole])
- @@map("hq_accounts")
+ @@map("hq_account")
}
-// ─── 开城与合伙人 ─────────────────────────────────────
+// ─── USER ─────────────────────────────────────────────
-model Partner {
- id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
- companyName String @map("company_name") @db.VarChar(128)
- address String @db.VarChar(256)
- contactPhone String @map("contact_phone") @db.VarChar(20)
- bankAccountName String? @map("bank_account_name") @db.VarChar(64)
- bankAccountNo String? @map("bank_account_no") @db.VarChar(32)
- bankBranch String? @map("bank_branch") @db.VarChar(128)
- createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
- updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+model User {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ userNo String @unique @map("user_no") @db.VarChar(20)
+ deviceKey String? @unique @map("device_key") @db.VarChar(36)
+ phone String? @unique @db.VarChar(20)
+ phoneVerifiedAt DateTime? @map("phone_verified_at") @db.DateTime(3)
+ mergedIntoUserId BigInt? @map("merged_into_user_id") @db.UnsignedBigInt
+ wxOpenId String? @map("wx_open_id") @db.VarChar(64)
+ wxUnionId String? @map("wx_union_id") @db.VarChar(64)
+ nickname String? @db.VarChar(64)
+ avatarResourceId BigInt? @map("avatar_resource_id") @db.UnsignedBigInt
+ status Int @default(1) @db.TinyInt
+ sourceType UserSourceType @default(ORGANIC) @map("source_type")
+ sourceRefId BigInt? @map("source_ref_id") @db.UnsignedBigInt
+ sourceLabel String? @map("source_label") @db.VarChar(128)
+ referrerUserId BigInt? @map("referrer_user_id") @db.UnsignedBigInt
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
- cities City[]
- accounts PartnerAccount[]
- stores Store[]
- bills PartnerBill[]
- withdrawals PartnerWithdrawal[]
- contracts PartnerContract[]
- orderCommissions OrderCommission[]
-
- @@index([contactPhone])
- @@map("partners")
-}
-
-model City {
- id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
- code String @unique @db.VarChar(16)
- name String @db.VarChar(64)
- province String @db.VarChar(32)
- status CityStatus @default(PENDING)
- partnerId BigInt? @map("partner_id") @db.UnsignedBigInt
- localMinQty Int @default(2) @map("local_min_qty")
- crossMinQty Int @default(6) @map("cross_min_qty")
- createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
- updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
-
- partner Partner? @relation(fields: [partnerId], references: [id], onDelete: SetNull)
- commissionRule CityCommissionRule?
- stores Store[]
+ mergedInto User? @relation("UserMerge", fields: [mergedIntoUserId], references: [id], onDelete: SetNull)
+ mergedFrom User[] @relation("UserMerge")
+ referrer User? @relation("UserReferrer", fields: [referrerUserId], references: [id], onDelete: SetNull)
+ referrers User[] @relation("UserReferrer")
+ avatar CommonResource? @relation("UserAvatar", fields: [avatarResourceId], references: [id], onDelete: SetNull)
+ addresses UserAddress[]
+ cityPreference UserCityPreference?
+ promoTouch UserPromoAttribution?
orders Order[]
+ benefitCoupons BenefitCoupon[]
+ redeemRecords RedeemRecord[]
- @@index([partnerId])
- @@index([status])
- @@map("cities")
+ @@index([sourceType, sourceRefId])
+ @@index([referrerUserId])
+ @@index([mergedIntoUserId])
+ @@index([wxOpenId])
+ @@map("user_user")
}
-model CityCommissionRule {
- id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
- cityId BigInt @unique @map("city_id") @db.UnsignedBigInt
- orderCommissionRate Decimal @map("order_commission_rate") @db.Decimal(5, 4)
- redeemCommissionRate Decimal @map("redeem_commission_rate") @db.Decimal(5, 4)
- partnerProfitRate Decimal @default(0.35) @map("partner_profit_rate") @db.Decimal(5, 4)
- storeSettlementRate Decimal @default(0.60) @map("store_settlement_rate") @db.Decimal(5, 4)
- updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+model UserAddress {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ userId BigInt @map("user_id") @db.UnsignedBigInt
+ receiverName String @map("receiver_name") @db.VarChar(32)
+ phone String @db.VarChar(20)
+ province String @db.VarChar(32)
+ city String @db.VarChar(32)
+ district String @db.VarChar(32)
+ detail String @db.VarChar(256)
+ latitude Decimal? @db.Decimal(10, 7)
+ longitude Decimal? @db.Decimal(10, 7)
+ isDefault Int @default(0) @map("is_default") @db.TinyInt
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
- city City @relation(fields: [cityId], references: [id], onDelete: Cascade)
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
- @@map("city_commission_rules")
+ @@index([userId])
+ @@map("user_address")
}
-model PartnerContract {
- id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
- partnerId BigInt @map("partner_id") @db.UnsignedBigInt
- contractNo String @map("contract_no") @db.VarChar(64)
- fileUrl String @map("file_url") @db.VarChar(512)
- signedAt DateTime @map("signed_at") @db.DateTime(3)
- expireAt DateTime? @map("expire_at") @db.DateTime(3)
- status String @default("ACTIVE") @db.VarChar(16)
- createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+model UserCityPreference {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ userId BigInt @unique @map("user_id") @db.UnsignedBigInt
+ selectedCityCode String? @map("selected_city_code") @db.VarChar(16)
+ selectedDistrict String? @map("selected_district") @db.VarChar(32)
+ locateCityCode String? @map("locate_city_code") @db.VarChar(16)
+ locateDistrict String? @map("locate_district") @db.VarChar(32)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
- partner Partner @relation(fields: [partnerId], references: [id], onDelete: Restrict)
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
- @@index([partnerId])
- @@map("partner_contracts")
+ @@map("user_city_preference")
}
-// ─── 商品 ─────────────────────────────────────────────
+model UserPromoAttribution {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ userId BigInt @unique @map("user_id") @db.UnsignedBigInt
+ promoCodeId BigInt @map("promo_code_id") @db.UnsignedBigInt
+ channelName String @map("channel_name") @db.VarChar(128)
+ firstTouchAt DateTime @map("first_touch_at") @db.DateTime(3)
-model Product {
- id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
- skuCode String @unique @map("sku_code") @db.VarChar(32)
- name String @db.VarChar(128)
- subtitle String? @db.VarChar(256)
- aromaType AromaType @map("aroma_type")
- spec String @db.VarChar(128)
- price Decimal @db.Decimal(10, 2)
- benefitAmount Decimal? @map("benefit_amount") @db.Decimal(10, 2)
- status ProductStatus @default(DRAFT)
- sortOrder Int @default(0) @map("sort_order")
- mainImageUrl String @map("main_image_url") @db.VarChar(512)
- detailContent Json? @map("detail_content")
- carouselUrls Json? @map("carousel_urls")
- createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
- updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ promoCode CommonPromoCode @relation(fields: [promoCodeId], references: [id], onDelete: Restrict)
- orderItems OrderItem[]
-
- @@index([status, aromaType])
- @@map("products")
+ @@index([promoCodeId])
+ @@map("user_promo_attribution")
}
-// ─── 门店 ─────────────────────────────────────────────
-
-model StoreCategory {
- id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
- code String @unique @db.VarChar(32)
- name String @db.VarChar(64)
- sort Int @default(0)
-
- stores Store[]
-
- @@map("store_categories")
-}
+// ─── STORE ────────────────────────────────────────────
model Store {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
@@ -462,13 +581,13 @@ model Store {
name String @db.VarChar(128)
phone String @db.VarChar(20)
province String @db.VarChar(32)
- cityName String @map("city") @db.VarChar(32)
+ cityName String @map("city_name") @db.VarChar(32)
district String @db.VarChar(32)
address String @db.VarChar(256)
latitude Decimal? @db.Decimal(10, 7)
longitude Decimal? @db.Decimal(10, 7)
intro String? @db.Text
- coverUrl String? @map("cover_url") @db.VarChar(512)
+ coverResourceId BigInt? @map("cover_resource_id") @db.UnsignedBigInt
avgPrice Decimal? @map("avg_price") @db.Decimal(10, 2)
rating Decimal? @db.Decimal(3, 2)
tags Json?
@@ -481,269 +600,136 @@ model Store {
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
- cityRef City @relation(fields: [cityId], references: [id], onDelete: Restrict)
- partner Partner @relation(fields: [partnerId], references: [id], onDelete: Restrict)
- category StoreCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
+ cityRef CommonCity @relation(fields: [cityId], references: [id], onDelete: Restrict)
+ partner Partner @relation(fields: [partnerId], references: [id], onDelete: Restrict)
+ category CommonStoreCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
+ coverResource CommonResource? @relation("StoreCover", fields: [coverResourceId], references: [id], onDelete: SetNull)
account StoreAccount?
- media StoreMedia[]
- audits StoreAudit[]
redeemRecords RedeemRecord[]
- storePayouts StorePayout[]
ratings StoreRating[]
+ payouts StorePayout[]
@@index([cityId, status])
@@index([partnerId])
- @@map("stores")
+ @@map("store_store")
}
-model StoreMedia {
- id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
- storeId BigInt @map("store_id") @db.UnsignedBigInt
- mediaType String @map("media_type") @db.VarChar(16)
- url String @db.VarChar(512)
- sortOrder Int @default(0) @map("sort_order")
- createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+model StoreAccount {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ storeId BigInt @unique @map("store_id") @db.UnsignedBigInt
+ phone String @unique @db.VarChar(20)
+ name String @db.VarChar(64)
+ wxOpenId String? @map("wx_open_id") @db.VarChar(64)
+ wxUnionId String? @map("wx_union_id") @db.VarChar(64)
+ status AccountStatus @default(ACTIVE)
+ lastLoginAt DateTime? @map("last_login_at") @db.DateTime(3)
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
- @@index([storeId])
- @@map("store_media")
+ @@map("store_account")
}
-model StoreAudit {
- id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
- storeId BigInt @map("store_id") @db.UnsignedBigInt
- auditType StoreAuditType @map("audit_type")
- status StoreAuditStatus @default(PENDING)
- submitData Json? @map("submit_data")
- rejectReason String? @map("reject_reason") @db.VarChar(512)
- reviewerId BigInt? @map("reviewer_id") @db.UnsignedBigInt
- submittedAt DateTime @default(now()) @map("submitted_at") @db.DateTime(3)
- reviewedAt DateTime? @map("reviewed_at") @db.DateTime(3)
-
- store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
- reviewer HqAccount? @relation(fields: [reviewerId], references: [id], onDelete: SetNull)
-
- @@index([storeId, status])
- @@index([reviewerId])
- @@map("store_audits")
-}
-
-// ─── 推广码 ───────────────────────────────────────────
-
-model PromoCode {
- id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
- code String @unique @db.VarChar(32)
- name String @db.VarChar(128)
- status PromoCodeStatus @default(ACTIVE)
- wxQrcodeUrl String? @map("wx_qrcode_url") @db.VarChar(512)
- scanCount Int @default(0) @map("scan_count")
- orderCount Int @default(0) @map("order_count")
- createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
-
- orders Order[]
- attributions UserPromoAttribution[]
-
- @@map("promo_codes")
-}
-
-model UserPromoAttribution {
- id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
- userId BigInt @unique @map("user_id") @db.UnsignedBigInt
- promoCodeId BigInt @map("promo_code_id") @db.UnsignedBigInt
- channelName String @map("channel_name") @db.VarChar(128)
- firstTouchAt DateTime @map("first_touch_at") @db.DateTime(3)
-
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
- promoCode PromoCode @relation(fields: [promoCodeId], references: [id], onDelete: Restrict)
-
- @@index([promoCodeId])
- @@map("user_promo_attributions")
-}
-
-// ─── 订单与支付 ─────────────────────────────────────
+// ─── ORDER ────────────────────────────────────────────
model Order {
- id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
- orderNo String @unique @map("order_no") @db.VarChar(32)
- orderType OrderType @default(NORMAL) @map("order_type")
- userId BigInt @map("user_id") @db.UnsignedBigInt
- cityId BigInt @map("city_id") @db.UnsignedBigInt
- status OrderStatus @default(PENDING_PAY)
- deliveryType DeliveryType @map("delivery_type")
- originOrderId BigInt? @map("origin_order_id") @db.UnsignedBigInt
- promoCodeId BigInt? @map("promo_code_id") @db.UnsignedBigInt
- channelSource String? @map("channel_source") @db.VarChar(128)
- receiverName String @map("receiver_name") @db.VarChar(32)
- receiverPhone String @map("receiver_phone") @db.VarChar(20)
- receiverAddress String @map("receiver_address") @db.Text
- receiverProvince String @map("receiver_province") @db.VarChar(32)
- receiverCity String @map("receiver_city") @db.VarChar(32)
- receiverDistrict String @map("receiver_district") @db.VarChar(32)
- clientIp String? @map("client_ip") @db.VarChar(45)
- ipProvince String? @map("ip_province") @db.VarChar(32)
- ipCity String? @map("ip_city") @db.VarChar(32)
- ipDistrict String? @map("ip_district") @db.VarChar(32)
- gpsProvince String? @map("gps_province") @db.VarChar(32)
- gpsCity String? @map("gps_city") @db.VarChar(32)
- gpsDistrict String? @map("gps_district") @db.VarChar(32)
- gpsLatitude Decimal? @map("gps_latitude") @db.Decimal(10, 7)
- gpsLongitude Decimal? @map("gps_longitude") @db.Decimal(10, 7)
- gpsAddress String? @map("gps_address") @db.VarChar(256)
- productAmount Decimal @map("product_amount") @db.Decimal(10, 2)
- freightAmount Decimal @default(0) @map("freight_amount") @db.Decimal(10, 2)
- freightPayType String? @map("freight_pay_type") @db.VarChar(8)
- payAmount Decimal @map("pay_amount") @db.Decimal(10, 2)
- benefitAmount Decimal @default(0) @map("benefit_amount") @db.Decimal(10, 2)
- paidAt DateTime? @map("paid_at") @db.DateTime(3)
- shippedAt DateTime? @map("shipped_at") @db.DateTime(3)
- completedAt DateTime? @map("completed_at") @db.DateTime(3)
- cancelledAt DateTime? @map("cancelled_at") @db.DateTime(3)
- payExpireAt DateTime? @map("pay_expire_at") @db.DateTime(3)
- remark String? @db.VarChar(512)
- createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
- updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
-
- user User @relation(fields: [userId], references: [id], onDelete: Restrict)
- city City @relation(fields: [cityId], references: [id], onDelete: Restrict)
- originOrder Order? @relation("ReshipmentOrder", fields: [originOrderId], references: [id], onDelete: SetNull)
- reshipments Order[] @relation("ReshipmentOrder")
- promoCode PromoCode? @relation(fields: [promoCodeId], references: [id], onDelete: SetNull)
- items OrderItem[]
- statusLogs OrderStatusLog[]
- delivery OrderDelivery?
- payment Payment?
- refunds Refund[]
- benefitCoupons BenefitCoupon[]
- intercepts DeliveryIntercept[]
- afterSales AfterSaleTicket[]
- commissions OrderCommission[]
-
- @@index([userId, status])
- @@index([cityId, createdAt])
- @@index([receiverPhone])
- @@index([originOrderId])
- @@index([ipCity])
- @@index([gpsCity])
- @@map("orders")
-}
-
-model OrderItem {
- id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
- orderId BigInt @map("order_id") @db.UnsignedBigInt
- productId BigInt @map("product_id") @db.UnsignedBigInt
- productName String @map("product_name") @db.VarChar(128)
- productSpec String @map("product_spec") @db.VarChar(128)
- productImage String @map("product_image") @db.VarChar(512)
- unitPrice Decimal @map("unit_price") @db.Decimal(10, 2)
- quantity Int
- subtotal Decimal @db.Decimal(10, 2)
-
- order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
- product Product @relation(fields: [productId], references: [id], onDelete: Restrict)
-
- @@index([orderId])
- @@map("order_items")
-}
-
-model OrderStatusLog {
- id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
- orderId BigInt @map("order_id") @db.UnsignedBigInt
- fromStatus String? @map("from_status") @db.VarChar(32)
- toStatus String @map("to_status") @db.VarChar(32)
- operator String? @db.VarChar(64)
- remark String? @db.VarChar(256)
- createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
-
- order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
-
- @@index([orderId])
- @@map("order_status_logs")
-}
-
-model OrderDelivery {
- id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
- orderId BigInt @unique @map("order_id") @db.UnsignedBigInt
- provider String @db.VarChar(16)
- providerOrderNo String? @map("provider_order_no") @db.VarChar(64)
- trackingNo String? @map("tracking_no") @db.VarChar(64)
- outWarehouseAt DateTime? @map("out_warehouse_at") @db.DateTime(3)
- shippingAt DateTime? @map("shipping_at") @db.DateTime(3)
- deliveredAt DateTime? @map("delivered_at") @db.DateTime(3)
- rawPayload Json? @map("raw_payload")
- updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
-
- order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
-
- @@map("order_deliveries")
-}
-
-model Payment {
- id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
- orderId BigInt @unique @map("order_id") @db.UnsignedBigInt
- paymentNo String @unique @map("payment_no") @db.VarChar(32)
- amount Decimal @db.Decimal(10, 2)
- status PaymentStatus @default(PENDING)
- wxTransactionId String? @unique @map("wx_transaction_id") @db.VarChar(64)
- wxPrepayId String? @map("wx_prepay_id") @db.VarChar(64)
- paidAt DateTime? @map("paid_at") @db.DateTime(3)
- notifyPayload Json? @map("notify_payload")
- createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
- updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
-
- order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
-
- @@map("payments")
-}
-
-model Refund {
- id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
- orderId BigInt @map("order_id") @db.UnsignedBigInt
- refundNo String @unique @map("refund_no") @db.VarChar(32)
- amount Decimal @db.Decimal(10, 2)
- reason String? @db.VarChar(512)
- status RefundStatus @default(PENDING)
- wxRefundId String? @map("wx_refund_id") @db.VarChar(64)
- benefitAdjust Decimal? @map("benefit_adjust") @db.Decimal(10, 2)
- operatorId BigInt? @map("operator_id") @db.UnsignedBigInt
- approvedAt DateTime? @map("approved_at") @db.DateTime(3)
- refundedAt DateTime? @map("refunded_at") @db.DateTime(3)
- createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
-
- order Order @relation(fields: [orderId], references: [id], onDelete: Restrict)
- operator HqAccount? @relation(fields: [operatorId], references: [id], onDelete: SetNull)
-
- @@index([orderId])
- @@index([operatorId])
- @@map("refunds")
-}
-
-model DeliveryIntercept {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
- orderId BigInt @map("order_id") @db.UnsignedBigInt
- status InterceptStatus @default(PENDING)
- oldAddress Json @map("old_address")
- newAddress Json @map("new_address")
- partnerId BigInt? @map("partner_id") @db.UnsignedBigInt
- interceptAt DateTime? @map("intercept_at") @db.DateTime(3)
- redeliveryOrderId BigInt? @map("redelivery_order_id") @db.UnsignedBigInt
+ orderNo String @unique @map("order_no") @db.VarChar(32)
+ orderType OrderType @default(NORMAL) @map("order_type")
+ userId BigInt @map("user_id") @db.UnsignedBigInt
+ cityId BigInt @map("city_id") @db.UnsignedBigInt
+ status OrderStatus @default(PENDING_PAY)
+ payStatus PayStatus @default(UNPAID) @map("pay_status")
+ deliveryType DeliveryType @map("delivery_type")
+ originOrderId BigInt? @map("origin_order_id") @db.UnsignedBigInt
+ promoCodeId BigInt? @map("promo_code_id") @db.UnsignedBigInt
+ channelSource String? @map("channel_source") @db.VarChar(128)
+ productId BigInt @map("product_id") @db.UnsignedBigInt
+ barcode69 String @map("barcode_69") @db.VarChar(32)
+ productName String @map("product_name") @db.VarChar(128)
+ productSpec String @map("product_spec") @db.VarChar(128)
+ imageResourceId BigInt? @map("image_resource_id") @db.UnsignedBigInt
+ quantity Int
+ listUnitPrice Decimal @map("list_unit_price") @db.Decimal(10, 2)
+ listAmount Decimal @map("list_amount") @db.Decimal(10, 2)
+ discountAmount Decimal @default(0) @map("discount_amount") @db.Decimal(10, 2)
+ productAmount Decimal @map("product_amount") @db.Decimal(10, 2)
+ freightAmount Decimal @default(0) @map("freight_amount") @db.Decimal(10, 2)
+ freightPayType FreightPayType? @map("freight_pay_type")
+ payAmount Decimal @map("pay_amount") @db.Decimal(10, 2)
+ benefitAmount Decimal @default(0) @map("benefit_amount") @db.Decimal(10, 2)
+ receiverName String @map("receiver_name") @db.VarChar(32)
+ receiverPhone String @map("receiver_phone") @db.VarChar(20)
+ receiverAddress String @map("receiver_address") @db.Text
+ receiverProvince String @map("receiver_province") @db.VarChar(32)
+ receiverCity String @map("receiver_city") @db.VarChar(32)
+ receiverDistrict String @map("receiver_district") @db.VarChar(32)
+ clientIp String? @map("client_ip") @db.VarChar(45)
+ ipProvince String? @map("ip_province") @db.VarChar(32)
+ ipCity String? @map("ip_city") @db.VarChar(32)
+ ipDistrict String? @map("ip_district") @db.VarChar(32)
+ gpsProvince String? @map("gps_province") @db.VarChar(32)
+ gpsCity String? @map("gps_city") @db.VarChar(32)
+ gpsDistrict String? @map("gps_district") @db.VarChar(32)
+ gpsLatitude Decimal? @map("gps_latitude") @db.Decimal(10, 7)
+ gpsLongitude Decimal? @map("gps_longitude") @db.Decimal(10, 7)
+ gpsAddress String? @map("gps_address") @db.VarChar(256)
+ payExternalNo String? @map("pay_external_no") @db.VarChar(64)
+ paidAt DateTime? @map("paid_at") @db.DateTime(3)
+ shippedAt DateTime? @map("shipped_at") @db.DateTime(3)
+ completedAt DateTime? @map("completed_at") @db.DateTime(3)
+ cancelledAt DateTime? @map("cancelled_at") @db.DateTime(3)
+ payExpireAt DateTime? @map("pay_expire_at") @db.DateTime(3)
remark String? @db.VarChar(512)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
- order Order @relation(fields: [orderId], references: [id], onDelete: Restrict)
+ user User @relation(fields: [userId], references: [id], onDelete: Restrict)
+ city CommonCity @relation(fields: [cityId], references: [id], onDelete: Restrict)
+ originOrder Order? @relation("OrderReshipment", fields: [originOrderId], references: [id], onDelete: SetNull)
+ reshipments Order[] @relation("OrderReshipment")
+ promoCode CommonPromoCode? @relation(fields: [promoCodeId], references: [id], onDelete: SetNull)
+ product CommonProductItem @relation(fields: [productId], references: [id], onDelete: Restrict)
+ imageResource CommonResource? @relation("OrderProductImage", fields: [imageResourceId], references: [id], onDelete: SetNull)
+ delivery OrderDelivery?
+ benefitCoupon BenefitCoupon?
- @@index([orderId, status])
- @@map("delivery_intercepts")
+ @@index([userId, status])
+ @@index([cityId, createdAt])
+ @@index([productId])
+ @@index([barcode69])
+ @@index([payExternalNo])
+ @@index([ipCity])
+ @@index([gpsCity])
+ @@map("user_order")
}
-// ─── 好客权益 ─────────────────────────────────────────
+model OrderDelivery {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ orderId BigInt @unique @map("order_id") @db.UnsignedBigInt
+ provider DeliveryProvider
+ providerOrderNo String? @map("provider_order_no") @db.VarChar(64)
+ trackingNo String? @map("tracking_no") @db.VarChar(64)
+ outWarehouseAt DateTime? @map("out_warehouse_at") @db.DateTime(3)
+ shippingAt DateTime? @map("shipping_at") @db.DateTime(3)
+ deliveredAt DateTime? @map("delivered_at") @db.DateTime(3)
+ signPhotoResourceId BigInt? @map("sign_photo_resource_id") @db.UnsignedBigInt
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+
+ order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
+ signPhotoResource CommonResource? @relation("DeliverySignPhoto", fields: [signPhotoResourceId], references: [id], onDelete: SetNull)
+
+ @@map("user_order_delivery")
+}
+
+// ─── BENEFIT & REDEEM ─────────────────────────────────
model BenefitCoupon {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
couponNo String @unique @map("coupon_no") @db.VarChar(32)
userId BigInt @map("user_id") @db.UnsignedBigInt
- orderId BigInt @map("order_id") @db.UnsignedBigInt
+ orderId BigInt @unique @map("order_id") @db.UnsignedBigInt
totalAmount Decimal @map("total_amount") @db.Decimal(10, 2)
usedAmount Decimal @default(0) @map("used_amount") @db.Decimal(10, 2)
balance Decimal @db.Decimal(10, 2)
@@ -753,55 +739,12 @@ model BenefitCoupon {
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
- user User @relation(fields: [userId], references: [id], onDelete: Restrict)
- order Order @relation(fields: [orderId], references: [id], onDelete: Restrict)
- ledgers BenefitLedger[]
+ user User @relation(fields: [userId], references: [id], onDelete: Restrict)
+ order Order @relation(fields: [orderId], references: [id], onDelete: Restrict)
redeemRecords RedeemRecord[]
- redeemTokens RedeemToken[]
@@index([userId, status])
- @@index([orderId])
- @@map("benefit_coupons")
-}
-
-model BenefitLedger {
- id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
- userId BigInt @map("user_id") @db.UnsignedBigInt
- couponId BigInt @map("coupon_id") @db.UnsignedBigInt
- type BenefitLedgerType
- amount Decimal @db.Decimal(10, 2)
- balanceAfter Decimal @map("balance_after") @db.Decimal(10, 2)
- refType String? @map("ref_type") @db.VarChar(32)
- refId BigInt? @map("ref_id") @db.UnsignedBigInt
- remark String? @db.VarChar(256)
- createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
-
- user User @relation(fields: [userId], references: [id], onDelete: Restrict)
- coupon BenefitCoupon @relation(fields: [couponId], references: [id], onDelete: Restrict)
-
- @@index([userId, createdAt])
- @@index([couponId])
- @@map("benefit_ledgers")
-}
-
-// ─── 核销 ─────────────────────────────────────────────
-
-model RedeemToken {
- id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
- token String @unique @db.VarChar(64)
- userId BigInt @map("user_id") @db.UnsignedBigInt
- couponId BigInt @map("coupon_id") @db.UnsignedBigInt
- storeId BigInt? @map("store_id") @db.UnsignedBigInt
- amount Decimal @db.Decimal(10, 2)
- status RedeemTokenStatus @default(ACTIVE)
- expireAt DateTime @map("expire_at") @db.DateTime(3)
- usedAt DateTime? @map("used_at") @db.DateTime(3)
- createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
-
- coupon BenefitCoupon @relation(fields: [couponId], references: [id], onDelete: Restrict)
-
- @@index([couponId])
- @@map("redeem_tokens")
+ @@map("user_benefit_coupon")
}
model RedeemRecord {
@@ -812,19 +755,16 @@ model RedeemRecord {
storeId BigInt @map("store_id") @db.UnsignedBigInt
amount Decimal @db.Decimal(10, 2)
settleAmount Decimal @map("settle_amount") @db.Decimal(10, 2)
- tokenId BigInt? @map("token_id") @db.UnsignedBigInt
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
- user User @relation(fields: [userId], references: [id], onDelete: Restrict)
- coupon BenefitCoupon @relation(fields: [couponId], references: [id], onDelete: Restrict)
- store Store @relation(fields: [storeId], references: [id], onDelete: Restrict)
- payout StorePayout?
- rating StoreRating?
- commissions OrderCommission[]
+ user User @relation(fields: [userId], references: [id], onDelete: Restrict)
+ coupon BenefitCoupon @relation(fields: [couponId], references: [id], onDelete: Restrict)
+ store Store @relation(fields: [storeId], references: [id], onDelete: Restrict)
+ rating StoreRating?
+ payout StorePayout?
@@index([storeId, createdAt])
- @@index([userId])
- @@map("redeem_records")
+ @@map("user_redeem_record")
}
model StoreRating {
@@ -838,11 +778,9 @@ model StoreRating {
redeemRecord RedeemRecord @relation(fields: [redeemRecordId], references: [id], onDelete: Cascade)
store Store @relation(fields: [storeId], references: [id], onDelete: Restrict)
- @@map("store_ratings")
+ @@map("user_store_rating")
}
-// ─── 结算 ─────────────────────────────────────────────
-
model StorePayout {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
redeemRecordId BigInt @unique @map("redeem_record_id") @db.UnsignedBigInt
@@ -860,149 +798,50 @@ model StorePayout {
store Store @relation(fields: [storeId], references: [id], onDelete: Restrict)
@@index([storeId, status])
- @@index([expectedPayAt])
- @@map("store_payouts")
+ @@map("store_payout")
}
-model OrderCommission {
- id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
- orderId BigInt? @map("order_id") @db.UnsignedBigInt
- redeemId BigInt? @map("redeem_id") @db.UnsignedBigInt
- partnerId BigInt @map("partner_id") @db.UnsignedBigInt
- commissionType String @map("commission_type") @db.VarChar(16)
- baseAmount Decimal @map("base_amount") @db.Decimal(10, 2)
- rate Decimal @db.Decimal(5, 4)
- amount Decimal @db.Decimal(10, 2)
- billId BigInt? @map("bill_id") @db.UnsignedBigInt
- createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+// ─── LOG ──────────────────────────────────────────────
- order Order? @relation(fields: [orderId], references: [id], onDelete: SetNull)
- redeem RedeemRecord? @relation(fields: [redeemId], references: [id], onDelete: SetNull)
- partner Partner @relation(fields: [partnerId], references: [id], onDelete: Restrict)
- bill PartnerBill? @relation(fields: [billId], references: [id], onDelete: SetNull)
+model LogThirdParty {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ provider ThirdPartyProvider
+ scene String @db.VarChar(64)
+ refType String? @map("ref_type") @db.VarChar(32)
+ refId BigInt? @map("ref_id") @db.UnsignedBigInt
+ requestUrl String? @map("request_url") @db.VarChar(512)
+ requestBody Json? @map("request_body")
+ responseBody Json? @map("response_body")
+ externalNo String? @map("external_no") @db.VarChar(128)
+ amount Decimal? @db.Decimal(10, 2)
+ status ThirdPartyLogStatus @default(PENDING)
+ errorMessage String? @map("error_message") @db.VarChar(512)
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
- @@index([partnerId])
- @@index([billId])
- @@map("order_commissions")
+ @@index([refType, refId])
+ @@index([provider, scene, createdAt])
+ @@index([externalNo])
+ @@map("log_third_party")
}
-model PartnerBill {
- id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
- billNo String @unique @map("bill_no") @db.VarChar(32)
- partnerId BigInt @map("partner_id") @db.UnsignedBigInt
- periodStart DateTime @map("period_start") @db.DateTime(3)
- periodEnd DateTime @map("period_end") @db.DateTime(3)
- orderCommission Decimal @map("order_commission") @db.Decimal(10, 2)
- redeemCommission Decimal @map("redeem_commission") @db.Decimal(10, 2)
- totalAmount Decimal @map("total_amount") @db.Decimal(10, 2)
- status PartnerBillStatus @default(DRAFT)
- confirmedAt DateTime? @map("confirmed_at") @db.DateTime(3)
- paidAt DateTime? @map("paid_at") @db.DateTime(3)
- createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
-
- partner Partner @relation(fields: [partnerId], references: [id], onDelete: Restrict)
- commissions OrderCommission[]
-
- @@index([partnerId, status])
- @@map("partner_bills")
-}
-
-model PartnerWithdrawal {
- id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
- partnerId BigInt @map("partner_id") @db.UnsignedBigInt
- amount Decimal @db.Decimal(10, 2)
- status WithdrawalStatus @default(PENDING)
- remark String? @db.VarChar(256)
- appliedAt DateTime @default(now()) @map("applied_at") @db.DateTime(3)
- processedAt DateTime? @map("processed_at") @db.DateTime(3)
-
- partner Partner @relation(fields: [partnerId], references: [id], onDelete: Restrict)
-
- @@index([partnerId])
- @@map("partner_withdrawals")
-}
-
-// ─── 埋点 ─────────────────────────────────────────────
-
-model EventLog {
- id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
- userId BigInt? @map("user_id") @db.UnsignedBigInt
- eventName String @map("event_name") @db.VarChar(64)
- params Json?
- clientApp String? @map("client_app") @db.VarChar(32)
- createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
-
- user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
+model LogUserAnalytics {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ userId BigInt? @map("user_id") @db.UnsignedBigInt
+ sessionId String? @map("session_id") @db.VarChar(64)
+ eventName String @map("event_name") @db.VarChar(64)
+ clientApp ClientApp? @map("client_app")
+ pagePath String? @map("page_path") @db.VarChar(128)
+ refType String? @map("ref_type") @db.VarChar(32)
+ refId BigInt? @map("ref_id") @db.UnsignedBigInt
+ keyword String? @db.VarChar(128)
+ sourceType String? @map("source_type") @db.VarChar(32)
+ sourceRefId BigInt? @map("source_ref_id") @db.UnsignedBigInt
+ extraJson Json? @map("extra_json")
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ @@index([userId, createdAt])
@@index([eventName, createdAt])
- @@index([userId])
- @@map("event_logs")
-}
-
-// ─── 运营与售后 ─────────────────────────────────────
-
-model AfterSaleTicket {
- id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
- ticketNo String @unique @map("ticket_no") @db.VarChar(32)
- type AfterSaleType
- status AfterSaleStatus @default(PENDING)
- orderId BigInt @map("order_id") @db.UnsignedBigInt
- reshipmentOrderId BigInt? @map("reshipment_order_id") @db.UnsignedBigInt
- refundId BigInt? @map("refund_id") @db.UnsignedBigInt
- reason String? @db.VarChar(512)
- operatorId BigInt? @map("operator_id") @db.UnsignedBigInt
- createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
- completedAt DateTime? @map("completed_at") @db.DateTime(3)
-
- order Order @relation(fields: [orderId], references: [id], onDelete: Restrict)
- operator HqAccount? @relation(fields: [operatorId], references: [id], onDelete: SetNull)
-
- @@index([orderId])
- @@index([status, type])
- @@index([operatorId])
- @@map("after_sale_tickets")
-}
-
-model Alert {
- id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
- alertType AlertType @map("alert_type")
- status AlertStatus @default(OPEN)
- refType String @map("ref_type") @db.VarChar(32)
- refId BigInt @map("ref_id") @db.UnsignedBigInt
- title String @db.VarChar(128)
- content String @db.Text
- resolvedAt DateTime? @map("resolved_at") @db.DateTime(3)
- createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
-
- @@index([status, alertType])
- @@map("alerts")
-}
-
-model OperationLog {
- id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
- hqAccountId BigInt @map("hq_account_id") @db.UnsignedBigInt
- action String @db.VarChar(64)
- refType String? @map("ref_type") @db.VarChar(32)
- refId BigInt? @map("ref_id") @db.UnsignedBigInt
- detail Json?
- createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
-
- hqAccount HqAccount @relation(fields: [hqAccountId], references: [id], onDelete: Restrict)
-
- @@index([hqAccountId, createdAt])
- @@map("operation_logs")
-}
-
-model SmsLog {
- id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
- phone String @db.VarChar(20)
- template String @db.VarChar(64)
- content String @db.Text
- status String @db.VarChar(16)
- refType String? @map("ref_type") @db.VarChar(32)
- refId BigInt? @map("ref_id") @db.UnsignedBigInt
- createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
-
- @@index([phone, createdAt])
- @@map("sms_logs")
+ @@index([sessionId])
+ @@index([refType, refId])
+ @@map("log_user_analytics")
}
diff --git a/server/dukang-api/prisma/schema.v31.prisma b/server/dukang-api/prisma/schema.v31.prisma
new file mode 100644
index 0000000..0a8334e
--- /dev/null
+++ b/server/dukang-api/prisma/schema.v31.prisma
@@ -0,0 +1,847 @@
+// 杜康好客 · V3.1 数据模型(由 init_v3.sql 生成)
+// 激活方式:确认后替换 schema.prisma,执行 prisma migrate / db push + seed-v31
+// 旧版备份:schema.legacy-v21.prisma
+
+generator client {
+ provider = "prisma-client-js"
+}
+
+datasource db {
+ provider = "mysql"
+ url = env("DATABASE_URL")
+}
+
+// ─── 枚举(与 init_v3.sql COMMENT 对齐)────────────────
+
+enum ClientApp {
+ USER_MINI
+ USER_H5
+ PARTNER_MINI
+ PARTNER_H5
+ HQ_MINI
+ HQ_WEB
+ SHOP_H5
+}
+
+enum ResourceOwnerType {
+ PRODUCT
+ STORE
+ PARTNER
+ USER
+ ORDER
+ PROMO
+ HQ
+}
+
+enum ResourceBizType {
+ COVER
+ ENV
+ CONTRACT
+ CAROUSEL
+ DETAIL
+ AVATAR
+ QRCODE
+ SIGN_PHOTO
+ VIDEO
+}
+
+enum ResourceMediaType {
+ IMAGE
+ VIDEO
+ FILE
+}
+
+enum ResourceStatus {
+ ACTIVE
+ DELETED
+}
+
+enum EventType {
+ STORE_AUDIT
+ ORDER_STATUS
+ BENEFIT_LEDGER
+ HQ_OPERATION
+ PROMO_TOUCH
+}
+
+enum ActorType {
+ USER
+ STORE
+ PARTNER
+ HQ
+ SYSTEM
+}
+
+enum TicketType {
+ REFUND
+ RESHIPMENT
+ ALERT
+}
+
+enum AromaType {
+ QINGXIANG
+ JIANGXIANG
+ NONGXIANG
+}
+
+enum ProductStatus {
+ DRAFT
+ ON_SALE
+ OFF_SALE
+}
+
+enum PromoCodeStatus {
+ ACTIVE
+ DISABLED
+}
+
+enum CityStatus {
+ PENDING
+ ACTIVE
+ PAUSED
+}
+
+enum PartnerStaffRole {
+ PARTNER
+ INTERNAL
+ PROMOTER
+}
+
+enum AccountStatus {
+ ACTIVE
+ DISABLED
+}
+
+enum HqAdminRole {
+ SUPER_ADMIN
+ OPS
+ FINANCE
+ CUSTOMER_SERVICE
+}
+
+enum PartnerBillStatus {
+ DRAFT
+ CONFIRMED
+ PAID
+}
+
+enum StoreStatus {
+ OPEN
+ PAUSED
+ CLOSED
+}
+
+enum UserSourceType {
+ ORGANIC
+ PROMO_CODE
+ SHARE_LINK
+ FRIEND_REFERRAL
+ OFFLINE_EVENT
+ OTHER
+}
+
+enum OrderType {
+ NORMAL
+ RESHIPMENT
+}
+
+enum OrderStatus {
+ PENDING_PAY
+ PENDING_SHIP
+ OUT_WAREHOUSE
+ SHIPPING
+ PENDING_RECEIVE
+ COMPLETED
+ CANCELLED
+ REFUNDING
+ REFUNDED
+}
+
+enum PayStatus {
+ UNPAID
+ PAYING
+ PAID
+ REFUNDING
+ REFUNDED
+}
+
+enum DeliveryType {
+ LOCAL
+ CROSS_CITY
+}
+
+enum FreightPayType {
+ FREE
+ COD
+}
+
+enum DeliveryProvider {
+ XFX
+ LOGISTICS
+ MANUAL
+}
+
+enum BenefitCouponStatus {
+ ACTIVE
+ USED_UP
+ VOID
+}
+
+enum StorePayoutStatus {
+ PENDING
+ PAID
+}
+
+enum ThirdPartyProvider {
+ WECHAT_PAY
+ WECHAT_REFUND
+ WECHAT_AUTH
+ WECHAT_MAP
+ XFX
+ SMS
+ LOGISTICS
+}
+
+enum ThirdPartyLogStatus {
+ PENDING
+ SUCCESS
+ FAILED
+}
+
+enum BenefitLedgerType {
+ GRANT
+ REDEEM
+ REFUND_VOID
+ ADJUST
+}
+
+// ─── COMMON ───────────────────────────────────────────
+
+model CommonWxAppConfig {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ clientApp ClientApp @unique @map("client_app")
+ appId String @map("app_id") @db.VarChar(64)
+ appSecret String @map("app_secret") @db.VarChar(128)
+ mchId String? @map("mch_id") @db.VarChar(32)
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+
+ @@map("common_wx_app_config")
+}
+
+model CommonResource {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ ownerType ResourceOwnerType @map("owner_type")
+ ownerId BigInt @map("owner_id") @db.UnsignedBigInt
+ bizType ResourceBizType @map("biz_type")
+ mediaType ResourceMediaType @default(IMAGE) @map("media_type")
+ ossBucket String @map("oss_bucket") @db.VarChar(64)
+ ossKey String @map("oss_key") @db.VarChar(256)
+ url String @db.VarChar(512)
+ fileName String? @map("file_name") @db.VarChar(128)
+ fileSize BigInt? @map("file_size") @db.UnsignedBigInt
+ mimeType String? @map("mime_type") @db.VarChar(64)
+ sortOrder Int @default(0) @map("sort_order")
+ status ResourceStatus @default(ACTIVE)
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+
+ productCovers CommonProductItem[] @relation("ProductCover")
+ promoQrcodes CommonPromoCode[] @relation("PromoQrcode")
+ userAvatars User[] @relation("UserAvatar")
+ storeCovers Store[] @relation("StoreCover")
+ orderImages Order[] @relation("OrderProductImage")
+ deliveryPhotos OrderDelivery[] @relation("DeliverySignPhoto")
+
+ @@index([ownerType, ownerId, bizType])
+ @@index([status])
+ @@map("common_resource")
+}
+
+model CommonEvent {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ eventType EventType @map("event_type")
+ refType String @map("ref_type") @db.VarChar(32)
+ refId BigInt @map("ref_id") @db.UnsignedBigInt
+ actorType ActorType? @map("actor_type")
+ actorId BigInt? @map("actor_id") @db.UnsignedBigInt
+ status String? @db.VarChar(32)
+ param1 String? @db.VarChar(128)
+ param1Desc String? @map("param1_desc") @db.VarChar(64)
+ param2 String? @db.VarChar(128)
+ param2Desc String? @map("param2_desc") @db.VarChar(64)
+ param3 String? @db.VarChar(128)
+ param3Desc String? @map("param3_desc") @db.VarChar(64)
+ amount1 Decimal? @db.Decimal(10, 2)
+ amount2 Decimal? @db.Decimal(10, 2)
+ remark String? @db.VarChar(512)
+ extraJson Json? @map("extra_json")
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+
+ @@index([refType, refId, eventType])
+ @@index([eventType, createdAt])
+ @@index([actorType, actorId])
+ @@map("common_event")
+}
+
+model CommonTicket {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ ticketNo String @unique @map("ticket_no") @db.VarChar(32)
+ ticketType TicketType @map("ticket_type")
+ status String @default("PENDING") @db.VarChar(32)
+ refType String @map("ref_type") @db.VarChar(32)
+ refId BigInt @map("ref_id") @db.UnsignedBigInt
+ operatorType ActorType? @map("operator_type")
+ operatorId BigInt? @map("operator_id") @db.UnsignedBigInt
+ param1 String? @db.VarChar(128)
+ param1Desc String? @map("param1_desc") @db.VarChar(64)
+ param2 String? @db.VarChar(128)
+ param2Desc String? @map("param2_desc") @db.VarChar(64)
+ param3 String? @db.VarChar(128)
+ param3Desc String? @map("param3_desc") @db.VarChar(64)
+ remark String? @db.VarChar(512)
+ extraJson Json? @map("extra_json")
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ completedAt DateTime? @map("completed_at") @db.DateTime(3)
+
+ @@index([refType, refId])
+ @@index([ticketType, status])
+ @@map("common_ticket")
+}
+
+model CommonProductItem {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ skuCode String @unique @map("sku_code") @db.VarChar(32)
+ barcode69 String @unique @map("barcode_69") @db.VarChar(32)
+ name String @db.VarChar(128)
+ subtitle String? @db.VarChar(256)
+ aromaType AromaType @map("aroma_type")
+ spec String @db.VarChar(128)
+ price Decimal @db.Decimal(10, 2)
+ benefitAmount Decimal? @map("benefit_amount") @db.Decimal(10, 2)
+ status ProductStatus @default(DRAFT)
+ sortOrder Int @default(0) @map("sort_order")
+ coverResourceId BigInt? @map("cover_resource_id") @db.UnsignedBigInt
+ detailContent Json? @map("detail_content")
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+
+ coverResource CommonResource? @relation("ProductCover", fields: [coverResourceId], references: [id], onDelete: SetNull)
+ orders Order[]
+
+ @@index([status, aromaType])
+ @@map("common_product_item")
+}
+
+model CommonStoreCategory {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ code String @unique @db.VarChar(32)
+ name String @db.VarChar(64)
+ sort Int @default(0)
+ stores Store[]
+
+ @@map("common_store_category")
+}
+
+model CommonPromoCode {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ code String @unique @db.VarChar(32)
+ name String @db.VarChar(128)
+ status PromoCodeStatus @default(ACTIVE)
+ qrcodeResourceId BigInt? @map("qrcode_resource_id") @db.UnsignedBigInt
+ scanCount Int @default(0) @map("scan_count")
+ orderCount Int @default(0) @map("order_count")
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+
+ qrcodeResource CommonResource? @relation("PromoQrcode", fields: [qrcodeResourceId], references: [id], onDelete: SetNull)
+ attributions UserPromoAttribution[]
+ orders Order[]
+
+ @@map("common_promo_code")
+}
+
+model CommonCity {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ code String @unique @db.VarChar(16)
+ name String @db.VarChar(64)
+ province String @db.VarChar(32)
+ status CityStatus @default(PENDING)
+ partnerId BigInt? @map("partner_id") @db.UnsignedBigInt
+ localMinQty Int @default(2) @map("local_min_qty")
+ crossMinQty Int @default(6) @map("cross_min_qty")
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+
+ partner Partner? @relation(fields: [partnerId], references: [id], onDelete: SetNull)
+ commissionRule CommonCityCommissionRule?
+ stores Store[]
+ orders Order[]
+
+ @@index([partnerId])
+ @@map("common_city")
+}
+
+model CommonCityCommissionRule {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ cityId BigInt @unique @map("city_id") @db.UnsignedBigInt
+ orderCommissionRate Decimal @default(0) @map("order_commission_rate") @db.Decimal(5, 4)
+ redeemCommissionRate Decimal @default(0) @map("redeem_commission_rate") @db.Decimal(5, 4)
+ partnerProfitRate Decimal @default(0.35) @map("partner_profit_rate") @db.Decimal(5, 4)
+ storeSettlementRate Decimal @default(0.60) @map("store_settlement_rate") @db.Decimal(5, 4)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+
+ city CommonCity @relation(fields: [cityId], references: [id], onDelete: Cascade)
+
+ @@map("common_city_commission_rule")
+}
+
+// ─── PARTNER ──────────────────────────────────────────
+
+model Partner {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ companyName String @map("company_name") @db.VarChar(128)
+ address String @db.VarChar(256)
+ contactPhone String @map("contact_phone") @db.VarChar(20)
+ contractNo String? @map("contract_no") @db.VarChar(64)
+ contractSignedAt DateTime? @map("contract_signed_at") @db.DateTime(3)
+ contractExpireAt DateTime? @map("contract_expire_at") @db.DateTime(3)
+ bankAccountName String? @map("bank_account_name") @db.VarChar(64)
+ bankAccountNo String? @map("bank_account_no") @db.VarChar(32)
+ bankBranch String? @map("bank_branch") @db.VarChar(128)
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+
+ cities CommonCity[]
+ accounts PartnerAccount[]
+ stores Store[]
+ bills PartnerBill[]
+
+ @@index([contactPhone])
+ @@map("partner_partner")
+}
+
+model PartnerAccount {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ partnerId BigInt @map("partner_id") @db.UnsignedBigInt
+ phone String @unique @db.VarChar(20)
+ name String @db.VarChar(64)
+ wxOpenId String? @map("wx_open_id") @db.VarChar(64)
+ wxUnionId String? @map("wx_union_id") @db.VarChar(64)
+ isPrimary Int @default(0) @map("is_primary") @db.TinyInt
+ parentAccountId BigInt? @map("parent_account_id") @db.UnsignedBigInt
+ staffRole PartnerStaffRole? @map("staff_role")
+ status AccountStatus @default(ACTIVE)
+ lastLoginAt DateTime? @map("last_login_at") @db.DateTime(3)
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+
+ partner Partner @relation(fields: [partnerId], references: [id], onDelete: Restrict)
+ parent PartnerAccount? @relation("PartnerAccountHierarchy", fields: [parentAccountId], references: [id], onDelete: SetNull)
+ children PartnerAccount[] @relation("PartnerAccountHierarchy")
+
+ @@index([partnerId])
+ @@index([parentAccountId])
+ @@index([wxOpenId])
+ @@map("partner_account")
+}
+
+model PartnerBill {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ billNo String @unique @map("bill_no") @db.VarChar(32)
+ partnerId BigInt @map("partner_id") @db.UnsignedBigInt
+ periodStart DateTime @map("period_start") @db.DateTime(3)
+ periodEnd DateTime @map("period_end") @db.DateTime(3)
+ orderCommission Decimal @default(0) @map("order_commission") @db.Decimal(10, 2)
+ redeemCommission Decimal @default(0) @map("redeem_commission") @db.Decimal(10, 2)
+ totalAmount Decimal @map("total_amount") @db.Decimal(10, 2)
+ status PartnerBillStatus @default(DRAFT)
+ confirmedAt DateTime? @map("confirmed_at") @db.DateTime(3)
+ paidAt DateTime? @map("paid_at") @db.DateTime(3)
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+
+ partner Partner @relation(fields: [partnerId], references: [id], onDelete: Restrict)
+
+ @@index([partnerId, status])
+ @@map("partner_bill")
+}
+
+// ─── HQ ───────────────────────────────────────────────
+
+model HqAccount {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ phone String @unique @db.VarChar(20)
+ name String @db.VarChar(64)
+ adminRole HqAdminRole @default(OPS) @map("admin_role")
+ wxOpenId String? @map("wx_open_id") @db.VarChar(64)
+ wxUnionId String? @map("wx_union_id") @db.VarChar(64)
+ status AccountStatus @default(ACTIVE)
+ lastLoginAt DateTime? @map("last_login_at") @db.DateTime(3)
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+
+ @@map("hq_account")
+}
+
+// ─── USER ─────────────────────────────────────────────
+
+model User {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ userNo String @unique @map("user_no") @db.VarChar(20)
+ deviceKey String? @unique @map("device_key") @db.VarChar(36)
+ phone String? @unique @db.VarChar(20)
+ phoneVerifiedAt DateTime? @map("phone_verified_at") @db.DateTime(3)
+ mergedIntoUserId BigInt? @map("merged_into_user_id") @db.UnsignedBigInt
+ wxOpenId String? @map("wx_open_id") @db.VarChar(64)
+ wxUnionId String? @map("wx_union_id") @db.VarChar(64)
+ nickname String? @db.VarChar(64)
+ avatarResourceId BigInt? @map("avatar_resource_id") @db.UnsignedBigInt
+ status Int @default(1) @db.TinyInt
+ sourceType UserSourceType @default(ORGANIC) @map("source_type")
+ sourceRefId BigInt? @map("source_ref_id") @db.UnsignedBigInt
+ sourceLabel String? @map("source_label") @db.VarChar(128)
+ referrerUserId BigInt? @map("referrer_user_id") @db.UnsignedBigInt
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+
+ mergedInto User? @relation("UserMerge", fields: [mergedIntoUserId], references: [id], onDelete: SetNull)
+ mergedFrom User[] @relation("UserMerge")
+ referrer User? @relation("UserReferrer", fields: [referrerUserId], references: [id], onDelete: SetNull)
+ referrers User[] @relation("UserReferrer")
+ avatar CommonResource? @relation("UserAvatar", fields: [avatarResourceId], references: [id], onDelete: SetNull)
+ addresses UserAddress[]
+ cityPreference UserCityPreference?
+ promoTouch UserPromoAttribution?
+ orders Order[]
+ benefitCoupons BenefitCoupon[]
+ redeemRecords RedeemRecord[]
+
+ @@index([sourceType, sourceRefId])
+ @@index([referrerUserId])
+ @@index([mergedIntoUserId])
+ @@index([wxOpenId])
+ @@map("user_user")
+}
+
+model UserAddress {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ userId BigInt @map("user_id") @db.UnsignedBigInt
+ receiverName String @map("receiver_name") @db.VarChar(32)
+ phone String @db.VarChar(20)
+ province String @db.VarChar(32)
+ city String @db.VarChar(32)
+ district String @db.VarChar(32)
+ detail String @db.VarChar(256)
+ latitude Decimal? @db.Decimal(10, 7)
+ longitude Decimal? @db.Decimal(10, 7)
+ isDefault Int @default(0) @map("is_default") @db.TinyInt
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ @@index([userId])
+ @@map("user_address")
+}
+
+model UserCityPreference {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ userId BigInt @unique @map("user_id") @db.UnsignedBigInt
+ selectedCityCode String? @map("selected_city_code") @db.VarChar(16)
+ selectedDistrict String? @map("selected_district") @db.VarChar(32)
+ locateCityCode String? @map("locate_city_code") @db.VarChar(16)
+ locateDistrict String? @map("locate_district") @db.VarChar(32)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ @@map("user_city_preference")
+}
+
+model UserPromoAttribution {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ userId BigInt @unique @map("user_id") @db.UnsignedBigInt
+ promoCodeId BigInt @map("promo_code_id") @db.UnsignedBigInt
+ channelName String @map("channel_name") @db.VarChar(128)
+ firstTouchAt DateTime @map("first_touch_at") @db.DateTime(3)
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ promoCode CommonPromoCode @relation(fields: [promoCodeId], references: [id], onDelete: Restrict)
+
+ @@index([promoCodeId])
+ @@map("user_promo_attribution")
+}
+
+// ─── STORE ────────────────────────────────────────────
+
+model Store {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ cityId BigInt @map("city_id") @db.UnsignedBigInt
+ partnerId BigInt @map("partner_id") @db.UnsignedBigInt
+ categoryId BigInt? @map("category_id") @db.UnsignedBigInt
+ name String @db.VarChar(128)
+ phone String @db.VarChar(20)
+ province String @db.VarChar(32)
+ cityName String @map("city_name") @db.VarChar(32)
+ district String @db.VarChar(32)
+ address String @db.VarChar(256)
+ latitude Decimal? @db.Decimal(10, 7)
+ longitude Decimal? @db.Decimal(10, 7)
+ intro String? @db.Text
+ coverResourceId BigInt? @map("cover_resource_id") @db.UnsignedBigInt
+ avgPrice Decimal? @map("avg_price") @db.Decimal(10, 2)
+ rating Decimal? @db.Decimal(3, 2)
+ tags Json?
+ status StoreStatus @default(PAUSED)
+ openTime String? @map("open_time") @db.VarChar(8)
+ closeTime String? @map("close_time") @db.VarChar(8)
+ bankAccountName String? @map("bank_account_name") @db.VarChar(64)
+ bankAccountNo String? @map("bank_account_no") @db.VarChar(32)
+ bankBranch String? @map("bank_branch") @db.VarChar(128)
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+
+ cityRef CommonCity @relation(fields: [cityId], references: [id], onDelete: Restrict)
+ partner Partner @relation(fields: [partnerId], references: [id], onDelete: Restrict)
+ category CommonStoreCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
+ coverResource CommonResource? @relation("StoreCover", fields: [coverResourceId], references: [id], onDelete: SetNull)
+ account StoreAccount?
+ redeemRecords RedeemRecord[]
+ ratings StoreRating[]
+ payouts StorePayout[]
+
+ @@index([cityId, status])
+ @@index([partnerId])
+ @@map("store_store")
+}
+
+model StoreAccount {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ storeId BigInt @unique @map("store_id") @db.UnsignedBigInt
+ phone String @unique @db.VarChar(20)
+ name String @db.VarChar(64)
+ wxOpenId String? @map("wx_open_id") @db.VarChar(64)
+ wxUnionId String? @map("wx_union_id") @db.VarChar(64)
+ status AccountStatus @default(ACTIVE)
+ lastLoginAt DateTime? @map("last_login_at") @db.DateTime(3)
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+
+ store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
+
+ @@map("store_account")
+}
+
+// ─── ORDER ────────────────────────────────────────────
+
+model Order {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ orderNo String @unique @map("order_no") @db.VarChar(32)
+ orderType OrderType @default(NORMAL) @map("order_type")
+ userId BigInt @map("user_id") @db.UnsignedBigInt
+ cityId BigInt @map("city_id") @db.UnsignedBigInt
+ status OrderStatus @default(PENDING_PAY)
+ payStatus PayStatus @default(UNPAID) @map("pay_status")
+ deliveryType DeliveryType @map("delivery_type")
+ originOrderId BigInt? @map("origin_order_id") @db.UnsignedBigInt
+ promoCodeId BigInt? @map("promo_code_id") @db.UnsignedBigInt
+ channelSource String? @map("channel_source") @db.VarChar(128)
+ productId BigInt @map("product_id") @db.UnsignedBigInt
+ barcode69 String @map("barcode_69") @db.VarChar(32)
+ productName String @map("product_name") @db.VarChar(128)
+ productSpec String @map("product_spec") @db.VarChar(128)
+ imageResourceId BigInt? @map("image_resource_id") @db.UnsignedBigInt
+ quantity Int
+ listUnitPrice Decimal @map("list_unit_price") @db.Decimal(10, 2)
+ listAmount Decimal @map("list_amount") @db.Decimal(10, 2)
+ discountAmount Decimal @default(0) @map("discount_amount") @db.Decimal(10, 2)
+ productAmount Decimal @map("product_amount") @db.Decimal(10, 2)
+ freightAmount Decimal @default(0) @map("freight_amount") @db.Decimal(10, 2)
+ freightPayType FreightPayType? @map("freight_pay_type")
+ payAmount Decimal @map("pay_amount") @db.Decimal(10, 2)
+ benefitAmount Decimal @default(0) @map("benefit_amount") @db.Decimal(10, 2)
+ receiverName String @map("receiver_name") @db.VarChar(32)
+ receiverPhone String @map("receiver_phone") @db.VarChar(20)
+ receiverAddress String @map("receiver_address") @db.Text
+ receiverProvince String @map("receiver_province") @db.VarChar(32)
+ receiverCity String @map("receiver_city") @db.VarChar(32)
+ receiverDistrict String @map("receiver_district") @db.VarChar(32)
+ clientIp String? @map("client_ip") @db.VarChar(45)
+ ipProvince String? @map("ip_province") @db.VarChar(32)
+ ipCity String? @map("ip_city") @db.VarChar(32)
+ ipDistrict String? @map("ip_district") @db.VarChar(32)
+ gpsProvince String? @map("gps_province") @db.VarChar(32)
+ gpsCity String? @map("gps_city") @db.VarChar(32)
+ gpsDistrict String? @map("gps_district") @db.VarChar(32)
+ gpsLatitude Decimal? @map("gps_latitude") @db.Decimal(10, 7)
+ gpsLongitude Decimal? @map("gps_longitude") @db.Decimal(10, 7)
+ gpsAddress String? @map("gps_address") @db.VarChar(256)
+ payExternalNo String? @map("pay_external_no") @db.VarChar(64)
+ paidAt DateTime? @map("paid_at") @db.DateTime(3)
+ shippedAt DateTime? @map("shipped_at") @db.DateTime(3)
+ completedAt DateTime? @map("completed_at") @db.DateTime(3)
+ cancelledAt DateTime? @map("cancelled_at") @db.DateTime(3)
+ payExpireAt DateTime? @map("pay_expire_at") @db.DateTime(3)
+ remark String? @db.VarChar(512)
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+
+ user User @relation(fields: [userId], references: [id], onDelete: Restrict)
+ city CommonCity @relation(fields: [cityId], references: [id], onDelete: Restrict)
+ originOrder Order? @relation("OrderReshipment", fields: [originOrderId], references: [id], onDelete: SetNull)
+ reshipments Order[] @relation("OrderReshipment")
+ promoCode CommonPromoCode? @relation(fields: [promoCodeId], references: [id], onDelete: SetNull)
+ product CommonProductItem @relation(fields: [productId], references: [id], onDelete: Restrict)
+ imageResource CommonResource? @relation("OrderProductImage", fields: [imageResourceId], references: [id], onDelete: SetNull)
+ delivery OrderDelivery?
+ benefitCoupon BenefitCoupon?
+
+ @@index([userId, status])
+ @@index([cityId, createdAt])
+ @@index([productId])
+ @@index([barcode69])
+ @@index([payExternalNo])
+ @@index([ipCity])
+ @@index([gpsCity])
+ @@map("user_order")
+}
+
+model OrderDelivery {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ orderId BigInt @unique @map("order_id") @db.UnsignedBigInt
+ provider DeliveryProvider
+ providerOrderNo String? @map("provider_order_no") @db.VarChar(64)
+ trackingNo String? @map("tracking_no") @db.VarChar(64)
+ outWarehouseAt DateTime? @map("out_warehouse_at") @db.DateTime(3)
+ shippingAt DateTime? @map("shipping_at") @db.DateTime(3)
+ deliveredAt DateTime? @map("delivered_at") @db.DateTime(3)
+ signPhotoResourceId BigInt? @map("sign_photo_resource_id") @db.UnsignedBigInt
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+
+ order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
+ signPhotoResource CommonResource? @relation("DeliverySignPhoto", fields: [signPhotoResourceId], references: [id], onDelete: SetNull)
+
+ @@map("user_order_delivery")
+}
+
+// ─── BENEFIT & REDEEM ─────────────────────────────────
+
+model BenefitCoupon {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ couponNo String @unique @map("coupon_no") @db.VarChar(32)
+ userId BigInt @map("user_id") @db.UnsignedBigInt
+ orderId BigInt @unique @map("order_id") @db.UnsignedBigInt
+ totalAmount Decimal @map("total_amount") @db.Decimal(10, 2)
+ usedAmount Decimal @default(0) @map("used_amount") @db.Decimal(10, 2)
+ balance Decimal @db.Decimal(10, 2)
+ status BenefitCouponStatus @default(ACTIVE)
+ sourceProduct String @map("source_product") @db.VarChar(128)
+ version Int @default(0)
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+
+ user User @relation(fields: [userId], references: [id], onDelete: Restrict)
+ order Order @relation(fields: [orderId], references: [id], onDelete: Restrict)
+ redeemRecords RedeemRecord[]
+
+ @@index([userId, status])
+ @@map("user_benefit_coupon")
+}
+
+model RedeemRecord {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ redeemNo String @unique @map("redeem_no") @db.VarChar(32)
+ userId BigInt @map("user_id") @db.UnsignedBigInt
+ couponId BigInt @map("coupon_id") @db.UnsignedBigInt
+ storeId BigInt @map("store_id") @db.UnsignedBigInt
+ amount Decimal @db.Decimal(10, 2)
+ settleAmount Decimal @map("settle_amount") @db.Decimal(10, 2)
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+
+ user User @relation(fields: [userId], references: [id], onDelete: Restrict)
+ coupon BenefitCoupon @relation(fields: [couponId], references: [id], onDelete: Restrict)
+ store Store @relation(fields: [storeId], references: [id], onDelete: Restrict)
+ rating StoreRating?
+ payout StorePayout?
+
+ @@index([storeId, createdAt])
+ @@map("user_redeem_record")
+}
+
+model StoreRating {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ redeemRecordId BigInt @unique @map("redeem_record_id") @db.UnsignedBigInt
+ storeId BigInt @map("store_id") @db.UnsignedBigInt
+ serviceScore Int @map("service_score") @db.TinyInt
+ envScore Int @map("env_score") @db.TinyInt
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+
+ redeemRecord RedeemRecord @relation(fields: [redeemRecordId], references: [id], onDelete: Cascade)
+ store Store @relation(fields: [storeId], references: [id], onDelete: Restrict)
+
+ @@map("user_store_rating")
+}
+
+model StorePayout {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ redeemRecordId BigInt @unique @map("redeem_record_id") @db.UnsignedBigInt
+ storeId BigInt @map("store_id") @db.UnsignedBigInt
+ redeemAmount Decimal @map("redeem_amount") @db.Decimal(10, 2)
+ payoutAmount Decimal @map("payout_amount") @db.Decimal(10, 2)
+ settlementRate Decimal @map("settlement_rate") @db.Decimal(5, 4)
+ status StorePayoutStatus @default(PENDING)
+ expectedPayAt DateTime @map("expected_pay_at") @db.DateTime(3)
+ paidAt DateTime? @map("paid_at") @db.DateTime(3)
+ batchNo String? @map("batch_no") @db.VarChar(32)
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+
+ redeemRecord RedeemRecord @relation(fields: [redeemRecordId], references: [id], onDelete: Restrict)
+ store Store @relation(fields: [storeId], references: [id], onDelete: Restrict)
+
+ @@index([storeId, status])
+ @@map("store_payout")
+}
+
+// ─── LOG ──────────────────────────────────────────────
+
+model LogThirdParty {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ provider ThirdPartyProvider
+ scene String @db.VarChar(64)
+ refType String? @map("ref_type") @db.VarChar(32)
+ refId BigInt? @map("ref_id") @db.UnsignedBigInt
+ requestUrl String? @map("request_url") @db.VarChar(512)
+ requestBody Json? @map("request_body")
+ responseBody Json? @map("response_body")
+ externalNo String? @map("external_no") @db.VarChar(128)
+ amount Decimal? @db.Decimal(10, 2)
+ status ThirdPartyLogStatus @default(PENDING)
+ errorMessage String? @map("error_message") @db.VarChar(512)
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+
+ @@index([refType, refId])
+ @@index([provider, scene, createdAt])
+ @@index([externalNo])
+ @@map("log_third_party")
+}
+
+model LogUserAnalytics {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ userId BigInt? @map("user_id") @db.UnsignedBigInt
+ sessionId String? @map("session_id") @db.VarChar(64)
+ eventName String @map("event_name") @db.VarChar(64)
+ clientApp ClientApp? @map("client_app")
+ pagePath String? @map("page_path") @db.VarChar(128)
+ refType String? @map("ref_type") @db.VarChar(32)
+ refId BigInt? @map("ref_id") @db.UnsignedBigInt
+ keyword String? @db.VarChar(128)
+ sourceType String? @map("source_type") @db.VarChar(32)
+ sourceRefId BigInt? @map("source_ref_id") @db.UnsignedBigInt
+ extraJson Json? @map("extra_json")
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+
+ @@index([userId, createdAt])
+ @@index([eventName, createdAt])
+ @@index([sessionId])
+ @@index([refType, refId])
+ @@map("log_user_analytics")
+}
diff --git a/server/dukang-api/prisma/seed-v31.ts b/server/dukang-api/prisma/seed-v31.ts
new file mode 100644
index 0000000..a336edf
--- /dev/null
+++ b/server/dukang-api/prisma/seed-v31.ts
@@ -0,0 +1,248 @@
+import { PrismaClient, ResourceBizType, ResourceMediaType, ResourceOwnerType } from '@prisma/client';
+
+const prisma = new PrismaClient();
+
+async function createMockResource(
+ ownerType: ResourceOwnerType,
+ ownerId: bigint,
+ bizType: ResourceBizType,
+ url: string,
+) {
+ return prisma.commonResource.create({
+ data: {
+ ownerType,
+ ownerId,
+ bizType,
+ mediaType: ResourceMediaType.IMAGE,
+ ossBucket: 'mock-dukang',
+ ossKey: `mock/${ownerType.toLowerCase()}/${ownerId}/${bizType.toLowerCase()}`,
+ url,
+ },
+ });
+}
+
+async function main() {
+ console.log('Seeding v3.1 data...');
+
+ await prisma.logUserAnalytics.deleteMany();
+ await prisma.logThirdParty.deleteMany();
+ await prisma.storePayout.deleteMany();
+ await prisma.storeRating.deleteMany();
+ await prisma.redeemRecord.deleteMany();
+ await prisma.benefitCoupon.deleteMany();
+ await prisma.orderDelivery.deleteMany();
+ await prisma.order.deleteMany();
+ await prisma.commonEvent.deleteMany();
+ await prisma.commonTicket.deleteMany();
+ await prisma.userPromoAttribution.deleteMany();
+ await prisma.userCityPreference.deleteMany();
+ await prisma.userAddress.deleteMany();
+ await prisma.user.deleteMany();
+ await prisma.storeAccount.deleteMany();
+ await prisma.store.deleteMany();
+ await prisma.partnerBill.deleteMany();
+ await prisma.partnerAccount.deleteMany();
+ await prisma.commonCityCommissionRule.deleteMany();
+ await prisma.commonCity.deleteMany();
+ await prisma.partner.deleteMany();
+ await prisma.commonProductItem.deleteMany();
+ await prisma.commonStoreCategory.deleteMany();
+ await prisma.commonPromoCode.deleteMany();
+ await prisma.commonResource.deleteMany();
+ await prisma.hqAccount.deleteMany();
+
+ const partner = await prisma.partner.create({
+ data: {
+ companyName: '郑州城市合伙人',
+ address: '河南省郑州市金水区',
+ contactPhone: '13700000001',
+ bankAccountName: '郑州合伙人公司',
+ bankAccountNo: '6222021234567890',
+ bankBranch: '工商银行郑州分行',
+ },
+ });
+
+ const city = await prisma.commonCity.create({
+ data: {
+ code: '410100',
+ name: '郑州市',
+ province: '河南省',
+ status: 'ACTIVE',
+ partnerId: partner.id,
+ localMinQty: 2,
+ crossMinQty: 6,
+ },
+ });
+
+ await prisma.commonCityCommissionRule.create({
+ data: {
+ cityId: city.id,
+ orderCommissionRate: 0.05,
+ redeemCommissionRate: 0.03,
+ partnerProfitRate: 0.35,
+ storeSettlementRate: 0.6,
+ },
+ });
+
+ const categories = await Promise.all([
+ prisma.commonStoreCategory.create({ data: { code: 'HOTPOT', name: '火锅', sort: 1 } }),
+ prisma.commonStoreCategory.create({ data: { code: 'LOCAL', name: '地方菜', sort: 2 } }),
+ ]);
+
+ const productDefs = [
+ { skuCode: 'QX-001', name: '杜康·白水古酿 500ml', subtitle: '清香型 52度 礼盒装', price: 599, sortOrder: 1, img: 'https://picsum.photos/seed/dukang1/400/400' },
+ { skuCode: 'QX-002', name: '杜康·年份陈酿(十年)', subtitle: '清香型 42度 纯粮酿造', price: 880, sortOrder: 2, img: 'https://picsum.photos/seed/dukang2/400/400' },
+ { skuCode: 'QX-003', name: '杜康·御享1号 珍藏版', subtitle: '高端定制 限量发售', price: 1299, sortOrder: 3, img: 'https://picsum.photos/seed/dukang3/400/400' },
+ { skuCode: 'QX-004', name: '杜康·经典传承', subtitle: '清香型 纯粮固态', price: 399, sortOrder: 4, img: 'https://picsum.photos/seed/dukang4/400/400' },
+ ];
+
+ const products = [];
+ for (const [i, def] of productDefs.entries()) {
+ const product = await prisma.commonProductItem.create({
+ data: {
+ skuCode: def.skuCode,
+ barcode69: `69000000000${i + 1}`,
+ name: def.name,
+ subtitle: def.subtitle,
+ aromaType: 'QINGXIANG',
+ spec: def.skuCode === 'QX-002' ? '500ml | 42度' : def.skuCode === 'QX-004' ? '500ml | 46度' : '500ml | 52度',
+ price: def.price,
+ benefitAmount: def.price,
+ status: 'ON_SALE',
+ sortOrder: def.sortOrder,
+ },
+ });
+ const cover = await createMockResource(ResourceOwnerType.PRODUCT, product.id, ResourceBizType.COVER, def.img);
+ await prisma.commonProductItem.update({
+ where: { id: product.id },
+ data: { coverResourceId: cover.id },
+ });
+ products.push(product);
+ }
+
+ await prisma.partnerAccount.create({
+ data: {
+ partnerId: partner.id,
+ phone: '13700000001',
+ name: '郑州合伙人主账号',
+ isPrimary: 1,
+ staffRole: 'PARTNER',
+ },
+ });
+
+ const storeDefs = [
+ {
+ name: '郑州老城店',
+ phone: '0371-88880001',
+ district: '金水区',
+ address: '花园路100号',
+ intro: '正宗河南菜,欢迎核销好客权益',
+ img: 'https://picsum.photos/seed/store1/400/300',
+ categoryId: categories[0].id,
+ accountPhone: '13900000001',
+ accountName: '老城店店长',
+ },
+ {
+ name: '郑州美食城店',
+ phone: '0371-88880002',
+ district: '二七区',
+ address: '大学路200号',
+ intro: '地方特色餐饮',
+ img: 'https://picsum.photos/seed/store2/400/300',
+ categoryId: categories[1].id,
+ accountPhone: '13900000002',
+ accountName: '美食城店长',
+ },
+ ];
+
+ for (const def of storeDefs) {
+ const store = await prisma.store.create({
+ data: {
+ cityId: city.id,
+ partnerId: partner.id,
+ categoryId: def.categoryId,
+ name: def.name,
+ phone: def.phone,
+ province: '河南省',
+ cityName: '郑州市',
+ district: def.district,
+ address: def.address,
+ intro: def.intro,
+ status: 'OPEN',
+ openTime: '10:00',
+ closeTime: '22:00',
+ bankAccountName: def.name,
+ bankAccountNo: '6222029876543210',
+ bankBranch: '建设银行郑州分行',
+ },
+ });
+ const cover = await createMockResource(ResourceOwnerType.STORE, store.id, ResourceBizType.COVER, def.img);
+ await prisma.store.update({ where: { id: store.id }, data: { coverResourceId: cover.id } });
+ await prisma.storeAccount.create({
+ data: { storeId: store.id, phone: def.accountPhone, name: def.accountName },
+ });
+ }
+
+ await prisma.user.create({
+ data: {
+ userNo: 'DK88293401',
+ phone: '13800000001',
+ phoneVerifiedAt: new Date(),
+ nickname: '测试用户',
+ cityPreference: {
+ create: {
+ selectedCityCode: '410100',
+ selectedDistrict: '郑州市',
+ locateCityCode: '410100',
+ locateDistrict: '金水区',
+ },
+ },
+ },
+ });
+
+ await prisma.hqAccount.create({
+ data: {
+ phone: '13600000001',
+ name: '总部管理员',
+ adminRole: 'SUPER_ADMIN',
+ },
+ });
+
+ const now = new Date();
+ const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
+ const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
+ await prisma.partnerBill.create({
+ data: {
+ billNo: `PB${Date.now()}`,
+ partnerId: partner.id,
+ periodStart,
+ periodEnd,
+ orderCommission: 1200,
+ redeemCommission: 800,
+ totalAmount: 2000,
+ status: 'CONFIRMED',
+ confirmedAt: now,
+ },
+ });
+
+ console.log('Seed complete:', {
+ city: city.name,
+ products: products.length,
+ stores: storeDefs.length,
+ testPhones: {
+ user: '13800000001',
+ store: '13900000001',
+ partner: '13700000001',
+ hq: '13600000001',
+ },
+ });
+}
+
+main()
+ .catch((e) => {
+ console.error(e);
+ process.exit(1);
+ })
+ .finally(async () => {
+ await prisma.$disconnect();
+ });
diff --git a/server/dukang-api/prisma/sync-benefit-to-price.ts b/server/dukang-api/prisma/sync-benefit-to-price.ts
index 2bba2ad..3c76f6a 100644
--- a/server/dukang-api/prisma/sync-benefit-to-price.ts
+++ b/server/dukang-api/prisma/sync-benefit-to-price.ts
@@ -1,5 +1,5 @@
/**
- * 将 products.benefit_amount 同步为与 price 相同(全额好客权益)。
+ * 将 common_product_item.benefit_amount 同步为与 price 相同(全额好客权益)。
* 用法:pnpm db:sync-benefit
*/
import { PrismaClient } from '@prisma/client';
@@ -7,13 +7,13 @@ import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
- const products = await prisma.product.findMany({
+ const products = await prisma.commonProductItem.findMany({
select: { id: true, skuCode: true, name: true, price: true, benefitAmount: true },
orderBy: { sortOrder: 'asc' },
});
if (products.length === 0) {
- console.log('No products found. Run pnpm db:seed first.');
+ console.log('No products found. Run pnpm prisma:seed first.');
return;
}
@@ -23,7 +23,7 @@ async function main() {
const price = Number(p.price);
const before = p.benefitAmount != null ? Number(p.benefitAmount) : null;
- await prisma.product.update({
+ await prisma.commonProductItem.update({
where: { id: p.id },
data: { benefitAmount: p.price },
});
diff --git a/server/dukang-api/src/app.module.ts b/server/dukang-api/src/app.module.ts
index f37bf46..837e413 100644
--- a/server/dukang-api/src/app.module.ts
+++ b/server/dukang-api/src/app.module.ts
@@ -15,6 +15,8 @@ import { SettlementModule } from './modules/settlement/settlement.module';
import { AnalyticsModule } from './modules/analytics/analytics.module';
import { JobsModule } from './jobs/jobs.module';
import { OpsModule } from './modules/ops/ops.module';
+import { CommonModule } from './modules/common/common.module';
+import { CallbacksModule } from './callbacks/callbacks.module';
@Module({
imports: [
@@ -38,6 +40,8 @@ import { OpsModule } from './modules/ops/ops.module';
AnalyticsModule,
JobsModule,
OpsModule,
+ CommonModule,
+ CallbacksModule,
],
})
export class AppModule {}
diff --git a/server/dukang-api/src/callbacks/callbacks.module.ts b/server/dukang-api/src/callbacks/callbacks.module.ts
new file mode 100644
index 0000000..c3b3883
--- /dev/null
+++ b/server/dukang-api/src/callbacks/callbacks.module.ts
@@ -0,0 +1,10 @@
+import { Module } from '@nestjs/common';
+import { IntegrationsModule } from '../integrations/integrations.module';
+import { TradeModule } from '../modules/trade/trade.module';
+import { WechatPayCallbackController } from './wechat-pay.controller';
+
+@Module({
+ imports: [IntegrationsModule, TradeModule],
+ controllers: [WechatPayCallbackController],
+})
+export class CallbacksModule {}
diff --git a/server/dukang-api/src/callbacks/wechat-pay.controller.ts b/server/dukang-api/src/callbacks/wechat-pay.controller.ts
new file mode 100644
index 0000000..1b03a0c
--- /dev/null
+++ b/server/dukang-api/src/callbacks/wechat-pay.controller.ts
@@ -0,0 +1,38 @@
+import { Controller, Headers, Inject, Post, Req, Res } from '@nestjs/common';
+import type { Request, Response } from 'express';
+import { TradeService } from '../modules/trade/trade.service';
+import { WECHAT_PROVIDER } from '../integrations/integrations.constants';
+import type { IWechatProvider } from '../integrations/wechat/wechat.interface';
+
+type RawBodyRequest = Request & { body: Buffer };
+
+@Controller('callbacks/wechat')
+export class WechatPayCallbackController {
+ constructor(
+ private readonly tradeService: TradeService,
+ @Inject(WECHAT_PROVIDER) private readonly wechat: IWechatProvider,
+ ) {}
+
+ @Post('pay')
+ async payNotify(
+ @Req() req: RawBodyRequest,
+ @Headers() headers: Record,
+ @Res() res: Response,
+ ) {
+ try {
+ const rawBody =
+ (req as Request & { rawBody?: Buffer }).rawBody?.toString('utf8') ??
+ (typeof req.body === 'string' ? req.body : JSON.stringify(req.body ?? {}));
+ const notify = await this.wechat.parsePayNotification(headers, rawBody);
+ await this.tradeService.handlePaySuccess({
+ orderNo: notify.outTradeNo,
+ transactionId: notify.transactionId,
+ amountFen: notify.amountFen,
+ });
+ return res.status(200).json({ code: 'SUCCESS', message: '成功' });
+ } catch (err) {
+ const message = err instanceof Error ? err.message : '处理失败';
+ return res.status(500).json({ code: 'FAIL', message });
+ }
+ }
+}
diff --git a/server/dukang-api/src/common/compat/v31-compat.ts b/server/dukang-api/src/common/compat/v31-compat.ts
new file mode 100644
index 0000000..8b837cb
--- /dev/null
+++ b/server/dukang-api/src/common/compat/v31-compat.ts
@@ -0,0 +1,72 @@
+import type { CommonEvent } from '@prisma/client';
+
+type OrderLike = {
+ productName?: string;
+ productSpec?: string;
+ quantity?: number;
+ listUnitPrice?: unknown;
+ payAmount?: unknown;
+ payStatus?: string;
+ payExternalNo?: string | null;
+ paidAt?: Date | string | null;
+ imageResource?: { url?: string } | null;
+};
+
+export function mapOrderItemCompat(order: OrderLike) {
+ return {
+ productName: order.productName ?? '',
+ productSpec: order.productSpec ?? '',
+ productImage: order.imageResource?.url ?? '',
+ unitPrice: Number(order.listUnitPrice ?? 0),
+ quantity: order.quantity ?? 1,
+ };
+}
+
+export function mapOrderCompat(order: T) {
+ const payStatus = order.payStatus ?? 'UNPAID';
+ return {
+ ...order,
+ items: [mapOrderItemCompat(order)],
+ payment: {
+ status: payStatus === 'PAID' ? 'SUCCESS' : payStatus,
+ externalNo: order.payExternalNo ?? null,
+ paidAt: order.paidAt ?? null,
+ },
+ };
+}
+
+export function mapStoreCompat(store: T) {
+ return {
+ ...store,
+ coverUrl: store.coverResource?.url ?? null,
+ };
+}
+
+export function mapStatusLogCompat(events: CommonEvent[]) {
+ return events.map((e) => ({
+ fromStatus: e.param1,
+ toStatus: e.param2 ?? '',
+ operator: e.param3,
+ remark: e.remark,
+ createdAt: e.createdAt,
+ }));
+}
+
+export function mapBenefitLedgerCompat(
+ event: CommonEvent,
+ user?: { userNo?: string | null } | null,
+ coupon?: { couponNo?: string } | null,
+) {
+ return {
+ id: event.id,
+ type: event.param1,
+ amount: event.amount1 != null ? Number(event.amount1) : 0,
+ balanceAfter: event.amount2 != null ? Number(event.amount2) : 0,
+ remark: event.remark,
+ createdAt: event.createdAt,
+ userId: event.actorId,
+ couponId: event.param2 ? BigInt(event.param2) : null,
+ user: user ? { userNo: user.userNo } : undefined,
+ coupon: coupon ? { couponNo: coupon.couponNo } : undefined,
+ };
+}
diff --git a/server/dukang-api/src/common/event/event.helpers.ts b/server/dukang-api/src/common/event/event.helpers.ts
new file mode 100644
index 0000000..cb2f091
--- /dev/null
+++ b/server/dukang-api/src/common/event/event.helpers.ts
@@ -0,0 +1,67 @@
+import type { Prisma } from '@prisma/client';
+
+type BenefitLedgerType = 'GRANT' | 'REDEEM' | 'REFUND_VOID' | 'ADJUST';
+
+export function buildBenefitLedgerEvent(data: {
+ userId: bigint;
+ couponId: bigint;
+ type: BenefitLedgerType;
+ amount: number;
+ balanceAfter: number;
+ refType: string;
+ refId?: bigint;
+ remark?: string;
+}): Prisma.CommonEventCreateInput {
+ return {
+ eventType: 'BENEFIT_LEDGER',
+ refType: data.refType,
+ refId: data.refId ?? data.couponId,
+ actorType: 'USER',
+ actorId: data.userId,
+ param1: data.type,
+ param1Desc: 'ledger_type',
+ param2: data.couponId.toString(),
+ param2Desc: 'coupon_id',
+ amount1: data.amount,
+ amount2: data.balanceAfter,
+ remark: data.remark,
+ };
+}
+
+export function buildOrderStatusEvent(data: {
+ orderId: bigint;
+ fromStatus: string;
+ toStatus: string;
+ operator: string;
+ remark?: string;
+}): Prisma.CommonEventCreateInput {
+ return {
+ eventType: 'ORDER_STATUS',
+ refType: 'ORDER',
+ refId: data.orderId,
+ actorType: 'SYSTEM',
+ param1: data.fromStatus,
+ param1Desc: 'from_status',
+ param2: data.toStatus,
+ param2Desc: 'to_status',
+ param3: data.operator,
+ param3Desc: 'operator',
+ remark: data.remark,
+ };
+}
+
+export function benefitLedgerWhere(userId?: bigint, couponId?: bigint): Prisma.CommonEventWhereInput {
+ return {
+ eventType: 'BENEFIT_LEDGER',
+ ...(userId ? { actorType: 'USER', actorId: userId } : {}),
+ ...(couponId ? { param2: couponId.toString() } : {}),
+ };
+}
+
+export function orderStatusLogWhere(orderId: bigint): Prisma.CommonEventWhereInput {
+ return {
+ eventType: 'ORDER_STATUS',
+ refType: 'ORDER',
+ refId: orderId,
+ };
+}
diff --git a/server/dukang-api/src/integrations/integrations.constants.ts b/server/dukang-api/src/integrations/integrations.constants.ts
index 3b96cfa..a5b3052 100644
--- a/server/dukang-api/src/integrations/integrations.constants.ts
+++ b/server/dukang-api/src/integrations/integrations.constants.ts
@@ -1,3 +1,5 @@
export const SMS_PROVIDER = 'SMS_PROVIDER';
export const PAY_PROVIDER = 'PAY_PROVIDER';
export const DELIVERY_PROVIDER = 'DELIVERY_PROVIDER';
+export const WECHAT_PROVIDER = 'WECHAT_PROVIDER';
+export const OSS_PROVIDER = 'OSS_PROVIDER';
diff --git a/server/dukang-api/src/integrations/integrations.module.ts b/server/dukang-api/src/integrations/integrations.module.ts
index 0e3e27c..89b5a6e 100644
--- a/server/dukang-api/src/integrations/integrations.module.ts
+++ b/server/dukang-api/src/integrations/integrations.module.ts
@@ -1,21 +1,67 @@
import { Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bullmq';
+import { loadAppConfig } from '@dukang/shared-types';
import { SmsMockProvider } from './sms/sms.mock.provider';
import { PayMockProvider } from './pay/pay.mock.provider';
+import { PayWechatProvider } from './pay/pay.wechat.provider';
import { DeliveryMockProvider } from './delivery/delivery.mock.provider';
-import { SMS_PROVIDER, PAY_PROVIDER, DELIVERY_PROVIDER } from './integrations.constants';
+import { WechatApiProvider } from './wechat/wechat.api.provider';
+import { WechatDisabledProvider } from './wechat/wechat.disabled.provider';
+import { OssMockProvider } from './oss/oss.mock.provider';
+import { OssAliyunProvider } from './oss/oss.aliyun.provider';
+import {
+ SMS_PROVIDER,
+ PAY_PROVIDER,
+ DELIVERY_PROVIDER,
+ WECHAT_PROVIDER,
+ OSS_PROVIDER,
+} from './integrations.constants';
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';
@Module({
imports: [BullModule.registerQueue({ name: DELIVERY_QUEUE })],
providers: [
{ provide: SMS_PROVIDER, useClass: SmsMockProvider },
- { provide: PAY_PROVIDER, useClass: PayMockProvider },
- { provide: DELIVERY_PROVIDER, useClass: DeliveryMockProvider },
- SmsMockProvider,
+ WechatApiProvider,
+ WechatDisabledProvider,
+ {
+ provide: WECHAT_PROVIDER,
+ useFactory: (api: WechatApiProvider, disabled: WechatDisabledProvider): IWechatProvider => {
+ const cfg = loadAppConfig();
+ const enabled =
+ (cfg.wechatAuthEnabled || cfg.wechatPayEnabled) &&
+ (!!cfg.wxAppId || !!process.env.WX_MCH_ID);
+ return enabled ? api : disabled;
+ },
+ inject: [WechatApiProvider, WechatDisabledProvider],
+ },
PayMockProvider,
+ PayWechatProvider,
+ {
+ provide: PAY_PROVIDER,
+ useFactory: (mock: PayMockProvider, wechat: PayWechatProvider): IPayProvider => {
+ const cfg = loadAppConfig();
+ return cfg.mockPay ? mock : wechat;
+ },
+ inject: [PayMockProvider, PayWechatProvider],
+ },
+ { 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],
+ },
+ SmsMockProvider,
DeliveryMockProvider,
],
- exports: [SMS_PROVIDER, PAY_PROVIDER, DELIVERY_PROVIDER],
+ exports: [SMS_PROVIDER, PAY_PROVIDER, DELIVERY_PROVIDER, WECHAT_PROVIDER, OSS_PROVIDER],
})
export class IntegrationsModule {}
diff --git a/server/dukang-api/src/integrations/oss/oss.aliyun.client.ts b/server/dukang-api/src/integrations/oss/oss.aliyun.client.ts
new file mode 100644
index 0000000..83ae77d
--- /dev/null
+++ b/server/dukang-api/src/integrations/oss/oss.aliyun.client.ts
@@ -0,0 +1,25 @@
+import OSS = require('ali-oss');
+
+export type AliyunOssClientConfig = {
+ accessKeyId: string;
+ accessKeySecret: string;
+ bucket: string;
+ region: string;
+ endpoint?: string;
+ authorizationV4?: boolean;
+};
+
+export function createAliyunOssClient(config: AliyunOssClientConfig): OSS {
+ return new OSS({
+ region: config.region,
+ accessKeyId: config.accessKeyId,
+ accessKeySecret: config.accessKeySecret,
+ bucket: config.bucket,
+ ...(config.endpoint ? { endpoint: config.endpoint } : {}),
+ ...(config.authorizationV4 ? { authorizationV4: true } : {}),
+ });
+}
+
+export function resolveOssUploadHost(bucket: string, region: string): string {
+ return `https://${bucket}.${region}.aliyuncs.com`;
+}
diff --git a/server/dukang-api/src/integrations/oss/oss.aliyun.provider.ts b/server/dukang-api/src/integrations/oss/oss.aliyun.provider.ts
new file mode 100644
index 0000000..29f43e0
--- /dev/null
+++ b/server/dukang-api/src/integrations/oss/oss.aliyun.provider.ts
@@ -0,0 +1,112 @@
+import { Injectable } from '@nestjs/common';
+import type {
+ IOssProvider,
+ OssPutObjectInput,
+ OssPutObjectResult,
+ OssUploadTokenInput,
+ OssUploadTokenResult,
+} from './oss.interface';
+import { buildOssObjectKey } from './oss.key.util';
+import { createAliyunOssClient, resolveOssUploadHost } from './oss.aliyun.client';
+
+const DEFAULT_EXPIRE_SECONDS = 15 * 60;
+const DEFAULT_MAX_BYTES = 10 * 1024 * 1024;
+
+@Injectable()
+export class OssAliyunProvider implements IOssProvider {
+ private readonly accessKeyId = process.env.OSS_ACCESS_KEY_ID ?? '';
+ private readonly accessKeySecret = process.env.OSS_ACCESS_KEY_SECRET ?? '';
+ private readonly bucket = process.env.OSS_BUCKET ?? '';
+ private readonly region = process.env.OSS_REGION ?? 'oss-cn-hangzhou';
+ private readonly cdnBase = process.env.OSS_CDN_BASE ?? '';
+ private readonly endpoint = process.env.OSS_ENDPOINT ?? '';
+ private readonly uploadPrefix = (process.env.OSS_UPLOAD_PREFIX ?? 'uploads').replace(/\/$/, '');
+ private readonly expireSeconds = Number(process.env.OSS_UPLOAD_EXPIRE_SECONDS ?? DEFAULT_EXPIRE_SECONDS);
+ private readonly maxUploadBytes = Number(process.env.OSS_MAX_UPLOAD_BYTES ?? DEFAULT_MAX_BYTES);
+ private readonly authorizationV4 = process.env.OSS_AUTHORIZATION_V4 === 'true';
+ private client: ReturnType | null = null;
+
+ isEnabled() {
+ return !!(this.accessKeyId && this.accessKeySecret && this.bucket);
+ }
+
+ private getClient() {
+ if (!this.isEnabled()) {
+ throw new Error('OSS credentials are not configured');
+ }
+ if (!this.client) {
+ this.client = createAliyunOssClient({
+ accessKeyId: this.accessKeyId,
+ accessKeySecret: this.accessKeySecret,
+ bucket: this.bucket,
+ region: this.region,
+ endpoint: this.endpoint || undefined,
+ authorizationV4: this.authorizationV4,
+ });
+ }
+ return this.client;
+ }
+
+ 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}`;
+ }
+
+ getUploadToken(dto: OssUploadTokenInput): OssUploadTokenResult {
+ const client = this.getClient();
+ const ossKey = buildOssObjectKey(this.uploadPrefix, dto.bizType, dto.fileName);
+ const expireAt = new Date(Date.now() + this.expireSeconds * 1000);
+ const host = resolveOssUploadHost(this.bucket, this.region);
+
+ const policy = {
+ expiration: expireAt.toISOString(),
+ conditions: [
+ ['content-length-range', 0, this.maxUploadBytes],
+ ['eq', '$bucket', this.bucket],
+ ['starts-with', '$key', `${this.uploadPrefix}/${dto.bizType.toLowerCase()}/`],
+ ],
+ };
+
+ const signed = client.calculatePostSignature(policy);
+
+ return {
+ bucket: this.bucket,
+ region: this.region,
+ ossKey,
+ url: this.buildPublicUrl(ossKey),
+ mock: false,
+ expireAt: expireAt.toISOString(),
+ mediaType: dto.mediaType,
+ bizType: dto.bizType,
+ host,
+ policy: signed.policy,
+ signature: signed.Signature,
+ accessKeyId: signed.OSSAccessKeyId,
+ };
+ }
+
+ async putObject(input: OssPutObjectInput): Promise {
+ const client = this.getClient();
+ const ossKey = buildOssObjectKey(this.uploadPrefix, input.bizType, input.fileName);
+ await client.put(ossKey, input.buffer, {
+ mime: input.mimeType || 'application/octet-stream',
+ headers: {
+ 'Content-Disposition': 'inline',
+ },
+ });
+ return {
+ bucket: this.bucket,
+ region: this.region,
+ ossKey,
+ url: this.buildPublicUrl(ossKey),
+ mock: false,
+ };
+ }
+}
diff --git a/server/dukang-api/src/integrations/oss/oss.interface.ts b/server/dukang-api/src/integrations/oss/oss.interface.ts
new file mode 100644
index 0000000..6de878c
--- /dev/null
+++ b/server/dukang-api/src/integrations/oss/oss.interface.ts
@@ -0,0 +1,47 @@
+export interface OssUploadTokenInput {
+ bizType: string;
+ mediaType: string;
+ fileName: string;
+}
+
+export interface OssUploadTokenResult {
+ bucket: string;
+ region: string;
+ ossKey: string;
+ url: string;
+ mock: boolean;
+ expireAt: string;
+ mediaType: string;
+ bizType: string;
+ /** OSS PostObject 直传 host,mock 时为空 */
+ host?: string;
+ /** Base64 policy,mock 时为空 */
+ policy?: string;
+ /** HMAC-SHA1 签名,mock 时为空 */
+ signature?: string;
+ /** RAM AccessKeyId,mock 时为空 */
+ accessKeyId?: string;
+}
+
+export interface OssPutObjectInput {
+ bizType: string;
+ mediaType: string;
+ fileName: string;
+ buffer: Buffer;
+ mimeType?: string;
+}
+
+export interface OssPutObjectResult {
+ bucket: string;
+ region: string;
+ ossKey: string;
+ url: string;
+ mock: boolean;
+}
+
+export interface IOssProvider {
+ isEnabled(): boolean;
+ getUploadToken(input: OssUploadTokenInput): OssUploadTokenResult;
+ putObject(input: OssPutObjectInput): Promise;
+ buildPublicUrl(ossKey: string): string;
+}
diff --git a/server/dukang-api/src/integrations/oss/oss.key.util.ts b/server/dukang-api/src/integrations/oss/oss.key.util.ts
new file mode 100644
index 0000000..c098278
--- /dev/null
+++ b/server/dukang-api/src/integrations/oss/oss.key.util.ts
@@ -0,0 +1,7 @@
+import { randomUUID } from 'crypto';
+
+export function buildOssObjectKey(uploadPrefix: string, bizType: string, fileName: string): string {
+ const ext = fileName.includes('.') ? fileName.split('.').pop() : 'bin';
+ const dir = `${uploadPrefix.replace(/\/$/, '')}/${bizType.toLowerCase()}/`;
+ return `${dir}${Date.now()}-${randomUUID().slice(0, 8)}.${ext}`;
+}
diff --git a/server/dukang-api/src/integrations/oss/oss.mock.provider.ts b/server/dukang-api/src/integrations/oss/oss.mock.provider.ts
new file mode 100644
index 0000000..c3bee2a
--- /dev/null
+++ b/server/dukang-api/src/integrations/oss/oss.mock.provider.ts
@@ -0,0 +1,49 @@
+import { Injectable } from '@nestjs/common';
+import type {
+ IOssProvider,
+ OssPutObjectInput,
+ OssPutObjectResult,
+ OssUploadTokenInput,
+ OssUploadTokenResult,
+} from './oss.interface';
+import { buildOssObjectKey } from './oss.key.util';
+
+@Injectable()
+export class OssMockProvider implements IOssProvider {
+ private readonly bucket = process.env.OSS_BUCKET || 'mock-dukang';
+ private readonly region = process.env.OSS_REGION || 'oss-cn-hangzhou';
+ private readonly cdnBase = process.env.OSS_CDN_BASE || 'https://mock-cdn.dukang.local';
+
+ isEnabled() {
+ return false;
+ }
+
+ buildPublicUrl(ossKey: string) {
+ return `${this.cdnBase.replace(/\/$/, '')}/${ossKey.replace(/^\//, '')}`;
+ }
+
+ getUploadToken(dto: OssUploadTokenInput): OssUploadTokenResult {
+ const key = buildOssObjectKey('uploads', dto.bizType, dto.fileName);
+ return {
+ bucket: this.bucket,
+ region: this.region,
+ ossKey: key,
+ url: this.buildPublicUrl(key),
+ mock: true,
+ expireAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
+ mediaType: dto.mediaType,
+ bizType: dto.bizType,
+ };
+ }
+
+ async putObject(input: OssPutObjectInput): Promise {
+ const ossKey = buildOssObjectKey('uploads', input.bizType, input.fileName);
+ return {
+ bucket: this.bucket,
+ region: this.region,
+ ossKey,
+ url: this.buildPublicUrl(ossKey),
+ mock: true,
+ };
+ }
+}
diff --git a/server/dukang-api/src/integrations/pay/pay.interface.ts b/server/dukang-api/src/integrations/pay/pay.interface.ts
index c2cd164..ae5fb15 100644
--- a/server/dukang-api/src/integrations/pay/pay.interface.ts
+++ b/server/dukang-api/src/integrations/pay/pay.interface.ts
@@ -1,3 +1,9 @@
+import type { WechatJsapiPrepayParams } from '@dukang/shared-types';
+
+export type PayOrderResult =
+ | { mode: 'mock'; externalNo: string }
+ | { mode: 'jsapi'; prepay: WechatJsapiPrepayParams };
+
export interface IPayProvider {
- payOrder(orderId: bigint): Promise<{ externalNo: string }>;
+ payOrder(orderId: bigint, openId?: string): Promise;
}
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 a08e216..ce7e8d4 100644
--- a/server/dukang-api/src/integrations/pay/pay.mock.provider.ts
+++ b/server/dukang-api/src/integrations/pay/pay.mock.provider.ts
@@ -1,15 +1,15 @@
import { Injectable } from '@nestjs/common';
import { loadAppConfig } from '@dukang/shared-types';
-import { IPayProvider } from './pay.interface';
+import type { IPayProvider, PayOrderResult } from './pay.interface';
@Injectable()
export class PayMockProvider implements IPayProvider {
private readonly config = loadAppConfig();
- async payOrder(_orderId: bigint): Promise<{ externalNo: string }> {
+ async payOrder(_orderId: bigint, _openId?: string): Promise {
if (!this.config.mockPay) {
- throw new Error('Real WeChat pay not implemented in preV1');
+ throw new Error('Real WeChat pay requires PayWechatProvider');
}
- return { externalNo: `MOCK-${Date.now()}` };
+ return { mode: 'mock', externalNo: `MOCK-${Date.now()}` };
}
}
diff --git a/server/dukang-api/src/integrations/pay/pay.wechat.provider.ts b/server/dukang-api/src/integrations/pay/pay.wechat.provider.ts
new file mode 100644
index 0000000..3aae1cf
--- /dev/null
+++ b/server/dukang-api/src/integrations/pay/pay.wechat.provider.ts
@@ -0,0 +1,43 @@
+import { Inject, Injectable, Logger } from '@nestjs/common';
+import { loadAppConfig } from '@dukang/shared-types';
+import { PrismaService } from '../../common/prisma/prisma.module';
+import { WECHAT_PROVIDER } from '../integrations.constants';
+import type { IWechatProvider } from '../wechat/wechat.interface';
+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,
+ @Inject(WECHAT_PROVIDER) private readonly wechat: IWechatProvider,
+ ) {}
+
+ async payOrder(orderId: bigint, openId?: string): Promise {
+ if (this.config.mockPay) {
+ return { mode: 'mock', externalNo: `MOCK-${Date.now()}` };
+ }
+ if (!this.wechat.isPayEnabled()) {
+ throw new Error('微信支付未配置:请设置 WECHAT_PAY_ENABLED=true 与 WX_MCH_ID 等商户参数');
+ }
+ if (!openId) {
+ throw new Error('微信支付需要用户 openId,请先完成微信授权登录');
+ }
+
+ const order = await this.prisma.order.findUnique({ where: { id: orderId } });
+ if (!order) throw new Error('订单不存在');
+
+ const amountFen = Math.round(Number(order.payAmount) * 100);
+ this.logger.log(`create JSAPI prepay order=${order.orderNo} mchid=${this.wechat.getMchId()}`);
+ const prepay = await this.wechat.createJsapiPrepay({
+ orderNo: order.orderNo,
+ description: `杜康好客订单 ${order.orderNo}`,
+ amountFen,
+ openId,
+ notifyUrl: process.env.WX_PAY_NOTIFY_URL ?? '',
+ });
+ return { mode: 'jsapi', prepay };
+ }
+}
diff --git a/server/dukang-api/src/integrations/wechat/wechat-pay.util.ts b/server/dukang-api/src/integrations/wechat/wechat-pay.util.ts
new file mode 100644
index 0000000..48d19ce
--- /dev/null
+++ b/server/dukang-api/src/integrations/wechat/wechat-pay.util.ts
@@ -0,0 +1,69 @@
+import { createDecipheriv, createVerify, timingSafeEqual } from 'crypto';
+
+export type WechatPayNotifyResource = {
+ transaction_id: string;
+ out_trade_no: string;
+ trade_state: string;
+ trade_state_desc?: string;
+ amount?: { total?: number; payer_total?: number };
+};
+
+export type WechatPayNotifyEnvelope = {
+ id: string;
+ create_time: string;
+ event_type: string;
+ resource_type: string;
+ summary: string;
+ resource: {
+ algorithm: string;
+ ciphertext: string;
+ associated_data?: string;
+ nonce: string;
+ original_type?: string;
+ };
+};
+
+export function decryptPayResource(
+ apiV3Key: string,
+ associatedData: string,
+ nonce: string,
+ ciphertext: string,
+): WechatPayNotifyResource {
+ const key = Buffer.from(apiV3Key, 'utf8');
+ const buf = Buffer.from(ciphertext, 'base64');
+ const authTag = buf.subarray(buf.length - 16);
+ const data = buf.subarray(0, buf.length - 16);
+ const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(nonce, 'utf8'));
+ if (associatedData) {
+ decipher.setAAD(Buffer.from(associatedData, 'utf8'));
+ }
+ decipher.setAuthTag(authTag);
+ const decoded = Buffer.concat([decipher.update(data), decipher.final()]);
+ return JSON.parse(decoded.toString('utf8')) as WechatPayNotifyResource;
+}
+
+export function verifyPaySignature(params: {
+ platformPublicKeyPem: string;
+ timestamp: string;
+ nonce: string;
+ body: string;
+ signature: string;
+}): boolean {
+ const message = `${params.timestamp}\n${params.nonce}\n${params.body}\n`;
+ const verifier = createVerify('RSA-SHA256');
+ verifier.update(message);
+ verifier.end();
+ const ok = verifier.verify(params.platformPublicKeyPem, params.signature, 'base64');
+ if (!ok) return false;
+ const ts = Number(params.timestamp);
+ if (!Number.isFinite(ts)) return false;
+ const skewMs = Math.abs(Date.now() - ts * 1000);
+ return skewMs <= 5 * 60 * 1000;
+}
+
+export function safeEqual(a: string, b: string): boolean {
+ const ba = Buffer.from(a);
+ const bb = Buffer.from(b);
+ if (ba.length !== bb.length) return false;
+ return timingSafeEqual(ba, bb);
+}
diff --git a/server/dukang-api/src/integrations/wechat/wechat.api.provider.ts b/server/dukang-api/src/integrations/wechat/wechat.api.provider.ts
new file mode 100644
index 0000000..a7619ba
--- /dev/null
+++ b/server/dukang-api/src/integrations/wechat/wechat.api.provider.ts
@@ -0,0 +1,362 @@
+import { createDecipheriv, createHash, createSign, randomBytes, randomUUID } from 'crypto';
+import { BadRequestException, Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
+import { loadAppConfig } from '@dukang/shared-types';
+import { RedisService } from '../../common/redis/redis.service';
+import type { IWechatProvider, WechatCodeSession, WechatOAuthSession } from './wechat.interface';
+import {
+ decryptPayResource,
+ verifyPaySignature,
+ type WechatPayNotifyEnvelope,
+} from './wechat-pay.util';
+
+type TokenCache = { accessToken: string; expiresAt: number };
+type TicketCache = { ticket: string; expiresAt: number };
+
+const ACCESS_TOKEN_KEY = 'wechat:access_token';
+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 ?? '';
+ private readonly mchId = process.env.WX_MCH_ID ?? '';
+ private readonly mchSerialNo = process.env.WX_MCH_SERIAL_NO ?? '';
+ private readonly mchPrivateKey = (process.env.WX_MCH_PRIVATE_KEY ?? '').replace(/\\n/g, '\n');
+ private readonly apiV3Key = process.env.WX_API_V3_KEY ?? '';
+ private readonly notifyUrl = process.env.WX_PAY_NOTIFY_URL ?? '';
+ private readonly platformCert = (process.env.WX_PLATFORM_CERT ?? '').replace(/\\n/g, '\n');
+
+ constructor(private readonly redis: RedisService) {}
+
+ isEnabled() {
+ return this.config.wechatAuthEnabled && !!this.appId && !!this.appSecret;
+ }
+
+ isPayEnabled() {
+ return (
+ this.config.wechatPayEnabled &&
+ !!this.appId &&
+ !!this.mchId &&
+ !!this.mchSerialNo &&
+ !!this.mchPrivateKey &&
+ !!this.apiV3Key
+ );
+ }
+
+ getMchId() {
+ return this.mchId;
+ }
+
+ buildOAuthUrl(redirectUri: string, state: string, scope = 'snsapi_userinfo') {
+ const qs = new URLSearchParams({
+ appid: this.appId,
+ redirect_uri: redirectUri,
+ response_type: 'code',
+ scope,
+ state,
+ });
+ return `https://open.weixin.qq.com/connect/oauth2/authorize?${qs.toString()}#wechat_redirect`;
+ }
+
+ async code2Session(code: string): Promise {
+ const url = new URL('https://api.weixin.qq.com/sns/jscode2session');
+ url.searchParams.set('appid', this.appId);
+ url.searchParams.set('secret', this.appSecret);
+ url.searchParams.set('js_code', code);
+ url.searchParams.set('grant_type', 'authorization_code');
+ const data = await this.fetchJson<{
+ openid?: string;
+ unionid?: string;
+ session_key?: string;
+ errcode?: number;
+ errmsg?: string;
+ }>(url.toString());
+ if (!data.openid) {
+ throw new InternalServerErrorException(data.errmsg || '微信 code2session 失败');
+ }
+ return {
+ openId: data.openid,
+ unionId: data.unionid,
+ sessionKey: data.session_key,
+ };
+ }
+
+ async oauth2AccessToken(code: string): Promise {
+ const url = new URL('https://api.weixin.qq.com/sns/oauth2/access_token');
+ url.searchParams.set('appid', this.appId);
+ url.searchParams.set('secret', this.appSecret);
+ url.searchParams.set('code', code);
+ url.searchParams.set('grant_type', 'authorization_code');
+ const data = await this.fetchJson<{
+ openid?: string;
+ unionid?: string;
+ access_token?: string;
+ refresh_token?: string;
+ errcode?: number;
+ errmsg?: string;
+ }>(url.toString());
+ if (!data.openid) {
+ throw new InternalServerErrorException(data.errmsg || '微信 OAuth 失败');
+ }
+ return {
+ openId: data.openid,
+ unionId: data.unionid,
+ accessToken: data.access_token,
+ refreshToken: data.refresh_token,
+ };
+ }
+
+ async createJssdkConfig(url: string) {
+ const ticket = await this.getJsapiTicket();
+ const nonceStr = randomBytes(8).toString('hex');
+ const timestamp = Math.floor(Date.now() / 1000);
+ const raw = `jsapi_ticket=${ticket}&noncestr=${nonceStr}×tamp=${timestamp}&url=${url}`;
+ const signature = createHash('sha1').update(raw).digest('hex');
+ return {
+ appId: this.appId,
+ timestamp,
+ nonceStr,
+ signature,
+ jsApiList: ['getLocation', 'scanQRCode', 'chooseWXPay'],
+ };
+ }
+
+ async getPhoneNumberByCode(code: string, platform: 'mini' | 'h5'): Promise {
+ if (platform === 'h5') {
+ throw new InternalServerErrorException('H5 请使用短信绑定手机号');
+ }
+ const accessToken = await this.getAccessToken();
+ const url = `https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=${accessToken}`;
+ const data = await this.fetchJson<{
+ errcode?: number;
+ errmsg?: string;
+ phone_info?: { phoneNumber?: string; purePhoneNumber?: string };
+ }>(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ code }),
+ });
+ const phone = data.phone_info?.purePhoneNumber || data.phone_info?.phoneNumber;
+ if (!phone) {
+ throw new InternalServerErrorException(data.errmsg || '获取手机号失败');
+ }
+ return phone;
+ }
+
+ async createJsapiPrepay(params: {
+ orderNo: string;
+ description: string;
+ amountFen: number;
+ openId: string;
+ notifyUrl: string;
+ }) {
+ 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',
+ );
+ }
+ const notifyUrl = params.notifyUrl || this.notifyUrl;
+ if (!notifyUrl) {
+ throw new InternalServerErrorException('请配置 WX_PAY_NOTIFY_URL');
+ }
+ const body = {
+ appid: this.appId,
+ mchid: this.mchId,
+ description: params.description,
+ out_trade_no: params.orderNo,
+ notify_url: notifyUrl,
+ amount: { total: params.amountFen, currency: 'CNY' },
+ payer: { openid: params.openId },
+ };
+ const path = '/v3/pay/transactions/jsapi';
+ const payload = JSON.stringify(body);
+ const auth = this.signPayRequest('POST', path, payload);
+ const res = await this.fetchPayJson<{ prepay_id?: string }>(
+ `https://api.mch.weixin.qq.com${path}`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Accept: 'application/json',
+ Authorization: auth,
+ },
+ body: payload,
+ },
+ );
+ if (!res.prepay_id) {
+ throw new InternalServerErrorException('微信预支付下单失败');
+ }
+ this.logger.log(`JSAPI prepay ok mchid=${this.mchId} orderNo=${params.orderNo}`);
+ const timeStamp = String(Math.floor(Date.now() / 1000));
+ const nonceStr = randomUUID().replace(/-/g, '');
+ const packageStr = `prepay_id=${res.prepay_id}`;
+ const message = `${this.appId}\n${timeStamp}\n${nonceStr}\n${packageStr}\n`;
+ const sign = createSign('RSA-SHA256');
+ sign.update(message);
+ sign.end();
+ const paySign = sign.sign(this.mchPrivateKey, 'base64');
+ return {
+ appId: this.appId,
+ timeStamp,
+ nonceStr,
+ package: packageStr,
+ signType: 'RSA' as const,
+ paySign,
+ };
+ }
+
+ async parsePayNotification(
+ headers: Record,
+ rawBody: string,
+ ) {
+ if (!this.isPayEnabled()) {
+ throw new BadRequestException('微信支付未启用');
+ }
+ const signature = this.headerValue(headers, 'wechatpay-signature');
+ const timestamp = this.headerValue(headers, 'wechatpay-timestamp');
+ const nonce = this.headerValue(headers, 'wechatpay-nonce');
+ if (!signature || !timestamp || !nonce) {
+ throw new BadRequestException('微信回调签名头缺失');
+ }
+ if (this.platformCert) {
+ const valid = verifyPaySignature({
+ platformPublicKeyPem: this.platformCert,
+ timestamp,
+ nonce,
+ body: rawBody,
+ signature,
+ });
+ if (!valid) {
+ throw new BadRequestException('微信回调验签失败');
+ }
+ } else {
+ this.logger.warn('WX_PLATFORM_CERT 未配置,跳过回调 RSA 验签(仅建议开发环境)');
+ }
+
+ const envelope = JSON.parse(rawBody) as WechatPayNotifyEnvelope;
+ if (envelope.event_type !== 'TRANSACTION.SUCCESS') {
+ throw new BadRequestException(`忽略的事件类型: ${envelope.event_type}`);
+ }
+ const resource = decryptPayResource(
+ this.apiV3Key,
+ envelope.resource.associated_data ?? '',
+ envelope.resource.nonce,
+ envelope.resource.ciphertext,
+ );
+ if (resource.trade_state !== 'SUCCESS') {
+ throw new BadRequestException(`交易未成功: ${resource.trade_state}`);
+ }
+ return {
+ transactionId: resource.transaction_id,
+ outTradeNo: resource.out_trade_no,
+ tradeState: resource.trade_state,
+ amountFen: resource.amount?.total ?? resource.amount?.payer_total ?? 0,
+ };
+ }
+
+ private headerValue(headers: Record, key: string) {
+ const raw = headers[key] ?? headers[key.toLowerCase()];
+ if (Array.isArray(raw)) return raw[0];
+ return raw;
+ }
+
+ private async getAccessToken(): Promise {
+ const cached = await this.redis.getJson(ACCESS_TOKEN_KEY);
+ if (cached && cached.expiresAt > Date.now()) return cached.accessToken;
+
+ const url = new URL('https://api.weixin.qq.com/cgi-bin/token');
+ url.searchParams.set('grant_type', 'client_credential');
+ url.searchParams.set('appid', this.appId);
+ url.searchParams.set('secret', this.appSecret);
+ const data = await this.fetchJson<{ access_token?: string; expires_in?: number; errcode?: number; errmsg?: string }>(
+ url.toString(),
+ );
+ if (!data.access_token) {
+ throw new InternalServerErrorException(data.errmsg || '获取 access_token 失败');
+ }
+ const ttl = Math.max((data.expires_in ?? 7200) - 300, 60);
+ await this.redis.setJson(
+ ACCESS_TOKEN_KEY,
+ { accessToken: data.access_token, expiresAt: Date.now() + ttl * 1000 },
+ ttl,
+ );
+ return data.access_token;
+ }
+
+ private async getJsapiTicket(): Promise {
+ const cached = await this.redis.getJson(JSAPI_TICKET_KEY);
+ if (cached && cached.expiresAt > Date.now()) return cached.ticket;
+
+ const accessToken = await this.getAccessToken();
+ const url = new URL('https://api.weixin.qq.com/cgi-bin/ticket/getticket');
+ url.searchParams.set('access_token', accessToken);
+ url.searchParams.set('type', 'jsapi');
+ const data = await this.fetchJson<{ ticket?: string; expires_in?: number; errcode?: number; errmsg?: string }>(
+ url.toString(),
+ );
+ if (!data.ticket) {
+ throw new InternalServerErrorException(data.errmsg || '获取 jsapi_ticket 失败');
+ }
+ const ttl = Math.max((data.expires_in ?? 7200) - 300, 60);
+ await this.redis.setJson(
+ JSAPI_TICKET_KEY,
+ { ticket: data.ticket, expiresAt: Date.now() + ttl * 1000 },
+ ttl,
+ );
+ return data.ticket;
+ }
+
+ private signPayRequest(method: string, path: string, body: string) {
+ const timestamp = Math.floor(Date.now() / 1000);
+ const nonce = randomUUID();
+ const message = `${method}\n${path}\n${timestamp}\n${nonce}\n${body}\n`;
+ const sign = createSign('RSA-SHA256');
+ sign.update(message);
+ sign.end();
+ const signature = sign.sign(this.mchPrivateKey, 'base64');
+ return `WECHATPAY2-SHA256-RSA2048 mchid="${this.mchId}",nonce_str="${nonce}",signature="${signature}",timestamp="${timestamp}",serial_no="${this.mchSerialNo}"`;
+ }
+
+ private async fetchPayJson(url: string, init?: RequestInit): Promise {
+ const res = await fetch(url, init);
+ const text = await res.text();
+ let data: T & { code?: string; message?: string };
+ try {
+ data = JSON.parse(text) as T & { code?: string; message?: string };
+ } catch {
+ this.logger.error(`WeChat Pay invalid JSON (${res.status}): ${text.slice(0, 300)}`);
+ throw new InternalServerErrorException('微信支付接口响应异常');
+ }
+ if (!res.ok) {
+ const detail = data.message || data.code || text.slice(0, 200);
+ this.logger.error(`WeChat Pay API ${res.status}: ${detail}`);
+ throw new InternalServerErrorException(`微信支付下单失败: ${detail}`);
+ }
+ return data;
+ }
+
+ private async fetchJson(url: string, init?: RequestInit): Promise {
+ const res = await fetch(url, init);
+ const text = await res.text();
+ try {
+ return JSON.parse(text) as T;
+ } catch {
+ this.logger.error(`WeChat API invalid JSON: ${text.slice(0, 200)}`);
+ throw new InternalServerErrorException('微信接口响应异常');
+ }
+ }
+
+ /** 解密小程序敏感数据(备用) */
+ decryptData(sessionKey: string, encryptedData: string, iv: string): Record {
+ const key = Buffer.from(sessionKey, 'base64');
+ const decipher = createDecipheriv('aes-128-cbc', key, Buffer.from(iv, 'base64'));
+ decipher.setAutoPadding(true);
+ const decoded = Buffer.concat([
+ decipher.update(Buffer.from(encryptedData, 'base64')),
+ decipher.final(),
+ ]);
+ return JSON.parse(decoded.toString('utf8')) as Record;
+ }
+}
diff --git a/server/dukang-api/src/integrations/wechat/wechat.disabled.provider.ts b/server/dukang-api/src/integrations/wechat/wechat.disabled.provider.ts
new file mode 100644
index 0000000..ae28645
--- /dev/null
+++ b/server/dukang-api/src/integrations/wechat/wechat.disabled.provider.ts
@@ -0,0 +1,49 @@
+import { Injectable, NotImplementedException } from '@nestjs/common';
+import type { IWechatProvider } from './wechat.interface';
+
+@Injectable()
+export class WechatDisabledProvider implements IWechatProvider {
+ isEnabled() {
+ return false;
+ }
+
+ isPayEnabled() {
+ return false;
+ }
+
+ getMchId() {
+ return '';
+ }
+
+ private disabled(): never {
+ throw new NotImplementedException('FEATURE_DISABLED');
+ }
+
+ code2Session() {
+ return this.disabled();
+ }
+
+ oauth2AccessToken() {
+ return this.disabled();
+ }
+
+ createJssdkConfig() {
+ return this.disabled();
+ }
+
+ buildOAuthUrl() {
+ return this.disabled();
+ }
+
+ getPhoneNumberByCode() {
+ return this.disabled();
+ }
+
+ createJsapiPrepay() {
+ return this.disabled();
+ }
+
+ parsePayNotification() {
+ return this.disabled();
+ }
+}
diff --git a/server/dukang-api/src/integrations/wechat/wechat.interface.ts b/server/dukang-api/src/integrations/wechat/wechat.interface.ts
new file mode 100644
index 0000000..cd3cdbb
--- /dev/null
+++ b/server/dukang-api/src/integrations/wechat/wechat.interface.ts
@@ -0,0 +1,62 @@
+import type { WechatJssdkConfig, WechatJsapiPrepayParams } from '@dukang/shared-types';
+
+export type WechatCodeSession = {
+ openId: string;
+ unionId?: string;
+ sessionKey?: string;
+ accessToken?: string;
+};
+
+export type WechatOAuthSession = {
+ openId: string;
+ unionId?: string;
+ accessToken?: string;
+ refreshToken?: string;
+};
+
+export type WechatPayNotifyResult = {
+ transactionId: string;
+ outTradeNo: string;
+ tradeState: string;
+ amountFen: number;
+};
+
+export interface IWechatProvider {
+ isEnabled(): boolean;
+
+ /** 微信支付是否已配置(商户号 + 证书) */
+ isPayEnabled(): boolean;
+
+ /** 当前商户号(用于日志/排查) */
+ getMchId(): string;
+
+ /** 小程序 code2session */
+ code2Session(code: string): Promise;
+
+ /** 公众号 H5 OAuth code 换 openId */
+ oauth2AccessToken(code: string): Promise;
+
+ /** JSSDK 签名配置 */
+ createJssdkConfig(url: string): Promise;
+
+ /** 构建公众号 OAuth 授权 URL */
+ buildOAuthUrl(redirectUri: string, state: string, scope?: string): string;
+
+ /** 小程序手机号 code 解密(或调用微信 getPhoneNumber 接口) */
+ getPhoneNumberByCode(code: string, platform: 'mini' | 'h5'): Promise;
+
+ /** 创建 JSAPI 预支付参数(使用 WX_MCH_ID 统一下单) */
+ createJsapiPrepay(params: {
+ orderNo: string;
+ description: string;
+ amountFen: number;
+ openId: string;
+ notifyUrl: string;
+ }): Promise;
+
+ /** 解析并验签支付回调通知 */
+ parsePayNotification(
+ headers: Record,
+ rawBody: string,
+ ): Promise;
+}
diff --git a/server/dukang-api/src/main.ts b/server/dukang-api/src/main.ts
index 2b6bcfb..087bcdc 100644
--- a/server/dukang-api/src/main.ts
+++ b/server/dukang-api/src/main.ts
@@ -1,15 +1,25 @@
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { ValidationPipe } from '@nestjs/common';
+import { json } from 'express';
import { AppModule } from './app.module';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
async function bootstrap() {
- const app = await NestFactory.create(AppModule);
+ const app = await NestFactory.create(AppModule, { bodyParser: false });
app.setGlobalPrefix('api/v1');
app.set('trust proxy', true);
app.enableCors({ origin: true, credentials: true });
+ app.use(
+ json({
+ verify: (req, _res, buf) => {
+ if (req.url?.includes('/callbacks/wechat/pay')) {
+ (req as { rawBody?: Buffer }).rawBody = buf;
+ }
+ },
+ }),
+ );
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new ResponseInterceptor());
diff --git a/server/dukang-api/src/modules/analytics/analytics.service.ts b/server/dukang-api/src/modules/analytics/analytics.service.ts
index 9d6df92..c38633a 100644
--- a/server/dukang-api/src/modules/analytics/analytics.service.ts
+++ b/server/dukang-api/src/modules/analytics/analytics.service.ts
@@ -1,4 +1,5 @@
import { Injectable } from '@nestjs/common';
+import type { ClientApp } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
@Injectable()
@@ -11,12 +12,12 @@ export class AnalyticsService {
events: Array<{ eventName: string; params?: Record }>,
) {
if (!events?.length) return { count: 0 };
- await this.prisma.eventLog.createMany({
+ await this.prisma.logUserAnalytics.createMany({
data: events.map((e) => ({
userId,
eventName: e.eventName,
- params: e.params as never,
- clientApp,
+ extraJson: e.params as never,
+ clientApp: clientApp as ClientApp,
})),
});
return { count: events.length };
diff --git a/server/dukang-api/src/modules/benefit/benefit.service.ts b/server/dukang-api/src/modules/benefit/benefit.service.ts
index f97f370..caee503 100644
--- a/server/dukang-api/src/modules/benefit/benefit.service.ts
+++ b/server/dukang-api/src/modules/benefit/benefit.service.ts
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { calcBenefitAmount, calcBenefitSummary, generateCouponNo } from '@dukang/domain';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
+import { buildBenefitLedgerEvent, benefitLedgerWhere } from '../../common/event/event.helpers';
@Injectable()
export class BenefitService {
@@ -10,17 +11,14 @@ export class BenefitService {
async grantOnOrderPaid(orderId: bigint) {
const order = await this.prisma.order.findUniqueOrThrow({
where: { id: orderId },
- include: { items: true },
});
- const item = order.items[0];
- if (!item) return null;
- const product = await this.prisma.product.findUnique({ where: { id: item.productId } });
+ const product = await this.prisma.commonProductItem.findUnique({ where: { id: order.productId } });
const unitBenefit = calcBenefitAmount({
- price: Number(item.unitPrice),
+ price: Number(order.listUnitPrice),
benefitAmount: product?.benefitAmount ? Number(product.benefitAmount) : null,
});
- const totalBenefit = unitBenefit * item.quantity;
+ const totalBenefit = unitBenefit * order.quantity;
const coupon = await this.prisma.benefitCoupon.create({
data: {
@@ -29,12 +27,12 @@ export class BenefitService {
orderId: order.id,
totalAmount: totalBenefit,
balance: totalBenefit,
- sourceProduct: item.productName,
+ sourceProduct: order.productName,
},
});
- await this.prisma.benefitLedger.create({
- data: {
+ await this.prisma.commonEvent.create({
+ data: buildBenefitLedgerEvent({
userId: order.userId,
couponId: coupon.id,
type: 'GRANT',
@@ -43,7 +41,7 @@ export class BenefitService {
refType: 'ORDER',
refId: order.id,
remark: '购酒赠券',
- },
+ }),
});
return serializeBigInt(coupon);
@@ -69,8 +67,8 @@ export class BenefitService {
}
async getLedger(userId: bigint, couponId?: bigint) {
- const list = await this.prisma.benefitLedger.findMany({
- where: { userId, ...(couponId ? { couponId } : {}) },
+ const list = await this.prisma.commonEvent.findMany({
+ where: benefitLedgerWhere(userId, couponId),
orderBy: { createdAt: 'desc' },
});
return serializeBigInt(list);
@@ -81,8 +79,8 @@ export class BenefitService {
where: { id: couponId, userId },
});
if (!coupon) return null;
- const ledgers = await this.prisma.benefitLedger.findMany({
- where: { couponId },
+ const ledgers = await this.prisma.commonEvent.findMany({
+ where: benefitLedgerWhere(undefined, couponId),
orderBy: { createdAt: 'desc' },
});
return serializeBigInt({ coupon, ledgers });
diff --git a/server/dukang-api/src/modules/catalog/catalog.service.ts b/server/dukang-api/src/modules/catalog/catalog.service.ts
index e127150..f60d56f 100644
--- a/server/dukang-api/src/modules/catalog/catalog.service.ts
+++ b/server/dukang-api/src/modules/catalog/catalog.service.ts
@@ -7,7 +7,7 @@ export class CatalogService {
constructor(private readonly prisma: PrismaService) {}
async listCities() {
- const cities = await this.prisma.city.findMany({
+ const cities = await this.prisma.commonCity.findMany({
where: { status: 'ACTIVE' },
include: { partner: { select: { companyName: true } } },
});
@@ -15,9 +15,10 @@ export class CatalogService {
}
async listProducts(aromaType?: string) {
- const products = await this.prisma.product.findMany({
+ const products = await this.prisma.commonProductItem.findMany({
where: { status: 'ON_SALE', ...(aromaType ? { aromaType: aromaType as never } : {}) },
orderBy: { sortOrder: 'asc' },
+ include: { coverResource: true },
});
return serializeBigInt(
products.map((p) => ({
@@ -25,17 +26,22 @@ export class CatalogService {
benefitAmount: p.benefitAmount ?? p.price,
price: Number(p.price),
benefitDisplay: Number(p.benefitAmount ?? p.price),
+ mainImageUrl: p.coverResource?.url ?? null,
})),
);
}
async getProduct(id: bigint) {
- const product = await this.prisma.product.findUnique({ where: { id } });
+ const product = await this.prisma.commonProductItem.findUnique({
+ where: { id },
+ include: { coverResource: true },
+ });
if (!product) return null;
return serializeBigInt({
...product,
benefitAmount: product.benefitAmount ?? product.price,
price: Number(product.price),
+ mainImageUrl: product.coverResource?.url ?? null,
});
}
}
diff --git a/server/dukang-api/src/modules/common/client-config.controller.ts b/server/dukang-api/src/modules/common/client-config.controller.ts
new file mode 100644
index 0000000..019ccd2
--- /dev/null
+++ b/server/dukang-api/src/modules/common/client-config.controller.ts
@@ -0,0 +1,14 @@
+import { Controller, Get } from '@nestjs/common';
+import { loadAppConfig } from '@dukang/shared-types';
+
+@Controller('common')
+export class ClientConfigController {
+ @Get('client-config')
+ clientConfig() {
+ const cfg = loadAppConfig();
+ 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
new file mode 100644
index 0000000..b58a5df
--- /dev/null
+++ b/server/dukang-api/src/modules/common/common.module.ts
@@ -0,0 +1,28 @@
+import { Module } from '@nestjs/common';
+import { IamModule } from '../iam/iam.module';
+import { IntegrationsModule } from '../../integrations/integrations.module';
+import { ResourceService } from './resource.service';
+import { EventService } from './event.service';
+import { TicketService } from './ticket.service';
+import { ThirdPartyLogService } from './third-party-log.service';
+import { ResourceController } from './resource.controller';
+import { EventController } from './event.controller';
+import { TicketController } from './ticket.controller';
+import { ThirdPartyLogController } from './third-party-log.controller';
+import { WechatController } from './wechat.controller';
+import { ClientConfigController } from './client-config.controller';
+
+@Module({
+ imports: [IamModule, IntegrationsModule],
+ controllers: [
+ ResourceController,
+ EventController,
+ TicketController,
+ ThirdPartyLogController,
+ WechatController,
+ ClientConfigController,
+ ],
+ providers: [ResourceService, EventService, TicketService, ThirdPartyLogService],
+ exports: [ResourceService, EventService, TicketService],
+})
+export class CommonModule {}
diff --git a/server/dukang-api/src/modules/common/dto/common-mutate.dto.ts b/server/dukang-api/src/modules/common/dto/common-mutate.dto.ts
new file mode 100644
index 0000000..b9dd1e9
--- /dev/null
+++ b/server/dukang-api/src/modules/common/dto/common-mutate.dto.ts
@@ -0,0 +1,188 @@
+import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
+
+export class UploadTokenDto {
+ @IsString()
+ @IsNotEmpty()
+ bizType: string;
+
+ @IsString()
+ @IsIn(['IMAGE', 'VIDEO', 'FILE'])
+ mediaType: string;
+
+ @IsString()
+ @IsNotEmpty()
+ fileName: string;
+}
+
+export class UploadFileDto {
+ @IsString()
+ @IsNotEmpty()
+ bizType: string;
+
+ @IsString()
+ @IsIn(['IMAGE', 'VIDEO', 'FILE'])
+ mediaType: string;
+}
+
+export class RegisterResourceDto {
+ @IsString()
+ @IsNotEmpty()
+ ownerType: string;
+
+ @IsString()
+ @IsNotEmpty()
+ ownerId: string;
+
+ @IsString()
+ @IsNotEmpty()
+ bizType: string;
+
+ @IsString()
+ @IsIn(['IMAGE', 'VIDEO', 'FILE'])
+ mediaType: string;
+
+ @IsString()
+ @IsNotEmpty()
+ ossKey: string;
+
+ @IsString()
+ @IsNotEmpty()
+ url: string;
+
+ @IsOptional()
+ @IsString()
+ ossBucket?: string;
+
+ @IsOptional()
+ @IsString()
+ fileName?: string;
+
+ @IsOptional()
+ sortOrder?: number;
+}
+
+export class UpdateResourceDto {
+ @IsOptional()
+ @IsString()
+ url?: string;
+
+ @IsOptional()
+ @IsIn(['IMAGE', 'VIDEO', 'FILE'])
+ mediaType?: string;
+
+ @IsOptional()
+ sortOrder?: number;
+
+ @IsOptional()
+ @IsIn(['ACTIVE', 'DELETED'])
+ status?: string;
+}
+
+export class CreateEventDto {
+ @IsString()
+ @IsNotEmpty()
+ eventType: string;
+
+ @IsString()
+ @IsNotEmpty()
+ refType: string;
+
+ @IsString()
+ @IsNotEmpty()
+ refId: string;
+
+ @IsOptional()
+ @IsString()
+ actorType?: string;
+
+ @IsOptional()
+ @IsString()
+ actorId?: string;
+
+ @IsOptional()
+ @IsString()
+ status?: string;
+
+ @IsOptional()
+ @IsString()
+ param1?: string;
+
+ @IsOptional()
+ @IsString()
+ param1Desc?: string;
+
+ @IsOptional()
+ @IsString()
+ param2?: string;
+
+ @IsOptional()
+ @IsString()
+ param2Desc?: string;
+
+ @IsOptional()
+ @IsString()
+ param3?: string;
+
+ @IsOptional()
+ @IsString()
+ param3Desc?: string;
+
+ @IsOptional()
+ amount1?: number;
+
+ @IsOptional()
+ amount2?: number;
+
+ @IsOptional()
+ @IsString()
+ remark?: string;
+
+ @IsOptional()
+ extraJson?: Record;
+}
+
+export class CreateTicketDto {
+ @IsString()
+ @IsIn(['REFUND', 'RESHIPMENT', 'ALERT'])
+ ticketType: string;
+
+ @IsString()
+ @IsNotEmpty()
+ refType: string;
+
+ @IsString()
+ @IsNotEmpty()
+ refId: string;
+
+ @IsOptional()
+ @IsString()
+ remark?: string;
+
+ @IsOptional()
+ @IsString()
+ param1?: string;
+
+ @IsOptional()
+ @IsString()
+ param1Desc?: string;
+}
+
+export class UpdateTicketStatusDto {
+ @IsString()
+ @IsNotEmpty()
+ status: string;
+
+ @IsOptional()
+ @IsString()
+ remark?: string;
+}
+
+export class AssignTicketDto {
+ @IsString()
+ @IsIn(['HQ', 'PARTNER', 'SYSTEM'])
+ operatorType: string;
+
+ @IsString()
+ @IsNotEmpty()
+ operatorId: string;
+}
diff --git a/server/dukang-api/src/modules/common/dto/common-query.dto.ts b/server/dukang-api/src/modules/common/dto/common-query.dto.ts
new file mode 100644
index 0000000..374d0e4
--- /dev/null
+++ b/server/dukang-api/src/modules/common/dto/common-query.dto.ts
@@ -0,0 +1,93 @@
+import { Type } from 'class-transformer';
+import { IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
+
+export class PaginationQueryDto {
+ @IsOptional()
+ @Type(() => Number)
+ @IsInt()
+ @Min(1)
+ page?: number = 1;
+
+ @IsOptional()
+ @Type(() => Number)
+ @IsInt()
+ @Min(1)
+ @Max(100)
+ pageSize?: number = 20;
+}
+
+export class ResourceListQueryDto extends PaginationQueryDto {
+ @IsOptional()
+ @IsString()
+ ownerType?: string;
+
+ @IsOptional()
+ @IsString()
+ ownerId?: string;
+
+ @IsOptional()
+ @IsString()
+ bizType?: string;
+
+ @IsOptional()
+ @IsIn(['ACTIVE', 'DELETED'])
+ status?: string;
+}
+
+export class EventListQueryDto extends PaginationQueryDto {
+ @IsOptional()
+ @IsString()
+ refType?: string;
+
+ @IsOptional()
+ @IsString()
+ refId?: string;
+
+ @IsOptional()
+ @IsString()
+ eventType?: string;
+}
+
+export class EventTimelineQueryDto {
+ @IsString()
+ refType: string;
+
+ @IsString()
+ refId: string;
+}
+
+export class TicketListQueryDto extends PaginationQueryDto {
+ @IsOptional()
+ @IsString()
+ ticketType?: string;
+
+ @IsOptional()
+ @IsString()
+ status?: string;
+
+ @IsOptional()
+ @IsString()
+ refType?: string;
+
+ @IsOptional()
+ @IsString()
+ refId?: string;
+}
+
+export class ThirdPartyLogQueryDto extends PaginationQueryDto {
+ @IsOptional()
+ @IsString()
+ provider?: string;
+
+ @IsOptional()
+ @IsString()
+ scene?: string;
+
+ @IsOptional()
+ @IsString()
+ refType?: string;
+
+ @IsOptional()
+ @IsString()
+ refId?: string;
+}
diff --git a/server/dukang-api/src/modules/common/event.controller.ts b/server/dukang-api/src/modules/common/event.controller.ts
new file mode 100644
index 0000000..c057501
--- /dev/null
+++ b/server/dukang-api/src/modules/common/event.controller.ts
@@ -0,0 +1,31 @@
+import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
+import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
+import { EventService } from './event.service';
+import { EventListQueryDto, EventTimelineQueryDto } from './dto/common-query.dto';
+import { CreateEventDto } from './dto/common-mutate.dto';
+
+@Controller('common/events')
+@UseGuards(JwtAuthGuard)
+export class EventController {
+ constructor(private readonly service: EventService) {}
+
+ @Post()
+ create(@Body() dto: CreateEventDto) {
+ return this.service.create(dto);
+ }
+
+ @Get()
+ list(@Query() query: EventListQueryDto) {
+ return this.service.list(query);
+ }
+
+ @Get('timeline')
+ timeline(@Query() query: EventTimelineQueryDto) {
+ return this.service.timeline(query);
+ }
+
+ @Get(':id')
+ detail(@Param('id') id: string) {
+ return this.service.detail(BigInt(id));
+ }
+}
diff --git a/server/dukang-api/src/modules/common/event.service.ts b/server/dukang-api/src/modules/common/event.service.ts
new file mode 100644
index 0000000..9718cb5
--- /dev/null
+++ b/server/dukang-api/src/modules/common/event.service.ts
@@ -0,0 +1,71 @@
+import { Injectable, NotFoundException } from '@nestjs/common';
+import type { ActorType, EventType, ResourceBizType, ResourceMediaType, ResourceOwnerType } from '@prisma/client';
+import { Prisma } from '@prisma/client';
+import { PrismaService } from '../../common/prisma/prisma.module';
+import { serializeBigInt } from '../../common/decorators/current-user.decorator';
+import type { EventListQueryDto, EventTimelineQueryDto } from './dto/common-query.dto';
+import type { CreateEventDto } from './dto/common-mutate.dto';
+
+@Injectable()
+export class EventService {
+ constructor(private readonly prisma: PrismaService) {}
+
+ async create(dto: CreateEventDto) {
+ const event = await this.prisma.commonEvent.create({
+ data: {
+ eventType: dto.eventType as EventType,
+ refType: dto.refType,
+ refId: BigInt(dto.refId),
+ actorType: dto.actorType as ActorType | undefined,
+ actorId: dto.actorId ? BigInt(dto.actorId) : undefined,
+ status: dto.status,
+ param1: dto.param1,
+ param1Desc: dto.param1Desc,
+ param2: dto.param2,
+ param2Desc: dto.param2Desc,
+ param3: dto.param3,
+ param3Desc: dto.param3Desc,
+ amount1: dto.amount1,
+ amount2: dto.amount2,
+ remark: dto.remark,
+ extraJson: dto.extraJson as Prisma.InputJsonValue | undefined,
+ },
+ });
+ return serializeBigInt(event);
+ }
+
+ async list(query: EventListQueryDto) {
+ const page = query.page ?? 1;
+ const pageSize = query.pageSize ?? 20;
+ const where: Prisma.CommonEventWhereInput = {};
+ if (query.refType) where.refType = query.refType;
+ if (query.refId) where.refId = BigInt(query.refId);
+ if (query.eventType) where.eventType = query.eventType as Prisma.EnumEventTypeFilter['equals'];
+
+ const [items, total] = await Promise.all([
+ this.prisma.commonEvent.findMany({
+ where,
+ orderBy: { createdAt: 'desc' },
+ skip: (page - 1) * pageSize,
+ take: pageSize,
+ }),
+ this.prisma.commonEvent.count({ where }),
+ ]);
+ return serializeBigInt({ items, total, page, pageSize });
+ }
+
+ async timeline(query: EventTimelineQueryDto) {
+ const items = await this.prisma.commonEvent.findMany({
+ where: { refType: query.refType, refId: BigInt(query.refId) },
+ orderBy: { createdAt: 'asc' },
+ take: 200,
+ });
+ return serializeBigInt(items);
+ }
+
+ async detail(id: bigint) {
+ const event = await this.prisma.commonEvent.findUnique({ where: { id } });
+ if (!event) throw new NotFoundException('事件不存在');
+ return serializeBigInt(event);
+ }
+}
diff --git a/server/dukang-api/src/modules/common/resource.controller.ts b/server/dukang-api/src/modules/common/resource.controller.ts
new file mode 100644
index 0000000..b541f7c
--- /dev/null
+++ b/server/dukang-api/src/modules/common/resource.controller.ts
@@ -0,0 +1,66 @@
+import {
+ Body,
+ Controller,
+ Delete,
+ Get,
+ Param,
+ Post,
+ Put,
+ Query,
+ UploadedFile,
+ UseGuards,
+ UseInterceptors,
+} from '@nestjs/common';
+import { FileInterceptor } from '@nestjs/platform-express';
+import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
+import { ResourceService } from './resource.service';
+import { ResourceListQueryDto } from './dto/common-query.dto';
+import { RegisterResourceDto, UpdateResourceDto, UploadFileDto, UploadTokenDto } from './dto/common-mutate.dto';
+
+const DEFAULT_MAX_BYTES = 10 * 1024 * 1024;
+
+@Controller('common/resources')
+@UseGuards(JwtAuthGuard)
+export class ResourceController {
+ constructor(private readonly service: ResourceService) {}
+
+ @Post('upload-token')
+ uploadToken(@Body() dto: UploadTokenDto) {
+ return this.service.getUploadToken(dto);
+ }
+
+ @Post('upload')
+ @UseInterceptors(
+ FileInterceptor('file', {
+ limits: { fileSize: Number(process.env.OSS_MAX_UPLOAD_BYTES ?? DEFAULT_MAX_BYTES) },
+ }),
+ )
+ upload(@UploadedFile() file: Express.Multer.File, @Body() dto: UploadFileDto) {
+ return this.service.uploadFile(file, dto);
+ }
+
+ @Post()
+ register(@Body() dto: RegisterResourceDto) {
+ return this.service.register(dto);
+ }
+
+ @Get()
+ list(@Query() query: ResourceListQueryDto) {
+ return this.service.list(query);
+ }
+
+ @Get(':id')
+ detail(@Param('id') id: string) {
+ return this.service.detail(BigInt(id));
+ }
+
+ @Put(':id')
+ update(@Param('id') id: string, @Body() dto: UpdateResourceDto) {
+ return this.service.update(BigInt(id), dto);
+ }
+
+ @Delete(':id')
+ remove(@Param('id') id: string) {
+ return this.service.remove(BigInt(id));
+ }
+}
diff --git a/server/dukang-api/src/modules/common/resource.service.ts b/server/dukang-api/src/modules/common/resource.service.ts
new file mode 100644
index 0000000..8b304bc
--- /dev/null
+++ b/server/dukang-api/src/modules/common/resource.service.ts
@@ -0,0 +1,108 @@
+import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
+import type { ResourceBizType, ResourceMediaType, ResourceOwnerType } from '@prisma/client';
+import { Prisma } from '@prisma/client';
+import { PrismaService } from '../../common/prisma/prisma.module';
+import { serializeBigInt } from '../../common/decorators/current-user.decorator';
+import { OSS_PROVIDER } from '../../integrations/integrations.constants';
+import type { IOssProvider } from '../../integrations/oss/oss.interface';
+import type { ResourceListQueryDto } from './dto/common-query.dto';
+import type { RegisterResourceDto, UpdateResourceDto, UploadFileDto, UploadTokenDto } from './dto/common-mutate.dto';
+
+const DEFAULT_MAX_BYTES = 10 * 1024 * 1024;
+
+@Injectable()
+export class ResourceService {
+ constructor(
+ private readonly prisma: PrismaService,
+ @Inject(OSS_PROVIDER) private readonly oss: IOssProvider,
+ ) {}
+
+ getUploadToken(dto: UploadTokenDto) {
+ return this.oss.getUploadToken(dto);
+ }
+
+ async uploadFile(file: Express.Multer.File | undefined, dto: UploadFileDto) {
+ if (!file) {
+ throw new BadRequestException('请选择要上传的文件');
+ }
+ const maxUploadBytes = Number(process.env.OSS_MAX_UPLOAD_BYTES ?? DEFAULT_MAX_BYTES);
+ if (file.size > maxUploadBytes) {
+ throw new BadRequestException(`文件不能超过 ${Math.floor(maxUploadBytes / 1024 / 1024)}MB`);
+ }
+ return this.oss.putObject({
+ bizType: dto.bizType,
+ mediaType: dto.mediaType,
+ fileName: file.originalname || 'upload.bin',
+ buffer: file.buffer,
+ mimeType: file.mimetype,
+ });
+ }
+
+ async register(dto: RegisterResourceDto) {
+ const resource = await this.prisma.commonResource.create({
+ data: {
+ ownerType: dto.ownerType as ResourceOwnerType,
+ ownerId: BigInt(dto.ownerId),
+ bizType: dto.bizType as ResourceBizType,
+ mediaType: dto.mediaType as ResourceMediaType,
+ ossBucket: dto.ossBucket ?? process.env.OSS_BUCKET ?? 'mock-dukang',
+ ossKey: dto.ossKey,
+ url: dto.url || this.oss.buildPublicUrl(dto.ossKey),
+ fileName: dto.fileName,
+ sortOrder: dto.sortOrder ?? 0,
+ },
+ });
+ return serializeBigInt(resource);
+ }
+
+ async list(query: ResourceListQueryDto) {
+ const page = query.page ?? 1;
+ const pageSize = query.pageSize ?? 20;
+ const where: Prisma.CommonResourceWhereInput = {
+ status: (query.status ?? 'ACTIVE') as Prisma.EnumResourceStatusFilter['equals'],
+ };
+ if (query.ownerType) where.ownerType = query.ownerType as Prisma.EnumResourceOwnerTypeFilter['equals'];
+ if (query.ownerId) where.ownerId = BigInt(query.ownerId);
+ if (query.bizType) where.bizType = query.bizType as Prisma.EnumResourceBizTypeFilter['equals'];
+
+ const [items, total] = await Promise.all([
+ this.prisma.commonResource.findMany({
+ where,
+ orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
+ skip: (page - 1) * pageSize,
+ take: pageSize,
+ }),
+ this.prisma.commonResource.count({ where }),
+ ]);
+ return serializeBigInt({ items, total, page, pageSize });
+ }
+
+ async detail(id: bigint) {
+ const resource = await this.prisma.commonResource.findUnique({ where: { id } });
+ if (!resource) throw new NotFoundException('资源不存在');
+ return serializeBigInt(resource);
+ }
+
+ async update(id: bigint, dto: UpdateResourceDto) {
+ await this.detail(id);
+ const resource = await this.prisma.commonResource.update({
+ where: { id },
+ data: {
+ ...(dto.url !== undefined ? { url: dto.url, ossKey: dto.url } : {}),
+ ...(dto.mediaType !== undefined ? { mediaType: dto.mediaType as 'IMAGE' | 'VIDEO' | 'FILE' } : {}),
+ ...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}),
+ ...(dto.status !== undefined ? { status: dto.status as 'ACTIVE' | 'DELETED' } : {}),
+ },
+ });
+ return serializeBigInt(resource);
+ }
+
+ async remove(id: bigint) {
+ await this.detail(id);
+ await this.prisma.commonResource.update({
+ where: { id },
+ data: { status: 'DELETED' },
+ });
+ return { ok: true };
+ }
+}
diff --git a/server/dukang-api/src/modules/common/third-party-log.controller.ts b/server/dukang-api/src/modules/common/third-party-log.controller.ts
new file mode 100644
index 0000000..9bd635c
--- /dev/null
+++ b/server/dukang-api/src/modules/common/third-party-log.controller.ts
@@ -0,0 +1,20 @@
+import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
+import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
+import { ThirdPartyLogService } from './third-party-log.service';
+import { ThirdPartyLogQueryDto } from './dto/common-query.dto';
+
+@Controller('common/third-party-logs')
+@UseGuards(HqAuthGuard)
+export class ThirdPartyLogController {
+ constructor(private readonly service: ThirdPartyLogService) {}
+
+ @Get()
+ list(@Query() query: ThirdPartyLogQueryDto) {
+ return this.service.list(query);
+ }
+
+ @Get(':id')
+ detail(@Param('id') id: string) {
+ return this.service.detail(BigInt(id));
+ }
+}
diff --git a/server/dukang-api/src/modules/common/third-party-log.service.ts b/server/dukang-api/src/modules/common/third-party-log.service.ts
new file mode 100644
index 0000000..ae92c60
--- /dev/null
+++ b/server/dukang-api/src/modules/common/third-party-log.service.ts
@@ -0,0 +1,37 @@
+import { Injectable, NotFoundException } from '@nestjs/common';
+import { Prisma } from '@prisma/client';
+import { PrismaService } from '../../common/prisma/prisma.module';
+import { serializeBigInt } from '../../common/decorators/current-user.decorator';
+import type { ThirdPartyLogQueryDto } from './dto/common-query.dto';
+
+@Injectable()
+export class ThirdPartyLogService {
+ constructor(private readonly prisma: PrismaService) {}
+
+ async list(query: ThirdPartyLogQueryDto) {
+ const page = query.page ?? 1;
+ const pageSize = query.pageSize ?? 20;
+ const where: Prisma.LogThirdPartyWhereInput = {};
+ if (query.provider) where.provider = query.provider as Prisma.EnumThirdPartyProviderFilter['equals'];
+ if (query.scene) where.scene = { contains: query.scene };
+ if (query.refType) where.refType = query.refType;
+ if (query.refId) where.refId = BigInt(query.refId);
+
+ const [items, total] = await Promise.all([
+ this.prisma.logThirdParty.findMany({
+ where,
+ orderBy: { createdAt: 'desc' },
+ skip: (page - 1) * pageSize,
+ take: pageSize,
+ }),
+ this.prisma.logThirdParty.count({ where }),
+ ]);
+ return serializeBigInt({ items, total, page, pageSize });
+ }
+
+ async detail(id: bigint) {
+ const log = await this.prisma.logThirdParty.findUnique({ where: { id } });
+ if (!log) throw new NotFoundException('日志不存在');
+ return serializeBigInt(log);
+ }
+}
diff --git a/server/dukang-api/src/modules/common/ticket.controller.ts b/server/dukang-api/src/modules/common/ticket.controller.ts
new file mode 100644
index 0000000..faa74a9
--- /dev/null
+++ b/server/dukang-api/src/modules/common/ticket.controller.ts
@@ -0,0 +1,38 @@
+import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
+import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
+import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
+import { TicketService } from './ticket.service';
+import { TicketListQueryDto } from './dto/common-query.dto';
+import { AssignTicketDto, CreateTicketDto, UpdateTicketStatusDto } from './dto/common-mutate.dto';
+
+@Controller('common/tickets')
+@UseGuards(JwtAuthGuard)
+export class TicketController {
+ constructor(private readonly service: TicketService) {}
+
+ @Post()
+ create(@Body() dto: CreateTicketDto) {
+ return this.service.create(dto);
+ }
+
+ @Get()
+ list(@Query() query: TicketListQueryDto) {
+ return this.service.list(query);
+ }
+
+ @Get(':id')
+ detail(@Param('id') id: string) {
+ return this.service.detail(BigInt(id));
+ }
+
+ @Put(':id/status')
+ updateStatus(@Param('id') id: string, @Body() dto: UpdateTicketStatusDto) {
+ return this.service.updateStatus(BigInt(id), dto);
+ }
+
+ @Put(':id/assign')
+ @UseGuards(HqAuthGuard)
+ assign(@Param('id') id: string, @Body() dto: AssignTicketDto) {
+ return this.service.assign(BigInt(id), dto);
+ }
+}
diff --git a/server/dukang-api/src/modules/common/ticket.service.ts b/server/dukang-api/src/modules/common/ticket.service.ts
new file mode 100644
index 0000000..603d7cb
--- /dev/null
+++ b/server/dukang-api/src/modules/common/ticket.service.ts
@@ -0,0 +1,83 @@
+import { Injectable, NotFoundException } from '@nestjs/common';
+import type { ActorType, TicketType } from '@prisma/client';
+import { Prisma } from '@prisma/client';
+import { PrismaService } from '../../common/prisma/prisma.module';
+import { serializeBigInt } from '../../common/decorators/current-user.decorator';
+import type { TicketListQueryDto } from './dto/common-query.dto';
+import type { AssignTicketDto, CreateTicketDto, UpdateTicketStatusDto } from './dto/common-mutate.dto';
+
+function generateTicketNo() {
+ return `TK${Date.now()}${Math.floor(Math.random() * 900 + 100)}`;
+}
+
+@Injectable()
+export class TicketService {
+ constructor(private readonly prisma: PrismaService) {}
+
+ async create(dto: CreateTicketDto) {
+ const ticket = await this.prisma.commonTicket.create({
+ data: {
+ ticketNo: generateTicketNo(),
+ ticketType: dto.ticketType as TicketType,
+ refType: dto.refType,
+ refId: BigInt(dto.refId),
+ remark: dto.remark,
+ param1: dto.param1,
+ param1Desc: dto.param1Desc,
+ },
+ });
+ return serializeBigInt(ticket);
+ }
+
+ async list(query: TicketListQueryDto) {
+ const page = query.page ?? 1;
+ const pageSize = query.pageSize ?? 20;
+ const where: Prisma.CommonTicketWhereInput = {};
+ if (query.ticketType) where.ticketType = query.ticketType as Prisma.EnumTicketTypeFilter['equals'];
+ if (query.status) where.status = query.status;
+ if (query.refType) where.refType = query.refType;
+ if (query.refId) where.refId = BigInt(query.refId);
+
+ const [items, total] = await Promise.all([
+ this.prisma.commonTicket.findMany({
+ where,
+ orderBy: { createdAt: 'desc' },
+ skip: (page - 1) * pageSize,
+ take: pageSize,
+ }),
+ this.prisma.commonTicket.count({ where }),
+ ]);
+ return serializeBigInt({ items, total, page, pageSize });
+ }
+
+ async detail(id: bigint) {
+ const ticket = await this.prisma.commonTicket.findUnique({ where: { id } });
+ if (!ticket) throw new NotFoundException('工单不存在');
+ return serializeBigInt(ticket);
+ }
+
+ async updateStatus(id: bigint, dto: UpdateTicketStatusDto) {
+ await this.detail(id);
+ const ticket = await this.prisma.commonTicket.update({
+ where: { id },
+ data: {
+ status: dto.status,
+ remark: dto.remark,
+ completedAt: ['COMPLETED', 'CLOSED', 'RESOLVED'].includes(dto.status) ? new Date() : undefined,
+ },
+ });
+ return serializeBigInt(ticket);
+ }
+
+ async assign(id: bigint, dto: AssignTicketDto) {
+ await this.detail(id);
+ const ticket = await this.prisma.commonTicket.update({
+ where: { id },
+ data: {
+ operatorType: dto.operatorType as ActorType,
+ operatorId: BigInt(dto.operatorId),
+ },
+ });
+ return serializeBigInt(ticket);
+ }
+}
diff --git a/server/dukang-api/src/modules/common/wechat.controller.ts b/server/dukang-api/src/modules/common/wechat.controller.ts
new file mode 100644
index 0000000..6691df6
--- /dev/null
+++ b/server/dukang-api/src/modules/common/wechat.controller.ts
@@ -0,0 +1,43 @@
+import { BadRequestException, Body, Controller, Get, Inject, Post, Query } from '@nestjs/common';
+import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
+import { WECHAT_PROVIDER } from '../../integrations/integrations.constants';
+import type { IWechatProvider } from '../../integrations/wechat/wechat.interface';
+
+class PhoneNumberDto {
+ @IsString()
+ @IsNotEmpty()
+ code: string;
+
+ @IsString()
+ @IsIn(['mini', 'h5'])
+ @IsOptional()
+ platform?: 'mini' | 'h5';
+}
+
+@Controller('common/wechat')
+export class WechatController {
+ constructor(@Inject(WECHAT_PROVIDER) private readonly wechat: IWechatProvider) {}
+
+ @Get('jssdk-config')
+ async jssdkConfig(@Query('url') url: string) {
+ if (!url) throw new BadRequestException('url 参数必填');
+ return this.wechat.createJssdkConfig(url);
+ }
+
+ @Get('oauth-url')
+ oauthUrl(
+ @Query('redirectUri') redirectUri: string,
+ @Query('state') state: string,
+ @Query('scope') scope?: string,
+ ) {
+ if (!redirectUri || !state) throw new BadRequestException('redirectUri 与 state 必填');
+ return { url: this.wechat.buildOAuthUrl(redirectUri, state, scope) };
+ }
+
+ @Post('phone-number')
+ phoneNumber(@Body() dto: PhoneNumberDto) {
+ return this.wechat
+ .getPhoneNumberByCode(dto.code, dto.platform ?? 'mini')
+ .then((phone) => ({ phone }));
+ }
+}
diff --git a/server/dukang-api/src/modules/iam/auth.controller.ts b/server/dukang-api/src/modules/iam/auth.controller.ts
index 73128f2..4ff3d71 100644
--- a/server/dukang-api/src/modules/iam/auth.controller.ts
+++ b/server/dukang-api/src/modules/iam/auth.controller.ts
@@ -3,8 +3,10 @@ import type { Request } from 'express';
import { AuthService } from './auth.service';
import {
BindPhoneDto,
+ BindWechatPhoneDto,
BootstrapSessionDto,
LoginSmsDto,
+ LoginWechatDto,
RefreshTokenDto,
SendSmsDto,
} from './dto/auth.dto';
@@ -48,13 +50,26 @@ export class UserAuthController {
}
@Post('auth/login/wechat')
- wechatLogin() {
- return this.authService.wechatDisabled();
+ @UseGuards(OptionalJwtAuthGuard)
+ wechatLogin(@Req() req: Request, @Body() dto: LoginWechatDto) {
+ const guest = (req as Request & { user?: AuthUser }).user;
+ const guestId = guest?.actorType === 'USER' ? guest.actorId : undefined;
+ return this.authService.loginUserWechat(
+ dto.code,
+ ClientApp.USER_H5,
+ dto.platform ?? 'h5',
+ guestId,
+ );
}
@Post('auth/wechat/bind-phone')
- bindPhoneLegacy() {
- return this.authService.wechatDisabled();
+ bindPhoneLegacy(@Body() dto: BindWechatPhoneDto) {
+ return this.authService.bindWechatPhone(
+ dto.wxSessionKey,
+ dto.phone,
+ dto.code,
+ ClientApp.USER_H5,
+ );
}
@Get('auth/me')
@@ -79,8 +94,8 @@ export class ShopAuthController {
}
@Post('login/wechat')
- wechatLogin() {
- return this.authService.wechatDisabled();
+ wechatLogin(@Body() dto: LoginWechatDto) {
+ return this.authService.loginStoreWechat(dto.code, ClientApp.SHOP_H5, dto.platform ?? 'h5');
}
}
@@ -99,8 +114,8 @@ export class PartnerAuthController {
}
@Post('login/wechat')
- wechatLogin() {
- return this.authService.wechatDisabled();
+ wechatLogin(@Body() dto: LoginWechatDto) {
+ return this.authService.loginPartnerWechat(dto.code, ClientApp.PARTNER_H5, dto.platform ?? 'h5');
}
}
diff --git a/server/dukang-api/src/modules/iam/auth.service.ts b/server/dukang-api/src/modules/iam/auth.service.ts
index dd63efa..c906dc7 100644
--- a/server/dukang-api/src/modules/iam/auth.service.ts
+++ b/server/dukang-api/src/modules/iam/auth.service.ts
@@ -12,13 +12,23 @@ import { JwtService } from '@nestjs/jwt';
import { ClientApp, SmsScene } from '@dukang/shared-types';
import { generateUserNo } from '@dukang/domain';
import { PrismaService } from '../../common/prisma/prisma.module';
-import { SMS_PROVIDER } from '../../integrations/integrations.constants';
+import { RedisService } from '../../common/redis/redis.service';
+import { SMS_PROVIDER, WECHAT_PROVIDER } from '../../integrations/integrations.constants';
import { ISmsProvider } from '../../integrations/sms/sms.interface';
+import type { IWechatProvider } from '../../integrations/wechat/wechat.interface';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import type { Prisma } from '@prisma/client';
import type { User } from '@prisma/client';
+type WxSessionPayload = {
+ openId: string;
+ unionId?: string;
+ sessionKey?: string;
+ clientApp: ClientApp;
+ guestId?: string;
+};
+
type UserRow = Pick<
User,
| 'id'
@@ -27,18 +37,23 @@ type UserRow = Pick<
| 'phone'
| 'phoneVerifiedAt'
| 'nickname'
- | 'avatarUrl'
+ | 'avatarResourceId'
| 'wxOpenId'
+ | 'wxUnionId'
| 'mergedIntoUserId'
| 'status'
->;
+> & {
+ avatar?: { url: string } | null;
+};
@Injectable()
export class AuthService {
constructor(
private readonly prisma: PrismaService,
private readonly jwtService: JwtService,
+ private readonly redis: RedisService,
@Inject(SMS_PROVIDER) private readonly smsProvider: ISmsProvider,
+ @Inject(WECHAT_PROVIDER) private readonly wechatProvider: IWechatProvider,
) {}
async sendSms(phone: string, scene: string) {
@@ -57,6 +72,7 @@ export class AuthService {
status: 1,
mergedIntoUserId: null,
},
+ include: { avatar: true },
});
}
@@ -67,13 +83,14 @@ export class AuthService {
userNo: generateUserNo(),
deviceKey: resolvedDeviceKey,
nickname: '访客',
- cityPref: {
+ cityPreference: {
create: {
selectedCityCode: '410100',
selectedDistrict: '郑州市',
},
},
},
+ include: { avatar: true },
});
}
@@ -96,7 +113,10 @@ export class AuthService {
async loginUser(phone: string, code: string, clientApp: ClientApp, guestId?: bigint) {
await this.smsProvider.verify(phone, code, SmsScene.USER_LOGIN);
- let user: UserRow | null = await this.prisma.user.findUnique({ where: { phone } });
+ let user: UserRow | null = await this.prisma.user.findUnique({
+ where: { phone },
+ include: { avatar: true },
+ });
if (!user) {
if (guestId) {
@@ -110,6 +130,7 @@ export class AuthService {
phoneVerifiedAt: new Date(),
nickname: guest.nickname === '访客' ? `用户${phone.slice(-4)}` : guest.nickname,
},
+ include: { avatar: true },
});
}
} catch {
@@ -123,13 +144,14 @@ export class AuthService {
phoneVerifiedAt: new Date(),
userNo: generateUserNo(),
nickname: `用户${phone.slice(-4)}`,
- cityPref: {
+ cityPreference: {
create: {
selectedCityCode: '410100',
selectedDistrict: '郑州市',
},
},
},
+ include: { avatar: true },
});
}
} else {
@@ -137,6 +159,7 @@ export class AuthService {
user = await this.prisma.user.update({
where: { id: user.id },
data: { phoneVerifiedAt: new Date() },
+ include: { avatar: true },
});
}
if (guestId && guestId !== user.id) {
@@ -173,6 +196,7 @@ export class AuthService {
phoneVerifiedAt: new Date(),
nickname: guest.nickname === '访客' ? `用户${phone.slice(-4)}` : guest.nickname,
},
+ include: { avatar: true },
});
} else {
await this.assertActiveUser(existing.id);
@@ -275,6 +299,242 @@ export class AuthService {
throw new NotImplementedException('FEATURE_DISABLED');
}
+ assertWechatEnabled() {
+ if (!this.wechatProvider.isEnabled()) {
+ this.wechatDisabled();
+ }
+ }
+
+ async loginUserWechat(
+ code: string,
+ clientApp: ClientApp,
+ platform: 'h5' | 'mini' = 'h5',
+ guestId?: bigint,
+ ) {
+ this.assertWechatEnabled();
+ const session =
+ platform === 'mini'
+ ? await this.wechatProvider.code2Session(code)
+ : await this.wechatProvider.oauth2AccessToken(code);
+
+ let user = await this.prisma.user.findFirst({
+ where: { wxOpenId: session.openId, status: 1, mergedIntoUserId: null },
+ include: { avatar: true },
+ });
+
+ if (user?.phone && user.phoneVerifiedAt) {
+ let activeUser: UserRow =
+ guestId && guestId !== user.id ? await this.mergeUsers(guestId, user.id) : (user as UserRow);
+ activeUser = await this.prisma.user.update({
+ where: { id: activeUser.id },
+ data: {
+ wxUnionId: session.unionId ?? activeUser.wxUnionId,
+ },
+ include: { avatar: true },
+ });
+ return this.buildSessionResponse(activeUser, clientApp, activeUser.deviceKey);
+ }
+
+ const wxSessionKey = randomUUID();
+ await this.redis.setJson(
+ `wx:session:${wxSessionKey}`,
+ {
+ openId: session.openId,
+ unionId: session.unionId,
+ sessionKey: 'sessionKey' in session ? session.sessionKey : undefined,
+ clientApp,
+ guestId: guestId?.toString(),
+ } satisfies WxSessionPayload,
+ 1800,
+ );
+
+ if (user && !user.phoneVerifiedAt) {
+ return {
+ needBindPhone: true,
+ wxSessionKey,
+ actorType: 'USER',
+ actorId: user.id.toString(),
+ phoneVerified: false,
+ user: this.formatUserProfile(user),
+ };
+ }
+
+ return {
+ needBindPhone: true,
+ wxSessionKey,
+ phoneVerified: false,
+ };
+ }
+
+ async bindWechatPhone(
+ wxSessionKey: string,
+ phone: string,
+ code: string,
+ clientApp: ClientApp,
+ ) {
+ this.assertWechatEnabled();
+ await this.smsProvider.verify(phone, code, SmsScene.BIND_PHONE);
+
+ const wxSession = await this.redis.getJson(`wx:session:${wxSessionKey}`);
+ if (!wxSession) throw new BadRequestException('微信会话已过期,请重新授权');
+
+ const guestId = wxSession.guestId ? BigInt(wxSession.guestId) : undefined;
+ const wxUser = await this.prisma.user.findFirst({
+ where: { wxOpenId: wxSession.openId, status: 1, mergedIntoUserId: null },
+ include: { avatar: true },
+ });
+
+ const existingPhone = await this.prisma.user.findUnique({
+ where: { phone },
+ include: { avatar: true },
+ });
+
+ let targetUserId: bigint | null = wxUser?.id ?? null;
+
+ if (!wxUser && !existingPhone) {
+ if (guestId) {
+ try {
+ const guest = await this.assertActiveUser(guestId);
+ const updated = await this.prisma.user.update({
+ where: { id: guest.id },
+ data: {
+ phone,
+ phoneVerifiedAt: new Date(),
+ wxOpenId: wxSession.openId,
+ wxUnionId: wxSession.unionId,
+ nickname: guest.nickname === '访客' ? `用户${phone.slice(-4)}` : guest.nickname,
+ },
+ });
+ targetUserId = updated.id;
+ } catch {
+ /* fall through */
+ }
+ }
+ if (!targetUserId) {
+ const created = await this.prisma.user.create({
+ data: {
+ phone,
+ phoneVerifiedAt: new Date(),
+ wxOpenId: wxSession.openId,
+ wxUnionId: wxSession.unionId,
+ userNo: generateUserNo(),
+ nickname: `用户${phone.slice(-4)}`,
+ cityPreference: {
+ create: {
+ selectedCityCode: '410100',
+ selectedDistrict: '郑州市',
+ },
+ },
+ },
+ });
+ targetUserId = created.id;
+ }
+ } else if (existingPhone) {
+ await this.assertActiveUser(existingPhone.id);
+ const updated = await this.prisma.user.update({
+ where: { id: existingPhone.id },
+ data: {
+ wxOpenId: wxSession.openId,
+ wxUnionId: wxSession.unionId,
+ phoneVerifiedAt: existingPhone.phoneVerifiedAt ?? new Date(),
+ },
+ });
+ targetUserId = updated.id;
+ if (guestId && guestId !== updated.id) {
+ targetUserId = (await this.mergeUsers(guestId, updated.id)).id;
+ }
+ } else if (wxUser) {
+ if (wxUser.phone && wxUser.phone !== phone) {
+ throw new BadRequestException('手机号已被其他账号占用');
+ }
+ const updated = await this.prisma.user.update({
+ where: { id: wxUser.id },
+ data: {
+ phone,
+ phoneVerifiedAt: new Date(),
+ wxUnionId: wxSession.unionId ?? wxUser.wxUnionId,
+ },
+ });
+ targetUserId = updated.id;
+ }
+
+ if (!targetUserId) throw new BadRequestException('绑定失败');
+ const user = await this.assertActiveUser(targetUserId);
+ await this.redis.del(`wx:session:${wxSessionKey}`);
+ return this.buildSessionResponse(user, clientApp, user.deviceKey);
+ }
+
+ async loginStoreWechat(code: string, clientApp: ClientApp, platform: 'h5' | 'mini' = 'h5') {
+ this.assertWechatEnabled();
+ const session =
+ platform === 'mini'
+ ? await this.wechatProvider.code2Session(code)
+ : await this.wechatProvider.oauth2AccessToken(code);
+
+ let account = await this.prisma.storeAccount.findFirst({
+ where: { wxOpenId: session.openId },
+ include: { store: true },
+ });
+
+ if (!account) {
+ throw new BadRequestException('首次登录请使用手机验证码,登录后将自动关联微信');
+ }
+
+ account = await this.prisma.storeAccount.update({
+ where: { id: account.id },
+ data: {
+ wxOpenId: session.openId,
+ wxUnionId: session.unionId ?? account.wxUnionId,
+ lastLoginAt: new Date(),
+ },
+ include: { store: true },
+ });
+
+ return this.issueToken('STORE', account.id, clientApp, false, undefined, {
+ id: account.id.toString(),
+ storeId: account.storeId.toString(),
+ name: account.name,
+ phone: account.phone,
+ storeName: account.store.name,
+ });
+ }
+
+ async loginPartnerWechat(code: string, clientApp: ClientApp, platform: 'h5' | 'mini' = 'h5') {
+ this.assertWechatEnabled();
+ const session =
+ platform === 'mini'
+ ? await this.wechatProvider.code2Session(code)
+ : await this.wechatProvider.oauth2AccessToken(code);
+
+ let account = await this.prisma.partnerAccount.findFirst({
+ where: { wxOpenId: session.openId },
+ include: { partner: true },
+ });
+
+ if (!account) {
+ throw new BadRequestException('首次登录请使用手机验证码,登录后将自动关联微信');
+ }
+
+ account = await this.prisma.partnerAccount.update({
+ where: { id: account.id },
+ data: {
+ wxOpenId: session.openId,
+ wxUnionId: session.unionId ?? account.wxUnionId,
+ lastLoginAt: new Date(),
+ },
+ include: { partner: true },
+ });
+
+ return this.issueToken('PARTNER', account.id, clientApp, false, undefined, undefined, {
+ id: account.id.toString(),
+ partnerId: account.partnerId.toString(),
+ name: account.name,
+ phone: account.phone,
+ isPrimary: account.isPrimary === 1,
+ companyName: account.partner.companyName,
+ });
+ }
+
private async mergeUsers(guestId: bigint, primaryId: bigint): Promise {
if (guestId === primaryId) {
return this.assertActiveUser(primaryId);
@@ -293,9 +553,12 @@ export class AuthService {
await tx.order.updateMany({ where: { userId: guestId }, data: { userId: primaryId } });
await tx.userAddress.updateMany({ where: { userId: guestId }, data: { userId: primaryId } });
await tx.benefitCoupon.updateMany({ where: { userId: guestId }, data: { userId: primaryId } });
- await tx.benefitLedger.updateMany({ where: { userId: guestId }, data: { userId: primaryId } });
+ await tx.commonEvent.updateMany({
+ where: { actorType: 'USER', actorId: guestId },
+ data: { actorId: primaryId },
+ });
await tx.redeemRecord.updateMany({ where: { userId: guestId }, data: { userId: primaryId } });
- await tx.eventLog.updateMany({ where: { userId: guestId }, data: { userId: primaryId } });
+ await tx.logUserAnalytics.updateMany({ where: { userId: guestId }, data: { userId: primaryId } });
const primaryPref = await tx.userCityPreference.findUnique({ where: { userId: primaryId } });
const guestPref = await tx.userCityPreference.findUnique({ where: { userId: guestId } });
@@ -343,7 +606,10 @@ export class AuthService {
}
private async assertActiveUser(userId: bigint): Promise {
- const user = await this.prisma.user.findUnique({ where: { id: userId } });
+ const user = await this.prisma.user.findUnique({
+ where: { id: userId },
+ include: { avatar: true },
+ });
if (!user) throw new NotFoundException('用户不存在');
if (user.mergedIntoUserId) {
throw new UnauthorizedException('账号已合并,请重新进入');
@@ -366,7 +632,7 @@ export class AuthService {
phone: user.phone ? user.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2') : null,
phoneVerified: !!user.phoneVerifiedAt,
nickname: user.nickname,
- avatarUrl: user.avatarUrl,
+ avatarUrl: user.avatar?.url ?? null,
hasWechat: !!user.wxOpenId,
};
}
diff --git a/server/dukang-api/src/modules/iam/dto/auth.dto.ts b/server/dukang-api/src/modules/iam/dto/auth.dto.ts
index bb17ab6..e61e9e7 100644
--- a/server/dukang-api/src/modules/iam/dto/auth.dto.ts
+++ b/server/dukang-api/src/modules/iam/dto/auth.dto.ts
@@ -1,4 +1,4 @@
-import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
+import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class SendSmsDto {
@IsString()
@@ -41,3 +41,28 @@ export class BindPhoneDto {
@IsNotEmpty()
code: string;
}
+
+export class LoginWechatDto {
+ @IsString()
+ @IsNotEmpty()
+ code: string;
+
+ @IsString()
+ @IsIn(['h5', 'mini'])
+ @IsOptional()
+ platform?: 'h5' | 'mini';
+}
+
+export class BindWechatPhoneDto {
+ @IsString()
+ @IsNotEmpty()
+ wxSessionKey: string;
+
+ @IsString()
+ @IsNotEmpty()
+ phone: string;
+
+ @IsString()
+ @IsNotEmpty()
+ code: string;
+}
diff --git a/server/dukang-api/src/modules/ops/admin-benefit.service.ts b/server/dukang-api/src/modules/ops/admin-benefit.service.ts
index 95a1a79..4a7cf80 100644
--- a/server/dukang-api/src/modules/ops/admin-benefit.service.ts
+++ b/server/dukang-api/src/modules/ops/admin-benefit.service.ts
@@ -2,6 +2,8 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
+import { buildBenefitLedgerEvent, benefitLedgerWhere } from '../../common/event/event.helpers';
+import { mapBenefitLedgerCompat } from '../../common/compat/v31-compat';
import type { AdminBenefitCouponsQueryDto, AdminBenefitLedgersQueryDto } from './dto/admin-query.dto';
@Injectable()
@@ -38,12 +40,16 @@ export class AdminBenefitService {
include: {
user: { select: { id: true, userNo: true, phone: true, nickname: true } },
order: { select: { id: true, orderNo: true, status: true, payAmount: true } },
- ledgers: { orderBy: { createdAt: 'desc' }, take: 20 },
redeemRecords: { orderBy: { createdAt: 'desc' }, take: 10, include: { store: { select: { id: true, name: true } } } },
},
});
if (!coupon) throw new NotFoundException('权益券不存在');
- return serializeBigInt(coupon);
+ const ledgers = await this.prisma.commonEvent.findMany({
+ where: benefitLedgerWhere(undefined, id),
+ orderBy: { createdAt: 'desc' },
+ take: 20,
+ });
+ return serializeBigInt({ ...coupon, ledgers });
}
async voidCoupon(id: bigint) {
@@ -57,8 +63,8 @@ export class AdminBenefitService {
data: { status: 'VOID', balance: 0 },
});
if (Number(coupon.balance) > 0) {
- await tx.benefitLedger.create({
- data: {
+ await tx.commonEvent.create({
+ data: buildBenefitLedgerEvent({
userId: coupon.userId,
couponId: coupon.id,
type: 'ADJUST',
@@ -66,7 +72,7 @@ export class AdminBenefitService {
balanceAfter: 0,
refType: 'ADMIN_VOID',
remark: 'HQ 手动作废',
- },
+ }),
});
}
return row;
@@ -77,24 +83,47 @@ export class AdminBenefitService {
async listLedgers(query: AdminBenefitLedgersQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
- const where: Prisma.BenefitLedgerWhereInput = {};
- if (query.userId) where.userId = BigInt(query.userId);
- if (query.couponId) where.couponId = BigInt(query.couponId);
- if (query.type) where.type = query.type as Prisma.EnumBenefitLedgerTypeFilter['equals'];
+ const where: Prisma.CommonEventWhereInput = {
+ eventType: 'BENEFIT_LEDGER',
+ ...(query.userId ? { actorType: 'USER', actorId: BigInt(query.userId) } : {}),
+ ...(query.couponId ? { param2: BigInt(query.couponId).toString() } : {}),
+ ...(query.type ? { param1: query.type } : {}),
+ };
const [items, total] = await Promise.all([
- this.prisma.benefitLedger.findMany({
+ this.prisma.commonEvent.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
- include: {
- user: { select: { id: true, userNo: true } },
- coupon: { select: { id: true, couponNo: true } },
- },
}),
- this.prisma.benefitLedger.count({ where }),
+ this.prisma.commonEvent.count({ where }),
]);
- return serializeBigInt({ items, total, page, pageSize });
+
+ const userIds = [...new Set(items.map((i) => i.actorId).filter(Boolean))] as bigint[];
+ const couponIds = [...new Set(items.map((i) => i.param2).filter(Boolean))].map((id) => BigInt(id!));
+ const [users, coupons] = await Promise.all([
+ userIds.length
+ ? this.prisma.user.findMany({ where: { id: { in: userIds } }, select: { id: true, userNo: true } })
+ : Promise.resolve([] as { id: bigint; userNo: string | null }[]),
+ couponIds.length
+ ? this.prisma.benefitCoupon.findMany({ where: { id: { in: couponIds } }, select: { id: true, couponNo: true } })
+ : Promise.resolve([] as { id: bigint; couponNo: string }[]),
+ ]);
+ const userMap = new Map(users.map((u) => [u.id.toString(), u] as const));
+ const couponMap = new Map(coupons.map((c) => [c.id.toString(), c] as const));
+
+ return serializeBigInt({
+ items: items.map((e) =>
+ mapBenefitLedgerCompat(
+ e,
+ e.actorId ? userMap.get(e.actorId.toString()) : null,
+ e.param2 ? couponMap.get(e.param2) : null,
+ ),
+ ),
+ total,
+ page,
+ pageSize,
+ });
}
}
diff --git a/server/dukang-api/src/modules/ops/admin-cities.service.ts b/server/dukang-api/src/modules/ops/admin-cities.service.ts
index 92c3f4d..681ed40 100644
--- a/server/dukang-api/src/modules/ops/admin-cities.service.ts
+++ b/server/dukang-api/src/modules/ops/admin-cities.service.ts
@@ -12,14 +12,14 @@ export class AdminCitiesService {
async list(query: AdminCitiesQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
- const where: Prisma.CityWhereInput = {};
+ const where: Prisma.CommonCityWhereInput = {};
if (query.name) where.name = { contains: query.name };
if (query.code) where.code = { contains: query.code };
if (query.status) where.status = query.status as Prisma.EnumCityStatusFilter['equals'];
if (query.partnerId) where.partnerId = BigInt(query.partnerId);
const [items, total] = await Promise.all([
- this.prisma.city.findMany({
+ this.prisma.commonCity.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
@@ -29,7 +29,7 @@ export class AdminCitiesService {
_count: { select: { stores: true, orders: true } },
},
}),
- this.prisma.city.count({ where }),
+ this.prisma.commonCity.count({ where }),
]);
return serializeBigInt({
items: items.map((c) => ({
@@ -45,7 +45,7 @@ export class AdminCitiesService {
}
async detail(id: bigint) {
- const city = await this.prisma.city.findUnique({
+ const city = await this.prisma.commonCity.findUnique({
where: { id },
include: {
partner: true,
@@ -58,9 +58,9 @@ export class AdminCitiesService {
}
async create(dto: CreateCityDto) {
- const exists = await this.prisma.city.findUnique({ where: { code: dto.code } });
+ const exists = await this.prisma.commonCity.findUnique({ where: { code: dto.code } });
if (exists) throw new BadRequestException('城市编码已存在');
- const city = await this.prisma.city.create({
+ const city = await this.prisma.commonCity.create({
data: {
code: dto.code,
name: dto.name,
@@ -79,7 +79,7 @@ export class AdminCitiesService {
}
async update(id: bigint, dto: UpdateCityDto) {
- const city = await this.prisma.city.update({
+ const city = await this.prisma.commonCity.update({
where: { id },
data: {
...(dto.name !== undefined ? { name: dto.name } : {}),
diff --git a/server/dukang-api/src/modules/ops/admin-orders.controller.ts b/server/dukang-api/src/modules/ops/admin-orders.controller.ts
index 1435f59..7bab823 100644
--- a/server/dukang-api/src/modules/ops/admin-orders.controller.ts
+++ b/server/dukang-api/src/modules/ops/admin-orders.controller.ts
@@ -1,6 +1,7 @@
-import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
+import { Body, Controller, Get, Param, Put, Query, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { AdminOrdersService } from './admin-orders.service';
+import { UpdateOrderStatusDto } from './dto/admin-mutate.dto';
import { AdminOrdersQueryDto } from './dto/admin-query.dto';
@Controller('admin/orders')
@@ -17,4 +18,10 @@ export class AdminOrdersController {
detail(@Param('id') id: string) {
return this.ordersService.detail(BigInt(id));
}
+
+ /** preV1 调试:直接改订单状态,不走业务校验 */
+ @Put(':id/status')
+ updateStatus(@Param('id') id: string, @Body() dto: UpdateOrderStatusDto) {
+ return this.ordersService.updateStatusDebug(BigInt(id), dto.status);
+ }
}
diff --git a/server/dukang-api/src/modules/ops/admin-orders.service.ts b/server/dukang-api/src/modules/ops/admin-orders.service.ts
index ad63bbd..7b974b8 100644
--- a/server/dukang-api/src/modules/ops/admin-orders.service.ts
+++ b/server/dukang-api/src/modules/ops/admin-orders.service.ts
@@ -2,11 +2,17 @@ import { Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
+import { orderStatusLogWhere } from '../../common/event/event.helpers';
+import { mapOrderCompat, mapStatusLogCompat } from '../../common/compat/v31-compat';
+import { TradeService } from '../trade/trade.service';
import type { AdminOrdersQueryDto } from './dto/admin-query.dto';
@Injectable()
export class AdminOrdersService {
- constructor(private readonly prisma: PrismaService) {}
+ constructor(
+ private readonly prisma: PrismaService,
+ private readonly tradeService: TradeService,
+ ) {}
async list(query: AdminOrdersQueryDto) {
const page = query.page ?? 1;
@@ -54,17 +60,31 @@ export class AdminOrdersService {
phoneVerifiedAt: true,
},
},
- items: true,
delivery: true,
- payment: true,
- statusLogs: { orderBy: { createdAt: 'asc' } },
- benefitCoupons: {
+ benefitCoupon: {
select: { id: true, couponNo: true, balance: true, status: true },
},
city: { select: { id: true, name: true, code: true } },
+ product: { select: { id: true, name: true, skuCode: true, barcode69: true } },
+ imageResource: { select: { id: true, url: true } },
},
});
if (!order) throw new NotFoundException('订单不存在');
- return serializeBigInt(order);
+ const statusLogs = await this.prisma.commonEvent.findMany({
+ where: orderStatusLogWhere(id),
+ orderBy: { createdAt: 'asc' },
+ });
+ return serializeBigInt(mapOrderCompat({
+ ...order,
+ statusLogs: mapStatusLogCompat(statusLogs),
+ benefitCoupons: order.benefitCoupon ? [order.benefitCoupon] : [],
+ }));
+ }
+
+ async updateStatusDebug(id: bigint, status: string) {
+ const order = await this.prisma.order.findUnique({ where: { id } });
+ if (!order) throw new NotFoundException('订单不存在');
+ await this.tradeService.applyStatusTransition(id, order.status, status, 'HQ_DEBUG');
+ return this.detail(id);
}
}
diff --git a/server/dukang-api/src/modules/ops/admin-products.controller.ts b/server/dukang-api/src/modules/ops/admin-products.controller.ts
new file mode 100644
index 0000000..efbea21
--- /dev/null
+++ b/server/dukang-api/src/modules/ops/admin-products.controller.ts
@@ -0,0 +1,31 @@
+import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
+import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
+import { AdminProductsService } from './admin-products.service';
+import { AdminProductsQueryDto } from './dto/admin-query.dto';
+import { CreateProductDto, UpdateProductDto } from './dto/admin-mutate.dto';
+
+@Controller('admin/products')
+@UseGuards(HqAuthGuard)
+export class AdminProductsController {
+ constructor(private readonly service: AdminProductsService) {}
+
+ @Get()
+ list(@Query() query: AdminProductsQueryDto) {
+ return this.service.list(query);
+ }
+
+ @Get(':id')
+ detail(@Param('id') id: string) {
+ return this.service.detail(BigInt(id));
+ }
+
+ @Post()
+ create(@Body() dto: CreateProductDto) {
+ return this.service.create(dto);
+ }
+
+ @Put(':id')
+ update(@Param('id') id: string, @Body() dto: UpdateProductDto) {
+ return this.service.update(BigInt(id), dto);
+ }
+}
diff --git a/server/dukang-api/src/modules/ops/admin-products.service.ts b/server/dukang-api/src/modules/ops/admin-products.service.ts
new file mode 100644
index 0000000..f8e730e
--- /dev/null
+++ b/server/dukang-api/src/modules/ops/admin-products.service.ts
@@ -0,0 +1,135 @@
+import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
+import { Prisma } from '@prisma/client';
+import { PrismaService } from '../../common/prisma/prisma.module';
+import { serializeBigInt } from '../../common/decorators/current-user.decorator';
+import type { AdminProductsQueryDto } from './dto/admin-query.dto';
+import type { CreateProductDto, UpdateProductDto } from './dto/admin-mutate.dto';
+
+@Injectable()
+export class AdminProductsService {
+ constructor(private readonly prisma: PrismaService) {}
+
+ async list(query: AdminProductsQueryDto) {
+ const page = query.page ?? 1;
+ const pageSize = query.pageSize ?? 20;
+ const where: Prisma.CommonProductItemWhereInput = {};
+ if (query.name) where.name = { contains: query.name };
+ if (query.status) where.status = query.status as Prisma.EnumProductStatusFilter['equals'];
+ if (query.aromaType) where.aromaType = query.aromaType as Prisma.EnumAromaTypeFilter['equals'];
+
+ const [items, total] = await Promise.all([
+ this.prisma.commonProductItem.findMany({
+ where,
+ orderBy: { sortOrder: 'asc' },
+ skip: (page - 1) * pageSize,
+ take: pageSize,
+ include: { coverResource: true },
+ }),
+ this.prisma.commonProductItem.count({ where }),
+ ]);
+ return serializeBigInt({
+ items: items.map((p) => ({
+ ...p,
+ mainImageUrl: p.coverResource?.url ?? null,
+ })),
+ total,
+ page,
+ pageSize,
+ });
+ }
+
+ async detail(id: bigint) {
+ const product = await this.prisma.commonProductItem.findUnique({
+ where: { id },
+ include: { coverResource: true },
+ });
+ if (!product) throw new NotFoundException('商品不存在');
+ return serializeBigInt({ ...product, mainImageUrl: product.coverResource?.url ?? null });
+ }
+
+ async create(dto: CreateProductDto) {
+ const exists = await this.prisma.commonProductItem.findFirst({
+ where: { OR: [{ skuCode: dto.skuCode }, { barcode69: dto.barcode69 }] },
+ });
+ if (exists) throw new BadRequestException('SKU 或 69 码已存在');
+
+ const product = await this.prisma.commonProductItem.create({
+ data: {
+ skuCode: dto.skuCode,
+ barcode69: dto.barcode69,
+ name: dto.name,
+ subtitle: dto.subtitle,
+ aromaType: dto.aromaType as 'QINGXIANG' | 'JIANGXIANG' | 'NONGXIANG',
+ spec: dto.spec,
+ price: dto.price,
+ benefitAmount: dto.benefitAmount ?? dto.price,
+ status: (dto.status ?? 'DRAFT') as 'DRAFT' | 'ON_SALE' | 'OFF_SALE',
+ sortOrder: dto.sortOrder ?? 0,
+ },
+ });
+
+ if (dto.coverUrl) {
+ const cover = await this.prisma.commonResource.create({
+ data: {
+ ownerType: 'PRODUCT',
+ ownerId: product.id,
+ bizType: 'COVER',
+ mediaType: 'IMAGE',
+ ossBucket: 'legacy',
+ ossKey: dto.coverUrl,
+ url: dto.coverUrl,
+ },
+ });
+ await this.prisma.commonProductItem.update({
+ where: { id: product.id },
+ data: { coverResourceId: cover.id },
+ });
+ }
+
+ return this.detail(product.id);
+ }
+
+ async update(id: bigint, dto: UpdateProductDto) {
+ await this.detail(id);
+ await this.prisma.commonProductItem.update({
+ where: { id },
+ data: {
+ ...(dto.name !== undefined ? { name: dto.name } : {}),
+ ...(dto.subtitle !== undefined ? { subtitle: dto.subtitle } : {}),
+ ...(dto.spec !== undefined ? { spec: dto.spec } : {}),
+ ...(dto.price !== undefined ? { price: dto.price } : {}),
+ ...(dto.benefitAmount !== undefined ? { benefitAmount: dto.benefitAmount } : {}),
+ ...(dto.status !== undefined ? { status: dto.status as 'DRAFT' | 'ON_SALE' | 'OFF_SALE' } : {}),
+ ...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}),
+ },
+ });
+
+ if (dto.coverUrl) {
+ const product = await this.prisma.commonProductItem.findUniqueOrThrow({ where: { id } });
+ if (product.coverResourceId) {
+ await this.prisma.commonResource.update({
+ where: { id: product.coverResourceId },
+ data: { url: dto.coverUrl, ossKey: dto.coverUrl },
+ });
+ } else {
+ const cover = await this.prisma.commonResource.create({
+ data: {
+ ownerType: 'PRODUCT',
+ ownerId: id,
+ bizType: 'COVER',
+ mediaType: 'IMAGE',
+ ossBucket: 'legacy',
+ ossKey: dto.coverUrl,
+ url: dto.coverUrl,
+ },
+ });
+ await this.prisma.commonProductItem.update({
+ where: { id },
+ data: { coverResourceId: cover.id },
+ });
+ }
+ }
+
+ return this.detail(id);
+ }
+}
diff --git a/server/dukang-api/src/modules/ops/admin-redeem.service.ts b/server/dukang-api/src/modules/ops/admin-redeem.service.ts
index b1ba17a..57280b8 100644
--- a/server/dukang-api/src/modules/ops/admin-redeem.service.ts
+++ b/server/dukang-api/src/modules/ops/admin-redeem.service.ts
@@ -1,4 +1,5 @@
import { Injectable, NotFoundException } from '@nestjs/common';
+import type { DeliveryProvider } from '@prisma/client';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
@@ -42,7 +43,6 @@ export class AdminRedeemService {
store: { include: { partner: { select: { id: true, companyName: true } } } },
coupon: true,
payout: true,
- commissions: true,
},
});
if (!record) throw new NotFoundException('核销记录不存在');
@@ -58,7 +58,7 @@ export class AdminDeliveriesService {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.OrderDeliveryWhereInput = {};
- if (query.provider) where.provider = query.provider;
+ if (query.provider) where.provider = query.provider as DeliveryProvider;
if (query.trackingNo) where.trackingNo = { contains: query.trackingNo };
if (query.orderNo) {
where.order = { orderNo: { contains: query.orderNo } };
@@ -79,6 +79,8 @@ export class AdminDeliveriesService {
receiverName: true,
receiverPhone: true,
deliveryType: true,
+ productName: true,
+ quantity: true,
},
},
},
@@ -95,7 +97,7 @@ export class AdminDeliveriesService {
order: {
include: {
user: { select: { id: true, userNo: true, phone: true } },
- items: true,
+ imageResource: { select: { url: true } },
},
},
},
@@ -108,7 +110,7 @@ export class AdminDeliveriesService {
const delivery = await this.prisma.orderDelivery.update({
where: { id },
data: {
- ...(dto.provider !== undefined ? { provider: dto.provider } : {}),
+ ...(dto.provider !== undefined ? { provider: dto.provider as DeliveryProvider } : {}),
...(dto.providerOrderNo !== undefined ? { providerOrderNo: dto.providerOrderNo } : {}),
...(dto.trackingNo !== undefined ? { trackingNo: dto.trackingNo } : {}),
},
diff --git a/server/dukang-api/src/modules/ops/admin-stores.service.ts b/server/dukang-api/src/modules/ops/admin-stores.service.ts
index d44a039..ebb5e4a 100644
--- a/server/dukang-api/src/modules/ops/admin-stores.service.ts
+++ b/server/dukang-api/src/modules/ops/admin-stores.service.ts
@@ -2,6 +2,7 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
+import { mapStoreCompat } from '../../common/compat/v31-compat';
import type { AdminStoreAccountsQueryDto, AdminStoreMediaQueryDto, AdminStoresQueryDto } from './dto/admin-query.dto';
import type {
CreateStoreAccountDto,
@@ -37,11 +38,17 @@ export class AdminStoresService {
cityRef: { select: { id: true, name: true, code: true } },
partner: { select: { id: true, companyName: true } },
account: { select: { id: true, phone: true, name: true, status: true } },
+ coverResource: { select: { id: true, url: true } },
},
}),
this.prisma.store.count({ where }),
]);
- return serializeBigInt({ items, total, page, pageSize });
+ return serializeBigInt({
+ items: items.map((s) => mapStoreCompat(s)),
+ total,
+ page,
+ pageSize,
+ });
}
async detailStore(id: bigint) {
@@ -52,18 +59,30 @@ export class AdminStoresService {
partner: true,
category: true,
account: true,
- media: { orderBy: { sortOrder: 'asc' } },
- audits: { orderBy: { submittedAt: 'desc' }, take: 5 },
+ coverResource: true,
_count: { select: { redeemRecords: true, ratings: true } },
},
});
if (!store) throw new NotFoundException('门店不存在');
- return serializeBigInt({
+ const [media, audits] = await Promise.all([
+ this.prisma.commonResource.findMany({
+ where: { ownerType: 'STORE', ownerId: id, status: 'ACTIVE' },
+ orderBy: { sortOrder: 'asc' },
+ }),
+ this.prisma.commonEvent.findMany({
+ where: { eventType: 'STORE_AUDIT', refType: 'STORE', refId: id },
+ orderBy: { createdAt: 'desc' },
+ take: 5,
+ }),
+ ]);
+ return serializeBigInt(mapStoreCompat({
...store,
+ media,
+ audits,
redeemCount: store._count.redeemRecords,
ratingCount: store._count.ratings,
_count: undefined,
- });
+ }));
}
async updateStoreStatus(id: bigint, dto: UpdateStoreStatusDto) {
@@ -81,19 +100,45 @@ export class AdminStoresService {
...(dto.name !== undefined ? { name: dto.name } : {}),
...(dto.phone !== undefined ? { phone: dto.phone } : {}),
...(dto.intro !== undefined ? { intro: dto.intro } : {}),
- ...(dto.coverUrl !== undefined ? { coverUrl: dto.coverUrl } : {}),
...(dto.address !== undefined ? { address: dto.address } : {}),
...(dto.district !== undefined ? { district: dto.district } : {}),
},
});
- return serializeBigInt(store);
+
+ if (dto.coverUrl) {
+ const current = await this.prisma.store.findUniqueOrThrow({ where: { id } });
+ if (current.coverResourceId) {
+ await this.prisma.commonResource.update({
+ where: { id: current.coverResourceId },
+ data: { url: dto.coverUrl, ossKey: dto.coverUrl },
+ });
+ } else {
+ const cover = await this.prisma.commonResource.create({
+ data: {
+ ownerType: 'STORE',
+ ownerId: id,
+ bizType: 'COVER',
+ mediaType: 'IMAGE',
+ ossBucket: 'legacy',
+ ossKey: dto.coverUrl,
+ url: dto.coverUrl,
+ },
+ });
+ await this.prisma.store.update({ where: { id }, data: { coverResourceId: cover.id } });
+ }
+ }
+
+ return this.detailStore(id);
}
async createStore(dto: CreateStoreDto) {
const partner = await this.prisma.partner.findUnique({ where: { id: BigInt(dto.partnerId) } });
if (!partner) throw new BadRequestException('开城合伙人不存在');
- const city = await this.prisma.city.findUnique({ where: { id: BigInt(dto.cityId) } });
+ const city = await this.prisma.commonCity.findUnique({ where: { id: BigInt(dto.cityId) } });
if (!city) throw new BadRequestException('开城城市不存在');
+ if (city.partnerId && city.partnerId !== partner.id) {
+ throw new BadRequestException('开城城市与合伙人不匹配');
+ }
const store = await this.prisma.store.create({
data: {
@@ -107,11 +152,73 @@ export class AdminStoresService {
district: dto.district ?? '',
address: dto.address,
intro: dto.intro ?? null,
- coverUrl: dto.coverUrl ?? null,
+ bankAccountName: dto.bankAccountName ?? null,
+ bankAccountNo: dto.bankAccountNo ?? null,
+ bankBranch: dto.bankBranch ?? null,
+ openTime: '10:00',
+ closeTime: '22:00',
status: 'OPEN',
},
});
+ if (dto.coverUrl) {
+ const cover = await this.prisma.commonResource.create({
+ data: {
+ ownerType: 'STORE',
+ ownerId: store.id,
+ bizType: 'COVER',
+ mediaType: 'IMAGE',
+ ossBucket: 'legacy',
+ ossKey: dto.coverUrl,
+ url: dto.coverUrl,
+ },
+ });
+ await this.prisma.store.update({ where: { id: store.id }, data: { coverResourceId: cover.id } });
+ }
+
+ const envUrls = (dto.envPhotoUrls ?? []).filter(Boolean);
+ for (let i = 0; i < envUrls.length; i++) {
+ await this.prisma.commonResource.create({
+ data: {
+ ownerType: 'STORE',
+ ownerId: store.id,
+ bizType: 'ENV',
+ mediaType: 'IMAGE',
+ ossBucket: 'legacy',
+ ossKey: envUrls[i],
+ url: envUrls[i],
+ sortOrder: i,
+ },
+ });
+ }
+
+ if (dto.contractUrl) {
+ await this.prisma.commonResource.create({
+ data: {
+ ownerType: 'STORE',
+ ownerId: store.id,
+ bizType: 'CONTRACT',
+ mediaType: 'FILE',
+ ossBucket: 'legacy',
+ ossKey: dto.contractUrl,
+ url: dto.contractUrl,
+ },
+ });
+ }
+
+ await this.prisma.commonEvent.create({
+ data: {
+ eventType: 'STORE_AUDIT',
+ refType: 'STORE',
+ refId: store.id,
+ actorType: 'HQ',
+ status: 'APPROVED',
+ param1: 'NEW',
+ param1Desc: 'audit_type',
+ remark: 'HQ 后台新建',
+ },
+ });
+
await this.prisma.storeAccount.create({
data: {
storeId: store.id,
@@ -120,7 +227,7 @@ export class AdminStoresService {
},
});
- return serializeBigInt(store);
+ return this.detailStore(store.id);
}
async createStoreAccount(dto: CreateStoreAccountDto) {
@@ -139,19 +246,21 @@ export class AdminStoresService {
async listStoreMedia(query: AdminStoreMediaQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
- const where: Prisma.StoreMediaWhereInput = {};
- if (query.storeId) where.storeId = BigInt(query.storeId);
- if (query.mediaType) where.mediaType = query.mediaType;
+ const where: Prisma.CommonResourceWhereInput = {
+ ownerType: 'STORE',
+ status: 'ACTIVE',
+ };
+ if (query.storeId) where.ownerId = BigInt(query.storeId);
+ if (query.mediaType) where.mediaType = query.mediaType as Prisma.EnumResourceMediaTypeFilter['equals'];
const [items, total] = await Promise.all([
- this.prisma.storeMedia.findMany({
+ this.prisma.commonResource.findMany({
where,
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
skip: (page - 1) * pageSize,
take: pageSize,
- include: { store: { select: { id: true, name: true } } },
}),
- this.prisma.storeMedia.count({ where }),
+ this.prisma.commonResource.count({ where }),
]);
return serializeBigInt({ items, total, page, pageSize });
}
@@ -159,10 +268,14 @@ export class AdminStoresService {
async createStoreMedia(dto: CreateStoreMediaDto) {
const store = await this.prisma.store.findUnique({ where: { id: BigInt(dto.storeId) } });
if (!store) throw new BadRequestException('门店不存在');
- const media = await this.prisma.storeMedia.create({
+ const media = await this.prisma.commonResource.create({
data: {
- storeId: store.id,
- mediaType: dto.mediaType,
+ ownerType: 'STORE',
+ ownerId: store.id,
+ bizType: 'ENV',
+ mediaType: dto.mediaType as 'IMAGE' | 'VIDEO',
+ ossBucket: 'legacy',
+ ossKey: dto.url,
url: dto.url,
sortOrder: dto.sortOrder ?? 0,
},
@@ -171,11 +284,11 @@ export class AdminStoresService {
}
async updateStoreMedia(id: bigint, dto: UpdateStoreMediaDto) {
- const media = await this.prisma.storeMedia.update({
+ const media = await this.prisma.commonResource.update({
where: { id },
data: {
- ...(dto.url !== undefined ? { url: dto.url } : {}),
- ...(dto.mediaType !== undefined ? { mediaType: dto.mediaType } : {}),
+ ...(dto.url !== undefined ? { url: dto.url, ossKey: dto.url } : {}),
+ ...(dto.mediaType !== undefined ? { mediaType: dto.mediaType as 'IMAGE' | 'VIDEO' } : {}),
...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}),
},
});
@@ -183,7 +296,10 @@ export class AdminStoresService {
}
async deleteStoreMedia(id: bigint) {
- await this.prisma.storeMedia.delete({ where: { id } });
+ await this.prisma.commonResource.update({
+ where: { id },
+ data: { status: 'DELETED' },
+ });
return { ok: true };
}
diff --git a/server/dukang-api/src/modules/ops/admin-users.service.ts b/server/dukang-api/src/modules/ops/admin-users.service.ts
index 4858020..24699f0 100644
--- a/server/dukang-api/src/modules/ops/admin-users.service.ts
+++ b/server/dukang-api/src/modules/ops/admin-users.service.ts
@@ -59,7 +59,7 @@ export class AdminUsersService {
const user = await this.prisma.user.findUnique({
where: { id },
include: {
- cityPref: true,
+ cityPreference: true,
mergedInto: { select: { id: true, userNo: true, phone: true, nickname: true } },
orders: {
orderBy: { createdAt: 'desc' },
diff --git a/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts
index 6b2ef85..10bf26d 100644
--- a/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts
+++ b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts
@@ -1,4 +1,4 @@
-import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
+import { IsArray, IsIn, IsNotEmpty, IsNumber, IsOptional, IsString } from 'class-validator';
export class UpdateStoreStatusDto {
@IsString()
@@ -58,6 +58,27 @@ export class CreateStoreDto {
@IsOptional()
@IsString()
accountName?: string;
+
+ @IsOptional()
+ @IsString()
+ bankAccountName?: string;
+
+ @IsOptional()
+ @IsString()
+ bankAccountNo?: string;
+
+ @IsOptional()
+ @IsString()
+ bankBranch?: string;
+
+ @IsOptional()
+ @IsArray()
+ @IsString({ each: true })
+ envPhotoUrls?: string[];
+
+ @IsOptional()
+ @IsString()
+ contractUrl?: string;
}
export class UpdateStoreDto {
@@ -270,6 +291,22 @@ export class UpdateStoreMediaDto {
sortOrder?: number;
}
+export class UpdateOrderStatusDto {
+ @IsString()
+ @IsIn([
+ 'PENDING_PAY',
+ 'PENDING_SHIP',
+ 'OUT_WAREHOUSE',
+ 'SHIPPING',
+ 'PENDING_RECEIVE',
+ 'COMPLETED',
+ 'CANCELLED',
+ 'REFUNDING',
+ 'REFUNDED',
+ ])
+ status: string;
+}
+
export class UpdateDeliveryDto {
@IsOptional()
@IsString()
@@ -311,3 +348,81 @@ export class UpdateHqAccountDto {
@IsIn(['ACTIVE', 'DISABLED'])
status?: string;
}
+
+export class CreateProductDto {
+ @IsString()
+ @IsNotEmpty()
+ skuCode: string;
+
+ @IsString()
+ @IsNotEmpty()
+ barcode69: string;
+
+ @IsString()
+ @IsNotEmpty()
+ name: string;
+
+ @IsOptional()
+ @IsString()
+ subtitle?: string;
+
+ @IsIn(['QINGXIANG', 'JIANGXIANG', 'NONGXIANG'])
+ aromaType: string;
+
+ @IsString()
+ @IsNotEmpty()
+ spec: string;
+
+ @IsNumber()
+ price: number;
+
+ @IsOptional()
+ @IsNumber()
+ benefitAmount?: number;
+
+ @IsOptional()
+ @IsIn(['DRAFT', 'ON_SALE', 'OFF_SALE'])
+ status?: string;
+
+ @IsOptional()
+ @IsNumber()
+ sortOrder?: number;
+
+ @IsOptional()
+ @IsString()
+ coverUrl?: string;
+}
+
+export class UpdateProductDto {
+ @IsOptional()
+ @IsString()
+ name?: string;
+
+ @IsOptional()
+ @IsString()
+ subtitle?: string;
+
+ @IsOptional()
+ @IsString()
+ spec?: string;
+
+ @IsOptional()
+ @IsNumber()
+ price?: number;
+
+ @IsOptional()
+ @IsNumber()
+ benefitAmount?: number;
+
+ @IsOptional()
+ @IsIn(['DRAFT', 'ON_SALE', 'OFF_SALE'])
+ status?: string;
+
+ @IsOptional()
+ @IsNumber()
+ sortOrder?: number;
+
+ @IsOptional()
+ @IsString()
+ coverUrl?: string;
+}
diff --git a/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts b/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts
index 2acfb23..e5d29c1 100644
--- a/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts
+++ b/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts
@@ -213,6 +213,20 @@ export class AdminCitiesQueryDto extends PaginationQueryDto {
partnerId?: string;
}
+export class AdminProductsQueryDto extends PaginationQueryDto {
+ @IsOptional()
+ @IsString()
+ name?: string;
+
+ @IsOptional()
+ @IsString()
+ status?: string;
+
+ @IsOptional()
+ @IsString()
+ aromaType?: string;
+}
+
export class AdminStoreMediaQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
diff --git a/server/dukang-api/src/modules/ops/ops.module.ts b/server/dukang-api/src/modules/ops/ops.module.ts
index 4ea8a99..cb04e8e 100644
--- a/server/dukang-api/src/modules/ops/ops.module.ts
+++ b/server/dukang-api/src/modules/ops/ops.module.ts
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { IamModule } from '../iam/iam.module';
+import { TradeModule } from '../trade/trade.module';
import { AdminDashboardController } from './admin-dashboard.controller';
import { AdminDashboardService } from './admin-dashboard.service';
import { AdminUsersController } from './admin-users.controller';
@@ -18,10 +19,12 @@ import { AdminRedeemRecordsController, AdminDeliveriesController } from './admin
import { AdminRedeemService, AdminDeliveriesService } from './admin-redeem.service';
import { AdminHqAccountsController } from './admin-hq-accounts.controller';
import { AdminHqAccountsService } from './admin-hq-accounts.service';
+import { AdminProductsController } from './admin-products.controller';
+import { AdminProductsService } from './admin-products.service';
import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
@Module({
- imports: [IamModule],
+ imports: [IamModule, TradeModule],
controllers: [
AdminDashboardController,
AdminUsersController,
@@ -37,6 +40,7 @@ import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
AdminRedeemRecordsController,
AdminDeliveriesController,
AdminHqAccountsController,
+ AdminProductsController,
],
providers: [
AdminDashboardService,
@@ -49,6 +53,7 @@ import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
AdminRedeemService,
AdminDeliveriesService,
AdminHqAccountsService,
+ AdminProductsService,
SuperAdminGuard,
],
})
diff --git a/server/dukang-api/src/modules/redeem/redeem.service.ts b/server/dukang-api/src/modules/redeem/redeem.service.ts
index d140ee9..0a8e088 100644
--- a/server/dukang-api/src/modules/redeem/redeem.service.ts
+++ b/server/dukang-api/src/modules/redeem/redeem.service.ts
@@ -15,6 +15,7 @@ import { PrismaService } from '../../common/prisma/prisma.module';
import { RedisService } from '../../common/redis/redis.service';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { SettlementService } from '../settlement/settlement.service';
+import { buildBenefitLedgerEvent } from '../../common/event/event.helpers';
@Injectable()
export class RedeemService {
@@ -57,17 +58,6 @@ export class RedeemService {
const token = randomBytes(16).toString('hex');
const expireAt = new Date(Date.now() + REDEEM_TOKEN_TTL_SECONDS * 1000);
- await this.prisma.redeemToken.create({
- data: {
- token,
- userId,
- couponId: primaryCouponId,
- storeId: body.storeId ? BigInt(body.storeId) : null,
- amount: body.amount,
- expireAt,
- },
- });
-
await this.redis.setJson(
`redeem:token:${token}`,
{
@@ -130,7 +120,7 @@ export class RedeemService {
}
const amount = Number(cached.amount);
- const cityRule = await this.prisma.cityCommissionRule.findFirst({
+ const cityRule = await this.prisma.commonCityCommissionRule.findFirst({
where: { city: { stores: { some: { id: account.storeId } } } },
});
const settlementRate = cityRule ? Number(cityRule.storeSettlementRate) : 0.6;
@@ -154,8 +144,8 @@ export class RedeemService {
if (updated.count === 0) throw new BadRequestException('核销失败,请重试');
const newBalance = Number(coupon.balance) - allocAmount;
- await tx.benefitLedger.create({
- data: {
+ await tx.commonEvent.create({
+ data: buildBenefitLedgerEvent({
userId: coupon.userId,
couponId: coupon.id,
type: 'REDEEM',
@@ -163,7 +153,7 @@ export class RedeemService {
balanceAfter: newBalance,
refType: 'STORE',
refId: account.storeId,
- },
+ }),
});
}
@@ -178,11 +168,6 @@ export class RedeemService {
},
});
- await tx.redeemToken.updateMany({
- where: { token: body.token },
- data: { status: 'USED', usedAt: new Date(), storeId: account.storeId },
- });
-
return redeemRecord;
});
diff --git a/server/dukang-api/src/modules/store/store.service.ts b/server/dukang-api/src/modules/store/store.service.ts
index 7636711..9811930 100644
--- a/server/dukang-api/src/modules/store/store.service.ts
+++ b/server/dukang-api/src/modules/store/store.service.ts
@@ -6,6 +6,7 @@ import {
import { loadAppConfig } from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
+import { mapStoreCompat } from '../../common/compat/v31-compat';
@Injectable()
export class StoreService {
@@ -16,39 +17,43 @@ export class StoreService {
async listOpenStores(cityCode?: string) {
const where: Record = { status: 'OPEN' };
if (cityCode) {
- const city = await this.prisma.city.findFirst({ where: { code: cityCode } });
+ const city = await this.prisma.commonCity.findFirst({ where: { code: cityCode } });
if (city) where.cityId = city.id;
}
const stores = await this.prisma.store.findMany({
where: where as never,
- include: { category: true },
+ include: { category: true, coverResource: true },
orderBy: { createdAt: 'desc' },
});
- return serializeBigInt(stores);
+ return serializeBigInt(stores.map(mapStoreCompat));
}
async getStore(id: bigint) {
const store = await this.prisma.store.findFirst({
where: { id, status: 'OPEN' },
- include: { category: true, media: true },
+ include: { category: true, coverResource: true },
});
if (!store) throw new NotFoundException('门店不存在');
- return serializeBigInt(store);
+ const media = await this.prisma.commonResource.findMany({
+ where: { ownerType: 'STORE', ownerId: id, status: 'ACTIVE', bizType: 'ENV' },
+ orderBy: { sortOrder: 'asc' },
+ });
+ return serializeBigInt(mapStoreCompat({ ...store, media }));
}
async partnerListStores(partnerAccountId: bigint) {
const account = await this.getPartnerAccount(partnerAccountId);
const stores = await this.prisma.store.findMany({
where: { partnerId: account.partnerId },
- include: { category: true, audits: { orderBy: { submittedAt: 'desc' }, take: 1 } },
+ include: { category: true, coverResource: true },
orderBy: { createdAt: 'desc' },
});
- return serializeBigInt(stores);
+ return serializeBigInt(stores.map(mapStoreCompat));
}
async createStore(partnerAccountId: bigint, body: Record) {
const account = await this.getPartnerAccount(partnerAccountId);
- const city = await this.prisma.city.findFirst({ where: { partnerId: account.partnerId } });
+ const city = await this.prisma.commonCity.findFirst({ where: { partnerId: account.partnerId } });
if (!city) throw new BadRequestException('合伙人未绑定开城');
const store = await this.prisma.store.create({
@@ -63,7 +68,7 @@ export class StoreService {
district: String(body.district ?? ''),
address: String(body.address),
intro: body.intro ? String(body.intro) : null,
- coverUrl: body.coverUrl ? String(body.coverUrl) : null,
+ coverResourceId: body.coverResourceId ? BigInt(String(body.coverResourceId)) : null,
bankAccountName: body.bankAccountName ? String(body.bankAccountName) : null,
bankAccountNo: body.bankAccountNo ? String(body.bankAccountNo) : null,
bankBranch: body.bankBranch ? String(body.bankBranch) : null,
@@ -73,13 +78,17 @@ export class StoreService {
},
});
- const audit = await this.prisma.storeAudit.create({
+ const audit = await this.prisma.commonEvent.create({
data: {
- storeId: store.id,
- auditType: 'NEW',
+ eventType: 'STORE_AUDIT',
+ refType: 'STORE',
+ refId: store.id,
+ actorType: 'PARTNER',
+ actorId: partnerAccountId,
status: this.config.autoApproveStore ? 'APPROVED' : 'PENDING',
- submitData: body as never,
- reviewedAt: this.config.autoApproveStore ? new Date() : null,
+ param1: 'NEW',
+ param1Desc: 'audit_type',
+ extraJson: body as never,
},
});
@@ -97,9 +106,9 @@ export class StoreService {
async getShopStore(storeAccountId: bigint) {
const account = await this.prisma.storeAccount.findUniqueOrThrow({
where: { id: storeAccountId },
- include: { store: { include: { category: true } } },
+ include: { store: { include: { category: true, coverResource: true } } },
});
- return serializeBigInt(account.store);
+ return serializeBigInt(mapStoreCompat(account.store));
}
async updateShopStatus(storeAccountId: bigint, status: 'OPEN' | 'PAUSED') {
@@ -115,13 +124,24 @@ export class StoreService {
async partnerDashboard(partnerAccountId: bigint) {
const account = await this.getPartnerAccount(partnerAccountId);
- const [storeCount, orderCount] = await Promise.all([
+ const [storeCount, orderCount, recentStores] = await Promise.all([
this.prisma.store.count({ where: { partnerId: account.partnerId } }),
this.prisma.order.count({
where: { city: { partnerId: account.partnerId } },
}),
+ this.prisma.store.findMany({
+ where: { partnerId: account.partnerId },
+ select: { id: true, name: true, status: true, createdAt: true },
+ orderBy: { createdAt: 'desc' },
+ take: 10,
+ }),
]);
- return { storeCount, orderCount, companyName: account.partner.companyName };
+ return {
+ storeCount,
+ orderCount,
+ companyName: account.partner.companyName,
+ recentStores: serializeBigInt(recentStores),
+ };
}
private async getPartnerAccount(partnerAccountId: bigint) {
diff --git a/server/dukang-api/src/modules/trade/trade.service.ts b/server/dukang-api/src/modules/trade/trade.service.ts
index cd178e3..7dfae8c 100644
--- a/server/dukang-api/src/modules/trade/trade.service.ts
+++ b/server/dukang-api/src/modules/trade/trade.service.ts
@@ -4,12 +4,14 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
+import type { FreightPayType } from '@prisma/client';
import {
calcBenefitAmount,
generateOrderNo,
orderTabToStatuses,
validateMinPurchase,
} from '@dukang/domain';
+import { loadAppConfig, WECHAT_AUTH_REQUIRED } from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { BenefitService } from '../benefit/benefit.service';
@@ -19,6 +21,8 @@ import { IDeliveryProvider } from '../../integrations/delivery/delivery.interfac
import { IpGeoService } from '../../common/geo/ip-geo.service';
import { buildOrderClientLocationSnapshot } from '../../common/geo/client-location.util';
import { extractClientIp } from '../../common/geo/client-ip.util';
+import { buildOrderStatusEvent, orderStatusLogWhere } from '../../common/event/event.helpers';
+import { mapOrderCompat, mapStatusLogCompat } from '../../common/compat/v31-compat';
import type { Request } from 'express';
@Injectable()
@@ -32,13 +36,13 @@ export class TradeService {
) {}
async preview(userId: bigint, body: { productId: string; quantity: number; addressId?: string }) {
- const product = await this.prisma.product.findUnique({
+ const product = await this.prisma.commonProductItem.findUnique({
where: { id: BigInt(body.productId) },
});
if (!product || product.status !== 'ON_SALE') {
throw new BadRequestException('商品不可购买');
}
- const city = await this.prisma.city.findFirst({ where: { status: 'ACTIVE' } });
+ const city = await this.prisma.commonCity.findFirst({ where: { status: 'ACTIVE' } });
if (!city) throw new BadRequestException('暂无开城城市');
let deliveryType: 'LOCAL' | 'CROSS_CITY' = 'LOCAL';
@@ -66,13 +70,15 @@ export class TradeService {
benefitAmount: product.benefitAmount ? Number(product.benefitAmount) : null,
});
+ const freightPayType: FreightPayType | null = deliveryType === 'CROSS_CITY' ? 'COD' : null;
+
return {
product: serializeBigInt(product),
quantity: body.quantity,
deliveryType,
productAmount,
freightAmount: deliveryType === 'CROSS_CITY' ? 0 : 0,
- freightPayType: deliveryType === 'CROSS_CITY' ? 'COD' : null,
+ freightPayType,
payAmount: productAmount,
benefitAmount: benefitPerUnit * body.quantity,
city: serializeBigInt(city),
@@ -95,10 +101,10 @@ export class TradeService {
});
if (!address) throw new BadRequestException('请选择收货地址');
- const product = await this.prisma.product.findUniqueOrThrow({
+ const product = await this.prisma.commonProductItem.findUniqueOrThrow({
where: { id: BigInt(body.productId) },
});
- const city = await this.prisma.city.findFirstOrThrow({ where: { status: 'ACTIVE' } });
+ const city = await this.prisma.commonCity.findFirstOrThrow({ where: { status: 'ACTIVE' } });
const orderNo = generateOrderNo();
const payExpireAt = new Date(Date.now() + 30 * 60 * 1000);
@@ -114,7 +120,17 @@ export class TradeService {
userId,
cityId: city.id,
status: 'PENDING_PAY',
+ payStatus: 'UNPAID',
deliveryType: preview.deliveryType as 'LOCAL' | 'CROSS_CITY',
+ productId: product.id,
+ barcode69: product.barcode69,
+ productName: product.name,
+ productSpec: product.spec,
+ imageResourceId: product.coverResourceId,
+ quantity: body.quantity,
+ listUnitPrice: product.price,
+ listAmount: preview.productAmount,
+ productAmount: preview.productAmount,
receiverName: address.receiverName,
receiverPhone: address.phone,
receiverAddress: `${address.province}${address.city}${address.district}${address.detail}`,
@@ -131,73 +147,77 @@ export class TradeService {
gpsLatitude: location.gpsLatitude,
gpsLongitude: location.gpsLongitude,
gpsAddress: location.gpsAddress,
- productAmount: preview.productAmount,
freightAmount: preview.freightAmount,
freightPayType: preview.freightPayType,
payAmount: preview.payAmount,
benefitAmount: preview.benefitAmount,
payExpireAt,
- items: {
- create: {
- productId: product.id,
- productName: product.name,
- productSpec: product.spec,
- productImage: product.mainImageUrl,
- unitPrice: product.price,
- quantity: body.quantity,
- subtotal: preview.productAmount,
- },
- },
- payment: {
- create: {
- paymentNo: `PAY${orderNo}`,
- amount: preview.payAmount,
- status: 'PENDING',
- },
- },
},
- include: { items: true, payment: true },
+ include: { product: true, imageResource: true },
});
- return serializeBigInt(order);
+ return serializeBigInt(mapOrderCompat(order));
}
async payOrder(userId: bigint, orderId: bigint) {
const order = await this.prisma.order.findFirst({
where: { id: orderId, userId },
- include: { items: true, payment: true },
});
if (!order) throw new NotFoundException('订单不存在');
if (order.status !== 'PENDING_PAY') {
throw new BadRequestException('订单状态不可支付');
}
- const { externalNo } = await this.payProvider.payOrder(orderId);
+ const user = await this.prisma.user.findUnique({ where: { id: userId } });
+ const openId = user?.wxOpenId ?? undefined;
+ const appConfig = loadAppConfig();
+ if (!appConfig.mockPay && !openId) {
+ throw new BadRequestException(WECHAT_AUTH_REQUIRED);
+ }
+ const payResult = await this.payProvider.payOrder(orderId, openId);
+
+ if (payResult.mode === 'jsapi') {
+ return {
+ mode: 'jsapi' as const,
+ orderId: order.id.toString(),
+ prepay: payResult.prepay,
+ };
+ }
+
+ const { externalNo } = payResult;
const now = new Date();
await this.prisma.$transaction(async (tx) => {
- await tx.payment.update({
- where: { orderId: order.id },
- data: {
- status: 'SUCCESS',
- paidAt: now,
- wxTransactionId: externalNo,
- },
- });
await tx.order.update({
where: { id: order.id },
- data: { status: 'PENDING_SHIP', paidAt: now },
- });
- await tx.orderStatusLog.create({
data: {
+ status: 'PENDING_SHIP',
+ payStatus: 'PAID',
+ paidAt: now,
+ payExternalNo: externalNo,
+ },
+ });
+ await tx.logThirdParty.create({
+ data: {
+ provider: 'WECHAT_PAY',
+ scene: 'ORDER_PAY',
+ refType: 'ORDER',
+ refId: order.id,
+ externalNo,
+ amount: order.payAmount,
+ status: 'SUCCESS',
+ },
+ });
+ await tx.commonEvent.create({
+ data: buildOrderStatusEvent({
orderId: order.id,
fromStatus: 'PENDING_PAY',
toStatus: 'PENDING_SHIP',
operator: 'MOCK_PAY',
- },
+ }),
});
await tx.orderDelivery.create({
- data: { orderId: order.id, provider: 'MOCK' },
+ data: { orderId: order.id, provider: 'MANUAL' },
});
});
@@ -207,6 +227,87 @@ export class TradeService {
return this.getOrder(userId, orderId);
}
+ /** 微信支付回调:幂等更新订单为已支付并发券 */
+ async handlePaySuccess(params: {
+ orderNo: string;
+ transactionId: string;
+ amountFen: number;
+ }) {
+ const order = await this.prisma.order.findUnique({ where: { orderNo: params.orderNo } });
+ if (!order) {
+ throw new NotFoundException('订单不存在');
+ }
+
+ if (order.payStatus === 'PAID') {
+ return { orderId: order.id.toString(), alreadyPaid: true };
+ }
+
+ const expectedFen = Math.round(Number(order.payAmount) * 100);
+ if (params.amountFen > 0 && params.amountFen !== expectedFen) {
+ throw new BadRequestException('支付金额与订单不符');
+ }
+
+ const existingLog = await this.prisma.logThirdParty.findFirst({
+ where: {
+ provider: 'WECHAT_PAY',
+ externalNo: params.transactionId,
+ status: 'SUCCESS',
+ },
+ });
+ if (existingLog) {
+ return { orderId: order.id.toString(), alreadyPaid: true };
+ }
+
+ const now = new Date();
+ await this.prisma.$transaction(async (tx) => {
+ const current = await tx.order.findUnique({ where: { id: order.id } });
+ if (!current || current.payStatus === 'PAID') return;
+
+ await tx.order.update({
+ where: { id: order.id },
+ data: {
+ status: 'PENDING_SHIP',
+ payStatus: 'PAID',
+ paidAt: now,
+ payExternalNo: params.transactionId,
+ },
+ });
+ await tx.logThirdParty.create({
+ data: {
+ provider: 'WECHAT_PAY',
+ scene: 'ORDER_PAY',
+ refType: 'ORDER',
+ refId: order.id,
+ externalNo: params.transactionId,
+ amount: order.payAmount,
+ status: 'SUCCESS',
+ },
+ });
+ await tx.commonEvent.create({
+ data: buildOrderStatusEvent({
+ orderId: order.id,
+ fromStatus: 'PENDING_PAY',
+ toStatus: 'PENDING_SHIP',
+ operator: 'WECHAT_PAY',
+ }),
+ });
+ const delivery = await tx.orderDelivery.findUnique({ where: { orderId: order.id } });
+ if (!delivery) {
+ await tx.orderDelivery.create({
+ data: { orderId: order.id, provider: 'MANUAL' },
+ });
+ }
+ });
+
+ const refreshed = await this.prisma.order.findUnique({ where: { id: order.id } });
+ if (refreshed?.payStatus === 'PAID') {
+ await this.benefitService.grantOnOrderPaid(order.id);
+ await this.deliveryProvider.scheduleAutoAdvance(order.id);
+ }
+
+ return { orderId: order.id.toString(), alreadyPaid: false };
+ }
+
async listOrders(userId: bigint, tab = 'all', page = 1, pageSize = 20) {
const statuses = orderTabToStatuses(tab);
const where = {
@@ -216,29 +317,32 @@ export class TradeService {
const [list, total] = await Promise.all([
this.prisma.order.findMany({
where,
- include: { items: true, benefitCoupons: true },
+ include: { benefitCoupon: true, imageResource: true },
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.order.count({ where }),
]);
- return { list: serializeBigInt(list), total, page, pageSize };
+ return { list: serializeBigInt(list.map(mapOrderCompat)), total, page, pageSize };
}
async getOrder(userId: bigint, orderId: bigint) {
const order = await this.prisma.order.findFirst({
where: { id: orderId, userId },
include: {
- items: true,
delivery: true,
- payment: true,
- benefitCoupons: true,
- statusLogs: { orderBy: { createdAt: 'desc' } },
+ benefitCoupon: true,
+ imageResource: true,
+ product: true,
},
});
if (!order) throw new NotFoundException('订单不存在');
- return serializeBigInt(order);
+ const statusLogs = await this.prisma.commonEvent.findMany({
+ where: orderStatusLogWhere(orderId),
+ orderBy: { createdAt: 'desc' },
+ });
+ return serializeBigInt(mapOrderCompat({ ...order, statusLogs: mapStatusLogCompat(statusLogs) }));
}
async updateAddress(userId: bigint, orderId: bigint, body: Record) {
@@ -258,14 +362,14 @@ export class TradeService {
receiverAddress: String(body.receiverAddress ?? order.receiverAddress),
},
});
- await this.prisma.orderStatusLog.create({
- data: {
+ await this.prisma.commonEvent.create({
+ data: buildOrderStatusEvent({
orderId,
fromStatus: order.status,
toStatus: order.status,
operator: 'USER',
remark: '修改收货地址',
- },
+ }),
});
return serializeBigInt(updated);
}
@@ -284,33 +388,37 @@ export class TradeService {
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: partnerAccountId },
});
- const cities = await this.prisma.city.findMany({ where: { partnerId: account.partnerId } });
+ const cities = await this.prisma.commonCity.findMany({ where: { partnerId: account.partnerId } });
const cityIds = cities.map((c) => c.id);
const where = { cityId: { in: cityIds } };
const [list, total] = await Promise.all([
this.prisma.order.findMany({
where,
- include: { items: true, delivery: true, user: { select: { phone: true, nickname: true } } },
+ include: { delivery: true, imageResource: true, user: { select: { phone: true, nickname: true } } },
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.order.count({ where }),
]);
- return { list: serializeBigInt(list), total, page, pageSize };
+ return { list: serializeBigInt(list.map(mapOrderCompat)), total, page, pageSize };
}
async getPartnerOrder(partnerAccountId: bigint, orderId: bigint) {
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: partnerAccountId },
});
- const cities = await this.prisma.city.findMany({ where: { partnerId: account.partnerId } });
+ const cities = await this.prisma.commonCity.findMany({ where: { partnerId: account.partnerId } });
const order = await this.prisma.order.findFirst({
where: { id: orderId, cityId: { in: cities.map((c) => c.id) } },
- include: { items: true, delivery: true, statusLogs: true, user: true },
+ include: { delivery: true, user: true, imageResource: true },
});
if (!order) throw new NotFoundException('订单不存在');
- return serializeBigInt(order);
+ const statusLogs = await this.prisma.commonEvent.findMany({
+ where: orderStatusLogWhere(orderId),
+ orderBy: { createdAt: 'desc' },
+ });
+ return serializeBigInt(mapOrderCompat({ ...order, statusLogs: mapStatusLogCompat(statusLogs) }));
}
async advanceDelivery(partnerAccountId: bigint, orderId: bigint, targetStatus: string) {
@@ -357,13 +465,13 @@ export class TradeService {
if (Object.keys(deliveryData).length) {
await tx.orderDelivery.update({ where: { orderId }, data: deliveryData as never });
}
- await tx.orderStatusLog.create({
- data: {
+ await tx.commonEvent.create({
+ data: buildOrderStatusEvent({
orderId,
fromStatus: currentStatus,
toStatus: targetStatus,
operator,
- },
+ }),
});
});
}
diff --git a/skills.md b/skills.md
index d037750..115f51f 100644
--- a/skills.md
+++ b/skills.md
@@ -14,13 +14,26 @@
---
+## Cursor Skills / Agents(推荐)
+
+| 资源 | 路径 | 用途 |
+|------|------|------|
+| AI 入口 | [`AGENTS.md`](./AGENTS.md) | 自动加载的项目上下文 |
+| 编码 Skill | `.cursor/skills/dukang-coding/` | @dukang-coding |
+| preV1 Skill | `.cursor/skills/dukang-prev1/` | Mock / Flag |
+| 任务卡 Skill | `.cursor/skills/dukang-task-card/` | P1-* / M*-* |
+| OWNER 子 Agent | `.cursor/agents/owner-*.md` | `/owner-a-user-trade` 等 |
+| 边界审查 | `.cursor/agents/boundary-reviewer.md` | PR 前只读审查 |
+| Scoped Rules | `.cursor/rules/*.mdc` | 按文件类型自动附加 |
+
## 文档读取顺序
1. [`杜康好客-V2编码手册.md`](./杜康好客-V2编码手册.md) — PRD、DB v3.1、API、计划(最高权威)
-2. [`agent.md`](./agent.md) — Agent 流程与 DoD
-3. [`conventions.md`](./conventions.md) — 模块边界、提交规范
-4. `pages/{端}/` — UI 原型(字段、Tab、跳转)
-5. `.cursor/skills/dukang-coding/reference-*.md` — 前后端路径速查(可选)
+2. [`杜康好客-preV1编码手册.md`](./杜康好客-preV1编码手册.md) — 当前 Mock 联调裁剪
+3. [`AGENTS.md`](./AGENTS.md) + [`agent.md`](./agent.md) — 流程、OWNER、DoD
+4. [`conventions.md`](./conventions.md) — 模块边界、提交规范
+5. `pages/{端}/` — UI 原型(字段、Tab、跳转)
+6. `.cursor/skills/dukang-coding/reference-*.md` — 前后端路径速查(可选)
---