fix: implement channel group routing failover

This commit is contained in:
hectorzhao
2026-07-03 11:07:43 +08:00
parent 131f344ac4
commit a890b03163
16 changed files with 853 additions and 121 deletions
+9 -1
View File
@@ -29,6 +29,7 @@ export type AdminChannel = {
code: string;
name: string;
carrier?: string | null;
sendRegion?: string | null;
gatewayHost: string;
gatewayPort: number;
enterpriseCode?: string | null;
@@ -264,6 +265,8 @@ export type ChannelGroup = DictionaryItem & {
code: string;
name: string;
description?: string | null;
retryEnabled?: boolean;
retryTimeLimitHours?: number;
items?: Array<Record<string, unknown>>;
};
@@ -523,10 +526,12 @@ export const adminApi = {
body: JSON.stringify({ reason }),
}),
listChannelGroups: () => request<ChannelGroup[]>('/admin/channel-groups'),
createChannelGroup: (body: { code: string; name: string; description?: string; status?: string }) =>
createChannelGroup: (body: { code: string; name: string; description?: string; status?: string; retryEnabled?: boolean; retryTimeLimitHours?: number }) =>
request<ChannelGroup>('/admin/channel-groups', { method: 'POST', body: JSON.stringify(body) }),
addChannelGroupItem: (body: Record<string, unknown>) =>
request<DictionaryItem>('/admin/channel-groups/items', { method: 'POST', body: JSON.stringify(body) }),
createChannelRouteRule: (body: { tenantId?: string; applicationId: string; groupId: string; carrier: string; priority?: number; status?: string }) =>
request<DictionaryItem>('/admin/channel-route-rules', { method: 'POST', body: JSON.stringify(body) }),
listChannelReportFields: (channelId?: string) => request<ChannelReportField[]>(withQuery('/admin/channel-report-fields', { channelId })),
createChannelReportField: (body: Record<string, unknown>) =>
request<ChannelReportField>('/admin/channel-report-fields', { method: 'POST', body: JSON.stringify(body) }),
@@ -562,6 +567,9 @@ export const adminApi = {
listPhoneSegments: () => request<DictionaryItem[]>('/admin/dictionaries/phone-segments'),
createPhoneSegment: (body: { prefix: string; carrier: string; province?: string; city?: string }) =>
request<DictionaryItem>('/admin/dictionaries/phone-segments', { method: 'POST', body: JSON.stringify(body) }),
listPhoneCarrierRules: () => request<DictionaryItem[]>('/admin/dictionaries/phone-carrier-rules'),
createPhoneCarrierRule: (body: { carrier: string; pattern: string; priority?: number; status?: string; remark?: string }) =>
request<DictionaryItem>('/admin/dictionaries/phone-carrier-rules', { method: 'POST', body: JSON.stringify(body) }),
listDrainageFields: () => request<DictionaryItem[]>('/admin/dictionaries/drainage-fields'),
createDrainageField: (body: { code: string; name: string; fieldType: string; required?: boolean; status?: string; description?: string }) =>
request<DictionaryItem>('/admin/dictionaries/drainage-fields', { method: 'POST', body: JSON.stringify(body) }),
+39 -14
View File
@@ -4,13 +4,14 @@ import { useNavigate } from 'react-router-dom';
import { adminApi, type AdminChannel, type ChannelLinkLogResponse } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
type Carrier = 'mobile' | 'unicom' | 'telecom';
type Carrier = 'mobile' | 'unicom' | 'telecom' | 'all';
type ChannelStatus = 'normal' | 'stopped' | 'connecting' | 'failed';
type SmsChannel = {
id: string;
name: string;
carrier: Carrier;
sendRegion: string;
unitPrice: number;
status: ChannelStatus;
total: number;
@@ -47,6 +48,7 @@ const carrierOptions = [
{ label: '移动', value: 'mobile' },
{ label: '联通', value: 'unicom' },
{ label: '电信', value: 'telecom' },
{ label: '三网', value: 'all' },
];
const statusOptions = [
@@ -65,9 +67,7 @@ const protocolOptions = [
const regionOptions = [
{ label: '全国', value: '全国' },
{ label: '华东', value: '华东' },
{ label: '华南', value: '华南' },
{ label: '华北', value: '华北' },
...'北京,天津,河北,山西,内蒙古,辽宁,吉林,黑龙江,上海,江苏,浙江,安徽,福建,江西,山东,河南,湖北,湖南,广东,广西,海南,重庆,四川,贵州,云南,西藏,陕西,甘肃,青海,宁夏,新疆,香港,澳门,台湾'.split(',').map((province) => ({ label: province, value: province })),
];
const extensionOptions = [
@@ -81,12 +81,14 @@ const carrierLabelMap: Record<Carrier, string> = {
mobile: '移动',
unicom: '联通',
telecom: '电信',
all: '三网',
};
const carrierToneMap: Record<Carrier, 'info' | 'danger' | 'success'> = {
const carrierToneMap: Record<Carrier, 'info' | 'danger' | 'success' | 'neutral'> = {
mobile: 'info',
unicom: 'danger',
telecom: 'success',
all: 'neutral',
};
const statusLabelMap: Record<ChannelStatus, string> = {
@@ -114,7 +116,8 @@ function mapApiChannel(channel: AdminChannel): SmsChannel {
return {
id: channel.id,
name: channel.name,
carrier: channel.carrier === 'unicom' || channel.carrier === 'telecom' ? channel.carrier : 'mobile',
carrier: channel.carrier === 'unicom' || channel.carrier === 'telecom' || channel.carrier === 'all' ? channel.carrier : 'mobile',
sendRegion: channel.sendRegion ?? '全国',
unitPrice: channel.unitPrice,
status: statusMap[channel.status] ?? 'normal',
total: 0,
@@ -159,7 +162,7 @@ function ChannelFormModal({
const [name, setName] = useState(channel?.name ?? '');
const [carrier, setCarrier] = useState<Carrier>(channel?.carrier ?? 'mobile');
const [unitPrice, setUnitPrice] = useState(channel ? String(channel.unitPrice / 100) : '0.0300');
const [region, setRegion] = useState('全国');
const [region, setRegion] = useState(channel?.sendRegion ?? '全国');
const [protocol, setProtocol] = useState('CMPP');
const [gatewayHost, setGatewayHost] = useState(channel?.gatewayHost ?? '');
const [gatewayPort, setGatewayPort] = useState(channel?.gatewayPort ?? '17890');
@@ -175,6 +178,7 @@ function ChannelFormModal({
id: channel?.id ?? String(Math.floor(10000 + Math.random() * 80000)),
name: name || '新建短信通道',
carrier,
sendRegion: region,
unitPrice: Number(unitPrice || 0) * 100,
status: channel?.status ?? 'connecting',
total: channel?.total ?? 0,
@@ -212,7 +216,7 @@ function ChannelFormModal({
<Input label="* 通道名称" onChange={(event) => setName(event.target.value)} placeholder="请输入通道名称" value={name} />
<div className="sms-channel-radio-row">
<span>* </span>
{(['mobile', 'unicom', 'telecom'] as const).map((item) => (
{(['mobile', 'unicom', 'telecom', 'all'] as const).map((item) => (
<label key={item}>
<input checked={carrier === item} onChange={() => setCarrier(item)} type="radio" />
{carrierLabelMap[item]}
@@ -350,12 +354,33 @@ export function AdminChannelsPage() {
[carrier, channels, keyword, status],
);
function upsertChannel(nextChannel: SmsChannel) {
setChannels((items) => {
const exists = items.some((item) => item.id === nextChannel.id);
return exists ? items.map((item) => (item.id === nextChannel.id ? nextChannel : item)) : [nextChannel, ...items];
});
setModal(null);
async function upsertChannel(nextChannel: SmsChannel) {
if (modal?.mode === 'edit') {
setError('短信通道编辑接口待补,当前不做本地模拟保存');
return;
}
try {
const created = await adminApi.createChannel({
code: `CH-${Date.now()}`,
name: nextChannel.name,
carrier: nextChannel.carrier,
sendRegion: nextChannel.sendRegion,
gatewayHost: nextChannel.gatewayHost,
gatewayPort: Number(nextChannel.gatewayPort),
enterpriseCode: nextChannel.corpCode,
account: nextChannel.account,
passwordCipher: 'secret',
srcId: nextChannel.accessNo,
rateLimitPerSecond: 100,
unitPrice: Math.round(nextChannel.unitPrice),
status: 'active',
});
setChannels((items) => [mapApiChannel(created), ...items]);
setModal(null);
setError('');
} catch (failure) {
setError(failure instanceof Error ? failure.message : '通道创建失败');
}
}
async function toggleChannel(channel: SmsChannel) {
+74 -5
View File
@@ -10,20 +10,35 @@ type PhoneSegment = DictionaryItem & {
city?: string | null;
};
type CarrierRule = DictionaryItem & {
carrier?: string;
pattern?: string;
priority?: number;
remark?: string | null;
};
export function AdminPhoneSegmentsPage() {
const [segments, setSegments] = useState<PhoneSegment[]>([]);
const [rules, setRules] = useState<CarrierRule[]>([]);
const [activeTab, setActiveTab] = useState<'segments' | 'rules'>('segments');
const [keyword, setKeyword] = useState('');
const [creating, setCreating] = useState(false);
const [creatingRule, setCreatingRule] = useState(false);
const [prefix, setPrefix] = useState('');
const [carrier, setCarrier] = useState('中国移动');
const [province, setProvince] = useState('');
const [city, setCity] = useState('');
const [ruleCarrier, setRuleCarrier] = useState('mobile');
const [rulePattern, setRulePattern] = useState('');
const [rulePriority, setRulePriority] = useState('100');
const [ruleRemark, setRuleRemark] = useState('');
const [error, setError] = useState('');
function loadData() {
adminApi.listPhoneSegments()
.then((items) => {
setSegments(items as PhoneSegment[]);
Promise.all([adminApi.listPhoneSegments(), adminApi.listPhoneCarrierRules()])
.then(([segmentItems, ruleItems]) => {
setSegments(segmentItems as PhoneSegment[]);
setRules(ruleItems as CarrierRule[]);
setError('');
})
.catch((failure: Error) => setError(failure.message || '手机号段加载失败'));
@@ -50,6 +65,17 @@ export function AdminPhoneSegmentsPage() {
.catch((failure: Error) => setError(failure.message || '手机号段新增失败'));
}
function createCarrierRule() {
adminApi.createPhoneCarrierRule({ carrier: ruleCarrier, pattern: rulePattern, priority: Number(rulePriority) || 100, remark: ruleRemark })
.then(() => {
setRulePattern('');
setRuleRemark('');
setCreatingRule(false);
loadData();
})
.catch((failure: Error) => setError(failure.message || '运营商区分规则新增失败'));
}
const columns = useMemo<Array<TableColumn<PhoneSegment>>>(() => [
{ key: 'segment', title: '手机号段(手机号码前7位)', width: '230px', render: (record) => <strong>{record.prefix}</strong> },
{ key: 'carrier', title: '运营商', width: '150px', render: (record) => record.carrier ?? '-' },
@@ -58,6 +84,13 @@ export function AdminPhoneSegmentsPage() {
{ key: 'createdAt', title: '创建时间', width: '190px', render: (record) => record.createdAt ?? '-' },
], []);
const ruleColumns = useMemo<Array<TableColumn<CarrierRule>>>(() => [
{ key: 'carrier', title: '运营商', width: '140px', render: (record) => record.carrier ?? '-' },
{ key: 'pattern', title: '号码前缀正则', render: (record) => <strong>{record.pattern}</strong> },
{ key: 'priority', title: '优先级', width: '120px', render: (record) => record.priority ?? 100 },
{ key: 'remark', title: '备注', render: (record) => record.remark ?? '-' },
], []);
return (
<section className="page-stack admin-system-page">
<div className="page-heading">
@@ -70,11 +103,19 @@ export function AdminPhoneSegmentsPage() {
<div className="surface admin-system-toolbar">
<Input onChange={(event) => setKeyword(event.target.value)} placeholder="搜索手机号段、运营商、省份或城市" prefix={<Search size={16} />} value={keyword} />
<Button icon={<Plus size={16} />} onClick={() => setCreating(true)}></Button>
<div className="segmented-control">
<button className={activeTab === 'segments' ? 'is-active' : ''} onClick={() => setActiveTab('segments')} type="button"></button>
<button className={activeTab === 'rules' ? 'is-active' : ''} onClick={() => setActiveTab('rules')} type="button"></button>
</div>
<Button icon={<Plus size={16} />} onClick={() => activeTab === 'segments' ? setCreating(true) : setCreatingRule(true)}>
{activeTab === 'segments' ? '新增号段' : '新增规则'}
</Button>
</div>
<div className="surface admin-system-table-card">
<Table columns={columns} data={filteredSegments} emptyText="暂无手机号段" rowKey="id" />
{activeTab === 'segments'
? <Table columns={columns} data={filteredSegments} emptyText="暂无手机号段" rowKey="id" />
: <Table columns={ruleColumns} data={rules} emptyText="暂无运营商区分规则" rowKey="id" />}
</div>
<Modal
@@ -104,6 +145,34 @@ export function AdminPhoneSegmentsPage() {
<Input label="城市" onChange={(event) => setCity(event.target.value)} value={city} />
</div>
</Modal>
<Modal
footer={(
<>
<Button onClick={() => setCreatingRule(false)} variant="ghost"></Button>
<Button disabled={!rulePattern} onClick={createCarrierRule}></Button>
</>
)}
onClose={() => setCreatingRule(false)}
open={creatingRule}
title="新增运营商区分规则"
>
<div className="admin-system-modal-form">
<Select
label="运营商"
onChange={(event) => setRuleCarrier(event.target.value)}
options={[
{ label: '移动', value: 'mobile' },
{ label: '联通', value: 'unicom' },
{ label: '电信', value: 'telecom' },
]}
value={ruleCarrier}
/>
<Input label="号码前缀正则" onChange={(event) => setRulePattern(event.target.value)} placeholder="例如 ^13[4-9]" value={rulePattern} />
<Input label="优先级" onChange={(event) => setRulePriority(event.target.value)} value={rulePriority} />
<Input label="备注" onChange={(event) => setRuleRemark(event.target.value)} value={ruleRemark} />
</div>
</Modal>
</section>
);
}
+40 -3
View File
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { ArrowLeft } from 'lucide-react';
import { adminApi } from '@/api/adminApi';
import { adminApi, type ChannelGroup } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Select } from '@/components/ui';
export function AdminSmsApplicationFormPage() {
@@ -14,8 +14,18 @@ export function AdminSmsApplicationFormPage() {
const [phoneDailyLimit, setPhoneDailyLimit] = useState('10');
const [mismatchPolicy, setMismatchPolicy] = useState('manual_review');
const [ipAddress, setIpAddress] = useState('');
const [groups, setGroups] = useState<ChannelGroup[]>([]);
const [mobileGroupId, setMobileGroupId] = useState('');
const [unicomGroupId, setUnicomGroupId] = useState('');
const [telecomGroupId, setTelecomGroupId] = useState('');
const [error, setError] = useState('');
useEffect(() => {
adminApi.listChannelGroups()
.then((items) => setGroups(items.filter((item) => item.status !== 'disabled' && item.status !== 'deleted')))
.catch((failure: Error) => setError(failure.message || '通道组加载失败'));
}, []);
function goBack() {
navigate('/admin/enterprise-applications');
}
@@ -29,6 +39,15 @@ export function AdminSmsApplicationFormPage() {
setError('短信应用编辑接口待补,当前不做本地模拟保存');
return;
}
const selectedGroups = [
{ carrier: 'mobile', groupId: mobileGroupId },
{ carrier: 'unicom', groupId: unicomGroupId },
{ carrier: 'telecom', groupId: telecomGroupId },
].filter((item) => item.groupId);
if (selectedGroups.length === 0) {
setError('请至少配置一个运营商通道组');
return;
}
adminApi.createEnterpriseApplication({
tenantId: enterpriseId,
name: appName,
@@ -38,10 +57,25 @@ export function AdminSmsApplicationFormPage() {
templateMismatchMode: mismatchPolicy,
ipAllowlist: ipAddress ? [ipAddress] : [],
})
.then(goBack)
.then(async (application) => {
await Promise.all(selectedGroups.map((item, index) => adminApi.createChannelRouteRule({
tenantId: enterpriseId,
applicationId: application.id,
groupId: item.groupId,
carrier: item.carrier,
priority: (index + 1) * 10,
status: 'active',
})));
goBack();
})
.catch((failure: Error) => setError(failure.message || '短信应用保存失败'));
}
const groupOptions = [
{ label: '不配置', value: '' },
...groups.map((group) => ({ label: group.name, value: group.id })),
];
return (
<section className="page-stack admin-app-form-page">
<div className="page-heading">
@@ -73,6 +107,9 @@ export function AdminSmsApplicationFormPage() {
value={mismatchPolicy}
/>
<Input label="IP 白名单" onChange={(event) => setIpAddress(event.target.value)} placeholder="例如 192.168.1.100/32" value={ipAddress} />
<Select label="移动通道组" onChange={(event) => setMobileGroupId(event.target.value)} options={groupOptions} value={mobileGroupId} />
<Select label="联通通道组" onChange={(event) => setUnicomGroupId(event.target.value)} options={groupOptions} value={unicomGroupId} />
<Select label="电信通道组" onChange={(event) => setTelecomGroupId(event.target.value)} options={groupOptions} value={telecomGroupId} />
</div>
</section>