512 lines
18 KiB
TypeScript
512 lines
18 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||
import type { ReactNode } from 'react'
|
||
import { Alert, Card, Descriptions, Table, Tag, Typography } from 'antd'
|
||
import EndpointDoc, { CommonResponseDoc } from '../openapi/EndpointDoc'
|
||
import {
|
||
commonResponseFields,
|
||
endpoints,
|
||
errorCodes,
|
||
orderStatusTable,
|
||
paymentStatusTable,
|
||
} 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: 'Web 发货页',
|
||
scene: '商户想要“只返回一个链接”,让用户直接打开平台页面填写 UID',
|
||
interfaces: '/api/client/v1/orders/{order_no}/delivery-link',
|
||
},
|
||
{
|
||
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',
|
||
},
|
||
]
|
||
|
||
// ---------- 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[] {
|
||
return [
|
||
{
|
||
title: '开始',
|
||
items: [
|
||
{ id: 'overview', label: '概述' },
|
||
{ id: 'auth', label: '鉴权与签名' },
|
||
],
|
||
},
|
||
{
|
||
title: '接口',
|
||
items: endpoints.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,
|
||
})),
|
||
})),
|
||
},
|
||
{
|
||
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-tabs">
|
||
<button
|
||
className={`api-docs__toc-tab${activePageId !== 'auth' ? ' api-docs__toc-tab--active' : ''}`}
|
||
type="button"
|
||
onClick={() => handleNav('overview')}
|
||
>
|
||
开放 API
|
||
</button>
|
||
<button
|
||
className={`api-docs__toc-tab${activePageId === 'auth' ? ' api-docs__toc-tab--active' : ''}`}
|
||
type="button"
|
||
onClick={() => handleNav('auth')}
|
||
>
|
||
API 密钥
|
||
</button>
|
||
</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}
|
||
{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 签名鉴权。
|
||
发货链路同时支持「返回签名链接」和「返回结构化数据」两种模式。
|
||
<Text code>/api/open/v1</Text> 仍是上游回调链路,和本页的商户 API 分开。
|
||
</>
|
||
}
|
||
>
|
||
<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>
|
||
</ol>
|
||
}
|
||
/>
|
||
<Card className="api-docs__card" size="small" title="发货链路概览">
|
||
<Table
|
||
className="api-docs__table"
|
||
size="small"
|
||
pagination={false}
|
||
rowKey="mode"
|
||
dataSource={deliveryModes}
|
||
columns={[
|
||
{ title: '模式', dataIndex: 'mode', width: 140 },
|
||
{ title: '适用场景', dataIndex: 'scene' },
|
||
{
|
||
title: '相关接口',
|
||
dataIndex: 'interfaces',
|
||
render: (v: string) => <pre className="api-docs__pre">{v}</pre>,
|
||
},
|
||
]}
|
||
/>
|
||
</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>Idempotency-Key</Text>,且必须与 <Text code>client_order_no</Text> 一致。</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>
|
||
)
|
||
}
|
||
|
||
function StatusSection() {
|
||
return (
|
||
<SectionWrap id="status" title="状态说明">
|
||
<Card className="api-docs__card" size="small" title="履约状态 fulfillment_status 与 can_fulfill">
|
||
<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_fulfill',
|
||
dataIndex: 'example',
|
||
width: 160,
|
||
render: (v: string) =>
|
||
v === 'can_fulfill=true' ? <Tag color="green">true</Tag> : <Tag>false</Tag>,
|
||
},
|
||
{ title: '说明', dataIndex: 'desc' },
|
||
]}
|
||
/>
|
||
</Card>
|
||
<Card className="api-docs__card" size="small" title="支付状态 payment_status">
|
||
<Table
|
||
className="api-docs__table"
|
||
size="small"
|
||
pagination={false}
|
||
rowKey="name"
|
||
dataSource={paymentStatusTable}
|
||
columns={[
|
||
{ title: '状态', dataIndex: 'name', width: 140, render: (v) => <Text code>{v}</Text> },
|
||
{ 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 下单(幂等,Idempotency-Key = client_order_no)
|
||
↓
|
||
拿响应里的 order_no 进入发货平台链接
|
||
↓
|
||
发货平台查询订单并执行发货
|
||
↓
|
||
发货平台通过上游回调更新 fulfillment_status
|
||
↓
|
||
GET /orders/{order_no} 轮询,或接收 order.fulfillment.updated 回调`}</pre>
|
||
}
|
||
/>
|
||
</SectionWrap>
|
||
)
|
||
}
|