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:
@@ -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`)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -10,6 +10,7 @@ export const ROCK_TOKEN_STATUS_LABELS: Record<string, string> = {
|
||||
available: '可用库存',
|
||||
using: '扫码使用中',
|
||||
bound: '已授权绑定',
|
||||
redeeming: '发货处理中',
|
||||
redeemed: '已交付发货',
|
||||
mismatch: '绑定错误',
|
||||
abnormal: '已上报失效',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user