首页 / 资讯中心 / 文章详情

第三方短信API接入实战:签名算法、回调与避坑指南

第三方短信API接入实战:签名算法、回调与避坑指南 ★ FEATURED ARTICLE
做后端这些年接过的第三方服务里通知短信属于那种“平时没人提出事全怪你”的模块。前阵子团队做短信服务商迁移把验证码、告警、工单通知全部对接一套新的第三方短信API整个过程捋下来发现真正麻烦的不是发一条消息而是鉴权签名、模板审核、状态回执、异常重试这些细节。这篇东西不打算堆官方文档我直接用一套可以跑起来的Demo把通知短信接入第三方服务商的核心链路讲清楚从选型到签名原理再到回调排查新手照着做也能在半天内完成联调。1. 通知短信接入前的思路与踩坑1.1 自己发短信不是不行而是不划算很多团队第一次做短信功能第一反应是自己找运营商通道或者买一台短信猫设备。我的建议是除非你有运营商背景否则别碰这种方案。短信猫插着SIM卡靠AT指令发消息看似自由实际恶心得很——设备并发低、SIM卡容易被运营商风控、通道被投诉多了还会被封号。就算你拿到的是正规行业通道自建系统还要处理消息状态、重发机制、模板管理、计费对账这些运维成本远大于你想象的水平。更合理的方案是接入第三方短信服务商。服务商把运营商通道、状态回执、高并发调度这些脏活累活都封装好了你只需要调一个HTTP接口把手机号、模板ID、参数传过去剩下的交给网关。市面上主流的服务商比如阿里云、腾讯云、容联云、Twilio核心能力都是这个模式差别在定价、送达率、回调质量这些细节上。1.2 选型时到底在看什么很多人选短信服务商只看单价比如“一条三分钱”还是“一条四分钱”然后用脚投票。但短信这种业务单价只是最表面的一项。我整理了几个真正影响线上体验的指标评估维度具体关注点为什么重要送达率真实环境下验证码短信到达率是否稳定到达率低于99%就别考虑了用户收不到验证码直接流失状态回执是否提供实时状态报告DELIVRD/UNDELIV等没有回执你连短信被网关拒了都查不到模板审核模板审核时效和通过率新用户注册模板经常改文案审核慢会卡业务接口质量API的SLA、超时时间、错误码文档接口不稳定会拖垮你的主流程回调能力是否支持HTTP回调、回调是否带签名需要拿状态回执做业务闭环时必须要有封禁与风控是否容易因为频率问题封你的号验证码场景天然高频率选错服务商天天被限流我之前碰到过一个服务商销售时承诺“到达率99.9%”结果半夜发高并发的时候接口本身就开始5秒超时最后平均送达率只有97%。所以选型阶段别只看PPT建议让销售给你开一个测试账号自己写脚本跑1000条真实号码测试统计延迟和状态回执用数据说话。1.3 接入前先把这几个名词搞懂第三方短信接口的文档普遍存在“每个字都认识连起来看不懂”的问题。我在这里把几个高频名词用大白话说一下签名短信开头用【】包起来的那段比如【XX云】。它不是自己随便加的要先在服务商后台申请审核通过后才能用。模板短信正文的固定格式比如“您的验证码为${code}5分钟内有效”。变量用占位符表示也要先审核。TemplateCode / SignName模板和签名在服务商平台上的唯一编号代码里传的就是这两个值。AccessKey / Secret你的账号身份凭证。调用API时用来生成签名告诉服务商“我是谁”相当于API的钥匙。状态报告回执短信发出后运营商返回的最终投递结果标志短信是送到用户手机、被拒收还是被拦截。记住签名和模板是两套独立审核体系。有些人只申请了签名忘了申请模板导致代码里明明传了值服务商还是报“模板不存在”排查半天才发现是审核还没过。2. 短信API的底层机制与核心概念2.1 一条通知短信的完整旅程要把接口调好首先得知道发一条短信的过程你的业务系统调用服务商的HTTP API传入手机号、模板ID、模板参数。服务商校验签名和账户余额判断消息是否合法。通过校验后消息按手机号归属地路由到对应的运营商短信网关移动、联通、电信。运营商网关把短信下发到用户所在基站。手机收到短信后运营商网关产生一条状态回执原路返回给服务商。服务商通过回调接口或状态查询接口把最终结果告诉你。这个过程看起来是一条直线实际上每一跳都可能出问题。最典型的是第3步到第5步之间服务商返回“发送成功”只表示它受理了你的消息并不代表用户一定收到了。真正可靠的判断标准是回执状态比如运营商返回DELIVRD才说明短信真正到了手机。所以我在设计系统时一直坚持一个原则发送接口的结果只能作为参考业务闭环必须依赖回执。比如“验证码是否发出去”判断不了用户是否收到只有回执为成功才算一次有效发送。2.2 鉴权、签名与报文结构第三方短信API的鉴权方式看起来各家不一样但底层模型大差不差核心就三件事身份、防篡改、防重放。身份通过 AccessKeyId 标识调用者。防篡改把请求参数部分、或全部加上 Secret 做签名服务端用同样的算法校验。防重放请求里带一个随机字符串SignatureNonce或时间戳服务端发现同样随机串再次出现就拒绝。我拿一个比较通用的请求模型举例。假设你要调SendSms这个动作请求参数一般长这样ActionSendSms AccessKeyIdLTAI5t**** PhoneNumbers138****1234 SignName【XX科技】 TemplateCodeSMS_123456 TemplateParam{code:123456} Timestamp2025-05-06T12:00:00Z FormatJSON Version2017-05-25 SignatureNonce550e8400-e29b-41d4-a716-446655440000这里最关键的一步是生成Signature。通用的算法套路是对所有请求参数按字典序排序。按照keyvalue的方式拼接成规范化字符串每个值都要做URL编码。拼上请求方法和路径组成StringToSign。用你的 AccessKeySecret 作为密钥对这个字符串做 HMAC-SHA256 摘要然后 Base64 编码。把签名追加到请求参数中随请求一起发送。具体的编码规则通常是这样大写字母、小写字母、数字、-、_、.、~不编码其他字符用百分号编码空格要编码成%20而不是。踩坑人最多的就是这里很多语言自带的urlencode会把空格编码成服务端不认直接报签名错误。2.3 模板和签名的关系签名和模板的关系很容易混淆。签名是“谁发的”模板是“发什么内容”。发送短信时两个都必须在服务商后台申请并审核通过。我在项目里见过一个典型错误开发同学把签名理解成了“短信内容里的变量”直接把商户名称传进去结果报错InvalidSignName。还有一次我们业务需要一个临时通知文案同事图省事把变量写在了签名位置结果短信整条发出来格式都是乱的。正确做法是签名写死在配置文件里模板参数只在TemplateParam中传递。3. 从零写出一个可运行的Demo3.1 环境准备与工程目录下面我带大家写一个最简单的可运行Demo功能就是发送一条带验证码的通知短信。语言我用Python原因是签名算法逻辑直观适合解释原理。你换成Java、Go、Node.js思路完全一样。环境准备Python 3.8requests库用来发HTTP请求一个测试用的短信服务商账号拿到 AccessKeyId 和 AccessKeySecret一个已审核通过的签名和模板工程目录很简单sms-demo/ ├── sms_sender.py # 核心发送逻辑 └── config.py # 配置信息在config.py中填入你的配置ACCESS_KEY_ID 你的AccessKeyId ACCESS_KEY_SECRET 你的AccessKeySecret ENDPOINT https://api.example.com/send # 换成服务商文档里的真实地址 SIGN_NAME 【XX科技】 # 已审核的签名 TEMPLATE_CODE SMS_123456 # 已审核的模板ID3.2 签名算法实现与参数计算过程签名算法的代码我拆开讲方便你看懂每一步在干什么。import hashlib import hmac import base64 from urllib.parse import quote def percent_encode(value): 按照规范做URL编码 字母、数字、-、_、.、~ 不编码 其他字符百分号编码空格编码为 %20。 return quote(str(value), safe-_.~) def sign_request(params, secret, methodPOST, path/): 生成接口签名。 params: 请求参数字典 secret: AccessKeySecret method: 请求方法一般是 POST path: 请求路径一般是 / # 1. 参数按字典序排序 sorted_keys sorted(params.keys()) # 2. 拼接规范化请求串 canonicalized_query_string .join( f{percent_encode(k)}{percent_encode(params[k])} for k in sorted_keys ) # 3. 拼出 StringToSign # 这里是通用套路HTTP方法 路径 规范化参数串路径和参数都要再编码一次 string_to_sign f{method}{percent_encode(path)}{percent_encode(canonicalized_query_string)} print( StringToSign 用于排错 ) print(string_to_sign) print() # 4. 用 Secret 做 HMAC-SHA256结果 Base64 digest hmac.new( secret.encode(utf-8), string_to_sign.encode(utf-8), hashlib.sha256 ).digest() return base64.b64encode(digest).decode(utf-8)这一步就是很多人报SignatureDoesNotMatch的根源。我举个例子假设排序拼接后的规范化参数是AccessKeyIdLTAI5t****ActionSendSmsFormatJSONPhoneNumbers138****1234在对它做percent_encode的时候和都会被编码变成AccessKeyId%3DLTAI5t****%26Action%3DSendSms%26Format%3DJSON%26PhoneNumbers%3D138****1234如果你用的是requests或者urllib.parse.urlencode默认行为可能不编码/、?这类字符或者把空格编码成最后算出来的签名和服务端不一致。保险做法是自己封装一个percent_encode不要直接用语言的默认编码函数。3.3 发送Demo完整代码接下来是完整的发送函数。除了签名还要处理模板参数里中文的JSON序列化这个也是容易出问题的点。import json import time import uuid import requests from config import ( ACCESS_KEY_ID, ACCESS_KEY_SECRET, ENDPOINT, SIGN_NAME, TEMPLATE_CODE, ) from sms_sender import sign_request, percent_encode def send_sms(phone, code): 发送验证码短信 phone: 手机号 code: 验证码 # 模板参数JSON字符串中文建议 ensure_asciiFalse template_param json.dumps({code: code}, ensure_asciiFalse) params { Action: SendSms, AccessKeyId: ACCESS_KEY_ID, PhoneNumbers: phone, SignName: SIGN_NAME, TemplateCode: TEMPLATE_CODE, TemplateParam: template_param, Timestamp: time.strftime(%Y-%m-%dT%H:%M:%SZ, time.gmtime()), Format: JSON, Version: 2017-05-25, SignatureNonce: str(uuid.uuid4()), } # 参数里加入签名注意签名字段本身不参与签名运算 params[Signature] sign_request(params, ACCESS_KEY_SECRET) # 发送请求 try: resp requests.post(ENDPOINT, dataparams, timeout10) print(HTTP状态码:, resp.status_code) print(响应内容:, resp.text) result resp.json() # 服务商接口一般用一个 Code 字段标识是否成功 if result.get(Code) OK: print(发送成功消息ID:, result.get(BizId)) else: print(发送失败:, result.get(Code), result.get(Message)) except requests.exceptions.Timeout: print(请求超时需要走重试逻辑) except Exception as exc: print(未知异常:, exc) if __name__ __main__: # 演示给手机号发送验证码 482913 send_sms(13800138000, 482913)运行之后你会看到终端打印出StringToSign可以用来和服务商文档里的示例对比。如果这一步完全一致基本说明签名没问题不一致的话优先检查编码规则。响应里那个BizId要记下来它是这条短信的服务商侧唯一标识后面查状态报告时会用到。3.4 其他语言参考curl与Java有时候你在服务器上快速验证不想写代码curl是最快的。下面是一个简化的curl示例注意签名要先用脚本算好curl -X POST https://api.example.com/send \ -H Content-Type: application/x-www-form-urlencoded \ --data-urlencode ActionSendSms \ --data-urlencode AccessKeyIdLTAI5t**** \ --data-urlencode PhoneNumbers13800138000 \ --data-urlencode SignName【XX科技】 \ --data-urlencode TemplateCodeSMS_123456 \ --data-urlencode TemplateParam{code:482913} \ --data-urlencode Signature你的签名值Java这边我建议用服务商官方SDK而不是自己手写签名。原因很简单官方SDK把签名、重试、序列化都封装好了经受过生产环境考验。自己实现的话至少要关注TreeMap排序、URLEncoder编码、HMAC-SHA256 这几个点。// 伪代码示例展示核心步骤 TreeMapString, String params new TreeMap(); params.put(Action, SendSms); params.put(PhoneNumbers, 13800138000); // ... 其他参数 StringBuilder canonicalized new StringBuilder(); for (Map.EntryString, String entry : params.entrySet()) { canonicalized.append(percentEncode(entry.getKey())) .append() .append(percentEncode(entry.getValue())) .append(); } // 去掉末尾 // 然后拼 method、path用 HMAC-SHA256 签名Java里最隐蔽的坑是URLEncoder.encode会把空格编码成而短信API要求的是%20。如果你没做 replace 处理签名必定报错。4. 接入后的工程化处理4.1 回调接收与幂等发了短信不代表完事真正要做的还有状态报告回调。服务商在运营商回执回来之后会往你配置的回调地址推一条数据大概长这样{ message_id: 123456789, phone: 13800138000, status: DELIVRD, err_code: DELIVRD, send_time: 2025-05-06 12:00:01, report_time: 2025-05-06 12:00:03 }DELIVRD表示用户收到了要是看到UNDELIV或者REJECT就要考虑重新发送或者告警。我用Flask写一个最简回调接收服务from flask import Flask, request, jsonify app Flask(__name__) app.route(/sms/status, methods[POST]) def sms_status_callback(): # 服务商推过来的数据可能是 JSON也可能是 form 表单按实际情况解析 data request.get_json(silentTrue) or request.form.to_dict() message_id data.get(message_id) status data.get(status) if not message_id: return jsonify({code: 1, msg: 缺少 message_id}) # 幂等处理用 Redis SETNX或数据库唯一索引 # key fsms:report:{message_id} # if not redis.set(key, 1, nxTrue, ex86400): # return jsonify({code: 0, msg: duplicate}) # 更新业务侧发送记录状态 if status DELIVRD: print(f短信 {message_id} 已成功送达 {data.get(phone)}) else: print(f短信 {message_id} 投递失败状态码 {status}) # 回调一定要快速应答服务商可能因为超时反复推送 return jsonify({code: 0, msg: ok}) if __name__ __main__: app.run(host0.0.0.0, port5000)这里最要命的就是幂等。服务商为了保证回调必达会在你应答超时或网络抖动时重新推送同一条状态报告。如果你没有按message_id去重用户状态会被连续更新好几次轻则日志混乱重则重复触发业务操作。4.2 重试、限流与缓存短信接口属于外部依赖一定要在调用侧做好重试和限流。重试要分错误类型网络超时比如requests.Timeout可以重试但间隔递增比如第1次2秒、第2次5秒。服务商明确返回业务错误比如签名错误、模板不存在、余额不足不要重试重试一万次结果还是一样反而会把自己账户的并发打满。服务商返回“流量控制”错误比如BUSINESS_LIMIT_CONTROL不要重试说明你触发了频控重试只会加重限制。限流是很多新手忽略的。验证码接口很容易被攻击者盯上用同一个手机号无限薅短信。我在项目里的做法是同一手机号60秒内只能触发一次发送。同一手机号一天内最多发10条验证码。验证码有效期5分钟校验通过后立即删除。模板参数里的验证码用随机数避免被预测。这些逻辑用Redis实现很轻量SETEX就能搞定。限流值要跟服务商后台配置一致否则服务商那边先把你限了你这边还一脸懵。4.3 日志与告警短信这块的日志必须比其他接口多打几个字段。我每次发送都会记录以下信息请求ID自定义生成用来关联整个链路手机号敏感信息可以脱敏但排查时候就知道有多难受模板ID、签名服务商返回的BizId网关耗时最终回执状态告警规则建议设三档发送成功率低于98%比如5分钟内成功率下降立刻报警。服务商接口响应P95耗时超过3秒说明通道抖动提前介入。同一错误码集中出现比如连续10条都是SignNameDoesNotExist多半是配置被改动。我遇到过最坑的一次半夜短信通道整体故障服务商接口返回200但回执全是UNKNOWN如果不是提前盯了成功率告警客户会发现得比我们还早。5. 常见问题和排查技巧实录5.1 高频问题速查表我把这些年集成短信接口遇到的高频问题整理成一个速查表建议收藏。实际上很多问题症状一样底层原因差得很远按表格对照能省不少时间。现象可能原因排查方法报SignatureDoesNotMatchURL编码不规范空格编成Secret配错参与签名的参数不对打印StringToSign跟官方文档给出的示例逐字符对比报InvalidSignName签名没审核通过或签名带了多余字符后台确认签名状态检查签名文本是否完全一致接口返回成功用户收不到手机号在运营商黑名单签名未报备回执未到查状态报告重点看最终回执状态码同一手机号频繁报频控触发了服务商频率限制查看服务商文档的频控规则前端加验证码回调一直重复推送你的回调接口应答超时或返回非2xx确保回调处理器快速应答并做幂等去重中文模板参数乱码TemplateParam没设置ensure_asciiFalse或请求编码不对统一使用UTF-8JSON用ensure_asciiFalse测试号能收到线上收不到线上用的模板和签名没审核或余额不足核对线上账号与测试账号的配置差异发送延迟很高服务商通道拥堵或者手机号是小众运营商看P95耗时建议服务商换通道5.2 一个真实的排查案例有一次线上验证码突然大面积收不到服务商接口一切正常回执状态也显示DELIVRD但用户就是没收到。排查了一天最后发现是签名公司在运营商侧被报备错了导致大部分手机号被运营商策略性拦截。这个案例说两个教训。第一回执成功也不代表用户收到运营商层面可能做了策略拦截服务商感知不到。第二遇到大面积收不到第一时间联系服务商查通道质量别在代码层面瞎找。后来我们跟服务商签了SLA要求“实际到达率”也要统计而不只是看接口状态。还有一次是签名不匹配问题。同事在代码里用了老的Secret测试环境一直报错但服务商后台看到配置没问题。最后发现是公司内部密钥管理系统跟代码仓库不同步旧的Secret没被清理。从那以后我要求所有密钥走统一的密钥管理不在代码里硬编码也不在代码仓库放任何明文密钥。5.3 联调期的几个保命技巧最后分享几个我每次接短信服务商都在用的土办法虽然看起来不起眼但真的能救命。第一第一次联调永远先打日志。把StringToSign和请求参数全部打印出来逐行跟文档中的请求示例对比比瞎猜快得多。签名错误90%都是“看起来一样实际编码不同”。第二回调地址先用公网临时接口测试。服务商回调必须从公网访问你的地址本机localhost永远收不到。没有测试环境的话可以用一个简单的HTTP请求转存平台把回调报文抓下来看一眼确认字段再写正式逻辑。第三手机号写测试白名单。短信是按条计费的而且高频测试容易触发风控我通常要求所有测试环境只能发白名单里的手机号线上配置里也留一份“测试号”白名单避免误操作批量发出去。第四提前准备拨测脚本。线上短信功能不常用很容易悄悄挂掉。我会写一个定时脚本每天给某个测试手机号发一条验证码然后检查回执。这个习惯帮我提前发现过两次服务商通道故障都是早上8点之前自动发现的。这个小投入的产出比相当高强烈建议加进运维巡检清单。
阅读完成 · 觉得有帮助?
咨询建站