feat: Rock 兑换支持上游异步 pending 并记录发货全链路报文

问题:上游 0666.pro 兑换返回 HTTP 200 + status=pending(官方处理中),
旧逻辑仅凭「没有 error 字段」就判定成功并写 redeemed 终态,导致本地显示
已发货而上游仍是 bound,且 redeemed 为硬终态无法自愈。

后端:
- RedeemSkin 改为三态判定(success / pending / failed),不再无 error 即成功
- 新增 redeeming「发货处理中」状态;pending 不写 redeemed_at/fulfill_id
- 成功落库前二次 refresh 确认上游 redeemed 或 redeemed_at 才置终态
- 新增确认 worker:20s 轮询、30s→5m 指数退避、110001 全局冷却 60s、
  15 分钟未确认仅告警不回退,确认后同步订单为 delivered 并触发回调
- pending 期间禁止重复兑换,订单保持 delivering,不下发发货成功回调
- 新增 rock_delivery_logs 表与记录器:入站参数、内部状态、上游请求/响应
  原文全部留档,保留 180 天
- 分配/绑定校验/刷新/导入/删除/回收站/统计/客服查询全部适配 redeeming
- 迁移 026:redeeming 跟踪字段、部分索引与报文日志表

前端:
- Token 列表新增「发货处理中」筛选项、标签与超时提示
- 删除/回收站纳入 redeeming 危险状态
- 历史抽屉拆为「状态历史 / 发货报文」双页签,按 trace 展示报文详情
- 买家发货页在 pending 时提示官方处理中,无需重复提交

