fix: implement channel group routing failover
This commit is contained in:
+9
-1
@@ -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) }),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user