import { useEffect, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { ArrowLeft, Globe2, Info, RadioTower, RefreshCw } from 'lucide-react'; import { adminApi, type ChannelGroup, type DictionaryItem, type EnterpriseApplication, type HttpApiConfig } from '@/api/adminApi'; import { Breadcrumb, Button, Input, Select, Tag } from '@/components/ui'; import { isValidMoneyInput, moneyUnitsToYuan, yuanToMoneyUnits } from '@/utils/currency'; type Carrier = 'mobile' | 'unicom' | 'telecom'; type QueuePriority = 'normal' | 'priority'; type InterfaceType = 'cmpp20'; const carrierMeta: Record = { mobile: { label: '移动', description: '移动号码只会进入移动通道组' }, unicom: { label: '联通', description: '联通号码只会进入联通通道组' }, telecom: { label: '电信', description: '电信号码只会进入电信通道组' }, }; const deliveryModeOptions = [ { label: '仅 CMPP', value: 'cmpp' }, { label: '仅 HTTP', value: 'http' }, { label: 'CMPP + HTTP 双投', value: 'both' }, { label: '不投递', value: 'none' }, ]; const httpCapabilityOptions: Array<{ key: keyof HttpApiConfig; label: string }> = [ { key: 'sendEnabled', label: '单条发送' }, { key: 'messageQueryEnabled', label: '状态查询' }, { key: 'receiptWebhookEnabled', label: '回执回调' }, { key: 'uplinkQueryEnabled', label: '上行查询' }, { key: 'uplinkWebhookEnabled', label: '上行回调' }, { key: 'credentialSelfServiceEnabled', label: '客户端自助密钥' }, ]; export function AdminSmsApplicationFormPage() { const navigate = useNavigate(); const { enterpriseId, appId } = useParams(); const isEdit = Boolean(appId); const [appName, setAppName] = useState(''); const [scene, setScene] = useState('行业通知'); const [dailyLimit, setDailyLimit] = useState('100000'); const [customerUnitPrice, setCustomerUnitPrice] = useState('0.0300'); const [queuePriority, setQueuePriority] = useState('normal'); const [cmppAccount, setCmppAccount] = useState(''); const [applicationExtension, setApplicationExtension] = useState(''); const [accessNumberFillEnabled, setAccessNumberFillEnabled] = useState(false); const [accessNumberFillPrefix, setAccessNumberFillPrefix] = useState(''); const [passwordCipher, setPasswordCipher] = useState(() => generateApplicationPassword()); const [interfaceEnabled, setInterfaceEnabled] = useState(true); const [interfaceType, setInterfaceType] = useState('cmpp20'); const [cmppMaxConnections, setCmppMaxConnections] = useState('1'); const [phoneDailyLimit, setPhoneDailyLimit] = useState('10'); const [mismatchPolicy, setMismatchPolicy] = useState('manual_review'); const [downstreamReceiptRetryEnabled, setDownstreamReceiptRetryEnabled] = useState(true); const [downstreamUplinkRetryEnabled, setDownstreamUplinkRetryEnabled] = useState(true); const [ipAddress, setIpAddress] = useState(''); const [httpConfig, setHttpConfig] = useState({ enabled: false, sendEnabled: true, messageQueryEnabled: true, receiptWebhookEnabled: true, uplinkWebhookEnabled: true, uplinkQueryEnabled: true, credentialSelfServiceEnabled: true, qpsLimit: 10, timestampToleranceSeconds: 300, maxCredentialCount: 2, uplinkRetentionDays: 90, maxQueryRangeDays: 31, maxPageSize: 100, receiptDeliveryMode: 'http', uplinkDeliveryMode: 'http', webhookRetryEnabled: true, webhookMaxAttempts: 7, webhookTimeoutSeconds: 10, requireHttps: true, allowClientManualRetry: true, allowClientTest: true, }); const [httpIpAddress, setHttpIpAddress] = useState(''); const [groups, setGroups] = useState([]); const [mobileGroupId, setMobileGroupId] = useState(''); const [unicomGroupId, setUnicomGroupId] = useState(''); const [telecomGroupId, setTelecomGroupId] = useState(''); const [error, setError] = useState(''); const [saving, setSaving] = useState(false); useEffect(() => { let cancelled = false; async function loadForm() { try { const [groupItems, application, routeRules] = await Promise.all([ adminApi.listChannelGroups(), isEdit && appId ? adminApi.getEnterpriseApplication(appId) : Promise.resolve(null), isEdit ? adminApi.listChannelRouteRules() : Promise.resolve([]), ]); if (cancelled) { return; } setGroups(groupItems.filter((item) => item.status !== 'disabled' && item.status !== 'deleted')); if (application) { if (enterpriseId && application.tenantId !== enterpriseId) { setError('应用不属于当前企业,已停止加载'); return; } hydrateApplication(application, routeRules); } } catch (failure) { if (!cancelled) { setError(failure instanceof Error ? failure.message : '短信应用加载失败'); } } } void loadForm(); return () => { cancelled = true; }; }, [appId, enterpriseId, isEdit]); useEffect(() => { if (!appId) return; let cancelled = false; adminApi.getApplicationHttpApiConfig(appId).then((result) => { if (cancelled) return; if (result.config) setHttpConfig(result.config); setHttpIpAddress(result.ipAllowlist.join('\n')); }).catch((failure: Error) => { if (!cancelled) setError(failure.message || 'HTTP接口配置加载失败'); }); return () => { cancelled = true; }; }, [appId]); function goBack() { navigate('/admin/enterprise-applications'); } function hydrateApplication(application: EnterpriseApplication, routeRules: DictionaryItem[]) { setAppName(application.name); setScene(application.scene ?? ''); setDailyLimit(application.dailyLimit ? String(application.dailyLimit) : ''); setCustomerUnitPrice(moneyUnitsToYuan(application.customerUnitPrice).toFixed(4)); setQueuePriority(application.queuePriority === 'priority' ? 'priority' : 'normal'); setCmppAccount(application.cmppAccount ?? ''); setApplicationExtension(application.cmppApplicationExtension ?? ''); setAccessNumberFillEnabled(application.cmppAccessNumberFillEnabled === true); setAccessNumberFillPrefix(application.cmppAccessNumberFillPrefix ?? ''); setPasswordCipher(''); setInterfaceEnabled(application.interfaceEnabled !== false); setInterfaceType('cmpp20'); setCmppMaxConnections(String(application.cmppMaxConnections ?? 1)); setPhoneDailyLimit(application.maxPhonesPerTask ? String(application.maxPhonesPerTask) : ''); setMismatchPolicy(application.templateMismatchMode ?? 'reject'); setDownstreamReceiptRetryEnabled(application.downstreamReceiptRetryEnabled !== false); setDownstreamUplinkRetryEnabled(application.downstreamUplinkRetryEnabled !== false); setIpAddress(application.ipAllowlist?.map((item) => item.ipCidr).join('\n') ?? ''); const activeRules = routeRules.filter((rule) => ( rule.applicationId === application.id && rule.status !== 'deleted' && !rule.province && !rule.channelId )); setMobileGroupId(getRouteGroupId(activeRules, 'mobile')); setUnicomGroupId(getRouteGroupId(activeRules, 'unicom')); setTelecomGroupId(getRouteGroupId(activeRules, 'telecom')); } async function submit() { if (!enterpriseId) { setError('缺少企业 ID'); return; } const selectedGroups = [ { carrier: 'mobile' as Carrier, groupId: mobileGroupId }, { carrier: 'unicom' as Carrier, groupId: unicomGroupId }, { carrier: 'telecom' as Carrier, groupId: telecomGroupId }, ].filter((item) => item.groupId); if (selectedGroups.length === 0) { setError('请至少配置一个运营商通道组'); return; } if (!isValidMoneyInput(customerUnitPrice)) { setError('客户单价必须是非负金额,且最多保留小数点后 4 位'); return; } const normalizedExtension = applicationExtension.trim(); const normalizedFillPrefix = accessNumberFillPrefix.trim(); if (normalizedExtension && !/^\d+$/.test(normalizedExtension)) { setError('应用扩展码只能填写数字'); return; } if (accessNumberFillEnabled && !normalizedExtension) { setError('开启接入号填充时必须填写应用扩展码'); return; } if (accessNumberFillEnabled && !/^\d+$/.test(normalizedFillPrefix)) { setError('开启接入号填充时必须填写数字格式的填充前缀'); return; } if (`${accessNumberFillEnabled ? normalizedFillPrefix : ''}${normalizedExtension}`.length > 21) { setError('客户侧接入号不能超过 21 位'); return; } const payload = { name: appName, scene, dailyLimit: Number(dailyLimit) || undefined, customerUnitPrice: yuanToMoneyUnits(customerUnitPrice), queuePriority, cmppAccount: cmppAccount.trim() || undefined, cmppApplicationExtension: normalizedExtension, cmppAccessNumberFillEnabled: accessNumberFillEnabled, cmppAccessNumberFillPrefix: accessNumberFillEnabled ? normalizedFillPrefix : '', passwordCipher: passwordCipher.trim() || undefined, interfaceEnabled, interfaceType, cmppMaxConnections: Number(cmppMaxConnections) || 1, maxPhonesPerTask: Number(phoneDailyLimit) || undefined, templateMismatchMode: mismatchPolicy, downstreamReceiptRetryEnabled, downstreamUplinkRetryEnabled, ipAllowlist: parseIpAllowlist(ipAddress), }; setSaving(true); setError(''); try { const application = isEdit && appId ? await adminApi.updateEnterpriseApplication(appId, payload) : await adminApi.createEnterpriseApplication({ tenantId: enterpriseId, ...payload }); await adminApi.replaceApplicationRouteRules(application.id, { routes: selectedGroups.map((item, index) => ({ carrier: item.carrier, groupId: item.groupId, priority: (index + 1) * 10, status: 'active', })), }); await adminApi.updateApplicationHttpApiConfig(application.id, { ...httpConfig, ipAllowlist: parseIpAllowlist(httpIpAddress) }); goBack(); } catch (failure) { setError(failure instanceof Error ? failure.message : '短信应用保存失败'); } finally { setSaving(false); } } const groupOptionsByCarrier = (carrier: ChannelGroup['carrier']) => [ { label: '不配置', value: '' }, ...groups.filter((group) => group.carrier === carrier).map((group) => ({ label: group.name, value: group.id })), ]; const selectedGroupCount = [mobileGroupId, unicomGroupId, telecomGroupId].filter(Boolean).length; const clientSrcIdPreview = `${accessNumberFillEnabled ? accessNumberFillPrefix.trim() : ''}${applicationExtension.trim()}`; const routeCards: Array<{ carrier: Carrier; groupId: string; onChange: (value: string) => void }> = [ { carrier: 'mobile', groupId: mobileGroupId, onChange: setMobileGroupId }, { carrier: 'unicom', groupId: unicomGroupId, onChange: setUnicomGroupId }, { carrier: 'telecom', groupId: telecomGroupId, onChange: setTelecomGroupId }, ]; return (

短信应用和三网通道组配置写入真实后台接口。

{error ?

{error}

: null}

业务信息

先填写应用基础信息,保存后将生成真实企业应用。

setAppName(event.target.value)} placeholder="请输入应用名称" required value={appName} /> setScene(event.target.value)} placeholder="行业通知/营销推广/验证码" value={scene} /> setDailyLimit(event.target.value)} placeholder="100000" required value={dailyLimit} /> setCustomerUnitPrice(event.target.value)} placeholder="0.0300" required step="0.0001" type="number" value={customerUnitPrice} />
发送队列
优先队列会在发送调度中插队处理,但仍必须经过模板、签名、余额、通道组和通道限速校验。
setPhoneDailyLimit(event.target.value)} placeholder="10" required value={phoneDailyLimit} /> setInterfaceType('cmpp20')} type="radio" />CMPP2.0
setCmppAccount(event.target.value)} placeholder="留空自动生成" value={cmppAccount} /> setApplicationExtension(event.target.value)} placeholder="例如 0001" value={applicationExtension} />
客户接入号填充
填充前缀只用于满足客户系统的接入号长度限制;平台校验客户 Src_Id 时去掉开头前缀,上游发送时只拼接真实应用扩展码。
{accessNumberFillEnabled ? setAccessNumberFillPrefix(event.target.value)} placeholder="例如 00" required value={accessNumberFillPrefix} /> : null} setPasswordCipher(event.target.value)} placeholder="16 位接口密码" suffix={} value={passwordCipher} /> setCmppMaxConnections(event.target.value)} placeholder="1" required value={cmppMaxConnections} /> setIpAddress(event.target.value)} placeholder="多个 IP/CIDR 可用逗号、空格或换行分隔" value={ipAddress} />
CMPP 下游投递策略
首次投递始终保留;关闭后,已写出但未收到 CMPP_DELIVER_RESP 的消息不会自动重发,仍可在下游投递记录中手工重投。
) :
CMPP 接口未开通,账号、接入号和长连接参数已收起。
}

