新增 API 调试工具页面,支持填写 AppKey/Secret 测试开放接口

- 仅开发模式下管理员可见,菜单入口通过 import.meta.env.DEV 控制
- 浏览器端 Web Crypto API 计算 HMAC-SHA256 签名,与后端 BuildOpenV1Sign 一致
- 接口选择采用卡片式 Radio 列表,路径/查询/请求体参数动态生成
- AppKey/Secret 自动持久化到 localStorage,支持一键清除
- 展示签名详情(签名串/X-Sign)和完整响应
This commit is contained in:
yml2213
2026-07-30 16:47:54 +08:00
parent 881ef40fb7
commit f4f77a5d16
3 changed files with 485 additions and 0 deletions
+9
View File
@@ -7,6 +7,7 @@ import Login from './pages/Login'
import Register from './pages/Register'
import Dashboard from './pages/Dashboard'
import OpenApiDocs from './pages/OpenApiDocs'
import ApiDebugger from './pages/ApiDebugger'
import MerchantCenter from './pages/MerchantCenter'
import PlatformMerchants from './pages/PlatformMerchants'
import type { ReactNode } from 'react'
@@ -53,6 +54,14 @@ function AppRoutes() {
</AdminRoute>
}
/>
<Route
path="api-debug"
element={
<AdminRoute>
<ApiDebugger />
</AdminRoute>
}
/>
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
+6
View File
@@ -17,6 +17,7 @@ import {
MenuUnfoldOutlined,
ApiOutlined,
ShopOutlined,
BugOutlined,
} from '@ant-design/icons'
import { useAuth } from '../store/auth'
import type { MenuProps } from 'antd'
@@ -42,6 +43,11 @@ export default function MainLayout() {
{ key: '/platform-merchants', icon: <ShopOutlined />, label: '商户管理' },
{ key: '/open-api', icon: <ApiOutlined />, label: '开放接口' },
)
if (import.meta.env.DEV) {
items.push(
{ key: '/api-debug', icon: <BugOutlined />, label: 'API 调试' },
)
}
}
return items
}, [isAdmin])
+470
View File
@@ -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>
)
}