做后端这些年,接过的第三方服务里,通知短信属于那种“平时没人提,出事全怪你”的模块。前阵子团队做短信服务商迁移,把验证码、告警、工单通知全部对接一套新的第三方短信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这个动作,请求参数一般长这样:
Action=SendSms AccessKeyId=LTAI5t**** PhoneNumbers=138****1234 SignName=【XX科技】 TemplateCode=SMS_123456 TemplateParam={"code":"123456"} Timestamp=2025-05-06T12:00:00Z Format=JSON Version=2017-05-25 SignatureNonce=550e8400-e29b-41d4-a716-446655440000这里最关键的一步是生成Signature。通用的算法套路是:
- 对所有请求参数按字典序排序。
- 按照
key=value的方式拼接成规范化字符串,每个值都要做URL编码。 - 拼上请求方法和路径,组成
StringToSign。 - 用你的 AccessKeySecret 作为密钥,对这个字符串做 HMAC-SHA256 摘要,然后 Base64 编码。
- 把签名追加到请求参数中,随请求一起发送。
具体的编码规则通常是这样:大写字母、小写字母、数字、-、_、.、~不编码,其他字符用百分号编码,空格要编码成%20而不是+。踩坑人最多的就是这里,很多语言自带的urlencode会把空格编码成+,服务端不认,直接报签名错误。
2.3 模板和签名的关系
签名和模板的关系很容易混淆。签名是“谁发的”,模板是“发什么内容”。发送短信时,两个都必须在服务商后台申请并审核通过。
我在项目里见过一个典型错误:开发同学把签名理解成了“短信内容里的变量”,直接把商户名称传进去,结果报错InvalidSignName。还有一次我们业务需要一个临时通知文案,同事图省事,把变量写在了签名位置,结果短信整条发出来格式都是乱的。正确做法是:签名写死在配置文件里,模板参数只在TemplateParam中传递。
3. 从零写出一个可运行的Demo
3.1 环境准备与工程目录
下面我带大家写一个最简单的可运行Demo,功能就是发送一条带验证码的通知短信。语言我用Python,原因是签名算法逻辑直观,适合解释原理。你换成Java、Go、Node.js,思路完全一样。
环境准备:
- Python 3.8+
requests库,用来发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, method='POST', 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的根源。我举个例子,假设排序拼接后的规范化参数是:
AccessKeyId=LTAI5t****&Action=SendSms&Format=JSON&PhoneNumbers=138****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_ascii=False template_param = json.dumps({"code": code}, ensure_ascii=False) 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, data=params, timeout=10) 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 'Action=SendSms' \ --data-urlencode 'AccessKeyId=LTAI5t****' \ --data-urlencode 'PhoneNumbers=13800138000' \ --data-urlencode 'SignName=【XX科技】' \ --data-urlencode 'TemplateCode=SMS_123456' \ --data-urlencode 'TemplateParam={"code":"482913"}' \ --data-urlencode 'Signature=你的签名值'Java这边,我建议用服务商官方SDK,而不是自己手写签名。原因很简单:官方SDK把签名、重试、序列化都封装好了,经受过生产环境考验。自己实现的话,至少要关注TreeMap排序、URLEncoder编码、HMAC-SHA256 这几个点。
// 伪代码示例,展示核心步骤 TreeMap<String, String> params = new TreeMap<>(); params.put("Action", "SendSms"); params.put("PhoneNumbers", "13800138000"); // ... 其他参数 StringBuilder canonicalized = new StringBuilder(); for (Map.Entry<String, 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(silent=True) 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 = f"sms:report:{message_id}" # if not redis.set(key, "1", nx=True, ex=86400): # 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(host="0.0.0.0", port=5000)这里最要命的就是幂等。服务商为了保证回调必达,会在你应答超时或网络抖动时重新推送同一条状态报告。如果你没有按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 高频问题速查表
我把这些年集成短信接口遇到的高频问题整理成一个速查表,建议收藏。实际上很多问题症状一样,底层原因差得很远,按表格对照能省不少时间。
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
报SignatureDoesNotMatch | URL编码不规范,空格编成+;Secret配错;参与签名的参数不对 | 打印StringToSign,跟官方文档给出的示例逐字符对比 |
报InvalidSignName | 签名没审核通过,或签名带了多余字符 | 后台确认签名状态,检查签名文本是否完全一致 |
| 接口返回成功,用户收不到 | 手机号在运营商黑名单;签名未报备;回执未到 | 查状态报告,重点看最终回执状态码 |
| 同一手机号频繁报频控 | 触发了服务商频率限制 | 查看服务商文档的频控规则,前端加验证码 |
| 回调一直重复推送 | 你的回调接口应答超时或返回非2xx | 确保回调处理器快速应答,并做幂等去重 |
| 中文模板参数乱码 | TemplateParam没设置ensure_ascii=False,或请求编码不对 | 统一使用UTF-8,JSON用ensure_ascii=False |
| 测试号能收到,线上收不到 | 线上用的模板和签名没审核,或余额不足 | 核对线上账号与测试账号的配置差异 |
| 发送延迟很高 | 服务商通道拥堵,或者手机号是小众运营商 | 看P95耗时,建议服务商换通道 |
5.2 一个真实的排查案例
有一次线上验证码突然大面积收不到,服务商接口一切正常,回执状态也显示DELIVRD,但用户就是没收到。排查了一天,最后发现是签名公司在运营商侧被报备错了,导致大部分手机号被运营商策略性拦截。
这个案例说两个教训。第一,回执成功也不代表用户收到,运营商层面可能做了策略拦截,服务商感知不到。第二,遇到大面积收不到,第一时间联系服务商查通道质量,别在代码层面瞎找。后来我们跟服务商签了SLA,要求“实际到达率”也要统计,而不只是看接口状态。
还有一次是签名不匹配问题。同事在代码里用了老的Secret,测试环境一直报错,但服务商后台看到配置没问题。最后发现是公司内部密钥管理系统跟代码仓库不同步,旧的Secret没被清理。从那以后我要求所有密钥走统一的密钥管理,不在代码里硬编码,也不在代码仓库放任何明文密钥。
5.3 联调期的几个保命技巧
最后分享几个我每次接短信服务商都在用的土办法,虽然看起来不起眼,但真的能救命。
第一,第一次联调永远先打日志。把StringToSign和请求参数全部打印出来,逐行跟文档中的请求示例对比,比瞎猜快得多。签名错误90%都是“看起来一样,实际编码不同”。
第二,回调地址先用公网临时接口测试。服务商回调必须从公网访问你的地址,本机localhost永远收不到。没有测试环境的话,可以用一个简单的HTTP请求转存平台,把回调报文抓下来看一眼,确认字段再写正式逻辑。
第三,手机号写测试白名单。短信是按条计费的,而且高频测试容易触发风控,我通常要求所有测试环境只能发白名单里的手机号,线上配置里也留一份“测试号”白名单,避免误操作批量发出去。
第四,提前准备拨测脚本。线上短信功能不常用,很容易悄悄挂掉。我会写一个定时脚本,每天给某个测试手机号发一条验证码,然后检查回执。这个习惯帮我提前发现过两次服务商通道故障,都是早上8点之前自动发现的。这个小投入的产出比相当高,强烈建议加进运维巡检清单。