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) => ( + void openDetail(row.id)}> + 详情 + + ), + }, + ]; + + return ( + + 待处理核销(弱网兜底) + { + setFilters(v); + setPage(1); + }} + > + + + + + + + + + + + ({ + value, + label, + }))} + /> + + + + 查询 + + + + + { + setPage(p); + setPageSize(ps); + }, + }} + /> + + setDrawerOpen(false)} + extra={ + detail?.status === 'PENDING' && ( + + { setRejectReason(''); setRejectOpen(true); }}> + 驳回 + + void complete()}> + 人工补核销 + + + ) + } + > + {detail && ( + <> + {detail.photoUrl && ( + + + + )} + + {detail.pendingNo} + + {detail.redeemToken} + + + + {REDEEM_PENDING_STATUS_LABELS[detail.status]} + + + ¥{detail.amount} + {detail.redeemType} + {detail.failCount} + + {detail.store?.name || '—'}({detail.store?.id}) + + + {detail.user?.userNo || '—'} / {detail.user?.phone || '无手机'} + + + {detail.redeemRecord?.redeemNo || '—'} + + {detail.rejectReason || '—'} + {detail.remark || '—'} + {fmtTime(detail.createdAt)} + + {detail.processedAt ? fmtTime(detail.processedAt) : '—'} + + + > + )} + + + void reject()} + onCancel={() => setRejectOpen(false)} + > + setRejectReason(e.target.value)} + /> + + + ); +} diff --git a/apps/h5-shop/src/components/WeakNetFallbackPanel.tsx b/apps/h5-shop/src/components/WeakNetFallbackPanel.tsx new file mode 100644 index 0000000..bcf49c1 --- /dev/null +++ b/apps/h5-shop/src/components/WeakNetFallbackPanel.tsx @@ -0,0 +1,159 @@ +import { useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { request } from '../lib/api'; +import { uploadRedeemPendingPhoto } from '../lib/upload'; +import { isWechatEnv, weixinSdk } from '../lib/weixin'; +import type { RedeemPendingSubmitResult } from '@dukang/shared-types'; + +type Props = { + redeemToken: string; + failCount: number; +}; + +export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props) { + const navigate = useNavigate(); + const inputRef = useRef(null); + const [uploading, setUploading] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [previewUrl, setPreviewUrl] = useState(''); + const [photoResourceId, setPhotoResourceId] = useState(''); + const [result, setResult] = useState(null); + const [msg, setMsg] = useState(''); + + async function handleFile(file: File) { + setUploading(true); + setMsg(''); + try { + const registered = await uploadRedeemPendingPhoto(file); + setPhotoResourceId(registered.id); + setPreviewUrl(registered.url); + } catch (e) { + setMsg(e instanceof Error ? e.message : '上传失败'); + } finally { + setUploading(false); + } + } + + async function pickPhoto() { + setMsg(''); + if (isWechatEnv()) { + try { + setUploading(true); + await weixinSdk.init(); + const files = await weixinSdk.chooseImages({ + count: 1, + sourceType: ['album', 'camera'], + }); + if (files?.[0]) { + await handleFile(files[0]); + } + } catch (e) { + const text = e instanceof Error ? e.message : '选图失败'; + if (!/cancel/i.test(text)) setMsg(text); + } finally { + setUploading(false); + } + return; + } + inputRef.current?.click(); + } + + async function submitPending() { + if (!photoResourceId) { + setMsg('请先拍摄或上传核销码照片'); + return; + } + setSubmitting(true); + setMsg(''); + try { + const res = await request('SHOP_H5', '/shop/redeem/pending', { + method: 'POST', + body: JSON.stringify({ + token: redeemToken, + photoResourceId, + failCount, + }), + }); + setResult(res); + } catch (e) { + setMsg(e instanceof Error ? e.message : '提交失败'); + } finally { + setSubmitting(false); + } + } + + function copyText(text: string) { + void navigator.clipboard?.writeText(text).then( + () => setMsg('已复制'), + () => setMsg('复制失败,请手动长按复制'), + ); + } + + return ( + + 弱网核销兜底 + + 扫码核销网络失败已达 {failCount} 次。请记录用户手机号尝试「手机号核销」, + 或拍下用户核销码照片提交客服人工处理。 + + + + 核销码 ID(追查用) + copyText(redeemToken)}> + {redeemToken} + + + + {result ? ( + + 已提交客服,待处理单号: + copyText(result.pendingNo)}> + {result.pendingNo} + + + 请告知用户保留核销码编号,客服将尽快人工补核销。 + + + ) : ( + <> + { + const file = e.target.files?.[0]; + if (file) void handleFile(file); + }} + /> + {previewUrl && ( + + )} + + void pickPhoto()}> + {uploading ? '上传中…' : previewUrl ? '重新拍照' : '拍照 / 选图'} + + void submitPending()} + > + {submitting ? '提交中…' : '提交客服人工核销'} + + navigate('/redeem/phone')} + > + 去手机号核销 + + + > + )} + + {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}} - - - {loading ? 'sync' : 'check_circle'} - - {loading ? '正在核销...' : preview ? `确认核销 ¥${formatAmount(previewAmount)}` : '加载中…'} - - - 请核对金额后点击确认 + {!showWeakNet && ( + <> + void confirm()} + > + + {loading ? 'sync' : 'check_circle'} + + {loading ? '正在核销...' : preview ? `确认核销 ¥${formatAmount(previewAmount)}` : '加载中…'} + + 请核对金额后点击确认 + > + )} + {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} + + + )} 取消核销 diff --git a/packages/domain/src/index.test.ts b/packages/domain/src/index.test.ts index c7785fa..d6a4923 100644 --- a/packages/domain/src/index.test.ts +++ b/packages/domain/src/index.test.ts @@ -7,6 +7,9 @@ import { allocateBenefitCoupons, calcBenefitSummary, orderTabToStatuses, + classifyRedeemClientError, + generateRedeemPendingNo, + REDEEM_WEAKNET_FAIL_THRESHOLD, } from './index'; describe('calcBenefitAmount', () => { @@ -120,3 +123,26 @@ describe('calcBenefitSummary', () => { }); }); }); + +describe('classifyRedeemClientError', () => { + it('treats 5xx and network messages as NETWORK', () => { + expect(classifyRedeemClientError({ status: 502 })).toBe('NETWORK'); + expect(classifyRedeemClientError({ message: 'Failed to fetch' })).toBe('NETWORK'); + expect(classifyRedeemClientError({ message: '网络超时' })).toBe('NETWORK'); + }); + + it('treats business messages as BUSINESS', () => { + expect(classifyRedeemClientError({ status: 400, message: '核销码无效或已过期' })).toBe('BUSINESS'); + expect(classifyRedeemClientError({ message: '余额不足' })).toBe('BUSINESS'); + }); +}); + +describe('generateRedeemPendingNo', () => { + it('uses RP prefix', () => { + expect(generateRedeemPendingNo().startsWith('RP')).toBe(true); + }); + + it('exports weaknet threshold of 5', () => { + expect(REDEEM_WEAKNET_FAIL_THRESHOLD).toBe(5); + }); +}); diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index 2ed0d6a..5a0280c 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -111,6 +111,36 @@ export function generateRedeemNo(): string { return `RD${Date.now()}${Math.floor(Math.random() * 1000)}`; } +export function generateRedeemPendingNo(): string { + return `RP${Date.now()}${Math.floor(Math.random() * 1000)}`; +} + +/** 弱网核销:网络类失败达到该次数后触发兜底 */ +export const REDEEM_WEAKNET_FAIL_THRESHOLD = 5; + +export type RedeemErrorClass = 'NETWORK' | 'BUSINESS'; + +/** 门店端/服务端共用:区分网络类与业务类核销错误(仅 NETWORK 计入 5 次) */ +export function classifyRedeemClientError(input: { + status?: number | null; + message?: string | null; +}): RedeemErrorClass { + const status = input.status; + const message = (input.message ?? '').toLowerCase(); + if (status != null && status >= 500) return 'NETWORK'; + if (status === 0 || status === 408 || status === 429 || status === 502 || status === 503 || status === 504) { + return 'NETWORK'; + } + if ( + /failed to fetch|network|timeout|timed out|econnreset|econnrefused|etimedout|abort|offline|连接|网络|超时/.test( + message, + ) + ) { + return 'NETWORK'; + } + return 'BUSINESS'; +} + export function orderTabToStatuses(tab: string): string[] | undefined { switch (tab) { case 'pending_pay': diff --git a/packages/shared-types/src/enums.ts b/packages/shared-types/src/enums.ts index 4a85a8b..80d97a9 100644 --- a/packages/shared-types/src/enums.ts +++ b/packages/shared-types/src/enums.ts @@ -180,3 +180,7 @@ export const SMS_CODE_TTL_SECONDS = 180; export const REDEEM_RESULT_TTL_SECONDS = 360; /** 手机号核销会话 TTL(查权益 → 选金额 → 确认) */ export const REDEEM_PHONE_SESSION_TTL_SECONDS = 600; +/** 弱网兜底:preview 快照 TTL(token 失效后仍可提交待处理单) */ +export const REDEEM_PENDING_SNAPSHOT_TTL_SECONDS = 600; +/** 弱网核销:网络类失败阈值 */ +export const REDEEM_WEAKNET_FAIL_THRESHOLD = 5; diff --git a/packages/shared-types/src/redeem.ts b/packages/shared-types/src/redeem.ts index f19657b..f0eee3f 100644 --- a/packages/shared-types/src/redeem.ts +++ b/packages/shared-types/src/redeem.ts @@ -44,3 +44,46 @@ export interface RedeemPhonePrepareDto { amount: number; expireInSeconds: number; } + +export type RedeemPendingStatus = 'PENDING' | 'COMPLETED' | 'REJECTED'; + +export const REDEEM_PENDING_STATUS_LABELS: Record = { + PENDING: '待处理', + COMPLETED: '已补核销', + REJECTED: '已驳回', +}; + +export type RedeemErrorClass = 'NETWORK' | 'BUSINESS'; + +export type RedeemFailureReportResult = { + failCount: number; + thresholdReached: boolean; + threshold: number; +}; + +export type RedeemPendingSubmitResult = { + pendingId: string; + pendingNo: string; + redeemToken: string; + failCount: number; +}; + +export type RedeemPendingItem = { + id: string; + pendingNo: string; + redeemToken: string; + amount: number; + redeemType: string; + failCount: number; + status: RedeemPendingStatus; + remark?: string | null; + rejectReason?: string | null; + processedAt?: string | null; + createdAt: string; + updatedAt: string; + store?: { id: string; name: string; cityName?: string } | null; + user?: { id: string; userNo?: string | null; phone?: string | null; nickname?: string | null } | null; + photoUrl?: string | null; + redeemRecordId?: string | null; + redeemRecord?: { id: string; redeemNo: string } | null; +}; diff --git a/packages/shared-types/src/store-log.ts b/packages/shared-types/src/store-log.ts index 23c9e7a..7bb053a 100644 --- a/packages/shared-types/src/store-log.ts +++ b/packages/shared-types/src/store-log.ts @@ -8,7 +8,18 @@ export type StoreLogCategory = export const STORE_LOG_EVENT_CATEGORIES: Record = { login: ['store_sms_send', 'store_sms_login', 'store_sms_verify_fail', 'store_login_success'], wechat_auth: ['store_wechat_login', 'store_wechat_bind'], - redeem: ['store_redeem_preview', 'store_redeem_confirm'], + redeem: [ + 'store_redeem_preview', + 'store_redeem_confirm', + 'store_redeem_confirm_fail', + 'store_redeem_weaknet_threshold', + 'store_redeem_pending_submit', + 'store_redeem_pending_complete', + 'store_redeem_pending_reject', + 'store_redeem_phone_lookup_sms', + 'store_redeem_phone_balance', + 'store_redeem_phone_prepare', + ], payout: ['store_payout_created', 'store_payout_paid'], store_ops: ['store_status_change'], }; diff --git a/server/dukang-api/prisma/schema.prisma b/server/dukang-api/prisma/schema.prisma index 3c07ee1..e53fb74 100644 --- a/server/dukang-api/prisma/schema.prisma +++ b/server/dukang-api/prisma/schema.prisma @@ -43,6 +43,13 @@ enum ResourceBizType { QRCODE SIGN_PHOTO VIDEO + REDEEM_PENDING_PHOTO +} + +enum RedeemPendingStatus { + PENDING + COMPLETED + REJECTED } enum ResourceMediaType { @@ -280,12 +287,13 @@ model CommonResource { createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) - productCovers CommonProductItem[] @relation("ProductCover") - promoQrcodes CommonPromoCode[] @relation("PromoQrcode") - userAvatars User[] @relation("UserAvatar") - storeCovers Store[] @relation("StoreCover") - orderImages Order[] @relation("OrderProductImage") - deliveryPhotos OrderDelivery[] @relation("DeliverySignPhoto") + productCovers CommonProductItem[] @relation("ProductCover") + promoQrcodes CommonPromoCode[] @relation("PromoQrcode") + userAvatars User[] @relation("UserAvatar") + storeCovers Store[] @relation("StoreCover") + orderImages Order[] @relation("OrderProductImage") + deliveryPhotos OrderDelivery[] @relation("DeliverySignPhoto") + redeemPendingPhotos RedeemPendingRecord[] @relation("RedeemPendingPhoto") @@index([ownerType, ownerId, bizType]) @@index([status]) @@ -606,8 +614,9 @@ model User { promoTouch UserPromoAttribution? ownedPromoCodes CommonPromoCode[] @relation("PromoOwnerUser") orders Order[] - benefitCoupons BenefitCoupon[] - redeemRecords RedeemRecord[] + benefitCoupons BenefitCoupon[] + redeemRecords RedeemRecord[] + redeemPendingRecords RedeemPendingRecord[] @@index([sourceType, sourceRefId]) @@index([referrerUserId]) @@ -700,9 +709,10 @@ model Store { category CommonStoreCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull) coverResource CommonResource? @relation("StoreCover", fields: [coverResourceId], references: [id], onDelete: SetNull) account StoreAccount? - redeemRecords RedeemRecord[] - ratings StoreRating[] - payouts StorePayout[] + redeemRecords RedeemRecord[] + redeemPendingRecords RedeemPendingRecord[] + ratings StoreRating[] + payouts StorePayout[] @@index([cityId, status]) @@index([partnerAccountId]) @@ -721,7 +731,8 @@ model StoreAccount { createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) - store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) + store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) + redeemPendingRecords RedeemPendingRecord[] @@map("store_account") } @@ -854,16 +865,50 @@ model RedeemRecord { settleAmount Decimal @map("settle_amount") @db.Decimal(10, 2) createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) - user User @relation(fields: [userId], references: [id], onDelete: Restrict) - coupon BenefitCoupon @relation(fields: [couponId], references: [id], onDelete: Restrict) - store Store @relation(fields: [storeId], references: [id], onDelete: Restrict) - rating StoreRating? - payout StorePayout? + user User @relation(fields: [userId], references: [id], onDelete: Restrict) + coupon BenefitCoupon @relation(fields: [couponId], references: [id], onDelete: Restrict) + store Store @relation(fields: [storeId], references: [id], onDelete: Restrict) + rating StoreRating? + payout StorePayout? + pending RedeemPendingRecord? @@index([storeId, createdAt]) @@map("user_redeem_record") } +model RedeemPendingRecord { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + pendingNo String @unique @map("pending_no") @db.VarChar(32) + redeemToken String @map("redeem_token") @db.VarChar(64) + storeId BigInt @map("store_id") @db.UnsignedBigInt + storeAccountId BigInt @map("store_account_id") @db.UnsignedBigInt + userId BigInt @map("user_id") @db.UnsignedBigInt + amount Decimal @db.Decimal(10, 2) + redeemType String @default("DIRECT") @map("redeem_type") @db.VarChar(16) + allocationsJson Json @map("allocations_json") + photoResourceId BigInt @map("photo_resource_id") @db.UnsignedBigInt + failCount Int @default(5) @map("fail_count") + status RedeemPendingStatus @default(PENDING) + redeemRecordId BigInt? @unique @map("redeem_record_id") @db.UnsignedBigInt + processedAt DateTime? @map("processed_at") @db.DateTime(3) + processedByHqId BigInt? @map("processed_by_hq_id") @db.UnsignedBigInt + rejectReason String? @map("reject_reason") @db.VarChar(256) + remark String? @db.VarChar(256) + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) + + store Store @relation(fields: [storeId], references: [id], onDelete: Restrict) + storeAccount StoreAccount @relation(fields: [storeAccountId], references: [id], onDelete: Restrict) + user User @relation(fields: [userId], references: [id], onDelete: Restrict) + photoResource CommonResource @relation("RedeemPendingPhoto", fields: [photoResourceId], references: [id], onDelete: Restrict) + redeemRecord RedeemRecord? @relation(fields: [redeemRecordId], references: [id], onDelete: SetNull) + + @@index([storeId, status, createdAt]) + @@index([redeemToken]) + @@index([status, createdAt]) + @@map("user_redeem_pending") +} + model StoreRating { id BigInt @id @default(autoincrement()) @db.UnsignedBigInt redeemRecordId BigInt @unique @map("redeem_record_id") @db.UnsignedBigInt 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 b38e014..0b3a8c9 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 @@ -50,6 +50,8 @@ export const HqOperationAction = { PROMO_CODE_CREATE: 'PROMO_CODE_CREATE', PROMO_CODE_UPDATE: 'PROMO_CODE_UPDATE', PROMO_CODE_UPDATE_STATUS: 'PROMO_CODE_UPDATE_STATUS', + REDEEM_PENDING_COMPLETE: 'REDEEM_PENDING_COMPLETE', + REDEEM_PENDING_REJECT: 'REDEEM_PENDING_REJECT', } as const; export type HqOperationActionCode = (typeof HqOperationAction)[keyof typeof HqOperationAction]; @@ -105,6 +107,8 @@ export const HQ_OPERATION_ACTION_LABELS: Record = { [HqOperationAction.PROMO_CODE_CREATE]: '创建推广码', [HqOperationAction.PROMO_CODE_UPDATE]: '编辑推广码', [HqOperationAction.PROMO_CODE_UPDATE_STATUS]: '推广码启停', + [HqOperationAction.REDEEM_PENDING_COMPLETE]: '弱网待处理单-补核销', + [HqOperationAction.REDEEM_PENDING_REJECT]: '弱网待处理单-驳回', STORE_PAYOUT: '门店打款确认', }; diff --git a/server/dukang-api/src/common/redis/redis.service.ts b/server/dukang-api/src/common/redis/redis.service.ts index 490b670..5cecadc 100644 --- a/server/dukang-api/src/common/redis/redis.service.ts +++ b/server/dukang-api/src/common/redis/redis.service.ts @@ -30,4 +30,16 @@ export class RedisService { async ttl(key: string): Promise { return this.redis.ttl(key); } + + async incr(key: string, ttlSeconds?: number): Promise { + const count = await this.redis.incr(key); + if (ttlSeconds && count === 1) { + await this.redis.expire(key, ttlSeconds); + } + return count; + } + + async get(key: string): Promise { + return this.redis.get(key); + } } diff --git a/server/dukang-api/src/modules/ops/admin-redeem-pending.controller.ts b/server/dukang-api/src/modules/ops/admin-redeem-pending.controller.ts new file mode 100644 index 0000000..7941e0c --- /dev/null +++ b/server/dukang-api/src/modules/ops/admin-redeem-pending.controller.ts @@ -0,0 +1,52 @@ +import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common'; +import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; +import { CurrentUser } from '../../common/decorators/current-user.decorator'; +import type { AuthUser } from '../../common/guards/jwt-auth.guard'; +import { HqOperation } from '../../common/hq-operation/hq-operation.decorator'; +import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants'; +import { RedeemService } from '../redeem/redeem.service'; +import { + AdminRedeemPendingQueryDto, + AdminRedeemPendingRejectDto, +} from './dto/admin-redeem-pending.dto'; + +@Controller('admin/redeem-pending') +@UseGuards(HqAuthGuard) +export class AdminRedeemPendingController { + constructor(private readonly redeemService: RedeemService) {} + + @Get() + list(@Query() query: AdminRedeemPendingQueryDto) { + return this.redeemService.listPendingRedeems(query); + } + + @Get(':id') + detail(@Param('id') id: string) { + return this.redeemService.getPendingRedeem(BigInt(id)); + } + + @Post(':id/complete') + @HqOperation({ + action: HqOperationAction.REDEEM_PENDING_COMPLETE, + refType: 'REDEEM_PENDING', + refIdParam: 'id', + }) + complete(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.redeemService.completePendingRedeem(BigInt(id), user.actorId); + } + + @Post(':id/reject') + @HqOperation({ + action: HqOperationAction.REDEEM_PENDING_REJECT, + refType: 'REDEEM_PENDING', + refIdParam: 'id', + includeBody: true, + }) + reject( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() body: AdminRedeemPendingRejectDto, + ) { + return this.redeemService.rejectPendingRedeem(BigInt(id), user.actorId, body.reason); + } +} diff --git a/server/dukang-api/src/modules/ops/dto/admin-redeem-pending.dto.ts b/server/dukang-api/src/modules/ops/dto/admin-redeem-pending.dto.ts new file mode 100644 index 0000000..d570549 --- /dev/null +++ b/server/dukang-api/src/modules/ops/dto/admin-redeem-pending.dto.ts @@ -0,0 +1,26 @@ +import { IsIn, IsOptional, IsString, MaxLength } from 'class-validator'; +import { PaginationQueryDto } from './admin-query.dto'; + +export class AdminRedeemPendingQueryDto extends PaginationQueryDto { + @IsOptional() + @IsIn(['PENDING', 'COMPLETED', 'REJECTED']) + status?: 'PENDING' | 'COMPLETED' | 'REJECTED'; + + @IsOptional() + @IsString() + storeId?: string; + + @IsOptional() + @IsString() + pendingNo?: string; + + @IsOptional() + @IsString() + redeemToken?: string; +} + +export class AdminRedeemPendingRejectDto { + @IsString() + @MaxLength(256) + reason: string; +} diff --git a/server/dukang-api/src/modules/ops/ops.module.ts b/server/dukang-api/src/modules/ops/ops.module.ts index c3ae76e..4d3d09e 100644 --- a/server/dukang-api/src/modules/ops/ops.module.ts +++ b/server/dukang-api/src/modules/ops/ops.module.ts @@ -44,6 +44,7 @@ import { AdminProductDetailTemplatesService } from './admin-product-detail-templ import { RedeemModule } from '../redeem/redeem.module'; import { AdminRedeemDebugController } from './admin-redeem-debug.controller'; import { AdminRedeemDebugService } from './admin-redeem-debug.service'; +import { AdminRedeemPendingController } from './admin-redeem-pending.controller'; import { AdminWechatBindingsController } from './admin-wechat-bindings.controller'; import { AdminWechatBindingsService } from './admin-wechat-bindings.service'; import { AdminHqPermissionsController } from './admin-hq-permissions.controller'; @@ -77,6 +78,7 @@ import { AdminHqPermissionsService } from './admin-hq-permissions.service'; AdminXiaofeixiaController, AdminProductDetailTemplatesController, AdminRedeemDebugController, + AdminRedeemPendingController, AdminWechatBindingsController, AdminHqPermissionsController, ], diff --git a/server/dukang-api/src/modules/redeem/dto/weaknet-redeem.dto.ts b/server/dukang-api/src/modules/redeem/dto/weaknet-redeem.dto.ts new file mode 100644 index 0000000..b133a4f --- /dev/null +++ b/server/dukang-api/src/modules/redeem/dto/weaknet-redeem.dto.ts @@ -0,0 +1,40 @@ +import { Type } from 'class-transformer'; +import { IsIn, IsInt, IsNotEmpty, IsOptional, IsString, MaxLength, Min } from 'class-validator'; + +export class RedeemFailureReportDto { + @IsString() + @IsNotEmpty() + token: string; + + @IsIn(['NETWORK', 'BUSINESS']) + errorClass: 'NETWORK' | 'BUSINESS'; + + @IsOptional() + @IsString() + @MaxLength(256) + message?: string; + + @IsIn(['preview', 'confirm']) + step: 'preview' | 'confirm'; +} + +export class RedeemPendingSubmitDto { + @IsString() + @IsNotEmpty() + token: string; + + @IsString() + @IsNotEmpty() + photoResourceId: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + failCount?: number; + + @IsOptional() + @IsString() + @MaxLength(256) + remark?: string; +} diff --git a/server/dukang-api/src/modules/redeem/redeem.controller.ts b/server/dukang-api/src/modules/redeem/redeem.controller.ts index 7458c2a..f80c754 100644 --- a/server/dukang-api/src/modules/redeem/redeem.controller.ts +++ b/server/dukang-api/src/modules/redeem/redeem.controller.ts @@ -8,6 +8,7 @@ import { RedeemPhonePrepareDto, RedeemPhoneSendLookupSmsDto, } from './dto/phone-redeem.dto'; +import { RedeemFailureReportDto, RedeemPendingSubmitDto } from './dto/weaknet-redeem.dto'; @Controller('redeem') @UseGuards(JwtAuthGuard) @@ -50,6 +51,22 @@ export class ShopRedeemController { return this.redeemService.confirmRedeem(user.actorId, body); } + @Post('failures') + reportFailure( + @CurrentUser() user: AuthUser, + @Body() body: RedeemFailureReportDto, + ) { + return this.redeemService.reportNetworkFailure(user.actorId, body); + } + + @Post('pending') + submitPending( + @CurrentUser() user: AuthUser, + @Body() body: RedeemPendingSubmitDto, + ) { + return this.redeemService.submitPendingRedeem(user.actorId, body); + } + @Get('records') records( @CurrentUser() user: AuthUser, diff --git a/server/dukang-api/src/modules/redeem/redeem.service.ts b/server/dukang-api/src/modules/redeem/redeem.service.ts index a403c7f..497a275 100644 --- a/server/dukang-api/src/modules/redeem/redeem.service.ts +++ b/server/dukang-api/src/modules/redeem/redeem.service.ts @@ -7,16 +7,20 @@ import { randomBytes } from 'crypto'; import { calcRedeemSettleAmount, generateRedeemNo, + generateRedeemPendingNo, + REDEEM_WEAKNET_FAIL_THRESHOLD, validateRedeemAmount, allocateBenefitCoupons, } from '@dukang/domain'; import { ClientApp, + REDEEM_PENDING_SNAPSHOT_TTL_SECONDS, REDEEM_PHONE_SESSION_TTL_SECONDS, REDEEM_RESULT_TTL_SECONDS, REDEEM_TOKEN_TTL_SECONDS, SmsScene, } from '@dukang/shared-types'; +import { Prisma } from '@prisma/client'; import { PrismaService } from '../../common/prisma/prisma.module'; import { RedisService } from '../../common/redis/redis.service'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; @@ -33,6 +37,10 @@ type TokenPayload = { allocations?: Array<{ couponId: string; amount: number }>; }; +type PendingSnapshot = TokenPayload & { + redeemType: 'DIRECT' | 'COUPON'; +}; + type RedeemResultPayload = { recordId: string; redeemNo: string; @@ -470,6 +478,21 @@ export class RedeemService { }); const ttl = await this.redis.ttl(`redeem:token:${token}`); + const redeemType: 'DIRECT' | 'COUPON' = + cached.allocations && cached.allocations.length > 1 + ? 'DIRECT' + : cached.couponId + ? 'COUPON' + : 'DIRECT'; + + await this.redis.setJson( + `redeem:pending-snapshot:${token}`, + { + ...cached, + redeemType, + } satisfies PendingSnapshot, + REDEEM_PENDING_SNAPSHOT_TTL_SECONDS, + ); this.analyticsService.trackStoreOneSafe(storeAccountId, 'SHOP_H5', { storeId: account.storeId, @@ -478,7 +501,7 @@ export class RedeemService { tokenSuffix: token.slice(-8), amount: cached.amount, userId: cached.userId, - redeemType: cached.allocations && cached.allocations.length > 1 ? 'DIRECT' : cached.couponId ? 'COUPON' : 'DIRECT', + redeemType, }, }); @@ -487,7 +510,7 @@ export class RedeemService { amount: cached.amount, user, boundStoreId: cached.storeId, - redeemType: cached.allocations && cached.allocations.length > 1 ? 'DIRECT' : cached.couponId ? 'COUPON' : 'DIRECT', + redeemType, expireInSeconds: ttl > 0 ? ttl : 0, storeMatch: !cached.storeId || cached.storeId === account.storeId.toString(), }); @@ -495,8 +518,20 @@ export class RedeemService { async confirmRedeem(storeAccountId: bigint, body: { token: string }) { const account = await this.loadOpenStoreAccount(storeAccountId); + const token = body.token?.trim(); + if (!token) throw new BadRequestException('请提供核销码'); - const cached = await this.redis.getJson(`redeem:token:${body.token}`); + const existingResult = await this.redis.getJson(`redeem:result:${token}`); + if (existingResult) { + const record = await this.prisma.redeemRecord.findUnique({ + where: { id: BigInt(existingResult.recordId) }, + }); + if (record) { + return serializeBigInt(record); + } + } + + const cached = await this.redis.getJson(`redeem:token:${token}`); if (!cached) throw new BadRequestException('核销码无效或已过期'); if (cached.storeId && cached.storeId !== account.storeId.toString()) { @@ -531,11 +566,11 @@ export class RedeemService { BigInt(cached.userId), tokenAmount, normalizedAllocations, - { channel: 'token', tokenSuffix: body.token.slice(-8) }, + { channel: 'token', tokenSuffix: token.slice(-8) }, ); await this.redis.setJson( - `redeem:result:${body.token}`, + `redeem:result:${token}`, { recordId: record.id.toString(), redeemNo: record.redeemNo, @@ -547,11 +582,365 @@ export class RedeemService { } satisfies RedeemResultPayload, REDEEM_RESULT_TTL_SECONDS, ); - await this.redis.del(`redeem:token:${body.token}`); + await this.redis.del(`redeem:token:${token}`); + await this.redis.del(`redeem:netfail:${storeAccountId}:${token}`); return serializeBigInt(record); } + private netFailKey(storeAccountId: bigint, token: string) { + return `redeem:netfail:${storeAccountId}:${token}`; + } + + async reportNetworkFailure( + storeAccountId: bigint, + body: { + token: string; + errorClass: 'NETWORK' | 'BUSINESS'; + message?: string; + step: 'preview' | 'confirm'; + }, + ) { + const account = await this.prisma.storeAccount.findUniqueOrThrow({ + where: { id: storeAccountId }, + }); + const token = body.token.trim(); + if (!token) throw new BadRequestException('请提供核销码'); + + let failCount = 0; + if (body.errorClass === 'NETWORK') { + failCount = await this.redis.incr(this.netFailKey(storeAccountId, token), REDEEM_TOKEN_TTL_SECONDS); + } else { + const raw = await this.redis.get(this.netFailKey(storeAccountId, token)); + failCount = raw ? Number(raw) || 0 : 0; + } + + const thresholdReached = failCount >= REDEEM_WEAKNET_FAIL_THRESHOLD; + + this.analyticsService.trackStoreOneSafe(storeAccountId, ClientApp.SHOP_H5, { + storeId: account.storeId, + eventName: 'store_redeem_confirm_fail', + extraJson: { + token, + tokenSuffix: token.slice(-8), + errorClass: body.errorClass, + failCount, + step: body.step, + message: body.message?.slice(0, 200) ?? null, + }, + }); + + if (thresholdReached && body.errorClass === 'NETWORK') { + this.analyticsService.trackStoreOneSafe(storeAccountId, ClientApp.SHOP_H5, { + storeId: account.storeId, + eventName: 'store_redeem_weaknet_threshold', + extraJson: { + token, + tokenSuffix: token.slice(-8), + failCount, + threshold: REDEEM_WEAKNET_FAIL_THRESHOLD, + }, + }); + } + + return { + failCount, + thresholdReached, + threshold: REDEEM_WEAKNET_FAIL_THRESHOLD, + }; + } + + private async resolvePendingSnapshot(token: string): Promise { + const live = await this.redis.getJson(`redeem:token:${token}`); + if (live) { + return { + ...live, + redeemType: + live.allocations && live.allocations.length > 1 + ? 'DIRECT' + : live.couponId + ? 'COUPON' + : 'DIRECT', + }; + } + const snap = await this.redis.getJson(`redeem:pending-snapshot:${token}`); + if (!snap) throw new BadRequestException('核销码已失效且无可用快照,请用户重新出码或改用手机号核销'); + return snap; + } + + async submitPendingRedeem( + storeAccountId: bigint, + body: { token: string; photoResourceId: string; failCount?: number; remark?: string }, + ) { + const account = await this.loadOpenStoreAccount(storeAccountId); + const token = body.token.trim(); + if (!token) throw new BadRequestException('请提供核销码'); + + const existingPending = await this.prisma.redeemPendingRecord.findFirst({ + where: { redeemToken: token, storeId: account.storeId, status: 'PENDING' }, + }); + if (existingPending) { + return serializeBigInt({ + pendingId: existingPending.id, + pendingNo: existingPending.pendingNo, + redeemToken: existingPending.redeemToken, + failCount: existingPending.failCount, + }); + } + + const alreadyDone = await this.redis.getJson(`redeem:result:${token}`); + if (alreadyDone) { + throw new BadRequestException('该核销码已核销成功,无需提交待处理单'); + } + + const snapshot = await this.resolvePendingSnapshot(token); + if (snapshot.storeId && snapshot.storeId !== account.storeId.toString()) { + throw new BadRequestException('该核销码仅限指定门店使用'); + } + + const photoId = BigInt(body.photoResourceId); + const photo = await this.prisma.commonResource.findFirst({ + where: { id: photoId, status: 'ACTIVE' }, + }); + if (!photo) throw new BadRequestException('核销码照片不存在'); + + const allocations = + snapshot.allocations ?? + (snapshot.couponId ? [{ couponId: snapshot.couponId, amount: snapshot.amount }] : []); + if (!allocations.length) throw new BadRequestException('核销码数据异常'); + + const rawFail = await this.redis.get(this.netFailKey(storeAccountId, token)); + const failCount = Math.max( + Number(body.failCount) || 0, + rawFail ? Number(rawFail) || 0 : 0, + REDEEM_WEAKNET_FAIL_THRESHOLD, + ); + + const pending = await this.prisma.redeemPendingRecord.create({ + data: { + pendingNo: generateRedeemPendingNo(), + redeemToken: token, + storeId: account.storeId, + storeAccountId: account.id, + userId: BigInt(snapshot.userId), + amount: snapshot.amount, + redeemType: snapshot.redeemType, + allocationsJson: allocations as Prisma.InputJsonValue, + photoResourceId: photoId, + failCount, + remark: body.remark?.trim() || null, + }, + }); + + this.analyticsService.trackStoreOneSafe(storeAccountId, ClientApp.SHOP_H5, { + storeId: account.storeId, + eventName: 'store_redeem_pending_submit', + refType: 'REDEEM_PENDING', + refId: pending.id, + extraJson: { + pendingNo: pending.pendingNo, + redeemToken: token, + photoResourceId: photoId.toString(), + failCount, + amount: Number(pending.amount), + }, + }); + + return serializeBigInt({ + pendingId: pending.id, + pendingNo: pending.pendingNo, + redeemToken: pending.redeemToken, + failCount: pending.failCount, + }); + } + + async listPendingRedeems(query: { + page?: number; + pageSize?: number; + status?: 'PENDING' | 'COMPLETED' | 'REJECTED'; + storeId?: string; + pendingNo?: string; + redeemToken?: string; + }) { + const page = query.page ?? 1; + const pageSize = query.pageSize ?? 20; + const where: Prisma.RedeemPendingRecordWhereInput = {}; + if (query.status) where.status = query.status; + if (query.storeId) where.storeId = BigInt(query.storeId); + if (query.pendingNo) where.pendingNo = { contains: query.pendingNo }; + if (query.redeemToken) where.redeemToken = { contains: query.redeemToken }; + + const [items, total] = await Promise.all([ + this.prisma.redeemPendingRecord.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + include: { + store: { select: { id: true, name: true, cityName: true } }, + user: { select: { id: true, userNo: true, phone: true, nickname: true } }, + photoResource: { select: { id: true, url: true } }, + redeemRecord: { select: { id: true, redeemNo: true } }, + }, + }), + this.prisma.redeemPendingRecord.count({ where }), + ]); + + return serializeBigInt({ + items: items.map((row) => ({ + ...row, + photoUrl: row.photoResource?.url ?? null, + photoResource: undefined, + })), + total, + page, + pageSize, + }); + } + + async getPendingRedeem(id: bigint) { + const row = await this.prisma.redeemPendingRecord.findUnique({ + where: { id }, + include: { + store: { select: { id: true, name: true, cityName: true, phone: true } }, + user: { select: { id: true, userNo: true, phone: true, nickname: true } }, + photoResource: { select: { id: true, url: true } }, + redeemRecord: { select: { id: true, redeemNo: true, amount: true, settleAmount: true } }, + storeAccount: { select: { id: true, name: true, phone: true } }, + }, + }); + if (!row) throw new NotFoundException('待处理核销单不存在'); + return serializeBigInt({ + ...row, + photoUrl: row.photoResource?.url ?? null, + }); + } + + async completePendingRedeem(pendingId: bigint, hqAccountId: bigint) { + const pending = await this.prisma.redeemPendingRecord.findUnique({ + where: { id: pendingId }, + }); + if (!pending) throw new NotFoundException('待处理核销单不存在'); + if (pending.status === 'COMPLETED' && pending.redeemRecordId) { + const record = await this.prisma.redeemRecord.findUnique({ + where: { id: pending.redeemRecordId }, + }); + return serializeBigInt({ pending, record }); + } + if (pending.status !== 'PENDING') { + throw new BadRequestException('待处理单状态不可补核销'); + } + + const account = await this.prisma.storeAccount.findUniqueOrThrow({ + where: { id: pending.storeAccountId }, + include: { store: true }, + }); + if (account.store.status !== 'OPEN') { + throw new BadRequestException('门店未营业,无法补核销'); + } + + const allocationsRaw = pending.allocationsJson as Array<{ couponId: string; amount: number }>; + const normalizedAllocations = allocationsRaw.map((item) => ({ + couponId: String(item.couponId), + amount: Number(item.amount), + })); + await this.validateAllocations(normalizedAllocations); + + const amount = Number(pending.amount); + const record = await this.executeRedeem( + account, + pending.userId, + amount, + normalizedAllocations, + { channel: 'token', tokenSuffix: pending.redeemToken.slice(-8) }, + ); + + const now = new Date(); + const updated = await this.prisma.redeemPendingRecord.update({ + where: { id: pending.id }, + data: { + status: 'COMPLETED', + redeemRecordId: record.id, + processedAt: now, + processedByHqId: hqAccountId, + }, + include: { + store: { select: { id: true, name: true } }, + user: { select: { id: true, userNo: true, phone: true } }, + redeemRecord: true, + }, + }); + + await this.redis.setJson( + `redeem:result:${pending.redeemToken}`, + { + recordId: record.id.toString(), + redeemNo: record.redeemNo, + userId: pending.userId.toString(), + amount, + storeId: account.storeId.toString(), + storeName: account.store.name, + createdAt: record.createdAt.toISOString(), + } satisfies RedeemResultPayload, + REDEEM_RESULT_TTL_SECONDS, + ); + await this.redis.del(`redeem:token:${pending.redeemToken}`); + await this.redis.del(`redeem:pending-snapshot:${pending.redeemToken}`); + await this.redis.del(this.netFailKey(pending.storeAccountId, pending.redeemToken)); + + this.analyticsService.trackStoreOneSafe(pending.storeAccountId, ClientApp.SHOP_H5, { + storeId: pending.storeId, + eventName: 'store_redeem_pending_complete', + refType: 'REDEEM_PENDING', + refId: pending.id, + extraJson: { + pendingNo: pending.pendingNo, + redeemToken: pending.redeemToken, + redeemNo: record.redeemNo, + hqAccountId: hqAccountId.toString(), + }, + }); + + return serializeBigInt({ pending: updated, record }); + } + + async rejectPendingRedeem(pendingId: bigint, hqAccountId: bigint, reason: string) { + const pending = await this.prisma.redeemPendingRecord.findUnique({ + where: { id: pendingId }, + }); + if (!pending) throw new NotFoundException('待处理核销单不存在'); + if (pending.status !== 'PENDING') { + throw new BadRequestException('仅待处理状态可驳回'); + } + const rejectReason = reason?.trim(); + if (!rejectReason) throw new BadRequestException('请填写驳回原因'); + + const updated = await this.prisma.redeemPendingRecord.update({ + where: { id: pendingId }, + data: { + status: 'REJECTED', + rejectReason, + processedAt: new Date(), + processedByHqId: hqAccountId, + }, + }); + + this.analyticsService.trackStoreOneSafe(pending.storeAccountId, ClientApp.SHOP_H5, { + storeId: pending.storeId, + eventName: 'store_redeem_pending_reject', + refType: 'REDEEM_PENDING', + refId: pending.id, + extraJson: { + pendingNo: pending.pendingNo, + redeemToken: pending.redeemToken, + reason: rejectReason, + hqAccountId: hqAccountId.toString(), + }, + }); + + return serializeBigInt(updated); + } + async listShopRecords(storeAccountId: bigint, page = 1, pageSize = 20) { const account = await this.prisma.storeAccount.findUniqueOrThrow({ where: { id: storeAccountId },
+ 扫码核销网络失败已达 {failCount} 次。请记录用户手机号尝试「手机号核销」, + 或拍下用户核销码照片提交客服人工处理。 +
已提交客服,待处理单号:
+ 请告知用户保留核销码编号,客服将尽快人工补核销。 +
{msg}
请核对金额后点击确认
待核销金额
¥ {formatMoney(confirmAmount)}
核销码编号(供追查)
+ {token} +