import { useCallback, useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import { Alert, Card, Descriptions, Table, Tag, Typography } from 'antd'
import { BookOutlined } from '@ant-design/icons'
import EndpointDoc, { CommonResponseDoc } from '../openapi/EndpointDoc'
import {
commonResponseFields,
endpoints,
errorCodes,
orderStatusTable,
} from '../openapi/endpoints'
import { endpointSections, type EndpointSpec } from '../openapi/types'
const { Paragraph, Text } = Typography
const baseUrl =
typeof window !== 'undefined'
? window.location.origin.replace(':5173', ':8080')
: 'http://localhost:8080'
const deliveryModes = [
{
mode: '发货链接模式',
scene: '实现最简:下单后拿一个签名链接,直接跳转平台发货页,由买家在页面上绑定账号并提交',
interfaces: '/api/client/v1/orders/{order_no}/delivery-link',
steps: '下单 → 获取发货链接 → 链接给买家 → 平台页完成发货',
recommend: '商户无自建发货页、想最快上线时使用',
},
{
mode: '自建发货页模式',
scene: '商户自己有前端页面,把发货流程嵌在自己的系统里(可预填下单时透传的账号/区服)',
interfaces: '/api/client/v1/orders/{order_no}/delivery\n/api/client/v1/orders/{order_no}/delivery/bind\n/api/client/v1/orders/{order_no}/delivery/submit',
steps: '下单 → 查询发货数据 → 发起绑定 → 提交发货',
recommend: '商户需要自定义发货交互(展示区服、预填账号)时使用',
},
]
// ---------- TOC 目录构建 ----------
interface TocLeaf {
id: string
label: string
level: 3 // 接口子小节(接口地址/请求方式/...)
}
interface TocItem {
id: string
label: string
children?: TocLeaf[]
}
interface TocGroup {
title: string
items: TocItem[]
}
function buildToc(): TocGroup[] {
const tocItems = (eps: typeof endpoints) =>
eps.map((ep) => ({
id: `ep-${ep.key}`,
label: ep.title,
children: endpointSections.map((s) => ({
id: `ep-${ep.key}-${s.id}`,
label: s.label,
level: 3 as const,
})),
}))
return [
{
title: '开始',
items: [
{ id: 'overview', label: '概述' },
{ id: 'auth', label: '鉴权与签名' },
],
},
{
title: '基础接口',
items: tocItems(endpoints.filter((ep) => ep.group === 'basic')),
},
{
title: '发货链接模式',
items: tocItems(endpoints.filter((ep) => ep.group === 'delivery-link')),
},
{
title: '自建发货页模式',
items: tocItems(endpoints.filter((ep) => ep.group === 'delivery-self')),
},
{
title: '回调',
items: [
{ id: 'callback', label: '回调通知' },
{ id: 'callback-sign', label: '签名校验' },
{ id: 'callback-retry', label: '推送与重试' },
],
},
{
title: '附录',
items: [{ id: 'status', label: '状态说明' }],
},
]
}
const toc = buildToc()
const pageItems = toc.flatMap((group) => group.items)
const pageIds = new Set(pageItems.map((item) => item.id))
function findPageId(id: string): string {
if (pageIds.has(id)) return id
for (const item of pageItems) {
if (item.children?.some((child) => child.id === id)) return item.id
}
return 'overview'
}
function getInitialHash() {
if (typeof window === 'undefined') return 'overview'
return decodeURIComponent(window.location.hash.replace(/^#/, '')) || 'overview'
}
// ---------- 锚点跳转 ----------
function scrollToId(id: string) {
const el = document.getElementById(id)
if (!el) return
const root = document.getElementById('docScroll')
if (!root) return
const top = root.scrollTop + el.getBoundingClientRect().top - root.getBoundingClientRect().top - 22
root.scrollTo({ top, behavior: 'smooth' })
}
export default function OpenApiDocs() {
const initialHash = useMemo(() => getInitialHash(), [])
const [activePageId, setActivePageId] = useState(() => findPageId(initialHash))
const [activeAnchorId, setActiveAnchorId] = useState(initialHash)
const activeEndpoint = useMemo(
() => endpoints.find((spec) => `ep-${spec.key}` === activePageId),
[activePageId],
)
useEffect(() => {
const syncHash = () => {
const nextHash = getInitialHash()
setActivePageId(findPageId(nextHash))
setActiveAnchorId(nextHash)
}
window.addEventListener('hashchange', syncHash)
return () => window.removeEventListener('hashchange', syncHash)
}, [])
useEffect(() => {
const root = document.getElementById('docScroll')
if (!root) return
if (activeAnchorId !== activePageId) {
requestAnimationFrame(() => scrollToId(activeAnchorId))
return
}
root.scrollTo({ top: 0 })
}, [activeAnchorId, activePageId])
const activeIndex = useMemo(
() => pageItems.findIndex((item) => item.id === activePageId),
[activePageId],
)
const handleNav = useCallback((id: string) => {
const pageId = findPageId(id)
setActivePageId(pageId)
setActiveAnchorId(id)
if (typeof window !== 'undefined') {
window.history.replaceState(null, '', `${window.location.pathname}${window.location.search}#${id}`)
}
}, [])
const handleStep = useCallback(
(direction: -1 | 1) => {
const next = pageItems[activeIndex + direction]
if (next) handleNav(next.id)
},
[activeIndex, handleNav],
)
return (
{/* 左侧目录 */}
文档章节目录
{toc.map((group) => (
{group.title}
{group.items.map((item) => {
const isActive = activePageId === item.id
return (
)
})}
))}
{/* 右侧文档 */}
)
}
function PageStepper({
currentIndex,
items,
onStep,
}: {
currentIndex: number
items: TocItem[]
onStep: (direction: -1 | 1) => void
}) {
const prev = items[currentIndex - 1]
const next = items[currentIndex + 1]
return (
onStep(-1)}
>
上一篇
{prev?.label ?? '无'}
onStep(1)}
>
下一篇
{next?.label ?? '无'}
)
}
// ---------- 各文档小节 ----------
function SectionWrap({
id,
title,
desc,
children,
}: {
id: string
title: string
desc?: ReactNode
children?: ReactNode
}) {
return (
{title}
{desc ?
{desc} : null}
{children}
)
}
function OverviewSection() {
return (
面向商户系统调用的开放 API。凭证在「开放 API / API 密钥」创建,
采用 X-App-Key + HMAC-SHA256 签名鉴权。
发货链路同时支持「返回签名链接」和「返回结构化数据」两种模式。
>
}
>
在「开放 API / API 密钥」创建凭证,获得 AppKey 与 AppSecret 。
每次请求携带 X-App-Key / X-Timestamp / X-Nonce / X-Sign 四个鉴权头。
调用「商品列表」拿到可售 sku ,调用「下单」创建订单并扣款。
拿到 order_no 后,按「发货对接方式」二选一:发货链接模式(返回链接即可)或自建发货页模式(结构化接口)。
在「回调」页配置回调地址并订阅事件,订单状态变化会实时推送,见「回调通知」。
}
/>
{v} ,
},
{
title: '适用场景',
dataIndex: 'scene',
width: 280,
render: (v: string) => {v} ,
},
{
title: '接口顺序',
dataIndex: 'steps',
width: 240,
render: (v: string) => {v} ,
},
{
title: '相关接口',
dataIndex: 'interfaces',
width: 260,
render: (v: string) => {v} ,
},
{
title: '建议',
dataIndex: 'recommend',
width: 180,
render: (v: string) => {v} ,
},
]}
/>
{baseUrl}
X-App-Key 、X-Timestamp 、X-Nonce 、X-Sign
允许 ±300 秒
所有时间字段统一返回 RFC3339 秒级北京时间,例如 2026-07-30T17:53:58+08:00
POST 请求使用 application/json
)
}
function AuthSection() {
return (
{v} },
{
title: '必填',
dataIndex: 'required',
width: 80,
render: (v) => v ? 是 : 否 ,
},
{ title: '说明', dataIndex: 'desc' },
]}
/>
app_key 、body_sha256 、method 、nonce 、path 、timestamp
参数按 ASCII 字典序,用 & 拼成 k1=v1&k2=v2&...
app_key, body_sha256, method, nonce, path, timestamp
大写,如 GET / POST
仅 URL.Path,不含域名和 query
SHA256(原始 body 字节) 的十六进制小写;GET 用空 body 的摘要
原样拼接,不做 URL encode
hex( HMAC-SHA256( app_secret, 签名串 ) ) ,小写十六进制
{`app_key=ak_xxx&body_sha256=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855&method=GET&nonce=a1b2c3d4e5f67890&path=/api/client/v1/products×tamp=1721450000`}
{`# body = {"client_order_no":"shop-10001","sku":"suit_pink_sheep"}
app_key=ak_xxx&body_sha256=<实际请求 body 字节的 SHA256 十六进制>&method=POST&nonce=a1b2c3d4e5f67890&path=/api/client/v1/orders×tamp=1721450000`}
POST 签名用的 body 必须与实际发送的 body 字节级一致 ,不要签名后再改空格或字段顺序。
value 不要 URL encode,原样参与拼接。
签名失败常见原因:secret 错、path 多了 query、body 与签名不一致、时间戳过期、nonce 重复、参数未按字典序拼接。
下单幂等直接使用 client_order_no ,无需额外 Header。
}
/>
)
}
function EndpointSection({ spec }: { spec: EndpointSpec }) {
return (
{spec.title}
{spec.method}
{spec.scope}
)
}
// ---------- 回调通知文档 ----------
const callbackEvents = [
{
event: 'order.created',
desc: '订单创建成功(已扣款,order_status=paid)',
trigger: '创建订单',
},
{
event: 'order.shipping.updated',
desc: '发货状态变化:提交发货(delivering)、发货成功(delivered)、发货失败(ship_failed)',
trigger: '提交发货 / 发货结果回传',
},
{
event: 'order.cancelled',
desc: '订单取消并退款(order_status=cancelled)',
trigger: '取消订单',
},
]
const callbackPayloadExample = `{
"event_id": "4751626d-d608-42c1-a453-60ffcafc01fc",
"event": "order.shipping.updated",
"occurred_at": "2026-08-03T10:11:20+08:00",
"data": {
"order_no": "FO20260803000123",
"client_order_no": "shop-10001",
"order_status": "delivered",
"can_ship": false,
"product_sku": "suit_pink_sheep",
"quantity": 1,
"amount": 105,
"currency": "POINT",
"provider_order_no": "PROV-xxxx",
"failure_reason": "",
"created_at": "2026-08-03T10:00:00+08:00",
"delivered_at": "2026-08-03T10:11:20+08:00",
"data": { "game_account": "4808146277", "game_channel": "ios-wechat", "role_name": "玩家名" }
}
}`
const retrySchedule = [
{ attempt: 2, after: '15 秒' },
{ attempt: 3, after: '15 秒' },
{ attempt: 4, after: '30 秒' },
{ attempt: 5, after: '3 分钟' },
{ attempt: 6, after: '10 分钟' },
{ attempt: 7, after: '20 分钟' },
{ attempt: 8, after: '30 分钟' },
{ attempt: 9, after: '30 分钟' },
{ attempt: 10, after: '30 分钟' },
{ attempt: 11, after: '1 小时' },
{ attempt: 12, after: '3 小时' },
{ attempt: 13, after: '3 小时' },
{ attempt: 14, after: '3 小时' },
{ attempt: 15, after: '6 小时' },
{ attempt: 16, after: '6 小时' },
]
function CallbackSection() {
return (
平台通过 POST 方式把订单事件推送到商户配置的回调地址。
商户在后台「回调」页配置一个 URL 并订阅事件,配置保存在 outbox 表中,
订单事务提交后才写入待推送记录,进程重启不会丢失。
>
}
>
在商户后台「回调」页填写回调 URL 并订阅事件,保存后获得 CallbackSecret (仅展示一次,可在后台重置)。
接收平台推送:请求头带 X-Event-ID / X-Timestamp / X-Sign ,按「签名校验」验证后处理。
返回 HTTP 2xx 即视为投递成功;非 2xx 或超时按「推送与重试」策略重试。
}
/>
{v} },
{ title: '触发时机', dataIndex: 'trigger', width: 220 },
{ title: '说明', dataIndex: 'desc' },
]}
/>
POST https://你的服务器/callback
application/json
{`POST /callback HTTP/1.1
Content-Type: application/json
X-Event-ID: 4751626d-d608-42c1-a453-60ffcafc01fc
X-Timestamp: 1785723081
X-Sign: 191892922db64bf41159f58e...`}
{callbackPayloadExample}
返回 2xx (任意状态码)即视为投递成功;返回其他状态码或超时视为失败并进入重试。
同一事件的 X-Event-ID 全局唯一,请按它做幂等处理,避免重复入账。
事件里的 occurred_at 是事件发生时间,与请求头 X-Timestamp 不同。
)
}
function CallbackSignSection() {
return (
{v} },
{ title: '说明', dataIndex: 'desc' },
]}
/>
CallbackSecret (保存回调配置时展示一次,可在后台「重置密钥」)
sha256hex(body) —— 原始请求体字节的 SHA256 十六进制(小写)
body_sha256=<hash>×tamp=<unix秒> ,按 ASCII 字典序 & 拼接(与开放 API 鉴权同一风格)
hex( HMAC-SHA256( secret, 签名串 ) ) ,小写十六进制
{`bodyHash = sha256Hex(rawBody)
content = "body_sha256=" + bodyHash + "×tamp=" + timestamp
expected = hmacSHA256Hex(secret, content)
ok = (X-Sign == expected)`}
校验用 原始 body 字节 ,不要重新序列化后再算摘要。
建议校验 X-Timestamp 与当前时间偏差(如 ±5 分钟)后验签。
若在后台重置密钥,旧密钥立即失效,新密钥签名推送。
}
/>
)
}
function CallbackRetrySection() {
return (
回调基于数据库 outbox 持久化推送(每 3 秒轮询一批),失败后按固定间隔退避重试,
参考微信/支付宝通知机制。重试次数与间隔可通过环境变量调整:
CALLBACK_MAX_ATTEMPTS (默认 16)、
CALLBACK_RETRY_SCHEDULE (逗号分隔秒数)。
>
}
>
{v} },
{ title: '距上次失败间隔', dataIndex: 'after' },
]}
/>
共尝试 16 次(首次 + 15 次重试),全部失败后标记 failed ,不再推送。
单次推送超时 15 秒 (可配 CALLBACK_PUSH_TIMEOUT_SECONDS )按失败处理。
接收方返回任意 2xx 即停止重试。
未配置回调地址或订阅未匹配事件时不会产生推送。
}
/>
)
}
function StatusSection() { return (
{v} },
{
title: 'can_ship',
dataIndex: 'example',
width: 160,
render: (v: string) =>
v === 'can_ship=true' ? true : false ,
},
{ title: '说明', dataIndex: 'desc' },
]}
/>
{`拿到 sku(商品列表)
↓
POST /orders 下单(client_order_no 幂等)
↓
拿响应里的 order_no 进入发货链接
↓
买家在发货页绑定账号并提交发货
↓
平台完成发货处理并更新 order_status
↓
GET /orders/{order_no} 轮询,或接收 order.shipping.updated 回调`}
}
/>
)
}