fix: enforce drainage uniqueness and carrier-specific reporting

This commit is contained in:
hectorzhao
2026-09-14 18:12:38 +08:00
parent ac6449028c
commit 7f9abe3da0
26 changed files with 2637 additions and 965 deletions
+3 -5
View File
@@ -347,11 +347,9 @@ export function AdminReportTasksPage() {
render: (record) => (
<div>
<strong>{record.channel?.name ?? record.channelId}</strong>
{record.reportType !== 'drainage' ? (
<div className="muted">
{record.carrier ? <CarrierTag carrier={record.carrier} /> : '历史通道级(未拆分)'}
</div>
) : null}
<div className="muted">
{record.carrier ? <CarrierTag carrier={record.carrier} /> : '历史通道级(未拆分)'}
</div>
</div>
),
},
@@ -1,7 +1,7 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { describe, expect, it, vi } from 'vitest';
import { AdminSmsApplicationFormPage } from './AdminSmsApplicationFormPage';
import { AdminSmsApplicationFormPage, parseIpAllowlist } from './AdminSmsApplicationFormPage';
vi.mock('@/api/adminApi', () => ({
adminApi: {
@@ -44,3 +44,12 @@ describe('application form feedback', () => {
expect(dialog).toBeInTheDocument();
});
});
it('accepts comma separated HTTP IP entries including IPv6 and CIDR', () => {
expect(parseIpAllowlist('203.0.113.1, 203.0.113.0/242001:db8::1\n2001:db8::/64')).toEqual([
'203.0.113.1',
'203.0.113.0/24',
'2001:db8::1',
'2001:db8::/64',
]);
});
@@ -595,7 +595,7 @@ export function AdminSmsApplicationFormPage() {
<Input
label="HTTP IP 白名单"
onChange={(event) => setHttpIpAddress(event.target.value)}
placeholder="多个 IP/CIDR 可换行填写,留空表示不限制"
placeholder="多个 IP/CIDR 可用英文逗号、中文逗号或空白分隔,留空表示不限制"
value={httpIpAddress}
/>
<Input
@@ -779,7 +779,7 @@ function getRouteGroupId(routeRules: DictionaryItem[], carrier: Carrier) {
return typeof rule?.groupId === 'string' ? rule.groupId : '';
}
function parseIpAllowlist(value: string) {
export function parseIpAllowlist(value: string) {
return value
.split(/[\s,]+/)
.map((item) => item.trim())
@@ -0,0 +1,57 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { adminApi, type ClientSmsSignature } from '@/api/adminApi';
import { DrainageReportStatusModal } from './SignatureReportModals';
import type { DrainageInfo } from './signature.types';
vi.mock('@/api/adminApi', () => ({ adminApi: { changeReportTaskStatuses: vi.fn().mockResolvedValue([]) } }));
describe('drainage report carrier form', () => {
it('submits three separate carrier decisions and keeps failures visible', async () => {
const item = { id: 'd', url: 'example.com' } as DrainageInfo;
const signature = {
id: 's',
name: '【测试】',
drainageReportTargets: {
d: ['mobile', 'unicom', 'telecom'].map((carrier) => ({
channelId: 'c',
channel: { id: 'c', name: '三网通道' },
carrier,
status: 'pending',
})),
},
} as unknown as ClientSmsSignature;
const saved = vi.fn();
render(<DrainageReportStatusModal item={item} signature={signature} onClose={() => {}} onSaved={saved} />);
fireEvent.click(screen.getByLabelText('三网通道移动报备状态'));
fireEvent.click(screen.getByRole('option', { name: '报备通过' }));
fireEvent.click(screen.getByLabelText('三网通道联通报备状态'));
fireEvent.click(screen.getByRole('option', { name: '报备失败' }));
fireEvent.click(screen.getByRole('button', { name: '保存状态' }));
await waitFor(() => expect(saved).toHaveBeenCalled());
expect(vi.mocked(adminApi.changeReportTaskStatuses).mock.calls[0][0].items).toEqual([
{
signatureId: 's',
drainageItemId: 'd',
reportType: 'drainage',
channelId: 'c',
carrier: 'mobile',
status: 'approved',
},
{
signatureId: 's',
drainageItemId: 'd',
reportType: 'drainage',
channelId: 'c',
carrier: 'unicom',
status: 'failed',
},
{
signatureId: 's',
drainageItemId: 'd',
reportType: 'drainage',
channelId: 'c',
carrier: 'telecom',
status: 'pending',
},
]);
});
});
@@ -2,69 +2,280 @@ import { useState } from 'react';
import { Info } from 'lucide-react';
import { adminApi, type ClientSmsSignature } from '@/api/adminApi';
import { Button, CarrierTag, Modal, Select, Textarea } from '@/components/ui';
import { carrierLabel, CarrierReportTag } from './signature.helpers';
import { carrierLabel } from './signature.helpers';
import type { DrainageInfo } from './signature.types';
const reportStatusOptions = [
{ label: '未报备', value: 'pending' }, { label: '资料待补充', value: 'waiting_material' },
{ label: '报备中', value: 'reporting' }, { label: '报备通过', value: 'approved' },
{ label: '报备失败', value: 'failed' }, { label: '放弃报备', value: 'abandoned' },
{ label: '未报备', value: 'pending' },
{ label: '资料待补充', value: 'waiting_material' },
{ label: '报备', value: 'reporting' },
{ label: '报备通过', value: 'approved' },
{ label: '报备失败', value: 'failed' },
{ label: '放弃报备', value: 'abandoned' },
];
export function ChannelReportStatusModal({ item, onClose, onSaved }: { item: ClientSmsSignature; onClose: () => void; onSaved: () => void }) {
export function ChannelReportStatusModal({
item,
onClose,
onSaved,
}: {
item: ClientSmsSignature;
onClose: () => void;
onSaved: () => void;
}) {
const targets = item.reportTargets ?? [];
const carriers = ['mobile', 'unicom', 'telecom'] as const;
const [statuses, setStatuses] = useState<Record<string, string>>(() => Object.fromEntries(targets.map((target) => [`${target.channelId}:${target.carrier}`, target.status])));
const [statuses, setStatuses] = useState<Record<string, string>>(() =>
Object.fromEntries(targets.map((target) => [`${target.channelId}:${target.carrier}`, target.status])),
);
const [reason, setReason] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
async function save() {
setSaving(true);
try {
await adminApi.changeReportTaskStatuses({ items: targets.map((target) => ({ signatureId: item.id, channelId: target.channelId, carrier: target.carrier, status: statuses[`${target.channelId}:${target.carrier}`] ?? target.status })), reason, sourceEntry: 'enterprise_signature' });
await adminApi.changeReportTaskStatuses({
items: targets.map((target) => ({
signatureId: item.id,
channelId: target.channelId,
carrier: target.carrier,
status: statuses[`${target.channelId}:${target.carrier}`] ?? target.status,
})),
reason,
sourceEntry: 'enterprise_signature',
});
onSaved();
} catch (failure) { setError(failure instanceof Error ? failure.message : '报备状态保存失败'); } finally { setSaving(false); }
} catch (failure) {
setError(failure instanceof Error ? failure.message : '报备状态保存失败');
} finally {
setSaving(false);
}
}
return <Modal footer={<><Button onClick={onClose} variant="ghost"></Button><Button disabled={!targets.length || saving} onClick={() => void save()}>{saving ? '保存中...' : '保存状态'}</Button></>} onClose={onClose} open size="xl" title="修改签名报备状态">
<div className="signature-report-status"><div className="signature-report-status__context"><strong>{item.name}</strong><span>{item.tenant?.name ?? item.tenantId} · {item.application?.name ?? '-'}</span></div><div className="signature-alert"><Info size={18} /><span></span></div>
{error ? <p className="form-error">{error}</p> : null}
{targets.length ? <div className="signature-report-status__carriers">{carriers.map((carrier) => { const carrierTargets = targets.filter((target) => target.carrier === carrier); return <section className="signature-report-status__carrier" key={carrier}><header><CarrierTag carrier={carrier} /><span>{carrierTargets.length} </span></header><div className="signature-report-status__list">{carrierTargets.length ? carrierTargets.map((target) => { const key = `${target.channelId}:${target.carrier}`; return <div className="signature-report-status__row" key={key}><strong title={target.channel.name}>{target.channel.name}</strong><Select aria-label={`${target.channel.name}${carrierLabel(target.carrier)}报备状态`} onChange={(event) => setStatuses((current) => ({ ...current, [key]: event.target.value }))} options={reportStatusOptions} value={statuses[key] ?? target.status} /></div>; }) : <div className="signature-report-status__empty">{carrierLabel(carrier)}</div>}</div></section>; })}</div> : <div className="empty-state"></div>}
<Textarea label="修改原因" onChange={(event) => setReason(event.target.value)} placeholder="请输入运营商工单、确认依据或人工处理说明" rows={3} value={reason} />
</div>
</Modal>;
}
export function DrainageReportStatusModal({ item, onClose, onSaved, signature }: { item: DrainageInfo; onClose: () => void; onSaved: () => void; signature: ClientSmsSignature }) {
const targets = signature.drainageReportTargets?.[item.id] ?? [];
const [statuses, setStatuses] = useState<Record<string, string>>(() => Object.fromEntries(targets.map((target) => [target.channelId, target.status])));
const [reason, setReason] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
async function save() {
setSaving(true);
try {
await adminApi.changeReportTaskStatuses({ items: targets.map((target) => ({ signatureId: signature.id, channelId: target.channelId, reportType: 'drainage', drainageItemId: item.id, status: statuses[target.channelId] ?? target.status })), reason, sourceEntry: 'enterprise_signature' });
onSaved();
} catch (failure) { setError(failure instanceof Error ? failure.message : '引流报备状态保存失败'); } finally { setSaving(false); }
}
return <Modal footer={<><Button onClick={onClose} variant="ghost"></Button><Button disabled={!targets.length || saving} onClick={() => void save()}>{saving ? '保存中...' : '保存状态'}</Button></>} onClose={onClose} open size="xl" title="按通道修改引流信息报备状态">
<div className="page-stack"><div className="signature-alert"><Info size={18} /><span></span></div>
{error ? <p className="form-error">{error}</p> : null}
{targets.length ? targets.map((target) => <div className="surface admin-report-target-row" key={target.channelId}><div><strong>{target.channel.name}</strong><div className="muted">{carrierLabel(target.channel.carrier)} · {target.channel.name}</div></div><Select onChange={(event) => setStatuses((current) => ({ ...current, [target.channelId]: event.target.value }))} options={reportStatusOptions} value={statuses[target.channelId] ?? target.status} /></div>) : <div className="empty-state"></div>}
<Textarea label="修改原因" onChange={(event) => setReason(event.target.value)} rows={3} value={reason} />
</div>
</Modal>;
}
export function ConfirmModal({ message, onCancel, onConfirm }: { message: string; onCancel: () => void; onConfirm: () => void }) {
return (
<Modal
footer={(
footer={
<>
<Button onClick={onCancel} variant="ghost"></Button>
<Button onClick={onConfirm} variant="danger"></Button>
<Button onClick={onClose} variant="ghost">
</Button>
<Button disabled={!targets.length || saving} onClick={() => void save()}>
{saving ? '保存中...' : '保存状态'}
</Button>
</>
)}
}
onClose={onClose}
open
size="xl"
title="修改签名报备状态"
>
<div className="signature-report-status">
<div className="signature-report-status__context">
<strong>{item.name}</strong>
<span>
{item.tenant?.name ?? item.tenantId} · {item.application?.name ?? '-'}
</span>
</div>
<div className="signature-alert">
<Info size={18} />
<span></span>
</div>
{error ? <p className="form-error">{error}</p> : null}
{targets.length ? (
<div className="signature-report-status__carriers">
{carriers.map((carrier) => {
const carrierTargets = targets.filter((target) => target.carrier === carrier);
return (
<section className="signature-report-status__carrier" key={carrier}>
<header>
<CarrierTag carrier={carrier} />
<span>{carrierTargets.length} </span>
</header>
<div className="signature-report-status__list">
{carrierTargets.length ? (
carrierTargets.map((target) => {
const key = `${target.channelId}:${target.carrier}`;
return (
<div className="signature-report-status__row" key={key}>
<strong title={target.channel.name}>{target.channel.name}</strong>
<Select
aria-label={`${target.channel.name}${carrierLabel(target.carrier)}报备状态`}
onChange={(event) =>
setStatuses((current) => ({ ...current, [key]: event.target.value }))
}
options={reportStatusOptions}
value={statuses[key] ?? target.status}
/>
</div>
);
})
) : (
<div className="signature-report-status__empty">{carrierLabel(carrier)}</div>
)}
</div>
</section>
);
})}
</div>
) : (
<div className="empty-state"></div>
)}
<Textarea
label="修改原因"
onChange={(event) => setReason(event.target.value)}
placeholder="请输入运营商工单、确认依据或人工处理说明"
rows={3}
value={reason}
/>
</div>
</Modal>
);
}
export function DrainageReportStatusModal({
item,
onClose,
onSaved,
signature,
}: {
item: DrainageInfo;
signature: ClientSmsSignature;
onClose: () => void;
onSaved: () => void;
}) {
const targets = signature.drainageReportTargets?.[item.id] ?? [];
const carriers = ['mobile', 'unicom', 'telecom'] as const;
const [statuses, setStatuses] = useState<Record<string, string>>(() =>
Object.fromEntries(targets.map((target) => [`${target.channelId}:${target.carrier}`, target.status])),
);
const [reason, setReason] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
async function save() {
setSaving(true);
try {
await adminApi.changeReportTaskStatuses({
items: targets.map((target) => ({
signatureId: signature.id,
reportType: 'drainage',
drainageItemId: item.id,
channelId: target.channelId,
carrier: target.carrier,
status: statuses[`${target.channelId}:${target.carrier}`] ?? target.status,
})),
reason,
sourceEntry: 'enterprise_signature',
});
onSaved();
} catch (failure) {
setError(failure instanceof Error ? failure.message : '报备状态保存失败');
} finally {
setSaving(false);
}
}
return (
<Modal
footer={
<>
<Button onClick={onClose} variant="ghost">
</Button>
<Button disabled={!targets.length || saving} onClick={() => void save()}>
{saving ? '保存中...' : '保存状态'}
</Button>
</>
}
onClose={onClose}
open
size="xl"
title="修改引流报备状态"
>
<div className="signature-report-status">
<div className="signature-report-status__context">
<strong>{item.url}</strong>
<span>
{signature.name} · {signature.application?.name ?? '-'}
</span>
</div>
<div className="signature-alert">
<Info size={18} />
<span></span>
</div>
{error ? <p className="form-error">{error}</p> : null}
{targets.length ? (
<div className="signature-report-status__carriers">
{carriers.map((carrier) => {
const carrierTargets = targets.filter((target) => target.carrier === carrier);
return (
<section className="signature-report-status__carrier" key={carrier}>
<header>
<CarrierTag carrier={carrier} />
<span>{carrierTargets.length} </span>
</header>
<div className="signature-report-status__list">
{carrierTargets.length ? (
carrierTargets.map((target) => {
const key = `${target.channelId}:${target.carrier}`;
return (
<div className="signature-report-status__row" key={key}>
<strong title={target.channel.name}>
{target.channel.name}
{target.approvalScope === 'legacy_channel' ? '(继承历史通道状态)' : ''}
</strong>
<Select
aria-label={`${target.channel.name}${carrierLabel(target.carrier)}报备状态`}
onChange={(event) =>
setStatuses((current) => ({ ...current, [key]: event.target.value }))
}
options={reportStatusOptions}
value={statuses[key] ?? target.status}
/>
</div>
);
})
) : (
<div className="signature-report-status__empty">{carrierLabel(carrier)}</div>
)}
</div>
</section>
);
})}
</div>
) : (
<div className="empty-state"></div>
)}
<Textarea
label="修改原因"
onChange={(event) => setReason(event.target.value)}
placeholder="请输入运营商工单、确认依据或人工处理说明"
rows={3}
value={reason}
/>
</div>
</Modal>
);
}
export function ConfirmModal({
message,
onCancel,
onConfirm,
}: {
message: string;
onCancel: () => void;
onConfirm: () => void;
}) {
return (
<Modal
footer={
<>
<Button onClick={onCancel} variant="ghost">
</Button>
<Button onClick={onConfirm} variant="danger">
</Button>
</>
}
onClose={onCancel}
open
title="删除确认"
+11 -12
View File
@@ -19,6 +19,9 @@ type SendDetailModalProps = {
};
export function SendDetailModal({ record, segmentAudits, segmentLoading, onClose }: SendDetailModalProps) {
const visibleWordDecisions = record.channelWordDecisions?.filter(
(decision) => decision.snapshot.hits.length > 0 || Boolean(decision.snapshot.reason),
);
const routeRows = buildRouteRows(record, segmentAudits);
const channelGroupNames = Array.from(new Set(routeRows.map((route) => route.channelGroup).filter(Boolean)));
const orderedSegmentAudits = [...segmentAudits].sort((left, right) => {
@@ -47,15 +50,13 @@ export function SendDetailModal({ record, segmentAudits, segmentLoading, onClose
}
>
<div className="admin-sms-send-detail">
<section aria-label="通道筛选原因">
<h3></h3>
{record.channelWordDecisions?.length ? (
record.channelWordDecisions.map((decision) => (
{visibleWordDecisions?.length ? (
<section aria-label="通道筛选原因">
<h3></h3>
{visibleWordDecisions.map((decision) => (
<div key={decision.id}>
<p>
{getTime(decision.decidedAt)} ·{' '}
{decision.snapshot.reason ||
(decision.snapshot.hits.length ? '已排除命中通道,按剩余候选选路' : '候选通道未命中通道敏感词')}
{getTime(decision.decidedAt)} · {decision.snapshot.reason || '已排除命中通道,按剩余候选选路'}
</p>
{decision.snapshot.hits.map((hit) => (
<p key={hit.channelId}>
@@ -65,11 +66,9 @@ export function SendDetailModal({ record, segmentAudits, segmentLoading, onClose
</p>
))}
</div>
))
) : (
<p className="muted">{segmentLoading ? '加载中…' : '暂无通道敏感词选路记录'}</p>
)}
</section>
))}
</section>
) : null}
<section aria-label="引流发送资格">
<h3></h3>
{record.drainageGate ? (
@@ -5,17 +5,39 @@ import { SmsRecordList } from './SmsRecordList';
import { SendDetailModal } from './SendDetailModal';
const record = {
id: 'record-1', messageId: 'message-1', content: '请访问example.com查询', hasDrainageContent: true,
id: 'record-1',
messageId: 'message-1',
content: '请访问example.com查询',
hasDrainageContent: true,
drainageDetection: { matches: [{ start: 3, end: 14, value: 'example.com' }] },
queuedAt: '2026-08-31T01:00:00Z', deliveredAt: '2026-08-31T01:00:05Z', status: 'delivered',
amountCents: 5, billingUnits: 1, phoneNumber: '13800138000', submitRecords: [], receiptRecords: [],
queuedAt: '2026-08-31T01:00:00Z',
deliveredAt: '2026-08-31T01:00:05Z',
status: 'delivered',
amountCents: 5,
billingUnits: 1,
phoneNumber: '13800138000',
submitRecords: [],
receiptRecords: [],
} as unknown as SmsMessageRecord;
describe('SMS drainage and final receipt presentation', () => {
it('shows only a positive drainage badge under status and removes receipt time from list', () => {
const { container } = render(<SmsRecordList currentPage={1} loading={false} records={[record, { ...record, id: 'record-2', hasDrainageContent: false }]} total={2} totalPages={1} onExport={() => {}} onOpenDetail={() => {}} onPageChange={() => {}} />);
const { container } = render(
<SmsRecordList
currentPage={1}
loading={false}
records={[record, { ...record, id: 'record-2', hasDrainageContent: false }]}
total={2}
totalPages={1}
onExport={() => {}}
onOpenDetail={() => {}}
onPageChange={() => {}}
/>,
);
expect(screen.getAllByText('含引流')).toHaveLength(1);
expect(container.querySelector('.admin-sms-record-status-stack .admin-sms-record-drainage-badge')).toHaveTextContent('含引流');
expect(
container.querySelector('.admin-sms-record-status-stack .admin-sms-record-drainage-badge'),
).toHaveTextContent('含引流');
expect(screen.queryByText('不含引流')).not.toBeInTheDocument();
expect(screen.queryByText('回执时间')).not.toBeInTheDocument();
expect(screen.queryByText('09:00:05')).not.toBeInTheDocument();
@@ -30,10 +52,49 @@ describe('SMS drainage and final receipt presentation', () => {
});
it('shows negative drainage only in details and does not mislabel untested historical records', () => {
const { rerender } = render(<SendDetailModal record={{ ...record, hasDrainageContent: false }} segmentAudits={[]} segmentLoading={false} onClose={() => {}} />);
const { rerender } = render(
<SendDetailModal
record={{ ...record, hasDrainageContent: false }}
segmentAudits={[]}
segmentLoading={false}
onClose={() => {}}
/>,
);
expect(screen.getByText('不含引流')).toBeVisible();
expect(document.querySelector('mark')).toBeNull();
rerender(<SendDetailModal record={{ ...record, hasDrainageContent: undefined }} segmentAudits={[]} segmentLoading={false} onClose={() => {}} />);
rerender(
<SendDetailModal
record={{ ...record, hasDrainageContent: undefined }}
segmentAudits={[]}
segmentLoading={false}
onClose={() => {}}
/>,
);
expect(screen.getByText('未检测')).toBeVisible();
});
});
it('hides no-hit routing snapshots while retaining sensitive-word failures', () => {
const clean = { id: 'clean', decidedAt: '2026-09-14T07:41:51Z', snapshot: { hits: [], reason: null } };
const props = { segmentAudits: [], segmentLoading: false, onClose: () => {} };
const { rerender } = render(
<SendDetailModal {...props} record={{ ...record, channelWordDecisions: [clean] } as unknown as SmsMessageRecord} />,
);
expect(screen.queryByRole('region', { name: '通道筛选原因' })).not.toBeInTheDocument();
expect(screen.queryByText(/候选通道未命中/)).not.toBeInTheDocument();
rerender(
<SendDetailModal
{...props}
record={
{
...record,
channelWordDecisions: [
clean,
{ ...clean, id: 'blocked', snapshot: { hits: [], reason: '可用通道均命中通道敏感词' } },
],
} as unknown as SmsMessageRecord
}
/>,
);
expect(screen.getByText(/可用通道均命中通道敏感词/)).toBeVisible();
});