测试:新增 pending 不落终态、异常响应不误判、重复提交不重复兑换、
确认后同步订单、保持 pending 并退避 5 个用例;go build/vet/test 与
前端构建均通过。
This commit is contained in:
2026-11-14 12:09:16 +08:00
parent 227d4b93ad
commit f0dea6a583
33 changed files with 2241 additions and 170 deletions
+40
View File
@@ -597,6 +597,7 @@ export interface RockStats {
available_stock: number;
using_stock: number;
bound_stock: number;
redeeming_stock?: number;
redeemed_stock: number;
mismatch_stock: number;
abnormal_stock: number;
@@ -604,6 +605,7 @@ export interface RockStats {
using_overdue_stock?: number;
using_danger_stock?: number;
bound_overdue_stock?: number;
redeeming_timeout_stock?: number;
auto_refresh_mismatch_enabled?: boolean;
upstream_points: number;
auto_delivery_enabled: boolean;
@@ -672,6 +674,14 @@ export interface RockTokenItem {
using_at?: string;
bound_at?: string;
redeemed_at?: string;
// 上游异步兑奖(pending)跟踪字段
redeem_requested_at?: string;
redeem_checked_at?: string;
redeem_attempts?: number;
redeem_next_retry_at?: string;
redeem_last_message?: string;
redeem_last_error?: string;
redeem_timeout_warned_at?: string;
created_at: string;
updated_at?: string;
deleted_at?: string | null;
@@ -816,6 +826,32 @@ export interface RockTokenStatusLog {
created_at: string
}
// 发货全链路报文日志:一次发货动作的每一步(入站 / 上游请求 / 上游响应 / 内部状态)。
export interface RockDeliveryLog {
id: number
trace_id: string
token_id: number
upstream_token_id: string
order_no: string
step: string
direction: 'inbound' | 'outbound' | 'internal' | string
status: string
http_method: string
http_url: string
http_status: number
request_headers: string
request_body: string
response_headers: string
response_body: string
duration_ms: number
error_message: string
actor_user_id?: number | null
actor_ip: string
user_agent: string
created_at: string
finished_at?: string
}
export interface RockStaleRefreshResult {
scanned: number;
fixed: number;
@@ -983,6 +1019,10 @@ export const rockApi = {
request
.get(`/platform/rock/tokens/${id}/status-logs`)
.then((r) => r.data.data as { list: RockTokenStatusLog[] }),
listDeliveryLogs: (id: number) =>
request
.get(`/platform/rock/tokens/${id}/delivery-logs`)
.then((r) => r.data.data as { list: RockDeliveryLog[] }),
refreshTokenBinding: (id: number) =>
request
.post(`/platform/rock/tokens/${id}/refresh-binding`)
+13 -4
View File
@@ -300,6 +300,7 @@ export default function Delivery() {
try {
const result = await deliveryApi.submit(decodedOrderNo, values.game_account, bindResult.bind_uuid, deliveryAuth)
setSubmitResult(result)
// pending 时订单仍是 delivering,保留 submitResult 让买家页展示「官方处理中」文案。
setOrder((prev) =>
prev
? {
@@ -311,7 +312,11 @@ export default function Delivery() {
}
: prev
)
message.success(result.message || '发货已提交')
if (result.pending) {
message.info(result.message || '官方处理中,系统会自动确认发货结果')
} else {
message.success(result.message || '发货已提交')
}
} catch (e) {
message.error(e instanceof Error ? e.message : '提交发货失败')
} finally {
@@ -541,10 +546,12 @@ export default function Delivery() {
<Spin indicator={<LoadingOutlined style={{ fontSize: 28, color: '#1677ff' }} spin />} />
<div>
<Typography.Title level={4} style={{ margin: 0, color: '#1677ff' }}>
道具正在极速发货中...
{submitResult?.pending ? '上游官方正在处理发货...' : '道具正在极速发货中...'}
</Typography.Title>
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
已成功提交上游发放节点,通常在 10 ~ 30 秒内到账,页面会自动更新。
{submitResult?.pending
? '上游已受理兑换,腾讯官方仍在处理中,系统会自动确认结果,无需重复提交。'
: '已成功提交上游发放节点,通常在 10 ~ 30 秒内到账,页面会自动更新。'}
</Typography.Text>
</div>
</div>
@@ -559,7 +566,9 @@ export default function Delivery() {
</Descriptions.Item>
<Descriptions.Item label="游戏区服">{order.game_channel || '自动识别'}</Descriptions.Item>
<Descriptions.Item label="处理进度">
<Tag color="processing" icon={<SyncOutlined spin />}>队列发放中</Tag>
<Tag color="processing" icon={<SyncOutlined spin />}>
{submitResult?.pending ? '官方处理中' : '队列发放中'}
</Tag>
</Descriptions.Item>
</Descriptions>
</div>
@@ -64,6 +64,16 @@ export function RockTokenFilterBar({
<Radio.Button value="available">可用</Radio.Button>
<Radio.Button value="using">使用中</Radio.Button>
<Radio.Button value="bound">已绑定</Radio.Button>
<Radio.Button value="redeeming">
<Badge
count={(stats?.redeeming_stock ?? 0) + (stats?.redeeming_timeout_stock ?? 0)}
offset={[6, -2]}
size="small"
style={{ backgroundColor: (stats?.redeeming_timeout_stock ?? 0) > 0 ? '#ef4444' : '#6366f1' }}
>
<span>发货处理中</span>
</Badge>
</Radio.Button>
<Radio.Button value="redeemed">已发货</Radio.Button>
<Radio.Button value="mismatch">
<Badge count={stats?.mismatch_stock ?? 0} offset={[6, -2]} size="small">
@@ -99,6 +99,8 @@ export function RockTokenTable({
message.warning(res.message || '上游绑定状态需要人工核对')
} else if (res.status === 'redeemed') {
message.success(res.message || '上游已发货,本地状态已同步为已交付')
} else if (res.status === 'redeeming') {
message.info(res.message || '上游仍未确认发货,保持发货处理中')
} else if (res.bound) {
const role = res.role_name || res.role_id || '-'
message.success(res.message || `刷新成功:上游已绑定「${role}」`)
@@ -28,6 +28,7 @@ const STATUS_LABELS: Record<string, string> = {
available: '可用库存',
using: '使用中',
bound: '已绑定',
redeeming: '发货处理中',
redeemed: '已发货',
mismatch: '绑定错误',
abnormal: '已上报失效',
@@ -35,7 +36,7 @@ const STATUS_LABELS: Record<string, string> = {
bound_overdue: '绑定超时',
}
const DANGEROUS_STATUSES = new Set(['using', 'bound', 'mismatch'])
const DANGEROUS_STATUSES = new Set(['using', 'bound', 'redeeming', 'mismatch'])
function statusLabel(status: string) {
return STATUS_LABELS[status] || status || '-'
@@ -49,6 +50,8 @@ function statusTagColor(status: string) {
return 'processing'
case 'bound':
return 'cyan'
case 'redeeming':
return 'geekblue'
case 'redeemed':
return 'purple'
case 'mismatch':
@@ -222,7 +225,7 @@ export function TokenDeleteModal({
<Text type="secondary"> · 其中可用库存 {available} 条</Text>
)}
{dangerous > 0 && (
<Text type="danger"> · 使用中/已绑定/绑定错误 {dangerous} 条</Text>
<Text type="danger"> · 使用中/已绑定/发货处理中/绑定错误 {dangerous} 条</Text>
)}
</div>
<div>
@@ -247,7 +250,7 @@ export function TokenDeleteModal({
showIcon
icon={<ExclamationCircleOutlined />}
message={`包含 ${dangerous} 条进行中的 Token`}
description="使用中/已绑定/绑定错误的 Token 与订单履约相关。删除后它们会从列表和统计中消失,可能导致客服排查困难;请确认这些订单已无需处理,或已经人工接管。"
description="使用中/已绑定/发货处理中/绑定错误的 Token 与订单履约相关。删除后它们会从列表和统计中消失,可能导致客服排查困难;请确认这些订单已无需处理,或已经人工接管。"
/>
)}
@@ -31,6 +31,7 @@ const STATUS_LABELS: Record<string, string> = {
available: '可用库存',
using: '使用中',
bound: '已绑定',
redeeming: '发货处理中',
redeemed: '已发货',
mismatch: '绑定错误',
abnormal: '已上报失效',
@@ -43,6 +44,7 @@ const STATUS_OPTIONS = [
{ value: 'available', label: '可用库存' },
{ value: 'using', label: '使用中' },
{ value: 'bound', label: '已绑定' },
{ value: 'redeeming', label: '发货处理中' },
{ value: 'redeemed', label: '已发货' },
{ value: 'mismatch', label: '绑定错误' },
{ value: 'abnormal', label: '已上报失效' },
@@ -50,7 +52,7 @@ const STATUS_OPTIONS = [
{ value: 'bound_overdue', label: '绑定超时' },
]
const DANGEROUS_STATUSES = new Set(['using', 'bound', 'mismatch'])
const DANGEROUS_STATUSES = new Set(['using', 'bound', 'redeeming', 'mismatch'])
const SKIP_REASONS: Record<string, string> = {
not_deleted: '已不在回收站',
@@ -70,6 +72,8 @@ function statusTagColor(status: string) {
return 'processing'
case 'bound':
return 'cyan'
case 'redeeming':
return 'geekblue'
case 'redeemed':
return 'purple'
case 'mismatch':
@@ -1,7 +1,25 @@
import { useCallback, useEffect, useState } from 'react'
import { Alert, Button, Drawer, Empty, Grid, Space, Spin, Tag, Timeline, Typography } from 'antd'
import { useCallback, useEffect, useState, type CSSProperties } from 'react'
import {
Alert,
Button,
Collapse,
Drawer,
Empty,
Grid,
Space,
Spin,
Tabs,
Tag,
Timeline,
Typography,
} from 'antd'
import { ReloadOutlined } from '@ant-design/icons'
import { rockApi, type RockTokenItem, type RockTokenStatusLog } from '../../api'
import {
rockApi,
type RockDeliveryLog,
type RockTokenItem,
type RockTokenStatusLog,
} from '../../api'
import { formatDateTime } from '../../utils/time'
import { rockTokenStatusLabel } from './types'
@@ -36,6 +54,36 @@ const SOURCE_LABELS: Record<string, string> = {
restore: '回收站恢复',
}
// 发货链路的步骤中文名,用于报文时间线。
const DELIVERY_STEP_LABELS: Record<string, string> = {
submit_request: '提交发货',
redeem_request: '上游兑换请求',
redeem_result: '上游兑换结果',
redeem_confirm: '发货结果确认',
redeem_timeout: '发货确认超时',
refresh_token: '上游刷新(管理端)',
refresh_redeem_binding: '上游刷新(公开端)',
state_change: '本地状态变更',
order_update: '订单状态更新',
}
const DELIVERY_STATUS_META: Record<
string,
{ label: string; tagColor: string; timelineColor: string }
> = {
started: { label: '进行中', tagColor: 'processing', timelineColor: 'blue' },
success: { label: '成功', tagColor: 'success', timelineColor: 'green' },
pending: { label: '处理中', tagColor: 'warning', timelineColor: 'orange' },
failed: { label: '失败', tagColor: 'error', timelineColor: 'red' },
skipped: { label: '跳过', tagColor: 'default', timelineColor: 'gray' },
}
const DIRECTION_META: Record<string, { label: string; color: string }> = {
inbound: { label: '本系统接收', color: 'geekblue' },
outbound: { label: '调用上游', color: 'purple' },
internal: { label: '内部处理', color: 'default' },
}
function eventLabel(eventType: string): string {
return EVENT_META[eventType]?.label || eventType || '事件'
}
@@ -63,6 +111,144 @@ function transitionText(log: RockTokenStatusLog): string {
return `${from} → ${to}`
}
// prettyJSON 让报文可读;非法 JSON 原样返回,保留上游原始文本。
function prettyJSON(raw?: string): string {
const value = (raw || '').trim()
if (!value) return ''
try {
return JSON.stringify(JSON.parse(value), null, 2)
} catch {
return value
}
}
function deliveryStatusMeta(status: string) {
return (
DELIVERY_STATUS_META[status] || {
label: status || '未知',
tagColor: 'default',
timelineColor: 'gray',
}
)
}
function DeliveryLogItem({ log }: { log: RockDeliveryLog }) {
const statusMeta = deliveryStatusMeta(log.status)
const directionMeta = DIRECTION_META[log.direction] || {
label: log.direction || '未知方向',
color: 'default',
}
const requestBody = prettyJSON(log.request_body)
const responseBody = prettyJSON(log.response_body)
const requestHeaders = prettyJSON(log.request_headers)
const responseHeaders = prettyJSON(log.response_headers)
const hasDetail = Boolean(
requestHeaders || requestBody || responseHeaders || responseBody || log.error_message,
)
return (
<Space direction="vertical" size={4} style={{ width: '100%' }}>
<Space size={[6, 4]} wrap>
<Tag color={directionMeta.color}>{directionMeta.label}</Tag>
<Tag color={statusMeta.tagColor}>{statusMeta.label}</Tag>
<Text strong style={{ fontSize: 13 }}>
{DELIVERY_STEP_LABELS[log.step] || log.step || '步骤'}
</Text>
{log.http_method ? (
<Text code style={{ fontSize: 11 }}>
{log.http_method}
{log.http_status ? ` ${log.http_status}` : ''}
</Text>
) : null}
{log.duration_ms > 0 && (
<Text type="secondary" style={{ fontSize: 11 }}>
{log.duration_ms} ms
</Text>
)}
</Space>
{log.http_url && (
<Text
type="secondary"
style={{ fontSize: 11, wordBreak: 'break-all' }}
copyable={{ text: log.http_url }}
>
{log.http_url}
</Text>
)}
{hasDetail && (
<Collapse
size="small"
ghost
items={[
{
key: 'detail',
label: <Text style={{ fontSize: 12 }}>查看报文详情</Text>,
children: (
<Space direction="vertical" size={8} style={{ width: '100%' }}>
{requestHeaders && (
<div>
<Text strong style={{ fontSize: 12 }}>
请求头
</Text>
<pre style={preStyle}>{requestHeaders}</pre>
</div>
)}
{requestBody && (
<div>
<Text strong style={{ fontSize: 12 }}>
请求报文
</Text>
<pre style={preStyle}>{requestBody}</pre>
</div>
)}
{responseHeaders && (
<div>
<Text strong style={{ fontSize: 12 }}>
响应头
</Text>
<pre style={preStyle}>{responseHeaders}</pre>
</div>
)}
{responseBody && (
<div>
<Text strong style={{ fontSize: 12 }}>
响应报文
</Text>
<pre style={preStyle}>{responseBody}</pre>
</div>
)}
{log.error_message && (
<Alert type="error" showIcon message={log.error_message} />
)}
</Space>
),
},
]}
/>
)}
<Text type="secondary" style={{ fontSize: 11 }}>
{formatDateTime(log.created_at)}
{log.trace_id ? ` · trace ${log.trace_id}` : ''}
{log.actor_user_id ? ` · 操作人 #${log.actor_user_id}` : ''}
</Text>
{log.error_message && !hasDetail && <Alert type="error" showIcon message={log.error_message} />}
</Space>
)
}
const preStyle: CSSProperties = {
margin: '4px 0 0',
padding: 8,
background: '#0f172a',
color: '#e2e8f0',
borderRadius: 6,
fontSize: 11,
lineHeight: 1.5,
maxHeight: 280,
overflow: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
}
export function TokenStatusLogDrawer({
open,
token,
@@ -72,8 +258,12 @@ export function TokenStatusLogDrawer({
const screens = Grid.useBreakpoint()
const isMobile = !screens.md
const [logs, setLogs] = useState<RockTokenStatusLog[]>([])
const [deliveryLogs, setDeliveryLogs] = useState<RockDeliveryLog[]>([])
const [loading, setLoading] = useState(false)
const [loadingDelivery, setLoadingDelivery] = useState(false)
const [error, setError] = useState('')
const [deliveryError, setDeliveryError] = useState('')
const [activeTab, setActiveTab] = useState('status')
const load = useCallback(async () => {
if (!token) return
@@ -91,51 +281,31 @@ export function TokenStatusLogDrawer({
}
}, [token, loadLogs])
const loadDelivery = useCallback(async () => {
if (!token) return
setLoadingDelivery(true)
setDeliveryError('')
try {
const res = await rockApi.listDeliveryLogs(token.id)
setDeliveryLogs(res.list || [])
} catch (e) {
setDeliveryError(e instanceof Error ? e.message : '加载发货报文失败')
} finally {
setLoadingDelivery(false)
}
}, [token])
useEffect(() => {
if (!open || !token) return
setLogs([])
load()
}, [open, token, load])
return (
<Drawer
open={open}
onClose={onClose}
width={isMobile ? '100%' : 600}
title={token ? `状态历史 · #${token.id}` : '状态历史'}
extra={
<Button
size="small"
icon={<ReloadOutlined />}
loading={loading}
onClick={load}
>
刷新
</Button>
}
>
{token && (
<div
style={{
marginBottom: 16,
padding: 10,
borderRadius: 8,
background: '#f8fafc',
border: '1px solid #e2e8f0',
}}
>
<Space direction="vertical" size={2} style={{ width: '100%' }}>
<Text style={{ fontFamily: 'monospace', fontSize: 12 }}>
{token.upstream_token_id || '-'} / {token.redeem_code || '-'}
</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
当前状态:{rockTokenStatusLabel(token.status)}
{token.assigned_order_no ? ` · 工单 ${token.assigned_order_no}` : ''}
</Text>
</Space>
</div>
)}
setDeliveryLogs([])
setActiveTab('status')
void load()
void loadDelivery()
}, [open, token, load, loadDelivery])
const statusContent = (
<>
{error && (
<Alert
type="error"
@@ -149,7 +319,6 @@ export function TokenStatusLogDrawer({
}
/>
)}
{loading ? (
<div style={{ padding: '48px 0', textAlign: 'center' }}>
<Spin />
@@ -192,6 +361,108 @@ export function TokenStatusLogDrawer({
}))}
/>
)}
</>
)
const deliveryContent = (
<>
{deliveryError && (
<Alert
type="error"
showIcon
message={deliveryError}
style={{ marginBottom: 12 }}
action={
<Button size="small" onClick={loadDelivery}>
重试
</Button>
}
/>
)}
{loadingDelivery ? (
<div style={{ padding: '48px 0', textAlign: 'center' }}>
<Spin />
</div>
) : deliveryLogs.length === 0 ? (
<Empty description="暂无发货报文记录" />
) : (
<Timeline
items={deliveryLogs.map((log) => ({
key: log.id,
color: deliveryStatusMeta(log.status).timelineColor,
children: <DeliveryLogItem log={log} />,
}))}
/>
)}
</>
)
return (
<Drawer
open={open}
onClose={onClose}
width={isMobile ? '100%' : 720}
title={token ? `发货历史 · #${token.id}` : '发货历史'}
extra={
<Button
size="small"
icon={<ReloadOutlined />}
loading={loading || loadingDelivery}
onClick={() => {
void load()
void loadDelivery()
}}
>
刷新
</Button>
}
>
{token && (
<div
style={{
marginBottom: 12,
padding: 10,
borderRadius: 8,
background: '#f8fafc',
border: '1px solid #e2e8f0',
}}
>
<Space direction="vertical" size={2} style={{ width: '100%' }}>
<Text style={{ fontFamily: 'monospace', fontSize: 12, wordBreak: 'break-all' }}>
{token.upstream_token_id || '-'} / {token.redeem_code || '-'}
</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
当前状态:{rockTokenStatusLabel(token.status)}
{token.assigned_order_no ? ` · 工单 ${token.assigned_order_no}` : ''}
</Text>
{token.status === 'redeeming' && (
<Text type="secondary" style={{ fontSize: 12 }}>
{token.redeem_last_message || '上游官方处理中,系统会自动确认发货结果'}
{token.redeem_requested_at
? ` · 提交于 ${formatDateTime(token.redeem_requested_at)}`
: ''}
</Text>
)}
</Space>
</div>
)}
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
items={[
{
key: 'status',
label: `状态历史${logs.length ? ` (${logs.length})` : ''}`,
children: statusContent,
},
{
key: 'delivery',
label: `发货报文${deliveryLogs.length ? ` (${deliveryLogs.length})` : ''}`,
children: deliveryContent,
},
]}
/>
</Drawer>
)
}
@@ -208,6 +208,29 @@ export function buildTokenColumns({
</Tag>
)
}
case 'redeeming': {
// pending 起点以 redeem_requested_at 为准,按分钟展示等待时长。
const pendingDays = elapsedDaysSince(record.redeem_requested_at)
const pendingMinutes = pendingDays === null ? null : Math.floor(pendingDays * 24 * 60)
const timeoutWarned = Boolean(record.redeem_timeout_warned_at)
return (
<Tooltip
title={
record.redeem_last_message ||
'上游官方处理中,系统会自动轮询确认发货结果'
}
>
<Tag
color={timeoutWarned ? 'volcano' : 'geekblue'}
style={{ fontWeight: 600, padding: '2px 8px' }}
>
{timeoutWarned
? `⏱ 发货确认超时${pendingMinutes !== null ? ` ${pendingMinutes} 分钟` : ''}`
: '⏳ 发货处理中'}
</Tag>
</Tooltip>
)
}
case 'redeemed':
return (
<Tag color="purple" style={{ fontWeight: 600, padding: '2px 8px' }}>
@@ -270,6 +293,18 @@ export function buildTokenColumns({
{usingDays !== null ? `(${floorDays(usingDays).toFixed(1)} 天)` : ''}
</div>
)}
{record.status === 'redeeming' && (
<div
style={{
fontSize: 11,
color: record.redeem_timeout_warned_at ? '#dc2626' : '#4338ca',
}}
>
<span>⏳ 提交发货: </span>
{formatDateTime(record.redeem_requested_at)}
{record.redeem_attempts ? `(已查询 ${record.redeem_attempts} 次)` : ''}
</div>
)}
{record.mismatch_at && (
<div style={{ fontSize: 11, color: '#e11d48' }}>
<span>⚠️ 绑错: </span>
+1
View File
@@ -10,6 +10,7 @@ export const ROCK_TOKEN_STATUS_LABELS: Record<string, string> = {
available: '可用库存',
using: '扫码使用中',
bound: '已授权绑定',
redeeming: '发货处理中',
redeemed: '已交付发货',
mismatch: '绑定错误',
abnormal: '已上报失效',
+2
View File
@@ -233,10 +233,12 @@ export interface DeliveryBindResult {
export interface DeliverySubmitResult {
order_no: string
status: string
pending?: boolean
message: string
provider_order_no?: string
game_account?: Record<string, unknown>
upstream_order?: unknown
delivery?: unknown
}
export interface DeliveryLinkResult {