feat: add HTTP API and complete client workflows
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
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';
|
||||
|
||||
export function ClientHttpApiPage() {
|
||||
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
|
||||
const [applicationId, setApplicationId] = useState('');
|
||||
const [config, setConfig] = useState<HttpApiConfigResponse | null>(null);
|
||||
const [credentials, setCredentials] = useState<HttpApiCredential[]>([]);
|
||||
const [webhooks, setWebhooks] = useState<HttpWebhookEndpoint[]>([]);
|
||||
const [requests, setRequests] = useState<HttpApiRequestLog[]>([]);
|
||||
const [deliveries, setDeliveries] = useState<HttpWebhookDelivery[]>([]);
|
||||
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);
|
||||
|
||||
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 {
|
||||
const [nextConfig, nextCredentials, nextWebhooks, nextRequests, nextDeliveries] = await Promise.all([
|
||||
clientApi.getApplicationHttpApiConfig(id), clientApi.listHttpApiCredentials(id), clientApi.listHttpWebhooks(id),
|
||||
clientApi.listHttpApiRequests(id), clientApi.listHttpWebhookDeliveries(id),
|
||||
]);
|
||||
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 ?? '');
|
||||
} catch (failure) { setError(failure instanceof Error ? failure.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 overview = <div className="page-stack">
|
||||
{!api?.enabled ? <p className="form-error">当前应用尚未由运营端开通 HTTP 接口。</p> : null}
|
||||
<div className="surface" style={{ padding: 18 }}><h3>{config?.applicationName ?? '企业应用'}</h3><p className="muted">基础地址:{window.location.origin}/api/openapi/v1</p><div className="table-actions">
|
||||
<Tag tone={api?.sendEnabled ? 'success' : 'info'}>单条发送 {api?.sendEnabled ? '已开通' : '未开通'}</Tag>
|
||||
<Tag tone={api?.messageQueryEnabled ? 'success' : 'info'}>状态查询 {api?.messageQueryEnabled ? '已开通' : '未开通'}</Tag>
|
||||
<Tag tone={api?.uplinkQueryEnabled ? 'success' : 'info'}>上行查询 {api?.uplinkQueryEnabled ? '已开通' : '未开通'}</Tag>
|
||||
<Tag tone={api?.receiptWebhookEnabled ? 'success' : 'info'}>回执回调 {api?.receiptWebhookEnabled ? '已开通' : '未开通'}</Tag>
|
||||
<Tag tone={api?.uplinkWebhookEnabled ? 'success' : 'info'}>上行回调 {api?.uplinkWebhookEnabled ? '已开通' : '未开通'}</Tag>
|
||||
</div></div>
|
||||
<div className="surface" style={{ padding: 18 }}><h3>调用限制</h3><p>QPS:{api?.qpsLimit ?? '-'} · 签名时间容差:{api?.timestampToleranceSeconds ?? '-'} 秒 · 上行单次查询跨度:{api?.maxQueryRangeDays ?? '-'} 天 · 最大分页:{api?.maxPageSize ?? '-'}</p><p className="muted">HTTP IP 白名单:{config?.ipAllowlist.join('、') || '未限制'}</p></div>
|
||||
</div>;
|
||||
|
||||
const credentialPanel = <div className="page-stack"><div className="section-heading"><div><h3><KeyRound size={17} />访问凭据</h3><p className="muted">密钥只在创建时展示一次;建议轮换时先创建新凭据,完成切换后再吊销旧凭据。</p></div><Button disabled={!api?.credentialSelfServiceEnabled} onClick={() => void createCredential()}>创建凭据</Button></div>
|
||||
{credentials.map((item) => <div className="surface" key={item.id} style={{ display: 'grid', gridTemplateColumns: '1fr 1.5fr 100px 1fr auto', gap: 12, padding: 14, alignItems: 'center' }}><strong>{item.name}</strong><code>{item.accessKey}</code><span>****{item.secretLast4}</span><span className="muted">最近使用:{item.lastUsedAt ? new Date(item.lastUsedAt).toLocaleString('zh-CN') : '从未'}</span>{item.status === 'active' ? <Button disabled={!api?.credentialSelfServiceEnabled} onClick={() => void clientApi.revokeHttpApiCredential(applicationId, item.id).then(() => loadApplication(applicationId))} size="sm" variant="danger">吊销</Button> : <Tag tone="info">已吊销</Tag>}</div>)}
|
||||
{credentials.length === 0 ? <p className="muted">暂无访问凭据。</p> : null}
|
||||
</div>;
|
||||
|
||||
const callbackPanel = <div className="page-stack"><div className="surface" style={{ padding: 18 }}><h3><Webhook size={17} /> 回执回调</h3><Input label="回调 URL" onChange={(event) => setReceiptUrl(event.target.value)} placeholder="https://example.com/webhooks/sms/receipt" value={receiptUrl} /><div className="table-actions" style={{ marginTop: 12 }}><Button onClick={() => void saveWebhook('receipt')}>保存</Button>{webhooks.some((item) => item.eventType === 'receipt') ? <Button onClick={() => void saveWebhook('receipt', true)} variant="ghost">轮换签名密钥</Button> : null}</div></div>
|
||||
<div className="surface" style={{ padding: 18 }}><h3><Webhook size={17} /> 上行回调</h3><Input label="回调 URL" onChange={(event) => setUplinkUrl(event.target.value)} placeholder="https://example.com/webhooks/sms/uplink" value={uplinkUrl} /><div className="table-actions" style={{ marginTop: 12 }}><Button onClick={() => void saveWebhook('uplink')}>保存</Button>{webhooks.some((item) => item.eventType === 'uplink') ? <Button onClick={() => void saveWebhook('uplink', true)} variant="ghost">轮换签名密钥</Button> : null}</div></div></div>;
|
||||
|
||||
const docsPanel = <div className="page-stack"><div className="surface" style={{ padding: 18 }}><h3><BookOpen size={17} /> 鉴权规则</h3><p>每次请求携带 <code>X-App-Key</code>、<code>X-Timestamp</code>、<code>X-Nonce</code>、<code>X-Signature</code>。签名原文为:</p><pre>{`METHOD
|
||||
/api/openapi/v1/...
|
||||
TIMESTAMP
|
||||
NONCE
|
||||
SHA256(rawBody)`}</pre><p>使用访问密钥执行 HMAC-SHA256,输出小写十六进制。单发还必须携带 <code>Idempotency-Key</code>。</p></div>
|
||||
<div className="surface" style={{ padding: 18 }}><h3>接口清单</h3><pre>{`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}`}</pre><p className="muted">完整 OpenAPI 文档:<a href="/api/client-docs" rel="noreferrer" target="_blank">/api/client-docs</a></p></div>
|
||||
<div className="surface" style={{ padding: 18 }}><h3>回调验签</h3><p>回调请求头包含 X-Event-Id、X-Event-Type、X-Timestamp、X-Signature。签名原文为 <code>TIMESTAMP + '\\n' + rawBody</code>,同样使用 HMAC-SHA256。客户系统必须按 X-Event-Id 幂等。</p></div></div>;
|
||||
|
||||
const logsPanel = <div className="page-stack"><div className="surface" style={{ padding: 16 }}><h3>最近调用</h3>{requests.map((item) => <p key={item.id}><code>{item.requestId}</code> · {item.businessCode ?? item.status} · {item.sourceIp ?? '-'} · {item.durationMs ?? '-'}ms · {new Date(item.createdAt).toLocaleString('zh-CN')}</p>)}{requests.length === 0 ? <p className="muted">暂无调用记录。</p> : null}</div>
|
||||
<div className="surface" style={{ padding: 16 }}><h3>最近回调投递</h3>{deliveries.map((item) => <div className="section-heading" key={item.id}><p><code>{item.event.eventId}</code> · {item.endpoint.eventType} · {item.status} · 尝试 {item.attemptCount} 次{item.lastError ? ` · ${item.lastError}` : ''}</p>{item.status !== 'delivered' && api?.allowClientManualRetry ? <Button icon={<RefreshCw size={14} />} onClick={() => void clientApi.retryHttpWebhookDelivery(applicationId, item.id).then(() => loadApplication(applicationId))} size="sm" variant="ghost">重投</Button> : null}</div>)}{deliveries.length === 0 ? <p className="muted">暂无回调投递记录。</p> : null}</div></div>;
|
||||
|
||||
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 <section className="page-stack"><div className="page-heading"><div><h1>接口对接</h1><p>管理 HTTP 访问凭据、回调地址、接口文档及真实投递记录。</p></div><Select label="企业应用" onChange={(event) => setApplicationId(event.target.value)} options={applications.map((item) => ({ label: item.name, value: item.id }))} value={applicationId} /></div>
|
||||
{loading ? <p className="muted">正在加载接口配置...</p> : null}{error ? <p className="form-error">{error}</p> : null}
|
||||
{revealedSecret ? <div className="surface" style={{ border: '1px solid #f59e0b', padding: 16 }}><strong>{revealedSecret.title}</strong>{revealedSecret.accessKey ? <p>Access Key:<code>{revealedSecret.accessKey}</code></p> : null}<p>Secret:<code>{revealedSecret.secret}</code></p><Button icon={<Copy size={14} />} onClick={() => void navigator.clipboard.writeText([revealedSecret.accessKey, revealedSecret.secret].filter(Boolean).join('\n'))} size="sm">复制</Button></div> : null}
|
||||
{!loading && applicationId ? <Tabs items={tabs} /> : null}
|
||||
</section>;
|
||||
}
|
||||
Reference in New Issue
Block a user