Files
lislgosms/src/apps/client/ClientHttpApiPage.tsx
T
2026-09-14 12:48:10 +08:00

367 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
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);
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;
const sequence = ++loadSequence.current;
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),
]);
if (sequence !== loadSequence.current) return;
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) {
if (sequence !== loadSequence.current) return;
const message = failure instanceof Error ? failure.message : '';
setError(
message.toLowerCase().includes('internal server error')
? '接口资料暂时加载失败,请稍后重试。'
: message || 'HTTP接口资料加载失败',
);
} finally {
if (sequence === loadSequence.current) setLoading(false);
}
}
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 ?? '',
});
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 = (
<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 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 = <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 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 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>
);
}