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 (
{/* 左侧目录 */} {/* 右侧文档 */}
{activePageId === 'overview' ? : null} {activePageId === 'auth' ? : null} {activePageId === 'callback' ? : null} {activePageId === 'callback-sign' ? : null} {activePageId === 'callback-retry' ? : null} {activeEndpoint ? : null} {activePageId === 'status' ? : null}
) } 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 (
) } // ---------- 各文档小节 ---------- 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 密钥」创建凭证,获得 AppKeyAppSecret
  • 每次请求携带 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-KeyX-TimestampX-NonceX-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_keybody_sha256methodnoncepathtimestamp 参数按 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>&timestamp=<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 回调`} } /> ) }