feat: remediate HTTP API reliability and developer documentation
CSS quality / css-quality (push) Has been cancelled

This commit is contained in:
hectorzhao
2026-09-14 12:48:10 +08:00
parent d13ca0713a
commit f0e843436c
33 changed files with 3332 additions and 452 deletions
+277 -53
View File
@@ -1,11 +1,21 @@
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 { HttpDeveloperDocs } from './http-docs/HttpDeveloperDocs';
import { useEffect, useRef, useState } from 'react';
import { 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 loadSequence = useRef(0);
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
const [applicationId, setApplicationId] = useState('');
const [config, setConfig] = useState<HttpApiConfigResponse | null>(null);
@@ -15,7 +25,9 @@ export function ClientHttpApiPage() {
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 [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);
@@ -33,15 +45,20 @@ export function ClientHttpApiPage() {
}
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));
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;
const sequence = ++loadSequence.current;
setLoading(true);
setError('');
try {
@@ -51,9 +68,13 @@ export function ClientHttpApiPage() {
setRequests([]);
setDeliveries([]);
const results = await Promise.allSettled([
clientApi.getApplicationHttpApiConfig(id), clientApi.listHttpApiCredentials(id), clientApi.listHttpWebhooks(id),
clientApi.listHttpApiRequests(id), clientApi.listHttpWebhookDeliveries(id),
clientApi.getApplicationHttpApiConfig(id),
clientApi.listHttpApiCredentials(id),
clientApi.listHttpWebhooks(id),
clientApi.listHttpApiRequests(id),
clientApi.listHttpWebhookDeliveries(id),
]);
if (sequence !== loadSequence.current) return;
const [configResult, credentialsResult, webhooksResult, requestsResult, deliveriesResult] = results;
if (configResult.status === 'rejected') throw configResult.reason;
const nextConfig = configResult.value;
@@ -68,75 +89,278 @@ export function ClientHttpApiPage() {
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('部分接口记录暂时无法加载,请稍后刷新;接口概览仍可正常使用。');
if (results.some((result) => result.status === 'rejected'))
setError('部分接口记录暂时无法加载,请稍后刷新;接口概览仍可正常使用。');
} catch (failure) {
if (sequence !== loadSequence.current) return;
const message = failure instanceof Error ? failure.message : '';
setError(message.toLowerCase().includes('internal server error') ? '接口资料暂时加载失败,请稍后重试。' : message || 'HTTP接口资料加载失败');
setError(
message.toLowerCase().includes('internal server error')
? '接口资料暂时加载失败,请稍后重试。'
: message || 'HTTP接口资料加载失败',
);
} finally {
if (sequence === loadSequence.current) setLoading(false);
}
finally { setLoading(false); }
}
useEffect(() => { void loadApplication(applicationId); }, [applicationId]);
useEffect(() => {
setRevealedSecret(null);
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 ?? '' });
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 : '创建凭据失败'); }
} 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 });
if (saved.secret)
setRevealedSecret({
title: `${eventType === 'receipt' ? '回执' : '上行'}回调签名密钥仅展示一次`,
secret: saved.secret,
});
await loadApplication(applicationId);
} catch (failure) { setError(failure instanceof Error ? failure.message : '保存Webhook失败'); }
} catch (failure) {
setError(failure instanceof Error ? failure.message : '保存Webhook失败');
}
}
const api = config?.config;
const publicApiOrigin = httpApiPublicOrigin(config, window.location.origin);
const overview = <div className="page-stack">
{!api?.enabled ? <p className="form-error"> HTTP </p> : null}
<div className="surface" style={{ padding: 18 }}><div className="section-heading"><div><h3>{config?.applicationName ?? '企业应用'}</h3><p className="muted">{publicApiOrigin}/api/openapi/v1</p></div><Button disabled={!api?.enabled} icon={<Copy size={14} />} onClick={() => void copyHttpParams()} size="sm">{paramsCopied ? '已复制' : '复制HTTP参数'}</Button></div><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 overview = (
<div className="page-stack">
{!api?.enabled ? <p className="form-error"> HTTP </p> : null}
<div className="surface" style={{ padding: 18 }}>
<div className="section-heading">
<div>
<h3>{config?.applicationName ?? '企业应用'}</h3>
<p className="muted">{publicApiOrigin}/api/openapi/v1</p>
</div>
<Button disabled={!api?.enabled} icon={<Copy size={14} />} onClick={() => void copyHttpParams()} size="sm">
{paramsCopied ? '已复制' : '复制HTTP参数'}
</Button>
</div>
<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 client-http-credential-row" key={item.id}><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 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 client-http-credential-row" key={item.id}>
<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 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={`${publicApiOrigin}/api/client-docs`} rel="noreferrer" target="_blank">{publicApiOrigin}/api/client-docs</a></p></div>
<div className="surface" style={{ padding: 18 }}><h3></h3><p> X-Event-IdX-Event-TypeX-TimestampX-Signature <code>TIMESTAMP + '\\n' + rawBody</code>使 HMAC-SHA256 X-Event-Id </p></div></div>;
const docsPanel = <HttpDeveloperDocs config={config} loading={loading} applicationId={applicationId} />;
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 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: '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 copyText([revealedSecret.accessKey, revealedSecret.secret].filter(Boolean).join('\n')).catch((failure: Error) => setError(failure.message))} size="sm"></Button></div> : null}
{!loading && applicationId ? <Tabs items={tabs} /> : null}
</section>;
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 copyText([revealedSecret.accessKey, revealedSecret.secret].filter(Boolean).join('\n')).catch(
(failure: Error) => setError(failure.message),
)
}
size="sm"
>
</Button>
</div>
) : null}
{applicationId ? <Tabs items={tabs} /> : docsPanel}
</section>
);
}