764 lines
28 KiB
TypeScript
764 lines
28 KiB
TypeScript
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 (
|
||
<div className="api-docs">
|
||
{/* 左侧目录 */}
|
||
<aside className="api-docs__toc">
|
||
<div className="api-docs__toc-header">
|
||
<BookOutlined style={{ color: '#2563eb', fontSize: 16 }} />
|
||
<span>文档章节目录</span>
|
||
</div>
|
||
<nav className="api-docs__nav">
|
||
{toc.map((group) => (
|
||
<div className="api-docs__nav-group" key={group.title}>
|
||
<div className="api-docs__nav-heading">{group.title}</div>
|
||
{group.items.map((item) => {
|
||
const isActive = activePageId === item.id
|
||
return (
|
||
<div className="api-docs__nav-row" key={item.id}>
|
||
<a
|
||
className={`api-docs__nav-item${isActive ? ' api-docs__nav-item--active' : ''}`}
|
||
onClick={() => handleNav(item.id)}
|
||
>
|
||
{item.label}
|
||
</a>
|
||
{item.children?.length && isActive ? (
|
||
<div className="api-docs__nav-sub">
|
||
{item.children.map((c) => (
|
||
<a
|
||
key={c.id}
|
||
className={`api-docs__nav-sub-item${activeAnchorId === c.id ? ' api-docs__nav-sub-item--active' : ''}`}
|
||
onClick={() => handleNav(c.id)}
|
||
>
|
||
{c.label}
|
||
</a>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
))}
|
||
</nav>
|
||
</aside>
|
||
|
||
{/* 右侧文档 */}
|
||
<div id="docScroll" className="api-docs__content">
|
||
<div className="api-docs__inner">
|
||
{activePageId === 'overview' ? <OverviewSection /> : null}
|
||
{activePageId === 'auth' ? <AuthSection /> : null}
|
||
{activePageId === 'callback' ? <CallbackSection /> : null}
|
||
{activePageId === 'callback-sign' ? <CallbackSignSection /> : null}
|
||
{activePageId === 'callback-retry' ? <CallbackRetrySection /> : null}
|
||
{activeEndpoint ? <EndpointSection spec={activeEndpoint} /> : null}
|
||
{activePageId === 'status' ? <StatusSection /> : null}
|
||
<PageStepper
|
||
currentIndex={activeIndex}
|
||
items={pageItems}
|
||
onStep={handleStep}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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 (
|
||
<div className="api-docs__pager">
|
||
<button
|
||
className="api-docs__pager-btn"
|
||
type="button"
|
||
disabled={!prev}
|
||
onClick={() => onStep(-1)}
|
||
>
|
||
<span>上一篇</span>
|
||
<strong>{prev?.label ?? '无'}</strong>
|
||
</button>
|
||
<button
|
||
className="api-docs__pager-btn api-docs__pager-btn--next"
|
||
type="button"
|
||
disabled={!next}
|
||
onClick={() => onStep(1)}
|
||
>
|
||
<span>下一篇</span>
|
||
<strong>{next?.label ?? '无'}</strong>
|
||
</button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ---------- 各文档小节 ----------
|
||
function SectionWrap({
|
||
id,
|
||
title,
|
||
desc,
|
||
children,
|
||
}: {
|
||
id: string
|
||
title: string
|
||
desc?: ReactNode
|
||
children?: ReactNode
|
||
}) {
|
||
return (
|
||
<div className="api-docs__section">
|
||
<h2 id={id} className="api-docs__h2">
|
||
{title}
|
||
</h2>
|
||
{desc ? <Paragraph className="api-docs__summary" type="secondary">{desc}</Paragraph> : null}
|
||
{children}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function OverviewSection() {
|
||
return (
|
||
<SectionWrap
|
||
id="overview"
|
||
title="概述"
|
||
desc={
|
||
<>
|
||
面向商户系统调用的开放 API。凭证在「开放 API / API 密钥」创建,
|
||
采用 <Text code>X-App-Key</Text> + HMAC-SHA256 签名鉴权。
|
||
发货链路同时支持「返回签名链接」和「返回结构化数据」两种模式。
|
||
</>
|
||
}
|
||
>
|
||
<Alert
|
||
className="api-docs__alert"
|
||
type="info"
|
||
showIcon
|
||
message="一句话上手"
|
||
description={
|
||
<ol style={{ margin: 0, paddingLeft: 20 }}>
|
||
<li>在「开放 API / API 密钥」创建凭证,获得 <Text code>AppKey</Text> 与 <Text code>AppSecret</Text>。</li>
|
||
<li>每次请求携带 <Text code>X-App-Key / X-Timestamp / X-Nonce / X-Sign</Text> 四个鉴权头。</li>
|
||
<li>调用「商品列表」拿到可售 <Text code>sku</Text>,调用「下单」创建订单并扣款。</li>
|
||
<li>拿到 <Text code>order_no</Text> 后,按「发货对接方式」二选一:发货链接模式(返回链接即可)或自建发货页模式(结构化接口)。</li>
|
||
<li>在「回调」页配置回调地址并订阅事件,订单状态变化会实时推送,见「回调通知」。</li>
|
||
</ol>
|
||
}
|
||
/>
|
||
<Card className="api-docs__card" size="small" title="发货对接方式(二选一,先选好再看对应接口)">
|
||
<Table
|
||
className="api-docs__table"
|
||
size="small"
|
||
pagination={false}
|
||
rowKey="mode"
|
||
dataSource={deliveryModes}
|
||
scroll={{ x: 1080 }}
|
||
columns={[
|
||
{
|
||
title: '方式',
|
||
dataIndex: 'mode',
|
||
width: 140,
|
||
render: (v: string) => <span style={{ fontWeight: 700, whiteSpace: 'nowrap', color: '#0f172a' }}>{v}</span>,
|
||
},
|
||
{
|
||
title: '适用场景',
|
||
dataIndex: 'scene',
|
||
width: 280,
|
||
render: (v: string) => <span style={{ fontSize: 13, lineHeight: 1.6, display: 'block', color: '#334155' }}>{v}</span>,
|
||
},
|
||
{
|
||
title: '接口顺序',
|
||
dataIndex: 'steps',
|
||
width: 240,
|
||
render: (v: string) => <pre className="api-docs__pre" style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>{v}</pre>,
|
||
},
|
||
{
|
||
title: '相关接口',
|
||
dataIndex: 'interfaces',
|
||
width: 260,
|
||
render: (v: string) => <pre className="api-docs__pre" style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>{v}</pre>,
|
||
},
|
||
{
|
||
title: '建议',
|
||
dataIndex: 'recommend',
|
||
width: 180,
|
||
render: (v: string) => <span style={{ fontSize: 13, lineHeight: 1.5, display: 'block', color: '#475569' }}>{v}</span>,
|
||
},
|
||
]}
|
||
/>
|
||
</Card>
|
||
<Card className="api-docs__card" size="small" title="环境信息">
|
||
<Descriptions size="small" column={1} bordered>
|
||
<Descriptions.Item label="Base URL">
|
||
<Text code>{baseUrl}</Text>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="鉴权头">
|
||
<Text code>X-App-Key</Text>、<Text code>X-Timestamp</Text>、<Text code>X-Nonce</Text>、<Text code>X-Sign</Text>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="时间偏差">允许 ±300 秒</Descriptions.Item>
|
||
<Descriptions.Item label="时间格式">
|
||
所有时间字段统一返回 RFC3339 秒级北京时间,例如 <Text code>2026-07-30T17:53:58+08:00</Text>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="请求体">POST 请求使用 <Text code>application/json</Text></Descriptions.Item>
|
||
</Descriptions>
|
||
</Card>
|
||
<div className="api-docs__common">
|
||
<CommonResponseDoc fields={commonResponseFields} errors={errorCodes} />
|
||
</div>
|
||
</SectionWrap>
|
||
)
|
||
}
|
||
|
||
function AuthSection() {
|
||
return (
|
||
<SectionWrap id="auth" title="鉴权与签名">
|
||
<Card className="api-docs__card" size="small" title="鉴权头(每次请求必带)">
|
||
<Table
|
||
className="api-docs__table"
|
||
size="small"
|
||
pagination={false}
|
||
rowKey="header"
|
||
dataSource={[
|
||
{ header: 'X-App-Key', required: true, desc: '商户分配的 AppKey' },
|
||
{ header: 'X-Timestamp', required: true, desc: '当前 Unix 秒时间戳' },
|
||
{ header: 'X-Nonce', required: true, desc: '随机串,8~96 字符,同一客户端有效期内不可重复' },
|
||
{ header: 'X-Sign', required: true, desc: 'HMAC-SHA256 签名,见下方算法' },
|
||
]}
|
||
columns={[
|
||
{ title: 'Header', dataIndex: 'header', width: 160, render: (v) => <Text code>{v}</Text> },
|
||
{
|
||
title: '必填',
|
||
dataIndex: 'required',
|
||
width: 80,
|
||
render: (v) => v ? <span className="api-docs__required">是</span> : <Text type="secondary">否</Text>,
|
||
},
|
||
{ title: '说明', dataIndex: 'desc' },
|
||
]}
|
||
/>
|
||
</Card>
|
||
|
||
<Card className="api-docs__card" size="small" title="签名算法">
|
||
<Descriptions size="small" column={1} bordered>
|
||
<Descriptions.Item label="参与参数">
|
||
<Text code>app_key</Text>、<Text code>body_sha256</Text>、<Text code>method</Text>、<Text code>nonce</Text>、<Text code>path</Text>、<Text code>timestamp</Text>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="拼接方式">参数按 ASCII 字典序,用 <Text code>&</Text> 拼成 <Text code>k1=v1&k2=v2&...</Text></Descriptions.Item>
|
||
<Descriptions.Item label="排序后顺序">
|
||
<Text code>app_key, body_sha256, method, nonce, path, timestamp</Text>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="method">大写,如 GET / POST</Descriptions.Item>
|
||
<Descriptions.Item label="path">仅 URL.Path,不含域名和 query</Descriptions.Item>
|
||
<Descriptions.Item label="body_sha256">
|
||
<Text code>SHA256(原始 body 字节)</Text> 的十六进制小写;GET 用空 body 的摘要
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="value">原样拼接,不做 URL encode</Descriptions.Item>
|
||
<Descriptions.Item label="X-Sign">
|
||
<Text code>hex( HMAC-SHA256( app_secret, 签名串 ) )</Text>,小写十六进制
|
||
</Descriptions.Item>
|
||
</Descriptions>
|
||
</Card>
|
||
|
||
<Card className="api-docs__card" size="small" title="签名示例(GET,body 为空)">
|
||
<pre className="api-docs__pre">{`app_key=ak_xxx&body_sha256=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855&method=GET&nonce=a1b2c3d4e5f67890&path=/api/client/v1/products×tamp=1721450000`}</pre>
|
||
</Card>
|
||
|
||
<Card className="api-docs__card" size="small" title="签名示例(POST,body 非空)">
|
||
<pre className="api-docs__pre">{`# 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`}</pre>
|
||
</Card>
|
||
|
||
<Alert
|
||
className="api-docs__alert"
|
||
type="warning"
|
||
showIcon
|
||
message="签名注意事项"
|
||
style={{ marginTop: 16 }}
|
||
description={
|
||
<ul style={{ margin: 0, paddingLeft: 20 }}>
|
||
<li>POST 签名用的 <Text code>body</Text> 必须与实际发送的 body <b>字节级一致</b>,不要签名后再改空格或字段顺序。</li>
|
||
<li>value <b>不要</b> URL encode,原样参与拼接。</li>
|
||
<li>签名失败常见原因:secret 错、path 多了 query、body 与签名不一致、时间戳过期、nonce 重复、参数未按字典序拼接。</li>
|
||
<li>下单幂等直接使用 <Text code>client_order_no</Text>,无需额外 Header。</li>
|
||
</ul>
|
||
}
|
||
/>
|
||
</SectionWrap>
|
||
)
|
||
}
|
||
|
||
function EndpointSection({ spec }: { spec: EndpointSpec }) {
|
||
return (
|
||
<div className="api-docs__section">
|
||
<div className="api-docs__endpoint-head">
|
||
<h2 id={`ep-${spec.key}`} className="api-docs__h2">
|
||
{spec.title}
|
||
</h2>
|
||
<div className="api-docs__endpoint-meta">
|
||
<Tag color={spec.method === 'GET' ? 'blue' : 'geekblue'}>{spec.method}</Tag>
|
||
<Text code>{spec.scope}</Text>
|
||
</div>
|
||
</div>
|
||
<EndpointDoc spec={spec} />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ---------- 回调通知文档 ----------
|
||
|
||
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 (
|
||
<SectionWrap
|
||
id="callback"
|
||
title="回调通知"
|
||
desc={
|
||
<>
|
||
平台通过 <Text code>POST</Text> 方式把订单事件推送到商户配置的回调地址。
|
||
商户在后台「回调」页配置一个 URL 并订阅事件,配置保存在 outbox 表中,
|
||
订单事务提交后才写入待推送记录,进程重启不会丢失。
|
||
</>
|
||
}
|
||
>
|
||
<Alert
|
||
className="api-docs__alert"
|
||
type="info"
|
||
showIcon
|
||
message="快速接入"
|
||
description={
|
||
<ol style={{ margin: 0, paddingLeft: 20 }}>
|
||
<li>在商户后台「回调」页填写回调 URL 并订阅事件,保存后获得 <Text code>CallbackSecret</Text>(仅展示一次,可在后台重置)。</li>
|
||
<li>接收平台推送:请求头带 <Text code>X-Event-ID / X-Timestamp / X-Sign</Text>,按「签名校验」验证后处理。</li>
|
||
<li>返回 HTTP 2xx 即视为投递成功;非 2xx 或超时按「推送与重试」策略重试。</li>
|
||
</ol>
|
||
}
|
||
/>
|
||
<Card className="api-docs__card" size="small" title="回调事件">
|
||
<Table
|
||
className="api-docs__table"
|
||
size="small"
|
||
pagination={false}
|
||
rowKey="event"
|
||
dataSource={callbackEvents}
|
||
columns={[
|
||
{ title: '事件', dataIndex: 'event', width: 220, render: (v) => <Text code>{v}</Text> },
|
||
{ title: '触发时机', dataIndex: 'trigger', width: 220 },
|
||
{ title: '说明', dataIndex: 'desc' },
|
||
]}
|
||
/>
|
||
</Card>
|
||
<Card className="api-docs__card" size="small" title="推送请求示例">
|
||
<Descriptions size="small" column={1} bordered style={{ marginBottom: 12 }}>
|
||
<Descriptions.Item label="URL">
|
||
<Text code>POST https://你的服务器/callback</Text>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="Content-Type">
|
||
<Text code>application/json</Text>
|
||
</Descriptions.Item>
|
||
</Descriptions>
|
||
<pre className="api-docs__pre">{`POST /callback HTTP/1.1
|
||
Content-Type: application/json
|
||
X-Event-ID: 4751626d-d608-42c1-a453-60ffcafc01fc
|
||
X-Timestamp: 1785723081
|
||
X-Sign: 191892922db64bf41159f58e...`}</pre>
|
||
<pre className="api-docs__pre">{callbackPayloadExample}</pre>
|
||
</Card>
|
||
<Card className="api-docs__card" size="small" title="接收方要求">
|
||
<ul style={{ margin: 0, paddingLeft: 20 }}>
|
||
<li>返回 <Text code>2xx</Text>(任意状态码)即视为投递成功;返回其他状态码或超时视为失败并进入重试。</li>
|
||
<li>同一事件的 <Text code>X-Event-ID</Text> 全局唯一,请按它做幂等处理,避免重复入账。</li>
|
||
<li>事件里的 <Text code>occurred_at</Text> 是事件发生时间,与请求头 <Text code>X-Timestamp</Text> 不同。</li>
|
||
</ul>
|
||
</Card>
|
||
</SectionWrap>
|
||
)
|
||
}
|
||
|
||
function CallbackSignSection() {
|
||
return (
|
||
<SectionWrap id="callback-sign" title="签名校验">
|
||
<Card className="api-docs__card" size="small" title="请求头">
|
||
<Table
|
||
className="api-docs__table"
|
||
size="small"
|
||
pagination={false}
|
||
rowKey="header"
|
||
dataSource={[
|
||
{ header: 'X-Event-ID', desc: '事件唯一 ID,接收方用于幂等' },
|
||
{ header: 'X-Timestamp', desc: '推送时的 Unix 秒时间戳' },
|
||
{ header: 'X-Sign', desc: 'HMAC-SHA256 签名,见下方算法' },
|
||
]}
|
||
columns={[
|
||
{ title: 'Header', dataIndex: 'header', width: 160, render: (v) => <Text code>{v}</Text> },
|
||
{ title: '说明', dataIndex: 'desc' },
|
||
]}
|
||
/>
|
||
</Card>
|
||
<Card className="api-docs__card" size="small" title="签名算法">
|
||
<Descriptions size="small" column={1} bordered>
|
||
<Descriptions.Item label="密钥">
|
||
<Text code>CallbackSecret</Text>(保存回调配置时展示一次,可在后台「重置密钥」)
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="body_hash">
|
||
<Text code>sha256hex(body)</Text> —— 原始请求体字节的 SHA256 十六进制(小写)
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="签名串">
|
||
<Text code>body_sha256=<hash>&timestamp=<unix秒></Text>,按 ASCII 字典序 <Text code>&</Text> 拼接(与开放 API 鉴权同一风格)
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="X-Sign">
|
||
<Text code>hex( HMAC-SHA256( secret, 签名串 ) )</Text>,小写十六进制
|
||
</Descriptions.Item>
|
||
</Descriptions>
|
||
</Card>
|
||
<Card className="api-docs__card" size="small" title="校验伪代码">
|
||
<pre className="api-docs__pre">{`bodyHash = sha256Hex(rawBody)
|
||
content = "body_sha256=" + bodyHash + "×tamp=" + timestamp
|
||
expected = hmacSHA256Hex(secret, content)
|
||
ok = (X-Sign == expected)`}</pre>
|
||
</Card>
|
||
<Alert
|
||
className="api-docs__alert"
|
||
type="warning"
|
||
showIcon
|
||
message="注意"
|
||
description={
|
||
<ul style={{ margin: 0, paddingLeft: 20 }}>
|
||
<li>校验用 <Text code>原始 body 字节</Text>,不要重新序列化后再算摘要。</li>
|
||
<li>建议校验 <Text code>X-Timestamp</Text> 与当前时间偏差(如 ±5 分钟)后验签。</li>
|
||
<li>若在后台重置密钥,旧密钥立即失效,新密钥签名推送。</li>
|
||
</ul>
|
||
}
|
||
/>
|
||
</SectionWrap>
|
||
)
|
||
}
|
||
|
||
function CallbackRetrySection() {
|
||
return (
|
||
<SectionWrap
|
||
id="callback-retry"
|
||
title="推送与重试"
|
||
desc={
|
||
<>
|
||
回调基于数据库 outbox 持久化推送(每 3 秒轮询一批),失败后按固定间隔退避重试,
|
||
参考微信/支付宝通知机制。重试次数与间隔可通过环境变量调整:
|
||
<Text code>CALLBACK_MAX_ATTEMPTS</Text>(默认 16)、
|
||
<Text code>CALLBACK_RETRY_SCHEDULE</Text>(逗号分隔秒数)。
|
||
</>
|
||
}
|
||
>
|
||
<Card className="api-docs__card" size="small" title="重试策略(默认)">
|
||
<Table
|
||
className="api-docs__table"
|
||
size="small"
|
||
pagination={false}
|
||
rowKey="attempt"
|
||
dataSource={retrySchedule}
|
||
columns={[
|
||
{ title: '第 N 次推送', dataIndex: 'attempt', width: 140, render: (v) => <Text code>{v}</Text> },
|
||
{ title: '距上次失败间隔', dataIndex: 'after' },
|
||
]}
|
||
/>
|
||
</Card>
|
||
<Alert
|
||
className="api-docs__alert"
|
||
type="info"
|
||
showIcon
|
||
message="规则"
|
||
description={
|
||
<ul style={{ margin: 0, paddingLeft: 20 }}>
|
||
<li>共尝试 <Text code>16</Text> 次(首次 + 15 次重试),全部失败后标记 <Text code>failed</Text>,不再推送。</li>
|
||
<li>单次推送超时 <Text code>15 秒</Text>(可配 <Text code>CALLBACK_PUSH_TIMEOUT_SECONDS</Text>)按失败处理。</li>
|
||
<li>接收方返回任意 <Text code>2xx</Text> 即停止重试。</li>
|
||
<li>未配置回调地址或订阅未匹配事件时不会产生推送。</li>
|
||
</ul>
|
||
}
|
||
/>
|
||
</SectionWrap>
|
||
)
|
||
}
|
||
|
||
function StatusSection() { return (
|
||
<SectionWrap id="status" title="状态说明">
|
||
<Card className="api-docs__card" size="small" title="订单状态 order_status 与 can_ship">
|
||
<Table
|
||
className="api-docs__table"
|
||
size="small"
|
||
pagination={false}
|
||
rowKey="name"
|
||
dataSource={orderStatusTable}
|
||
columns={[
|
||
{ title: '状态', dataIndex: 'name', width: 140, render: (v) => <Text code>{v}</Text> },
|
||
{
|
||
title: 'can_ship',
|
||
dataIndex: 'example',
|
||
width: 160,
|
||
render: (v: string) =>
|
||
v === 'can_ship=true' ? <Tag color="green">true</Tag> : <Tag>false</Tag>,
|
||
},
|
||
{ title: '说明', dataIndex: 'desc' },
|
||
]}
|
||
/>
|
||
</Card>
|
||
<Alert
|
||
className="api-docs__alert"
|
||
type="info"
|
||
showIcon
|
||
message="推荐调用流程"
|
||
style={{ marginTop: 16 }}
|
||
description={
|
||
<pre className="api-docs__pre api-docs__pre--plain">{`拿到 sku(商品列表)
|
||
↓
|
||
POST /orders 下单(client_order_no 幂等)
|
||
↓
|
||
拿响应里的 order_no 进入发货链接
|
||
↓
|
||
买家在发货页绑定账号并提交发货
|
||
↓
|
||
平台完成发货处理并更新 order_status
|
||
↓
|
||
GET /orders/{order_no} 轮询,或接收 order.shipping.updated 回调`}</pre>
|
||
}
|
||
/>
|
||
</SectionWrap>
|
||
)
|
||
}
|