fix: coordinate SMS completion and improve operations diagnostics
CSS quality / css-quality (push) Has been cancelled

This commit is contained in:
hectorzhao
2026-09-16 18:27:29 +08:00
parent a0209f93bc
commit a350aca883
40 changed files with 2599 additions and 366 deletions
@@ -25,8 +25,10 @@ export const adminInfrastructureMonitoringApi = {
startDate: string;
endDate: string;
}>(withQuery('/admin/infrastructure-monitoring/alert-history', { from, to, page })),
getInfrastructureMonitoringOverview: (range: InfrastructureMonitoringRange) =>
request<InfrastructureMonitoringOverview>(withQuery('/admin/infrastructure-monitoring/overview', { range })),
getInfrastructureMonitoringOverview: (range: InfrastructureMonitoringRange, signal?: AbortSignal) =>
request<InfrastructureMonitoringOverview>(withQuery('/admin/infrastructure-monitoring/overview', { range }), {
signal,
}),
getInfrastructureMonitoringNotificationSummary: (signal?: AbortSignal) =>
request<{ count: number; criticalCount: number }>('/admin/infrastructure-monitoring/notification-summary', {
signal,
@@ -38,6 +40,11 @@ export const adminInfrastructureMonitoringApi = {
method: 'PUT',
body: JSON.stringify(body),
}),
clearInfrastructureAlert: (fingerprint: string, activeAt: string) =>
request<{ cleared: boolean }>(`/admin/infrastructure-monitoring/alerts/${fingerprint}/clear`, {
method: 'POST',
body: JSON.stringify({ activeAt }),
}),
markInfrastructureAlertRead: (fingerprint: string, activeAt: string) =>
request<{ fingerprint: string; activeAt: string; acknowledged: true; acknowledgedAt: string }>(
`/admin/infrastructure-monitoring/alerts/${fingerprint}/read`,
@@ -0,0 +1,80 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AdminSystemMonitoringPage } from './AdminSystemMonitoringPage';
const api = vi.hoisted(() => ({
getInfrastructureMonitoringOverview: vi.fn(),
getInfrastructureAlertThresholds: vi.fn(),
clearInfrastructureAlert: vi.fn(),
}));
vi.mock('@/api/adminApi', () => ({ adminApi: api }));
vi.mock('@/components/ui/Chart', () => ({ Chart: () => <div></div> }));
vi.mock('./AlertHistory', () => ({ AlertHistory: () => null }));
const sample = {
available: true,
range: '24h',
collectedAt: '2026-09-16T00:00:00Z',
lastSampleAt: '2026-09-16T00:00:00Z',
summary: {
overallStatus: 'healthy',
serviceTotal: 0,
serviceHealthy: 0,
warningAlerts: 0,
criticalAlerts: 0,
activeAlerts: 0,
},
metrics: { cpuUsagePercent: 12.3 },
trends: {
cpuUsagePercent: [],
memoryUsagePercent: [],
diskUsagePercent: [],
networkReceiveBytesPerSecond: [],
networkTransmitBytesPerSecond: [],
},
services: [],
serviceMetrics: [],
disks: [],
alerts: [],
};
beforeEach(() => {
vi.clearAllMocks();
api.getInfrastructureMonitoringOverview.mockResolvedValue(sample);
api.getInfrastructureAlertThresholds.mockResolvedValue({ thresholds: {}, definitions: [], configVersion: 1 });
});
describe('monitoring snapshots', () => {
it('retains the last successful data with a visible stale warning after refresh fails, then recovers', async () => {
render(<AdminSystemMonitoringPage />);
await screen.findAllByText('12.3%');
api.getInfrastructureMonitoringOverview.mockRejectedValueOnce(new TypeError('Failed to fetch'));
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
await screen.findByText('监控数据更新失败');
expect(screen.getAllByText('12.3%').length).toBeGreaterThan(0);
expect(screen.getByRole('alert')).toHaveTextContent('上次成功数据');
expect(screen.getByRole('alert')).toHaveTextContent('并非实时状态');
api.getInfrastructureMonitoringOverview.mockResolvedValueOnce({ ...sample, metrics: { cpuUsagePercent: 45.6 } });
fireEvent(window, new Event('online'));
await screen.findAllByText('45.6%');
expect(screen.queryByText('监控数据更新失败')).not.toBeInTheDocument();
});
it('does not label a different time range with the previous range snapshot and aborts superseded requests', async () => {
render(<AdminSystemMonitoringPage />);
await screen.findAllByText('12.3%');
api.getInfrastructureMonitoringOverview.mockRejectedValueOnce(new TypeError('offline'));
fireEvent.click(screen.getByRole('button', { name: '近1小时' }));
await screen.findByText('监控数据更新失败');
expect(screen.queryByText('12.3%')).not.toBeInTheDocument();
expect(screen.getByRole('alert')).toHaveTextContent('尚无成功采样');
api.getInfrastructureMonitoringOverview.mockImplementationOnce(
(_range, signal) =>
new Promise((_resolve, reject) =>
signal.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError'))),
),
);
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
const calls = api.getInfrastructureMonitoringOverview.mock.calls;
const signal = calls[calls.length - 1]?.[1] as AbortSignal;
fireEvent.click(screen.getByRole('button', { name: '近24小时' }));
await waitFor(() => expect(signal.aborted).toBe(true));
await screen.findAllByText('12.3%');
});
});
@@ -227,6 +227,7 @@ function severityTag(severity: InfrastructureAlert['severity']) {
function makeAlertColumns(
onMarkRead: (alert: InfrastructureAlert) => void,
readingFingerprint: string,
onClear: (alert: InfrastructureAlert) => void,
): Array<TableColumn<InfrastructureAlert>> {
return [
{ key: 'severity', title: '级别', width: '82px', render: (record) => severityTag(record.severity) },
@@ -254,25 +255,42 @@ function makeAlertColumns(
render: (record) => `${record.currentValue || '—'} / ${record.threshold || '—'}`,
},
{ key: 'startedAt', title: '开始时间', width: '150px', render: (record) => formatTime(record.startedAt) },
{
key: 'status',
title: '状态',
width: '100px',
render: (record) => (record.status === 'resolved' ? '已恢复待清除' : '仍在触发'),
},
{ key: 'duration', title: '持续时间', width: '120px', render: (record) => formatDuration(record.startedAt) },
{
key: 'actions',
title: '操作',
width: '112px',
render: (record) =>
record.acknowledged ? (
<Tag tone="neutral"></Tag>
) : (
width: '180px',
render: (record) => (
<div className="table-actions">
{record.acknowledged ? (
<Tag tone="neutral"></Tag>
) : (
<Button
disabled={readingFingerprint === record.fingerprint}
icon={<CheckCircle2 size={14} />}
onClick={() => onMarkRead(record)}
size="sm"
variant="ghost"
>
{readingFingerprint === record.fingerprint ? '处理中' : '标记已读'}
</Button>
)}
<Button
disabled={readingFingerprint === record.fingerprint}
icon={<CheckCircle2 size={14} />}
onClick={() => onMarkRead(record)}
size="sm"
variant="ghost"
onClick={() => onClear(record)}
>
{readingFingerprint === record.fingerprint ? '处理中' : '标记已读'}
</Button>
),
</div>
),
},
];
}
@@ -291,25 +309,43 @@ export function AdminSystemMonitoringPage() {
const [readError, setReadError] = useState('');
const requestSequence = useRef(0);
const pendingRequests = useRef(0);
const activeRequest = useRef<AbortController | null>(null);
const snapshots = useRef<Partial<Record<InfrastructureMonitoringRange, InfrastructureMonitoringOverview>>>({});
const loadData = useCallback(
async (supersede = false) => {
if (!supersede && pendingRequests.current > 0) return;
if (supersede) activeRequest.current?.abort();
const controller = new AbortController();
activeRequest.current = controller;
const timer = window.setTimeout(() => controller.abort(), 20_000);
pendingRequests.current += 1;
const sequence = ++requestSequence.current;
setLoading(true);
setOverview(snapshots.current[range] ?? null);
try {
const result = await adminApi.getInfrastructureMonitoringOverview(range);
const result = await adminApi.getInfrastructureMonitoringOverview(range, controller.signal);
if (sequence !== requestSequence.current) return;
setOverview(result);
if (result.available) {
snapshots.current[range] = result;
setOverview(result);
} else {
setOverview(snapshots.current[range] ?? result);
}
setError(result.available ? '' : result.error || '监控数据当前不可用');
} catch (reason) {
if (sequence !== requestSequence.current) return;
setOverview(null);
setError(reason instanceof Error ? reason.message : '监控数据加载失败');
setOverview(snapshots.current[range] ?? null);
setError(
reason instanceof Error && reason.name === 'AbortError'
? '刷新超时,请检查网络后重试'
: '监控刷新失败,请检查网络或采集服务',
);
} finally {
if (sequence === requestSequence.current) setLoading(false);
pendingRequests.current -= 1;
window.clearTimeout(timer);
if (activeRequest.current === controller) activeRequest.current = null;
}
},
[range],
@@ -372,6 +408,43 @@ export function AdminSystemMonitoringPage() {
}
}, []);
const clearAlert = useCallback(
async (alert: InfrastructureAlert) => {
setReadingFingerprint(alert.fingerprint);
setReadError('');
try {
await adminApi.clearInfrastructureAlert(alert.fingerprint, alert.startedAt);
const remove = (snapshot: InfrastructureMonitoringOverview) => {
const alerts = snapshot.alerts.filter(
(item) => item.fingerprint !== alert.fingerprint || item.startedAt !== alert.startedAt,
);
return {
...snapshot,
alerts,
summary: {
...snapshot.summary,
activeAlerts: alerts.length,
criticalAlerts: alerts.filter((item) => item.severity === 'critical').length,
warningAlerts: alerts.filter((item) => item.severity === 'warning').length,
},
};
};
for (const range of Object.keys(snapshots.current) as InfrastructureMonitoringRange[]) {
const snapshot = snapshots.current[range];
if (snapshot) snapshots.current[range] = remove(snapshot);
}
setOverview((snapshot) => (snapshot ? remove(snapshot) : snapshot));
window.dispatchEvent(new Event('cmpp-infrastructure-alert-count-refresh'));
await loadData(true);
} catch (reason) {
setReadError(reason instanceof Error ? reason.message : '清除告警失败');
} finally {
setReadingFingerprint('');
}
},
[loadData],
);
useEffect(() => {
void loadData(true);
void loadSettings();
@@ -382,14 +455,17 @@ export function AdminSystemMonitoringPage() {
if (document.visibilityState === 'visible') void loadData();
};
document.addEventListener('visibilitychange', handleVisibility);
window.addEventListener('online', handleVisibility);
return () => {
requestSequence.current += 1;
activeRequest.current?.abort();
window.clearInterval(intervalId);
document.removeEventListener('visibilitychange', handleVisibility);
window.removeEventListener('online', handleVisibility);
};
}, [loadData, loadSettings]);
const status = STATUS_COPY[overview?.summary.overallStatus ?? 'unknown'];
const status = STATUS_COPY[error ? 'unknown' : (overview?.summary.overallStatus ?? 'unknown')];
const cpuOption = useMemo(
() =>
makeTrendOption({
@@ -442,10 +518,16 @@ export function AdminSystemMonitoringPage() {
const serviceTotal = overview?.summary.serviceTotal ?? 6;
const alertColumns = useMemo(
() =>
makeAlertColumns((alert) => {
void markAlertRead(alert);
}, readingFingerprint),
[markAlertRead, readingFingerprint],
makeAlertColumns(
(alert) => {
void markAlertRead(alert);
},
readingFingerprint,
(alert) => {
void clearAlert(alert);
},
),
[markAlertRead, readingFingerprint, clearAlert],
);
return (
@@ -487,8 +569,13 @@ export function AdminSystemMonitoringPage() {
<div className="system-monitoring-unavailable" role="alert">
<ShieldAlert size={20} />
<div>
<strong></strong>
<span>{error}</span>
<strong></strong>
<span>
{error}
{overview?.available
? `当前展示上次成功数据,采样时间 ${formatTime(overview.lastSampleAt ?? overview.collectedAt)},并非实时状态。`
: '尚无成功采样,请稍后重试。'}
</span>
</div>
</div>
) : null}
@@ -2,6 +2,7 @@ import { useRef, useState } from 'react';
import { Breadcrumb, Button, Input, Select, Textarea } from '@/components/ui';
import { calculateSignature, createNonce, type SignatureInput } from './signature';
import './HttpSignaturePage.css';
import { SmsRequestDebugger } from './SmsRequestDebugger';
function emptyInput(): SignatureInput {
return {
@@ -52,7 +53,9 @@ export function HttpSignaturePage() {
return (
<section className="page-stack http-signature-page">
<Breadcrumb items={['接口工具', 'HTTP签名计算']} />
<p className="muted"></p>
<p className="muted">
使
</p>
<form
className="surface http-signature-page__form"
autoComplete="off"
@@ -154,6 +157,7 @@ export function HttpSignaturePage() {
</div>
) : null}
<p role="status">{notice}</p>
<SmsRequestDebugger />
</section>
);
}
@@ -0,0 +1,69 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { SmsRequestDebugger } from './SmsRequestDebugger';
import { calculateSignature } from './signature';
afterEach(() => vi.unstubAllGlobals());
function fill() {
render(<SmsRequestDebugger />);
for (const [label, value] of [
['AccessKey', 'qa-key'],
['发送 AccessSecret', 'qa-secret'],
['手机号(mobile', '13800138000'],
['短信正文(content', '【测试】长短信中文内容'.repeat(20)],
]) {
fireEvent.change(screen.getByLabelText(label), { target: { value } });
}
}
describe('SMS request debugger', () => {
it('signs the exact UTF-8 body and displays real HTTP results without transmitting the secret', async () => {
const fetcher = vi
.fn()
.mockResolvedValue(new Response('{"code":"OK"}', { status: 202, headers: { 'X-Request-ID': 'qa-trace' } }));
vi.stubGlobal('fetch', fetcher);
fill();
fireEvent.click(screen.getByRole('button', { name: '发送短信' }));
await waitFor(() =>
expect((screen.getByLabelText('返回 HTTP 报文') as HTMLTextAreaElement).value).toContain('HTTP 202'),
);
const [path, options] = fetcher.mock.calls[0];
const headers = options.headers;
expect(headers['X-Signature']).toBe(
calculateSignature({
method: 'POST',
path,
body: options.body,
secret: 'qa-secret',
timestamp: headers['X-Timestamp'],
nonce: headers['X-Nonce'],
}).signature,
);
expect(JSON.stringify(options)).not.toContain('qa-secret');
expect(options.credentials).toBe('omit');
expect((screen.getByLabelText('返回 HTTP 报文') as HTMLTextAreaElement).value).toContain('qa-trace');
});
it('preserves the idempotency key after network uncertainty, never auto retries, and changes it when body changes', async () => {
const fetcher = vi.fn().mockRejectedValue(new TypeError('Failed to fetch'));
vi.stubGlobal('fetch', fetcher);
fill();
const key = (screen.getByLabelText('业务幂等键(Idempotency-Key') as HTMLInputElement).value;
fireEvent.click(screen.getByRole('button', { name: '发送短信' }));
await screen.findByRole('alert');
expect(screen.getByRole('alert')).toHaveTextContent('发送结果未知');
expect(fetcher).toHaveBeenCalledTimes(1);
expect(screen.getByLabelText('业务幂等键(Idempotency-Key')).toHaveValue(key);
fireEvent.change(screen.getByLabelText('短信正文(content'), { target: { value: '修改正文' } });
expect(screen.getByLabelText('业务幂等键(Idempotency-Key')).not.toHaveValue(key);
});
it('blocks duplicate clicks while a response is outstanding', async () => {
const fetcher = vi.fn().mockImplementation(() => new Promise(() => {}));
vi.stubGlobal('fetch', fetcher);
fill();
const send = screen.getByRole('button', { name: '发送短信' });
fireEvent.click(send);
fireEvent.click(send);
expect(fetcher).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,157 @@
import { useEffect, useRef, useState } from 'react';
import { Button, Input, Textarea } from '@/components/ui';
import { calculateSignature, createNonce } from './signature';
const path = '/api/openapi/v1/sms/messages';
export function SmsRequestDebugger() {
const [fields, setFields] = useState({
appKey: '',
secret: '',
mobile: '',
content: '',
clientMessageId: '',
idempotencyKey: createNonce(),
});
const [sending, setSending] = useState(false);
const [requestText, setRequestText] = useState('');
const [responseText, setResponseText] = useState('');
const [error, setError] = useState('');
const controller = useRef<AbortController | null>(null);
useEffect(() => () => controller.current?.abort(), []);
function change(key: keyof typeof fields, value: string) {
setFields((current) => ({
...current,
[key]: value,
...(key === 'idempotencyKey' ? {} : { idempotencyKey: createNonce() }),
}));
setError('');
}
async function send() {
if (controller.current) return;
if (!fields.appKey.trim() || !fields.secret || !/^1\d{10}$/.test(fields.mobile) || !fields.content.trim()) {
setError('请填写 AccessKey、AccessSecret、11位手机号和短信正文');
return;
}
if (!/^[A-Za-z0-9._:-]{8,128}$/.test(fields.idempotencyKey) || fields.clientMessageId.length > 128) {
setError('请检查业务幂等键(8~128位)及客户消息编号(最多128字符)');
return;
}
const body = JSON.stringify({
mobile: fields.mobile,
content: fields.content,
...(fields.clientMessageId ? { clientMessageId: fields.clientMessageId } : {}),
});
const timestamp = String(Math.floor(Date.now() / 1000));
const nonce = createNonce();
const { signature } = calculateSignature({ method: 'POST', path, timestamp, nonce, secret: fields.secret, body });
const headers = {
'Content-Type': 'application/json',
'X-App-Key': fields.appKey.trim(),
'X-Timestamp': timestamp,
'X-Nonce': nonce,
'X-Signature': signature,
'Idempotency-Key': fields.idempotencyKey,
};
setRequestText(
`POST ${path}\n${Object.entries(headers)
.map(([key, value]) => `${key}: ${value}`)
.join('\n')}\n\n${body}`,
);
setResponseText('');
setError('');
setSending(true);
const active = new AbortController();
controller.current = active;
const timer = window.setTimeout(() => active.abort(), 20_000);
let responseHead = '';
try {
const response = await fetch(path, {
method: 'POST',
credentials: 'omit',
redirect: 'error',
headers,
body,
signal: active.signal,
});
responseHead = `HTTP ${response.status} ${response.statusText}\n${Array.from(response.headers.entries())
.map(([key, value]) => `${key}: ${value}`)
.join('\n')}\n\n`;
const responseBody = await response.text();
setResponseText(responseHead + responseBody);
if (!response.ok) setError(`接口返回 ${response.status},请查看响应报文`);
} catch (reason) {
if (responseHead) setResponseText(responseHead + '[响应正文未完整读取]');
setError(
`${reason instanceof Error && reason.name === 'AbortError' ? '请求超时或已取消' : '网络请求失败'},发送结果未知。请先查询消息结果;如需重试,保持相同业务幂等键。`,
);
} finally {
window.clearTimeout(timer);
controller.current = null;
setSending(false);
}
}
return (
<section className="surface http-signature-page__form" aria-label="短信接口发送调试">
<h2></h2>
<p className="muted">
</p>
<div className="http-signature-page__fields">
<Input
label="AccessKey"
autoComplete="off"
disabled={sending}
value={fields.appKey}
onChange={(event) => change('appKey', event.target.value)}
/>
<Input
label="发送 AccessSecret"
type="password"
autoComplete="new-password"
disabled={sending}
value={fields.secret}
onChange={(event) => change('secret', event.target.value)}
/>
<Input
label="手机号(mobile"
disabled={sending}
value={fields.mobile}
onChange={(event) => change('mobile', event.target.value)}
/>
<Input
label="客户消息编号(clientMessageId,可选)"
disabled={sending}
value={fields.clientMessageId}
onChange={(event) => change('clientMessageId', event.target.value)}
/>
<Input
label="业务幂等键(Idempotency-Key"
disabled={sending}
value={fields.idempotencyKey}
onChange={(event) => change('idempotencyKey', event.target.value)}
/>
</div>
<Textarea
label="短信正文(content"
rows={5}
disabled={sending}
value={fields.content}
onChange={(event) => change('content', event.target.value)}
/>
<Button disabled={sending} onClick={() => void send()}>
{sending ? '发送中…' : '发送短信'}
</Button>
{error ? (
<p className="form-error" role="alert">
{error}
</p>
) : null}
{requestText ? <Textarea label="发送 HTTP 报文" rows={12} readOnly value={requestText} /> : null}
{responseText ? <Textarea label="返回 HTTP 报文" rows={12} readOnly value={responseText} /> : null}
</section>
);
}
+7 -18
View File
@@ -16,19 +16,12 @@ export function formatReceiptAmount(moneyUnits: number) {
return formatCents(Math.abs(moneyUnits));
}
export function RechargeReceiptDialog({
open,
record,
tenant,
onClose,
}: RechargeReceiptDialogProps) {
export function RechargeReceiptDialog({ open, record, tenant, onClose }: RechargeReceiptDialogProps) {
if (!record) return null;
const isCorrection = record.amountCents < 0;
const balanceAfter = record.balanceAfterCents;
const balanceBefore = balanceAfter === null || balanceAfter === undefined
? null
: balanceAfter - record.amountCents;
const balanceBefore = balanceAfter === null || balanceAfter === undefined ? null : balanceAfter - record.amountCents;
const enterpriseName = record.tenant?.name ?? tenant?.name ?? record.tenantId;
const enterpriseCode = record.tenant?.code ?? tenant?.code ?? '-';
@@ -40,13 +33,11 @@ export function RechargeReceiptDialog({
open={open}
title="账户充值回执"
>
<article className={['recharge-receipt', isCorrection ? 'recharge-receipt--correction' : ''].filter(Boolean).join(' ')}>
<article
className={['recharge-receipt', isCorrection ? 'recharge-receipt--correction' : ''].filter(Boolean).join(' ')}
>
<header className="recharge-receipt__header">
<img
alt="聆界短信平台"
className="recharge-receipt__logo"
src="/logo/logo1.png"
/>
<img alt="聆界短信平台" className="recharge-receipt__logo" src="/logo/logo1.png" />
<span className={isCorrection ? 'is-correction' : 'is-posted'}>
<CheckCircle2 aria-hidden="true" size={16} />
{isCorrection ? '已冲正' : '已入账'}
@@ -104,9 +95,7 @@ export function RechargeReceiptDialog({
<p>{record.remark || '无'}</p>
</section>
<footer className="recharge-receipt__note">
</footer>
<footer className="recharge-receipt__note"></footer>
</article>
</Modal>
);