ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

CMPP 2.0短信接入实战:报文解析、鉴权与心跳重连全指南

CMPP 2.0短信接入实战:报文解析、鉴权与心跳重连全指南 简介CMPP2.0协议是中国移动通信互联网短信网关接口协议China Mobile Peer to Peer为SPService Provider与短信服务中心SMSC之间的数据传输提供了标准化通信规范是第三方业务接入中国移动短信网络的基础。这份PDF教案面向短信业务开发工程师、SP接入人员及通信协议学习者系统梳理了协议适用范围、缩略语、网络结构、CMPP功能概述、协议栈等基础内容帮助读者快速建立整体认知。资源包为单个PDF文件大小仅478KB但章节完整涵盖通信方式长连接/短连接、消息定义、消息头格式消息ID、命令长度、命令码等以及CMPP_CONNECT、CMPP_SUBMIT、CMPP_QUERY、CMPP_DELIVER等关键命令的详细交互流程和应答机制并说明了SP与ISMG之间的消息定义。文档对编码规则、错误处理、心跳机制也有具体规定既可直接作为CMPP2.0协议开发调试的手册也适合用作教学培训讲义。已有79人学习浏览对需要快速查阅协议细则或备课的技术人员来说是一份轻量而实用的参考资料。1. 短信接入为何绕不开CMPP 2.0SP与ISMG之间的那点事做验证码、通知短信和营销消息只要走中国移动短信网关通讯协议CMPP 2.0你会发现所有报文、鉴权、心跳和重连都围绕一个TCP长连接在转。CMPP全称China Mobile Peer to Peer2.0是目前存量接入里覆盖面最广的版本定义了SP服务提供商和ISMG互联网短信网关之间消息交换的完整格式。这篇文章要解决的是几个实操问题报文怎么拼、登录怎么校验、长短信怎么拆、断线怎么救。适合正在接入移动通道的运维、通信开发以及刚上手短信网关协议的人照着搭一套最小可用客户端。2. 从TCP抓包开始拆CMPP 2.0报文头部三个字段和命令字映射2.1 CMPP 2.0的消息头为什么只有12字节CMPP 2.0所有报文共用一个固定消息头长度12字节结构简单到几乎没有回旋余地。三个字段分别是Total_Length、Command_Id、Sequence_Id顺序固定全部用网络字节序大端传输不按这个顺序解析必然翻车。Total_Length占4字节表示整条报文的字节数包含消息头本身Command_Id 4字节是命令字区分报文类型Sequence_Id 4字节是消息流水号。请求和响应的Sequence_Id必须一致否则无法做事务匹配。我之前接过一个渠道他们客户端把Sequence_Id每次请求都从1重新开始结果网关侧串包、重发、状态报告错位全来了最后抓包才发现流水号没做全局自增。为什么说CMPP 2.0比后来的3.0在接入时更烦人因为很多东西没有显式协商比如长短信分片、编解码方式都需要两边事先约定好。所以12字节头只是入口真正的变量全在消息体里。2.2 命令字镜像关系请求和响应差一个0x80000000命令字这块是新手最容易看花眼的地方。CMPP 2.0里请求命令字是0x00000001到0x00000008对应的响应命令字则在最高位加1也就是0x80000001到0x80000008。打个比方收到0x80000001就说明这是对CMPP_CONNECT的应答不是一个新的连接请求。命令字含义方向0x00000001CMPP_CONNECT 登录请求SP → ISMG0x80000001CMPP_CONNECT_RESP 登录应答ISMG → SP0x00000002CMPP_TERMINATE 终止连接SP → ISMG0x80000002CMPP_TERMINATE_RESP 终止应答ISMG → SP0x00000004CMPP_SUBMIT 短信提交SP → ISMG0x80000004CMPP_SUBMIT_RESP 提交应答ISMG → SP0x00000005CMPP_DELIVER 短信下发上行/状态报告ISMG → SP0x80000005CMPP_DELIVER_RESP 下发应答SP → ISMG0x00000008CMPP_ACTIVE_TEST 链路探测双向0x80000008CMPP_ACTIVE_TEST_RESP 探测应答双向CMPP_DELIVER尤其要注意它不只是用户上行短信还承载短信状态报告。状态报告里带Msg_Id和Stat字段你需要用Msg_Id去关联之前发出的SUBMIT。如果Msg_Id匹配不上多半是你在SUBMIT_RESP里没有保存网关返回的Msg_Id只存了自己本地流水号。这个问题我后面避坑章节会再讲。2.3 用tcpdump和Wireshark把协议从黑匣子变成可见接入调试期最有价值的动作就是抓包。SP连接移动ISMG一般走TCP端口7890但实际端口以中国移动分配给SP的接入参数为准有些省份会调整。抓包命令常用这一条tcpdump -i any -s 0 -X -nn tcp port 7890 -w cmpp.pcap抓完后用Wireshark打开cmpp.pcap。Wireshark没有内置CMPP 2.0的标准解码器不过没关系你只要做两步过滤先看TCP流找到第一个带0x00000001的包然后右键Follow TCP Stream就能看到二进制报文。对着消息头逐字节切三个字段立刻就清晰了。在实际操作中我一般会先抓一次登录过程确认两件事一是CMPP_CONNECT里的AuthenticatorSource是不是正确的16字节MD5二是CMPP_CONNECT_RESP返回的Status是不是0。如果Status不是0立刻查状态码表而不是继续往下调试SUBMIT能省掉半天瞎试的时间。抓包不是性能工具而是定位问题的第一现场宁可先抓包再动代码。3. 用Python实现最小CMPP 2.0客户端从登录到提交短信3.1 报文头封装struct处理大端序知道了报文结构下一步就是动手拼包。我用Python做最小实现因为struct一把梭处理二进制最直接不需要引第三方库。import struct def pack_header(command_id: int, seq_id: int, body: bytes) - bytes: total_length 12 len(body) return struct.pack(III, total_length, command_id, seq_id) body这里struct.pack的格式串III很关键。I表示无符号4字节整数大端序对应协议要求的网络字节序。Total_Length必须把12字节消息头包含进去这个很多人第一次写会漏导致网关收包后认为报文不完整连接被断开重来。调用方式举例发送一个CMPP_ACTIVE_TEST探测包命令字是0x00000008消息体为空直接pack_header(0x00000008, 10001, b)就能得到20字节的报文。注意这里是20字节不是12字节因为12字节头加上8字节空消息体不对CMPP_ACTIVE_TEST没有消息体总长度就是12字节这里别把消息体长度混进去。函数里len(body)为0所以总长度是12输出12字节这才是对的。3.2 登录报文AuthenticatorSource的MD5算法登录是CMPP 2.0的第一个硬门槛一大堆接入失败都死在鉴权上。CMPP_CONNECT报文格式按顺序是Source_Addr 6字节、AuthenticatorSource 16字节、Version 1字节、Timestamp 4字节。Source_Addr是你的SP企业代码不足6位左补零比如0123456789是10位就截取前6位不对Source_Addr固定6字节接入时移动会分配一个6位的SP_Id直接填进去。AuthenticatorSource的计算方法是行业里最容易传错的点。正确的计算方式是MD5(Source_Addr 9字节的0x00 Shared_Secret Timestamp)其中Timestamp取当前时间的月日时分秒格式为MMDDHHMMSS转成4字节整数。Shared_Secret是接入时移动分配的密码两边各存一份不进报文明文传输。import hashlib import time def build_authenticator_source(sp_id: str, shared_secret: str, timestamp: int) - bytes: md5 hashlib.md5() md5.update(sp_id.encode(utf-8)) md5.update(b\x00 * 9) md5.update(shared_secret.encode(utf-8)) md5.update(struct.pack(I, timestamp)) return md5.digest() def build_connect(sp_id: str, shared_secret: str, seq_id: int) - bytes: timestamp int(time.strftime(%m%d%H%M%S)) auth_source build_authenticator_source(sp_id, shared_secret, timestamp) body struct.pack(6s16sBI, sp_id.encode(utf-8), auth_source, 0x20, timestamp) return pack_header(0x00000001, seq_id, body)两个最容易踩坑的地方一是MD5拼接时Sp_Id后面要补9个字节的0x00不是补SP_Id到16字节也不是直接拼密码这个9字节补丁是协议定义死的二是Timestamp的格式2025年2月14日13点25分30秒Timestamp就是0214132530转成无符号4字节整数。如果你把年份也加进去鉴权结果必然和网关算出来的不一致。CMPP_CONNECT_RESP只有3字节消息体Status 1字节、AuthenticatorISMG 16字节、Version 1字节。收到后只要判断Status是否为0即可0表示登录成功非0需要查状态码。版本号0x20表示CMPP 2.00x10表示1.0如果网关返回版本不匹配多半是你这里填错了。3.3 构造CMPP_SUBMIT文本短信登录成功后的核心操作就是提交短信。CMPP_SUBMIT的消息体有一段固定长度部分加一段动态部分。固定部分依次是Msg_Id 8字节、Pk_Total 1字节、Pk_Number 1字节、Registered_Delivery 1字节、Msg_Level 1字节、Service_Id 10字节、Fee_User_Type 1字节、Fee_Terminal_Id 32字节、Fee_Terminal_Type 1字节、TP_Pid 1字节、TP_Udhi 1字节、Msg_Fmt 1字节、Msg_Src 6字节、Fee_Code 6字节、Src_Id 21字节、DestUsr_tl 1字节。动态部分是被叫号码列表和消息内容。def build_submit(seq_id: int, msg_src: str, service_id: str, src_id: str, dest_terminal: str, content: str, need_report: int 0) - bytes: msg_fmt 15 payload content.encode(gbk) dest_tl 1 fixed_part struct.pack( 8sBBB10sB32sBBB6s6s21sB, b\x00 * 8, # Msg_Id提交时填0 1, # Pk_Total单条短信填1 1, # Pk_Number分片序号 need_report, # Registered_Delivery是否需要状态报告 0, # Msg_Level优先级 service_id.encode(utf-8).ljust(10, b\x00), # Service_Id 0, # Fee_User_Type计费用户类型 b\x00 * 32, # Fee_Terminal_Id 0, # Fee_Terminal_Type 0, # TP_Pid 0, # TP_Udhi先按单条发 msg_fmt, # Msg_Fmt15表示GBK msg_src.encode(utf-8), # Msg_SrcSP的企业代码 b\x00 * 6, # Fee_Code先置空 src_id.encode(utf-8).ljust(21, b\x00), # Src_Id扩展码 dest_tl, # DestUsr_tl号码个数 ) dest_bytes dest_terminal.encode(utf-8).ljust(32, b\x00) return pack_header(0x00000004, seq_id, fixed_part dest_bytes bytes([len(payload)]) payload)Msg_Fmt字段要特别说明0表示ASCII8表示UCS215表示GBK。中文短信我在实际项目中优先用15字符集是GBK字节数比UCS2少一半网关侧支持也普遍如果遇到个别省份网关GBK乱码再切回8重发。Msg_Src字段填SP的企业代码这个值和登录时的Source_Addr一样提交时混填会导致网关在业务层拒收。submit发出后等待CMPP_SUBMIT_RESP消息体是Msg_Id 8字节加Result 1字节。Result为0代表网关受理不等于短信已经送达真是的送达状态要看状态报告。Msg_Id是网关回填的你要把它和本地关联保存。4. CMPP 2.0的会话护城河心跳、断线重连与流量控制4.1 CMPP_ACTIVE_TEST心跳与超时参数TCP连接建好后移动ISMG并不会一直等你。网关侧通常有一个空闲超时时间超过一定秒数没有数据传输连接就会被断开。CMPP_ACTIVE_TEST就是用来保活的双方都可以发收到后必须回CMPP_ACTIVE_TEST_RESP。报文格式是最简单的消息头加空消息体命令字0x00000008。def build_active_test(seq_id: int) - bytes: return pack_header(0x00000008, seq_id, b) def build_active_test_resp(seq_id: int) - bytes: return pack_header(0x80000008, seq_id, b)心跳间隔怎么设我一般设30秒到60秒。设太短会浪费网关资源设太长容易触发网关空闲断开。如果你发现连接总是在几分钟后悄无声息地被断优先把心跳间隔调到30秒再看网关侧是否还有别的空闲策略。发送心跳后要启动一个超时定时器比如8秒内没收到RESP就认为链路异常进入重连流程。这个超时不要设得比心跳间隔还长否则链路死了你还在傻等。4.2 断线重连要讲退避不要用固定1秒疯狂重试短信网关不是高可用架构ISMG会重启、会切换、会杀空闲连接。客户端必须做重连但重连策略绝不能是固定间隔死循环。网关如果在重启你每秒重连一次重启过程持续两分钟你就能攒出120个失败连接日志纯粹在制造告警噪音还有可能被网关侧当作恶意连接封IP。常见的做法是二进制退避重连第一次失败等1秒第二次等2秒第三次等4秒最多等到30秒封顶然后保持该间隔持续重试。连续重试期间不要每轮都打印完整堆栈打印一条简短的错误摘要就够了等重连成功后再打一条恢复日志。import socket import time def connect_with_backoff(host: str, port: int, build_login: callable, max_backoff: int 30) - socket.socket: delay 1 while True: try: sock socket.create_connection((host, port), timeout10) sock.sendall(build_login()) return sock except (socket.timeout, ConnectionRefusedError, OSError) as e: print(fconnect failed: {e}, retry in {delay}s) time.sleep(delay) delay min(delay * 2, max_backoff)这里要注意create_connection只能保证三次握手成功登录结果要靠发送CMPP_CONNECT后读响应来判断。如果登录失败返回Status非0看起来是连接建立了实际没有进入可用状态这种场景下要主动close再重连不要拿着一个没登录成功的socket继续发SUBMIT。重连的同时要把内嵌的会话状态清理掉比如Sequence_Id是全局递增继续用没问题但Msg_Id关联表要清空。如果不清理重连前发出的短信跑出的状态报告回来后匹配不上号容易误判成投递失败。4.3 流量控制单连接吞吐上限和窗口设计CMPP 2.0规范本身没有定义滑动窗口但因为底层是TCP你发得再快网关处理不过来也会背压或者断开。很多省份的ISMG对单连接下发速度有明确限制比如每秒200条到500条之间超出后连接会被流控错误中断。实际操作中我习惯把提交速率限制做成可配置的令牌桶默认速率从每分钟600条开始调稳定运行再逐步上调每次调50条观察5分钟看有没有SUBMIT_RESP超时或者状态错误。不要一上来顶着最大速率猛灌网关侧把你流控了全部短信积压反而拖慢整体下发效率。流量控制的另外一个维度是连接数量。CMPP协议允许SP同时建立多条连接但移动侧通常限制每条SP最多几路连接同时在线。多连接并发时Sequence_Id要全局唯一不能每个连接各自从1开始否则网关那边按Sequence_Id防重会把不同连接的请求误判为重复消息而丢弃。5. CMPP 2.0避坑指南5个高频故障的排查与解决5.1 登录失败返回Status4AuthenticatorSource鉴权失败现象客户端发完CMPP_CONNECT网关秒回Status4连接立即被断开。原因有三种常见可能Shared_Secret配置不一致Timestamp格式不对MD5拼接顺序漏了9字节补零。排查时先做三件事确认本地时间与北京标准时间误差不超过10秒确认Shared_Secret两边完全一致大小写和末尾换行符都算字符用抓包工具抓下Sent和Received的报文人工按MD5算法走一遍比对。解决我在代码里加了自校验函数把报文的二进制内容传进去重新算一遍AuthenticatorSource能算出不一致就说明问题出在报文构造算得一致但还是登录失败就去找移动侧核对账号密码状态。登录失败不要盲目重试连续失败5次就停下来人工排查否则账号存在临时锁定风险。5.2 发了SUBMIT之后一直等不到SUBMIT_RESP现象客户端发送CMPP_SUBMIT后服务端没有任何响应TCP连接也不断开一直挂到超时。原因通常是网关侧认为你登录态失效但没发终止包或者连接被设备会话保持策略静默回收客户端这边还傻乎乎以为连接可用。解决每个SUBMIT发送后都设一个等待响应超时我一般设10秒超时后主动断开连接并重连。别把超时设太大否则网关侧已经拉到流控错误你还在等响应整个链路就卡死了。同时检查Registered_Delivery字段如果你设为1请求状态报告那在SUBMIT_RESP之外还会收到DELIVER类型的状态报告不要把这两者混在一起。5.3 中文短信到手机上变成乱码现象短信发出后状态报告显示成功但用户收到的是乱码。原因多数是Msg_Fmt编码字段与消息体实际编码不一致。最常见的是内容用UTF-8编码Msg_Fmt却填了15GBK或者内容用GBK编码Msg_Fmt填了8UCS2。解决把Msg_Fmt和编码算法的配对写成一个独立函数比如我用15时就固定content.encode(gbk)用8时就content.encode(utf-16-be)用0时就只允许ASCII字符。在测试环境里分别用三个报文做一次真机测试确认平台支持后就锁定组合不允许运行时随意切换。乱码问题一旦出现所有已发短信都会产生投诉所以上线前编码测试是必做项。5.4 连接会莫名消失重连后一切正常但过几分钟又断现象TCP连接在没有任何业务流量时被服务端断开客户端检测到后重连恢复运行一段时间又断。原因基本是心跳丢失或心跳间隔大于网关空闲超时。部分ISMG要求SP主动发送CMPP_ACTIVE_TEST网关只负责回应不主动探测如果你没有发心跳空闲连接会被自动回收。解决把心跳逻辑和业务收发解耦放到独立线程里跑间隔30秒一次。发送心跳后同样要等响应连续3次无响应就触发重连。如果间隔30秒还断就改用20秒间隔并在抓包里确认ACTIVE_TEST和RESP是不是成对出现。心跳包不算业务流量网关侧对它也有计数阈值所以千万不要把心跳逻辑写成发送后不等待直接继续发。5.5 长短信被网关拆分但手机收到乱序或丢失现象超过140字节的短信内容拆成多条发送手机收到了但顺序错乱或者中间缺一条。原因在于分片时需要设置TP_Udhi1并在消息内容前加一个6字节的用户数据头UDH用UDH里的序号让对方重组。如果你只是把内容硬切成两段把Pk_Total和Pk_Number填了2和1没加UDH网关会把每一片当成独立短信下发自然不会重组。解决发长短信时先把内容按编码后的字节长度拆分GBK单条容量134字节UCS2单条容量67个UTF-16码元。每条分片的消息体开头加6字节UDH0x05 0x00 0x03 引用号 总片数 当前片号。同时TP_Udhi字段设为1。引用号每批长短信用一个随机数两批短信的引用号不能相同否则手机会把不同批次的片拼在一起。6. 上线前把CMPP 2.0接入变成可验证的检查动作CMPP 2.0接入上线前我会先做一轮逐项自检而不是直接拿生产账号去冲量。准备一个测试号码和一张测试卡按下面顺序走一遍。第一查鉴权连续登录10次每次间隔随机确认Status全为0同时抓包比对AuthenticatorSource与本地重算值一致。第二查心跳建立连接后不做任何业务操作保持心跳30分钟确认连接没有被断开。第三查单条编码分别用Msg_Fmt0、8、15发一条文本短信到测试手机核对显示内容是否正常。第四查长短信发送超长短信拆成2条和3条两种场景确认接收顺序正确且不丢片。第五查断线恢复用kill命令杀掉服务器端进程模拟ISMG重启观察客户端是否在30秒内自动重连并恢复发送。每次上线新省份我都会把这个检查动作清单放进发布流程文档里。别忘了测一下状态报告链路发一条需要状态报告的SUBMIT确认能收到DELIVER类型的state报告并且Msg_Id能和SUBMIT_RESP返回的Msg_Id对上。如果对不上把Msg_Id关联逻辑修好再上线不然你的短信送达率永远是一个黑匣子。希望这几条来自一线的实测经验能帮你在接入CMPP 2.0的路上少踩几个坑。本文还有配套的精品资源点击获取
返回列表