简介中国移动通信USSD应用接口协议是中国移动企业标准系列中的关键技术规范面向通信网络规划、设备选型及增值业务开发的工程技术人员为解决USSD业务在900/1800MHz TDMA数字蜂窝移动通信网中的落地实施提供完整依据。资源包内含1个doc格式文档大小约781KB内容按业务总体技术要求、应用接口协议、设备规范、业务流程、计费与信息安全等模块展开同时收录QB-D-069-2009、QB-D-070-2009、QB-E-031-2009、QB-E-032-2009等标准编号并给出API、HLR、MAP、MSC、MSISDN等核心术语定义和GSM相关规范引用便于读者逐项对照查询。已有576人浏览学习适合需要掌握USSD协议体系并用于实际网络运营维护与业务规划的专业人士参考。1. 先明白一件事USSD应用接口协议不是短信接口而是一条“拨号直达业务”的通道用户在手机拨号盘输入 *123# 并按接通键两三秒后屏幕弹出文字菜单1 查余额2 查流量0 退出。整个过程不装 App、不耗流量断网环境下一样能用。支撑它的不是短信接口而是运营商后台 USSD 平台与业务系统之间的 USSD 应用接口协议。协议描述上行请求如何送到业务侧、业务侧如何回菜单、会话何时释放把核心网的黑匣子变成一组可编程的 HTTP 回调。很多从短信接口转过来的团队第一次看协议会懵它要求有来有回按 begin、continue、end 状态回复少一个字段手机就可能转圈。接下来从会话模型讲到最小实现再讲真实接入时最容易踩坑的细节适合准备接 USSD 自助服务、充值、查询类业务的后端工程师照着可以搭出一个可联调的模拟服务也能知道上线前该确认哪些参数。2. 读懂协议先看会话模型USSD 请求从拨号到应答的完整链路2.1 一次拨号产生了什么USSD 上行请求的两个方向用户输入 *123# 并按下接通键后手机把 USSD 消息交给所在网络的 MSCMSC 发现这是 USSD 请求会路由到 USSD Server/HLR 侧运营商部署的 USSD 平台随后把消息包装成 HTTP 请求回调到业务系统注册的地址。这个过程在移动通信领域叫 Mobile Originated USSD业务侧收到的是“上行请求”。业务系统处理完之后把要展示给用户的文字和会话控制信息放在 HTTP 响应里返回平台再把它封装成手机能识别的 USSD 消息下发给终端。所以协议里的数据流其实是两个方向。上行方向是网关主动 POST 到你的接口下行方向是你的接口通过 HTTP Response 返回。很多新手把 HTTP Response 当成普通 Web 返回随便回个 200 空内容用户那边自然什么都收不到。做协议对接时先区分这两个方向再去看字段会简单很多。上行报文中常见的公共字段可以提前记下来不同运营商叫法略有差异但语义基本一致字段说明transactionId由平台生成的一次交互流水号每轮消息唯一sessionId本次 USSD 会话标识多轮交互里保持不变operation当前会话状态常见 begin / continue / endmsisdn用户手机号通常含国家码ussdNumber用户拨的接入码如 *123#content用户按键或输入的正文内容实际联调中transactionId 和 sessionId 是两个最容易混淆的字段。transactionId 是“这一条消息”的 IDsessionId 是“这一整段会话”的 ID。一次查询余额的 USSD 会话里用户可能拨号、按 1、再按 1会经历多轮消息每一轮 transactionId 都不同但 sessionId 从头到尾不变。后面做幂等的时候主要靠 transactionId 判断是否重复而 sessionId 用来找回用户当前走到哪一级菜单。还有一个需要提前确认的点operation 字段到底是字符串还是整数。有的平台用 begin/continue/end有的平台用 0/1/2甚至有平台用 INIT/REQ/END。拿到协议先做一张枚举映射表不要凭经验写死。真实项目里因为大小写和枚举不一致导致的联调失败比协议字段本身复杂得多。2.2 会话保活为什么你的接口要处理 begin / continue / endUSSD 和短信的一个本质区别在于它是面向会话的。短信发出去就不管了USSD 则要求手机和平台之间在短时间内维持一个交互上下文。协议通常用 operation 字段来表示这个上下文的当前状态。用户刚拨号进来对应 begin。业务系统收到 begin 后如果在响应里返回 operationcontinue平台会继续保持会话并把你的 content 显示到手机上同时用户键盘重新可用用户随后按了某个数字平台再次回调业务系统这轮消息通常标成 continue。当业务给用户展示了最终结果或者用户主动挂断最后的消息会变成 end 或 release。业务系统收到 end 后应当释放掉本地缓存的会话状态并返回一个终止应答平台这才真正拆除会话上下文。有些协议里还会出现 abort。abort 一般用于异常场景业务系统发现 session 不存在、消息重复、或者无法处理时返回 abort 让平台立刻结束会话。不要小看这个操作后面遇到的“会话结束后还重复收到消息”的坑很多就是没有及时返回 abort 导致的。还有一个容易被忽略的点continue 不一定代表用户按了键。某些平台在业务系统返回 continue 后会自动把同一段 content 再发送一遍或者因为网络原因重发。所以业务侧不能看到 continue 就认为用户一定做了新操作还要结合 content 内容和 transactionId 判断业务进度。协议状态机看着就三四个状态真正调起来远比想象中复杂。USSD 会话空闲超时一般由网络侧控制常见的有 30 秒、60 秒、90 秒。如果用户在菜单页停留时间过长平台会主动释放会话你的业务系统也可能在之后收到 end 或超时消息。因此本地缓存的 session TTL 要比网络侧的超时略长避免用户还在操作、业务侧却把会话弄丢了。这个参数值在上线前一定要向网关方确认不要拍脑袋写个 5 分钟。2.3 接口数据结构一个可参考的 JSON 报文格式USSD 应用接口协议没有全球统一标准但国内运营商开放给业务系统时常见做法是 HTTP JSON少数老平台还保留 XML。JSON 理解成本低字段扁平适合后端快速联调。下面是一段常用的上行消息样例字段名以实际协议文档为准{ transactionId: 202503211230987654321, sessionId: SES-8F3A9C, operation: begin, msisdn: 8613900000000, ussdNumber: *123#, content: , timestamp: 2025-03-21 12:30:00 }接到这条 begin 消息后业务系统如果希望展示主菜单就返回一个 operationcontinue 的 JSON{ transactionId: 202503211230987654321, sessionId: SES-8F3A9C, operation: continue, content: 1.查余额\n2.查流量\n0.退出, encoding: UCS2 }这里的 transactionId 要回填原请求里的值sessionId 也原样带回平台才能把应答关联到当前会话。content 是要显示给手机终端的内容\n 表示换行但有些平台要求写死为 \n 两个字节这点以协议为准。encoding 字段用于告诉平台 content 是 GSM7 还是 UCS2中文菜单一般需要 UCS2。应答里还有第二个关键点是否结束会话。如果查询结果已经算完希望会话到此结束就返回 operationend同时把最终内容带上如果希望用户继续停留在菜单里就返回 continue。判断是否结束不是你说了算也不是用户说了算而是协议文档里 operation 枚举值说了算。拿到协议先画一张状态迁移表再写代码。XML 老平台的结构大同小异根节点多为 ussd 或 message子节点就是上面这些字段。区别在于 content 里的换行和特殊字符需要做 XML 转义对中文菜单尤其麻烦。如果协议给了 XML 示例直接复制示例里的换行写法不要自己在代码里拼一个 \n 就交上去。很多“为什么返回正常但手机菜单没有分行”的问题根源都在这里。除了字段回调地址的安全校验也要一开始就做好。协议通常会要求业务系统配置一个密钥平台在 Header 或报文体里带签名业务侧必须验签后再处理否则任何人都能伪造用户请求打到接口上。签名算法常见有 MD5、HMAC-SHA256也有直接走 IP 白名单的。上线前把验签逻辑写好比在协议字段上纠结更紧急因为伪造请求会直接带来资费风险。2.4 多业务接入同一个回调地址怎么区分不同 USSD 号码一个业务系统往往不只接一个 USSD 号码可能 *123# 是余额查询*456# 是流量包订购。网关方通常只给你配一个回调地址所有号码的上行消息都会打到同一个 URL区分的依据就是报文里的 ussdNumber 字段。我一般会在入口处做一次路由把 ussdNumber 映射到不同的业务处理器def route_by_ussd_number(request): ussd_number request.get(ussdNumber, ) if ussd_number *123#: return balance_service(request) elif ussd_number *456#: return data_package_service(request) else: return {operation: abort, sessionId: request.get(sessionId, ), content: }逻辑说明根据 ussdNumber 分发避免把所有业务逻辑堆在一个函数里。参数说明ussdNumber 在不同平台可能带星号和井号也可能不带联调时要先确定是否包含这些符号路由表最好做成配置化而不是写死在代码里。3. 用 HTTP 回调方式把协议跑通最小 Webhook 服务与报文回话3.1 部署一个本地 Webhook先接收上行消息不需要真机不需要能连上运营商的测试卡先把协议行为在本地模拟出来。常见做法是起一个 HTTP 服务监听协议回调地址然后用 curl 模拟网关向上推送 begin/continue 消息。这里用 Python 标准库实现不依赖 Flask复制到服务器上就能跑。from http.server import HTTPServer, BaseHTTPRequestHandler import json class UssdHandler(BaseHTTPRequestHandler): def do_POST(self): length int(self.headers.get(Content-Length, 0)) raw self.rfile.read(length) request json.loads(raw.decode(utf-8)) print(receive:, json.dumps(request, ensure_asciiFalse)) response handle_ussd(request) body json.dumps(response, ensure_asciiFalse).encode(utf-8) self.send_response(200) self.send_header(Content-Type, application/json; charsetutf-8) self.send_header(Content-Length, str(len(body))) self.end_headers() self.wfile.write(body) def log_message(self, fmt, *args): print(f[http] {fmt % args}) def handle_ussd(request): # 先处理最简单的情况返回一个固定主菜单 return { operation: continue, sessionId: request.get(sessionId, ), content: 1.查余额\n2.查流量\n0.退出, encoding: UCS2 } if __name__ __main__: server HTTPServer((0.0.0.0, 9090), UssdHandler) print(USSD webhook listening on 9090) server.serve_forever()逻辑说明do_POST 先把请求体按 UTF-8 读成 JSON打印原始报文方便对照协议然后调用 handle_ussd 生成应答。这里应答固定返回 continue所以无论用户输入什么界面都会回到同一个主菜单。encoding 统一设为 UCS2避免乱码。参数说明监听地址 0.0.0.0 表示本机所有网卡都能访问联调用 127.0.0.1 也可以9090 端口要确保防火墙放行。跑起来后在另一个终端用 curl 模拟平台推送 begin 消息curl -X POST http://127.0.0.1:9090/ussd \ -H Content-Type: application/json \ -d {transactionId:T001,sessionId:SES-1,operation:begin,msisdn:8613900000000,ussdNumber:*123#,content:}服务端返回固定菜单本地 Webhook 就通了。注意如果运营商的回调地址是 HTTPS本地还得配证书或先用 HTTP 环境联调上线再切 HTTPS不要在同一台机器上开两个服务争抢端口。3.2 构造 USSD 回话应答菜单、金额、结束标志固定返回主菜单没有业务价值接下来把 handle_ussd 改造成按用户按键走不同分支。def handle_ussd(request): operation request.get(operation, begin) session_id request.get(sessionId, ) content request.get(content, ).strip() # 新会话进入时展示主菜单 if operation begin: return { operation: continue, sessionId: session_id, content: 欢迎使用自助查询\n1.查余额\n2.查流量\n0.退出, encoding: UCS2 } # 用户在一个已有会话里继续按键 if operation continue: if content 1: return { operation: end, sessionId: session_id, content: 您的余额为 88.88 元感谢使用, encoding: UCS2 } if content 2: return { operation: continue, sessionId: session_id, content: 当前剩余流量 10.5GB\n1.查询套餐\n0.返回上级, encoding: UCS2 } if content 0: return { operation: end, sessionId: session_id, content: 已退出, encoding: UCS2 } # 消息无法识别或会话异常立即终止 return { operation: abort, sessionId: session_id, content: , encoding: UCS2 }逻辑说明begin 时回 continue 让菜单显示continue 时按 content 里的数字决定下一步。查余额一次就能出结果所以直接返回 end查流量后面还可以再追一级所以继续返回 continue。用户按 0 则返回 end。所有未知按键统一返回 abort让网关别重试也避免会话悬挂。参数说明sessionId 每次都必须原样返回平台要靠它定位会话。content 里的换行用 \n 在 JSON 里会被转义成真实换行如果协议要求字面量的 \n代码里就要写 content: 1.查余额\n2.查流量。另外生产环境建议回填 transactionId方便双方按流水号对账。3.3 用 curl 模拟平台回调做一次端到端联调继续用 curl 模拟用户按 1 的 continue 消息curl -X POST http://127.0.0.1:9090/ussd \ -H Content-Type: application/json \ -d {transactionId:T002,sessionId:SES-1,operation:continue,msisdn:8613900000000,ussdNumber:*123#,content:1}服务端会返回 end 和余额文本相当于完整跑通了一次“拨号-选择-结果”的 USSD 会话。为了便于联调可以在 handle_ussd 里打日志记录每次返回的 JSON尤其把 operation、content、sessionId 打出来以后排查问题时非常有帮助。要多轮会话就连续发请求保持 sessionId 一致。比如先发 begin 拿主菜单再发 content2 的 continue 拿到流量子菜单再发 content1 查询套餐。注意每轮 content 是用户当前这次输入不是累计的菜单路径。平台不会把用户之前按过的所有数字拼起来业务系统要自己决定把 content1 理解成哪个业务动作这就是为什么多级菜单必须用 sessionId 存上下文。3.4 对接真实网关前要先问清的 4 个参数参数为什么必须确认回调地址与 HTTP 方法POST 还是 PUT路径是否需要带前缀鉴权方式Header Token / 报文签名 / IP 白名单operation 枚举值字符串还是整数end 还是 release编码与消息长度GSM7/UCS2content 是否要 hex最多多少字符这些没确认等于盲接。之前就有案例协议文档写 operation 是 0/1/2业务代码写 begin/continue/end上线后用户一拨号就失败排查半天才发现枚举定义与文档不一致。拿到协议后第一个动作是把这四个参数用表格列出来发给网关方确认再写代码。本地模拟和真实网关还有一个差别本地不会帮你验签、不会模拟超时所以模拟联调通过不代表真实可用。如果 curl 返回的不是预期 JSON先看服务端控制台有没有打印原始请求体。HTTP 回调最常见的坑是 Content-Type 没设对平台按 text/plain 发过来你用 json.loads 解析会抛异常。真实网关有时候会在报文最外层多加一层包装比如 { code: 0, data: {...} }解析时要先取 data。4. 状态机与数据格式session 是 USSD 一直不中断的核心4.1 三种基本状态与迁移条件USSD 会话在业务侧的视角可以简化为三个状态空闲(IDLE)、会话中(DIALOG)、结束(END)。用户拨号进入后IDLE 迁移到 DIALOG在 DIALOG 中业务侧返回 continue 时保持 DIALOG返回 end/release 时迁移到 END收到用户挂断或超时消息时也迁移到 END。业务侧还要处理一个特殊迁移在 DIALOG 状态下收到 abort要立刻清理本地资源。当前状态事件动作IDLE收到 begin创建会话返回 continue 菜单DIALOG收到 continue更新会话上下文按内容回下一步DIALOG返回 end清会话返回最终结果DIALOG收到 abort/end清会话不再下发内容ANY超时未响应网关侧释放会话业务侧等待回调或清理状态机看着简单难点在于业务侧和网络侧状态不一定同步。比如用户手机没信号平台已经释放会话但你的服务还在 DIALOG过几分钟平台的重试消息又进来了。所以业务侧不能只靠状态机描述来做逻辑还要给 session 设置 TTL避免僵尸会话长期占用内存和 Redis key。一个可参考的实践是收到 begin 时在缓存里写入 session 信息TTL 设为网络侧最大会话时长的 1.2 倍收到 continue 时刷新 TTL收到 end/abort 时主动删除。TTL 不够长会导致用户思考时间稍长缓存就没了下一次按键进来后业务侧认不出会话TTL 太长又会让无用的会话堆在那里。真实项目中60 秒到 90 秒是比较常见的起始值具体以协议文档为准。4.2 session id 的过期策略与超时Session id 是 USSD 会话的唯一标识。有些团队偷懒直接用 msisdn 当 key认为一个用户同一时间只能有一个会话。实际用户在功能机上可能同时发起两个不同 USSD 业务比如 *100# 和 *123#它们各有独立会话msisdn 相同但 sessionId 不同。如果用 msisdn 做缓存两个会话会互相覆盖用户在一个菜单里的操作会把另一个会话顶掉。下面这段代码用 Redis 保存会话并设置过期时间import redis, json r redis.Redis(host127.0.0.1, port6379, db0) SESSION_TTL 90 # 比网关侧超时略长 def save_session(session_id, payload): key fussd:{session_id} r.setex(key, SESSION_TTL, json.dumps(payload)) def load_session(session_id): key fussd:{session_id} raw r.get(key) return json.loads(raw) if raw else None def clear_session(session_id): r.delete(fussd:{session_id})逻辑说明setex 在写入的同时设置 TTL天然防止会话无限堆积。save_session 每次收到 continue 时调用相当于把过期时间向后滑动clear_session 在 end/abort 时调用确保用户退出后缓存立刻失效。参数说明SESSION_TTL 要大于网关上的会话空闲定时器我一般取协议里“最长会话时长”的 1.2 倍再留 10 秒余量。还有个细节真实网关回调的 sessionId 可能包含特殊字符或超长前缀不要直接拼到 Redis key 里正规做法是固定前缀加 sessionId 的哈希避免特殊字符带来 key 管理上的麻烦。如果协议里给了一个很长的 sessionId也不要截断截断会和别的会话撞车。可以这样生成 keyimport hashlib key ussd: hashlib.md5(session_id.encode(utf-8)).hexdigest()这样即使 sessionId 里有斜杠、星号也不会污染 Redis 的 key 空间。4.3 GSM-7 编码与中文 UCS2 的处理USSD 消息在核心网里走的字符集有限。纯英文、数字和部分符号在 GSM-7 字符集里一个字符占用一个 7-bit 单位能传输的长度相对较长一旦出现中文、Emoji 或特殊字符协议会切成 UCS2 编码中文每个字符固定两字节能放下的字数就少了很多。这里给一个 UCS2 编码工具函数def encode_ucs2(text): # 将文本转成 UTF-16 BE 的十六进制UCS2 常用这种表达 return text.encode(utf-16-be).hex().upper()调用示例 encode_ucs2(查余额) 67E54F59989D逻辑说明如果运营商协议要求 content 是 UCS2 十六进制就用这个函数生成如果协议直接允许 UTF-8 文本就不需要转 hex转了反而会在手机上显示成十六进制字符串。参数说明utf-16-be 是固定编码不要用 utf-16因为带 BOM 的 utf-16 会多出字节头导致手机端解析错位upper() 只是为了让十六进制便于人工查日志。中文菜单还要控制文本长度。曾在某省平台联调时协议规定最大支持 80 个 UCS2 字符我的菜单加提示语写满了 92 个字符手机端显示到一半就没了最后几行被截断用户看不到退出选项只能挂机。后来直接把菜单文案压到 70 字以内才稳定。具体上限以协议为准但经验值是中文菜单不要超过 7080 字英文不要超过 140160 个字符。4.4 常见误用把 USSD 当作长连接或短信接口USSD 每轮交互都是短连接不是长连接。有些团队把 HTTP 回调做成 WebSocket 长连接或者指望服务端可以主动向一个已经结束的会话推送消息这是不对的。USSD 协议的网络模型是“请求-应答”式只有平台回调你的接口时你才有机会返回内容你没有渠道主动往一个已经拆除的会话里塞消息。要主动给用户推送结果常见做法是发送短信或者使用 dial-back 方式重新发起一个 USSD 会话这个放在最后一章讲。短信接口看起来和 USSD 很像但短信是存储转发、无会话、不保证实时USSD 是实时交互、占用网络资源、必须快速响应。把 USSD 接口短信化比如收到 begin 后不入库就睡 30 秒再返回一定会被网关超时释放用户只看到“连接失败”。理解这一点后面对超时时就不会怀疑是玄学了。5. 避坑USSD接口对接最容易翻车的五个细节5.1 现象接口处理太慢手机提示“连接失败”典型场景用户拨 *123#手机转圈十几秒最后提示连接失败。服务端日志显示回调请求确实收到了也返回了 200但时间戳已经过了 10 秒。原因USSD 是实时业务网络侧不会等你的业务系统慢慢查库。很多团队把余额查询、账单列表这些耗时操作直接写在回调线程里执行一次查询要打数据库、调外部接口整体耗时超过网关超时阈值平台已经主动释放了会话之后你返回什么都进不了用户手机。解决把耗时操作和 USSD 应答解耦。收到 begin/continue 后先用最快的速度返回一个 continue 菜单或“正在查询”业务后台异步计算算完后再通过短信或 dial-back 把结果补上。如果业务必须在一次 USSD 会话里完成那就提前把结果缓存到 Redis回调只做缓存读取尽量把 HTTP 响应控制在 1 秒以内。我习惯在本地加一个中间件统计每个回调处理耗时超过 500ms 就告警比线上出问题再查日志强。5.2 现象中文菜单在部分手机上显示为方块或乱码典型场景同一段中文菜单测试机显示正常换到某款老功能机上就变成方块甚至直接空白。原因返回的 content 是 UTF-8 文本但协议要求 UCS2 十六进制或者反过来你按协议转了 UCS2但老款终端对某些字符集符号支持差。终端兼容性属于黑匣子很难精确控制但通常只要协议层编码正确主流 Android 手机和较新的功能机都能显示中文。解决先把协议文档里的编码要求找出来。有的平台要求 content 直接传中文 UTF-8由平台再转码有的平台则要求传 UCS2 hex。如果协议没写清楚找网关方要一个能跑的示例报文用示例里的 content 值在你的模拟服务里原样返回再用真机测试。不要在 JSON 里手动拼接 \uXXXX直接用中文即可让语言库处理转义。同时避免在 USSD 菜单里用 EmojiUSSD 字符集对 Emoji 支持极差用了大概率乱码。5.3 现象重复点击导致同一笔业务被触发两次典型场景用户查余额时由于网络重发同一个 continue 消息被平台推了两次如果你的接口没有幂等就会产生两条查询流水。查余额还好如果是点播订购类业务用户就悲剧了被扣两次钱不是没有可能。原因USSD 协议本身没有应用层 ack 机制平台重发是正常行为不是 bug。业务侧如果不做去重重复交易就会发生。有些团队拿 sessionId 做幂等这不够因为同一会话里用户在 1、2、3 之间来回按每一条消息的 content 都可能不同sessionId 不能区分“同一条消息”和“不同操作”。解决用 transactionId 做唯一键。每次收到回调先查本地库或 Redis 里这个 transactionId 是否处理过处理过就直接返回上次的应答不再执行业务逻辑。代码可以这样def handle_with_idempotency(request): trans_id request.get(transactionId) cached r.get(ftrans:{trans_id}) if cached is not None: return json.loads(cached) response handle_ussd(request) # 只有 end/abort 这类会释放会话的消息才建议缓存 if response.get(operation) in (end, abort): r.setex(ftrans:{trans_id}, 300, json.dumps(response, ensure_asciiFalse)) return response逻辑说明用 transactionId 做 key第一次处理后缓存 300 秒。平台重发相同 transactionId 时直接返回缓存结果不会再次执行查余额或扣费逻辑。参数说明缓存时长要覆盖平台最大重试间隔5 分钟是起始值如果协议明确写了重试窗口就按那个数据调整。不要缓存 continue 应答因为 continue 之后用户可能继续按键缓存会让后续动作失效。5.4 现象接口返回成功手机却没有收到菜单现象服务端日志已经返回了 200 和 JSON网关也显示发送成功用户手机却什么都没有或者弹出“USSD 代码运行不正常”。这类问题最消耗排查时间因为它不像超时那样一眼看出来属于典型的“接口成功、业务失败”。原因HTTP 状态码只代表传输成功不代表协议层成功。常见问题包括operation 字段值写错比如把 end 写成 endSessionsessionId 字段名不对平台匹配不到会话content 字段名用了 message而协议要求 content。还有一个隐蔽点应答 JSON 里嵌套了一层业务包装比如 { result: { operation: end, ... } }你的代码直接在最外层取 operation 就会失败。解决把网关下发的原始报文和你的响应报文都打到日志里以协议文档为准逐字段核对。不要只看 HTTP 200。我会写一个小的响应格式化函数把实际返回的字段名、操作值、编码都打出来联调时用 diff 工具和协议示例比对。如果本地没有真实网关日志就找网关方要一条真实调用记录比自己猜快得多。5.5 现象会话结束后又收到后续消息现象用户已经完成余额查询USSD 会话显示“感谢使用”并结束。但几分钟后你的接口又收到同一个 sessionId 的 continue 消息如果处理不当会再次给用户发一遍结果甚至把会话打开。原因网络侧重传是常见情况另一种情况是你在收到 end 时没有清理本地会话状态平台的重试消息仍然被当成有效业务处理。还有一种少见但真实的情况是用户手机终端自动缓存了 USSD 菜单在某个时间点重新上报sessionId 可能会被复用。解决在收到 end、abort、release 时立即调用 clear_session 删除缓存此后如果出现一个未知 sessionId 的消息统一返回 abort让平台释放不要再往业务分支里走。同时end 的幂等也同样重要同一个 sessionId 的 end 消息可能重复重复 end 不要报错直接返回相同的终止应答即可。6. 把接续性做稳dial-back 回呼与业务幂等表复杂业务不要硬塞在一次 USSD 会话里。比如用户查三个月账单你的服务要调账务接口可能耗时 3 秒而网关等不了。常见做法是第一次收到 begin 时返回 end 或 release先告诉用户“正在查询请留意稍后的 USSD 通知”然后异步任务去查数查完后通过平台的 dial-back 接口发起一个新会话把结果推到用户手机上。dial-back 在不同协议文档里叫法不同常见有 UssdDialBack、UssdCallback、回呼接口但模型一致业务系统向平台发起一次请求平台再以网络侧身份拨一个 USSD 会话给指定用户用户手机上会再次弹出菜单或文本。这个动作需要和原会话关联所以业务系统在第一次 begin 时就把 sessionId 和用户手机号存下来任务完成后带上原 sessionId 调 dial-back 接口平台才知道这是延续刚才那次业务。这里必须配一张业务幂等表。USSD 的所有请求都可能是重复的包括 dial-back 请求。表结构可以很简单CREATE TABLE ussd_idem ( trans_id VARCHAR(64) PRIMARY KEY, session_id VARCHAR(64) NOT NULL, msisdn VARCHAR(32) NOT NULL, status TINYINT DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );status 0 表示已收到请求、尚未处理完成1 表示业务已经处理完成2 表示最终结果已推送给用户。每次收到 begin 或 dial-back 通知时先尝试插入INSERT IGNORE INTO ussd_idem (trans_id, session_id, msisdn) VALUES (T001, SES-1, 8613900000000);如果影响行数为 0说明这个 trans_id 之前见过直接跳过业务逻辑影响行数为 1才继续执行查询或扣费。最终推送结束后再把 status 改成 2。这样即使平台重发三次也不会重复扣费。还有一个习惯值得分享我曾在一次上线前为了省事把 ease_status 更新和业务结果写入放在两个事务里结果业务成功、状态没更用户投诉被重复扣费。后来所有关键状态都放到同一个事务里或者用状态机禁止从“已完成”回退到“处理中”。USSD 协议本身不复杂复杂的永远是重复、超时、会话复用这些边界场景。希望这个幂等表和 dial-back 的思路帮到你少走一点弯路。本文还有配套的精品资源点击获取