ARTICLE DETAIL

资讯详情

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

短信API接入全流程解析:签名、模板、回执与频控避坑指南

短信API接入全流程解析:签名、模板、回执与频控避坑指南 做后端的基本都躲不开接短信API这关。上个月帮朋友排查一个线上问题他们系统每晚六点之后验证码就发不出去用户疯狂投诉。我翻了一圈日志发现API层返回的一直是成功最后拉出服务商的状态报告才定位到是同一个手机号码发得太频繁被频控拦了。短信接入这种事看着就是调下HTTP接口真到了生产环境签名、模板、回执、频控重试每一环都可能埋雷。这篇文章不打算把服务商文档复述一遍。我准备以“给系统加短信通知”这个典型需求为背景把第三方短信接口集成的完整过程讲清楚先聊第三方服务商到底解决了什么问题、选型要怎么看再说接入前必须准备的签名和模板然后给出一套可以直接跑起来的发送、查询和回执接收Demo最后把我在真实项目中踩过的高频坑和排查链路摊开说。不管你们用的是哪家服务商这套思路基本通用。1. 短信API接入前先想明白服务商替你解决了什么1.1 一条短信从业务系统到用户手机的完整路径短信发送不是一个简单的请求转发。你的业务系统把参数推到短信服务商的API服务商随后要完成内容审核、模板匹配并把请求转换后对接运营商网关协议运营商再根据手机号段进行路由最终到用户手机。这里面有一个很容易被忽略的点服务商和你之间的协议是HTTP服务商和运营商之间的协议则是SGIP、CMPP等一整套国内短信网关规范。后者的复杂性大部分情况下对你是透明的但理解这条链路后你才能解释很多奇怪现象。比如“API返回成功手机却收不到”绝大多数情况下问题不出在接口调用而是出在服务商后端审核或运营商拦截环节。此时你不能只盯着发送成功还要看异步状态报告。反过来如果“接口超时”也别下意识认为服务商没收到很可能请求已经到了只是响应在网络传输中丢了贸然重发就会造成重复短信。1.2 第三方短信API暴露给你的四个基本模块综合各家服务商你最终只需要关注四类能力身份认证、内容合规、发送调用、状态回执。身份认证很好理解就是服务商给你分配一组Key和Secret有的厂商还要求在控制台配置IP白名单只有白名单内的服务器才能调用API。第二个是内容合规按行业监管要求所有短信必须带签名和模板。签名是短信开头的【xxx】标识模板则是正文里可带变量的固定内容这部分要预先提交审核审核通过后才能发起发送。第三个是发送调用也就是Demo里我们要写的东西重点是参数结构、签名算法和超时降级策略。第四个是状态回执每条短信发出后服务商会通过异步回调通知你它最终是到达了、被退回还是被拦截这是判断真实送达率唯一的依据。很多公司把这部分功能做得极浅导致出问题时完全没有回溯能力。1.3 选型时我重点看的五个维度选服务商的时候不要只比单价我会把下面这些维度列成一张表去评估。关注点判断标准个人体会到达率与通道资源是否有多家运营商通道冗余是否支持异地容灾不要听宣传先用小量真实号码测状态报告完整度回执是否及时、字段是否包含失败原因有些服务商回执延迟十几分钟没法做告警频控策略的灵活性是否支持按号码、按模板维度限流验证码场景要求尤其高SDK和文档质量是否覆盖你所在的开发语言是否维护活跃冷门语言建议直接手写HTTP层价格与计费模式按条计费还是套餐包行业短信和营销短信是否区分行业短信价格高但更稳别混用再补充一句验证码、通知类短信通常走“行业短信”通道稳定性和到达率都更好但价格也更贵。营销短信单价低却容易被运营商限制频次甚至拦截。如果产品经理为了省钱强行把营销内容塞进验证码模板最后用户收不到消息背锅的一定是开发这个风险在规划阶段就要讲清楚。2. 接入第一关签名、模板和密钥一个都不能少写代码之前先在服务商控制台把账号维度的三件事搞定。我见过很多项目上线倒计时才发现模板还没过审这种延期完全可以提前规避。2.1 密钥管理从第一天就要干净短信服务商的凭证一般分两类一个是能公开出现在请求里的应用标识Key另一个是只用来计算签名的密钥Secret。你在控制台创建好后Secret要放到后端配置或者环境变量里绝对不要出现在前端代码和Git提交记录里。还有个容易被忽略的建议生产环境和开发环境分别创建不同的子账号和密钥。测试脚本走测试通道额度生产请求走生产通道额度互相隔离。否则某个同事本地调试时把生产短信额度发爆是很尴尬的生产事故。2.2 短信签名申请为什么总是被驳回签名是短信正文开头的【公司名】这段内容审核标准的本质是一句话签名必须能清楚表达“你是谁”。个人开发者可以用真实姓名或备案过的网站名企业开发者可以用营业执照上的公司名称、App名称或商标。实际中我见过最多的驳回原因有三个签名内容与主体信息对不上比如用了一个和公司无关的昵称签名太通用例如“通知”“验证码”这类词无法识别发送者签名超出规定长度或包含特殊符号。申请的时候我通常会一次性提交两三个候选名称错开审核周期避免驳回后干等。审核时间一般几小时到一天不等所以这步要放在项目早期做。2.3 模板变量把“变的内容”和“固定内容”分开模板是你短信正文的骨架例如您的验证码是${code}${minutes}分钟内有效。。注意占位符不是自己随便定的要用服务商规定的格式常见的有${}或{}。审核主要看模板内容是否合规以及变量是否语义明确。所谓语义明确是指${code}这类变量能看出来是验证码而不是把一整段可变内容都塞进去那样会被判定为绕过模板审核直接驳回。创建模板时我建议按场景建登录验证码、操作通知、系统告警各建一个。不要在一条模板里硬塞多种不相关内容因为模板一旦传错参数服务商会替换失败或直接报错。每次调用时模板变量以JSON字符串形式提交比如{code:123456,minutes:5}。2.4 测试环境的三个准备工作开发阶段一定要先准备好自己的测试手机号。第一步在控制台把测试号码加白名单或测试群组第二步如果服务商有沙箱环境先在沙箱里把签名和参数流程跑通第三步没有沙箱时用真实模板向测试号发低量级消息。这里要说下IP白名单服务商会限制调用方IP你本地电脑的IP和测试服务器的IP通常要分别加白。很多人本地调不通第一反应是改签名实际上只要确认IP白名单就好。这个过程在服务商文档里一般写得很浅却是接入初期最常卡住的环节。3. 手写请求层签名算法才是集成的灵魂很多人拿到服务商SDK后第一件事就是引入依赖然后调接口一切正常。我建议你至少在本地手写一次HTTP层调用把签名机制完全搞懂。原因很实在SDK把细节封装得太深一旦线上出现签名错误、编码问题、超时重试你不看底层代码根本无从排查。3.1 一个典型API请求的参数结构这里用一类常见的短信服务商接口做演示字段做一定通用化不绑定某一家的具体文档。发送短信的完整流程是构造参数 → 生成签名 → POST到接口地址 → 解析返回。参数通常包含下面这些参数名含义注意事项appKey应用标识服务商后台生成templateId模板ID审核通过后生成phone目标手机号有的服务商要求E.164格式有的要求纯号码templateParam模板变量必须是一个JSON字符串不是对象timestamp调用时间戳注意单位是秒还是毫秒nonce随机串一次一换用于防重放sign签名由密钥和上述参数计算而来初接者最容易在这里犯一个错把templateParam直接传成对象而不是序列化后的字符串。服务商的网关通常按严格格式解析字段多一层嵌套或少一层嵌套都会报“参数格式错误”。3.2 签名到底在签什么签名的原理可以类比为给快递贴防伪封条发件人用只有双方知道的密钥对关键字段做一次固定算法的摘要计算得到一串不可逆的签名值。服务商收到请求后用相同的密钥和算法重新计算一遍对比是否一致。由于密钥只有你和服务商持有攻击者即使截获请求也无法伪造合法签名。市面上绝大部分短信服务商用的要么是HMAC类算法HMAC-SHA1、HMAC-SHA256要么是MD5摘要。HMAC类算法会有不同版本的区别比如待签名串的字段排序方式、拼接分隔符、是否做URL编码这些细节每个厂商都不完全一样。下面给出一段通用HMAC-SHA256实现真实使用时请把待签名串的规范替换成你服务商文档里的样子import hashlib import hmac def gen_sign(app_key: str, app_secret: str, timestamp: str, nonce: str) - str: raw f{app_key}{timestamp}{nonce} signature hmac.new( app_secret.encode(utf-8), raw.encode(utf-8), hashlib.sha256, ).hexdigest() return signature注意上面代码里三个字段的拼接顺序是我简化的有的服务商会要求先对参数Key做字典序排序并拼接成k1v1k2v2有的会要求在最前面加上访问方法还有的会把nonce放在Header而不是Body里。所以拿到一家新厂商先花十分钟看懂文档里的“签名机制”章节再写代码。3.3 签名计算最容易翻车的三个细节时间戳单位算一个。有的接口要秒级时间戳有的要毫秒级用错单位报的错误信息非常隐晦经常还是“timestamp expired”或“签名错误”。我习惯在配置或代码注释里明确标注单位避免半年后自己都忘记。第二个坑是URL编码。部分服务商要求对参数值按RFC3986规则编码空格要编码成%20而不是中文要转成UTF-8百分号形式。用Python的requests库传json参数时它有自己的序列化方式如果不确定可以把实际发出的body打印出来和文档示例逐字符对比。第三个坑是nonce必须一次一换。直接在代码里用UUID生成就够了千万不要拿时间戳冒名顶替服务端通常会做防重放校验重复值直接拒掉。3.4 成功并不等于送达发送接口返回成功只能说明服务商已受理这条短信请求接下来还有运营商网关、黑名单校验、内容审核等多道工序。很多刚接短信的同学把API的成功当成送达一看到用户投诉就不知道怎么排查根源就是没建立“受理成功不等于送达”这个认知。真正的最终结果要看异步状态报告这也是下一章Demo里我把回调单独拿出来的原因。4. 一套能跑的Demo发送、查询和回执接收全流程这部分的代码我已经在本地验证过逻辑很简单目标是让你半小时内照着跑通一条链路。我用Python演示因为脚本简洁、适合作为集成参考。4.1 工程结构和配置创建两个文件config.py存放API地址、密钥等配置sms_client.py放发送和查询的核心逻辑。密钥读取用环境变量import os API_URL os.getenv(SMS_API_URL, https://sms.example.com/v1/sendSms) APP_KEY os.getenv(SMS_APP_KEY, ) APP_SECRET os.getenv(SMS_APP_SECRET, ) TEMPLATE_ID os.getenv(SMS_TEMPLATE_ID, )注意环境变量方式比硬编码安全得多至少不会因为代码仓库泄露导致密钥暴露。4.2 发送短信的完整实现核心发送函数做三件事生成时间戳和nonce、构造并序列化参数、计算签名发起请求。下面是完整代码import hashlib import hmac import json import logging import time import uuid import requests logging.basicConfig(levellogging.DEBUG) class SmsApiError(Exception): def __init__(self, code: str, message: str): self.code code self.message message super().__init__(f[{code}] {message}) def gen_sign(app_key: str, app_secret: str, timestamp: str, nonce: str) - str: raw f{app_key}{timestamp}{nonce} return hmac.new( app_secret.encode(utf-8), raw.encode(utf-8), hashlib.sha256, ).hexdigest() def send_sms(api_url, app_key, app_secret, template_id, phone, template_param, timeout5): timestamp str(int(time.time() * 1000)) nonce str(uuid.uuid4()) payload { appKey: app_key, templateId: template_id, phone: phone, templateParam: json.dumps(template_param, ensure_asciiFalse), timestamp: timestamp, nonce: nonce, } payload[sign] gen_sign(app_key, app_secret, timestamp, nonce) logging.debug(request payload%s, payload) try: resp requests.post(api_url, jsonpayload, timeouttimeout) result resp.json() except requests.exceptions.RequestException as exc: raise SmsApiError(NETWORK_ERROR, str(exc)) from exc if result.get(code) not in (0, OK): raise SmsApiError(result.get(code), result.get(msg)) return result代码里的json.dumps(template_param, ensure_asciiFalse)保留了中文原样很多服务商对变量里的中文编码要求严格这种方式最稳。同时我把超时时间设置为5秒短信接口没必要等太久超时后一定不要立刻无脑重发这点在后面坑5.5还会展开。4.3 查询发送状态主动查询最常用的场景是用户说没收到短信客服让你核实。这时候可以用服务商提供的查询接口入参是发送接口返回的messageIddef query_status(api_url, app_key, app_secret, message_id): timestamp str(int(time.time() * 1000)) nonce str(uuid.uuid4()) payload { appKey: app_key, messageId: message_id, timestamp: timestamp, nonce: nonce, } payload[sign] gen_sign(app_key, app_secret, timestamp, nonce) resp requests.post(api_url, jsonpayload, timeout5) return resp.json()我的建议是业务数据库里至少留一张短信发送记录表字段包含biz_order_no业务单号、phone、template_id、message_id、sync_status、async_status。同步返回后立即记录message_id和sync_status回调到达后再更新async_status。这样任何时候想复盘都能按手机号或业务单号纵向看到一条短信的全生命周期。4.4 接收状态报告回调用Flask可以快速搭一个回调端点来接收服务商的异步回执。生产环境一般用Spring Boot、FastAPI等成熟框架这里用Flask突出最小可运行import hashlib import hmac import logging import os from flask import Flask, request app Flask(__name__) def valid_callback_sign(body: dict, app_secret: str) - bool: items sorted([(k, str(v)) for k, v in body.items() if k ! sign]) raw .join([f{k}{v} for k, v in items]) calc hashlib.md5((raw app_secret).encode(utf-8)).hexdigest() return hmac.compare_digest(calc, body.get(sign, )) def process_status(body: dict) - None: message_id body.get(messageId) status body.get(status) err_code body.get(errCode) logging.info(message_id%s status%s errCode%s, message_id, status, err_code) app.post(/callback/sms/status) def sms_callback(): body request.get_json(forceTrue) if not valid_callback_sign(body, os.getenv(SMS_APP_SECRET, )): return invalid sign, 403 process_status(body) return OK if __name__ __main__: app.run(host0.0.0.0, port9000)回调签名校验的规则每家服务商也不一样有的是MD5有的是HMAC有的在Header里放Authorization。我这里写了一个常见MD5形式作为参考核心原则是先验签、再处理、后返回否则别人可以伪造回执往你系统里塞脏数据。而且处理逻辑一定要快先落库或丢进队列再异步更新业务状态不要在这个HTTP接口里做重逻辑等服务商超时重推不但麻烦还浪费资源。5. 实战高频踩坑五个问题及其完整排查链路说实话接短信API真正的学习材料不是文档是线上问题的排查过程。这一章我把踩过也帮别人处理过的五个高频问题全部贴出来附带完整的排查思路。5.1 签名对不上先核对时间戳、排序和URL编码问题现象通常是同样的参数在服务商控制台的调试工具里手动调用成功程序里一调就报签名不匹配。排查链路我建议按这个顺序走打开DEBUG日志打印出待签名串和最终签名做脱敏处理后保留与官方文档中给的示例逐字段对比重点看字段顺序和时间戳类型检查待签名串是否需要按参数Key做ASCII码字典序排序后拼接检查是否使用了RFC3986编码空格是不是被转成了%20而不是检查签名算法名称、摘要输出格式十六进制还是Base64。我印象最深的一次就是厂商要求所有参数按字典序先排序再拼接成k1v1k2v2而我直接按固定字段顺序拼待签名串后面排错足足花了半小时。5.2 验证码发不出去频控拦截的排查路径现象白天正常晚上七八点用户密集时段错误码变成isv.BUSINESS_LIMIT_CONTROL这类频控码。这不是系统故障是社会工程问题——所有人在同一个时间点发验证码触达了服务商对单号码或单模板的频控阈值。正确排查顺序是先记录下错误码和原始msg再查询数据库统计该号码最近10分钟、1小时和当天的发送条数随后对照服务商频控策略看是号码维度还是模板维度触限。解决方向不是去服务商后台调高上限而是业务层先做限流。验证码场景建议至少保证同一手机号60秒内只能重发一次、单日不超过10次这些规则放在业务系统里并且要有独立的频控日志出现用户投诉时能马上看到是业务层拦的还是服务商拦的。5.3 API返回成功但用户没收到这是最让人头疼的坑。排查时先拿发送接口返回的messageId调查询接口拉出该条短信的最终状态码。这里要区分两层含义如果是服务商退回一般会给出SIGNATURE_NOT_MATCH、TEMPLATE_NOT_APPROVED这类明确的业务错误码如果是运营商拦截状态报告里通常会出现类似黑名单、敏感词拦截等运营商侧原因。模板里带链接是造成运营商拦截的高发原因。除非你是经过备案的行业客户否则短信内容里出现http链接大概率被拦即使服务商API返回成功也一样。验证方式很简单换一条不带链接的模板向同一手机号发送能收到就基本确定是内容问题。这部分的处理不是修改代码而是和业务方确认合规边界。5.4 手机号格式不一致用户只差一个空格现象表现为一部分用户收不到验证码报错“手机号无效”。核对过完整号码后才发现有的号码来自第三方平台格式可能是138-xxxx-xxxx、86 138...甚至包含全角空格。服务商的解析器不做容错任何非数字字符都可能让号码校验失败。我会在服务入口统一做归一化处理去掉号码中的所有非数字字符并处理86开头的情况import re def normalize_phone(phone: str) - str: digits re.sub(r\D, , phone) if digits.startswith(86) and len(digits) 13: digits digits[2:] if len(digits) ! 11 or not digits.startswith(1): raise ValueError(finvalid phone: {phone}) return digits这个函数还要搭配单元测试把86 138 0000 0000、138-0000-0000等输入都测一遍。号码格式问题看着低级但它造成的用户流失却是真金白银的。5.5 用户收到重复短信超时重试的坑问题现象验证码场景下用户连续收到两条一模一样的短信。排查链路先看服务商后台发送记录确认两条是否由同一条业务请求产生再看应用日志定位第一次请求是否出现超时以及超时后是否触发了自动重发。这事的根因是网络超时和业务超时不一样。请求发出后可能数据已经到服务商只是响应回来的路上超时了。此时自动重发就会造成重复发送。正确做法是对超时场景不要立刻重发先走查询接口确认发送状态再决定要不要补偿。复杂度高一点的做法是引入业务幂等每次请求带一个和业务单号绑定的唯一ID服务商如果支持幂等就直接去重不支持就把message_id落库做本地去重。短信这块宁可少发也不要多发发多了用户会直接卸载App。6. 如果要上生产封装、降级和监控怎么搭Demo跑通只是第一步。真正把短信API接入沉淀成公司的公共能力还要过封装、降级和监控这三关。6.1 把短信客户端封装成业务无感组件我建议把发送逻辑收敛到一个类里对业务只暴露方法不泄露服务商细节。比如Python项目可以定义一个SmsClient提供send_verify_code(phone, code)、send_notification(phone, message)这样语义化的接口。内部统一处理模板参数拼装、签名、超时、异常转换、日志埋点。Java项目如果团队用的是Spring Boot思想一样对外是SmsService接口对内是SmsProvider实现连接用RestTemplate或WebClient统一抛出SmsException上层不需要知道服务商是谁。封装有一个直接好处将来从A家切到B家或升级SDK只影响这一层业务代码零改动。不做封装直接到处调SDK的项目切换成本高到让人想重构。6.2 多通道配置与降级短信作为核心触达通道我不建议只依赖一家服务商。行业惯例是至少接入两家一家主通道、一家备用。具体做法为每家服务商实现同一个接口SmsProvider分别封装各自的发送逻辑。切换逻辑可以是一个简单的配置开关也可以根据调用失败率自动切换。降级不能太激进。我会设置两个条件同时满足才切换连续失败超过N次且单次请求的重试也已用完。这样能避免因为临时抖动就在两个通道间反复横跳反而把两边都搞出问题。切换动作最好有审计日志能查到什么时间点、什么原因、从哪家切到了哪家。6.3 监控指标回执到达率才是硬指标接短信初期我只盯着接口返回成功率后来被现实教育了。真正需要盯的核心指标是回执到达率它是“真实到达用户手机的比例”。生产环境我会至少盯下面几项指标获取方式建议告警阈值API请求成功率本地调用日志统计低于99%触发告警回执到达率回调数据统计连续10分钟低于95%触发告警回执延迟回调时间减发送时间平均超过60秒触发告警频控拦截次数按错误码统计出现频控错误码即告警余额/套餐余量服务商接口或控制台低于阈值触发告警统计到达率时要注意过滤测试号、白名单号否则报表数字会被自己人搞得很失真。同时每条短信最好打上场景标签验证码和通知类的到达率要分开看因为两类业务的容忍度完全不同。6.4 关于短信能力建设我最后想说的三件事第一短信内容是生产力也是风险源。验证码模板里夹带营销语或者频繁发同一内容都会提升被运营商停通道的概率。通道一旦被停整个系统的重要通知链路直接断掉恢复周期以天计这是任何项目都承受不起的。第二密钥管理和额度管理要自动化。开发环境用独立子账号和额度配合余额告警至少能防住“某同事调试脚本刷爆生产短信额度”这种事故。我见过一家公司因为这个问题导致业务高峰期短信停止服务最后只能连夜联系服务商手动充值。第三短信API的超时时间要集中配置。Demo里我写的是5秒生产环境我建议设置3到5秒并且做一个统一的超时异常处理逻辑不要让每个调用方各自定义重试策略。配合前面说的先查后发机制这一条能直接避免大量重复短信投诉。短信集成不是多难的技术但它是一个非常典型的“细节决定成败”的工程场景把上面这些机制一一落地后面维护起来会轻松很多。
返回列表