feat(claim): 支持固定H5订单号领取与订单兑换有效性后台控制

- 数据库新增 099_order_claim_status.sql 迁移,扩展 claim_status、claim_status_reason 等字段
- 后端新增 /claims/h5/:orderNo、UID提交、分销提交接口,保留原有 token 接口双轨兼容
- 增加兑换有效性强拦截:暂停/关闭/退款/取消订单精准阻断并返回友好提示
- 后台新增 POST /admin/orders/:orderId/claim-status 接口与审计日志记录
- 前端管理后台(订单列表、详情页)支持 H5 兑换状态展示、一键复制链接及暂停/关闭/启用弹窗
- 前端领取页面适配 /h5/:orderNo 路由及暂停/关闭等状态的异常展示
- 验证方式:运行前后端类型检查、全量单元测试(后端 674、前端 132)及全量构建编译均通过
This commit is contained in:
yml2213
2026-10-10 18:05:46 +08:00
parent 2d727a6797
commit 412d219cca
21 changed files with 788 additions and 50 deletions
+19 -1
View File
@@ -11,7 +11,7 @@ import {
} from 'react-router'
import { getAdminRole, hasAdminSession } from '@/utils/admin-auth'
import { isClaimPath } from '@/utils/claim-path'
import { isClaimPath, isH5ClaimPath } from '@/utils/claim-path'
import { isRedeemPath } from '@/utils/redeem'
import { isWorkerSite, WORKER_SITE_ORIGIN } from '@/utils/site'
import { hasWorkerSession } from '@/utils/worker-auth'
@@ -100,6 +100,11 @@ export default function App() {
return <ClaimApplication />
}
// 路径式 H5 订单号领取链接(`https://<域名>/h5/<订单号>`,不带 `#`)
if (isH5ClaimPath(window.location.pathname)) {
return <H5ClaimApplication />
}
// 固定兑换链接是路径式(`https://<域名>/redeem`,不带 `#`),必须早于站点判断,
// 用独立的 BrowserRouter 分支承载,主站 HashRouter 不受影响。
if (isRedeemPath(window.location.pathname)) {
@@ -190,6 +195,19 @@ function ClaimApplication() {
)
}
function H5ClaimApplication() {
return (
<BrowserRouter>
<Suspense fallback={<RouteLoading />}>
<Routes>
<Route path="/h5/:orderNo" element={<ClaimPage />} />
<Route path="*" element={<ClaimPage />} />
</Routes>
</Suspense>
</BrowserRouter>
)
}
function WorkerApplication() {
return (
<BrowserRouter>
@@ -1,19 +1,44 @@
import { ArrowLeftOutlined } from '@ant-design/icons'
import { Alert, Button, Card, Descriptions, Skeleton, Space, Table, Tag, Typography } from 'antd'
import {
ArrowLeftOutlined,
CopyOutlined,
PauseCircleOutlined,
PlayCircleOutlined,
StopOutlined,
} from '@ant-design/icons'
import {
Alert,
Button,
Card,
Descriptions,
Input,
Modal,
Skeleton,
Space,
Table,
Tag,
Typography,
message,
} from 'antd'
import type { TableColumnsType } from 'antd'
import { useQuery } from '@tanstack/react-query'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useNavigate, useParams } from 'react-router'
import JsonPreview from '@/components/admin/JsonPreview'
import PageHeader from '@/components/admin/PageHeader'
import StatusTag from '@/components/admin/StatusTag'
import { fetchAdminOrderDetail } from '@/services/admin'
import { fetchAdminOrderDetail, updateAdminOrderClaimStatus } from '@/services/admin'
import type { AdminOrderDetail, AdminTaskListItem } from '@/types/admin'
import { formatAdminDateTime } from '@/utils/admin-time'
export default function AdminOrderDetailPage() {
const navigate = useNavigate()
const queryClient = useQueryClient()
const { orderId = '' } = useParams()
const [statusModalVisible, setStatusModalVisible] = useState(false)
const [targetStatus, setTargetStatus] = useState<'active' | 'paused' | 'closed'>('active')
const [statusReason, setStatusReason] = useState('')
const query = useQuery({
queryKey: ['admin-order-detail', orderId],
queryFn: () => fetchAdminOrderDetail(orderId),
@@ -21,6 +46,43 @@ export default function AdminOrderDetailPage() {
})
const detail = query.data?.data
const statusMutation = useMutation({
mutationFn: (data: { status: 'active' | 'paused' | 'closed'; reason?: string }) =>
updateAdminOrderClaimStatus(orderId, data),
onSuccess: () => {
message.success('订单兑换状态已更新')
setStatusModalVisible(false)
setStatusReason('')
void queryClient.invalidateQueries({ queryKey: ['admin-order-detail', orderId] })
},
onError: (err: unknown) => {
message.error(err instanceof Error ? err.message : '更新状态失败')
},
})
const handleCopyH5Link = () => {
if (!detail?.order.platformOrderId) return
const h5Url = `${window.location.origin}/h5/${encodeURIComponent(detail.order.platformOrderId)}`
navigator.clipboard
.writeText(h5Url)
.then(() => message.success('已复制 H5 领取链接: ' + h5Url))
.catch(() => message.error('复制失败,请手动复制'))
}
const openStatusModal = (status: 'active' | 'paused' | 'closed') => {
if (status === 'active') {
Modal.confirm({
title: '恢复启用订单兑换',
content: '恢复后,买家可正常进入 H5 页面提交 UID 领取,确认恢复吗?',
onOk: () => statusMutation.mutateAsync({ status: 'active', reason: '管理员恢复启用' }),
})
return
}
setTargetStatus(status)
setStatusReason('')
setStatusModalVisible(true)
}
if (query.isLoading) {
return (
<section className="page-stack">
@@ -93,7 +155,45 @@ export default function AdminOrderDetailPage() {
extra={<BackButton />}
/>
<Card title="基础信息">
<Card
title="基础信息"
extra={
<Space>
<Button icon={<CopyOutlined />} onClick={handleCopyH5Link}>
复制 H5 领取链接
</Button>
{detail.order.claimStatus === 'paused' || detail.order.claimStatus === 'closed' ? (
<Button
type="primary"
icon={<PlayCircleOutlined />}
onClick={() => openStatusModal('active')}
loading={statusMutation.isPending}
>
恢复启用
</Button>
) : (
<Button
danger
icon={<PauseCircleOutlined />}
onClick={() => openStatusModal('paused')}
loading={statusMutation.isPending}
>
暂停兑换
</Button>
)}
{detail.order.claimStatus !== 'closed' ? (
<Button
danger
icon={<StopOutlined />}
onClick={() => openStatusModal('closed')}
loading={statusMutation.isPending}
>
关闭兑换
</Button>
) : null}
</Space>
}
>
<Descriptions column={{ xs: 1, md: 2, xl: 3 }} bordered size="small">
<Descriptions.Item label="订单 ID">{detail.order.orderId}</Descriptions.Item>
<Descriptions.Item label="店铺">
@@ -106,6 +206,28 @@ export default function AdminOrderDetailPage() {
<Descriptions.Item label="支付状态">
<StatusTag value={detail.order.payStatus} kind="pay" />
</Descriptions.Item>
<Descriptions.Item label="H5 兑换状态">
<Space wrap>
{detail.order.claimStatus === 'paused' ? (
<Tag color="warning" icon={<PauseCircleOutlined />}>
已暂停兑换
</Tag>
) : detail.order.claimStatus === 'closed' ? (
<Tag color="default" icon={<StopOutlined />}>
已关闭兑换
</Tag>
) : (
<Tag color="success" icon={<PlayCircleOutlined />}>
正常有效
</Tag>
)}
{detail.order.claimStatusReason ? (
<span className="muted" style={{ fontSize: 12 }}>
({detail.order.claimStatusReason})
</span>
) : null}
</Space>
</Descriptions.Item>
<Descriptions.Item label="金额">
{detail.order.totalAmount} {detail.order.currency}
</Descriptions.Item>
@@ -177,6 +299,34 @@ export default function AdminOrderDetailPage() {
<Card title="原始载荷">
<JsonPreview value={detail.order.rawPayload} />
</Card>
<Modal
title={targetStatus === 'paused' ? '暂停订单兑换' : '关闭订单兑换'}
open={statusModalVisible}
onCancel={() => setStatusModalVisible(false)}
confirmLoading={statusMutation.isPending}
onOk={() => {
if (!statusReason.trim()) {
message.warning('请填写原因或备注(如:买家申请退款)')
return
}
statusMutation.mutate({ status: targetStatus, reason: statusReason.trim() })
}}
>
<div style={{ padding: '12px 0' }}>
<p style={{ color: '#64748b', marginBottom: 8 }}>
{targetStatus === 'paused'
? '暂停后,买家访问 H5 链接将被拦截并提示“兑换已暂停,请联系客服”,后续可随时恢复启用。'
: '关闭后,买家将无法继续进入 H5 页面兑换,适用于已退款或订单作废。'}
</p>
<Input.TextArea
rows={3}
placeholder="请输入暂停/关闭原因(必填,如:买家退款、售后争议排查)"
value={statusReason}
onChange={(e) => setStatusReason(e.target.value)}
/>
</div>
</Modal>
</section>
)
}
@@ -1,5 +1,5 @@
import { SearchOutlined, ReloadOutlined } from '@ant-design/icons'
import { Button, Card, Form, Input, Select, Space, Table, Tag, Typography } from 'antd'
import { SearchOutlined, ReloadOutlined, CopyOutlined } from '@ant-design/icons'
import { Button, Card, Form, Input, Select, Space, Table, Tag, Typography, message } from 'antd'
import type { TableColumnsType } from 'antd'
import { useQuery } from '@tanstack/react-query'
import { useEffect, useMemo } from 'react'
@@ -132,9 +132,25 @@ export default function AdminOrdersPage() {
minWidth: 220,
render: (_, row) => (
<div className="cell-stack">
<Typography.Link onClick={() => navigate(`/admin/orders/${row.orderId}`)}>
{row.platformOrderId}
</Typography.Link>
<Space size={4}>
<Typography.Link onClick={() => navigate(`/admin/orders/${row.orderId}`)}>
{row.platformOrderId}
</Typography.Link>
<Button
type="text"
size="small"
icon={<CopyOutlined />}
title="复制 H5 领取链接"
style={{ padding: 0, height: 'auto', color: '#64748b' }}
onClick={(e) => {
e.stopPropagation()
const url = `${window.location.origin}/h5/${encodeURIComponent(row.platformOrderId)}`
navigator.clipboard.writeText(url).then(() => {
message.success('已复制 H5 链接')
})
}}
/>
</Space>
<span className="muted">{row.shopName || row.shopId || row.provider}</span>
</div>
),
@@ -214,12 +230,17 @@ export default function AdminOrdersPage() {
},
{
title: '状态',
minWidth: 220,
minWidth: 240,
render: (_, row) => (
<Space size={[0, 6]} wrap>
<StatusTag value={row.payStatus} kind="pay" />
<StatusTag value={row.resourceStatus} kind="resource" />
<StatusTag value={row.customerStatus} kind="customer" />
{row.claimStatus === 'paused' ? (
<Tag color="warning">H5已暂停</Tag>
) : row.claimStatus === 'closed' ? (
<Tag color="default">H5已关闭</Tag>
) : null}
</Space>
),
},
+55 -13
View File
@@ -1,11 +1,23 @@
import { ExclamationCircleOutlined, LoadingOutlined } from '@ant-design/icons'
import {
ExclamationCircleOutlined,
LoadingOutlined,
StopOutlined,
WarningOutlined,
} from '@ant-design/icons'
import { Card, Empty, Spin } from 'antd'
import QRCode from 'qrcode'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useParams } from 'react-router'
import { showError, showSuccess } from '@/lib/feedback'
import { fetchClaimDetail, submitAffiliateDashClaim, submitClaimUid } from '@/services/claim'
import {
fetchClaimDetail,
fetchH5ClaimDetail,
submitAffiliateDashClaim,
submitClaimUid,
submitH5AffiliateDashClaim,
submitH5ClaimUid,
} from '@/services/claim'
import type { ClaimDetailData } from '@/types/claim'
import { AffiliateDashClaimPanel, AffiliateDashResultStep } from './ClaimAffiliateDashSteps'
@@ -15,8 +27,13 @@ import { resolveNextPollDelayMs, resolveRolePollDelayMs } from './claim-poll'
import { createClaimSnapshot, type ClaimSnapshot } from './claim-snapshot'
export default function ClaimPage() {
const { token: routeToken } = useParams<{ token: string }>()
const { token: routeToken, orderNo: routeOrderNo } = useParams<{
token?: string
orderNo?: string
}>()
const token = String(routeToken || '').trim()
const orderNo = String(routeOrderNo || '').trim()
const isH5Mode = Boolean(orderNo)
const [loading, setLoading] = useState(true)
const [submittingUid, setSubmittingUid] = useState(false)
@@ -82,7 +99,7 @@ export default function ClaimPage() {
const loadDetail = useCallback(
async (options: { silent?: boolean } = {}) => {
if (!token) {
if (!token && !orderNo) {
setLoading(false)
setErrorMessage('领取链接无效,请检查链接是否完整')
stopPolling()
@@ -95,7 +112,9 @@ export default function ClaimPage() {
setErrorMessage('')
try {
const response = await fetchClaimDetail(token)
const response = isH5Mode
? await fetchH5ClaimDetail(orderNo)
: await fetchClaimDetail(token)
await applyDetail(response.data)
return response.data
} catch (error) {
@@ -108,7 +127,7 @@ export default function ClaimPage() {
}
}
},
[applyDetail, stopPolling, token],
[applyDetail, isH5Mode, orderNo, stopPolling, token],
)
const resolveNextPollDelay = useCallback((nextSnapshot: ClaimSnapshot) => {
@@ -162,7 +181,9 @@ export default function ClaimPage() {
setSubmittingUid(true)
try {
const response = await submitClaimUid(token, uid)
const response = isH5Mode
? await submitH5ClaimUid(orderNo, uid)
: await submitClaimUid(token, uid)
await applyDetail(response.data)
showSuccess('UID 已提交')
} catch (error) {
@@ -194,10 +215,15 @@ export default function ClaimPage() {
setSubmittingSubmit(true)
try {
const response = await submitAffiliateDashClaim(token, {
gameAccount: uid,
bindUuid,
})
const response = isH5Mode
? await submitH5AffiliateDashClaim(orderNo, {
gameAccount: uid,
bindUuid,
})
: await submitAffiliateDashClaim(token, {
gameAccount: uid,
bindUuid,
})
await applyDetail(response.data)
showSuccess('发货已提交')
} catch (error) {
@@ -224,8 +250,24 @@ export default function ClaimPage() {
</Card>
) : errorMessage ? (
<Card className="claim-content-card claim-state-card">
<ExclamationCircleOutlined className="claim-large-icon claim-icon-error" />
<p>{errorMessage}</p>
{errorMessage.includes('暂停') ? (
<>
<WarningOutlined style={{ fontSize: 48, color: '#faad14' }} />
<h3 style={{ marginTop: 16 }}>订单兑换已暂停</h3>
<p style={{ color: '#64748b' }}>{errorMessage}</p>
</>
) : errorMessage.includes('关闭') || errorMessage.includes('退款') ? (
<>
<StopOutlined style={{ fontSize: 48, color: '#94a3b8' }} />
<h3 style={{ marginTop: 16 }}>订单不可兑换</h3>
<p style={{ color: '#64748b' }}>{errorMessage}</p>
</>
) : (
<>
<ExclamationCircleOutlined className="claim-large-icon claim-icon-error" />
<p>{errorMessage}</p>
</>
)}
</Card>
) : !detail ? (
<Card className="claim-content-card claim-state-card">
+15 -1
View File
@@ -1,4 +1,4 @@
import { apiGet } from '@/lib/http'
import { apiGet, apiPost } from '@/lib/http'
import type { AdminOrderDetail, AdminOrderListItem, AdminPagination } from '@/types/admin'
export function fetchAdminOrders(params?: Record<string, unknown>) {
@@ -11,3 +11,17 @@ export function fetchAdminOrders(params?: Record<string, unknown>) {
export function fetchAdminOrderDetail(orderId: number | string) {
return apiGet<AdminOrderDetail>(`/api/v1/admin/orders/${orderId}`)
}
export function updateAdminOrderClaimStatus(
orderId: number | string,
payload: { status: 'active' | 'paused' | 'closed'; reason?: string },
) {
return apiPost<{
orderId: number
platformOrderId: string
claimStatus: string
claimStatusReason: string
claimStatusUpdatedAt: string
}>(`/api/v1/admin/orders/${orderId}/claim-status`, payload)
}
+19
View File
@@ -15,3 +15,22 @@ export function submitAffiliateDashClaim(
) {
return apiPost<ClaimDetailData>(`/api/v1/claim/${token}/affiliate-dash/submit`, payload)
}
export function fetchH5ClaimDetail(orderNo: string) {
return apiGet<ClaimDetailData>(`/api/v1/claim/h5/${encodeURIComponent(orderNo)}`)
}
export function submitH5ClaimUid(orderNo: string, uid: string) {
return apiPost<ClaimDetailData>(`/api/v1/claim/h5/${encodeURIComponent(orderNo)}/uid`, { uid })
}
export function submitH5AffiliateDashClaim(
orderNo: string,
payload: { gameAccount: string; bindUuid: string },
) {
return apiPost<ClaimDetailData>(
`/api/v1/claim/h5/${encodeURIComponent(orderNo)}/affiliate-dash/submit`,
payload,
)
}
+6
View File
@@ -43,6 +43,9 @@ export interface AdminOrderListItem {
customerStatus: string
preparedTaskCount: number
completedTaskCount: number
claimStatus?: string
claimStatusReason?: string
claimStatusUpdatedAt?: string | null
}
export interface AdminOrderDetail {
@@ -55,6 +58,9 @@ export interface AdminOrderDetail {
platformOrderId: string
orderStatus: string
payStatus: string
claimStatus?: string
claimStatusReason?: string
claimStatusUpdatedAt?: string | null
buyerId: string
buyerName: string
receiverContact: string
+24
View File
@@ -39,6 +39,30 @@ export function isClaimPath(pathname: unknown): boolean {
return resolveClaimToken(pathname) !== ''
}
/**
* 取 H5 订单号领取链接(`/h5/<订单号>`)里的订单号。
*
* 只认 `/h5/<一段 orderNo>`:
* - `/h5/2623001922675146` 命中,结尾多余斜杠容忍(`/h5/2623001922675146/`);
* - `/h5`、`/h5/`、`/h5/a/b` 都不命中;
* - 查询串 / hash 片段(如 `/h5/123?x=1`)会被丢弃后取第一段。
*/
export function resolveH5ClaimOrderNo(pathname: unknown): string {
const normalized = String(pathname ?? '').trim()
if (!normalized.startsWith('/')) return ''
const segments = normalized.split(/[?#]/)[0].replace(/\/+$/, '').split('/').filter(Boolean)
if (segments.length !== 2 || segments[0] !== 'h5') return ''
return decodeTokenSegment(segments[1])
}
/** 是否为 H5 订单号领取链接(`/h5/<orderNo>`) */
export function isH5ClaimPath(pathname: unknown): boolean {
return resolveH5ClaimOrderNo(pathname) !== ''
}
function decodeTokenSegment(segment: string): string {
try {
return decodeURIComponent(segment)