import { useEffect, useState } from 'react'; import { BookOpen, Copy, KeyRound, RefreshCw, Webhook } from 'lucide-react'; import { clientApi, type ClientSmsApplication, type HttpApiConfigResponse, type HttpApiCredential, type HttpApiRequestLog, type HttpWebhookDelivery, type HttpWebhookEndpoint } from '@/api/adminApi'; import { Button, Input, Select, Tabs, Tag } from '@/components/ui'; import { copyText } from '@/utils/clipboard'; import { formatHttpApiParams, httpApiPublicOrigin } from '@/utils/interfaceParams'; export function ClientHttpApiPage() { const [applications, setApplications] = useState([]); const [applicationId, setApplicationId] = useState(''); const [config, setConfig] = useState(null); const [credentials, setCredentials] = useState([]); const [webhooks, setWebhooks] = useState([]); const [requests, setRequests] = useState([]); const [deliveries, setDeliveries] = useState([]); const [receiptUrl, setReceiptUrl] = useState(''); const [uplinkUrl, setUplinkUrl] = useState(''); const [revealedSecret, setRevealedSecret] = useState<{ title: string; accessKey?: string; secret: string } | null>(null); const [error, setError] = useState(''); const [loading, setLoading] = useState(true); const [paramsCopied, setParamsCopied] = useState(false); async function copyHttpParams() { if (!config) return; try { await copyText(formatHttpApiParams(config, window.location.origin)); setParamsCopied(true); setError(''); window.setTimeout(() => setParamsCopied(false), 1600); } catch (failure) { setError(failure instanceof Error ? failure.message : 'HTTP接口参数复制失败'); } } useEffect(() => { clientApi.listApplications().then((items) => { const active = items.filter((item) => item.status !== 'deleted'); setApplications(active); setApplicationId((current) => current || active[0]?.id || ''); }).catch((failure: Error) => setError(failure.message)).finally(() => setLoading(false)); }, []); async function loadApplication(id: string) { if (!id) return; setLoading(true); setError(''); try { setConfig(null); setCredentials([]); setWebhooks([]); setRequests([]); setDeliveries([]); const results = await Promise.allSettled([ clientApi.getApplicationHttpApiConfig(id), clientApi.listHttpApiCredentials(id), clientApi.listHttpWebhooks(id), clientApi.listHttpApiRequests(id), clientApi.listHttpWebhookDeliveries(id), ]); const [configResult, credentialsResult, webhooksResult, requestsResult, deliveriesResult] = results; if (configResult.status === 'rejected') throw configResult.reason; const nextConfig = configResult.value; const nextCredentials = credentialsResult.status === 'fulfilled' ? credentialsResult.value : []; const nextWebhooks = webhooksResult.status === 'fulfilled' ? webhooksResult.value : []; const nextRequests = requestsResult.status === 'fulfilled' ? requestsResult.value : []; const nextDeliveries = deliveriesResult.status === 'fulfilled' ? deliveriesResult.value : []; setConfig(nextConfig); setCredentials(nextCredentials); setWebhooks(nextWebhooks); setRequests(nextRequests); setDeliveries(nextDeliveries); setReceiptUrl(nextWebhooks.find((item) => item.eventType === 'receipt')?.url ?? ''); setUplinkUrl(nextWebhooks.find((item) => item.eventType === 'uplink')?.url ?? ''); if (results.some((result) => result.status === 'rejected')) setError('部分接口记录暂时无法加载,请稍后刷新;接口概览仍可正常使用。'); } catch (failure) { const message = failure instanceof Error ? failure.message : ''; setError(message.toLowerCase().includes('internal server error') ? '接口资料暂时加载失败,请稍后重试。' : message || 'HTTP接口资料加载失败'); } finally { setLoading(false); } } useEffect(() => { void loadApplication(applicationId); }, [applicationId]); async function createCredential() { try { const created = await clientApi.createHttpApiCredential(applicationId, { name: `客户端凭据 ${credentials.length + 1}` }); setRevealedSecret({ title: '访问凭据仅展示一次,请立即保存', accessKey: created.accessKey, secret: created.secret ?? '' }); await loadApplication(applicationId); } catch (failure) { setError(failure instanceof Error ? failure.message : '创建凭据失败'); } } async function saveWebhook(eventType: 'receipt' | 'uplink', rotateSecret = false) { const url = eventType === 'receipt' ? receiptUrl : uplinkUrl; try { const saved = await clientApi.saveHttpWebhook(applicationId, eventType, { url, rotateSecret }); if (saved.secret) setRevealedSecret({ title: `${eventType === 'receipt' ? '回执' : '上行'}回调签名密钥仅展示一次`, secret: saved.secret }); await loadApplication(applicationId); } catch (failure) { setError(failure instanceof Error ? failure.message : '保存Webhook失败'); } } const api = config?.config; const publicApiOrigin = httpApiPublicOrigin(config, window.location.origin); const overview =
{!api?.enabled ?

当前应用尚未由运营端开通 HTTP 接口。

: null}

{config?.applicationName ?? '企业应用'}

基础地址:{publicApiOrigin}/api/openapi/v1

单条发送 {api?.sendEnabled ? '已开通' : '未开通'} 状态查询 {api?.messageQueryEnabled ? '已开通' : '未开通'} 上行查询 {api?.uplinkQueryEnabled ? '已开通' : '未开通'} 回执回调 {api?.receiptWebhookEnabled ? '已开通' : '未开通'} 上行回调 {api?.uplinkWebhookEnabled ? '已开通' : '未开通'}

调用限制

QPS:{api?.qpsLimit ?? '-'} · 签名时间容差:{api?.timestampToleranceSeconds ?? '-'} 秒 · 上行单次查询跨度:{api?.maxQueryRangeDays ?? '-'} 天 · 最大分页:{api?.maxPageSize ?? '-'}

HTTP IP 白名单:{config?.ipAllowlist.join('、') || '未限制'}

; const credentialPanel =

访问凭据

密钥只在创建时展示一次;建议轮换时先创建新凭据,完成切换后再吊销旧凭据。

{credentials.map((item) =>
{item.name}{item.accessKey}****{item.secretLast4}最近使用:{item.lastUsedAt ? new Date(item.lastUsedAt).toLocaleString('zh-CN') : '从未'}{item.status === 'active' ? : 已吊销}
)} {credentials.length === 0 ?

暂无访问凭据。

: null}
; const callbackPanel =

回执回调

setReceiptUrl(event.target.value)} placeholder="https://example.com/webhooks/sms/receipt" value={receiptUrl} />
{webhooks.some((item) => item.eventType === 'receipt') ? : null}