HTTP 接口配置

管理接口能力、机器鉴权、查询限制与 Webhook 投递策略。

{httpConfig.enabled ? (
HTTP 能力
{httpCapabilityOptions.map(({ key, label }) => )}
访问密钥由客户端“接口对接”页面按权限创建;HTTP 白名单与 CMPP 白名单完全独立。
setHttpIpAddress(event.target.value)} placeholder="多个 IP/CIDR 可换行填写,留空表示不限制" value={httpIpAddress} /> setHttpConfig((current) => ({ ...current, qpsLimit: Number(event.target.value) || 1 }))} value={String(httpConfig.qpsLimit)} /> setHttpConfig((current) => ({ ...current, timestampToleranceSeconds: Number(event.target.value) || 300 }))} value={String(httpConfig.timestampToleranceSeconds)} /> setHttpConfig((current) => ({ ...current, maxCredentialCount: Number(event.target.value) || 2 }))} value={String(httpConfig.maxCredentialCount)} /> setHttpConfig((current) => ({ ...current, uplinkDeliveryMode: event.target.value as HttpApiConfig['uplinkDeliveryMode'] }))} options={deliveryModeOptions} value={httpConfig.uplinkDeliveryMode} /> setHttpConfig((current) => ({ ...current, webhookTimeoutSeconds: Number(event.target.value) || 10 }))} value={String(httpConfig.webhookTimeoutSeconds)} /> setHttpConfig((current) => ({ ...current, webhookMaxAttempts: Number(event.target.value) || 7 }))} value={String(httpConfig.webhookMaxAttempts)} />
HTTP 安全与重试
) :
HTTP 接口未开通,能力、鉴权和 Webhook 参数已收起。
}

运营商通道组

至少选择一个运营商通道组;每个运营商只能绑定同运营商通道组。

0 ? 'success' : 'warning'}>{selectedGroupCount}/3 已配置
{routeCards.map((card) => { const available = groups.filter((group) => group.carrier === card.carrier); const meta = carrierMeta[card.carrier]; return (
{meta.label}通道组 {meta.description}
{card.groupId ? '已选择' : `${available.length} 个可选`}