Files
affiliate_dash/frontend/src/pages/OpenApiDocs.tsx
T

512 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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="签名示例(GETbody 为空)">
<pre className="api-docs__pre">{`app_key=ak_xxx&body_sha256=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855&method=GET&nonce=a1b2c3d4e5f67890&path=/api/client/v1/products&timestamp=1721450000`}</pre>
</Card>
<Card className="api-docs__card" size="small" title="签名示例(POSTbody 非空)">
<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&timestamp=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 多了 querybody 与签名不一致、时间戳过期、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>
)
}