新增 API 调试工具页面,支持填写 AppKey/Secret 测试开放接口
- 仅开发模式下管理员可见,菜单入口通过 import.meta.env.DEV 控制 - 浏览器端 Web Crypto API 计算 HMAC-SHA256 签名,与后端 BuildOpenV1Sign 一致 - 接口选择采用卡片式 Radio 列表,路径/查询/请求体参数动态生成 - AppKey/Secret 自动持久化到 localStorage,支持一键清除 - 展示签名详情(签名串/X-Sign)和完整响应
This commit is contained in:
@@ -0,0 +1,470 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Descriptions,
|
||||
Form,
|
||||
Input,
|
||||
message,
|
||||
Radio,
|
||||
Space,
|
||||
Spin,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import { ClearOutlined, SendOutlined } from '@ant-design/icons'
|
||||
import { endpoints } from '../openapi/endpoints'
|
||||
|
||||
const STORAGE_KEY_APP = 'api_debug_app_key'
|
||||
const STORAGE_KEY_SECRET = 'api_debug_secret'
|
||||
|
||||
const { Title, Text, Paragraph } = Typography
|
||||
const { TextArea } = Input
|
||||
|
||||
const methodColor: Record<string, string> = {
|
||||
GET: 'blue',
|
||||
POST: 'green',
|
||||
PUT: 'orange',
|
||||
PATCH: 'gold',
|
||||
DELETE: 'red',
|
||||
}
|
||||
|
||||
async function hmacSha256Hex(secret: string, data: string): Promise<string> {
|
||||
const enc = new TextEncoder()
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
enc.encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign'],
|
||||
)
|
||||
const sig = await crypto.subtle.sign('HMAC', key, enc.encode(data))
|
||||
return Array.from(new Uint8Array(sig))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
}
|
||||
|
||||
async function sha256Hex(data: Uint8Array): Promise<string> {
|
||||
const hash = await crypto.subtle.digest('SHA-256', data as BufferSource)
|
||||
return Array.from(new Uint8Array(hash))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
}
|
||||
|
||||
function buildSignString(
|
||||
appKey: string,
|
||||
bodyHash: string,
|
||||
method: string,
|
||||
nonce: string,
|
||||
path: string,
|
||||
timestamp: string,
|
||||
) {
|
||||
return [
|
||||
`app_key=${appKey}`,
|
||||
`body_sha256=${bodyHash}`,
|
||||
`method=${method.toUpperCase()}`,
|
||||
`nonce=${nonce}`,
|
||||
`path=${path}`,
|
||||
`timestamp=${timestamp}`,
|
||||
].join('&')
|
||||
}
|
||||
|
||||
export default function ApiDebugger() {
|
||||
const [appKey, setAppKey] = useState(() => localStorage.getItem(STORAGE_KEY_APP) ?? '')
|
||||
const [secret, setSecret] = useState(() => localStorage.getItem(STORAGE_KEY_SECRET) ?? '')
|
||||
const [selectedKey, setSelectedKey] = useState<string>(endpoints[0]?.key ?? '')
|
||||
const [pathValues, setPathValues] = useState<Record<string, string>>({})
|
||||
const [queryValues, setQueryValues] = useState<Record<string, string>>({})
|
||||
const [bodyText, setBodyText] = useState('')
|
||||
const [idempotencyKey, setIdempotencyKey] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleAppKeyChange = (val: string) => {
|
||||
setAppKey(val)
|
||||
localStorage.setItem(STORAGE_KEY_APP, val)
|
||||
}
|
||||
|
||||
const handleSecretChange = (val: string) => {
|
||||
setSecret(val)
|
||||
localStorage.setItem(STORAGE_KEY_SECRET, val)
|
||||
}
|
||||
|
||||
const handleForget = () => {
|
||||
localStorage.removeItem(STORAGE_KEY_APP)
|
||||
localStorage.removeItem(STORAGE_KEY_SECRET)
|
||||
setAppKey('')
|
||||
setSecret('')
|
||||
}
|
||||
|
||||
const [response, setResponse] = useState<{
|
||||
status: number
|
||||
headers: Record<string, string>
|
||||
body: string
|
||||
elapsed: number
|
||||
} | null>(null)
|
||||
|
||||
const [signInfo, setSignInfo] = useState<{
|
||||
timestamp: string
|
||||
nonce: string
|
||||
signString: string
|
||||
signature: string
|
||||
path: string
|
||||
query: string
|
||||
} | null>(null)
|
||||
|
||||
const endpoint = endpoints.find((e) => e.key === selectedKey)
|
||||
const isCreateOrder = selectedKey === 'create-order'
|
||||
|
||||
useEffect(() => {
|
||||
if (endpoint?.requestExample) {
|
||||
try {
|
||||
JSON.parse(endpoint.requestExample)
|
||||
setBodyText(endpoint.requestExample)
|
||||
} catch {
|
||||
setBodyText('')
|
||||
}
|
||||
} else {
|
||||
setBodyText('')
|
||||
}
|
||||
setPathValues({})
|
||||
setQueryValues({})
|
||||
setIdempotencyKey('')
|
||||
setResponse(null)
|
||||
setSignInfo(null)
|
||||
}, [selectedKey, endpoint])
|
||||
|
||||
const buildPath = useCallback(() => {
|
||||
if (!endpoint) return ''
|
||||
let p = endpoint.path
|
||||
for (const key of Object.keys(pathValues)) {
|
||||
if (pathValues[key]) {
|
||||
p = p.replace(`{${key}}`, pathValues[key])
|
||||
}
|
||||
}
|
||||
return p
|
||||
}, [endpoint, pathValues])
|
||||
|
||||
const buildQuery = useCallback(() => {
|
||||
const p = new URLSearchParams()
|
||||
for (const key of Object.keys(queryValues)) {
|
||||
if (queryValues[key]) p.append(key, queryValues[key])
|
||||
}
|
||||
const qs = p.toString()
|
||||
return qs ? `?${qs}` : ''
|
||||
}, [queryValues])
|
||||
|
||||
const readyPath = useMemo(() => buildPath(), [buildPath])
|
||||
const readyQuery = useMemo(() => buildQuery(), [buildQuery])
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!appKey.trim() || !secret.trim()) {
|
||||
message.error('请先填写 AppKey 和 Secret')
|
||||
return
|
||||
}
|
||||
if (!endpoint) return
|
||||
|
||||
const enc = new TextEncoder()
|
||||
const bodyBytes = enc.encode(bodyText)
|
||||
const timestamp = String(Math.floor(Date.now() / 1000))
|
||||
const nonce = crypto.randomUUID().replace(/-/g, '').substring(0, 16)
|
||||
const bodyHash = await sha256Hex(bodyBytes)
|
||||
const method = endpoint.method
|
||||
const plainPath = readyPath
|
||||
const signString = buildSignString(appKey.trim(), bodyHash, method, nonce, plainPath, timestamp)
|
||||
const signature = await hmacSha256Hex(secret.trim(), signString)
|
||||
|
||||
setSignInfo({
|
||||
timestamp,
|
||||
nonce,
|
||||
signString,
|
||||
signature,
|
||||
path: plainPath,
|
||||
query: readyQuery,
|
||||
})
|
||||
|
||||
const fetchHeaders: Record<string, string> = {
|
||||
'X-App-Key': appKey.trim(),
|
||||
'X-Timestamp': timestamp,
|
||||
'X-Nonce': nonce,
|
||||
'X-Sign': signature,
|
||||
}
|
||||
if (method !== 'GET' && bodyText) {
|
||||
fetchHeaders['Content-Type'] = 'application/json'
|
||||
}
|
||||
if (isCreateOrder && idempotencyKey.trim()) {
|
||||
fetchHeaders['Idempotency-Key'] = idempotencyKey.trim()
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
const start = performance.now()
|
||||
try {
|
||||
const res = await fetch(plainPath + readyQuery, {
|
||||
method,
|
||||
headers: fetchHeaders,
|
||||
body: method === 'GET' ? undefined : bodyText,
|
||||
})
|
||||
const elapsed = Math.round(performance.now() - start)
|
||||
const resBody = await res.text()
|
||||
const resHeaders: Record<string, string> = {}
|
||||
res.headers.forEach((v, k) => {
|
||||
resHeaders[k] = v
|
||||
})
|
||||
setResponse({ status: res.status, headers: resHeaders, body: resBody, elapsed })
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
setResponse({
|
||||
status: 0,
|
||||
headers: {},
|
||||
body: `请求失败: ${msg}`,
|
||||
elapsed: Math.round(performance.now() - start),
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatJSON = (raw: string) => {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(raw), null, 2)
|
||||
} catch {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Title level={4} style={{ marginTop: 0 }}>
|
||||
API 调试工具
|
||||
</Title>
|
||||
<Paragraph type="secondary">
|
||||
填写商户 API 客户端的 AppKey 与 Secret,选择接口并发送请求。签名在浏览器端使用 Web Crypto API 计算。
|
||||
</Paragraph>
|
||||
|
||||
{!import.meta.env.DEV && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="当前为生产构建,调试工具仅开发模式可用。"
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card
|
||||
title="凭证"
|
||||
style={{ marginBottom: 20 }}
|
||||
extra={
|
||||
<Button danger onClick={handleForget} disabled={!appKey && !secret}>
|
||||
忘记凭证
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Space wrap size="middle">
|
||||
<Input
|
||||
placeholder="AppKey"
|
||||
value={appKey}
|
||||
onChange={(e) => handleAppKeyChange(e.target.value)}
|
||||
style={{ width: 320 }}
|
||||
allowClear
|
||||
size="middle"
|
||||
/>
|
||||
<Input.Password
|
||||
placeholder="Secret"
|
||||
value={secret}
|
||||
onChange={(e) => handleSecretChange(e.target.value)}
|
||||
style={{ width: 400 }}
|
||||
allowClear
|
||||
size="middle"
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="选择接口"
|
||||
style={{ marginBottom: 20 }}
|
||||
extra={
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SendOutlined />}
|
||||
loading={loading}
|
||||
onClick={handleSend}
|
||||
disabled={!appKey.trim() || !secret.trim()}
|
||||
size="middle"
|
||||
>
|
||||
发送请求
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Radio.Group
|
||||
value={selectedKey}
|
||||
onChange={(e) => setSelectedKey(e.target.value)}
|
||||
style={{ width: '100%', display: 'block' }}
|
||||
>
|
||||
{endpoints.map((ep) => (
|
||||
<div
|
||||
key={ep.key}
|
||||
style={{
|
||||
padding: '10px 14px',
|
||||
marginBottom: 8,
|
||||
border: selectedKey === ep.key ? '1px solid #1677ff' : '1px solid #f0f0f0',
|
||||
borderRadius: 8,
|
||||
background: selectedKey === ep.key ? '#f0f5ff' : '#fff',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
onClick={() => setSelectedKey(ep.key)}
|
||||
>
|
||||
<Radio value={ep.key} style={{ marginRight: 12 }} />
|
||||
<Tag color={methodColor[ep.method]} style={{ fontWeight: 700, fontSize: 13 }}>
|
||||
{ep.method}
|
||||
</Tag>
|
||||
<Text code style={{ fontSize: 14, marginRight: 12 }}>{ep.path}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>{ep.summary}</Text>
|
||||
</div>
|
||||
))}
|
||||
</Radio.Group>
|
||||
</Card>
|
||||
|
||||
{endpoint && (
|
||||
<Card title="请求参数" style={{ marginBottom: 20 }}>
|
||||
<Form layout="vertical">
|
||||
{endpoint.pathParams?.map((p) => (
|
||||
<Form.Item key={p.name} label={<Text code style={{ fontSize: 14 }}>{`{${p.name}}`}</Text>} help={p.desc}>
|
||||
<Input
|
||||
placeholder={p.example ?? p.desc}
|
||||
value={pathValues[p.name] ?? ''}
|
||||
onChange={(e) => setPathValues((prev) => ({ ...prev, [p.name]: e.target.value }))}
|
||||
style={{ maxWidth: 480 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
))}
|
||||
|
||||
{endpoint.queryParams?.map((p) => (
|
||||
<Form.Item key={p.name} label={<Text code style={{ fontSize: 14 }}>{p.name}</Text>} help={p.desc}>
|
||||
<Input
|
||||
placeholder={p.example ?? p.desc}
|
||||
value={queryValues[p.name] ?? ''}
|
||||
onChange={(e) => setQueryValues((prev) => ({ ...prev, [p.name]: e.target.value }))}
|
||||
style={{ maxWidth: 240 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
))}
|
||||
|
||||
{endpoint.method !== 'GET' && (
|
||||
<Form.Item
|
||||
label={
|
||||
<Space>
|
||||
<Text strong style={{ fontSize: 14 }}>Request Body (JSON)</Text>
|
||||
<Button size="small" icon={<ClearOutlined />} onClick={() => setBodyText('')}>清空</Button>
|
||||
{endpoint.requestExample && (
|
||||
<Button size="small" onClick={() => setBodyText(endpoint.requestExample!)}>填入示例</Button>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<TextArea
|
||||
rows={10}
|
||||
value={bodyText}
|
||||
onChange={(e) => setBodyText(e.target.value)}
|
||||
style={{ fontFamily: 'monospace', fontSize: 13, lineHeight: 1.5 }}
|
||||
placeholder={endpoint.requestExample ?? '{}'}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{isCreateOrder && (
|
||||
<Form.Item label={<Text code style={{ fontSize: 14 }}>Idempotency-Key</Text>} help="须与 client_order_no 一致">
|
||||
<Input
|
||||
value={idempotencyKey}
|
||||
onChange={(e) => setIdempotencyKey(e.target.value)}
|
||||
style={{ maxWidth: 480 }}
|
||||
placeholder="会从 Request Body 自动取 client_order_no"
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form>
|
||||
|
||||
<Alert
|
||||
type="info"
|
||||
message={
|
||||
<span style={{ fontSize: 14 }}>
|
||||
<Tag color={methodColor[endpoint.method]} style={{ fontWeight: 700 }}>{endpoint.method}</Tag>
|
||||
<Text code style={{ fontSize: 13 }}>{readyPath + readyQuery}</Text>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{signInfo && (
|
||||
<Card title="签名详情" style={{ marginBottom: 20 }}>
|
||||
<Descriptions column={1} bordered>
|
||||
<Descriptions.Item label="X-Timestamp">{signInfo.timestamp}</Descriptions.Item>
|
||||
<Descriptions.Item label="X-Nonce">{signInfo.nonce}</Descriptions.Item>
|
||||
<Descriptions.Item label="X-App-Key">{appKey.trim()}</Descriptions.Item>
|
||||
<Descriptions.Item label="签名串">
|
||||
<pre style={{ margin: 0, fontSize: 13, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
||||
{signInfo.signString}
|
||||
</pre>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="X-Sign">
|
||||
<Text code copyable style={{ fontSize: 13 }}>{signInfo.signature}</Text>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{response && (
|
||||
<Card
|
||||
title="响应"
|
||||
extra={
|
||||
<Space size="middle">
|
||||
<Tag color={response.status >= 200 && response.status < 300 ? 'green' : 'red'}>
|
||||
{response.status || 'ERR'}
|
||||
</Tag>
|
||||
<Text type="secondary">{response.elapsed}ms</Text>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
<div style={{ fontSize: 14, fontWeight: 500, margin: '8px 0 6px' }}>Response Headers</div>
|
||||
<pre
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: 10,
|
||||
background: '#f6f8fa',
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
lineHeight: 1.6,
|
||||
maxHeight: 200,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{Object.entries(response.headers)
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join('\n') || '(无)'}
|
||||
</pre>
|
||||
|
||||
<div style={{ fontSize: 14, fontWeight: 500, margin: '16px 0 6px' }}>Response Body</div>
|
||||
<pre
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: 14,
|
||||
background: '#f6f8fa',
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
lineHeight: 1.6,
|
||||
maxHeight: 600,
|
||||
overflow: 'auto',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
}}
|
||||
>
|
||||
{formatJSON(response.body)}
|
||||
</pre>
|
||||
</Spin>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user