add_business
diff --git a/apps/h5-partner/src/pages/ProxyOrderPage.tsx b/apps/h5-partner/src/pages/ProxyOrderPage.tsx
new file mode 100644
index 0000000..338cb4f
--- /dev/null
+++ b/apps/h5-partner/src/pages/ProxyOrderPage.tsx
@@ -0,0 +1,330 @@
+import { useEffect, useMemo, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import PageHeader from '@dukang/shared-ui/PageHeader';
+import ChinaRegionPicker from '../components/ChinaRegionPicker';
+import { request } from '../lib/api';
+import { formatRegionLabel, parseRegionCodes } from '../lib/china-region';
+import { toastError, toastSuccess } from '../lib/toast';
+import type {
+ PartnerProxyOrderCreateRequest,
+ PartnerProxyOrderOptions,
+ PartnerProxyOrderPreviewResult,
+} from '@dukang/shared-types';
+
+function fmtMoney(n: number) {
+ return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
+}
+
+export default function ProxyOrderPage() {
+ const navigate = useNavigate();
+ const [options, setOptions] = useState
(null);
+ const [loadingOptions, setLoadingOptions] = useState(true);
+ const [phone, setPhone] = useState('');
+ const [smsCode, setSmsCode] = useState('');
+ const [receiverName, setReceiverName] = useState('');
+ const [regionCodes, setRegionCodes] = useState([]);
+ const [addressDetail, setAddressDetail] = useState('');
+ const [productId, setProductId] = useState('');
+ const [quantity, setQuantity] = useState(2);
+ const [promoCodeId, setPromoCodeId] = useState('');
+ const [preview, setPreview] = useState(null);
+ const [previewLoading, setPreviewLoading] = useState(false);
+ const [smsCooldown, setSmsCooldown] = useState(0);
+ const [submitting, setSubmitting] = useState(false);
+ const [msg, setMsg] = useState('');
+
+ const region = useMemo(() => parseRegionCodes(regionCodes), [regionCodes]);
+ const regionLabel = region ? formatRegionLabel(region) : '';
+
+ useEffect(() => {
+ request('PARTNER_H5', '/partner/proxy-orders/options')
+ .then((data) => {
+ setOptions(data);
+ if (data.products[0]) setProductId(data.products[0].id);
+ })
+ .catch((e) => toastError(e instanceof Error ? e.message : '加载失败'))
+ .finally(() => setLoadingOptions(false));
+ }, []);
+
+ useEffect(() => {
+ if (!productId || quantity < 1) {
+ setPreview(null);
+ return;
+ }
+ const timer = setTimeout(() => {
+ setPreviewLoading(true);
+ request('PARTNER_H5', '/partner/proxy-orders/preview', {
+ method: 'POST',
+ body: JSON.stringify({
+ productId,
+ quantity,
+ receiverCity: region.city || undefined,
+ receiverDistrict: region.district || undefined,
+ }),
+ silent: true,
+ })
+ .then(setPreview)
+ .catch(() => setPreview(null))
+ .finally(() => setPreviewLoading(false));
+ }, 300);
+ return () => clearTimeout(timer);
+ }, [productId, quantity, region.city, region.district]);
+
+ async function sendSms() {
+ setMsg('');
+ if (!/^1\d{10}$/.test(phone.trim())) {
+ setMsg('请输入有效手机号');
+ return;
+ }
+ try {
+ const res = await request<{ maskedPhone: string }>('PARTNER_H5', '/partner/proxy-orders/send-sms', {
+ method: 'POST',
+ body: JSON.stringify({ phone: phone.trim() }),
+ silent: true,
+ });
+ setMsg(`验证码已发送至 ${res.maskedPhone}`);
+ setSmsCooldown(60);
+ const timer = setInterval(() => {
+ setSmsCooldown((s) => {
+ if (s <= 1) {
+ clearInterval(timer);
+ return 0;
+ }
+ return s - 1;
+ });
+ }, 1000);
+ } catch (e) {
+ setMsg(e instanceof Error ? e.message : '发送失败');
+ }
+ }
+
+ async function submit() {
+ setMsg('');
+ if (!/^1\d{10}$/.test(phone.trim())) {
+ setMsg('请输入有效手机号');
+ return;
+ }
+ if (!smsCode.trim()) {
+ setMsg('请输入验证码');
+ return;
+ }
+ if (!region?.province || !region.city || !region.district) {
+ setMsg('请选择省市区');
+ return;
+ }
+ if (!addressDetail.trim()) {
+ setMsg('请填写详细地址');
+ return;
+ }
+ if (!productId) {
+ setMsg('请选择商品');
+ return;
+ }
+
+ const payload: PartnerProxyOrderCreateRequest = {
+ phone: phone.trim(),
+ smsCode: smsCode.trim(),
+ receiverName: receiverName.trim() || undefined,
+ province: region.province,
+ city: region.city,
+ district: region.district,
+ addressDetail: addressDetail.trim(),
+ productId,
+ quantity,
+ promoCodeId: promoCodeId || undefined,
+ };
+
+ setSubmitting(true);
+ try {
+ const order = await request<{ id: string; orderNo: string }>('PARTNER_H5', '/partner/proxy-orders', {
+ method: 'POST',
+ body: JSON.stringify(payload),
+ silent: true,
+ });
+ toastSuccess(`代下单成功:${order.orderNo}`);
+ navigate(`/orders/${order.id}`);
+ } catch (e) {
+ setMsg(e instanceof Error ? e.message : '下单失败');
+ } finally {
+ setSubmitting(false);
+ }
+ }
+
+ const selectedProduct = options?.products.find((p) => p.id === productId);
+
+ return (
+
+
navigate(-1)} />
+
+
+ {loadingOptions ? (
+ 加载商品与推广码…
+ ) : (
+ <>
+
+
+
+
+ setPhone(e.target.value.replace(/\D/g, '').slice(0, 11))}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+ {regionLabel && {regionLabel}
}
+
+
+
+
+
+
+
+
+
+
+
+ {(options?.promoCodes.length ?? 0) > 0 && (
+
+
+
+
+ )}
+
+
+ 费用明细
+ {previewLoading ? (
+ 计算中…
+ ) : preview ? (
+ <>
+
+ 商品单价
+ ¥{fmtMoney(preview.unitPrice)}
+
+
+ 数量
+ ×{quantity}
+
+
+ 配送类型
+ {preview.deliveryType === 'LOCAL' ? '同城' : '跨城'}
+
+
+ 权益额
+ ¥{fmtMoney(preview.benefitAmount)}
+
+
+ 应付金额
+ ¥{fmtMoney(preview.payAmount)}
+
+ >
+ ) : (
+
+ {selectedProduct ? '请确认数量与地址后查看费用' : '请选择商品'}
+
+ )}
+
+
+ {msg && {msg}
}
+
+
+
+
+ 提交后将自动创建/关联用户,订单类型为「线下代下单」,状态直接标记为已收货,并发放对应权益。
+
+ >
+ )}
+
+
+ );
+}
diff --git a/apps/h5-partner/src/styles.css b/apps/h5-partner/src/styles.css
index b091939..076562d 100644
--- a/apps/h5-partner/src/styles.css
+++ b/apps/h5-partner/src/styles.css
@@ -3031,3 +3031,28 @@ body {
color: var(--color-outline, #8d706e);
pointer-events: none;
}
+
+.partner-form-section {
+ margin-bottom: 16px;
+}
+
+.partner-proxy-fee-card {
+ margin-top: 8px;
+ padding: 16px;
+ border-radius: 16px;
+ background: rgba(166, 29, 36, 0.04);
+ border: 1px solid rgba(166, 29, 36, 0.08);
+}
+
+.partner-proxy-fee-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 6px 0;
+}
+
+.partner-proxy-fee-row--total {
+ margin-top: 8px;
+ padding-top: 12px;
+ border-top: 1px dashed rgba(166, 29, 36, 0.15);
+}
diff --git a/apps/h5-user/src/App.tsx b/apps/h5-user/src/App.tsx
index 3e051a4..f2c4448 100644
--- a/apps/h5-user/src/App.tsx
+++ b/apps/h5-user/src/App.tsx
@@ -20,13 +20,12 @@ import RedeemSuccessPage from './pages/RedeemSuccessPage';
import PayPage from './pages/PayPage';
import CustomerServicePage from './pages/CustomerServicePage';
import { UserSessionProvider } from './contexts/UserSessionContext';
-import { capturePromoFromUrl, touchPromoIfNeeded } from './lib/promo';
+import { capturePromoFromUrl } from './lib/promo';
import WechatShareBootstrap from './components/WechatShareBootstrap';
function PromoBootstrap() {
useEffect(() => {
capturePromoFromUrl();
- void touchPromoIfNeeded();
}, []);
return null;
}
diff --git a/apps/h5-user/src/lib/promo.ts b/apps/h5-user/src/lib/promo.ts
index fe308dd..bbcdfd2 100644
--- a/apps/h5-user/src/lib/promo.ts
+++ b/apps/h5-user/src/lib/promo.ts
@@ -1,25 +1,33 @@
import { apiBase } from './api';
export const PROMO_STORAGE_KEY = 'dukang_promo_code';
+export const PROMO_PID_STORAGE_KEY = 'dukang_promo_pid';
-function readPromoFromSearch(search: string): string | null {
+function readPromoFromSearch(search: string): { code: string | null; pid: string | null } {
const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search);
const code = params.get('promo')?.trim();
- return code ? code.toUpperCase() : null;
+ const pid = params.get('pid')?.trim();
+ return {
+ code: code ? code.toUpperCase() : null,
+ pid: pid || null,
+ };
}
-/** 解析 URL 中的 ?promo= 并写入 sessionStorage */
+/** 解析 URL 中的 ?promo= / ?pid= 并写入 sessionStorage */
export function capturePromoFromUrl(): string | null {
if (typeof window === 'undefined') return null;
- let code = readPromoFromSearch(window.location.search);
- if (!code && window.location.hash.includes('?')) {
+ let parsed = readPromoFromSearch(window.location.search);
+ if (!parsed.code && !parsed.pid && window.location.hash.includes('?')) {
const hashQuery = window.location.hash.slice(window.location.hash.indexOf('?'));
- code = readPromoFromSearch(hashQuery);
+ parsed = readPromoFromSearch(hashQuery);
}
- if (code) {
- sessionStorage.setItem(PROMO_STORAGE_KEY, code);
+ if (parsed.code) {
+ sessionStorage.setItem(PROMO_STORAGE_KEY, parsed.code);
}
- return code ?? sessionStorage.getItem(PROMO_STORAGE_KEY);
+ if (parsed.pid) {
+ sessionStorage.setItem(PROMO_PID_STORAGE_KEY, parsed.pid);
+ }
+ return parsed.code ?? sessionStorage.getItem(PROMO_STORAGE_KEY);
}
export function getStoredPromoCode(): string | null {
@@ -27,10 +35,16 @@ export function getStoredPromoCode(): string | null {
return sessionStorage.getItem(PROMO_STORAGE_KEY);
}
+export function getStoredPromoPid(): string | null {
+ if (typeof window === 'undefined') return null;
+ return sessionStorage.getItem(PROMO_PID_STORAGE_KEY);
+}
+
/** 调用 /promo/touch 完成扫码归因(OptionalJwt:未登录也累加 scan_count) */
export async function touchPromoIfNeeded(): Promise {
const promoCode = getStoredPromoCode();
- if (!promoCode) return;
+ const qrcodeId = getStoredPromoPid();
+ if (!promoCode && !qrcodeId) return;
const headers: Record = {
'Content-Type': 'application/json',
@@ -43,7 +57,10 @@ export async function touchPromoIfNeeded(): Promise {
const res = await fetch(`${apiBase}/promo/touch`, {
method: 'POST',
headers,
- body: JSON.stringify({ promoCode }),
+ body: JSON.stringify({
+ ...(promoCode ? { promoCode } : {}),
+ ...(qrcodeId ? { qrcodeId } : {}),
+ }),
});
const json = await res.json();
if (json.code !== 0) return;
diff --git a/packages/shared-types/src/config.ts b/packages/shared-types/src/config.ts
index 1e4447a..9af8701 100644
--- a/packages/shared-types/src/config.ts
+++ b/packages/shared-types/src/config.ts
@@ -18,12 +18,19 @@ export interface AppConfig {
aliyunSmsTemplateCode: string;
/** 核销确认短信模板(REDEEM_PHONE_CONFIRM);未配置时回退 aliyunSmsTemplateCode */
aliyunSmsRedeemConfirmTemplateCode: string;
+ /** 合伙人代下单短信模板(PARTNER_PROXY_ORDER / 线下代发货);未配置时回退 aliyunSmsTemplateCode */
+ aliyunSmsProxyOrderTemplateCode: string;
aliyunSmsAccessKeyId: string;
aliyunSmsAccessKeySecret: string;
/** 腾讯位置服务 Key(逆地理编码) */
tencentLbsKey: string;
+ /** C 端 H5 落地页(推广码二维码链接前缀) */
+ userH5Url: string;
}
+/** 推广码 / C 端 H5 默认落地页(未配置 USER_H5_URL 时使用) */
+export const DEFAULT_USER_H5_URL = 'https://user.runxian.top/user';
+
/** 总部客服电话(C 端联系客服) */
export const CUSTOMER_SERVICE_PHONE = '400-888-1234';
@@ -48,8 +55,10 @@ export function loadAppConfig(env?: Record): AppConf
aliyunSmsSignName: e.ALIYUN_SMS_SIGN_NAME ?? '',
aliyunSmsTemplateCode: e.ALIYUN_SMS_TEMPLATE_CODE ?? '',
aliyunSmsRedeemConfirmTemplateCode: e.ALIYUN_SMS_REDEEM_CONFIRM_TEMPLATE_CODE ?? '',
+ aliyunSmsProxyOrderTemplateCode: e.ALIYUN_SMS_PROXY_ORDER_TEMPLATE_CODE ?? '',
aliyunSmsAccessKeyId: e.ALIYUN_SMS_ACCESS_KEY_ID ?? e.OSS_ACCESS_KEY_ID ?? '',
aliyunSmsAccessKeySecret: e.ALIYUN_SMS_ACCESS_KEY_SECRET ?? e.OSS_ACCESS_KEY_SECRET ?? '',
tencentLbsKey: e.TENCENT_LBS_KEY ?? '',
+ userH5Url: (e.USER_H5_URL || DEFAULT_USER_H5_URL).replace(/\/$/, ''),
};
}
diff --git a/packages/shared-types/src/enums.ts b/packages/shared-types/src/enums.ts
index cbdece9..4a85a8b 100644
--- a/packages/shared-types/src/enums.ts
+++ b/packages/shared-types/src/enums.ts
@@ -15,6 +15,25 @@ export enum ActorType {
HQ = 'HQ',
}
+/** 用户注册/首次归因来源(对应 user_user.source_type) */
+export enum UserSourceType {
+ ORGANIC = 'ORGANIC',
+ PROMO_CODE = 'PROMO_CODE',
+ SHARE_LINK = 'SHARE_LINK',
+ FRIEND_REFERRAL = 'FRIEND_REFERRAL',
+ OFFLINE_EVENT = 'OFFLINE_EVENT',
+ OTHER = 'OTHER',
+}
+
+export const USER_SOURCE_TYPE_LABELS: Record = {
+ [UserSourceType.ORGANIC]: '自然流量',
+ [UserSourceType.PROMO_CODE]: '推广码',
+ [UserSourceType.SHARE_LINK]: '分享链接',
+ [UserSourceType.FRIEND_REFERRAL]: '好友推荐',
+ [UserSourceType.OFFLINE_EVENT]: '线下活动',
+ [UserSourceType.OTHER]: '其他',
+};
+
export enum SmsScene {
USER_LOGIN = 'USER_LOGIN',
STORE_LOGIN = 'STORE_LOGIN',
@@ -27,8 +46,22 @@ export enum SmsScene {
REDEEM_PHONE_LOOKUP = 'REDEEM_PHONE_LOOKUP',
/** 门店手机号核销:核销确认验证码(阿里云模板「核销确认」) */
REDEEM_PHONE_CONFIRM = 'REDEEM_PHONE_CONFIRM',
+ /** 合伙人代下单:线下代发货确认验证码(发至用户手机) */
+ PARTNER_PROXY_ORDER = 'PARTNER_PROXY_ORDER',
}
+export enum OrderType {
+ NORMAL = 'NORMAL',
+ RESHIPMENT = 'RESHIPMENT',
+ PROXY = 'PROXY',
+}
+
+export const ORDER_TYPE_LABELS: Record = {
+ [OrderType.NORMAL]: '普通订单',
+ [OrderType.RESHIPMENT]: '补发订单',
+ [OrderType.PROXY]: '线下代下单',
+};
+
export enum OrderStatus {
PENDING_PAY = 'PENDING_PAY',
PENDING_SHIP = 'PENDING_SHIP',
diff --git a/packages/shared-types/src/promo.ts b/packages/shared-types/src/promo.ts
index b371b3b..ab7c317 100644
--- a/packages/shared-types/src/promo.ts
+++ b/packages/shared-types/src/promo.ts
@@ -1,24 +1,87 @@
export type PromoCodeStatus = 'ACTIVE' | 'DISABLED';
+export type PromoCodeScene =
+ | 'ONLINE_LINK'
+ | 'OFFLINE_PICKUP'
+ | 'PARTNER_CHANNEL'
+ | 'EVENT'
+ | 'OTHER';
+
+export const PROMO_CODE_SCENE_LABELS: Record = {
+ ONLINE_LINK: '线上链接',
+ OFFLINE_PICKUP: '现场提货',
+ PARTNER_CHANNEL: '合伙人渠道',
+ EVENT: '活动品鉴',
+ OTHER: '其他',
+};
+
+export const PROMO_CODE_STATUS_LABELS: Record = {
+ ACTIVE: '启用',
+ DISABLED: '已关闭',
+};
+
+export type PromoCodeOwnerUser = {
+ id: string;
+ userNo?: string | null;
+ nickname?: string | null;
+ phone?: string | null;
+};
+
export type PromoCodeItem = {
id: string;
code: string;
name: string;
+ scene: PromoCodeScene;
+ qrcodeId: string;
status: PromoCodeStatus;
scanCount: number;
orderCount: number;
landingUrl: string;
+ qrcodeUrl?: string | null;
+ remark?: string | null;
+ ownerUser?: PromoCodeOwnerUser | null;
createdAt: string;
+ updatedAt: string;
};
export type PromoCodeStats = {
scanCount: number;
orderCount: number;
conversionRate: number;
+ attributionCount?: number;
+ /** 用户表 source_ref_id 指向本推广码的用户数 */
+ sourceMarkedCount?: number;
+};
+
+export type PromoCodeAttributedUser = {
+ id: string;
+ userNo: string;
+ nickname: string | null;
+ phone: string | null;
+ phoneVerifiedAt: string | null;
+ sourceType: string;
+ sourceRefId: string | null;
+ firstTouchAt: string | null;
+ orderCount: number;
+ createdAt: string;
};
export type PromoTouchResult = {
promoCode: string;
+ promoCodeId: string;
channelName: string;
+ /** 是否首次写入 user_promo_attribution */
attributed: boolean;
+ /** 是否已将用户来源标记为 PROMO_CODE */
+ sourceApplied: boolean;
};
+
+export function promoConversion(scan: number, orders: number): string {
+ if (scan <= 0) return '0%';
+ return `${Math.round((orders / scan) * 1000) / 10}%`;
+}
+
+export function buildPromoLandingUrl(baseUrl: string, code: string, qrcodeId: string): string {
+ const base = baseUrl.replace(/\/$/, '');
+ return `${base}/?promo=${encodeURIComponent(code)}&pid=${encodeURIComponent(qrcodeId)}`;
+}
diff --git a/packages/shared-types/src/trade.ts b/packages/shared-types/src/trade.ts
index dda88f9..549090f 100644
--- a/packages/shared-types/src/trade.ts
+++ b/packages/shared-types/src/trade.ts
@@ -33,3 +33,50 @@ export interface DeliveryDto {
shippingAt?: string;
deliveredAt?: string;
}
+
+export type PartnerProxyOrderProductOption = {
+ id: string;
+ name: string;
+ spec: string;
+ price: number;
+ benefitAmount: number | null;
+};
+
+export type PartnerProxyOrderPromoOption = {
+ id: string;
+ code: string;
+ name: string;
+};
+
+export type PartnerProxyOrderOptions = {
+ products: PartnerProxyOrderProductOption[];
+ promoCodes: PartnerProxyOrderPromoOption[];
+};
+
+export type PartnerProxyOrderPreviewRequest = {
+ productId: string;
+ quantity: number;
+ receiverCity?: string;
+ receiverDistrict?: string;
+};
+
+export type PartnerProxyOrderPreviewResult = {
+ productAmount: number;
+ payAmount: number;
+ benefitAmount: number;
+ deliveryType: 'LOCAL' | 'CROSS_CITY';
+ unitPrice: number;
+};
+
+export type PartnerProxyOrderCreateRequest = {
+ phone: string;
+ smsCode: string;
+ receiverName?: string;
+ province: string;
+ city: string;
+ district: string;
+ addressDetail: string;
+ productId: string;
+ quantity: number;
+ promoCodeId?: string;
+};
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c2398fa..09619d3 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -356,6 +356,9 @@ importers:
ip2region:
specifier: ^2.3.0
version: 2.3.0(@types/node@20.19.43)
+ qrcode:
+ specifier: ^1.5.4
+ version: 1.5.4
reflect-metadata:
specifier: ^0.2.2
version: 0.2.2
@@ -381,6 +384,9 @@ importers:
'@types/node':
specifier: ^20.14.0
version: 20.19.43
+ '@types/qrcode':
+ specifier: ^1.5.6
+ version: 1.5.6
prisma:
specifier: ^5.18.0
version: 5.22.0
@@ -2492,6 +2498,9 @@ packages:
'@types/prop-types@15.7.15':
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
+ '@types/qrcode@1.5.6':
+ resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
+
'@types/qs@6.15.1':
resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==}
@@ -8827,6 +8836,10 @@ snapshots:
'@types/prop-types@15.7.15': {}
+ '@types/qrcode@1.5.6':
+ dependencies:
+ '@types/node': 20.19.43
+
'@types/qs@6.15.1': {}
'@types/range-parser@1.2.7': {}
diff --git a/server/dukang-api/.env.example b/server/dukang-api/.env.example
index e38381b..b28133d 100644
--- a/server/dukang-api/.env.example
+++ b/server/dukang-api/.env.example
@@ -16,6 +16,8 @@ ALIYUN_SMS_SIGN_NAME=
ALIYUN_SMS_TEMPLATE_CODE=
# 手机号核销「核销确认」短信模板(REDEEM_PHONE_CONFIRM);未配置时回退 ALIYUN_SMS_TEMPLATE_CODE
ALIYUN_SMS_REDEEM_CONFIRM_TEMPLATE_CODE=
+# 合伙人代下单「线下代发货」短信模板(PARTNER_PROXY_ORDER);未配置时回退 ALIYUN_SMS_TEMPLATE_CODE
+ALIYUN_SMS_PROXY_ORDER_TEMPLATE_CODE=
ALIYUN_SMS_ACCESS_KEY_ID=
ALIYUN_SMS_ACCESS_KEY_SECRET=
MOCK_PAY=true
@@ -27,9 +29,9 @@ MOCK_WECHAT=true
# 登录后是否走微信 SDK OAuth 授权(本地 false 可仅用短信登录/核销,不影响支付 Mock)
WX_AUTHORIZE=false
-# C 端 H5 落地页(推广码二维码链接前缀)
-# 本地开发:http://localhost:5173/user 生产统一入口:https://user.runxian.top/user
-USER_H5_URL=http://localhost:5173/user
+# C 端 H5 落地页(推广码二维码链接前缀,USER_H5_URL)
+# 未配置时默认 https://user.runxian.top/user;本地开发可设为 http://localhost:5173/user
+# USER_H5_URL=https://user.runxian.top/user
# 反向代理后提取真实客户端 IP(下单 IP 定位)
TRUST_PROXY=true
diff --git a/server/dukang-api/package.json b/server/dukang-api/package.json
index 917438a..97a1ca1 100644
--- a/server/dukang-api/package.json
+++ b/server/dukang-api/package.json
@@ -36,6 +36,7 @@
"express": "^4.21.0",
"ioredis": "^5.4.1",
"ip2region": "^2.3.0",
+ "qrcode": "^1.5.4",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
@@ -46,6 +47,7 @@
"@types/express": "^4.17.21",
"@types/multer": "^2.1.0",
"@types/node": "^20.14.0",
+ "@types/qrcode": "^1.5.6",
"prisma": "^5.18.0",
"ts-node": "^10.9.2",
"typescript": "^5.4.5"
diff --git a/server/dukang-api/prisma/init_v3.sql b/server/dukang-api/prisma/init_v3.sql
index 9606e7c..efd356b 100644
--- a/server/dukang-api/prisma/init_v3.sql
+++ b/server/dukang-api/prisma/init_v3.sql
@@ -136,16 +136,25 @@ CREATE TABLE common_store_category (
DROP TABLE IF EXISTS common_promo_code;
CREATE TABLE common_promo_code (
- id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
- code VARCHAR(32) NOT NULL,
- name VARCHAR(128) NOT NULL,
- status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ code VARCHAR(32) NOT NULL,
+ name VARCHAR(128) NOT NULL,
+ scene VARCHAR(32) NOT NULL DEFAULT 'ONLINE_LINK',
+ qrcode_id VARCHAR(64) NOT NULL COMMENT '二维码唯一识别码',
+ status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
+ owner_user_id BIGINT UNSIGNED DEFAULT NULL COMMENT '关联用户(统计/归因)',
+ remark VARCHAR(256) DEFAULT NULL,
qrcode_resource_id BIGINT UNSIGNED DEFAULT NULL COMMENT '小程序码 common_resource.id',
- scan_count INT NOT NULL DEFAULT 0,
- order_count INT NOT NULL DEFAULT 0,
- created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ scan_count INT NOT NULL DEFAULT 0,
+ order_count INT NOT NULL DEFAULT 0,
+ created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
- UNIQUE KEY uk_common_promo_code_code (code)
+ UNIQUE KEY uk_common_promo_code_code (code),
+ UNIQUE KEY uk_common_promo_code_qrcode_id (qrcode_id),
+ KEY idx_common_promo_code_owner (owner_user_id),
+ KEY idx_common_promo_code_scene_status (scene, status),
+ CONSTRAINT fk_common_promo_code_owner_user FOREIGN KEY (owner_user_id) REFERENCES user_user(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='推广码';
-- ===================== PARTNER(城市合伙人主账号 + 子账号) =============================
diff --git a/server/dukang-api/prisma/migrate-promo-code-v31.sql b/server/dukang-api/prisma/migrate-promo-code-v31.sql
new file mode 100644
index 0000000..15678bc
--- /dev/null
+++ b/server/dukang-api/prisma/migrate-promo-code-v31.sql
@@ -0,0 +1,28 @@
+-- 推广码表扩展:scene / qrcode_id / owner_user_id / remark / updated_at
+-- 已有数据会先回填 qrcode_id 再设 NOT NULL
+
+ALTER TABLE common_promo_code
+ ADD COLUMN IF NOT EXISTS scene VARCHAR(32) NOT NULL DEFAULT 'ONLINE_LINK' AFTER name,
+ ADD COLUMN IF NOT EXISTS qrcode_id VARCHAR(64) NULL AFTER scene,
+ ADD COLUMN IF NOT EXISTS owner_user_id BIGINT UNSIGNED NULL AFTER status,
+ ADD COLUMN IF NOT EXISTS remark VARCHAR(256) NULL AFTER owner_user_id,
+ ADD COLUMN IF NOT EXISTS updated_at DATETIME(3) NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) AFTER created_at;
+
+UPDATE common_promo_code
+SET qrcode_id = 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456'
+WHERE code = 'DKHQ001' AND (qrcode_id IS NULL OR qrcode_id = '');
+
+UPDATE common_promo_code
+SET qrcode_id = 'b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef12345678'
+WHERE code = 'DKDEMO1' AND (qrcode_id IS NULL OR qrcode_id = '');
+
+UPDATE common_promo_code
+SET qrcode_id = LOWER(REPLACE(UUID(), '-', ''))
+WHERE qrcode_id IS NULL OR qrcode_id = '';
+
+UPDATE common_promo_code
+SET updated_at = created_at
+WHERE updated_at IS NULL;
+
+ALTER TABLE common_promo_code
+ MODIFY qrcode_id VARCHAR(64) NOT NULL;
diff --git a/server/dukang-api/prisma/migrate-promo-code-v31.ts b/server/dukang-api/prisma/migrate-promo-code-v31.ts
new file mode 100644
index 0000000..74e26bc
--- /dev/null
+++ b/server/dukang-api/prisma/migrate-promo-code-v31.ts
@@ -0,0 +1,106 @@
+/**
+ * 一次性迁移:common_promo_code 扩展字段(scene / qrcode_id / owner_user_id / remark / updated_at)
+ * 用法:cd server/dukang-api && npx ts-node --transpile-only prisma/migrate-promo-code-v31.ts
+ */
+import { PrismaClient } from '@prisma/client';
+import { randomBytes } from 'crypto';
+
+const prisma = new PrismaClient();
+
+async function columnExists(table: string, column: string): Promise {
+ const rows = await prisma.$queryRawUnsafe>(
+ `SHOW COLUMNS FROM ${table} LIKE '${column}'`,
+ );
+ return rows.length > 0;
+}
+
+async function addColumn(sql: string) {
+ try {
+ await prisma.$executeRawUnsafe(sql);
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : String(e);
+ if (!msg.includes('Duplicate column')) throw e;
+ }
+}
+
+async function main() {
+ if (!(await columnExists('common_promo_code', 'scene'))) {
+ await addColumn(
+ `ALTER TABLE common_promo_code ADD COLUMN scene VARCHAR(32) NOT NULL DEFAULT 'ONLINE_LINK' AFTER name`,
+ );
+ }
+ if (!(await columnExists('common_promo_code', 'qrcode_id'))) {
+ await addColumn(`ALTER TABLE common_promo_code ADD COLUMN qrcode_id VARCHAR(64) NULL AFTER scene`);
+ }
+ if (!(await columnExists('common_promo_code', 'owner_user_id'))) {
+ await addColumn(
+ `ALTER TABLE common_promo_code ADD COLUMN owner_user_id BIGINT UNSIGNED NULL AFTER status`,
+ );
+ }
+ if (!(await columnExists('common_promo_code', 'remark'))) {
+ await addColumn(`ALTER TABLE common_promo_code ADD COLUMN remark VARCHAR(256) NULL AFTER owner_user_id`);
+ }
+ if (!(await columnExists('common_promo_code', 'updated_at'))) {
+ await addColumn(
+ `ALTER TABLE common_promo_code ADD COLUMN updated_at DATETIME(3) NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) AFTER created_at`,
+ );
+ }
+
+ const rows = await prisma.$queryRawUnsafe>(
+ `SELECT id, code, qrcode_id FROM common_promo_code`,
+ );
+
+ const preset: Record = {
+ DKHQ001: 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456',
+ DKDEMO1: 'b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef12345678',
+ };
+
+ for (const row of rows) {
+ if (row.qrcode_id) continue;
+ const qrcodeId = preset[row.code] ?? randomBytes(32).toString('hex');
+ await prisma.$executeRawUnsafe(
+ `UPDATE common_promo_code SET qrcode_id = ? WHERE id = ?`,
+ qrcodeId,
+ row.id,
+ );
+ }
+
+ await prisma.$executeRawUnsafe(`UPDATE common_promo_code SET updated_at = created_at WHERE updated_at IS NULL`);
+ await prisma.$executeRawUnsafe(`ALTER TABLE common_promo_code MODIFY qrcode_id VARCHAR(64) NOT NULL`);
+
+ try {
+ await prisma.$executeRawUnsafe(
+ `ALTER TABLE common_promo_code ADD UNIQUE KEY uk_common_promo_code_qrcode_id (qrcode_id)`,
+ );
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : String(e);
+ if (!msg.includes('Duplicate key name')) throw e;
+ }
+
+ try {
+ await prisma.$executeRawUnsafe(
+ `ALTER TABLE common_promo_code ADD KEY idx_common_promo_code_owner (owner_user_id)`,
+ );
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : String(e);
+ if (!msg.includes('Duplicate key name')) throw e;
+ }
+
+ try {
+ await prisma.$executeRawUnsafe(
+ `ALTER TABLE common_promo_code ADD KEY idx_common_promo_code_scene_status (scene, status)`,
+ );
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : String(e);
+ if (!msg.includes('Duplicate key name')) throw e;
+ }
+
+ console.log('migrate-promo-code-v31: OK');
+}
+
+main()
+ .catch((e) => {
+ console.error(e);
+ process.exit(1);
+ })
+ .finally(() => prisma.$disconnect());
diff --git a/server/dukang-api/prisma/schema.prisma b/server/dukang-api/prisma/schema.prisma
index 63558d1..3c07ee1 100644
--- a/server/dukang-api/prisma/schema.prisma
+++ b/server/dukang-api/prisma/schema.prisma
@@ -100,6 +100,14 @@ enum PromoCodeStatus {
DISABLED
}
+enum PromoCodeScene {
+ ONLINE_LINK
+ OFFLINE_PICKUP
+ PARTNER_CHANNEL
+ EVENT
+ OTHER
+}
+
enum CityStatus {
PENDING
ACTIVE
@@ -168,6 +176,7 @@ enum UserSourceType {
enum OrderType {
NORMAL
RESHIPMENT
+ PROXY
}
enum OrderStatus {
@@ -392,16 +401,24 @@ model CommonPromoCode {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
code String @unique @db.VarChar(32)
name String @db.VarChar(128)
+ scene PromoCodeScene @default(ONLINE_LINK)
+ qrcodeId String @unique @map("qrcode_id") @db.VarChar(64)
status PromoCodeStatus @default(ACTIVE)
+ ownerUserId BigInt? @map("owner_user_id") @db.UnsignedBigInt
+ remark String? @db.VarChar(256)
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)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+ ownerUser User? @relation("PromoOwnerUser", fields: [ownerUserId], references: [id], onDelete: SetNull)
qrcodeResource CommonResource? @relation("PromoQrcode", fields: [qrcodeResourceId], references: [id], onDelete: SetNull)
attributions UserPromoAttribution[]
orders Order[]
+ @@index([ownerUserId])
+ @@index([scene, status])
@@map("common_promo_code")
}
@@ -587,6 +604,7 @@ model User {
addresses UserAddress[]
cityPreference UserCityPreference?
promoTouch UserPromoAttribution?
+ ownedPromoCodes CommonPromoCode[] @relation("PromoOwnerUser")
orders Order[]
benefitCoupons BenefitCoupon[]
redeemRecords RedeemRecord[]
diff --git a/server/dukang-api/prisma/seed-v31.ts b/server/dukang-api/prisma/seed-v31.ts
index 0a8f100..0f0f079 100644
--- a/server/dukang-api/prisma/seed-v31.ts
+++ b/server/dukang-api/prisma/seed-v31.ts
@@ -589,6 +589,10 @@ async function main() {
name: '总部品鉴会',
+ scene: 'EVENT',
+
+ qrcodeId: 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456',
+
status: 'ACTIVE',
},
@@ -605,6 +609,10 @@ async function main() {
name: '郑州品鉴会演示',
+ scene: 'OFFLINE_PICKUP',
+
+ qrcodeId: 'b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef12345678',
+
status: 'ACTIVE',
},
diff --git a/server/dukang-api/src/app.module.ts b/server/dukang-api/src/app.module.ts
index f27eac3..6ce6eda 100644
--- a/server/dukang-api/src/app.module.ts
+++ b/server/dukang-api/src/app.module.ts
@@ -15,6 +15,7 @@ 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 { PromoModule } from './modules/promo/promo.module';
import { CityScopeModule } from './modules/city-scope/city-scope.module';
import { CommonModule } from './modules/common/common.module';
import { HqOperationModule } from './common/hq-operation/hq-operation.module';
@@ -42,6 +43,7 @@ import { CallbacksModule } from './callbacks/callbacks.module';
AnalyticsModule,
JobsModule,
OpsModule,
+ PromoModule,
CityScopeModule,
CommonModule,
HqOperationModule,
diff --git a/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts b/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts
index dcb3a0a..b38e014 100644
--- a/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts
+++ b/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts
@@ -47,6 +47,9 @@ export const HqOperationAction = {
PARTNER_BILL_MARK_PAID: 'PARTNER_BILL_MARK_PAID',
REDEEM_DEBUG_CREATE_TOKEN: 'REDEEM_DEBUG_CREATE_TOKEN',
REDEEM_DEBUG_CONFIRM: 'REDEEM_DEBUG_CONFIRM',
+ PROMO_CODE_CREATE: 'PROMO_CODE_CREATE',
+ PROMO_CODE_UPDATE: 'PROMO_CODE_UPDATE',
+ PROMO_CODE_UPDATE_STATUS: 'PROMO_CODE_UPDATE_STATUS',
} as const;
export type HqOperationActionCode = (typeof HqOperationAction)[keyof typeof HqOperationAction];
@@ -99,6 +102,9 @@ export const HQ_OPERATION_ACTION_LABELS: Record = {
[HqOperationAction.PARTNER_BILL_MARK_PAID]: '合伙人账单结算',
[HqOperationAction.REDEEM_DEBUG_CREATE_TOKEN]: '核销调试-生成码',
[HqOperationAction.REDEEM_DEBUG_CONFIRM]: '核销调试-确认核销',
+ [HqOperationAction.PROMO_CODE_CREATE]: '创建推广码',
+ [HqOperationAction.PROMO_CODE_UPDATE]: '编辑推广码',
+ [HqOperationAction.PROMO_CODE_UPDATE_STATUS]: '推广码启停',
STORE_PAYOUT: '门店打款确认',
};
diff --git a/server/dukang-api/src/integrations/sms/sms.aliyun.provider.ts b/server/dukang-api/src/integrations/sms/sms.aliyun.provider.ts
index 308d66a..3fd58e4 100644
--- a/server/dukang-api/src/integrations/sms/sms.aliyun.provider.ts
+++ b/server/dukang-api/src/integrations/sms/sms.aliyun.provider.ts
@@ -65,6 +65,12 @@ export class SmsAliyunProvider implements ISmsProvider {
) {
return this.config.aliyunSmsRedeemConfirmTemplateCode;
}
+ if (
+ scene === 'PARTNER_PROXY_ORDER' &&
+ this.config.aliyunSmsProxyOrderTemplateCode
+ ) {
+ return this.config.aliyunSmsProxyOrderTemplateCode;
+ }
return this.config.aliyunSmsTemplateCode;
}
diff --git a/server/dukang-api/src/modules/analytics/analytics.controller.ts b/server/dukang-api/src/modules/analytics/analytics.controller.ts
index 536c0b7..ea149f0 100644
--- a/server/dukang-api/src/modules/analytics/analytics.controller.ts
+++ b/server/dukang-api/src/modules/analytics/analytics.controller.ts
@@ -1,11 +1,11 @@
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
import { AnalyticsService } from './analytics.service';
+import { PromoCodeService } from '../promo/promo-code.service';
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
import { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { PromoTouchDto } from './dto/promo.dto';
import { ActorType } from '@dukang/shared-types';
-
@Controller('analytics')
export class AnalyticsController {
constructor(private readonly analyticsService: AnalyticsService) {}
@@ -19,12 +19,15 @@ export class AnalyticsController {
@Controller('promo')
export class PromoController {
- constructor(private readonly analyticsService: AnalyticsService) {}
+ constructor(private readonly promoCodeService: PromoCodeService) {}
@Post('touch')
@UseGuards(OptionalJwtAuthGuard)
touch(@CurrentUser() user: AuthUser | undefined, @Body() dto: PromoTouchDto) {
const userId = user?.actorType === ActorType.USER ? user.actorId : undefined;
- return this.analyticsService.touchPromo(dto.promoCode, userId);
+ return this.promoCodeService.touch(
+ { promoCode: dto.promoCode, qrcodeId: dto.qrcodeId },
+ userId,
+ );
}
-}
+}
\ No newline at end of file
diff --git a/server/dukang-api/src/modules/analytics/analytics.module.ts b/server/dukang-api/src/modules/analytics/analytics.module.ts
index c034510..ffc8ad9 100644
--- a/server/dukang-api/src/modules/analytics/analytics.module.ts
+++ b/server/dukang-api/src/modules/analytics/analytics.module.ts
@@ -1,11 +1,11 @@
import { Module, forwardRef } from '@nestjs/common';
import { IamModule } from '../iam/iam.module';
+import { PromoModule } from '../promo/promo.module';
import { AnalyticsController, PromoController } from './analytics.controller';
import { AnalyticsService } from './analytics.service';
@Module({
- imports: [forwardRef(() => IamModule)],
- controllers: [AnalyticsController, PromoController],
+ imports: [forwardRef(() => IamModule), PromoModule], controllers: [AnalyticsController, PromoController],
providers: [AnalyticsService],
exports: [AnalyticsService],
})
diff --git a/server/dukang-api/src/modules/analytics/analytics.service.ts b/server/dukang-api/src/modules/analytics/analytics.service.ts
index 412de69..aa2bcf8 100644
--- a/server/dukang-api/src/modules/analytics/analytics.service.ts
+++ b/server/dukang-api/src/modules/analytics/analytics.service.ts
@@ -1,4 +1,4 @@
-import { Injectable, NotFoundException } from '@nestjs/common';
+import { Injectable } from '@nestjs/common';
import type { ClientApp } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
@@ -123,43 +123,5 @@ export class AnalyticsService {
extraJson: event.extraJson as never,
};
}
-
- /** 扫码归因:始终累加 scan_count;已登录用户首次写入 user_promo_attribution */
- async touchPromo(promoCode: string, userId?: bigint) {
- const code = promoCode.trim().toUpperCase();
- const promo = await this.prisma.commonPromoCode.findUnique({ where: { code } });
- if (!promo || promo.status !== 'ACTIVE') {
- throw new NotFoundException('推广码无效或已停用');
- }
-
- await this.prisma.commonPromoCode.update({
- where: { id: promo.id },
- data: { scanCount: { increment: 1 } },
- });
-
- let attributed = false;
- if (userId) {
- const existing = await this.prisma.userPromoAttribution.findUnique({
- where: { userId },
- });
- if (!existing) {
- await this.prisma.userPromoAttribution.create({
- data: {
- userId,
- promoCodeId: promo.id,
- channelName: promo.name,
- firstTouchAt: new Date(),
- },
- });
- attributed = true;
- }
- }
-
- return serializeBigInt({
- promoCode: promo.code,
- channelName: promo.name,
- attributed,
- });
- }
}
diff --git a/server/dukang-api/src/modules/analytics/dto/promo.dto.ts b/server/dukang-api/src/modules/analytics/dto/promo.dto.ts
index 51a0664..b6aff03 100644
--- a/server/dukang-api/src/modules/analytics/dto/promo.dto.ts
+++ b/server/dukang-api/src/modules/analytics/dto/promo.dto.ts
@@ -1,7 +1,11 @@
-import { IsNotEmpty, IsString } from 'class-validator';
+import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class PromoTouchDto {
+ @IsOptional()
@IsString()
- @IsNotEmpty()
- promoCode: string;
+ promoCode?: string;
+
+ @IsOptional()
+ @IsString()
+ qrcodeId?: string;
}
diff --git a/server/dukang-api/src/modules/iam/auth.service.ts b/server/dukang-api/src/modules/iam/auth.service.ts
index e63674a..83d218f 100644
--- a/server/dukang-api/src/modules/iam/auth.service.ts
+++ b/server/dukang-api/src/modules/iam/auth.service.ts
@@ -90,6 +90,8 @@ export class AuthService {
case SmsScene.REDEEM_PHONE_LOOKUP:
case SmsScene.REDEEM_PHONE_CONFIRM:
return ClientApp.SHOP_H5;
+ case SmsScene.PARTNER_PROXY_ORDER:
+ return ClientApp.PARTNER_H5;
default:
return ClientApp.USER_H5;
}
@@ -140,6 +142,13 @@ export class AuthService {
});
return user ? { refType: 'USER', refId: user.id } : undefined;
}
+ case SmsScene.PARTNER_PROXY_ORDER: {
+ const user = await this.prisma.user.findFirst({
+ where: { phone, mergedIntoUserId: null, status: 1 },
+ select: { id: true },
+ });
+ return user ? { refType: 'USER', refId: user.id } : undefined;
+ }
default:
return undefined;
}
@@ -279,6 +288,9 @@ export class AuthService {
if (!user.phoneVerifiedAt) throw new BadRequestException('用户手机号未验证,无法核销');
return;
}
+ if (scene === SmsScene.PARTNER_PROXY_ORDER) {
+ return;
+ }
}
async verifySmsCode(phone: string, code: string, scene: SmsScene) {
@@ -286,6 +298,44 @@ export class AuthService {
await this.smsProvider.verify(normalizedPhone, code, scene);
}
+ /** 合伙人代下单:按手机号查找或创建已验证用户 */
+ async findOrCreateUserByPhone(phone: string) {
+ const normalizedPhone = this.assertMobilePhone(phone);
+ let user = await this.prisma.user.findUnique({
+ where: { phone: normalizedPhone },
+ include: { avatar: true },
+ });
+
+ if (!user) {
+ user = await this.prisma.user.create({
+ data: {
+ phone: normalizedPhone,
+ phoneVerifiedAt: new Date(),
+ userNo: generateUserNo(),
+ nickname: `用户${normalizedPhone.slice(-4)}`,
+ cityPreference: {
+ create: {
+ selectedCityCode: '410100',
+ selectedDistrict: '郑州市',
+ },
+ },
+ },
+ include: { avatar: true },
+ });
+ } else {
+ if (!user.phoneVerifiedAt) {
+ user = await this.prisma.user.update({
+ where: { id: user.id },
+ data: { phoneVerifiedAt: new Date() },
+ include: { avatar: true },
+ });
+ }
+ await this.assertActiveUser(user.id);
+ }
+
+ return user;
+ }
+
private async verifySmsForUser(
phone: string,
code: string,
@@ -1261,6 +1311,21 @@ export class AuthService {
}
}
+ if (
+ guest.sourceType === 'PROMO_CODE' &&
+ guest.sourceRefId &&
+ primary.sourceType === 'ORGANIC'
+ ) {
+ await tx.user.update({
+ where: { id: primaryId },
+ data: {
+ sourceType: 'PROMO_CODE',
+ sourceRefId: guest.sourceRefId,
+ sourceLabel: guest.sourceLabel ?? primary.sourceLabel,
+ },
+ });
+ }
+
const deviceKeyToTransfer =
guest.deviceKey && !primary.deviceKey ? guest.deviceKey : null;
if (deviceKeyToTransfer) {
diff --git a/server/dukang-api/src/modules/ops/admin-promo-codes.controller.ts b/server/dukang-api/src/modules/ops/admin-promo-codes.controller.ts
deleted file mode 100644
index 402b774..0000000
--- a/server/dukang-api/src/modules/ops/admin-promo-codes.controller.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
-import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
-import { AdminPromoCodesService } from './admin-promo-codes.service';
-import { AdminPromoCodesQueryDto } from './dto/admin-query.dto';
-import { CreatePromoCodeDto, UpdatePromoCodeStatusDto } from './dto/admin-mutate.dto';
-
-@Controller('admin/promo-codes')
-@UseGuards(HqAuthGuard)
-export class AdminPromoCodesController {
- constructor(private readonly service: AdminPromoCodesService) {}
-
- @Get()
- list(@Query() query: AdminPromoCodesQueryDto) {
- return this.service.list(query);
- }
-
- @Get(':id/stats')
- stats(@Param('id') id: string) {
- return this.service.stats(BigInt(id));
- }
-
- @Get(':id')
- detail(@Param('id') id: string) {
- return this.service.detail(BigInt(id));
- }
-
- @Post()
- create(@Body() dto: CreatePromoCodeDto) {
- return this.service.create(dto);
- }
-
- @Put(':id/status')
- updateStatus(@Param('id') id: string, @Body() dto: UpdatePromoCodeStatusDto) {
- return this.service.updateStatus(BigInt(id), dto.status);
- }
-}
diff --git a/server/dukang-api/src/modules/ops/admin-promo-codes.service.ts b/server/dukang-api/src/modules/ops/admin-promo-codes.service.ts
deleted file mode 100644
index a9addcf..0000000
--- a/server/dukang-api/src/modules/ops/admin-promo-codes.service.ts
+++ /dev/null
@@ -1,133 +0,0 @@
-import {
- BadRequestException,
- Injectable,
- NotFoundException,
-} from '@nestjs/common';
-import { PrismaService } from '../../common/prisma/prisma.module';
-import { serializeBigInt } from '../../common/decorators/current-user.decorator';
-import { CreatePromoCodeDto } from './dto/admin-mutate.dto';
-import { AdminPromoCodesQueryDto } from './dto/admin-query.dto';
-
-function userH5Base(): string {
- return (process.env.USER_H5_URL || 'http://localhost:5173/user').replace(/\/$/, '');
-}
-
-function buildLandingUrl(code: string): string {
- return `${userH5Base()}/?promo=${encodeURIComponent(code)}`;
-}
-
-function randomCode(): string {
- const n = Math.random().toString(36).slice(2, 8).toUpperCase();
- return `DK${n}`;
-}
-
-@Injectable()
-export class AdminPromoCodesService {
- constructor(private readonly prisma: PrismaService) {}
-
- private mapRow(row: {
- id: bigint;
- code: string;
- name: string;
- status: string;
- scanCount: number;
- orderCount: number;
- createdAt: Date;
- }) {
- return serializeBigInt({
- id: row.id,
- code: row.code,
- name: row.name,
- status: row.status,
- scanCount: row.scanCount,
- orderCount: row.orderCount,
- landingUrl: buildLandingUrl(row.code),
- createdAt: row.createdAt,
- });
- }
-
- async list(query: AdminPromoCodesQueryDto) {
- const page = query.page ?? 1;
- const pageSize = query.pageSize ?? 20;
- const where: {
- status?: 'ACTIVE' | 'DISABLED';
- name?: { contains: string };
- code?: { contains: string };
- } = {};
- if (query.status) where.status = query.status as 'ACTIVE' | 'DISABLED';
- if (query.name) where.name = { contains: query.name };
- if (query.code) where.code = { contains: query.code };
-
- const [items, total] = await Promise.all([
- this.prisma.commonPromoCode.findMany({
- where,
- orderBy: { createdAt: 'desc' },
- skip: (page - 1) * pageSize,
- take: pageSize,
- }),
- this.prisma.commonPromoCode.count({ where }),
- ]);
- return serializeBigInt({
- items: items.map((r) => this.mapRow(r)),
- total,
- page,
- pageSize,
- });
- }
-
- async detail(id: bigint) {
- const row = await this.prisma.commonPromoCode.findUnique({ where: { id } });
- if (!row) throw new NotFoundException('推广码不存在');
- const stats = this.statsFromRow(row);
- return serializeBigInt({ ...this.mapRow(row), stats });
- }
-
- async create(dto: CreatePromoCodeDto) {
- let code = dto.code?.trim().toUpperCase();
- if (code) {
- const exists = await this.prisma.commonPromoCode.findUnique({ where: { code } });
- if (exists) throw new BadRequestException('推广码已存在');
- } else {
- for (let i = 0; i < 5; i++) {
- const candidate = randomCode();
- const exists = await this.prisma.commonPromoCode.findUnique({ where: { code: candidate } });
- if (!exists) {
- code = candidate;
- break;
- }
- }
- if (!code) throw new BadRequestException('生成推广码失败,请重试');
- }
-
- const row = await this.prisma.commonPromoCode.create({
- data: {
- code,
- name: dto.name.trim(),
- status: 'ACTIVE',
- },
- });
- return this.mapRow(row);
- }
-
- async updateStatus(id: bigint, status: 'ACTIVE' | 'DISABLED') {
- const row = await this.prisma.commonPromoCode.update({
- where: { id },
- data: { status },
- });
- return this.mapRow(row);
- }
-
- async stats(id: bigint) {
- const row = await this.prisma.commonPromoCode.findUnique({ where: { id } });
- if (!row) throw new NotFoundException('推广码不存在');
- return serializeBigInt(this.statsFromRow(row));
- }
-
- private statsFromRow(row: { scanCount: number; orderCount: number }) {
- const scanCount = row.scanCount;
- const orderCount = row.orderCount;
- const conversionRate =
- scanCount > 0 ? Math.round((orderCount / scanCount) * 1000) / 10 : 0;
- return { scanCount, orderCount, conversionRate };
- }
-}
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 9637995..01a8ce7 100644
--- a/server/dukang-api/src/modules/ops/admin-users.service.ts
+++ b/server/dukang-api/src/modules/ops/admin-users.service.ts
@@ -16,6 +16,9 @@ function mapAdminUserRow(u: {
wxOpenId: string | null;
nickname: string | null;
status: number;
+ sourceType: string;
+ sourceRefId: bigint | null;
+ sourceLabel: string | null;
createdAt: Date;
updatedAt: Date;
_count: { orders: number };
@@ -31,6 +34,9 @@ function mapAdminUserRow(u: {
wechatVerified: !!u.wxOpenId,
nickname: u.nickname,
status: u.status,
+ sourceType: u.sourceType,
+ sourceRefId: u.sourceRefId,
+ sourceLabel: u.sourceLabel,
createdAt: u.createdAt,
updatedAt: u.updatedAt,
orderCount: u._count.orders,
@@ -69,6 +75,9 @@ export class AdminUsersService {
wxOpenId: true,
nickname: true,
status: true,
+ sourceType: true,
+ sourceRefId: true,
+ sourceLabel: true,
createdAt: true,
updatedAt: true,
_count: { select: { orders: true } },
@@ -107,12 +116,21 @@ export class AdminUsersService {
});
if (!user) throw new NotFoundException('用户不存在');
+ let sourcePromo: { id: bigint; code: string; name: string } | null = null;
+ if (user.sourceType === 'PROMO_CODE' && user.sourceRefId) {
+ sourcePromo = await this.prisma.commonPromoCode.findUnique({
+ where: { id: user.sourceRefId },
+ select: { id: true, code: true, name: true },
+ });
+ }
+
return serializeBigInt({
...user,
wechatVerified: !!user.wxOpenId,
mergedFromCount: user._count.mergedFrom,
orderCount: user._count.orders,
addressCount: user._count.addresses,
+ sourcePromo,
_count: undefined,
});
}
diff --git a/server/dukang-api/src/modules/ops/ops.module.ts b/server/dukang-api/src/modules/ops/ops.module.ts
index ea07f7b..c3ae76e 100644
--- a/server/dukang-api/src/modules/ops/ops.module.ts
+++ b/server/dukang-api/src/modules/ops/ops.module.ts
@@ -33,8 +33,6 @@ import { AdminHqLogsController } from './admin-hq-logs.controller';
import { AdminHqLogsService } from './admin-hq-logs.service';
import { AdminTicketsController } from './admin-tickets.controller';
import { AdminTicketsService } from './admin-tickets.service';
-import { AdminPromoCodesController } from './admin-promo-codes.controller';
-import { AdminPromoCodesService } from './admin-promo-codes.service';
import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
import { BenefitModule } from '../benefit/benefit.module';
import { CommonModule } from '../common/common.module';
@@ -79,7 +77,6 @@ import { AdminHqPermissionsService } from './admin-hq-permissions.service';
AdminXiaofeixiaController,
AdminProductDetailTemplatesController,
AdminRedeemDebugController,
- AdminPromoCodesController,
AdminWechatBindingsController,
AdminHqPermissionsController,
],
@@ -103,7 +100,6 @@ import { AdminHqPermissionsService } from './admin-hq-permissions.service';
AdminXiaofeixiaService,
AdminProductDetailTemplatesService,
AdminRedeemDebugService,
- AdminPromoCodesService,
AdminWechatBindingsService,
AdminHqPermissionsService,
SuperAdminGuard,
diff --git a/server/dukang-api/src/modules/promo/admin-promo-code.controller.ts b/server/dukang-api/src/modules/promo/admin-promo-code.controller.ts
new file mode 100644
index 0000000..0095cc2
--- /dev/null
+++ b/server/dukang-api/src/modules/promo/admin-promo-code.controller.ts
@@ -0,0 +1,84 @@
+import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
+import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
+import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
+import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
+import { PromoCodeService } from './promo-code.service';
+import {
+ CreatePromoCodeDto,
+ PromoCodeListQueryDto,
+ UpdatePromoCodeDto,
+ UpdatePromoCodeStatusDto,
+} from './dto/promo-code.dto';
+
+@Controller('admin/promo-codes')
+@UseGuards(HqAuthGuard)
+export class AdminPromoCodeController {
+ constructor(private readonly service: PromoCodeService) {}
+
+ @Get('scenes')
+ listScenes() {
+ return this.service.listScenes();
+ }
+
+ @Get()
+ list(@Query() query: PromoCodeListQueryDto) {
+ return this.service.list(query);
+ }
+
+ @Get(':id/users')
+ listUsers(@Param('id') id: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
+ return this.service.listUsers(
+ BigInt(id),
+ page ? Number(page) : 1,
+ pageSize ? Number(pageSize) : 20,
+ );
+ }
+
+ @Get(':id/stats')
+ stats(@Param('id') id: string) {
+ return this.service.stats(BigInt(id));
+ }
+
+ @Get(':id/qrcode')
+ qrcode(@Param('id') id: string) {
+ return this.service.getQrcodeUrl(BigInt(id));
+ }
+
+ @Get(':id')
+ detail(@Param('id') id: string) {
+ return this.service.detail(BigInt(id));
+ }
+
+ @Post()
+ @HqOperation({
+ action: HqOperationAction.PROMO_CODE_CREATE,
+ refType: 'PROMO_CODE',
+ batch: true,
+ includeBody: true,
+ })
+ create(@Body() dto: CreatePromoCodeDto) {
+ return this.service.create(dto);
+ }
+
+ @Put(':id')
+ @HqOperation({
+ action: HqOperationAction.PROMO_CODE_UPDATE,
+ refType: 'PROMO_CODE',
+ refIdParam: 'id',
+ includeBody: true,
+ })
+ update(@Param('id') id: string, @Body() dto: UpdatePromoCodeDto) {
+ return this.service.update(BigInt(id), dto);
+ }
+
+ @Put(':id/status')
+ @HqOperation({
+ action: HqOperationAction.PROMO_CODE_UPDATE_STATUS,
+ refType: 'PROMO_CODE',
+ refIdParam: 'id',
+ includeBody: true,
+ })
+ updateStatus(@Param('id') id: string, @Body() dto: UpdatePromoCodeStatusDto) {
+ return this.service.updateStatus(BigInt(id), dto.status);
+ }
+}
diff --git a/server/dukang-api/src/modules/promo/dto/promo-code.dto.ts b/server/dukang-api/src/modules/promo/dto/promo-code.dto.ts
new file mode 100644
index 0000000..86fd453
--- /dev/null
+++ b/server/dukang-api/src/modules/promo/dto/promo-code.dto.ts
@@ -0,0 +1,83 @@
+import { Type } from 'class-transformer';
+import { IsIn, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
+import { PromoCodeScene, PromoCodeStatus } from '@dukang/shared-types';
+
+export class PromoCodeListQueryDto {
+ @IsOptional()
+ @Type(() => Number)
+ page?: number;
+
+ @IsOptional()
+ @Type(() => Number)
+ pageSize?: number;
+
+ @IsOptional()
+ @IsString()
+ name?: string;
+
+ @IsOptional()
+ @IsString()
+ code?: string;
+
+ @IsOptional()
+ @IsIn(['ACTIVE', 'DISABLED'])
+ status?: PromoCodeStatus;
+
+ @IsOptional()
+ @IsIn(['ONLINE_LINK', 'OFFLINE_PICKUP', 'PARTNER_CHANNEL', 'EVENT', 'OTHER'])
+ scene?: PromoCodeScene;
+
+ @IsOptional()
+ @IsString()
+ ownerUserId?: string;
+}
+
+export class CreatePromoCodeDto {
+ @IsString()
+ @IsNotEmpty()
+ @MaxLength(128)
+ name: string;
+
+ @IsOptional()
+ @IsString()
+ @MaxLength(32)
+ code?: string;
+
+ @IsOptional()
+ @IsIn(['ONLINE_LINK', 'OFFLINE_PICKUP', 'PARTNER_CHANNEL', 'EVENT', 'OTHER'])
+ scene?: PromoCodeScene;
+
+ @IsOptional()
+ @IsString()
+ ownerUserId?: string;
+
+ @IsOptional()
+ @IsString()
+ @MaxLength(256)
+ remark?: string;
+}
+
+export class UpdatePromoCodeDto {
+ @IsOptional()
+ @IsString()
+ @MaxLength(128)
+ name?: string;
+
+ @IsOptional()
+ @IsIn(['ONLINE_LINK', 'OFFLINE_PICKUP', 'PARTNER_CHANNEL', 'EVENT', 'OTHER'])
+ scene?: PromoCodeScene;
+
+ @IsOptional()
+ @IsString()
+ ownerUserId?: string | null;
+
+ @IsOptional()
+ @IsString()
+ @MaxLength(256)
+ remark?: string | null;
+}
+
+export class UpdatePromoCodeStatusDto {
+ @IsIn(['ACTIVE', 'DISABLED'])
+ status: PromoCodeStatus;
+}
diff --git a/server/dukang-api/src/modules/promo/promo-code.service.ts b/server/dukang-api/src/modules/promo/promo-code.service.ts
new file mode 100644
index 0000000..db9ceb0
--- /dev/null
+++ b/server/dukang-api/src/modules/promo/promo-code.service.ts
@@ -0,0 +1,515 @@
+import {
+ BadRequestException,
+ Inject,
+ Injectable,
+ NotFoundException,
+} from '@nestjs/common';
+import { randomBytes } from 'crypto';
+import * as QRCode from 'qrcode';
+import {
+ PROMO_CODE_SCENE_LABELS,
+ PromoCodeScene,
+ buildPromoLandingUrl,
+ loadAppConfig,
+} from '@dukang/shared-types';
+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 {
+ CreatePromoCodeDto,
+ PromoCodeListQueryDto,
+ UpdatePromoCodeDto,
+} from './dto/promo-code.dto';
+
+type PromoRow = {
+ id: bigint;
+ code: string;
+ name: string;
+ scene: string;
+ qrcodeId: string;
+ status: string;
+ remark: string | null;
+ scanCount: number;
+ orderCount: number;
+ createdAt: Date;
+ updatedAt: Date;
+ ownerUser?: {
+ id: bigint;
+ userNo: string | null;
+ nickname: string | null;
+ phone: string | null;
+ } | null;
+ qrcodeResource?: { url: string } | null;
+};
+
+function userH5Base(): string {
+ return loadAppConfig().userH5Url;
+}
+
+function buildLandingUrl(code: string, qrcodeId: string): string {
+ return buildPromoLandingUrl(userH5Base(), code, qrcodeId);
+}
+
+function randomPromoCode(): string {
+ const n = Math.random().toString(36).slice(2, 8).toUpperCase();
+ return `DK${n}`;
+}
+
+function randomQrcodeId(): string {
+ return randomBytes(32).toString('hex');
+}
+
+function maskPhone(phone: string | null | undefined) {
+ if (!phone || phone.length < 7) return phone ?? null;
+ return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
+}
+
+@Injectable()
+export class PromoCodeService {
+ constructor(
+ private readonly prisma: PrismaService,
+ @Inject(OSS_PROVIDER) private readonly oss: IOssProvider,
+ ) {}
+
+ listScenes() {
+ return Object.entries(PROMO_CODE_SCENE_LABELS).map(([value, label]) => ({ value, label }));
+ }
+
+ async listActiveOptions() {
+ const rows = await this.prisma.commonPromoCode.findMany({
+ where: { status: 'ACTIVE' },
+ select: { id: true, code: true, name: true },
+ orderBy: { createdAt: 'desc' },
+ take: 100,
+ });
+ return serializeBigInt(rows);
+ }
+
+ /** 代下单绑定推广码:归因 + 用户来源(若可写) */
+ async attributeUserToPromo(userId: bigint, promoId: bigint) {
+ const promo = await this.prisma.commonPromoCode.findUnique({
+ where: { id: promoId },
+ select: { id: true, name: true, status: true },
+ });
+ if (!promo || promo.status !== 'ACTIVE') {
+ throw new BadRequestException('推广码无效或已停用');
+ }
+ const existing = await this.prisma.userPromoAttribution.findUnique({
+ where: { userId },
+ });
+ if (!existing) {
+ await this.prisma.userPromoAttribution.create({
+ data: {
+ userId,
+ promoCodeId: promo.id,
+ channelName: promo.name,
+ firstTouchAt: new Date(),
+ },
+ });
+ }
+ await this.applyPromoSourceToUser(userId, promo);
+ return promo;
+ }
+
+ private mapOwnerUser(user: PromoRow['ownerUser']) {
+ if (!user) return null;
+ return serializeBigInt({
+ id: user.id,
+ userNo: user.userNo,
+ nickname: user.nickname,
+ phone: maskPhone(user.phone),
+ });
+ }
+
+ private mapRow(row: PromoRow) {
+ return serializeBigInt({
+ id: row.id,
+ code: row.code,
+ name: row.name,
+ scene: row.scene,
+ qrcodeId: row.qrcodeId,
+ status: row.status,
+ remark: row.remark,
+ scanCount: row.scanCount,
+ orderCount: row.orderCount,
+ landingUrl: buildLandingUrl(row.code, row.qrcodeId),
+ qrcodeUrl: row.qrcodeResource?.url ?? null,
+ ownerUser: this.mapOwnerUser(row.ownerUser),
+ createdAt: row.createdAt,
+ updatedAt: row.updatedAt,
+ });
+ }
+
+ private includeRelations = {
+ ownerUser: {
+ select: { id: true, userNo: true, nickname: true, phone: true },
+ },
+ qrcodeResource: {
+ select: { url: true },
+ },
+ } as const;
+
+ async list(query: PromoCodeListQueryDto) {
+ const page = query.page ?? 1;
+ const pageSize = query.pageSize ?? 20;
+ const where: {
+ status?: 'ACTIVE' | 'DISABLED';
+ scene?: PromoCodeScene;
+ name?: { contains: string };
+ code?: { contains: string };
+ ownerUserId?: bigint;
+ } = {};
+ if (query.status) where.status = query.status;
+ if (query.scene) where.scene = query.scene;
+ if (query.name) where.name = { contains: query.name };
+ if (query.code) where.code = { contains: query.code.toUpperCase() };
+ if (query.ownerUserId?.trim()) {
+ where.ownerUserId = BigInt(query.ownerUserId.trim());
+ }
+
+ const [items, total] = await Promise.all([
+ this.prisma.commonPromoCode.findMany({
+ where,
+ include: this.includeRelations,
+ orderBy: { createdAt: 'desc' },
+ skip: (page - 1) * pageSize,
+ take: pageSize,
+ }),
+ this.prisma.commonPromoCode.count({ where }),
+ ]);
+
+ return serializeBigInt({
+ items: items.map((r) => this.mapRow(r)),
+ total,
+ page,
+ pageSize,
+ });
+ }
+
+ async detail(id: bigint) {
+ const row = await this.prisma.commonPromoCode.findUnique({
+ where: { id },
+ include: this.includeRelations,
+ });
+ if (!row) throw new NotFoundException('推广码不存在');
+ const stats = await this.statsFromRow(row);
+ return serializeBigInt({ ...this.mapRow(row), stats });
+ }
+
+ private async resolveOwnerUserId(ownerUserId?: string) {
+ if (!ownerUserId?.trim()) return undefined;
+ const user = await this.prisma.user.findFirst({
+ where: { id: BigInt(ownerUserId.trim()), mergedIntoUserId: null, status: 1 },
+ select: { id: true },
+ });
+ if (!user) throw new BadRequestException('关联用户不存在');
+ return user.id;
+ }
+
+ private async generateUniqueCode(custom?: string) {
+ let code = custom?.trim().toUpperCase();
+ if (code) {
+ const exists = await this.prisma.commonPromoCode.findUnique({ where: { code } });
+ if (exists) throw new BadRequestException('推广码已存在');
+ return code;
+ }
+ for (let i = 0; i < 8; i++) {
+ const candidate = randomPromoCode();
+ const exists = await this.prisma.commonPromoCode.findUnique({ where: { code: candidate } });
+ if (!exists) return candidate;
+ }
+ throw new BadRequestException('生成推广码失败,请重试');
+ }
+
+ private async generateUniqueQrcodeId() {
+ for (let i = 0; i < 8; i++) {
+ const candidate = randomQrcodeId();
+ const exists = await this.prisma.commonPromoCode.findUnique({ where: { qrcodeId: candidate } });
+ if (!exists) return candidate;
+ }
+ throw new BadRequestException('生成二维码 ID 失败,请重试');
+ }
+
+ private async createQrcodeResource(promoId: bigint, code: string, qrcodeId: string) {
+ const landingUrl = buildLandingUrl(code, qrcodeId);
+ const pngBuffer = await QRCode.toBuffer(landingUrl, {
+ width: 512,
+ margin: 1,
+ type: 'png',
+ color: { dark: '#1f1a17', light: '#ffffff' },
+ });
+ const uploaded = await this.oss.putObject({
+ bizType: 'QRCODE',
+ mediaType: 'IMAGE',
+ fileName: `promo-${code}.png`,
+ buffer: pngBuffer,
+ mimeType: 'image/png',
+ });
+ const resource = await this.prisma.commonResource.create({
+ data: {
+ ownerType: 'PROMO',
+ ownerId: promoId,
+ bizType: 'QRCODE',
+ mediaType: 'IMAGE',
+ ossBucket: uploaded.bucket,
+ ossKey: uploaded.ossKey,
+ url: uploaded.url,
+ fileName: `promo-${code}.png`,
+ fileSize: BigInt(pngBuffer.length),
+ mimeType: 'image/png',
+ },
+ });
+ return resource;
+ }
+
+ async create(dto: CreatePromoCodeDto) {
+ const code = await this.generateUniqueCode(dto.code);
+ const qrcodeId = await this.generateUniqueQrcodeId();
+ const ownerUserId = await this.resolveOwnerUserId(dto.ownerUserId);
+ const scene = (dto.scene ?? 'ONLINE_LINK') as PromoCodeScene;
+
+ const row = await this.prisma.commonPromoCode.create({
+ data: {
+ code,
+ name: dto.name.trim(),
+ scene,
+ qrcodeId,
+ status: 'ACTIVE',
+ ownerUserId,
+ remark: dto.remark?.trim() || null,
+ },
+ });
+
+ const resource = await this.createQrcodeResource(row.id, code, qrcodeId);
+ const updated = await this.prisma.commonPromoCode.update({
+ where: { id: row.id },
+ data: { qrcodeResourceId: resource.id },
+ include: this.includeRelations,
+ });
+ return this.mapRow(updated);
+ }
+
+ async update(id: bigint, dto: UpdatePromoCodeDto) {
+ await this.detail(id);
+ const data: {
+ name?: string;
+ scene?: PromoCodeScene;
+ remark?: string | null;
+ ownerUserId?: bigint | null;
+ } = {};
+ if (dto.name !== undefined) data.name = dto.name.trim();
+ if (dto.scene !== undefined) data.scene = dto.scene;
+ if (dto.remark !== undefined) data.remark = dto.remark?.trim() || null;
+ if (dto.ownerUserId !== undefined) {
+ if (dto.ownerUserId === null || dto.ownerUserId === '') {
+ data.ownerUserId = null;
+ } else {
+ data.ownerUserId = await this.resolveOwnerUserId(dto.ownerUserId);
+ }
+ }
+
+ const row = await this.prisma.commonPromoCode.update({
+ where: { id },
+ data,
+ include: this.includeRelations,
+ });
+ return this.mapRow(row);
+ }
+
+ async updateStatus(id: bigint, status: 'ACTIVE' | 'DISABLED') {
+ const row = await this.prisma.commonPromoCode.update({
+ where: { id },
+ data: { status },
+ include: this.includeRelations,
+ });
+ return this.mapRow(row);
+ }
+
+ async stats(id: bigint) {
+ const row = await this.prisma.commonPromoCode.findUnique({ where: { id } });
+ if (!row) throw new NotFoundException('推广码不存在');
+ return serializeBigInt(await this.statsFromRow(row));
+ }
+
+ async getQrcodeUrl(id: bigint) {
+ const row = await this.prisma.commonPromoCode.findUnique({
+ where: { id },
+ include: { qrcodeResource: { select: { url: true } } },
+ });
+ if (!row) throw new NotFoundException('推广码不存在');
+ if (!row.qrcodeResource?.url) {
+ throw new NotFoundException('二维码资源不存在,请重新生成推广码');
+ }
+ return {
+ qrcodeUrl: row.qrcodeResource.url,
+ landingUrl: buildLandingUrl(row.code, row.qrcodeId),
+ name: row.name,
+ code: row.code,
+ };
+ }
+
+ async findByCodeOrQrcodeId(input: { code?: string; qrcodeId?: string }) {
+ const code = input.code?.trim().toUpperCase();
+ const qrcodeId = input.qrcodeId?.trim();
+ if (code) {
+ return this.prisma.commonPromoCode.findUnique({ where: { code } });
+ }
+ if (qrcodeId) {
+ return this.prisma.commonPromoCode.findUnique({ where: { qrcodeId } });
+ }
+ return null;
+ }
+
+ /** C 端扫码/带参进入:累加 scan_count、归因、标记用户来源 */
+ async touch(input: { promoCode?: string; qrcodeId?: string }, userId?: bigint) {
+ const promoCode = input.promoCode?.trim().toUpperCase();
+ const qrcodeId = input.qrcodeId?.trim();
+ if (!promoCode && !qrcodeId) {
+ throw new BadRequestException('请提供 promoCode 或 qrcodeId');
+ }
+
+ const promo = await this.findByCodeOrQrcodeId({ code: promoCode, qrcodeId });
+ if (!promo || promo.status !== 'ACTIVE') {
+ throw new NotFoundException('推广码无效或已停用');
+ }
+
+ await this.prisma.commonPromoCode.update({
+ where: { id: promo.id },
+ data: { scanCount: { increment: 1 } },
+ });
+
+ let attributed = false;
+ let sourceApplied = false;
+
+ if (userId) {
+ const existing = await this.prisma.userPromoAttribution.findUnique({
+ where: { userId },
+ });
+ if (!existing) {
+ await this.prisma.userPromoAttribution.create({
+ data: {
+ userId,
+ promoCodeId: promo.id,
+ channelName: promo.name,
+ firstTouchAt: new Date(),
+ },
+ });
+ attributed = true;
+ }
+
+ sourceApplied = await this.applyPromoSourceToUser(userId, promo);
+ }
+
+ return serializeBigInt({
+ promoCode: promo.code,
+ promoCodeId: promo.id,
+ channelName: promo.name,
+ attributed,
+ sourceApplied,
+ });
+ }
+
+ /** 用户来源:PROMO_CODE + 推广码 ID(仅 ORGANIC 可写入,不覆盖已有来源) */
+ async applyPromoSourceToUser(
+ userId: bigint,
+ promo: { id: bigint; name: string },
+ ): Promise {
+ const user = await this.prisma.user.findUnique({
+ where: { id: userId },
+ select: { sourceType: true, mergedIntoUserId: true, status: true },
+ });
+ if (!user || user.mergedIntoUserId || user.status !== 1 || user.sourceType !== 'ORGANIC') {
+ return false;
+ }
+
+ await this.prisma.user.update({
+ where: { id: userId },
+ data: {
+ sourceType: 'PROMO_CODE',
+ sourceRefId: promo.id,
+ sourceLabel: promo.name,
+ },
+ });
+ return true;
+ }
+
+ private async statsFromRow(row: { id: bigint; scanCount: number; orderCount: number }) {
+ const scanCount = row.scanCount;
+ const orderCount = row.orderCount;
+ const conversionRate =
+ scanCount > 0 ? Math.round((orderCount / scanCount) * 1000) / 10 : 0;
+ const [attributionCount, sourceMarkedCount] = await Promise.all([
+ this.prisma.userPromoAttribution.count({
+ where: { promoCodeId: row.id },
+ }),
+ this.prisma.user.count({
+ where: {
+ sourceType: 'PROMO_CODE',
+ sourceRefId: row.id,
+ mergedIntoUserId: null,
+ },
+ }),
+ ]);
+ return { scanCount, orderCount, conversionRate, attributionCount, sourceMarkedCount };
+ }
+
+ /** 推广码关联用户:归因记录或用户来源指向本码 */
+ async listUsers(promoId: bigint, page = 1, pageSize = 20) {
+ const promo = await this.prisma.commonPromoCode.findUnique({
+ where: { id: promoId },
+ select: { id: true },
+ });
+ if (!promo) throw new NotFoundException('推广码不存在');
+
+ const where = {
+ mergedIntoUserId: null,
+ OR: [
+ { promoTouch: { promoCodeId: promoId } },
+ { sourceType: 'PROMO_CODE' as const, sourceRefId: promoId },
+ ],
+ };
+
+ const [items, total] = await Promise.all([
+ this.prisma.user.findMany({
+ where,
+ orderBy: { createdAt: 'desc' },
+ skip: (page - 1) * pageSize,
+ take: pageSize,
+ select: {
+ id: true,
+ userNo: true,
+ nickname: true,
+ phone: true,
+ phoneVerifiedAt: true,
+ sourceType: true,
+ sourceRefId: true,
+ createdAt: true,
+ promoTouch: { select: { firstTouchAt: true, promoCodeId: true } },
+ _count: { select: { orders: true } },
+ },
+ }),
+ this.prisma.user.count({ where }),
+ ]);
+
+ return serializeBigInt({
+ items: items.map((u) => ({
+ id: u.id,
+ userNo: u.userNo,
+ nickname: u.nickname,
+ phone: maskPhone(u.phone),
+ phoneVerifiedAt: u.phoneVerifiedAt,
+ sourceType: u.sourceType,
+ sourceRefId: u.sourceRefId,
+ firstTouchAt: u.promoTouch?.promoCodeId.toString() === promoId.toString()
+ ? u.promoTouch.firstTouchAt
+ : null,
+ orderCount: u._count.orders,
+ createdAt: u.createdAt,
+ })),
+ total,
+ page,
+ pageSize,
+ });
+ }
+}
diff --git a/server/dukang-api/src/modules/promo/promo.module.ts b/server/dukang-api/src/modules/promo/promo.module.ts
new file mode 100644
index 0000000..f883aff
--- /dev/null
+++ b/server/dukang-api/src/modules/promo/promo.module.ts
@@ -0,0 +1,21 @@
+import { Module } from '@nestjs/common';
+import { JwtModule } from '@nestjs/jwt';
+import { IntegrationsModule } from '../../integrations/integrations.module';
+import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
+import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
+import { AdminPromoCodeController } from './admin-promo-code.controller';
+import { PromoCodeService } from './promo-code.service';
+
+@Module({
+ imports: [
+ IntegrationsModule,
+ JwtModule.register({
+ secret: process.env.JWT_SECRET || 'dukang-prev1-dev-secret',
+ signOptions: { expiresIn: process.env.JWT_EXPIRES_IN || '7d' },
+ }),
+ ],
+ controllers: [AdminPromoCodeController],
+ providers: [PromoCodeService, JwtAuthGuard, HqAuthGuard],
+ exports: [PromoCodeService],
+})
+export class PromoModule {}
diff --git a/server/dukang-api/src/modules/trade/dto/partner-proxy-order.dto.ts b/server/dukang-api/src/modules/trade/dto/partner-proxy-order.dto.ts
new file mode 100644
index 0000000..7216c1e
--- /dev/null
+++ b/server/dukang-api/src/modules/trade/dto/partner-proxy-order.dto.ts
@@ -0,0 +1,71 @@
+import { Type } from 'class-transformer';
+import { IsInt, IsNotEmpty, IsOptional, IsString, Matches, MaxLength, Min } from 'class-validator';
+
+export class PartnerProxyOrderPreviewDto {
+ @IsString()
+ @IsNotEmpty()
+ productId: string;
+
+ @Type(() => Number)
+ @IsInt()
+ @Min(1)
+ quantity: number;
+
+ @IsOptional()
+ @IsString()
+ receiverCity?: string;
+
+ @IsOptional()
+ @IsString()
+ receiverDistrict?: string;
+}
+
+export class PartnerProxyOrderSendSmsDto {
+ @IsString()
+ @Matches(/^1\d{10}$/, { message: '请输入有效手机号' })
+ phone: string;
+}
+
+export class PartnerProxyOrderCreateDto {
+ @IsString()
+ @Matches(/^1\d{10}$/, { message: '请输入有效手机号' })
+ phone: string;
+
+ @IsString()
+ @IsNotEmpty()
+ smsCode: string;
+
+ @IsOptional()
+ @IsString()
+ @MaxLength(32)
+ receiverName?: string;
+
+ @IsString()
+ @MaxLength(32)
+ province: string;
+
+ @IsString()
+ @MaxLength(32)
+ city: string;
+
+ @IsString()
+ @MaxLength(32)
+ district: string;
+
+ @IsString()
+ @MaxLength(256)
+ addressDetail: string;
+
+ @IsString()
+ @IsNotEmpty()
+ productId: string;
+
+ @Type(() => Number)
+ @IsInt()
+ @Min(1)
+ quantity: number;
+
+ @IsOptional()
+ @IsString()
+ promoCodeId?: string;
+}
diff --git a/server/dukang-api/src/modules/trade/trade.controller.ts b/server/dukang-api/src/modules/trade/trade.controller.ts
index 3dd2db4..5be90a1 100644
--- a/server/dukang-api/src/modules/trade/trade.controller.ts
+++ b/server/dukang-api/src/modules/trade/trade.controller.ts
@@ -5,6 +5,11 @@ import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
import { PhoneVerifiedGuard } from '../../common/guards/phone-verified.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
+import {
+ PartnerProxyOrderCreateDto,
+ PartnerProxyOrderPreviewDto,
+ PartnerProxyOrderSendSmsDto,
+} from './dto/partner-proxy-order.dto';
@Controller('trade/orders')
@UseGuards(JwtAuthGuard)
@@ -106,3 +111,33 @@ export class PartnerReshipmentController {
return this.tradeService.listPartnerReshipments(user.actorId);
}
}
+
+@Controller('partner/proxy-orders')
+@UseGuards(JwtAuthGuard, PartnerPrimaryGuard)
+export class PartnerProxyOrderController {
+ constructor(private readonly tradeService: TradeService) {}
+
+ @Get('options')
+ options() {
+ return this.tradeService.getPartnerProxyOrderOptions();
+ }
+
+ @Post('preview')
+ preview(@Body() dto: PartnerProxyOrderPreviewDto) {
+ return this.tradeService.previewPartnerProxyOrder(dto);
+ }
+
+ @Post('send-sms')
+ sendSms(@Body() dto: PartnerProxyOrderSendSmsDto) {
+ return this.tradeService.sendPartnerProxyOrderSms(dto.phone);
+ }
+
+ @Post()
+ create(
+ @CurrentUser() user: AuthUser,
+ @Body() dto: PartnerProxyOrderCreateDto,
+ @Req() req: Request,
+ ) {
+ return this.tradeService.createPartnerProxyOrder(user.actorId, dto, req);
+ }
+}
diff --git a/server/dukang-api/src/modules/trade/trade.module.ts b/server/dukang-api/src/modules/trade/trade.module.ts
index e6065a7..921d140 100644
--- a/server/dukang-api/src/modules/trade/trade.module.ts
+++ b/server/dukang-api/src/modules/trade/trade.module.ts
@@ -6,12 +6,32 @@ import { BenefitModule } from '../benefit/benefit.module';
import { CatalogModule } from '../catalog/catalog.module';
import { CommonModule } from '../common/common.module';
import { CityScopeModule } from '../city-scope/city-scope.module';
-import { TradeController, PartnerOrderController, PartnerReshipmentController } from './trade.controller';
+import { PromoModule } from '../promo/promo.module';
+import {
+ TradeController,
+ PartnerOrderController,
+ PartnerProxyOrderController,
+ PartnerReshipmentController,
+} from './trade.controller';
import { TradeService } from './trade.service';
@Module({
- imports: [IntegrationsModule, IamModule, CatalogModule, AnalyticsModule, CityScopeModule, forwardRef(() => BenefitModule), CommonModule],
- controllers: [TradeController, PartnerOrderController, PartnerReshipmentController],
+ imports: [
+ IntegrationsModule,
+ IamModule,
+ CatalogModule,
+ AnalyticsModule,
+ CityScopeModule,
+ PromoModule,
+ forwardRef(() => BenefitModule),
+ CommonModule,
+ ],
+ controllers: [
+ TradeController,
+ PartnerOrderController,
+ PartnerProxyOrderController,
+ PartnerReshipmentController,
+ ],
providers: [TradeService],
exports: [TradeService],
})
diff --git a/server/dukang-api/src/modules/trade/trade.service.ts b/server/dukang-api/src/modules/trade/trade.service.ts
index 37d2b02..abf5b39 100644
--- a/server/dukang-api/src/modules/trade/trade.service.ts
+++ b/server/dukang-api/src/modules/trade/trade.service.ts
@@ -11,13 +11,15 @@ import {
orderTabToStatuses,
validateMinPurchase,
} from '@dukang/domain';
-import { loadAppConfig, WECHAT_AUTH_REQUIRED } from '@dukang/shared-types';
+import { loadAppConfig, ClientApp, SmsScene, WECHAT_AUTH_REQUIRED } from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { AnalyticsService } from '../analytics/analytics.service';
import { CatalogService } from '../catalog/catalog.service';
import { BenefitService } from '../benefit/benefit.service';
import { PartnerCityService } from '../city-scope/partner-city.service';
+import { AuthService } from '../iam/auth.service';
+import { PromoCodeService } from '../promo/promo-code.service';
import { TicketService } from '../common/ticket.service';
import { PAY_PROVIDER, DELIVERY_PROVIDER } from '../../integrations/integrations.constants';
import { IPayProvider } from '../../integrations/pay/pay.interface';
@@ -41,6 +43,8 @@ export class TradeService {
@Inject(DELIVERY_PROVIDER) private readonly deliveryProvider: IDeliveryProvider,
private readonly analyticsService: AnalyticsService,
private readonly partnerCityService: PartnerCityService,
+ private readonly authService: AuthService,
+ private readonly promoCodeService: PromoCodeService,
) {}
async preview(userId: bigint, body: { productId: string; quantity: number; addressId?: string }) {
@@ -584,4 +588,225 @@ export class TradeService {
});
});
}
+
+ async getPartnerProxyOrderOptions() {
+ const [products, promoCodes] = await Promise.all([
+ this.catalogService.listProducts(),
+ this.promoCodeService.listActiveOptions(),
+ ]);
+ return serializeBigInt({
+ products: products.map((p) => ({
+ id: p.id,
+ name: p.name,
+ spec: p.spec,
+ price: Number(p.price),
+ benefitAmount: p.benefitAmount != null ? Number(p.benefitAmount) : null,
+ })),
+ promoCodes,
+ });
+ }
+
+ async previewPartnerProxyOrder(body: {
+ productId: string;
+ quantity: number;
+ receiverCity?: string;
+ receiverDistrict?: string;
+ }) {
+ const product = await this.catalogService.getProduct(BigInt(body.productId));
+ if (!product || product.status !== 'ON_SALE') {
+ throw new BadRequestException('商品不可购买');
+ }
+ const city = await this.prisma.commonCity.findFirst({ where: { status: 'ACTIVE' } });
+ if (!city) throw new BadRequestException('暂无开城城市');
+
+ let deliveryType: 'LOCAL' | 'CROSS_CITY' = 'LOCAL';
+ const receiverCity = body.receiverCity?.trim();
+ if (receiverCity && receiverCity !== city.name && receiverCity !== '郑州市') {
+ deliveryType = 'CROSS_CITY';
+ }
+
+ const check = validateMinPurchase(
+ deliveryType,
+ body.quantity,
+ city.localMinQty,
+ city.crossMinQty,
+ );
+ if (!check.ok) throw new BadRequestException(check.message);
+
+ const unitPrice = Number(product.price);
+ const productAmount = unitPrice * body.quantity;
+ const benefitPerUnit = calcBenefitAmount({
+ price: unitPrice,
+ benefitAmount: product.benefitAmount ? Number(product.benefitAmount) : null,
+ });
+
+ return {
+ productAmount,
+ payAmount: productAmount,
+ benefitAmount: benefitPerUnit * body.quantity,
+ deliveryType,
+ unitPrice,
+ };
+ }
+
+ async sendPartnerProxyOrderSms(phone: string) {
+ const normalizedPhone = phone.trim();
+ await this.authService.sendSms(normalizedPhone, SmsScene.PARTNER_PROXY_ORDER, {
+ clientApp: ClientApp.PARTNER_H5,
+ });
+ const masked =
+ normalizedPhone.length >= 7
+ ? `${normalizedPhone.slice(0, 3)}****${normalizedPhone.slice(-4)}`
+ : normalizedPhone;
+ return { ok: true, maskedPhone: masked };
+ }
+
+ async createPartnerProxyOrder(
+ partnerAccountId: bigint,
+ body: {
+ phone: string;
+ smsCode: string;
+ receiverName?: string;
+ province: string;
+ city: string;
+ district: string;
+ addressDetail: string;
+ productId: string;
+ quantity: number;
+ promoCodeId?: string;
+ },
+ req: Request,
+ ) {
+ const normalizedPhone = body.phone.trim();
+ await this.authService.verifySmsCode(
+ normalizedPhone,
+ body.smsCode.trim(),
+ SmsScene.PARTNER_PROXY_ORDER,
+ );
+
+ const user = await this.authService.findOrCreateUserByPhone(normalizedPhone);
+ const preview = await this.previewPartnerProxyOrder({
+ productId: body.productId,
+ quantity: body.quantity,
+ receiverCity: body.city,
+ receiverDistrict: body.district,
+ });
+
+ const product = await this.prisma.commonProductItem.findUniqueOrThrow({
+ where: { id: BigInt(body.productId) },
+ });
+ const city = await this.prisma.commonCity.findFirstOrThrow({ where: { status: 'ACTIVE' } });
+ const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
+ const paySnapshot = await this.partnerCityService.resolveForOrder(city.id, body.district);
+
+ let promoCodeId: bigint | undefined;
+ if (body.promoCodeId?.trim()) {
+ promoCodeId = BigInt(body.promoCodeId.trim());
+ await this.promoCodeService.attributeUserToPromo(user.id, promoCodeId);
+ }
+
+ const receiverName = body.receiverName?.trim() || `用户${normalizedPhone.slice(-4)}`;
+ const receiverAddress = `${body.province}${body.city}${body.district}${body.addressDetail}`;
+ const orderNo = generateOrderNo();
+ const now = new Date();
+
+ const location = buildOrderClientLocationSnapshot(
+ req,
+ this.ipGeoService.resolve(extractClientIp(req)),
+ undefined,
+ );
+
+ const order = await this.prisma.$transaction(async (tx) => {
+ const created = await tx.order.create({
+ data: {
+ orderNo,
+ orderType: 'PROXY',
+ userId: user.id,
+ cityId: city.id,
+ status: 'COMPLETED',
+ payStatus: 'PAID',
+ deliveryType: preview.deliveryType,
+ channelSource: 'OFFLINE_PROXY',
+ promoCodeId,
+ 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,
+ payAmount: preview.payAmount,
+ benefitAmount: preview.benefitAmount,
+ freightAmount: 0,
+ freightPayType: preview.deliveryType === 'CROSS_CITY' ? 'COD' : null,
+ receiverName,
+ receiverPhone: normalizedPhone,
+ receiverAddress,
+ receiverProvince: body.province,
+ receiverCity: body.city,
+ receiverDistrict: body.district,
+ clientIp: location.clientIp,
+ ipProvince: location.ipProvince,
+ ipCity: location.ipCity,
+ ipDistrict: location.ipDistrict,
+ paidAt: now,
+ shippedAt: now,
+ completedAt: now,
+ partnerAccountIdAtPay: paySnapshot?.partnerAccountId ?? primary.id,
+ orderCommissionRateAtPay: paySnapshot?.orderCommissionRate ?? null,
+ remark: `合伙人代下单 partnerAccountId=${primary.id}`,
+ },
+ include: { product: true, imageResource: true },
+ });
+
+ await tx.orderDelivery.create({
+ data: {
+ orderId: created.id,
+ provider: 'MANUAL',
+ outWarehouseAt: now,
+ shippingAt: now,
+ deliveredAt: now,
+ },
+ });
+
+ await tx.commonEvent.create({
+ data: buildOrderStatusEvent({
+ orderId: created.id,
+ fromStatus: 'PENDING_PAY',
+ toStatus: 'COMPLETED',
+ operator: 'PARTNER_PROXY',
+ remark: '合伙人线下代下单',
+ }),
+ });
+
+ if (promoCodeId) {
+ await tx.commonPromoCode.update({
+ where: { id: promoCodeId },
+ data: { orderCount: { increment: 1 } },
+ });
+ }
+
+ return created;
+ });
+
+ await this.benefitService.grantOnOrderPaid(order.id);
+
+ this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
+ partnerAccountId: primary.id,
+ eventName: 'partner_proxy_order_create',
+ refType: 'ORDER',
+ refId: order.id,
+ extraJson: {
+ orderId: order.id.toString(),
+ userId: user.id.toString(),
+ productId: body.productId,
+ quantity: body.quantity,
+ promoCodeId: promoCodeId?.toString() ?? null,
+ },
+ });
+
+ return this.getPartnerOrder(partnerAccountId, order.id);
+ }
}