上行回调

setUplinkUrl(event.target.value)} placeholder="https://example.com/webhooks/sms/uplink" value={uplinkUrl} />
{webhooks.some((item) => item.eventType === 'uplink') ? : null}
; const docsPanel =

鉴权规则

每次请求携带 X-App-KeyX-TimestampX-NonceX-Signature。签名原文为:

{`METHOD
/api/openapi/v1/...
TIMESTAMP
NONCE
SHA256(rawBody)`}

使用访问密钥执行 HMAC-SHA256,输出小写十六进制。单发还必须携带 Idempotency-Key

接口清单

{`POST /api/openapi/v1/sms/messages\nGET  /api/openapi/v1/sms/messages/{messageId}\nGET  /api/openapi/v1/sms/uplinks\nGET  /api/openapi/v1/sms/uplinks/{uplinkId}`}

完整 OpenAPI 文档:{publicApiOrigin}/api/client-docs

回调验签

回调请求头包含 X-Event-Id、X-Event-Type、X-Timestamp、X-Signature。签名原文为 TIMESTAMP + '\\n' + rawBody,同样使用 HMAC-SHA256。客户系统必须按 X-Event-Id 幂等。

; const logsPanel =

最近调用

{requests.map((item) =>

{item.requestId} · {item.businessCode ?? item.status} · {item.sourceIp ?? '-'} · {item.durationMs ?? '-'}ms · {new Date(item.createdAt).toLocaleString('zh-CN')}

)}{requests.length === 0 ?

暂无调用记录。

: null}

最近回调投递

{deliveries.map((item) =>

{item.event.eventId} · {item.endpoint.eventType} · {item.status} · 尝试 {item.attemptCount} 次{item.lastError ? ` · ${item.lastError}` : ''}

{item.status !== 'delivered' && api?.allowClientManualRetry ? : null}
)}{deliveries.length === 0 ?

暂无回调投递记录。

: null}
; const tabs = [ { label: '接口概览', value: 'overview', content: overview }, { label: '访问凭据', value: 'credentials', content: credentialPanel }, { label: '回调配置', value: 'callbacks', content: callbackPanel }, { label: '接口文档', value: 'docs', content: docsPanel }, { label: '调用与回调记录', value: 'logs', content: logsPanel }, ]; return

接口对接

管理 HTTP 访问凭据、回调地址、接口文档及真实投递记录。