优化api文档
This commit is contained in:
+211
-182
@@ -1,5 +1,13 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
import { Alert, Card, Descriptions, Space, Table, Tabs, Tag, Typography } from 'antd'
|
||||
import { Alert, Card, Collapse, Descriptions, Space, Table, Tabs, Tag, Typography } from 'antd'
|
||||
import EndpointDoc, { CommonResponseDoc } from '../openapi/EndpointDoc'
|
||||
import {
|
||||
commonResponseFields,
|
||||
endpoints,
|
||||
errorCodes,
|
||||
orderStatusTable,
|
||||
paymentStatusTable,
|
||||
} from '../openapi/endpoints'
|
||||
|
||||
const { Title, Paragraph, Text } = Typography
|
||||
|
||||
@@ -8,33 +16,17 @@ const baseUrl =
|
||||
? window.location.origin.replace(':5173', ':8080')
|
||||
: 'http://localhost:8080'
|
||||
|
||||
const signPython = `import hashlib, hmac, json, time, uuid, requests
|
||||
|
||||
APP_KEY = "ak_xxx"
|
||||
APP_SECRET = "sk_xxx"
|
||||
BASE = "${baseUrl}"
|
||||
|
||||
def sign_headers(method: str, path: str, body: bytes = b"") -> dict:
|
||||
ts = str(int(time.time()))
|
||||
nonce = uuid.uuid4().hex
|
||||
body_hash = hashlib.sha256(body).hexdigest()
|
||||
raw = "\\n".join([ts, nonce, method.upper(), path, body_hash])
|
||||
sign = hmac.new(APP_SECRET.encode(), raw.encode(), hashlib.sha256).hexdigest()
|
||||
return {
|
||||
"X-App-Key": APP_KEY,
|
||||
"X-Timestamp": ts,
|
||||
"X-Nonce": nonce,
|
||||
"X-Sign": sign,
|
||||
}
|
||||
|
||||
path = "/api/client/v1/products"
|
||||
print(requests.get(BASE + path, headers=sign_headers("GET", path)).json())
|
||||
|
||||
path = "/api/client/v1/orders"
|
||||
payload = {"client_order_no": "shop-10001", "sku": "sku-basic", "quantity": 1}
|
||||
body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode()
|
||||
headers = {"Content-Type": "application/json", "Idempotency-Key": payload["client_order_no"], **sign_headers("POST", path, body)}
|
||||
print(requests.post(BASE + path, headers=headers, data=body).json())`
|
||||
const preStyle: CSSProperties = {
|
||||
margin: 0,
|
||||
padding: 12,
|
||||
background: '#f6f8fa',
|
||||
borderRadius: 6,
|
||||
fontSize: 12.5,
|
||||
lineHeight: 1.6,
|
||||
overflow: 'auto',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
}
|
||||
|
||||
export default function OpenApiDocs() {
|
||||
return (
|
||||
@@ -43,163 +35,32 @@ export default function OpenApiDocs() {
|
||||
开放接口
|
||||
</Title>
|
||||
<Paragraph type="secondary">
|
||||
面向商户系统、履约器和外部平台调用,凭证在「商户中心 / API 客户端」创建。原
|
||||
<Text code>/api/open/v1</Text> 保留给上游发货对接。
|
||||
面向商户系统、履约器和外部平台调用的通用开放 API。凭证在「商户中心 / API 客户端」创建,
|
||||
采用 <Text code>X-App-Key</Text> + HMAC-SHA256 签名鉴权。
|
||||
原 <Text code>/api/open/v1</Text> 保留给上游发货对接,不在本页展示。
|
||||
</Paragraph>
|
||||
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="v1 鉴权头:X-App-Key / X-Timestamp / X-Nonce / X-Sign"
|
||||
description={
|
||||
<Space direction="vertical" size={4}>
|
||||
<Text>
|
||||
签名内容为 <Text code>timestamp\nnonce\nMETHOD\npath\nsha256(body)</Text>。
|
||||
</Text>
|
||||
<Text>
|
||||
旧 <Text code>X-Api-Key</Text> 签名只给兼容客户端使用,新对接统一使用{' '}
|
||||
<Text code>X-App-Key</Text>。
|
||||
</Text>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'overview',
|
||||
label: '概览',
|
||||
children: <OverviewTab />,
|
||||
},
|
||||
{
|
||||
key: 'auth',
|
||||
label: '签名',
|
||||
children: (
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Card size="small" title="签名规则">
|
||||
<Descriptions size="small" column={1} bordered>
|
||||
<Descriptions.Item label="参与字段">
|
||||
timestamp、nonce、METHOD、path、sha256(body)
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="拼接方式">按固定顺序用换行符拼接</Descriptions.Item>
|
||||
<Descriptions.Item label="path">仅 URL.Path,不含域名和 query</Descriptions.Item>
|
||||
<Descriptions.Item label="body">GET 为空字节;POST 必须与实际发送 body 完全一致</Descriptions.Item>
|
||||
<Descriptions.Item label="X-Sign">hex(HMAC-SHA256(app_secret, raw)) 小写</Descriptions.Item>
|
||||
<Descriptions.Item label="Nonce">8~96 字符,同一客户端有效期内不可重复</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<pre style={{ ...preStyle, marginTop: 12 }}>{`timestamp
|
||||
nonce
|
||||
POST
|
||||
/api/open/v1/orders
|
||||
sha256(body)`}</pre>
|
||||
</Card>
|
||||
<Card size="small" title="Python 示例">
|
||||
<pre style={preStyle}>{signPython}</pre>
|
||||
</Card>
|
||||
</Space>
|
||||
),
|
||||
label: '鉴权与签名',
|
||||
children: <AuthTab />,
|
||||
},
|
||||
{
|
||||
key: 'endpoints',
|
||||
label: '接口',
|
||||
children: (
|
||||
<Card size="small">
|
||||
<Table
|
||||
size="small"
|
||||
pagination={false}
|
||||
rowKey="path"
|
||||
dataSource={[
|
||||
{ method: 'GET', path: '/api/client/v1/products', scope: 'products:read', desc: '查询已授权商品' },
|
||||
{ method: 'POST', path: '/api/client/v1/orders', scope: 'orders:write', desc: '幂等创建订单并扣款' },
|
||||
{ method: 'GET', path: '/api/client/v1/orders/{order_no}', scope: 'orders:read', desc: '查询订单状态' },
|
||||
{ method: 'POST', path: '/api/client/v1/orders/{order_no}/cancel', scope: 'orders:write', desc: '取消未履约订单并退款' },
|
||||
{ method: 'POST', path: '/api/client/v1/orders/{order_no}/ship-notify', scope: 'fulfillment:write', desc: '履约器回传 processing/success/failed' },
|
||||
{ method: 'GET', path: '/api/client/v1/wallet', scope: 'wallet:read', desc: '查询商户钱包' },
|
||||
]}
|
||||
columns={[
|
||||
{ title: '方法', dataIndex: 'method', width: 90, render: (v) => <Tag color={v === 'GET' ? 'blue' : 'green'}>{v}</Tag> },
|
||||
{ title: '路径', dataIndex: 'path', render: (v) => <Text code>{v}</Text> },
|
||||
{ title: '权限', dataIndex: 'scope', width: 150, render: (v) => <Text code>{v}</Text> },
|
||||
{ title: '说明', dataIndex: 'desc' },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'order',
|
||||
label: '下单',
|
||||
children: (
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Card size="small" title="请求">
|
||||
<Paragraph>
|
||||
<Tag color="green">POST</Tag>
|
||||
<Text code>/api/client/v1/orders</Text>
|
||||
</Paragraph>
|
||||
<pre style={preStyle}>{`{
|
||||
"client_order_no": "shop-10001",
|
||||
"sku": "sku-basic",
|
||||
"quantity": 1,
|
||||
"buyer_reference": "buyer-or-account",
|
||||
"data": {
|
||||
"server": "ios-wechat",
|
||||
"uid": "player-id"
|
||||
}
|
||||
}`}</pre>
|
||||
</Card>
|
||||
<Card size="small" title="响应">
|
||||
<pre style={preStyle}>{`{
|
||||
"code": 0,
|
||||
"message": "ok",
|
||||
"data": {
|
||||
"idempotent": false,
|
||||
"order": {
|
||||
"order_no": "FO202607300001...",
|
||||
"client_order_no": "shop-10001",
|
||||
"payment_status": "paid",
|
||||
"fulfillment_status": "pending",
|
||||
"can_fulfill": true
|
||||
}
|
||||
}
|
||||
}`}</pre>
|
||||
</Card>
|
||||
</Space>
|
||||
),
|
||||
label: '接口列表',
|
||||
children: <EndpointsTab />,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: '状态',
|
||||
children: (
|
||||
<Card size="small">
|
||||
<Table
|
||||
size="small"
|
||||
pagination={false}
|
||||
rowKey="status"
|
||||
dataSource={[
|
||||
{ status: 'pending', can: 'true', desc: '待履约,可被履约器接单' },
|
||||
{ status: 'processing', can: 'false', desc: '履约中' },
|
||||
{ status: 'succeeded', can: 'false', desc: '履约成功' },
|
||||
{ status: 'failed', can: 'true', desc: '履约失败,可重试' },
|
||||
{ status: 'cancelled', can: 'false', desc: '已取消' },
|
||||
]}
|
||||
columns={[
|
||||
{ title: 'fulfillment_status', dataIndex: 'status', render: (v) => <Text code>{v}</Text> },
|
||||
{ title: 'can_fulfill', dataIndex: 'can', width: 120, render: (v) => v === 'true' ? <Tag color="green">true</Tag> : <Tag>false</Tag> },
|
||||
{ title: '说明', dataIndex: 'desc' },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'callback',
|
||||
label: '回调',
|
||||
children: (
|
||||
<Card size="small">
|
||||
<Descriptions size="small" column={1} bordered>
|
||||
<Descriptions.Item label="事件">order.created / order.fulfillment.updated / order.cancelled</Descriptions.Item>
|
||||
<Descriptions.Item label="Header">X-Event-ID、X-Timestamp、X-Sign</Descriptions.Item>
|
||||
<Descriptions.Item label="签名">hex(HMAC-SHA256(callback_secret, timestamp + "\n" + sha256(body)))</Descriptions.Item>
|
||||
<Descriptions.Item label="投递">持久化 outbox,失败指数退避重试</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
),
|
||||
label: '状态说明',
|
||||
children: <StatusTab />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -207,13 +68,181 @@ sha256(body)`}</pre>
|
||||
)
|
||||
}
|
||||
|
||||
const preStyle: CSSProperties = {
|
||||
margin: 0,
|
||||
padding: 12,
|
||||
background: '#f5f5f5',
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
overflow: 'auto',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
function OverviewTab() {
|
||||
return (
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="一句话上手"
|
||||
description={
|
||||
<ol style={{ margin: 0, paddingLeft: 20 }}>
|
||||
<li>在「商户中心 / 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>履约器通过「发货回传」回写 processing/success/failed,状态可由「查询订单」轮询。</li>
|
||||
</ol>
|
||||
}
|
||||
/>
|
||||
<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="请求体">POST 请求使用 <Text code>application/json</Text></Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
<CommonResponseDoc fields={commonResponseFields} errors={errorCodes} />
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
|
||||
function AuthTab() {
|
||||
return (
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Card size="small" title="鉴权头(每次请求必带)">
|
||||
<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 ? <Tag color="red">是</Tag> : <Tag>否</Tag> },
|
||||
{ title: '说明', dataIndex: 'desc' },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card size="small" title="签名算法">
|
||||
<Descriptions size="small" column={1} bordered>
|
||||
<Descriptions.Item label="签名内容">
|
||||
<Text code>{'timestamp\nnonce\nMETHOD\npath\nsha256(body)'}</Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="拼接方式">按固定顺序,用换行符 <Text code>\n</Text> 拼接</Descriptions.Item>
|
||||
<Descriptions.Item label="METHOD">大写,如 GET / POST</Descriptions.Item>
|
||||
<Descriptions.Item label="path">仅 URL.Path,不含域名和 query</Descriptions.Item>
|
||||
<Descriptions.Item label="body">GET 为空字节;POST 必须与实际发送 body 完全一致</Descriptions.Item>
|
||||
<Descriptions.Item label="X-Sign">
|
||||
<Text code>hex( HMAC-SHA256( app_secret, 签名内容 ) )</Text>,小写十六进制
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<Card size="small" title="签名示例(GET,body 为空)">
|
||||
<pre style={preStyle}>{`timestamp
|
||||
nonce
|
||||
GET
|
||||
/api/client/v1/products
|
||||
sha256("") = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`}</pre>
|
||||
</Card>
|
||||
|
||||
<Card size="small" title="签名示例(POST,body 非空)">
|
||||
<pre style={preStyle}>{`timestamp
|
||||
nonce
|
||||
POST
|
||||
/api/client/v1/orders
|
||||
sha256(body) = <实际请求 body 字节的 SHA256 十六进制>`}</pre>
|
||||
</Card>
|
||||
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="签名注意事项"
|
||||
description={
|
||||
<ul style={{ margin: 0, paddingLeft: 20 }}>
|
||||
<li>POST 签名用的 <Text code>body</Text> 必须与实际发送的 body <b>字节级一致</b>,不要签名后再改空格或字段顺序。</li>
|
||||
<li>签名失败常见原因:secret 错、path 多了 query、body 不一致、时间戳过期、nonce 重复。</li>
|
||||
<li>下单接口还需携带 <Text code>Idempotency-Key</Text>,且必须与 <Text code>client_order_no</Text> 一致。</li>
|
||||
</ul>
|
||||
}
|
||||
/>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
|
||||
function EndpointsTab() {
|
||||
return (
|
||||
<Collapse
|
||||
accordion
|
||||
items={endpoints.map((spec) => ({
|
||||
key: spec.key,
|
||||
label: (
|
||||
<Space size="small">
|
||||
<Tag color={spec.method === 'GET' ? 'blue' : 'green'} style={{ margin: 0, fontWeight: 700 }}>
|
||||
{spec.method}
|
||||
</Tag>
|
||||
<Text code style={{ fontSize: 13 }}>{spec.path}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{spec.summary}</Text>
|
||||
</Space>
|
||||
),
|
||||
children: <EndpointDoc spec={spec} />,
|
||||
}))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusTab() {
|
||||
return (
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Card size="small" title="履约状态 fulfillment_status 与 can_fulfill">
|
||||
<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 size="small" title="支付状态 payment_status">
|
||||
<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
|
||||
type="info"
|
||||
showIcon
|
||||
message="推荐调用流程"
|
||||
description={
|
||||
<pre style={{ ...preStyle, background: 'transparent', padding: 0 }}>{`拿到 sku(商品列表)
|
||||
↓
|
||||
POST /orders 下单(幂等,Idempotency-Key = client_order_no)
|
||||
↓
|
||||
(履约器)POST /orders/{order_no}/ship-notify ship_status=processing
|
||||
↓
|
||||
按 product.sku 发货
|
||||
↓
|
||||
POST /orders/{order_no}/ship-notify ship_status=success 或 failed
|
||||
↓
|
||||
(可选)GET /orders/{order_no} 轮询确认 fulfillment_status=succeeded`}</pre>
|
||||
}
|
||||
/>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user