news 2026/10/16 17:38:51

USSD应用接口协议对接实战:会话模型、HTTP回调与避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
USSD应用接口协议对接实战:会话模型、HTTP回调与避坑指南

简介:中国移动通信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 消息交给所在网络的 MSC;MSC 发现这是 USSD 请求,会路由到 USSD Server/HLR 侧;运营商部署的 USSD 平台随后把消息包装成 HTTP 请求,回调到业务系统注册的地址。这个过程在移动通信领域叫 Mobile Originated USSD,业务侧收到的是“上行请求”。业务系统处理完之后,把要展示给用户的文字和会话控制信息放在 HTTP 响应里返回,平台再把它封装成手机能识别的 USSD 消息下发给终端。

所以协议里的数据流其实是两个方向。上行方向是网关主动 POST 到你的接口,下行方向是你的接口通过 HTTP Response 返回。很多新手把 HTTP Response 当成普通 Web 返回,随便回个 200 空内容,用户那边自然什么都收不到。做协议对接时,先区分这两个方向,再去看字段,会简单很多。

上行报文中常见的公共字段可以提前记下来,不同运营商叫法略有差异,但语义基本一致:

字段说明
transactionId由平台生成的一次交互流水号,每轮消息唯一
sessionId本次 USSD 会话标识,多轮交互里保持不变
operation当前会话状态,常见 begin / continue / end
msisdn用户手机号,通常含国家码
ussdNumber用户拨的接入码,如 *123#
content用户按键或输入的正文内容

实际联调中,transactionId 和 sessionId 是两个最容易混淆的字段。transactionId 是“这一条消息”的 ID,sessionId 是“这一整段会话”的 ID。一次查询余额的 USSD 会话里,用户可能拨号、按 1、再按 1,会经历多轮消息,每一轮 transactionId 都不同,但 sessionId 从头到尾不变。后面做幂等的时候,主要靠 transactionId 判断是否重复,而 sessionId 用来找回用户当前走到哪一级菜单。

还有一个需要提前确认的点:operation 字段到底是字符串还是整数。有的平台用 begin/continue/end,有的平台用 0/1/2,甚至有平台用 INIT/REQ/END。拿到协议先做一张枚举映射表,不要凭经验写死。真实项目里因为大小写和枚举不一致导致的联调失败,比协议字段本身复杂得多。

2.2 会话保活:为什么你的接口要处理 begin / continue / end

USSD 和短信的一个本质区别在于它是面向会话的。短信发出去就不管了,USSD 则要求手机和平台之间在短时间内维持一个交互上下文。协议通常用 operation 字段来表示这个上下文的当前状态。

用户刚拨号进来,对应 begin。业务系统收到 begin 后,如果在响应里返回 operation=continue,平台会继续保持会话并把你的 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 消息后,业务系统如果希望展示主菜单,就返回一个 operation=continue 的 JSON:

{ "transactionId": "202503211230987654321", "sessionId": "SES-8F3A9C", "operation": "continue", "content": "1.查余额\n2.查流量\n0.退出", "encoding": "UCS2" }

这里的 transactionId 要回填原请求里的值,sessionId 也原样带回,平台才能把应答关联到当前会话。content 是要显示给手机终端的内容,\n 表示换行,但有些平台要求写死为 \n 两个字节,这点以协议为准。encoding 字段用于告诉平台 content 是 GSM7 还是 UCS2,中文菜单一般需要 UCS2。

