diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 74ed4eb..ae389f3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> + + + + } + /> } /> diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index 478ca15..6ffe89d 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -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: , label: '商户管理' }, { key: '/open-api', icon: , label: '开放接口' }, ) + if (import.meta.env.DEV) { + items.push( + { key: '/api-debug', icon: , label: 'API 调试' }, + ) + } } return items }, [isAdmin]) diff --git a/frontend/src/pages/ApiDebugger.tsx b/frontend/src/pages/ApiDebugger.tsx new file mode 100644 index 0000000..4479475 --- /dev/null +++ b/frontend/src/pages/ApiDebugger.tsx @@ -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 = { + GET: 'blue', + POST: 'green', + PUT: 'orange', + PATCH: 'gold', + DELETE: 'red', +} + +async function hmacSha256Hex(secret: string, data: string): Promise { + 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 { + 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(endpoints[0]?.key ?? '') + const [pathValues, setPathValues] = useState>({}) + const [queryValues, setQueryValues] = useState>({}) + 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 + 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 = { + '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 = {} + 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 ( +
+ + API 调试工具 + + + 填写商户 API 客户端的 AppKey 与 Secret,选择接口并发送请求。签名在浏览器端使用 Web Crypto API 计算。 + + + {!import.meta.env.DEV && ( + + )} + + + 忘记凭证 + + } + > + + handleAppKeyChange(e.target.value)} + style={{ width: 320 }} + allowClear + size="middle" + /> + handleSecretChange(e.target.value)} + style={{ width: 400 }} + allowClear + size="middle" + /> + + + + } + loading={loading} + onClick={handleSend} + disabled={!appKey.trim() || !secret.trim()} + size="middle" + > + 发送请求 + + } + > + setSelectedKey(e.target.value)} + style={{ width: '100%', display: 'block' }} + > + {endpoints.map((ep) => ( +
setSelectedKey(ep.key)} + > + + + {ep.method} + + {ep.path} + {ep.summary} +
+ ))} +
+
+ + {endpoint && ( + +
+ {endpoint.pathParams?.map((p) => ( + {`{${p.name}}`}} help={p.desc}> + setPathValues((prev) => ({ ...prev, [p.name]: e.target.value }))} + style={{ maxWidth: 480 }} + /> + + ))} + + {endpoint.queryParams?.map((p) => ( + {p.name}} help={p.desc}> + setQueryValues((prev) => ({ ...prev, [p.name]: e.target.value }))} + style={{ maxWidth: 240 }} + /> + + ))} + + {endpoint.method !== 'GET' && ( + + Request Body (JSON) + + {endpoint.requestExample && ( + + )} + + } + > +