From 06b1cb22e0e3b39ed79fdab2c0a21cf9f85ce467 Mon Sep 17 00:00:00 2001 From: jacy-dukang Date: Sun, 12 Jul 2026 12:00:55 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BC=B1=E7=BD=91=E6=A0=B8=E9=94=80=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin-web/src/App.tsx | 2 + apps/admin-web/src/layouts/AdminLayout.tsx | 1 + .../admin-web/src/pages/PendingRedeemPage.tsx | 266 ++++++++++++ .../src/components/WeakNetFallbackPanel.tsx | 159 +++++++ apps/h5-shop/src/lib/api.ts | 13 +- apps/h5-shop/src/lib/redeem-failure.ts | 50 +++ apps/h5-shop/src/lib/upload.ts | 91 ++++ apps/h5-shop/src/pages/RedeemConfirmPage.tsx | 65 ++- apps/h5-shop/src/styles.css | 65 +++ apps/h5-user/src/pages/RedeemPage.tsx | 15 + packages/domain/src/index.test.ts | 26 ++ packages/domain/src/index.ts | 30 ++ packages/shared-types/src/enums.ts | 4 + packages/shared-types/src/redeem.ts | 43 ++ packages/shared-types/src/store-log.ts | 13 +- server/dukang-api/prisma/schema.prisma | 79 +++- .../hq-operation/hq-operation.constants.ts | 4 + .../src/common/redis/redis.service.ts | 12 + .../ops/admin-redeem-pending.controller.ts | 52 +++ .../ops/dto/admin-redeem-pending.dto.ts | 26 ++ .../dukang-api/src/modules/ops/ops.module.ts | 2 + .../modules/redeem/dto/weaknet-redeem.dto.ts | 40 ++ .../src/modules/redeem/redeem.controller.ts | 17 + .../src/modules/redeem/redeem.service.ts | 401 +++++++++++++++++- 24 files changed, 1434 insertions(+), 42 deletions(-) create mode 100644 apps/admin-web/src/pages/PendingRedeemPage.tsx create mode 100644 apps/h5-shop/src/components/WeakNetFallbackPanel.tsx create mode 100644 apps/h5-shop/src/lib/redeem-failure.ts create mode 100644 apps/h5-shop/src/lib/upload.ts create mode 100644 server/dukang-api/src/modules/ops/admin-redeem-pending.controller.ts create mode 100644 server/dukang-api/src/modules/ops/dto/admin-redeem-pending.dto.ts create mode 100644 server/dukang-api/src/modules/redeem/dto/weaknet-redeem.dto.ts diff --git a/apps/admin-web/src/App.tsx b/apps/admin-web/src/App.tsx index 694f46e..e345e66 100644 --- a/apps/admin-web/src/App.tsx +++ b/apps/admin-web/src/App.tsx @@ -10,6 +10,7 @@ import StoreAccountsPage from './pages/StoreAccountsPage'; import BenefitCouponsPage from './pages/BenefitCouponsPage'; import BenefitLedgersPage from './pages/BenefitLedgersPage'; import RedeemRecordsPage from './pages/RedeemRecordsPage'; +import PendingRedeemPage from './pages/PendingRedeemPage'; import RedeemDebugPage from './pages/RedeemDebugPage'; import DeliveriesPage from './pages/DeliveriesPage'; import XiaofeixiaTestPage from './pages/XiaofeixiaTestPage'; @@ -75,6 +76,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/admin-web/src/layouts/AdminLayout.tsx b/apps/admin-web/src/layouts/AdminLayout.tsx index 2915d2f..b841023 100644 --- a/apps/admin-web/src/layouts/AdminLayout.tsx +++ b/apps/admin-web/src/layouts/AdminLayout.tsx @@ -66,6 +66,7 @@ const MENU_ITEMS: MenuProps['items'] = [ { key: '/benefit/coupons', label: '权益券' }, { key: '/benefit/ledgers', label: '流水' }, { key: '/redeem-records', label: '核销记录' }, + { key: '/redeem-pending', label: '待处理核销' }, { key: '/redeem/debug', label: '核销调试' }, ], }, diff --git a/apps/admin-web/src/pages/PendingRedeemPage.tsx b/apps/admin-web/src/pages/PendingRedeemPage.tsx new file mode 100644 index 0000000..413cf53 --- /dev/null +++ b/apps/admin-web/src/pages/PendingRedeemPage.tsx @@ -0,0 +1,266 @@ +import { useState } from 'react'; +import { + Button, + Descriptions, + Drawer, + Form, + Image, + Input, + Modal, + Select, + Space, + Table, + Tag, + Typography, + message, +} from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { + REDEEM_PENDING_STATUS_LABELS, + type RedeemPendingItem, + type RedeemPendingStatus, +} from '@dukang/shared-types'; +import { request } from '../lib/api'; +import { fmtTime } from '../lib/constants'; +import { useAdminList } from '../lib/useAdminList'; + +const STATUS_COLOR: Record = { + PENDING: 'orange', + COMPLETED: 'green', + REJECTED: 'default', +}; + +export default function PendingRedeemPage() { + const [form] = Form.useForm(); + const [filters, setFilters] = useState>({}); + const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList( + '/admin/redeem-pending', + () => { + const qs = new URLSearchParams(); + if (filters.status) qs.set('status', filters.status); + if (filters.pendingNo) qs.set('pendingNo', filters.pendingNo); + if (filters.redeemToken) qs.set('redeemToken', filters.redeemToken); + if (filters.storeId) qs.set('storeId', filters.storeId); + return qs; + }, + [filters], + ); + const [detail, setDetail] = useState(null); + const [drawerOpen, setDrawerOpen] = useState(false); + const [acting, setActing] = useState(false); + const [rejectOpen, setRejectOpen] = useState(false); + const [rejectReason, setRejectReason] = useState(''); + + async function openDetail(id: string) { + const res = await request(`/admin/redeem-pending/${id}`); + setDetail(res); + setDrawerOpen(true); + } + + async function complete() { + if (!detail) return; + setActing(true); + try { + await request(`/admin/redeem-pending/${detail.id}/complete`, { method: 'POST', body: '{}' }); + message.success('已补核销'); + setDrawerOpen(false); + void reload(); + } catch (e) { + message.error(e instanceof Error ? e.message : '补核销失败'); + } finally { + setActing(false); + } + } + + async function reject() { + if (!detail || !rejectReason.trim()) { + message.error('请填写驳回原因'); + return; + } + setActing(true); + try { + await request(`/admin/redeem-pending/${detail.id}/reject`, { + method: 'POST', + body: JSON.stringify({ reason: rejectReason.trim() }), + }); + message.success('已驳回'); + setRejectOpen(false); + setDrawerOpen(false); + void reload(); + } catch (e) { + message.error(e instanceof Error ? e.message : '驳回失败'); + } finally { + setActing(false); + } + } + + const columns: ColumnsType = [ + { title: '待处理单号', dataIndex: 'pendingNo', width: 170 }, + { + title: '核销码 ID', + dataIndex: 'redeemToken', + width: 160, + ellipsis: true, + render: (v: string) => {v.slice(0, 8)}…, + }, + { title: '门店', dataIndex: ['store', 'name'], width: 140, render: (_, row) => row.store?.name || '—' }, + { + title: '用户', + width: 120, + render: (_, row) => row.user?.userNo || row.user?.phone || '—', + }, + { title: '金额', dataIndex: 'amount', width: 90, render: (v) => `¥${v}` }, + { title: '失败次数', dataIndex: 'failCount', width: 90 }, + { + title: '状态', + dataIndex: 'status', + width: 100, + render: (s: RedeemPendingStatus) => ( + {REDEEM_PENDING_STATUS_LABELS[s] || s} + ), + }, + { title: '提交时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, + { + title: '操作', + width: 80, + fixed: 'right', + render: (_, row) => ( + + ), + }, + ]; + + return ( +
+ 待处理核销(弱网兜底) +
{ + setFilters(v); + setPage(1); + }} + > + + + + + + + + + + + { + const file = e.target.files?.[0]; + if (file) void handleFile(file); + }} + /> + {previewUrl && ( + 核销码照片预览 + )} +
+ + + +
+ + )} + + {msg &&

{msg}

} + + ); +} diff --git a/apps/h5-shop/src/lib/api.ts b/apps/h5-shop/src/lib/api.ts index 5931cf8..7a72a41 100644 --- a/apps/h5-shop/src/lib/api.ts +++ b/apps/h5-shop/src/lib/api.ts @@ -124,10 +124,19 @@ async function rawRequest( if (authToken) headers.Authorization = `Bearer ${authToken}`; const res = await fetch(`${apiBase}${path}`, { ...options, headers }); - const json = await res.json(); + let json: { code: number; message?: string; data?: T }; + try { + json = await res.json(); + } catch { + const err = new Error(res.ok ? '接口返回异常' : `网络异常(HTTP ${res.status})`) as Error & { + status?: number; + }; + err.status = res.status; + throw err; + } if (json.code !== 0) { const err = new Error(json.message || '请求失败') as Error & { status?: number }; - err.status = json.code; + err.status = res.status >= 500 ? res.status : json.code; throw err; } return json.data as T; diff --git a/apps/h5-shop/src/lib/redeem-failure.ts b/apps/h5-shop/src/lib/redeem-failure.ts new file mode 100644 index 0000000..0cff208 --- /dev/null +++ b/apps/h5-shop/src/lib/redeem-failure.ts @@ -0,0 +1,50 @@ +import { REDEEM_WEAKNET_FAIL_THRESHOLD } from '@dukang/shared-types'; +import type { RedeemErrorClass, RedeemFailureReportResult } from '@dukang/shared-types'; +import { request } from './api'; + +export function isNetworkError(e: unknown): boolean { + const err = e as Error & { status?: number }; + const status = err.status; + const message = (err.message ?? String(e)).toLowerCase(); + if (status != null && status >= 500) return true; + if (status === 0 || status === 408 || status === 429 || status === 502 || status === 503 || status === 504) { + return true; + } + if (/failed to fetch|network|timeout|timed out|abort|offline|连接|网络|超时/.test(message)) { + return true; + } + // 无 status 的 fetch 失败通常是网络类 + if (status == null && e instanceof TypeError) return true; + return false; +} + +export async function reportRedeemFailure( + token: string, + step: 'preview' | 'confirm', + error: unknown, +): Promise { + const err = error as Error & { status?: number }; + const errorClass: RedeemErrorClass = isNetworkError(error) ? 'NETWORK' : 'BUSINESS'; + try { + return await request('SHOP_H5', '/shop/redeem/failures', { + method: 'POST', + body: JSON.stringify({ + token, + errorClass, + message: err.message?.slice(0, 200), + step, + }), + }); + } catch { + if (errorClass === 'NETWORK') { + return { + failCount: REDEEM_WEAKNET_FAIL_THRESHOLD, + thresholdReached: true, + threshold: REDEEM_WEAKNET_FAIL_THRESHOLD, + }; + } + return null; + } +} + +export { REDEEM_WEAKNET_FAIL_THRESHOLD }; diff --git a/apps/h5-shop/src/lib/upload.ts b/apps/h5-shop/src/lib/upload.ts new file mode 100644 index 0000000..4b7a272 --- /dev/null +++ b/apps/h5-shop/src/lib/upload.ts @@ -0,0 +1,91 @@ +import { apiBase } from './api'; +import { getStoreProfile } from './api'; + +const UPLOAD_TIMEOUT_MS = 120_000; + +export type UploadFileResult = { + url: string; + ossKey: string; + bucket: string; + mock: boolean; +}; + +export type RegisteredResource = { + id: string; + url: string; +}; + +async function uploadFileToOss(file: File, bizType: string): Promise { + const formData = new FormData(); + formData.append('file', file); + formData.append('bizType', bizType); + formData.append('mediaType', 'IMAGE'); + + const token = localStorage.getItem('accessToken'); + const headers: Record = { 'X-Client-App': 'SHOP_H5' }; + if (token) headers.Authorization = `Bearer ${token}`; + + const controller = new AbortController(); + const timeoutId = window.setTimeout(() => controller.abort(), UPLOAD_TIMEOUT_MS); + + try { + const res = await fetch(`${apiBase}/common/resources/upload`, { + method: 'POST', + headers, + body: formData, + signal: controller.signal, + }); + const json = await res.json(); + if (json.code === 401) { + localStorage.removeItem('accessToken'); + throw new Error('未登录'); + } + if (json.code !== 0) { + throw new Error(json.message || '上传失败'); + } + return json.data as UploadFileResult; + } catch (e) { + if (e instanceof DOMException && e.name === 'AbortError') { + throw new Error('上传超时,请检查网络后重试'); + } + throw e; + } finally { + window.clearTimeout(timeoutId); + } +} + +async function registerResource(upload: UploadFileResult, fileName: string): Promise { + const profile = getStoreProfile(); + if (!profile?.storeId) throw new Error('门店信息缺失,请重新登录'); + + const token = localStorage.getItem('accessToken'); + const headers: Record = { + 'Content-Type': 'application/json', + 'X-Client-App': 'SHOP_H5', + }; + if (token) headers.Authorization = `Bearer ${token}`; + + const res = await fetch(`${apiBase}/common/resources`, { + method: 'POST', + headers, + body: JSON.stringify({ + ownerType: 'STORE', + ownerId: profile.storeId, + bizType: 'REDEEM_PENDING_PHOTO', + mediaType: 'IMAGE', + ossKey: upload.ossKey, + url: upload.url, + ossBucket: upload.bucket, + fileName, + }), + }); + const json = await res.json(); + if (json.code !== 0) throw new Error(json.message || '登记资源失败'); + return { id: String(json.data.id), url: String(json.data.url) }; +} + +/** 上传核销码照片并登记为 CommonResource,返回 resourceId */ +export async function uploadRedeemPendingPhoto(file: File): Promise { + const uploaded = await uploadFileToOss(file, 'REDEEM_PENDING_PHOTO'); + return registerResource(uploaded, file.name || 'redeem-pending.jpg'); +} diff --git a/apps/h5-shop/src/pages/RedeemConfirmPage.tsx b/apps/h5-shop/src/pages/RedeemConfirmPage.tsx index e4f6597..2b6a09f 100644 --- a/apps/h5-shop/src/pages/RedeemConfirmPage.tsx +++ b/apps/h5-shop/src/pages/RedeemConfirmPage.tsx @@ -1,6 +1,8 @@ import { useEffect, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; +import WeakNetFallbackPanel from '../components/WeakNetFallbackPanel'; import { request } from '../lib/api'; +import { reportRedeemFailure } from '../lib/redeem-failure'; function formatAmount(n: number) { return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); @@ -23,6 +25,8 @@ export default function RedeemConfirmPage() { const [storeName, setStoreName] = useState(''); const [preview, setPreview] = useState(null); const [storeClosed, setStoreClosed] = useState(false); + const [failCount, setFailCount] = useState(0); + const [showWeakNet, setShowWeakNet] = useState(false); useEffect(() => { request>('SHOP_H5', '/shop/store') @@ -52,9 +56,16 @@ export default function RedeemConfirmPage() { body: JSON.stringify({ token }), }) .then(setPreview) - .catch((e) => { + .catch(async (e) => { setPreview(null); setMsg(e instanceof Error ? e.message : '无法预览核销码'); + const report = await reportRedeemFailure(token, 'preview', e); + if (report?.thresholdReached) { + setFailCount(report.failCount); + setShowWeakNet(true); + } else if (report) { + setFailCount(report.failCount); + } }); }, [token]); @@ -78,6 +89,13 @@ export default function RedeemConfirmPage() { navigate('/redeem/success', { state: { result, storeName, user: preview?.user } }); } catch (e) { setMsg(e instanceof Error ? e.message : '核销失败'); + const report = await reportRedeemFailure(token, 'confirm', e); + if (report?.thresholdReached) { + setFailCount(report.failCount); + setShowWeakNet(true); + } else if (report) { + setFailCount(report.failCount); + } } finally { setLoading(false); } @@ -139,8 +157,10 @@ export default function RedeemConfirmPage() {
- 券编号 - {token ? `…${token.slice(-8)}` : '扫码后显示'} + 核销码 ID + + {token || '扫码后显示'} +
有效期 @@ -152,26 +172,39 @@ export default function RedeemConfirmPage() { 仅限指定门店
)} + {failCount > 0 && !showWeakNet && ( +
+ 网络失败 + {failCount} / 5 次 +
+ )}
{msg &&

{msg}

} - - -

请核对金额后点击确认

+ {!showWeakNet && ( + <> + +

请核对金额后点击确认

+ + )}
+ {showWeakNet && token && ( + + )} +
wine_bar diff --git a/apps/h5-shop/src/styles.css b/apps/h5-shop/src/styles.css index f2fc880..748a039 100644 --- a/apps/h5-shop/src/styles.css +++ b/apps/h5-shop/src/styles.css @@ -1203,6 +1203,71 @@ margin-top: 8px; } +.shop-weaknet-panel { + margin: 0 16px 16px; + padding: 16px; + border-radius: 16px; + background: rgba(166, 29, 36, 0.06); + border: 1px solid rgba(166, 29, 36, 0.18); +} + +.shop-weaknet-token { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 12px; +} + +.shop-weaknet-copy { + display: block; + width: 100%; + text-align: left; + word-break: break-all; + padding: 10px 12px; + border-radius: 10px; + border: 1px dashed rgba(166, 29, 36, 0.35); + background: #fff; + font-family: ui-monospace, monospace; + font-size: 12px; + color: var(--color-on-surface, #1c1b1b); + cursor: pointer; +} + +.shop-weaknet-preview { + width: 100%; + max-height: 220px; + object-fit: contain; + border-radius: 12px; + margin-bottom: 12px; + background: #fff; +} + +.shop-weaknet-actions { + display: flex; + flex-direction: column; + gap: 10px; +} + +.shop-weaknet-success { + padding: 12px; + border-radius: 12px; + background: rgba(46, 125, 50, 0.08); +} + +.shop-btn-outline { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + min-height: 48px; + border-radius: 999px; + border: 1px solid var(--color-heritage-red, #a61d24); + background: transparent; + color: var(--color-heritage-red, #a61d24); + font-size: 15px; + font-weight: 600; +} + .shop-redeem-ornament { margin-top: auto; display: flex; diff --git a/apps/h5-user/src/pages/RedeemPage.tsx b/apps/h5-user/src/pages/RedeemPage.tsx index 7ab4868..246fdce 100644 --- a/apps/h5-user/src/pages/RedeemPage.tsx +++ b/apps/h5-user/src/pages/RedeemPage.tsx @@ -331,6 +331,21 @@ export default function RedeemPage() {

待核销金额

¥ {formatMoney(confirmAmount)}

+ {token && ( +
+

核销码编号(供追查)

+

+ {token} +

+
+ )}