应答里还有第二个关键点:是否结束会话。如果查询结果已经算完,希望会话到此结束,就返回 operation=end,同时把最终内容带上;如果希望用户继续停留在菜单里,就返回 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_ascii=False)) response = handle_ussd(request) body = json.dumps(response, ensure_ascii=False).encode('utf-8') self.send_response(200) self.send_header('Content-Type', 'application/json; charset=utf-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 拿主菜单,再发 content=2 的 continue 拿到流量子菜单,再发 content=1 查询套餐。注意每轮 content 是用户当前这次输入,不是累计的菜单路径。平台不会把用户之前按过的所有数字拼起来,业务系统要自己决定把 content=1 理解成哪个业务动作,这就是为什么多级菜单必须用 sessionId 存上下文。

3.4 对接真实网关前要先问清的 4 个参数

参数为什么必须确认
回调地址与 HTTP 方法POST 还是 PUT,路径是否需要带前缀
鉴权方式Header Token / 报文签名 / IP 白名单
operation 枚举值字符串还是整数,end 还是 release
编码与消息长度GSM7/UCS2,content 是否要 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(host='127.0.0.1', port=6379, db=0) SESSION_TTL = 90 # 比网关侧超时略长 def save_session(session_id, payload): key = f"ussd:{session_id}" r.setex(key, SESSION_TTL, json.dumps(payload)) def load_session(session_id): key = f"ussd:{session_id}" raw = r.get(key) return json.loads(raw) if raw else None def clear_session(session_id): r.delete(f"ussd:{session_id}")

逻辑说明:setex 在写入的同时设置 TTL,天然防止会话无限堆积。save_session 每次收到 continue 时调用,相当于把过期时间向后滑动;clear_session 在 end/abort 时调用,确保用户退出后缓存立刻失效。参数说明:SESSION_TTL 要大于网关上的会话空闲定时器,我一般取协议里“最长会话时长”的 1.2 倍,再留 10 秒余量。

还有个细节:真实网关回调的 sessionId 可能包含特殊字符或超长前缀,不要直接拼到 Redis key 里,正规做法是固定前缀加 sessionId 的哈希,避免特殊字符带来 key 管理上的麻烦。如果协议里给了一个很长的 sessionId,也不要截断,截断会和别的会话撞车。可以这样生成 key:

import 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 字以内,才稳定。具体上限以协议为准,但经验值是中文菜单不要超过 70~80 字,英文不要超过 140~160 个字符。

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 菜单里用 Emoji,USSD 字符集对 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(f"trans:{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(f"trans:{trans_id}", 300, json.dumps(response, ensure_ascii=False)) return response

逻辑说明:用 transactionId 做 key,第一次处理后缓存 300 秒。平台重发相同 transactionId 时,直接返回缓存结果,不会再次执行查余额或扣费逻辑。参数说明:缓存时长要覆盖平台最大重试间隔,5 分钟是起始值,如果协议明确写了重试窗口,就按那个数据调整。不要缓存 continue 应答,因为 continue 之后用户可能继续按键,缓存会让后续动作失效。

5.4 现象:接口返回成功,手机却没有收到菜单

现象:服务端日志已经返回了 200 和 JSON,网关也显示发送成功,用户手机却什么都没有,或者弹出“USSD 代码运行不正常”。这类问题最消耗排查时间,因为它不像超时那样一眼看出来,属于典型的“接口成功、业务失败”。

原因:HTTP 状态码只代表传输成功,不代表协议层成功。常见问题包括:operation 字段值写错,比如把 end 写成 endSession;sessionId 字段名不对,平台匹配不到会话;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 的思路帮到你,少走一点弯路。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 17:36:56

PyNastran读取BDF文件常见报错与稳定解析指南

简介:本资源是面向有限元分析工程师、CAE仿真开发者及Python科学计算学习者的PyNastran开源库实战包,聚焦BDF格式文件的解析、处理与二次开发。资源提供PyNastran v0.6.1(Python 2.7兼容版)完整源码及配套示例模型,涵盖…

作者头像 李华
网站建设 2026/10/16 17:36:50

Taskmgr.exe丢失或打不开?教你从Windows官方镜像提取原版文件

先把结论摆在这儿:Taskmgr.exe这东西,很少真的需要你去搞什么“免费下载”。它就是你Windows系统里任务管理器的主程序,路径固定在C:\Windows\System32\Taskmgr.exe。正常情况下它一直在那儿,CtrlShiftEsc就能呼出来,反…

作者头像 李华
网站建设 2026/10/16 17:36:08

Python语音控制无人机:从麦克风到Tello EDU指令的完整实战

坦白讲,这个项目听起来挺唬人,但拆开以后就是四件事:让电脑听懂人话、把语义翻译成指令、把指令发到无人机、无人机照着执行。我用 Python 给 Tello EDU 做了一套完整的语音控制系统,不需要额外硬件,电脑自带麦克风就能…

作者头像 李华
网站建设 2026/10/16 17:31:07

NARX与BP神经网络实战对比:动态时序预测谁更强?

做预测分析这行当久了,你会发现一个很有意思的现象:一聊到时序预测,大多数人的第一反应要么是统计模型,要么直接冲深度学习,反倒把神经网络家族里那对“看起来不那么新”的选手——NARX延时动态神经网络和BP神经网络—…

作者头像 李华
网站建设 2026/10/16 17:30:57

AI应用架构师:解决大模型落地“最后一公里”的关键角色

1. 先聊一个扎心的事实:AI项目为什么总在"最后一公里"翻车这几年我在不少企业里见过同一个剧本:算法团队花三个月训出一个效果很不错的模型,准确率坐实验室里测出来95%往上,团队士气高涨,老板也发了朋友圈预…

作者头像 李华
网站建设 2026/10/16 17:26:47

AdaBoost原理与MATLAB实现:从错题本机制到工业级避坑指南

1. 为什么AdaBoost不是“加权平均”,而是“错题本驱动的迭代矫正”很多人第一次看到AdaBoost,下意识会把它理解成“给不同样本加权重,然后做加权投票”——这就像学生考试后,老师把错题抄在本子上反复讲,但只说“你得重…

作者头像 李华