feat(fulfillment): 接入Rock发货平台(0666.pro)代理履约、通用库存池与UID强一致风控

1. 数据库新增 012_rock_fulfillment 迁移,创建 rock_configs 配置表与 rock_tokens 通用库存表,补齐黑色ROCK长袖T恤与蓝色运动服上衣商品目录;
2. 封装 RockClient 与 RockService,实现 0666.pro 平台 Bearer Token 极简连通校验、批量采购50个入库、水位监控巡检与自动补货;
3. 新增 RockProvider 实现标准履约路由,引入 Anti-Ghost 旧绑定基线比对与 UID 强一致门禁,扫错号硬阻断并记录为 mismatch 状态;
4. 针对黑色ROCK与蓝色运动服实现动态发货(绑定后发货前动态选择 skin),并提取腾讯官方发货流水号 fulfillId 对账;
5. 新增全局自动发货总开关(Kill Switch),支持一键软熔断降级转人工接单处理;
6. 前端新增 PlatformRock 管理大屏,提供总开关切换、Token配置与连通测试、库存监控、一键补货及错单解冻重置。
This commit is contained in:
yml2213
2026-10-05 14:56:11 +08:00
parent 46ec3574ae
commit ef7f67dd0c
16 changed files with 3421 additions and 2 deletions
+9
View File
@@ -14,6 +14,7 @@ import AlertSettings from './pages/AlertSettings'
import PlatformMerchants from './pages/PlatformMerchants'
import PlatformProducts from './pages/PlatformProducts'
import PlatformUsers from './pages/PlatformUsers'
import PlatformRock from './pages/PlatformRock'
import type { ReactNode } from 'react'
function PrivateRoute({ children }: { children: ReactNode }) {
const { token } = useAuth()
@@ -95,6 +96,14 @@ function AppRoutes() {
</AdminRoute>
}
/>
<Route
path="platform-rock"
element={
<AdminRoute>
<PlatformRock />
</AdminRoute>
}
/>
<Route path="platform-users" element={<AdminRoute><PlatformUsers /></AdminRoute>} />
<Route path="open-api" element={<MerchantPermissionRoute permission="api:manage"><OpenApiDocs /></MerchantPermissionRoute>} />
<Route
+67
View File
@@ -252,4 +252,71 @@ export const lewanApi = {
request.put(`/platform/products/${id}/routing`, data).then((r) => r.data.data as { updated: boolean }),
}
export interface RockConfig {
id: number
auto_delivery_enabled: boolean
base_url: string
bearer_token: string
username: string
points: number
min_points_warning: number
token_warning_stock: number
auto_purchase_enabled: boolean
auto_purchase_count: number
purchase_cooldown_minutes: number
last_purchased_at?: string
last_heartbeat_at?: string
}
export interface RockStats {
available_stock: number
using_stock: number
bound_stock: number
redeemed_stock: number
mismatch_stock: number
abnormal_stock: number
upstream_points: number
auto_delivery_enabled: boolean
token_warning_stock: number
auto_purchase_enabled: boolean
username: string
last_heartbeat_at?: string
}
export interface RockTokenItem {
id: number
upstream_token_id: string
redeem_code: string
color: string
region: string
status: string
assigned_order_no?: string
expected_uid?: string
actual_role_name?: string
actual_role_id?: string
actual_channel?: string
upstream_fulfill_id?: number
target_skin?: string
target_currency?: string
target_currency_quantity?: number
mismatch_at?: string
mismatch_reason?: string
claimed_at?: string
bound_at?: string
redeemed_at?: string
created_at: string
}
export const rockApi = {
getConfig: () => request.get('/platform/rock/config').then((r) => r.data.data as RockConfig),
updateConfig: (data: Partial<RockConfig>) => request.put('/platform/rock/config', data).then((r) => r.data.data as RockConfig),
testConnection: (bearer_token?: string) => request.post('/platform/rock/test-conn', { bearer_token }).then((r) => r.data.data),
getStats: () => request.get('/platform/rock/stats').then((r) => r.data.data as RockStats),
purchaseTokens: (count = 50) => request.post('/platform/rock/purchase', { count }).then((r) => r.data.data),
listTokens: (params?: { page?: number; page_size?: number; status?: string; search?: string }) =>
request.get('/platform/rock/tokens', { params }).then((r) => r.data.data as { list: RockTokenItem[]; total: number; page: number; page_size: number }),
resetToken: (id: number) => request.post(`/platform/rock/tokens/${id}/reset`).then((r) => r.data.data),
}
+1
View File
@@ -69,6 +69,7 @@ const adminSections: SidebarSection[] = [
children: [
{ key: 'merchant-products', label: '商品列表', path: '/merchant-products' },
{ key: 'platform-products', label: '渠道与路由配置', path: '/platform-products' },
{ key: 'platform-rock', label: 'Rock发货与库存风控', path: '/platform-rock' },
],
},
{
+609
View File
@@ -0,0 +1,609 @@
import { useCallback, useEffect, useState } from 'react'
import {
Alert,
Badge,
Button,
Card,
Col,
Divider,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Row,
Radio,
Space,
Statistic,
Switch,
Table,
Tag,
Tooltip,
Typography,
message,
} from 'antd'
import {
CheckCircleOutlined,
ExclamationCircleOutlined,
FireOutlined,
KeyOutlined,
PlusCircleOutlined,
ReloadOutlined,
SearchOutlined,
ShoppingCartOutlined,
ThunderboltOutlined,
UndoOutlined,
} from '@ant-design/icons'
import type { ColumnsType } from 'antd/es/table'
import { rockApi, type RockConfig, type RockStats, type RockTokenItem } from '../api'
import { PageHeader } from '../components/PageHeader'
const { Text } = Typography
export default function PlatformRock() {
const [config, setConfig] = useState<RockConfig | null>(null)
const [stats, setStats] = useState<RockStats | null>(null)
const [loadingStats, setLoadingStats] = useState(false)
const [testingConn, setTestingConn] = useState(false)
const [savingConfig, setSavingConfig] = useState(false)
const [purchasing, setPurchasing] = useState(false)
// Token 列表与筛选
const [tokens, setTokens] = useState<RockTokenItem[]>([])
const [totalTokens, setTotalTokens] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [filterStatus, setFilterStatus] = useState<string>('all')
const [searchKeyword, setSearchKeyword] = useState('')
const [loadingTokens, setLoadingTokens] = useState(false)
const [form] = Form.useForm()
const loadData = useCallback(async () => {
setLoadingStats(true)
try {
const [cfg, st] = await Promise.all([rockApi.getConfig(), rockApi.getStats()])
setConfig(cfg)
setStats(st)
form.setFieldsValue({
auto_delivery_enabled: cfg.auto_delivery_enabled,
base_url: cfg.base_url,
bearer_token: cfg.bearer_token,
min_points_warning: cfg.min_points_warning,
token_warning_stock: cfg.token_warning_stock,
auto_purchase_enabled: cfg.auto_purchase_enabled,
auto_purchase_count: cfg.auto_purchase_count,
purchase_cooldown_minutes: cfg.purchase_cooldown_minutes,
})
} catch (e) {
message.error('加载 Rock 平台配置失败: ' + (e instanceof Error ? e.message : '未知错误'))
} finally {
setLoadingStats(false)
}
}, [form])
const loadTokens = useCallback(async () => {
setLoadingTokens(true)
try {
const res = await rockApi.listTokens({
page,
page_size: pageSize,
status: filterStatus === 'all' ? undefined : filterStatus,
search: searchKeyword || undefined,
})
setTokens(res.list || [])
setTotalTokens(res.total || 0)
} catch (e) {
message.error('加载库存列表失败: ' + (e instanceof Error ? e.message : '未知错误'))
} finally {
setLoadingTokens(false)
}
}, [page, pageSize, filterStatus, searchKeyword])
useEffect(() => {
loadData()
}, [loadData])
useEffect(() => {
loadTokens()
}, [loadTokens])
// 一键切换自动发货总开关 (Kill Switch)
const handleToggleAutoDelivery = async (checked: boolean) => {
Modal.confirm({
title: checked ? '确认开启 Rock 自动发货通道?' : '确认一键关闭 Rock 自动发货通道(软熔断)?',
content: checked
? '开启后,黑色 ROCK 与蓝色运动服两款商品将恢复全自动反向代理发货。'
: '关闭后,两款商品将彻底脱离自动发货流程,订单将转交人工打手或客服处理,避免错单穿透。',
okText: '确认更改',
cancelText: '取消',
okType: checked ? 'primary' : 'danger',
onOk: async () => {
try {
const updated = await rockApi.updateConfig({ auto_delivery_enabled: checked })
setConfig(updated)
message.success(checked ? '自动发货已开启' : '自动发货已成功熔断关闭')
loadData()
} catch (e) {
message.error('更新总开关失败: ' + (e instanceof Error ? e.message : '未知错误'))
}
},
})
}
// 测试连通性
const handleTestConnection = async () => {
const bearerToken = form.getFieldValue('bearer_token')
if (!bearerToken) {
message.warning('请先输入 Bearer Token')
return
}
setTestingConn(true)
try {
const res = await rockApi.testConnection(bearerToken)
message.success(`连通成功!采购商户:${res.user?.username || '正常'},可用积分:${res.user?.points || 0}`)
loadData()
} catch (e) {
message.error('连通性测试失败:' + (e instanceof Error ? e.message : '未知错误'))
} finally {
setTestingConn(false)
}
}
// 保存策略配置
const handleSaveConfig = async () => {
try {
const values = await form.validateFields()
setSavingConfig(true)
const updated = await rockApi.updateConfig(values)
setConfig(updated)
message.success('配置已保存生效')
loadData()
} catch (e) {
if (e instanceof Error) {
message.error('保存失败: ' + e.message)
}
} finally {
setSavingConfig(false)
}
}
// 批量采购 50 个
const handlePurchase = async (count = 50) => {
setPurchasing(true)
try {
const res = await rockApi.purchaseTokens(count)
message.success(res.message || `成功采购 ${count} 个通用黑色 Token!`)
loadData()
loadTokens()
} catch (e) {
message.error('采购失败: ' + (e instanceof Error ? e.message : '未知错误'))
} finally {
setPurchasing(false)
}
}
// 人工重置解冻 Mismatch Token
const handleResetToken = async (id: number) => {
try {
await rockApi.resetToken(id)
message.success('已解冻重置为可用库存')
loadData()
loadTokens()
} catch (e) {
message.error('重置失败: ' + (e instanceof Error ? e.message : '未知错误'))
}
}
const columns: ColumnsType<RockTokenItem> = [
{
title: 'ID / 提取码',
dataIndex: 'id',
width: 140,
render: (id, record) => (
<Space direction="vertical" size={2}>
<Text strong>#{id}</Text>
<Text copyable={{ text: record.redeem_code }} type="secondary" style={{ fontSize: 12 }}>
{record.redeem_code?.substring(0, 10)}...
</Text>
</Space>
),
},
{
title: '颜色与适用范围',
dataIndex: 'color',
width: 130,
render: (_, record) => (
<Space direction="vertical" size={2}>
<Tag color="purple">通用黑色 Token</Tag>
<Text type="secondary" style={{ fontSize: 12 }}>
{record.region || 'Q/V可用'}
</Text>
</Space>
),
},
{
title: '状态',
dataIndex: 'status',
width: 120,
render: (status) => {
switch (status) {
case 'available':
return <Tag color="green">可用库存</Tag>
case 'using':
return <Tag color="blue">使用中 (扫码)</Tag>
case 'bound':
return <Tag color="cyan">已绑定授权</Tag>
case 'redeemed':
return <Tag color="purple">已交付发货</Tag>
case 'mismatch':
return <Tag color="red">绑定错误 (阻断)</Tag>
default:
return <Tag>{status}</Tag>
}
},
},
{
title: '关联订单',
dataIndex: 'assigned_order_no',
width: 170,
render: (orderNo) =>
orderNo ? (
<Text copyable style={{ fontSize: 12 }}>
{orderNo}
</Text>
) : (
<Text type="secondary">-</Text>
),
},
{
title: '预期 UID vs 扫码角色',
width: 220,
render: (_, record) => {
const hasMismatch = record.status === 'mismatch'
return (
<Space direction="vertical" size={2} style={{ width: '100%' }}>
<div>
<Text type="secondary" style={{ fontSize: 12 }}>预期UID: </Text>
<Text strong>{record.expected_uid || '-'}</Text>
</div>
<div>
<Text type="secondary" style={{ fontSize: 12 }}>扫码角色: </Text>
<Text type={hasMismatch ? 'danger' : undefined}>
{record.actual_role_name ? `${record.actual_role_name} (${record.actual_role_id || '-'})` : '-'}
</Text>
</div>
{hasMismatch && record.mismatch_reason && (
<Tooltip title={record.mismatch_reason}>
<Tag color="error" style={{ fontSize: 11, maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis' }}>
{record.mismatch_reason}
</Tag>
</Tooltip>
)}
</Space>
)
},
},
{
title: '官方发货凭证 (FulfillId)',
dataIndex: 'upstream_fulfill_id',
width: 150,
render: (fulfillId, record) =>
fulfillId ? (
<Space direction="vertical" size={2}>
<Text copyable strong style={{ color: '#52c41a' }}>
{fulfillId}
</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
{record.target_skin || '和平精英道具'}
</Text>
</Space>
) : (
<Text type="secondary">-</Text>
),
},
{
title: '操作',
key: 'action',
width: 100,
render: (_, record) => {
if (record.status === 'mismatch') {
return (
<Popconfirm
title="确定解冻重置该 Token?"
description="重置后将清除订单关联并重新释放为可用库存。"
onConfirm={() => handleResetToken(record.id)}
>
<Button size="small" type="link" danger icon={<UndoOutlined />}>
解冻重置
</Button>
</Popconfirm>
)
}
return <Text type="secondary">-</Text>
},
},
]
const autoDelivery = config?.auto_delivery_enabled ?? true
const availableStock = stats?.available_stock ?? 0
const isStockLow = availableStock <= (config?.token_warning_stock ?? 10)
return (
<div style={{ padding: 24, maxWidth: 1400, margin: '0 auto' }}>
<PageHeader
title="Rock 发货平台 (0666.pro) 自动发货与库存风控"
subtitle="管理和平精英「黑色ROCK长袖T恤」与「蓝色运动服上衣」通用库存池、采购策略、UID强一致防错门禁与软熔断机制"
/>
{/* 熔断状态警示条 */}
{!autoDelivery && (
<Alert
type="error"
showIcon
style={{ marginBottom: 20 }}
message="【注意】Rock 自动发货全局总开关已关闭(已软熔断)"
description="当前黑色 ROCK 和蓝色运动服两款商品已停止调用 0666.pro 自动发货,订单保留在待发货状态,将由人工打手在接单平台处理。"
/>
)}
{/* 核心指标统计看板 */}
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} sm={12} md={6}>
<Card bordered={false} style={{ background: '#fff', borderRadius: 8, boxShadow: '0 2px 8px rgba(0,0,0,0.06)' }}>
<Statistic
title="可用通用库存 (Available)"
value={availableStock}
valueStyle={{ color: isStockLow ? '#cf1322' : '#3f8600', fontWeight: 'bold' }}
prefix={<ShoppingCartOutlined />}
suffix="个"
/>
<div style={{ marginTop: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Text type="secondary" style={{ fontSize: 12 }}>
警戒水位: {config?.token_warning_stock ?? 10} 个
</Text>
{isStockLow && <Tag color="error">库存紧缺</Tag>}
</div>
</Card>
</Col>
<Col xs={24} sm={12} md={6}>
<Card bordered={false} style={{ background: '#fff', borderRadius: 8, boxShadow: '0 2px 8px rgba(0,0,0,0.06)' }}>
<Statistic
title="绑定错误阻断 (Mismatch)"
value={stats?.mismatch_stock ?? 0}
valueStyle={{ color: (stats?.mismatch_stock ?? 0) > 0 ? '#f5222d' : '#8c8c8c', fontWeight: 'bold' }}
prefix={<ExclamationCircleOutlined />}
suffix="个"
/>
<div style={{ marginTop: 8 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
{(stats?.mismatch_stock ?? 0) > 0 ? '存在 UID 校验不匹配的待排查记录' : '当前无绑定异常'}
</Text>
</div>
</Card>
</Col>
<Col xs={24} sm={12} md={6}>
<Card bordered={false} style={{ background: '#fff', borderRadius: 8, boxShadow: '0 2px 8px rgba(0,0,0,0.06)' }}>
<Statistic
title="履约中 / 已交付"
value={stats?.redeemed_stock ?? 0}
valueStyle={{ color: '#1677ff', fontWeight: 'bold' }}
prefix={<CheckCircleOutlined />}
suffix={`(扫码中: ${stats?.using_stock ?? 0})`}
/>
<div style={{ marginTop: 8 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
已完成发货交付总量
</Text>
</div>
</Card>
</Col>
<Col xs={24} sm={12} md={6}>
<Card bordered={false} style={{ background: '#fff', borderRadius: 8, boxShadow: '0 2px 8px rgba(0,0,0,0.06)' }}>
<Statistic
title="0666 账号积分余额"
value={stats?.upstream_points ?? 0}
precision={1}
valueStyle={{ color: (stats?.upstream_points ?? 0) < (config?.min_points_warning ?? 1050) ? '#faad14' : '#722ed1', fontWeight: 'bold' }}
prefix={<FireOutlined />}
suffix="点"
/>
<div style={{ marginTop: 8, display: 'flex', justifyContent: 'space-between' }}>
<Text type="secondary" style={{ fontSize: 12 }}>
账号: {stats?.username || '未同步'}
</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
约可采购 {Math.floor((stats?.upstream_points ?? 0) / 21)} 个
</Text>
</div>
</Card>
</Col>
</Row>
{/* 上游凭据与策略设置 */}
<Card
title={
<Space>
<KeyOutlined />
<span>0666.pro 采购凭据与自动发货控制</span>
</Space>
}
extra={
<Space>
<Button icon={<ReloadOutlined />} onClick={loadData} loading={loadingStats}>
刷新状态
</Button>
<Button
type="primary"
icon={<PlusCircleOutlined />}
onClick={() => handlePurchase(50)}
loading={purchasing}
>
一键采购 50 个通用 Token
</Button>
</Space>
}
style={{ marginBottom: 24, borderRadius: 8 }}
>
<Form form={form} layout="vertical">
<Row gutter={24}>
<Col xs={24} md={12}>
<Form.Item
label={
<Space>
<Text strong>自动发货总开关 (Kill Switch)</Text>
<Tooltip title="关闭后黑色 ROCK 与蓝色运动服两款商品停走自动发货通道,平滑转人工打手接单">
<ExclamationCircleOutlined />
</Tooltip>
</Space>
}
>
<Space size="middle">
<Switch
checked={autoDelivery}
onChange={handleToggleAutoDelivery}
checkedChildren="自动发货中"
unCheckedChildren="已熔断转人工"
/>
<Text type={autoDelivery ? 'success' : 'danger'}>
{autoDelivery ? '通道正常,全自动反代履约中' : '已熔断!商品已下线自动通道转交人工处理'}
</Text>
</Space>
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="base_url" label="上游平台接口地址 (Base URL)">
<Input placeholder="https://0666.pro" />
</Form.Item>
</Col>
<Col xs={24}>
<Form.Item
name="bearer_token"
label={
<Space>
<Text strong>上游商户 Bearer Token (yd_token)</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
(平台无密码,直接输入从 0666.pro 网页复制的 64 位 Token)
</Text>
</Space>
}
extra={
<div style={{ marginTop: 4 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
💡 极简提取指引:登录 0666.pro 网页后,按 F12 打开控制台,在 Console 执行{' '}
<code>localStorage.getItem('yd_token')</code> 即可一键复制。
</Text>
</div>
}
>
<Input.Password
placeholder="粘贴 64 位十六进制 Bearer Token"
addonAfter={
<Button type="link" size="small" onClick={handleTestConnection} loading={testingConn}>
测试连通性
</Button>
}
/>
</Form.Item>
</Col>
<Col xs={24} sm={12} md={6}>
<Form.Item name="token_warning_stock" label="库存告警安全水位">
<InputNumber min={1} max={500} style={{ width: '100%' }} addonAfter="个" />
</Form.Item>
</Col>
<Col xs={24} sm={12} md={6}>
<Form.Item name="auto_purchase_enabled" label="自动补货触发开关" valuePropName="checked">
<Switch checkedChildren="开启自动采购" unCheckedChildren="关闭" />
</Form.Item>
</Col>
<Col xs={24} sm={12} md={6}>
<Form.Item name="auto_purchase_count" label="单次自动补货数量">
<InputNumber min={5} max={200} style={{ width: '100%' }} addonAfter="个" />
</Form.Item>
</Col>
<Col xs={24} sm={12} md={6}>
<Form.Item name="purchase_cooldown_minutes" label="补货防抖冷却时间">
<InputNumber min={1} max={60} style={{ width: '100%' }} addonAfter="分钟" />
</Form.Item>
</Col>
</Row>
<Divider style={{ margin: '12px 0 20px 0' }} />
<Space>
<Button type="primary" onClick={handleSaveConfig} loading={savingConfig}>
保存策略配置
</Button>
</Space>
</Form>
</Card>
{/* 本地通用库存池明细 */}
<Card
title={
<Space>
<ThunderboltOutlined />
<span>通用 Token 库存池与错单审计列表</span>
</Space>
}
extra={
<Space>
<Radio.Group value={filterStatus} onChange={(e) => setFilterStatus(e.target.value)} buttonStyle="solid">
<Radio.Button value="all">全部</Radio.Button>
<Radio.Button value="available">可用</Radio.Button>
<Radio.Button value="using">使用中</Radio.Button>
<Radio.Button value="bound">已绑定</Radio.Button>
<Radio.Button value="redeemed">已发货</Radio.Button>
<Radio.Button value="mismatch">
<Badge count={stats?.mismatch_stock ?? 0} offset={[8, -2]}>
<span>绑定错误</span>
</Badge>
</Radio.Button>
</Radio.Group>
<Input
placeholder="搜索订单号 / UID / 角色名"
allowClear
prefix={<SearchOutlined />}
style={{ width: 220 }}
onPressEnter={(e) => setSearchKeyword(e.currentTarget.value)}
onChange={(e) => {
if (!e.target.value) setSearchKeyword('')
}}
/>
<Button icon={<ReloadOutlined />} onClick={loadTokens} loading={loadingTokens}>
刷新
</Button>
</Space>
}
style={{ borderRadius: 8 }}
>
<Table
rowKey="id"
columns={columns}
dataSource={tokens}
loading={loadingTokens}
pagination={{
current: page,
pageSize,
total: totalTokens,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p)
setPageSize(ps)
},
}}
/>
</Card>
</div>
)
}