fix: negotiate downstream cmpp protocol versions

This commit is contained in:
hectorzhao
2026-07-11 11:00:42 +08:00
parent a4d42cf702
commit bb4992f0f9
8 changed files with 276 additions and 83 deletions
@@ -69,6 +69,7 @@ function createPrismaMock() {
id: 'app-1', id: 'app-1',
tenantId: 'tenant-1', tenantId: 'tenant-1',
cmppAccount: '100001', cmppAccount: '100001',
cmppEnterpriseCode: 'SP0001',
secretHash: 'secret-hash', secretHash: 'secret-hash',
status: 'active', status: 'active',
interfaceEnabled: true, interfaceEnabled: true,
@@ -439,6 +440,20 @@ describe('SendChainService', () => {
expect(prisma.smsBatchTask.create).not.toHaveBeenCalled(); expect(prisma.smsBatchTask.create).not.toHaveBeenCalled();
}); });
it('returns the application enterprise code after Gateway authentication', async () => {
const { service } = createService();
await expect(service.authenticateInboundApplication({
account: '100001',
password: 'secret-hash',
remoteIp: '127.0.0.1',
})).resolves.toEqual(expect.objectContaining({
account: '100001',
enterpriseCode: 'SP0001',
status: 'authenticated',
}));
});
it('rejects Gateway authentication when application interface is disabled', async () => { it('rejects Gateway authentication when application interface is disabled', async () => {
const { service, prisma } = createService(); const { service, prisma } = createService();
prisma.smsApplication.findFirst.mockResolvedValue({ prisma.smsApplication.findFirst.mockResolvedValue({
+1
View File
@@ -1414,6 +1414,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
applicationId: application.id, applicationId: application.id,
tenantId: application.tenantId, tenantId: application.tenantId,
account: application.cmppAccount, account: application.cmppAccount,
enterpriseCode: application.cmppEnterpriseCode,
passwordCipher: application.secretHash, passwordCipher: application.secretHash,
status: 'authenticated', status: 'authenticated',
}; };
@@ -210,11 +210,11 @@
#### 4.8.2 下游客户 CMPP 接入能力 #### 4.8.2 下游客户 CMPP 接入能力
1. Gateway 必须监听生产 CMPP 端口 `17890`,作为平台侧 CMPP Server 接收企业客户系统连接;该端口不是 HTTP 健康检查或控制接口。 1. Gateway 必须监听生产 CMPP 端口 `17890`,作为平台侧 CMPP Server 接收企业客户系统连接;该端口不是 HTTP 健康检查或控制接口。
2. Gateway 必须按企业应用生成的 CMPP 接入参数校验客户 connect/login,包括客户侧企业代码、账号、密码、CMPP 版本、源 IP 白名单、短信接口开关、应用状态、企业状态、连接数上限;客户侧企业代码必须来自 `SmsApplication.cmppEnterpriseCode`,且 `interfaceEnabled=false` 时必须拒绝鉴权和后续 submit。 2. Gateway 必须按企业应用生成的 CMPP 接入参数校验客户 connect/login,包括客户侧企业代码、账号、密码、CMPP 版本、源 IP 白名单、短信接口开关、应用状态、企业状态、连接数上限;客户侧企业代码必须来自 `SmsApplication.cmppEnterpriseCode`,且 `interfaceEnabled=false` 时必须拒绝鉴权和后续 submit。Gateway 必须根据 CONNECT `Version` 为每条 TCP 连接独立协商 CMPP2.0/2.1/3.0 解包与响应类型,不得用固定 CMPP3.0 结构解析 CMPP2.0 Submit。
3. 客户端应用的 IP 白名单必须对 CMPP 下游连接生效;未命中白名单、应用停用、企业停用、密码错误、超过连接数上限时必须拒绝连接并记录系统日志。 3. 客户端应用的 IP 白名单必须对 CMPP 下游连接生效;未命中白名单、应用停用、企业停用、密码错误、超过连接数上限时必须拒绝连接并记录系统日志。
4. Gateway 必须维护应用级下游连接状态,回写 applicationId、tenantId、connectionId、currentConnections、desiredConnections、lastHeartbeatAt、lastError,运营端企业应用列表和连接详情必须来自这些真实状态。 4. Gateway 必须维护应用级下游连接状态,回写 applicationId、tenantId、connectionId、currentConnections、desiredConnections、lastHeartbeatAt、lastError,运营端企业应用列表和连接详情必须来自这些真实状态。
5. Gateway 必须实现下游 ActiveTest、Terminate、异常断开处理;断开后连接数和状态必须及时回写。 5. Gateway 必须实现下游 ActiveTest、Terminate、异常断开处理;断开后连接数和状态必须及时回写。
6. Gateway 必须处理客户提交的 CMPP Submit,将手机号、内容、源地址、企业应用、客户消息序号等转换为平台发送请求。 6. Gateway 必须处理客户提交的 CMPP Submit,将手机号、内容、源地址、企业应用、客户消息序号等转换为平台发送请求。应用身份必须来自当前已鉴权 TCP 连接绑定的 `cmppAccount`Submit `MsgSrc` 是独立的企业代码,必须与该应用 `cmppEnterpriseCode` 匹配,不得把 `MsgSrc` 当作登录账号查找应用。
7. 下游 CMPP Submit 进入平台后,不创建客户端批量任务,但必须按手机号维度创建 `sms_message_record`source 标记为 `cmpp`,并保留客户侧 sequence/msgId 映射。 7. 下游 CMPP Submit 进入平台后,不创建客户端批量任务,但必须按手机号维度创建 `sms_message_record`source 标记为 `cmpp`,并保留客户侧 sequence/msgId 映射。
8. 下游 CMPP Submit 必须复用 NestJS 发送前校验:企业/应用状态、IP 白名单、签名/模板报备、模板匹配策略、风控、黑名单、余额/授信、运营商识别、通道组路由。 8. 下游 CMPP Submit 必须复用 NestJS 发送前校验:企业/应用状态、IP 白名单、签名/模板报备、模板匹配策略、风控、黑名单、余额/授信、运营商识别、通道组路由。
9. 对客户 Submit 的响应必须符合 CMPP 协议:参数错误、鉴权失败、余额不足、模板或签名未通过、无可用通道、风控拒绝等应映射为明确失败状态;已接收进入平台发送链路时返回成功并生成可追踪平台 messageId。 9. 对客户 Submit 的响应必须符合 CMPP 协议:参数错误、鉴权失败、余额不足、模板或签名未通过、无可用通道、风控拒绝等应映射为明确失败状态;已接收进入平台发送链路时返回成功并生成可追踪平台 messageId。
+2
View File
@@ -1005,8 +1005,10 @@
- 预期结果: - 预期结果:
- 17890 是真实 CMPP Server 监听,不是 HTTP 端口。 - 17890 是真实 CMPP Server 监听,不是 HTTP 端口。
- bind 阶段调用真实 NestJS API 校验账号、密码、企业状态、认证状态、应用状态、短信接口开关和 IP 白名单。 - bind 阶段调用真实 NestJS API 校验账号、密码、企业状态、认证状态、应用状态、短信接口开关和 IP 白名单。
- CMPP2.0 和 CMPP3.0 连接分别返回对应版本 ConnectResp,后续 Submit/Deliver 按该 TCP 连接协商版本解包和组包,不发生字段错位。
- 密码错误、应用停用、企业停用、短信接口关闭、IP 不在白名单时 connect/login 被拒绝。 - 密码错误、应用停用、企业停用、短信接口关闭、IP 不在白名单时 connect/login 被拒绝。
- submit 被接受后返回 CMPP SubmitResp 成功,并在真实数据库创建 `sourceType=cmpp` 的发送记录,进入真实发送链路。 - submit 被接受后返回 CMPP SubmitResp 成功,并在真实数据库创建 `sourceType=cmpp` 的发送记录,进入真实发送链路。
- Submit 应用身份使用 bind 已鉴权账号;`MsgSrc` 使用应用级企业代码并独立校验。企业代码与登录账号不同时仍能正确定位应用,企业代码不匹配时返回失败。
- submit 内容不匹配审核模板、余额不足、短信接口关闭、无可用通道时返回明确失败,不得伪造成功。 - submit 内容不匹配审核模板、余额不足、短信接口关闭、无可用通道时返回明确失败,不得伪造成功。
- Gateway 对每次 submit 记录 `submit_received``submit_accepted`/`submit_rejected`;日志可按账号、IP、sequenceId、号码和 messageId 定位,拒绝时包含 NestJS 真实业务原因和 CMPP result,但不包含明文短信正文。 - Gateway 对每次 submit 记录 `submit_received``submit_accepted`/`submit_rejected`;日志可按账号、IP、sequenceId、号码和 messageId 定位,拒绝时包含 NestJS 真实业务原因和 CMPP result,但不包含明文短信正文。
- CMPP 包在进入 handler 前因长度、命令字、读包或 Unpack 失败时,Gateway 记录 `read/unpack packet failed`、远端地址、协议模式、错误类型和原始错误,不得静默断开。 - CMPP 包在进入 handler 前因长度、命令字、读包或 Unpack 失败时,Gateway 记录 `read/unpack packet failed`、远端地址、协议模式、错误类型和原始错误,不得静默断开。
+3
View File
@@ -658,6 +658,8 @@ npm run verify:phase8
- Gateway HTTP 回调在 NestJS 返回非 2xx 时保留最多 64KB 响应体,客户 Submit 失败日志可直接显示模板不匹配、IP 白名单、余额或路由等真实业务原因,不再只显示 HTTP 状态码。 - Gateway HTTP 回调在 NestJS 返回非 2xx 时保留最多 64KB 响应体,客户 Submit 失败日志可直接显示模板不匹配、IP 白名单、余额或路由等真实业务原因,不再只显示 HTTP 状态码。
- 日志不记录明文短信正文,仅记录字符数和 MD5 哈希,便于比对同一内容且避免日志泄露。 - 日志不记录明文短信正文,仅记录字符数和 MD5 哈希,便于比对同一内容且避免日志泄露。
- 将当前 gocmpp 版本固定为仓库内小型 fork,仅在 server 循环补充底层诊断:包在进入业务 handler 之前发生长度、命令字、包体读取或 Unpack 失败时,记录 `read/unpack packet failed`、远端地址、库解析协议模式、Go 错误类型和原始错误;正常 EOF 断开不记为解包失败。 - 将当前 gocmpp 版本固定为仓库内小型 fork,仅在 server 循环补充底层诊断:包在进入业务 handler 之前发生长度、命令字、包体读取或 Unpack 失败时,记录 `read/unpack packet failed`、远端地址、库解析协议模式、Go 错误类型和原始错误;正常 EOF 断开不记为解包失败。
- 生产复现确认 CMPP2.0 客户 Submit 被固定 CMPP3.0 解包导致 `MsgSrc/手机号/内容` 错位为空。现在 gocmpp server 按 CONNECT `Version` 将每条连接切换到 CMPP2.0/2.1/3.0 解包模式,并返回同版本 ConnectResp、SubmitResp 和 Deliver。
- Gateway 使用 bind 时已鉴权会话账号调用 NestJS 入站接口,Submit `MsgSrc` 改为与鉴权返回的应用级 `cmppEnterpriseCode` 独立比对,不再将企业代码误当登录账号。
### 验证状态 ### 验证状态
@@ -665,6 +667,7 @@ npm run verify:phase8
- `go test ./... -count=1`:通过。 - `go test ./... -count=1`:通过。
- `go build ./cmd/gateway`:通过。 - `go build ./cmd/gateway`:通过。
- 真实 TCP 非法包用例:向入站端口写入非法 `total_length`,确认业务 handler 未执行时仍产生 `read/unpack packet failed` 日志。 - 真实 TCP 非法包用例:向入站端口写入非法 `total_length`,确认业务 handler 未执行时仍产生 `read/unpack packet failed` 日志。
- CMPP2.0 真实集成用例:客户使用与登录账号不同的 `MsgSrc=SP0001`,完成 V20 ConnectResp、Cmpp2SubmitReq/Resp 和 Cmpp2Deliver ReceiptNestJS 收到的 account 仍为 bind 账号:通过。
## 2026-07-07 Gateway 上游提交与下游 Deliver 闭环补齐 ## 2026-07-07 Gateway 上游提交与下游 Deliver 闭环补齐
+86 -40
View File
@@ -59,6 +59,7 @@ type authResponse struct {
ApplicationID string `json:"applicationId"` ApplicationID string `json:"applicationId"`
TenantID string `json:"tenantId"` TenantID string `json:"tenantId"`
Account string `json:"account"` Account string `json:"account"`
EnterpriseCode string `json:"enterpriseCode"`
} }
type DownstreamReceipt struct { type DownstreamReceipt struct {
@@ -88,6 +89,7 @@ type DownstreamUplink struct {
type downstreamSession struct { type downstreamSession struct {
messageID string messageID string
account string account string
enterpriseCode string
protocol string protocol string
srcID string srcID string
phoneNumber string phoneNumber string
@@ -104,9 +106,11 @@ var downstreamRegistry = struct {
sync.RWMutex sync.RWMutex
byMessageID map[string]*downstreamSession byMessageID map[string]*downstreamSession
byAccount map[string]*downstreamSession byAccount map[string]*downstreamSession
byConn map[*cmpp.Conn]*downstreamSession
}{ }{
byMessageID: make(map[string]*downstreamSession), byMessageID: make(map[string]*downstreamSession),
byAccount: make(map[string]*downstreamSession), byAccount: make(map[string]*downstreamSession),
byConn: make(map[*cmpp.Conn]*downstreamSession),
} }
func (s Server) ListenAndServe() error { func (s Server) ListenAndServe() error {
@@ -128,24 +132,25 @@ func (s Server) handleLogin(response *cmpp.Response, packet *cmpp.Packet, logger
if !ok { if !ok {
return true, nil return true, nil
} }
resp := response.Packer.(*cmpp.Cmpp3ConnRspPkt)
resp.Version = 0x30
account := strings.TrimRight(req.SrcAddr, "\x00") account := strings.TrimRight(req.SrcAddr, "\x00")
if account == "" { if account == "" {
resp.Status = uint32(cmpp.ErrnoConnInvalidSrcAddr) setInboundConnectResponse(response.Packer, cmpp.ErrnoConnInvalidSrcAddr, req.AuthSrc, "", req.Version)
return false, cmpp.ConnRspStatusErrMap[cmpp.ErrnoConnInvalidSrcAddr] return false, cmpp.ConnRspStatusErrMap[cmpp.ErrnoConnInvalidSrcAddr]
} }
if req.Version != cmpp.V20 && req.Version != cmpp.V21 && req.Version != cmpp.V30 {
setInboundConnectResponse(response.Packer, cmpp.ErrnoConnVerTooHigh, req.AuthSrc, "", cmpp.V30)
return false, cmpp.ConnRspStatusErrMap[cmpp.ErrnoConnVerTooHigh]
}
auth, err := s.authenticate(packet.Conn.Conn.RemoteAddr(), account, req.AuthSrc, req.Timestamp) auth, err := s.authenticate(packet.Conn.Conn.RemoteAddr(), account, req.AuthSrc, req.Timestamp)
if err != nil { if err != nil {
logger.Printf("cmpp inbound auth failed account=%s remote=%s err=%v", account, packet.Conn.Conn.RemoteAddr(), err) logger.Printf("cmpp inbound auth failed account=%s remote=%s err=%v", account, packet.Conn.Conn.RemoteAddr(), err)
resp.Status = uint32(cmpp.ErrnoConnAuthFailed) setInboundConnectResponse(response.Packer, cmpp.ErrnoConnAuthFailed, req.AuthSrc, "", req.Version)
return false, cmpp.ConnRspStatusErrMap[cmpp.ErrnoConnAuthFailed] return false, cmpp.ConnRspStatusErrMap[cmpp.ErrnoConnAuthFailed]
} }
authSource := []byte(req.AuthSrc) setInboundConnectResponse(response.Packer, 0, req.AuthSrc, auth.PasswordCipher, req.Version)
authISMG := md5.Sum(bytes.Join([][]byte{{byte(resp.Status)}, authSource, []byte(auth.PasswordCipher)}, nil))
resp.AuthIsmg = string(authISMG[:])
session := downstreamSession{ session := downstreamSession{
account: strings.TrimSpace(defaultString(auth.Account, account)), account: strings.TrimSpace(defaultString(auth.Account, account)),
enterpriseCode: strings.TrimSpace(auth.EnterpriseCode),
protocol: cmppVersionName(req.Version), protocol: cmppVersionName(req.Version),
srcID: strings.TrimSpace(auth.Account), srcID: strings.TrimSpace(auth.Account),
remoteIP: remoteIP(packet.Conn.Conn.RemoteAddr()), remoteIP: remoteIP(packet.Conn.Conn.RemoteAddr()),
@@ -158,8 +163,8 @@ func (s Server) handleLogin(response *cmpp.Response, packet *cmpp.Packet, logger
rememberAccount(session) rememberAccount(session)
go s.flushPending(defaultString(auth.Account, account), logger) go s.flushPending(defaultString(auth.Account, account), logger)
logger.Printf( logger.Printf(
"cmpp inbound event=login_accepted protocol=%s requested_version=0x%02x response_version=0x30 account=%s remote=%s", "cmpp inbound event=login_accepted protocol=%s requested_version=0x%02x response_version=0x%02x account=%s remote=%s",
cmppVersionName(req.Version), uint8(req.Version), account, packet.Conn.Conn.RemoteAddr(), cmppVersionName(req.Version), uint8(req.Version), uint8(req.Version), account, packet.Conn.Conn.RemoteAddr(),
) )
return false, nil return false, nil
} }
@@ -169,16 +174,35 @@ func (s Server) handleSubmit(response *cmpp.Response, packet *cmpp.Packet, logge
if !ok { if !ok {
return true, nil return true, nil
} }
account := strings.TrimRight(req.msgSrc, "\x00") session := findSessionByConn(packet.Conn)
if session == nil || strings.TrimSpace(session.account) == "" {
logger.Printf(
"cmpp inbound event=submit_rejected protocol=%s packet_type=%s remote=%s seq=%d result=9 stage=session reason=%q",
req.protocol, req.protocol, packet.Conn.Conn.RemoteAddr(), req.sequenceID, "authenticated connection session not found",
)
setInboundSubmitResponse(response.Packer, 0, 9)
return false, nil
}
account := session.account
enterpriseCode := strings.TrimRight(req.msgSrc, "\x00")
if session.enterpriseCode != "" && enterpriseCode != session.enterpriseCode {
logger.Printf(
"cmpp inbound event=submit_rejected protocol=%s packet_type=%s account=%s enterprise_code=%s remote=%s seq=%d result=9 stage=protocol reason=%q",
defaultString(session.protocol, req.protocol), req.protocol, account, enterpriseCode, packet.Conn.Conn.RemoteAddr(), req.sequenceID,
fmt.Sprintf("enterprise code mismatch: expected %s", session.enterpriseCode),
)
setInboundSubmitResponse(response.Packer, 0, 9)
return false, nil
}
phone := "" phone := ""
if len(req.destTerminalIDs) > 0 { if len(req.destTerminalIDs) > 0 {
phone = strings.TrimRight(req.destTerminalIDs[0], "\x00") phone = strings.TrimRight(req.destTerminalIDs[0], "\x00")
} }
remote := packet.Conn.Conn.RemoteAddr() remote := packet.Conn.Conn.RemoteAddr()
clientProtocol := inboundClientProtocol(account, packet.Conn, req.protocol) clientProtocol := defaultString(session.protocol, req.protocol)
logger.Printf( logger.Printf(
"cmpp inbound event=submit_received protocol=%s packet_type=%s account=%s remote=%s seq=%d phone=%s src_id=%s msg_fmt=%d pk=%d/%d dest_count=%d content_bytes=%d", "cmpp inbound event=submit_received protocol=%s packet_type=%s account=%s enterprise_code=%s remote=%s seq=%d phone=%s src_id=%s msg_fmt=%d pk=%d/%d dest_count=%d content_bytes=%d",
clientProtocol, req.protocol, account, remote, req.sequenceID, phone, strings.TrimSpace(req.srcID), req.msgFmt, clientProtocol, req.protocol, account, enterpriseCode, remote, req.sequenceID, phone, strings.TrimSpace(req.srcID), req.msgFmt,
req.pkNumber, req.pkTotal, len(req.destTerminalIDs), len(req.msgContent), req.pkNumber, req.pkTotal, len(req.destTerminalIDs), len(req.msgContent),
) )
content, err := decodeContent(req.msgFmt, req.msgContent) content, err := decodeContent(req.msgFmt, req.msgContent)
@@ -218,6 +242,7 @@ func (s Server) handleSubmit(response *cmpp.Response, packet *cmpp.Packet, logge
rememberDownstream(downstreamSession{ rememberDownstream(downstreamSession{
messageID: result.MessageID, messageID: result.MessageID,
account: account, account: account,
enterpriseCode: session.enterpriseCode,
protocol: clientProtocol, protocol: clientProtocol,
srcID: strings.TrimSpace(req.srcID), srcID: strings.TrimSpace(req.srcID),
phoneNumber: phone, phoneNumber: phone,
@@ -278,14 +303,25 @@ func setInboundSubmitResponse(packet any, messageID uint64, result uint32) {
} }
} }
func inboundClientProtocol(account string, conn *cmpp.Conn, fallback string) string { func setInboundConnectResponse(packet any, status uint8, authSource string, secret string, version cmpp.Type) {
switch resp := packet.(type) {
case *cmpp.Cmpp2ConnRspPkt:
resp.Status = status
resp.AuthSrc = authSource
resp.Secret = secret
resp.Version = version
case *cmpp.Cmpp3ConnRspPkt:
resp.Status = uint32(status)
resp.AuthSrc = authSource
resp.Secret = secret
resp.Version = version
}
}
func findSessionByConn(conn *cmpp.Conn) *downstreamSession {
downstreamRegistry.RLock() downstreamRegistry.RLock()
defer downstreamRegistry.RUnlock() defer downstreamRegistry.RUnlock()
session := downstreamRegistry.byAccount[account] return downstreamRegistry.byConn[conn]
if session != nil && session.conn == conn && session.protocol != "" {
return session.protocol
}
return fallback
} }
func cmppVersionName(version cmpp.Type) string { func cmppVersionName(version cmpp.Type) string {
@@ -483,6 +519,7 @@ func rememberDownstream(session downstreamSession) {
session.touchPresence("connected", true, false) session.touchPresence("connected", true, false)
downstreamRegistry.Lock() downstreamRegistry.Lock()
downstreamRegistry.byMessageID[session.messageID] = &session downstreamRegistry.byMessageID[session.messageID] = &session
downstreamRegistry.byConn[session.conn] = &session
if session.account != "" { if session.account != "" {
downstreamRegistry.byAccount[session.account] = &session downstreamRegistry.byAccount[session.account] = &session
} }
@@ -496,6 +533,7 @@ func rememberAccount(session downstreamSession) {
session.touchPresence("connected", false, false) session.touchPresence("connected", false, false)
downstreamRegistry.Lock() downstreamRegistry.Lock()
downstreamRegistry.byAccount[session.account] = &session downstreamRegistry.byAccount[session.account] = &session
downstreamRegistry.byConn[session.conn] = &session
downstreamRegistry.Unlock() downstreamRegistry.Unlock()
} }
@@ -514,6 +552,9 @@ func forgetDownstream(session *downstreamSession) {
delete(downstreamRegistry.byAccount, session.account) delete(downstreamRegistry.byAccount, session.account)
} }
} }
if current := downstreamRegistry.byConn[session.conn]; current == session {
delete(downstreamRegistry.byConn, session.conn)
}
downstreamRegistry.Unlock() downstreamRegistry.Unlock()
_ = session.removePresence() _ = session.removePresence()
} }
@@ -700,16 +741,7 @@ func PushReceipt(event DownstreamReceipt) (bool, error) {
if err != nil { if err != nil {
return false, err return false, err
} }
deliver := &cmpp.Cmpp3DeliverReqPkt{ deliver := downstreamDeliverPacket(session, session.gatewayMsgID, session.srcID, defaultString(event.PhoneNumber, session.phoneNumber), 0, 1, string(receiptBytes))
MsgId: session.gatewayMsgID,
DestId: session.srcID,
ServiceId: "cmpp",
MsgFmt: 0,
SrcTerminalId: defaultString(event.PhoneNumber, session.phoneNumber),
RegisterDelivery: 1,
MsgLength: uint8(cmpp.CmppReceiptPktLen),
MsgContent: string(receiptBytes),
}
return sendDownstream(session, deliver) return sendDownstream(session, deliver)
} }
@@ -722,19 +754,33 @@ func PushUplink(event DownstreamUplink) (bool, error) {
if err != nil { if err != nil {
return false, err return false, err
} }
deliver := &cmpp.Cmpp3DeliverReqPkt{ deliver := downstreamDeliverPacket(
MsgId: messageIDFrom(defaultString(event.MessageID, event.Account), uint32(time.Now().UnixNano())), session,
DestId: defaultString(event.DestID, session.srcID), messageIDFrom(defaultString(event.MessageID, event.Account), uint32(time.Now().UnixNano())),
ServiceId: "cmpp", defaultString(event.DestID, session.srcID),
MsgFmt: 8, event.PhoneNumber,
SrcTerminalId: event.PhoneNumber, 8,
RegisterDelivery: 0, 0,
MsgLength: uint8(len(content)), content,
MsgContent: content, )
}
return sendDownstream(session, deliver) return sendDownstream(session, deliver)
} }
func downstreamDeliverPacket(session *downstreamSession, messageID uint64, destID string, sourceTerminalID string, msgFmt uint8, registerDelivery uint8, content string) cmpp.Packer {
if session != nil && (session.protocol == "cmpp20" || session.protocol == "cmpp21") {
return &cmpp.Cmpp2DeliverReqPkt{
MsgId: messageID, DestId: destID, ServiceId: "cmpp", MsgFmt: msgFmt,
SrcTerminalId: sourceTerminalID, RegisterDelivery: registerDelivery,
MsgLength: uint8(len(content)), MsgContent: content,
}
}
return &cmpp.Cmpp3DeliverReqPkt{
MsgId: messageID, DestId: destID, ServiceId: "cmpp", MsgFmt: msgFmt,
SrcTerminalId: sourceTerminalID, RegisterDelivery: registerDelivery,
MsgLength: uint8(len(content)), MsgContent: content,
}
}
func findSession(messageID string, account string) *downstreamSession { func findSession(messageID string, account string) *downstreamSession {
downstreamRegistry.RLock() downstreamRegistry.RLock()
defer downstreamRegistry.RUnlock() defer downstreamRegistry.RUnlock()
@@ -749,7 +795,7 @@ func findSession(messageID string, account string) *downstreamSession {
return nil return nil
} }
func sendDownstream(session *downstreamSession, deliver *cmpp.Cmpp3DeliverReqPkt) (bool, error) { func sendDownstream(session *downstreamSession, deliver cmpp.Packer) (bool, error) {
session.mu.Lock() session.mu.Lock()
defer session.mu.Unlock() defer session.mu.Unlock()
if err := session.conn.SendPkt(deliver, <-session.conn.SeqId); err != nil { if err := session.conn.SendPkt(deliver, <-session.conn.SeqId); err != nil {
+129 -7
View File
@@ -93,6 +93,8 @@ func (m *memoryRecoveryStore) GetAccountRecoveryStatus(_ context.Context, accoun
} }
func TestInboundServerAuthenticatesAndSubmits(t *testing.T) { func TestInboundServerAuthenticatesAndSubmits(t *testing.T) {
resetDownstreamRegistry()
defer resetDownstreamRegistry()
account := "100001" account := "100001"
password := "secret-hash" password := "secret-hash"
var gotAuth authRequest var gotAuth authRequest
@@ -103,7 +105,7 @@ func TestInboundServerAuthenticatesAndSubmits(t *testing.T) {
if err := json.NewDecoder(r.Body).Decode(&gotAuth); err != nil { if err := json.NewDecoder(r.Body).Decode(&gotAuth); err != nil {
t.Fatalf("decode auth: %v", err) t.Fatalf("decode auth: %v", err)
} }
_ = json.NewEncoder(w).Encode(authResponse{PasswordCipher: password}) _ = json.NewEncoder(w).Encode(authResponse{PasswordCipher: password, Account: account, EnterpriseCode: account})
case "/api/gateway/events/inbound/submit": case "/api/gateway/events/inbound/submit":
if err := json.NewDecoder(r.Body).Decode(&gotSubmit); err != nil { if err := json.NewDecoder(r.Body).Decode(&gotSubmit); err != nil {
t.Fatalf("decode submit: %v", err) t.Fatalf("decode submit: %v", err)
@@ -186,6 +188,92 @@ func TestInboundServerAuthenticatesAndSubmits(t *testing.T) {
} }
} }
func TestInboundServerNegotiatesCMPP2AndUsesAuthenticatedAccountForSubmit(t *testing.T) {
resetDownstreamRegistry()
defer resetDownstreamRegistry()
account := "100001"
password := "secret-hash"
var gotSubmit submitRequest
submitCalls := 0
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/gateway/events/inbound/authenticate":
_ = json.NewEncoder(w).Encode(authResponse{PasswordCipher: password, Account: account, EnterpriseCode: "SP0001"})
case "/api/gateway/events/inbound/submit":
submitCalls++
if err := json.NewDecoder(r.Body).Decode(&gotSubmit); err != nil {
t.Fatalf("decode submit: %v", err)
}
_ = json.NewEncoder(w).Encode(submitResponse{Accepted: true, MessageID: "MSG-CMPP2"})
case "/api/gateway/events/downstream/pending":
_ = json.NewEncoder(w).Encode([]pendingDelivery{})
default:
t.Fatalf("unexpected api path: %s", r.URL.Path)
}
}))
defer api.Close()
addr := reserveTCPAddr(t)
go func() {
_ = (Server{Addr: addr, APIBaseURL: api.URL + "/api"}).ListenAndServe()
}()
time.Sleep(300 * time.Millisecond)
client := cmpp.NewClient(cmpp.V20)
defer client.Disconnect()
if err := client.Connect(addr, account, password, 2*time.Second); err != nil {
t.Fatalf("connect CMPP2 inbound: %v", err)
}
content, err := cmpputils.Utf8ToUcs2("测试CMPP2")
if err != nil {
t.Fatalf("encode content: %v", err)
}
_, err = client.SendReqPkt(&cmpp.Cmpp2SubmitReqPkt{
PkTotal: 1, PkNumber: 1, RegisteredDelivery: 1, MsgLevel: 1,
ServiceId: "cmpp", FeeUserType: 2, FeeTerminalId: "13500002696",
MsgFmt: 8, MsgSrc: "SP0001", FeeType: "02", FeeCode: "0",
SrcId: "10690000", DestUsrTl: 1, DestTerminalId: []string{"13500002696"},
MsgLength: uint8(len(content)), MsgContent: content,
})
if err != nil {
t.Fatalf("send CMPP2 submit: %v", err)
}
rsp := recvSubmitRsp20(t, client)
if rsp.Result != 0 || rsp.MsgId == 0 {
t.Fatalf("unexpected CMPP2 submit response: %+v", rsp)
}
if gotSubmit.Account != account || gotSubmit.PhoneNumber != "13500002696" || gotSubmit.Content != "测试CMPP2" {
t.Fatalf("unexpected CMPP2 submit payload: %+v", gotSubmit)
}
_, err = client.SendReqPkt(&cmpp.Cmpp2SubmitReqPkt{
PkTotal: 1, PkNumber: 1, RegisteredDelivery: 1, MsgLevel: 1,
ServiceId: "cmpp", FeeUserType: 2, FeeTerminalId: "13500002696",
MsgFmt: 8, MsgSrc: "BAD001", FeeType: "02", FeeCode: "0",
SrcId: "10690000", DestUsrTl: 1, DestTerminalId: []string{"13500002696"},
MsgLength: uint8(len(content)), MsgContent: content,
})
if err != nil {
t.Fatalf("send mismatched enterprise code: %v", err)
}
if rejected := recvSubmitRsp20(t, client); rejected.Result != 9 {
t.Fatalf("expected enterprise code rejection, got %+v", rejected)
}
if submitCalls != 1 {
t.Fatalf("submit API calls = %d, want 1", submitCalls)
}
delivered, err := PushReceipt(DownstreamReceipt{
MessageID: "MSG-CMPP2", PhoneNumber: "13500002696", ReceiptStatus: "delivered",
DeliveredAt: time.Now().UTC().Format(time.RFC3339Nano),
})
if err != nil || !delivered {
t.Fatalf("push CMPP2 receipt delivered=%v err=%v", delivered, err)
}
deliver := recvDeliver20(t, client)
if deliver.RegisterDelivery != 1 {
t.Fatalf("expected CMPP2 receipt deliver, got %+v", deliver)
}
}
func TestPostIncludesAPIErrorResponseBody(t *testing.T) { func TestPostIncludesAPIErrorResponseBody(t *testing.T) {
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@@ -259,20 +347,21 @@ func TestSetInboundSubmitResponseSupportsCMPP2AndCMPP3(t *testing.T) {
} }
} }
func TestInboundClientProtocolUsesConnectRequestVersion(t *testing.T) { func TestFindSessionByConnUsesAuthenticatedConnection(t *testing.T) {
resetDownstreamRegistry() resetDownstreamRegistry()
defer resetDownstreamRegistry() defer resetDownstreamRegistry()
conn := &cmpp.Conn{} conn := &cmpp.Conn{}
downstreamRegistry.byAccount["100001"] = &downstreamSession{ session := &downstreamSession{
account: "100001", account: "100001",
protocol: "cmpp20", protocol: "cmpp20",
conn: conn, conn: conn,
} }
if got := inboundClientProtocol("100001", conn, "cmpp30"); got != "cmpp20" { downstreamRegistry.byConn[conn] = session
t.Fatalf("protocol = %s, want cmpp20", got) if got := findSessionByConn(conn); got != session {
t.Fatalf("unexpected session: %+v", got)
} }
if got := inboundClientProtocol("missing", conn, "cmpp30"); got != "cmpp30" { if got := findSessionByConn(&cmpp.Conn{}); got != nil {
t.Fatalf("fallback protocol = %s, want cmpp30", got) t.Fatalf("expected missing session, got %+v", got)
} }
} }
@@ -446,11 +535,28 @@ func recvDeliver(t *testing.T, client *cmpp.Client) *cmpp.Cmpp3DeliverReqPkt {
return nil return nil
} }
func recvDeliver20(t *testing.T, client *cmpp.Client) *cmpp.Cmpp2DeliverReqPkt {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
packet, err := client.RecvAndUnpackPkt(200 * time.Millisecond)
if err != nil {
continue
}
if deliver, ok := packet.(*cmpp.Cmpp2DeliverReqPkt); ok {
return deliver
}
}
t.Fatal("timed out waiting CMPP2 deliver request")
return nil
}
func resetDownstreamRegistry() { func resetDownstreamRegistry() {
downstreamRegistry.Lock() downstreamRegistry.Lock()
defer downstreamRegistry.Unlock() defer downstreamRegistry.Unlock()
downstreamRegistry.byAccount = make(map[string]*downstreamSession) downstreamRegistry.byAccount = make(map[string]*downstreamSession)
downstreamRegistry.byMessageID = make(map[string]*downstreamSession) downstreamRegistry.byMessageID = make(map[string]*downstreamSession)
downstreamRegistry.byConn = make(map[*cmpp.Conn]*downstreamSession)
} }
func reserveTCPAddr(t *testing.T) string { func reserveTCPAddr(t *testing.T) string {
@@ -481,3 +587,19 @@ func recvSubmitRsp(t *testing.T, client *cmpp.Client) *cmpp.Cmpp3SubmitRspPkt {
t.Fatal("timed out waiting submit response") t.Fatal("timed out waiting submit response")
return nil return nil
} }
func recvSubmitRsp20(t *testing.T, client *cmpp.Client) *cmpp.Cmpp2SubmitRspPkt {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
packet, err := client.RecvAndUnpackPkt(200 * time.Millisecond)
if err != nil {
continue
}
if rsp, ok := packet.(*cmpp.Cmpp2SubmitRspPkt); ok {
return rsp
}
}
t.Fatal("timed out waiting CMPP2 submit response")
return nil
}
+5 -1
View File
@@ -134,12 +134,16 @@ func (c *conn) readPacket() (*Response, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
typ := c.server.Typ typ := c.Conn.Typ
var pkt *Packet var pkt *Packet
var rsp *Response var rsp *Response
switch p := i.(type) { switch p := i.(type) {
case *CmppConnReqPkt: case *CmppConnReqPkt:
if p.Version == V20 || p.Version == V21 || p.Version == V30 {
c.Conn.Typ = p.Version
typ = p.Version
}
pkt = &Packet{ pkt = &Packet{
Packer: p, Packer: p,
Conn: c.Conn, Conn: c.Conn,