From e93e4b8f84a8b5c55c72bf854d8e2563117e428a Mon Sep 17 00:00:00 2001
From: jacy <18049821889@163.com>
Date: Mon, 3 Aug 2026 20:47:28 +0800
Subject: [PATCH] feat: audit diff, mock SMS 999888, env photos, proxy pay
lock, mini-user UX
Co-authored-by: Cursor
---
AGENTS.md | 2 +-
apps/admin-web/src/pages/LoginPage.tsx | 5 +-
apps/admin-web/src/pages/RedeemDebugPage.tsx | 5 +-
.../src/pages/StorePackageAuditsPage.tsx | 229 ++++++++++++++++--
apps/admin-web/src/pages/StoresPage.tsx | 8 +-
apps/h5-partner/src/lib/storeDraft.ts | 13 +-
.../src/pages/ProxyOrderDetailPage.tsx | 69 +++++-
apps/h5-partner/src/pages/ProxyOrderPage.tsx | 76 +++++-
apps/h5-partner/src/pages/StoreCreatePage.tsx | 16 +-
apps/h5-partner/src/pages/StoreDetailPage.tsx | 19 +-
apps/mini-hq/src/pages/login/index.tsx | 5 +-
apps/mini-user/src/pages/home/index.tsx | 32 ++-
.../src/pages/store-detail/index.tsx | 56 +----
packages/shared-types/src/config.ts | 3 +
packages/shared-types/src/store-package.ts | 5 +
packages/shared-types/src/trade.ts | 2 +
.../src/integrations/sms/sms-code.store.ts | 11 +-
.../src/integrations/sms/sms.mock.provider.ts | 4 +-
.../modules/store/store-package.controller.ts | 5 +
.../modules/store/store-package.service.ts | 22 ++
.../src/modules/store/store.service.ts | 5 +-
.../src/modules/trade/trade.controller.ts | 5 +
.../src/modules/trade/trade.service.ts | 62 ++++-
23 files changed, 524 insertions(+), 135 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 1b2ceb7..24c262b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -40,7 +40,7 @@ node scripts/smoke-prev1.mjs
pnpm lint && pnpm test
```
-Mock 验证码:`123456`。测试账号见 [`README.md`](./README.md)。
+Mock 验证码:`999888`。测试账号见 [`README.md`](./README.md)。
> **端口冲突**:`h5-partner` 与 `admin-web` 同为 5175,勿同时 `dev:partner` + `dev:admin`。
diff --git a/apps/admin-web/src/pages/LoginPage.tsx b/apps/admin-web/src/pages/LoginPage.tsx
index 59461da..7a99bea 100644
--- a/apps/admin-web/src/pages/LoginPage.tsx
+++ b/apps/admin-web/src/pages/LoginPage.tsx
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button, Card, Form, Input, Tabs, message, Typography } from 'antd';
+import { MOCK_SMS_FIXED_CODE } from '@dukang/shared-types';
import { saveAuth, request } from '../lib/api';
type LoginResult = { accessToken: string; refreshToken: string };
@@ -117,14 +118,14 @@ export default function LoginPage() {
form={smsForm}
layout="vertical"
onFinish={onSmsFinish}
- initialValues={{ phone: '13600000001', code: '123456' }}
+ initialValues={{ phone: '13600000001', code: MOCK_SMS_FIXED_CODE }}
>
0} onClick={() => void sendCode()}>
{codeCooldown > 0 ? `${codeCooldown}s` : '获取验证码'}
diff --git a/apps/admin-web/src/pages/RedeemDebugPage.tsx b/apps/admin-web/src/pages/RedeemDebugPage.tsx
index d48aaa1..690265a 100644
--- a/apps/admin-web/src/pages/RedeemDebugPage.tsx
+++ b/apps/admin-web/src/pages/RedeemDebugPage.tsx
@@ -2,6 +2,7 @@ import { useState } from 'react';
import {
Button, Card, Col, Form, Input, InputNumber, Row, Space, Tabs, Typography, message,
} from 'antd';
+import { MOCK_SMS_FIXED_CODE } from '@dukang/shared-types';
import { request } from '../lib/api';
type ApiResult = Record;
@@ -255,7 +256,7 @@ export default function RedeemDebugPage() {
-
+
-
+
)}
diff --git a/apps/h5-partner/src/lib/storeDraft.ts b/apps/h5-partner/src/lib/storeDraft.ts
index 1679dc9..4786d42 100644
--- a/apps/h5-partner/src/lib/storeDraft.ts
+++ b/apps/h5-partner/src/lib/storeDraft.ts
@@ -82,12 +82,18 @@ function timeToMinutes(hhmm: string): number {
return h * 60 + m;
}
+export const MIN_ENV_PHOTO_COUNT = 3;
+
export function normalizeStringArray(urls: unknown, minLen: number): string[] {
const arr = Array.isArray(urls) ? urls.map((u) => String(u ?? '')) : [];
while (arr.length < minLen) arr.push('');
return arr;
}
+export function addEnvPhotoSlot(urls: string[]): string[] {
+ return [...urls, ''];
+}
+
export function normalizeStoreDraftForm(raw: Partial | null | undefined): StoreDraftForm {
const base = defaultStoreForm();
if (!raw) return base;
@@ -104,7 +110,7 @@ export function normalizeStoreDraftForm(raw: Partial | null | un
openTime2: String(raw.openTime2 ?? base.openTime2),
closeTime2: String(raw.closeTime2 ?? base.closeTime2),
avgPrice: String(raw.avgPrice ?? base.avgPrice),
- envPhotoUrls: normalizeStringArray(raw.envPhotoUrls, 3),
+ envPhotoUrls: normalizeStringArray(raw.envPhotoUrls, MIN_ENV_PHOTO_COUNT),
packages: Array.isArray(raw.packages)
? raw.packages.map((p, i) => ({
name: String((p as { name?: string }).name ?? ''),
@@ -215,13 +221,14 @@ export function validateStoreStep2(
): string | null {
if (!form.coverUrl.trim()) return '请上传门头照';
const envCount = form.envPhotoUrls.filter((u) => u.trim()).length;
- if (envCount < 3) return '请上传至少 3 张环境照片';
+ if (envCount < MIN_ENV_PHOTO_COUNT) return `请上传至少 ${MIN_ENV_PHOTO_COUNT} 张环境照片`;
if (!form.contractUrl.trim()) return '请上传签约合同';
return null;
}
export function patchEnvPhotoAt(urls: string[], index: number, url: string): string[] {
- const envPhotoUrls = normalizeStringArray(urls, 3);
+ const envPhotoUrls = [...urls];
+ while (envPhotoUrls.length <= index) envPhotoUrls.push('');
envPhotoUrls[index] = url;
return envPhotoUrls;
}
diff --git a/apps/h5-partner/src/pages/ProxyOrderDetailPage.tsx b/apps/h5-partner/src/pages/ProxyOrderDetailPage.tsx
index 49af50c..17aa81e 100644
--- a/apps/h5-partner/src/pages/ProxyOrderDetailPage.tsx
+++ b/apps/h5-partner/src/pages/ProxyOrderDetailPage.tsx
@@ -59,6 +59,7 @@ export default function ProxyOrderDetailPage() {
const [payMethod, setPayMethod] = useState('NATIVE');
const [codeUrl, setCodeUrl] = useState(null);
const [qrDataUrl, setQrDataUrl] = useState(null);
+ const [cancelling, setCancelling] = useState(false);
const pollRef = useRef(null);
function stopPoll() {
@@ -77,7 +78,12 @@ export default function ProxyOrderDetailPage() {
useEffect(() => {
if (!id) return;
void request('PARTNER_H5', `/partner/proxy-orders/${id}`)
- .then(setOrder)
+ .then((data) => {
+ setOrder(data);
+ if (data.proxyPayMethod) {
+ setPayMethod(data.proxyPayMethod);
+ }
+ })
.catch((e) => setError(e instanceof Error ? e.message : '加载失败'));
}, [id]);
@@ -129,6 +135,15 @@ export default function ProxyOrderDetailPage() {
async function startPay(method: ProxyPayMethod) {
if (!id) return;
+ const locked = order?.proxyPayMethod;
+ if (locked && locked !== method) {
+ setPayMsg(
+ locked === 'JSAPI'
+ ? '该订单已发起微信代付,请先取消支付后重新下单'
+ : '该订单已生成收款码,请先取消支付后重新下单',
+ );
+ return;
+ }
setPaying(true);
setPayMsg('');
setPayMethod(method);
@@ -166,12 +181,14 @@ export default function ProxyOrderDetailPage() {
if (pay.mode === 'native' && pay.codeUrl) {
setCodeUrl(pay.codeUrl);
+ setOrder((prev) => (prev ? { ...prev, proxyPayMethod: method } : prev));
startPoll(id);
return;
}
if (pay.mode === 'jsapi' && pay.prepay) {
setCodeUrl(null);
+ setOrder((prev) => (prev ? { ...prev, proxyPayMethod: method } : prev));
await invokeWechatPay(pay.prepay, {
apiBase: '/api/v1',
clientApp: 'PARTNER_H5',
@@ -191,14 +208,27 @@ export default function ProxyOrderDetailPage() {
}
}
- const autoPayStartedRef = useRef(false);
- useEffect(() => {
- if (!id || !unpaid || autoPayStartedRef.current) return;
- autoPayStartedRef.current = true;
- void startPay('NATIVE');
- // 仅首次进入未支付详情时自动拉收款码
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [id, unpaid]);
+ async function cancelPay() {
+ if (!id) return;
+ setCancelling(true);
+ setPayMsg('');
+ stopPoll();
+ try {
+ await request('PARTNER_H5', `/partner/proxy-orders/${id}/cancel-pay`, {
+ method: 'POST',
+ body: '{}',
+ });
+ toastSuccess('已取消支付,可重新下单并选择其他支付方式');
+ navigate('/center/proxy-orders');
+ } catch (e) {
+ setPayMsg(e instanceof Error ? e.message : '取消失败');
+ toastError(e instanceof Error ? e.message : '取消失败');
+ } finally {
+ setCancelling(false);
+ }
+ }
+
+ const lockedPayMethod = order?.proxyPayMethod ?? null;
const img = order?.imageResource?.url || '';
const isOnSite = String(order?.deliveryType || '').toUpperCase() === 'ON_SITE_PICKUP';
@@ -285,6 +315,7 @@ export default function ProxyOrderDetailPage() {
void startPay('NATIVE')}
>
收款码
@@ -292,11 +323,21 @@ export default function ProxyOrderDetailPage() {
void startPay('JSAPI')}
>
微信代付
+ {lockedPayMethod ? (
+
+ 已选择{lockedPayMethod === 'NATIVE' ? '收款码' : '微信代付'},切换方式请先取消支付
+
+ ) : (
+
+ 请选择支付方式并点击下方按钮发起支付
+
+ )}
{payMethod === 'NATIVE' ? (
@@ -338,6 +379,16 @@ export default function ProxyOrderDetailPage() {
{payMsg}
) : null}
+
+
void cancelPay()}
+ >
+ {cancelling ? '取消中…' : '取消支付'}
+
) : null}
diff --git a/apps/h5-partner/src/pages/ProxyOrderPage.tsx b/apps/h5-partner/src/pages/ProxyOrderPage.tsx
index 70364f8..4cca0f8 100644
--- a/apps/h5-partner/src/pages/ProxyOrderPage.tsx
+++ b/apps/h5-partner/src/pages/ProxyOrderPage.tsx
@@ -70,6 +70,8 @@ export default function ProxyOrderPage() {
const [codeUrl, setCodeUrl] = useState
(null);
const [qrDataUrl, setQrDataUrl] = useState(null);
const [paying, setPaying] = useState(false);
+ const [cancelling, setCancelling] = useState(false);
+ const [lockedPayMethod, setLockedPayMethod] = useState(null);
const pollRef = useRef(null);
const region = useMemo(() => parseRegionCodes(regionCodes), [regionCodes]);
@@ -234,8 +236,17 @@ export default function ProxyOrderPage() {
}
async function startPay(orderId: string, method: ProxyPayMethod) {
+ if (lockedPayMethod && lockedPayMethod !== method) {
+ setMsg(
+ lockedPayMethod === 'JSAPI'
+ ? '该订单已发起微信代付,请先取消支付后重新下单'
+ : '该订单已生成收款码,请先取消支付后重新下单',
+ );
+ return;
+ }
setPaying(true);
setMsg('');
+ setPayMethod(method);
try {
if (method === 'JSAPI') {
if (!isWechatEnv()) {
@@ -266,11 +277,14 @@ export default function ProxyOrderPage() {
if (pay.mode === 'native' && pay.codeUrl) {
setCodeUrl(pay.codeUrl);
+ setLockedPayMethod(method);
startPoll(orderId);
return;
}
if (pay.mode === 'jsapi' && pay.prepay) {
+ setCodeUrl(null);
+ setLockedPayMethod(method);
await invokeWechatPay(pay.prepay, {
apiBase: '/api/v1',
clientApp: 'PARTNER_H5',
@@ -289,6 +303,40 @@ export default function ProxyOrderPage() {
}
}
+ async function cancelPay(orderId: string) {
+ setCancelling(true);
+ setMsg('');
+ stopPoll();
+ try {
+ await request('PARTNER_H5', `/partner/proxy-orders/${orderId}/cancel-pay`, {
+ method: 'POST',
+ body: '{}',
+ });
+ toastSuccess('已取消支付,可重新下单并选择其他支付方式');
+ setStep('form');
+ setCreated(null);
+ setCodeUrl(null);
+ setLockedPayMethod(null);
+ } catch (e) {
+ setMsg(e instanceof Error ? e.message : '取消失败');
+ } finally {
+ setCancelling(false);
+ }
+ }
+
+ function switchPayMethod(method: ProxyPayMethod) {
+ if (lockedPayMethod && lockedPayMethod !== method) {
+ setMsg(
+ lockedPayMethod === 'JSAPI'
+ ? '该订单已发起微信代付,请先取消支付后重新下单'
+ : '该订单已生成收款码,请先取消支付后重新下单',
+ );
+ return;
+ }
+ setPayMethod(method);
+ setMsg('');
+ }
+
async function submit() {
setMsg('');
const err = validateForm();
@@ -322,6 +370,7 @@ export default function ProxyOrderPage() {
setCreated(order);
setStep('pay');
setCodeUrl(null);
+ setLockedPayMethod(null);
await startPay(order.id, payMethod);
} catch (e) {
setMsg(e instanceof Error ? e.message : '下单失败');
@@ -359,24 +408,25 @@ export default function ProxyOrderPage() {
{
- setPayMethod('NATIVE');
- void startPay(created.id, 'NATIVE');
- }}
+ disabled={!!lockedPayMethod && lockedPayMethod !== 'NATIVE'}
+ onClick={() => switchPayMethod('NATIVE')}
>
收款码
{
- setPayMethod('JSAPI');
- void startPay(created.id, 'JSAPI');
- }}
+ disabled={!!lockedPayMethod && lockedPayMethod !== 'JSAPI'}
+ onClick={() => switchPayMethod('JSAPI')}
>
微信代付
+ {lockedPayMethod ? (
+
+ 已选择{lockedPayMethod === 'NATIVE' ? '收款码' : '微信代付'},切换方式请先取消支付
+
+ ) : null}
{payMethod === 'NATIVE' ? (
@@ -419,6 +469,16 @@ export default function ProxyOrderPage() {
{msg}
) : null}
+
+ void cancelPay(created.id)}
+ >
+ {cancelling ? '取消中…' : '取消支付'}
+
>
) : loadingOptions ? (
加载商品…
diff --git a/apps/h5-partner/src/pages/StoreCreatePage.tsx b/apps/h5-partner/src/pages/StoreCreatePage.tsx
index 14ebd80..1e57293 100644
--- a/apps/h5-partner/src/pages/StoreCreatePage.tsx
+++ b/apps/h5-partner/src/pages/StoreCreatePage.tsx
@@ -40,7 +40,8 @@ import {
validateStoreStep3,
patchEnvPhotoAt,
-
+ addEnvPhotoSlot,
+ MIN_ENV_PHOTO_COUNT,
} from '../lib/storeDraft';
import StorePackagesForm from '../components/StorePackagesForm';
import { normalizePackageFormItems, validatePackageFormItems } from '../lib/storePackages';
@@ -956,7 +957,7 @@ export default function StoreCreatePage() {
环境照片 *
- 至少 3 张,展示店内整洁环境
+ 至少 {MIN_ENV_PHOTO_COUNT} 张,展示店内整洁环境
@@ -982,6 +983,17 @@ export default function StoreCreatePage() {
+
+ setForm((prev) => ({ ...prev, envPhotoUrls: addEnvPhotoSlot(prev.envPhotoUrls) }))
+ }
+ >
+ 添加环境照片
+
+
diff --git a/apps/h5-partner/src/pages/StoreDetailPage.tsx b/apps/h5-partner/src/pages/StoreDetailPage.tsx
index 9dacc7d..94ef10c 100644
--- a/apps/h5-partner/src/pages/StoreDetailPage.tsx
+++ b/apps/h5-partner/src/pages/StoreDetailPage.tsx
@@ -5,7 +5,7 @@ import { request } from '../lib/api';
import { toastError, toastSuccess } from '../lib/toast';
import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { canManagePartnerStore } from '../lib/partnerAccess';
-import { normalizeStringArray, patchEnvPhotoAt } from '../lib/storeDraft';
+import { MIN_ENV_PHOTO_COUNT, normalizeStringArray, patchEnvPhotoAt, addEnvPhotoSlot } from '../lib/storeDraft';
import OssUploadField from '../components/OssUploadField';
import TencentLocPickerOverlay from '../components/TencentLocPickerOverlay';
import {
@@ -18,7 +18,6 @@ import {
} from '../lib/storeStatus';
const STATUS_OPTIONS: StoreStatusValue[] = ['OPEN', 'PAUSED', 'CLOSED'];
-const ENV_SLOT_COUNT = 3;
function uniqueEnvUrls(urls: string[]): string[] {
const seen = new Set();
@@ -81,7 +80,7 @@ export default function StoreDetailPage() {
.map((m) => String(m.url || '')),
)
: [];
- setEnvPhotoUrls(normalizeStringArray(envFromMedia, ENV_SLOT_COUNT));
+ setEnvPhotoUrls(normalizeStringArray(envFromMedia, MIN_ENV_PHOTO_COUNT));
}
useEffect(() => {
@@ -196,8 +195,8 @@ export default function StoreDetailPage() {
setActionError('请上传门头照');
return;
}
- if (nextEnv.length < ENV_SLOT_COUNT) {
- setActionError(`请上传至少 ${ENV_SLOT_COUNT} 张环境照片`);
+ if (nextEnv.length < MIN_ENV_PHOTO_COUNT) {
+ setActionError(`请上传至少 ${MIN_ENV_PHOTO_COUNT} 张环境照片`);
return;
}
setMediaSaving(true);
@@ -400,7 +399,7 @@ export default function StoreDetailPage() {
店内环境
{canMutate && !readOnly
- ? `需 ${ENV_SLOT_COUNT} 张 · 已选 ${uniqueEnvUrls(envPhotoUrls).length} 张`
+ ? `至少 ${MIN_ENV_PHOTO_COUNT} 张 · 已选 ${uniqueEnvUrls(envPhotoUrls).length} 张`
: envPhotos.length
? `已上传 ${envPhotos.length} 张`
: '暂无照片'}
@@ -420,6 +419,14 @@ export default function StoreDetailPage() {
/>
))}
+ setEnvPhotoUrls((prev) => addEnvPhotoSlot(prev))}
+ >
+ 添加环境照片
+
{
setCooldown((s) => {
diff --git a/apps/mini-user/src/pages/home/index.tsx b/apps/mini-user/src/pages/home/index.tsx
index d229a1c..e969609 100644
--- a/apps/mini-user/src/pages/home/index.tsx
+++ b/apps/mini-user/src/pages/home/index.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useMemo, useRef, useState } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { View, Text, Image, Swiper, SwiperItem } from '@tarojs/components';
import Taro, {
useDidShow,
@@ -200,6 +200,18 @@ export default function HomePage() {
return map;
}, [products]);
+ const visibleAromaTabs = useMemo(
+ () => AROMA_TABS.filter((t) => productsByAroma[t.key].length > 0),
+ [productsByAroma],
+ );
+
+ useEffect(() => {
+ if (loading || visibleAromaTabs.length === 0) return;
+ if (!visibleAromaTabs.some((t) => t.key === activeAroma)) {
+ setActiveAroma(visibleAromaTabs[0].key);
+ }
+ }, [loading, visibleAromaTabs, activeAroma]);
+
const banners = miniHome.banners;
const footerUrl = miniHome.footerUrl;
@@ -246,18 +258,18 @@ export default function HomePage() {
if (now - lastScrollSyncAtRef.current < 80) return;
lastScrollSyncAtRef.current = now;
const query = Taro.createSelectorQuery();
- AROMA_TABS.forEach((t) => {
+ visibleAromaTabs.forEach((t) => {
query.select(`#${aromaSectionId(t.key)}`).boundingClientRect();
});
query.exec((rects) => {
if (!Array.isArray(rects) || rects.length === 0) return;
- let next: AromaKey = AROMA_TABS[0].key;
- for (let i = 0; i < AROMA_TABS.length; i++) {
+ let next: AromaKey = visibleAromaTabs[0]?.key ?? AROMA_TABS[0].key;
+ for (let i = 0; i < visibleAromaTabs.length; i++) {
const rect = rects[i] as { top?: number } | null;
if (!rect || rect.top == null) continue;
// 区块顶进入导航下方一带时视为当前香型
if (rect.top <= AROMA_NAV_OFFSET_PX + 24) {
- next = AROMA_TABS[i].key;
+ next = visibleAromaTabs[i].key;
}
}
setActiveAroma((prev) => (prev === next ? prev : next));
@@ -341,7 +353,7 @@ export default function HomePage() {
- {AROMA_TABS.map((t) => (
+ {visibleAromaTabs.map((t) => (
0 &&
- AROMA_TABS.map((t) => {
+ visibleAromaTabs.map((t) => {
const list = productsByAroma[t.key];
return (
{t.label}
- {list.length === 0 ? (
- 该香型暂未上线
- ) : (
- list.map((p) => renderProductCard(p))
- )}
+ {list.map((p) => renderProductCard(p))}
);
})}
diff --git a/apps/mini-user/src/pages/store-detail/index.tsx b/apps/mini-user/src/pages/store-detail/index.tsx
index edadfe8..ed7cf0e 100644
--- a/apps/mini-user/src/pages/store-detail/index.tsx
+++ b/apps/mini-user/src/pages/store-detail/index.tsx
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { View, Text, Image, Textarea } from '@tarojs/components';
+import { View, Text, Image } from '@tarojs/components';
import Taro, {
useDidShow,
useLoad,
@@ -152,9 +152,6 @@ export default function StoreDetailPage() {
const [recentRedeems, setRecentRedeems] = useState([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState('');
- const [disputeOpen, setDisputeOpen] = useState(false);
- const [disputeRemark, setDisputeRemark] = useState('');
- const [disputeSubmitting, setDisputeSubmitting] = useState(false);
const [headerSolid, setHeaderSolid] = useState(false);
const storeRef = useRef(null);
storeRef.current = store;
@@ -276,27 +273,6 @@ export default function StoreDetailPage() {
Taro.makePhoneCall({ phoneNumber: store.phone }).catch(() => toast('无法拨打电话'));
}
- async function submitPackageDispute() {
- if (!storeId) return;
- setDisputeSubmitting(true);
- try {
- await request('/trade/package-disputes', {
- method: 'POST',
- body: JSON.stringify({
- storeId,
- remark: disputeRemark.trim() || '用户对门店套餐有异议',
- }),
- });
- toast('已提交套餐异议');
- setDisputeOpen(false);
- setDisputeRemark('');
- } catch (e) {
- toast(e instanceof Error ? e.message : '提交失败');
- } finally {
- setDisputeSubmitting(false);
- }
- }
-
function openMap() {
if (!store) return;
const lat = store.latitude != null ? Number(store.latitude) : NaN;
@@ -440,9 +416,6 @@ export default function StoreDetailPage() {
) : null}
))}
- setDisputeOpen(true)}>
- 对套餐有异议?提交反馈
-
) : null}
@@ -477,33 +450,6 @@ export default function StoreDetailPage() {
) : null}
- {disputeOpen ? (
- !disputeSubmitting && setDisputeOpen(false)}>
- e.stopPropagation()}>
- 套餐异议
- 请描述您对门店套餐内容的异议,客服将跟进处理。
-
-
- ) : null}
-
): AppConf
wechatPayEnabled: resolveWechatPayEnabled(base, e),
};
}
+
+/** Mock 短信环境固定验证码 */
+export const MOCK_SMS_FIXED_CODE = '999888';
diff --git a/packages/shared-types/src/store-package.ts b/packages/shared-types/src/store-package.ts
index 4d01381..d1bd87d 100644
--- a/packages/shared-types/src/store-package.ts
+++ b/packages/shared-types/src/store-package.ts
@@ -48,6 +48,11 @@ export interface StorePackageChangeRequestDto {
createdAt: string;
}
+/** 套餐变更审核详情(含线上现行套餐,供对比) */
+export interface StorePackageAuditDetailDto extends StorePackageChangeRequestDto {
+ livePackages: StorePackageViewDto[];
+}
+
export interface StorePackageAuditAction {
action: 'APPROVE' | 'REJECT';
rejectReason?: string;
diff --git a/packages/shared-types/src/trade.ts b/packages/shared-types/src/trade.ts
index 9c38a61..463a067 100644
--- a/packages/shared-types/src/trade.ts
+++ b/packages/shared-types/src/trade.ts
@@ -152,6 +152,8 @@ export type PartnerProxyOrderListItem = OrderDto & {
deliveryType?: string | null;
productSpec?: string | null;
imageResource?: { url?: string } | null;
+ /** 已锁定的支付方式(首次拉起支付后不可切换,须取消后重新下单) */
+ proxyPayMethod?: ProxyPayMethod | null;
};
export type PartnerProxyOrderListResponse = {
diff --git a/server/dukang-api/src/integrations/sms/sms-code.store.ts b/server/dukang-api/src/integrations/sms/sms-code.store.ts
index d137a23..c1f20ab 100644
--- a/server/dukang-api/src/integrations/sms/sms-code.store.ts
+++ b/server/dukang-api/src/integrations/sms/sms-code.store.ts
@@ -1,7 +1,9 @@
import { BadRequestException, Injectable } from '@nestjs/common';
-import { SMS_CODE_TTL_SECONDS } from '@dukang/shared-types';
+import { MOCK_SMS_FIXED_CODE, SMS_CODE_TTL_SECONDS } from '@dukang/shared-types';
import { RedisService } from '../../common/redis/redis.service';
+export { MOCK_SMS_FIXED_CODE };
+
const RATE_TTL_SECONDS = 60;
function codeKey(phone: string, scene: string) {
@@ -31,12 +33,15 @@ export class SmsCodeStore {
await this.redis.client.set(rateKey(phone), '1', 'EX', RATE_TTL_SECONDS);
}
- async generateAndStore(phone: string, scene: string): Promise {
- const code = randomSixDigitCode();
+ async storeCode(phone: string, scene: string, code: string): Promise {
await this.redis.client.set(codeKey(phone, scene), code, 'EX', SMS_CODE_TTL_SECONDS);
return code;
}
+ async generateAndStore(phone: string, scene: string): Promise {
+ return this.storeCode(phone, scene, randomSixDigitCode());
+ }
+
async verifyAndConsume(phone: string, scene: string, code: string) {
const key = codeKey(phone, scene);
const stored = await this.redis.client.get(key);
diff --git a/server/dukang-api/src/integrations/sms/sms.mock.provider.ts b/server/dukang-api/src/integrations/sms/sms.mock.provider.ts
index 7372780..3fb8852 100644
--- a/server/dukang-api/src/integrations/sms/sms.mock.provider.ts
+++ b/server/dukang-api/src/integrations/sms/sms.mock.provider.ts
@@ -2,7 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../common/prisma/prisma.module';
import { MockSmsCodeService } from '../../common/mock-sms-code/mock-sms-code.service';
import type { ISmsProvider, SmsActorRef, SmsSendResult } from './sms.interface';
-import { SmsCodeStore } from './sms-code.store';
+import { MOCK_SMS_FIXED_CODE, SmsCodeStore } from './sms-code.store';
function maskPhone(phone: string) {
return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
@@ -19,7 +19,7 @@ export class SmsMockProvider implements ISmsProvider {
) {}
async send(phone: string, scene: string, actorRef?: SmsActorRef): Promise {
- const code = await this.smsCodeStore.generateAndStore(phone, scene);
+ const code = await this.smsCodeStore.storeCode(phone, scene, MOCK_SMS_FIXED_CODE);
await this.mockSmsCodeService.record(phone, scene, code);
const masked = maskPhone(phone);
this.logger.log(`Mock SMS → ${masked} scene=${scene} code=${code}`);
diff --git a/server/dukang-api/src/modules/store/store-package.controller.ts b/server/dukang-api/src/modules/store/store-package.controller.ts
index 8546b18..6fe1689 100644
--- a/server/dukang-api/src/modules/store/store-package.controller.ts
+++ b/server/dukang-api/src/modules/store/store-package.controller.ts
@@ -83,6 +83,11 @@ export class AdminStorePackageAuditController {
});
}
+ @Get(':requestId')
+ detail(@Param('requestId') requestId: string) {
+ return this.packages.adminGetAuditDetail(BigInt(requestId));
+ }
+
@Put(':requestId/audit')
audit(
@CurrentUser() user: AuthUser,
diff --git a/server/dukang-api/src/modules/store/store-package.service.ts b/server/dukang-api/src/modules/store/store-package.service.ts
index 0f3c357..4438a1e 100644
--- a/server/dukang-api/src/modules/store/store-package.service.ts
+++ b/server/dukang-api/src/modules/store/store-package.service.ts
@@ -221,6 +221,28 @@ export class StorePackageService {
]);
}
+ async adminGetAuditDetail(requestId: bigint) {
+ const req = await this.prisma.storePackageChangeRequest.findUnique({
+ where: { id: requestId },
+ include: { store: { select: { id: true, name: true } } },
+ });
+ if (!req) throw new NotFoundException('审核记录不存在');
+ const livePackages = await this.listLivePackages(req.storeId);
+ return serializeBigInt({
+ id: req.id.toString(),
+ storeId: req.storeId.toString(),
+ storeName: req.store.name,
+ status: req.status,
+ packages: req.packagesJson as unknown as StorePackageItemDto[],
+ livePackages,
+ submitterType: req.submitterType,
+ submitterId: req.submitterId.toString(),
+ rejectReason: req.rejectReason,
+ reviewedAt: req.reviewedAt?.toISOString() ?? null,
+ createdAt: req.createdAt.toISOString(),
+ });
+ }
+
async adminListAudits(query: { status?: string; page?: number; pageSize?: number }) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
diff --git a/server/dukang-api/src/modules/store/store.service.ts b/server/dukang-api/src/modules/store/store.service.ts
index 28cd046..0bb4db1 100644
--- a/server/dukang-api/src/modules/store/store.service.ts
+++ b/server/dukang-api/src/modules/store/store.service.ts
@@ -670,7 +670,7 @@ export class StoreService {
return this.partnerGetStore(partnerAccountId, storeId);
}
- /** 重新上传门头照 / 环境照:先软删旧 ENV,再写入去重后的新图(最多 3 张) */
+ /** 重新上传门头照 / 环境照:先软删旧 ENV,再写入去重后的新图(至少 3 张,不设上限) */
async partnerUpdateStoreMedia(
partnerAccountId: bigint,
storeId: bigint,
@@ -1271,7 +1271,7 @@ export class StoreService {
await this.assertStoreOwnedByAccount(partnerAccountId, storeId);
}
- private normalizeEnvPhotoUrls(raw: unknown, max = 3): string[] {
+ private normalizeEnvPhotoUrls(raw: unknown): string[] {
if (!Array.isArray(raw)) return [];
const seen = new Set();
const urls: string[] = [];
@@ -1280,7 +1280,6 @@ export class StoreService {
if (!url || seen.has(url)) continue;
seen.add(url);
urls.push(url);
- if (urls.length >= max) break;
}
return urls;
}
diff --git a/server/dukang-api/src/modules/trade/trade.controller.ts b/server/dukang-api/src/modules/trade/trade.controller.ts
index ff5a18b..6fc1a9a 100644
--- a/server/dukang-api/src/modules/trade/trade.controller.ts
+++ b/server/dukang-api/src/modules/trade/trade.controller.ts
@@ -258,6 +258,11 @@ export class PartnerProxyOrderController {
return this.tradeService.payPartnerProxyOrder(user.actorId, BigInt(id), dto.payMethod);
}
+ @Post(':id/cancel-pay')
+ cancelPay(@CurrentUser() user: AuthUser, @Param('id') id: string) {
+ return this.tradeService.cancelPartnerProxyOrder(user.actorId, BigInt(id));
+ }
+
@Post(':id/pay/mock-confirm')
mockConfirmPay(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.tradeService.mockConfirmProxyPay(BigInt(id), {
diff --git a/server/dukang-api/src/modules/trade/trade.service.ts b/server/dukang-api/src/modules/trade/trade.service.ts
index 3331ee9..13a6610 100644
--- a/server/dukang-api/src/modules/trade/trade.service.ts
+++ b/server/dukang-api/src/modules/trade/trade.service.ts
@@ -1545,7 +1545,52 @@ export class TradeService {
where: orderStatusLogWhere(orderId),
orderBy: { createdAt: 'desc' },
});
- return serializeBigInt(mapOrderCompat({ ...order, statusLogs: mapStatusLogCompat(statusLogs) }));
+ return serializeBigInt({
+ ...mapOrderCompat({ ...order, statusLogs: mapStatusLogCompat(statusLogs) }),
+ proxyPayMethod: this.parseProxyPayMethod(order.channelSource),
+ });
+ }
+
+ private parseProxyPayMethod(channelSource: string | null | undefined): 'NATIVE' | 'JSAPI' | null {
+ if (!channelSource) return null;
+ const m = channelSource.match(/^PROXY_ONLINE:(NATIVE|JSAPI)$/);
+ return m ? (m[1] as 'NATIVE' | 'JSAPI') : null;
+ }
+
+ private proxyChannelWithMethod(method: 'NATIVE' | 'JSAPI'): string {
+ return `PROXY_ONLINE:${method}`;
+ }
+
+ /** 合伙人取消代下单支付(关闭待支付订单,可重新下单并选择其他支付方式) */
+ async cancelPartnerProxyOrder(partnerAccountId: bigint, orderId: bigint) {
+ const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
+ const order = await this.prisma.order.findFirst({
+ where: {
+ id: orderId,
+ orderType: 'PROXY',
+ proxyPartnerAccountId: primary.id,
+ },
+ });
+ if (!order) throw new NotFoundException('代下单不存在');
+ if (order.status !== 'PENDING_PAY' || order.payStatus !== 'UNPAID') {
+ throw new BadRequestException('仅待支付订单可取消');
+ }
+ await this.prisma.$transaction(async (tx) => {
+ await tx.order.update({
+ where: { id: orderId },
+ data: { status: 'CANCELLED', cancelledAt: new Date() },
+ });
+ await tx.commonEvent.create({
+ data: buildOrderStatusEvent({
+ orderId,
+ fromStatus: 'PENDING_PAY',
+ toStatus: 'CANCELLED',
+ operator: 'PARTNER_PROXY',
+ remark: '合伙人取消代下单支付',
+ }),
+ });
+ });
+ return serializeBigInt({ id: orderId.toString(), status: 'CANCELLED' });
}
/** HQ 代下单:商品/推广码选项(运营侧可看白名单测试酒) */
@@ -1775,6 +1820,21 @@ export class TradeService {
throw new BadRequestException('订单已超时未支付');
}
+ const lockedMethod = this.parseProxyPayMethod(order.channelSource);
+ if (lockedMethod && lockedMethod !== payMethod) {
+ throw new BadRequestException(
+ lockedMethod === 'JSAPI'
+ ? '该订单已发起微信代付,请先取消支付后重新下单'
+ : '该订单已生成收款码,请先取消支付后重新下单',
+ );
+ }
+ if (!lockedMethod) {
+ await this.prisma.order.update({
+ where: { id: orderId },
+ data: { channelSource: this.proxyChannelWithMethod(payMethod) },
+ });
+ }
+
this.payRedeemAnomaly.onPayAttempt(Number(order.payAmount), {
orderNo: order.orderNo,
userId: order.userId,