feat: restore credit limits and harden SMS sending

This commit is contained in:
hectorzhao
2026-07-14 18:46:35 +08:00
parent 3e3b7a9d1a
commit c1a17699db
23 changed files with 796 additions and 56 deletions
@@ -0,0 +1,492 @@
# CMPP 接入号上下游配置与匹配流程设计
## 1. 文档目的
本文梳理当前 CMPP 短信平台接入号的真实代码、生产数据和协议字段使用情况,并给出客户侧下游 CMPP 接入号、运营商侧上游 CMPP 接入号、号码映射、普通上行匹配和历史追溯的目标设计。
本文仅描述设计结论,不代表相关改造已经完成。后续实现仍必须基于真实 NestJS API、Prisma/PostgreSQL、Go Gateway、Redis 和生产 CMPP 链路验证,不允许使用前端静态数据、localStorage 或 mock 代替业务完成。
## 2. 结论摘要
当前系统已经具备“通道接入号下发、上行接收、模糊匹配、人工认领”的第一版链路,但尚未形成真正的应用级接入号分配和上下游映射模型。
现状核心问题如下:
1. 上游通道接入号和客户应用接入号混用。
2. 客户侧展示的接入地址、端口和接入号来自任意一条上游通道,而不是平台客户接入端点和本应用号码。
3. 客户 CMPP SUBMIT 中的 `Src_Id` 虽已传给 NestJS,但未校验是否属于当前应用,也未参与后续通道号码映射。
4. 多个应用绑定同一通道组时,上行 `Dest_Id` 会匹配到该组全部应用,无法唯一定位。
5. `extensionDigits` 目前只是配置和队列元数据,Go Gateway 没有根据它生成或匹配应用扩展号。
6. 上游 CMPP SUBMIT 的 `MsgSrc` 当前使用通道登录账号,而不是通道企业代码。
7. 普通上行没有完整保留 `Service_Id``LinkID`、原始接入号和规范化接入号。
8. 应用级 `cmppEnterpriseCode` 当前允许最长 32 字符,与 CMPP SUBMIT 中 `MsgSrc` 固定 6 字节的字段边界不一致。
生产只读检查显示:
- 当前 3 条通道的 `srcId` 均为 `1069999999`
- 其中 2 条通道为 active1 条为 disabled。
- 生产通道未配置 `serviceId` 和有效扩展位数。
- 4 个真实应用路由到同一个通道组和同一基础接入号。
- 当前 2 条普通上行的 `destId` 都是 `1069999999`,匹配状态均为 `ambiguous`
- 生产存在大量批量验证应用,不应自动分配正式生产接入号。
因此,当前共享基础接入号只能支撑模糊候选和人工认领,不能作为多应用上行自动路由的生产方案。
## 3. CMPP 字段边界
接入号设计必须区分以下概念:
| 概念 | CMPP 字段 | 含义 | 当前状态 |
| --- | --- | --- | --- |
| 登录账号 | CONNECT `Source_Addr` | 建立 CMPP TCP 会话的账号,协议字段为 6 字节 | 上下游已有账号字段 |
| 企业代码 | SUBMIT `MsgSrc` | SP 身份、地址翻译、计费和结算标识,协议字段为 6 字节 | 下游应用允许过长;上游错误使用登录账号 |
| 业务代码 | `Service_Id` | 业务类型,最长 10 字节 | 藏在通道 JSON 中,生产未配置 |
| 下发接入号 | SUBMIT `Src_Id` | 服务代码或以服务代码为前缀的长号码,最长 21 字节 | 当前只有通道级基础号码 |
| 上行接入号 | DELIVER `Dest_Id` | 用户实际回复到的服务代码或长号码 | 当前仅按通道基础号反查路由组 |
| 上行手机号 | DELIVER `Src_Terminal_Id` | 发送普通上行的用户手机号 | 当前已处理 |
| 点播关联标识 | `LinkID` | 点播类业务的关联标识 | 当前未传入 NestJS |
CMPP 2.0/3.0 协议要求:
- `MsgSrc` 和 CONNECT 登录账号是两个不同概念,即使运营商配置值相同,也必须在数据模型中分开保存。
- `Src_Id` 可以是基础服务代码,也可以是以服务代码为前缀的长号码。
- 普通上行 DELIVER 中,`Dest_Id` 是用户回复的接入号,`Src_Terminal_Id` 是用户手机号。
- 状态报告 DELIVER 和普通上行 DELIVER 必须通过 `Registered_Delivery` 严格分流。
- 普通上行自身的 `Msg_Id` 不能默认当作原下发消息 ID。
协议参考:
- [CMPP 2.0 协议](https://www.kannel.org/~tolj/specs/CMPP2/CMPP-2.0.pdf)
- [CMPP 3.0 协议](https://www.kannel.org/~tolj/specs/CMPP2/CMPP-v30.pdf)
行业监管要求端口类短信按照批准的码号结构、位长、用途、使用范围和期限使用,并保存发送时间、接收时间、发送端、接收端和内容等记录。因此系统不能因为协议字段最大长度为 21 字节,就允许任意拼接扩展码;扩展长度必须依据具体运营商合同和报备结果配置。
行业规则参考:
- [通信短信息服务管理规定](https://www.miit.gov.cn/zwgk/zcwj/flfg/art/2020/art_77bc7219833c4a08b4563ba42ad23e1f.html)
- [电信网编号计划(2017 年版)](https://www.miit.gov.cn/jgsj/xgj/wjfb/art/2020/art_eb0adf5b6e7148cbb70802b264878b1e.html)
- [电信网码号资源审批服务指南](https://qhca.miit.gov.cn/zwgk/txfz/hmzy/art/2021/art_754e60bc98664ad4a91748a37d29c6bc.html)
## 4. 当前真实实现
### 4.1 当前下发流程
1. 应用通过页面、HTTP API 或客户侧 CMPP 提交短信。
2. NestJS 根据应用、运营商、省份和通道组选择上游通道。
3. `SubmitCommand.cmpp.srcId` 直接取 `SmsChannel.srcId`
4. Go Gateway 构造运营商侧 CMPP SUBMIT。
5. 上游 `MsgSrc` 当前取通道登录账号。
6. 上游 `Src_Id` 取通道基础接入号。
7. 所有走同一通道的应用对外使用同一基础号码。
相关代码:
- `api/src/send-chain/send-chain.service.ts`:生成 `SubmitCommand`
- `gateway/internal/upstream/manager.go`:构造 CMPP 2.0/3.0 SUBMIT 报文。
### 4.2 当前客户 CMPP 入站流程
1. 客户通过 6 位账号 CONNECT。
2. Gateway 根据会话账号定位应用,并校验应用企业代码。
3. 客户 SUBMIT 的 `Src_Id` 被传给 NestJS。
4. NestJS 未校验该号码是否分配给当前应用。
5. NestJS 未把客户 `Src_Id` 保存到短信记录或提交记录。
6. 后续上游发送重新取通道 `srcId`,客户提交号码被丢弃。
相关代码:
- `gateway/internal/inbound/server.go`:客户 CMPP CONNECT/SUBMIT 解析。
- `api/src/send-chain/send-chain.service.ts``submitInboundMessage`
### 4.3 当前普通上行流程
1. 运营商通过 CMPP DELIVER 返回用户手机号和 `Dest_Id`
2. Gateway 将手机号、接入号和内容回调 NestJS。
3. NestJS 尝试按 messageId 匹配。
4. 未匹配时,根据“上行通道所属通道组”查询所有绑定该组的应用。
5. 多应用共用通道组时生成 `ambiguous` 候选。
6. 无接入号候选时,再按手机号和最近 72 小时下发记录匹配。
7. 最后进入人工认领。
当前流程能避免多候选时误推客户,但不能提供生产级接入号唯一路由。
相关代码:
- `gateway/internal/upstream/manager.go`:解析普通上行 DELIVER。
- `api/src/send-chain/send-chain.service.ts``resolveUplinkMatch`
### 4.4 当前客户 CMPP 参数接口问题
`SmsApplication` 没有应用接入号字段。`getApplicationCmppParams` 会查询最新一条未删除的 `SmsChannel`,将该上游通道的地址、端口和 `srcId` 返回给客户。
这会产生以下错误:
- 客户看到运营商侧上游网关地址,而不是平台客户接入地址。
- 不同应用得到同一个任意通道接入号。
- 上游通道新增、删除或排序变化可能改变客户展示参数。
- 应用接入参数与实际客户连接到 `8.160.169.106:17890` 的生产事实不一致。
相关代码:`api/src/sms-config/sms-config.service.ts``getApplicationCmppParams`
## 5. 目标数据模型
目标模型分为平台接入端点、上游号码能力、应用虚拟接入号和上下游绑定四层。
### 5.1 `CmppDownstreamEndpoint`
保存客户连接本平台的公共端点:
- `id`
- `name`
- `gatewayHost`
- `gatewayPort`
- `supportedVersions`
- `heartbeatSeconds`
- `status`
- `effectiveFrom`
- `effectiveTo`
生产客户参数应返回平台端点 `8.160.169.106:17890` 或后续正式域名/TLS 地址,不再读取任意上游通道。
### 5.2 `UpstreamAccessNumber`
表示运营商或上游供应商在某条通道上实际开通的号码能力:
- `id`
- `channelId`
- `enterpriseCode`:上游 `MsgSrc`,严格 6 字节。
- `serviceId`:最长 10 字节。
- `baseNumber`
- `extensionMode``none/fixed/allocated/shared`
- `allowedExtensionLengths`:由合同或报备决定。
- `maxTotalLength`:不得超过 21。
- `uplinkReturnMode``full/base_only/truncated/custom`
- `replySupported`
- `reportStatus`
- `effectiveFrom`
- `effectiveTo`
- `status`
不能把扩展位数设成全平台统一常量。不同运营商、供应商和通道允许的扩展位数可能不同,应由通道号码能力明确配置。
### 5.3 `ApplicationAccessNumber`
表示客户应用看到、提交和收到普通上行时使用的号码:
- `id`
- `tenantId`
- `applicationId`
- `endpointId`
- `accessNumber`
- `serviceId`
- `isDefault`
- `direction``mt/mo/both`
- `replyEnabled`
- `shareMode``exclusive/shared`
- `effectiveFrom`
- `effectiveTo`
- `status`
约束:
1. 同一活动号码原则上只能属于一个应用。
2. 允许共享时必须显式标记 `shared`
3. 共享号码不能仅按接入号直接自动投递。
4. 应用开放 CMPP 提交前必须至少有一个默认活动号码。
5. 客户 SUBMIT 中的 `Src_Id` 必须属于当前已鉴权应用。
### 5.4 `AccessNumberBinding`
表示客户应用号码与各上游通道号码之间的真实映射:
- `id`
- `applicationAccessNumberId`
- `channelId`
- `upstreamAccessNumberId`
- `upstreamFullNumber`
- `upstreamExtension`
- `serviceIdOverride`
- `carrier`
- `province`
- `priority`
- `matchMode`
- `effectiveFrom`
- `effectiveTo`
- `status`
关键约束:
- 可自动回复的号码,活动状态下 `(channelId, upstreamFullNumber)` 必须唯一。
- 多应用共享同一上游号码时,必须标记共享并禁止仅凭接入号自动投递。
- 激活绑定前校验号码前缀、长度、运营商、报备状态和通道状态。
- 每个需要发送的运营商/省份路由必须有可用号码绑定,否则该通道不是有效候选。
### 5.5 短信和提交快照
`SmsMessageRecord``SmsSubmitRecord` 中增加不可变快照:
- `applicationAccessNumberId`
- `accessNumberBindingId`
- `clientSrcId`
- `upstreamSrcId`
- `upstreamMsgSrc`
- `upstreamServiceId`
- `upstreamChannelId`
这样即使以后修改应用号码、通道或绑定,历史回执、上行和审计仍可按提交当时的号码关系追溯。
## 6. 目标下发流程
```mermaid
flowchart LR
A["客户应用提交短信"] --> B["CONNECT账号定位应用"]
B --> C["校验MsgSrc等于应用企业代码"]
C --> D["校验或补全客户Src_Id"]
D --> E["运营商、省份和通道组路由"]
E --> F["查询应用号码到候选通道的有效绑定"]
F --> G["生成上游MsgSrc、Service_Id、Src_Id"]
G --> H["保存客户号和上游号快照"]
H --> I["Gateway发送上游CMPP SUBMIT"]
```
详细规则:
1. CONNECT 账号只用于定位应用。
2. 客户 `MsgSrc` 必须与应用企业代码严格匹配;建议统一为 6 位 ASCII,现有超长数据需迁移或重新分配。
3. 客户 `Src_Id`
- 未填且应用只有一个默认号码时,自动补全默认号码。
- 已填写时,必须精确属于当前应用。
- 号码停用、过期、未分配或不允许下发时,返回明确的 `Src_Id` 非法错误。
- 校验失败不得进入计费、任务和上游发送队列。
4. 选出上游通道后,再查找应用号码在该通道上的有效绑定。
5. 主通道没有有效号码绑定时,可按通道组规则选择下一候选,但不能退化使用通道基础号码。
6. 上游 CMPP 报文必须使用:
- `MsgSrc = UpstreamAccessNumber.enterpriseCode`
- `Service_Id = binding.serviceIdOverride` 或上游号码默认值
- `Src_Id = AccessNumberBinding.upstreamFullNumber`
- `Dest_Terminal_Id = 用户手机号`
7. 最终客户号、上游号、企业代码、业务代码和绑定 ID 必须持久化为提交快照。
## 7. 目标普通上行流程
```mermaid
flowchart TD
A["收到上游普通DELIVER"] --> B["保存Channel、原始Dest_Id、Service_Id、LinkID"]
B --> C["按通道规则规范化Dest_Id"]
C --> D{"channelId和完整接入号唯一绑定?"}
D -- "是" --> E["定位应用和客户接入号"]
D -- "否" --> F{"通道明确配置截断或基础号回传?"}
F -- "是" --> G["手机号和近期下发号码快照辅助匹配"]
F -- "否" --> H["生成候选"]
G --> I{"得到唯一高置信结果?"}
I -- "是" --> E
I -- "否" --> H
H --> J["ambiguous或unmatched,进入人工认领"]
E --> K["转换为客户侧CMPP DELIVER"]
```
匹配优先级:
1. `(channelId, rawDestId)` 精确匹配活动绑定。
2. 按该通道明确配置的规范化、截断或别名规则匹配。
3. 手机号、通道和近期下发记录中的 `upstreamSrcId` 快照匹配。
4. 使用 `Service_Id``LinkID`、运营商、时间窗口作为辅助条件。
5. 多候选进入人工认领。
6. 完全无候选标记 `unmatched`
禁止:
- 仅因为多个应用绑定同一通道组,就把全部应用视为接入号候选。
- 选择任意 active 应用兜底。
- 对所有通道统一执行前缀匹配或截断。
- 把普通上行自身的 `Msg_Id` 当成原下发消息 ID。
- 在没有其他证据时将共享接入号自动投递给某个客户。
匹配成功后,客户侧普通上行 DELIVER 应使用:
- `Dest_Id = ApplicationAccessNumber.accessNumber`
- `Src_Terminal_Id = 用户手机号`
- `Service_Id = 客户侧业务代码`
- `LinkID = 可用时保留关联值`
原始上游号码仍需保存在数据库和运营审计中,但不能直接作为转换后的客户号码。
## 8. 配置界面设计
### 8.1 平台客户接入端点
独立配置:
- 公网域名/IP
- 端口
- CMPP 版本
- TLS 状态
- 心跳间隔
- 启停状态
### 8.2 上游通道页面
通道连接配置和号码能力分区展示:
- 登录账号 `Source_Addr`
- 企业代码 `MsgSrc`
- 默认 `Service_Id`
- 基础接入号
- 扩展模式
- 允许扩展长度
- 最大总长度
- 是否支持上行
- 上行回传模式
- 报备状态和生效时间
### 8.3 应用页面
应用 CMPP 参数区展示:
- 平台接入地址和端口
- 账号
- 密码
- 企业代码
- 协议版本
- 连接数和窗口
- 已分配客户接入号列表
- 默认号码
- 上行/下发能力
- 生效状态
### 8.4 上下游号码绑定矩阵
按应用号码、运营商、通道组和通道展示:
- 客户号码
- 上游基础号
- 上游扩展码
- 上游完整号码
- `Service_Id`
- 报备状态
- 是否支持上行
- 主备优先级
- 生效时间
绑定不完整时,不允许把对应通道标记为该应用的可用发送候选。
## 9. API 建议
建议新增或调整:
- `GET /api/admin/cmpp-downstream-endpoints`
- `POST /api/admin/cmpp-downstream-endpoints`
- `GET /api/admin/channels/:id/access-numbers`
- `POST /api/admin/channels/:id/access-numbers`
- `GET /api/admin/applications/:id/access-numbers`
- `POST /api/admin/applications/:id/access-numbers`
- `GET /api/admin/applications/:id/access-number-bindings`
- `POST /api/admin/applications/:id/access-number-bindings`
- `POST /api/admin/access-number-bindings/:id/activate`
- `POST /api/admin/access-number-bindings/:id/disable`
- `GET /api/client/applications/:id/cmpp-params`
客户参数接口返回结构建议:
```json
{
"gatewayHost": "8.160.169.106",
"gatewayPort": 17890,
"account": "123456",
"enterpriseCode": "900001",
"protocolVersion": "CMPP2.0",
"maxConnections": 1,
"windowSize": 16,
"accessNumbers": [
{
"accessNumber": "10699999990001",
"serviceId": "NOTICE",
"isDefault": true,
"replyEnabled": true,
"status": "active"
}
]
}
```
示例号码只用于说明结构,不能在未获得上游合同和报备确认时直接用于生产配置。
## 10. 生产数据迁移原则
1. 将现有 `SmsChannel.srcId=1069999999` 迁移为各通道的上游基础号码记录。
2. 从上游供应商合同或后台确认:
- 上游企业代码。
- `Service_Id`
- 允许扩展位数。
- 是否完整回传扩展号。
- 是否支持普通上行。
- 多连接或多通道是否共享同一号段。
3. 在未确认扩展规则前,不为多个应用自动生成扩展号码。
4. 现有 2 条 `ambiguous` 上行继续保留人工认领,不自动修改历史归属。
5. 大量批量验证应用不得自动分配正式号码。
6. 修复或迁移超过 6 字节的 `cmppEnterpriseCode`
7. 修复客户参数接口,不再返回任意上游通道地址和号码。
8. 对现有两条 active、配置相同的复制通道确认是否属于真实独立上游连接,避免重复通道与号码能力重复生效。
## 11. 实施优先级
### P0:数据正确性
- 新增上游号码、应用号码和绑定表。
- 修复客户 CMPP 参数接口。
- 严格校验客户 `MsgSrc``Src_Id`
- 上游 `MsgSrc` 改用通道企业代码。
- 持久化客户号码和上游号码快照。
- 上行事件补充 `Service_Id``LinkID` 和原始号码。
- 删除“通道组内所有应用都是接入号候选”的匹配路径。
### P1:配置与运营
- 增加通道号码能力配置。
- 增加应用接入号分配。
- 增加应用号码到三网通道的绑定矩阵。
- 激活前检查报备状态和三网映射完整性。
- 人工认领可生成规则建议,但不得自动修改正式绑定。
### P2:治理与指标
- 接入号精确匹配率、歧义率、未匹配率。
- 每个号码的下发量、上行量和最后活跃时间。
- 共享号码积压告警。
- 非法 `Src_Id`、未报备号码和异常截断告警。
- 接入号到期、报备失效和停用后的发送阻断。
当前数据规模不需要为接入号表提前做分区;优先保证唯一约束、有效期索引、通道和号码组合索引,以及历史快照完整性。
## 12. 验收清单
后续实现至少覆盖以下真实链路用例:
1. 客户参数接口只返回平台 CMPP 端点和当前应用号码。
2. 客户提交未分配 `Src_Id` 时被拒绝,且不计费、不入队。
3. 客户不填 `Src_Id` 且应用只有一个默认号时自动补全。
4. 同一客户号码在移动、联通、电信映射为不同上游号码。
5. 主备通道切换后使用各自绑定的上游 `Src_Id`
6. 上游 `MsgSrc` 与通道登录账号不同时仍能正常提交。
7. 完整扩展号上行能唯一匹配应用。
8. 运营商只返回基础号或截断号时,严格按该通道规则辅助匹配。
9. 两个应用共享号码时不得自动误投。
10. 普通上行和状态报告严格分流。
11. 长上行完成重组后仍按完整接入号匹配。
12. 修改号码配置后,历史消息仍按提交快照关联。
13. 接入号停用、过期或报备失效后阻断新发送,但历史记录仍可查询。
14. 上行匹配失败时真实写入 PostgreSQL 候选或未匹配记录,并支持人工认领。
15. Gateway、NestJS、PostgreSQL 和客户 CMPP 连接上的号码值可以相互核对。
## 13. 后续落地建议
建议后续另开实现批次,按以下顺序完成:
1. 先取得上游供应商的真实企业代码、业务代码、扩展位数和上行回传规则。
2. 建立 Prisma 模型、迁移和唯一约束。
3. 修复客户 CMPP 参数接口和应用企业代码校验。
4. 改造下发路由,在选中通道后解析号码绑定并保存快照。
5. 扩展 Gateway 上行事件字段。
6. 重写普通上行匹配优先级。
7. 补齐运营端配置页面和人工认领页面。
8. 使用真实 PostgreSQL、Redis、Gateway 和生产同类 CMPP 测试对端执行完整验收。