367 lines
14 KiB
TypeScript
367 lines
14 KiB
TypeScript
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>
|
||
);
|
||
}
|