实现多商户履约平台基础
This commit is contained in:
+138
-253
@@ -1,86 +1,67 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
import { Alert, Card, Descriptions, Space, Table, Tabs, Tag, Typography } from 'antd'
|
||||
|
||||
const { Title, Paragraph, Text, Link } = Typography
|
||||
const { Title, Paragraph, Text } = Typography
|
||||
|
||||
const baseUrl =
|
||||
typeof window !== 'undefined' ? window.location.origin.replace(':5173', ':8080') : 'http://localhost:8080'
|
||||
typeof window !== 'undefined'
|
||||
? window.location.origin.replace(':5173', ':8080')
|
||||
: 'http://localhost:8080'
|
||||
|
||||
const signPython = `import hmac, hashlib, time, uuid, requests
|
||||
const signPython = `import hashlib, hmac, json, time, uuid, requests
|
||||
|
||||
API_KEY = "sk_source_dev_key_change_me"
|
||||
API_SECRET = "sk_source_dev_secret_change_me"
|
||||
APP_KEY = "ak_xxx"
|
||||
APP_SECRET = "sk_xxx"
|
||||
BASE = "${baseUrl}"
|
||||
|
||||
def build_sign_string(api_key, timestamp, nonce, method, path, body=""):
|
||||
params = {
|
||||
"api_key": api_key,
|
||||
"body": body,
|
||||
"method": method.upper(),
|
||||
"nonce": nonce,
|
||||
"path": path,
|
||||
"timestamp": timestamp,
|
||||
}
|
||||
# 字典序 + & 拼接,value 不 URL encode
|
||||
return "&".join(f"{k}={params[k]}" for k in sorted(params.keys()))
|
||||
|
||||
def sign_headers(method: str, path: str, body: str = "") -> dict:
|
||||
def sign_headers(method: str, path: str, body: bytes = b"") -> dict:
|
||||
ts = str(int(time.time()))
|
||||
nonce = uuid.uuid4().hex
|
||||
raw = build_sign_string(API_KEY, ts, nonce, method, path, body)
|
||||
sign = hmac.new(API_SECRET.encode(), raw.encode(), hashlib.sha256).hexdigest()
|
||||
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-Api-Key": API_KEY,
|
||||
"X-App-Key": APP_KEY,
|
||||
"X-Timestamp": ts,
|
||||
"X-Nonce": nonce,
|
||||
"X-Sign": sign,
|
||||
}
|
||||
|
||||
# 查询
|
||||
path = "/api/open/v1/orders/O你的订单号"
|
||||
path = "/api/client/v1/products"
|
||||
print(requests.get(BASE + path, headers=sign_headers("GET", path)).json())
|
||||
|
||||
# 推送(body 必须与签名一致)
|
||||
path = "/api/open/v1/orders/ship-notify"
|
||||
body = '{"order_no":"O你的订单号","ship_status":"success","provider_order_no":"SRC001"}'
|
||||
headers = {"Content-Type": "application/json", **sign_headers("POST", path, body)}
|
||||
print(requests.post(BASE + path, headers=headers, data=body.encode()).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())`
|
||||
|
||||
export default function OpenApiDocs() {
|
||||
return (
|
||||
<div>
|
||||
<Title level={4} style={{ marginTop: 0 }}>
|
||||
开放接口文档(皮肤源头)
|
||||
开放接口
|
||||
</Title>
|
||||
<Paragraph type="secondary">
|
||||
给上游发货系统对接使用。详细 Markdown 文档见仓库{' '}
|
||||
<Text code>docs/开放接口-皮肤源头对接.md</Text>。
|
||||
面向商户系统、履约器和外部平台调用,凭证在「商户中心 / API 客户端」创建。原
|
||||
<Text code>/api/open/v1</Text> 保留给上游发货对接。
|
||||
</Paragraph>
|
||||
|
||||
<Alert
|
||||
type="warning"
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="鉴权:X-Api-Key + HMAC 签名(必填)"
|
||||
message="v1 鉴权头:X-App-Key / X-Timestamp / X-Nonce / X-Sign"
|
||||
description={
|
||||
<div>
|
||||
请求头必须同时携带:
|
||||
<Text code>X-Api-Key</Text>、<Text code>X-Timestamp</Text>、
|
||||
<Text code>X-Nonce</Text>、<Text code>X-Sign</Text>
|
||||
<br />
|
||||
开发默认 Key:
|
||||
<Text code copyable>
|
||||
sk_source_dev_key_change_me
|
||||
<Space direction="vertical" size={4}>
|
||||
<Text>
|
||||
签名内容为 <Text code>timestamp\nnonce\nMETHOD\npath\nsha256(body)</Text>。
|
||||
</Text>
|
||||
,Secret:
|
||||
<Text code copyable>
|
||||
sk_source_dev_secret_change_me
|
||||
<Text>
|
||||
旧 <Text code>X-Api-Key</Text> 签名只给兼容客户端使用,新对接统一使用{' '}
|
||||
<Text code>X-App-Key</Text>。
|
||||
</Text>
|
||||
<br />
|
||||
生产环境变量:
|
||||
<Text code>OPEN_API_KEY</Text> / <Text code>OPEN_API_SECRET</Text> /{' '}
|
||||
<Text code>OPEN_SIGN_SKEW</Text>
|
||||
</div>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -88,231 +69,135 @@ export default function OpenApiDocs() {
|
||||
items={[
|
||||
{
|
||||
key: 'auth',
|
||||
label: '签名规则',
|
||||
label: '签名',
|
||||
children: (
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Card size="small" title="待签名字符串(字典序 + & 拼接)">
|
||||
<Paragraph type="secondary" style={{ marginBottom: 8 }}>
|
||||
参数:api_key / body / method / nonce / path / timestamp → 按 key 排序后拼成
|
||||
k=v&k=v…(value <Text strong>不</Text> URL encode)
|
||||
</Paragraph>
|
||||
<pre style={preStyle}>{`api_key=sk_xxx&body=&method=GET&nonce=a1b2c3d4e5f67890&path=/api/open/v1/orders/O123×tamp=1721450000`}</pre>
|
||||
<pre style={{ ...preStyle, marginTop: 8 }}>{`api_key=sk_xxx&body={"order_no":"O123","ship_status":"success"}&method=POST&nonce=...&path=/api/open/v1/orders/ship-notify×tamp=1721450000`}</pre>
|
||||
<Descriptions size="small" column={1} bordered style={{ marginTop: 12 }}>
|
||||
<Descriptions.Item label="method">大写 GET / POST</Descriptions.Item>
|
||||
<Descriptions.Item label="path">
|
||||
URL.Path,不含 query,如 /api/open/v1/orders/O123
|
||||
<Card size="small" title="签名规则">
|
||||
<Descriptions size="small" column={1} bordered>
|
||||
<Descriptions.Item label="参与字段">
|
||||
timestamp、nonce、METHOD、path、sha256(body)
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="body">
|
||||
原始请求体;GET 用空字符串。POST 必须与实际发送 body 字节一致
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="X-Sign">
|
||||
hex(HMAC-SHA256(api_secret, string_to_sign)) 小写
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="时间窗">默认 ±300 秒</Descriptions.Item>
|
||||
<Descriptions.Item label="Nonce">8~64 字符,有效期内同一 Key 不可重复</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 完整示例">
|
||||
<Card size="small" title="Python 示例">
|
||||
<pre style={preStyle}>{signPython}</pre>
|
||||
</Card>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'flow',
|
||||
label: '对接流程',
|
||||
children: (
|
||||
<Card size="small">
|
||||
<Paragraph>
|
||||
<ol>
|
||||
<li>买家在店铺下单并完成支付(订单 status = paid)</li>
|
||||
<li>上游拿到店铺订单号 <Text code>order_no</Text></li>
|
||||
<li>
|
||||
带签名调用「订单查询」确认 <Text code>product.sku</Text> 与{' '}
|
||||
<Text code>can_ship</Text>
|
||||
</li>
|
||||
<li>
|
||||
<Text code>can_ship=true</Text> 时执行发货(按 sku)
|
||||
</li>
|
||||
<li>带签名调用「发货推送」回传 success / failed / processing</li>
|
||||
<li>我方同步订单状态;重复 success 幂等成功</li>
|
||||
</ol>
|
||||
</Paragraph>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
||||
Base URL 示例:<Text code>{baseUrl}</Text>
|
||||
</Paragraph>
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'query',
|
||||
label: '订单查询',
|
||||
children: (
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Card size="small" title="请求">
|
||||
<Paragraph>
|
||||
<Tag color="blue">GET</Tag>
|
||||
<Text code>/api/open/v1/orders/{order_no}</Text>
|
||||
</Paragraph>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
||||
Header:X-Api-Key / X-Timestamp / X-Nonce / X-Sign(见「签名规则」)
|
||||
</Paragraph>
|
||||
</Card>
|
||||
<Card size="small" title="响应字段">
|
||||
<Table
|
||||
size="small"
|
||||
pagination={false}
|
||||
rowKey="field"
|
||||
dataSource={[
|
||||
{ field: 'order_no', desc: '店铺订单号' },
|
||||
{ field: 'status', desc: '订单状态' },
|
||||
{ field: 'can_ship', desc: '是否可发货(发货前置请以此为准)' },
|
||||
{ field: 'cannot_ship_reason', desc: '不可发货原因' },
|
||||
{ field: 'product.sku', desc: '商品英文固定标识(发货用)' },
|
||||
{ field: 'product.name', desc: '商品中文名' },
|
||||
{ field: 'product.game', desc: '游戏,如和平精英' },
|
||||
{ field: 'buyer_name', desc: '买家名' },
|
||||
{ field: 'amount', desc: '金额' },
|
||||
{ field: 'shipped_at', desc: '发货成功时间' },
|
||||
]}
|
||||
columns={[
|
||||
{
|
||||
title: '字段',
|
||||
dataIndex: 'field',
|
||||
width: 200,
|
||||
render: (v) => <Text code>{v}</Text>,
|
||||
},
|
||||
{ title: '说明', dataIndex: 'desc' },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
<Card size="small" title="can_ship 规则">
|
||||
<Table
|
||||
size="small"
|
||||
pagination={false}
|
||||
rowKey="status"
|
||||
dataSource={[
|
||||
{ status: 'pending', ship: 'false', note: '未支付' },
|
||||
{ status: 'paid', ship: 'true', note: '可发' },
|
||||
{ status: 'delivering', ship: 'false', note: '发货中' },
|
||||
{ status: 'delivered', ship: 'false', note: '已完成' },
|
||||
{ status: 'ship_failed', ship: 'true', note: '可重试' },
|
||||
{ status: 'cancelled', ship: 'false', note: '已取消' },
|
||||
]}
|
||||
columns={[
|
||||
{
|
||||
title: 'status',
|
||||
dataIndex: 'status',
|
||||
render: (v) => <Text code>{v}</Text>,
|
||||
},
|
||||
{
|
||||
title: 'can_ship',
|
||||
dataIndex: 'ship',
|
||||
render: (v) =>
|
||||
v === 'true' ? <Tag color="green">true</Tag> : <Tag>false</Tag>,
|
||||
},
|
||||
{ title: '说明', dataIndex: 'note' },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'notify',
|
||||
label: '发货推送',
|
||||
children: (
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Card size="small" title="请求">
|
||||
<Paragraph>
|
||||
<Tag color="green">POST</Tag>
|
||||
<Text code>/api/open/v1/orders/ship-notify</Text>
|
||||
</Paragraph>
|
||||
<Paragraph type="secondary">
|
||||
除签名头外,Body 参与签名。Body 示例:
|
||||
</Paragraph>
|
||||
<pre style={preStyle}>{`{
|
||||
"order_no": "O202607201550038000",
|
||||
"ship_status": "success",
|
||||
"provider_order_no": "SRC20260720001",
|
||||
"shipped_at": "2026-07-20T16:00:00+08:00",
|
||||
"fail_reason": ""
|
||||
}`}</pre>
|
||||
</Card>
|
||||
<Card size="small" title="请求参数">
|
||||
<Descriptions size="small" column={1} bordered>
|
||||
<Descriptions.Item label="order_no">必填,店铺订单号</Descriptions.Item>
|
||||
<Descriptions.Item label="ship_status">
|
||||
必填:success / failed / processing
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="provider_order_no">可选,上游单号</Descriptions.Item>
|
||||
<Descriptions.Item label="shipped_at">
|
||||
可选,RFC3339;success 缺省用服务端时间
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="fail_reason">可选,失败原因</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
<Card size="small" title="状态映射与幂等">
|
||||
<Table
|
||||
size="small"
|
||||
pagination={false}
|
||||
rowKey="ship"
|
||||
style={{ marginBottom: 12 }}
|
||||
dataSource={[
|
||||
{ ship: 'processing', order: 'delivering', note: '已接单/发货中' },
|
||||
{ ship: 'success', order: 'delivered', note: '发货成功' },
|
||||
{ ship: 'failed', order: 'ship_failed', note: '失败可重试' },
|
||||
]}
|
||||
columns={[
|
||||
{
|
||||
title: 'ship_status',
|
||||
dataIndex: 'ship',
|
||||
render: (v) => <Text code>{v}</Text>,
|
||||
},
|
||||
{
|
||||
title: '订单状态',
|
||||
dataIndex: 'order',
|
||||
render: (v) => <Text code>{v}</Text>,
|
||||
},
|
||||
{ title: '说明', dataIndex: 'note' },
|
||||
]}
|
||||
/>
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="幂等:订单已 delivered 时再次推送 success 仍返回成功,不会重复处理。"
|
||||
/>
|
||||
</Card>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'errors',
|
||||
label: '错误码',
|
||||
key: 'endpoints',
|
||||
label: '接口',
|
||||
children: (
|
||||
<Card size="small">
|
||||
<Table
|
||||
size="small"
|
||||
pagination={false}
|
||||
rowKey="code"
|
||||
rowKey="path"
|
||||
dataSource={[
|
||||
{ code: 0, http: 200, msg: '成功' },
|
||||
{ code: 401, http: 401, msg: '鉴权失败:Key/签名/时间/Nonce' },
|
||||
{ code: 404, http: 404, msg: '订单不存在' },
|
||||
{ code: 400, http: 400, msg: '参数错误 / 状态不允许' },
|
||||
{ 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: 'code', dataIndex: 'code', width: 80 },
|
||||
{ title: 'HTTP', dataIndex: 'http', width: 80 },
|
||||
{ title: '说明', dataIndex: 'msg' },
|
||||
{ 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' },
|
||||
]}
|
||||
/>
|
||||
<Paragraph type="secondary" style={{ marginTop: 12, marginBottom: 0 }}>
|
||||
商品 sku 对照:
|
||||
<Link href="/skins"> 皮肤商品列表</Link>
|
||||
(字段「英文名」)或仓库 docs/商品英文名对照.md
|
||||
</Paragraph>
|
||||
</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>
|
||||
),
|
||||
},
|
||||
{
|
||||
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>
|
||||
),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user