简介:这份资源面向需要在 .NET 项目中接入阿里云短信服务的开发者,提供可直接参考的短信接口调用示例与配套依赖,覆盖账号鉴权、SendSms 请求参数组装、模板变量传递及响应处理等常见环节,适合用于用户验证码、通知推送等业务场景的入门与联调。压缩包共 92 个文件,约 485KB,以 68 个 cs 源码、18 个 dll 依赖库和 2 个 csproj 项目文件为主,另含少量 txt 说明、xml 配置与 jpg 图片,便于快速查看项目结构和 SDK 引用方式。目前已有 581 人学习下载。读者可从中获得接口调用示例代码、SDK 依赖组织方式以及错误码处理与重试思路,帮助减少从查阅文档到落地实现的试错成本,也可作为既有短信模块排查参数、签名与模板配置问题的对照材料。
1. 短信接口开发最容易出错的地方,往往出现在参数看起来都填对了之后
验证码页面上那个转圈动画转到第 30 秒,用户开始怀疑自己手机欠费——这种场面,做过短信接口开发的人都遇到过。云通信平台把一条短信封装成一个 RPC 风格的 HTTP 请求,看文档只有七八个字段,真正卡住人的却是三段:签名怎么算、模板怎么报、频率怎么控。这份接口开发例子给的就是一条能跑通的最小链路,从 AccessKey 鉴权、参数规范化、签名计算,到发请求、解析回执、按报错码定位问题,每一步都有能直接抄的代码和对照表。适合第一次接云短信的初中级后端,也适合接过但总在签名和模板上栽跟头的人,回头把参数逐个核对一遍。
2. 签名机制拆解:RPC 风格请求的三步拼装
2.1 参数为什么全变字符串:RPC 风格与 REST JSON 的取舍
短信网关这类基础设施,选择 query string 加表单这种扁平结构,并不是技术落后,而是权衡后的结果。它的调用方横跨 Java、PHP、Go、Node、Python,还要被各种老系统和网关设备调用,所以接口只认一种东西:字符串键值对。所有参数一律转成字符串,签名就是对拼好的字符串做一次 HMAC,任何语言只要有一个 HTTP 客户端和 HMAC 库,二十分钟就能接完,不需要依赖特定的序列化框架。
代价也很明显:类型信息全丢了。布尔值要写"true"还是"True",数字要不要补零,JSON 里嵌套的模板变量怎么塞进一个字符串参数——这些判断文档里往往一句话带过,实际却最容易出错。REST JSON 的思路是把结构化体直接丢给服务端,看着优雅,但一旦要求 body 参与签名,各语言的 JSON 序列化器在 key 顺序、空格、Unicode 转义上的差异就会让同一个对象算出不同的签名串,排查成本反而更高。
所以这类接口的签名规则才会写成:先排序,再编码,最后拼接。把不确定性全部在客户端消化掉,服务端只按同一套规则重算一遍。
2.2 规范化串的生成:排序、编码与被吃掉的字符
规范化(Canonicalized)这一步是整个签名里最容易出错的地方,通常需要三步处理:
第一步,把公共参数和业务参数合并成一个大字典,按参数名的 ASCII 码升序排序。注意是 ASCII 码,不是字典序,也不是本地化排序,Java 的String.compareTo、Python 的sorted()在纯 ASCII 键名上结果一致,但一旦键名里混进大小写,两者的差异就会暴露出来。参数名统一用大驼峰,就不会有这个问题。
第二步,对每个 key 和 value 做 RFC3986 编码。这里有三处细节跟标准 URL 编码不一样:空格必须编码成%20而不是+;星号*必须转成%2A;波浪号~不能转成%7E,要原样保留。很多语言自带的urlencode默认使用表单编码规则,会直接在这里出错,表现就是签名永远对不上,而参数看着一个没错。
第三步,用&把编码后的键值对按k=v拼接起来,中间不留空格。这一步产生的字符串就是后面要参与签名的原材料,我一般会在调试阶段直接把它打印出来,跟平台提供的在线签名工具逐字节比对。
| 字符 | 表单编码结果 | RFC3986 要求 | 后果 |
|---|---|---|---|
| 空格 | + | %20 | 签名不匹配 |
* | * | %2A | 签名不匹配 |
~ | %7E | ~ | 签名不匹配 |
/ | %2F | %2F | 一致,无需处理 |
+ | %2B | %2B | 一致,但值的+易被误会 |
提示:模板变量和签名名称里如果出现中文,编码必须走 UTF-8,且编码前不要做任何 trim 之外的字符替换。
2.3 HMAC-SHA1 签名与请求组装
规范化串拼好之后,签名本身反而是最简单的一步。按HTTPMethod & 编码后的路径 & 编码后的规范化串拼出 StringToSign,用AccessKeySecret + "&"作为密钥做 HMAC-SHA1,再 Base64,得到的就是Signature。这里最常被忽略的是密钥末尾那个&:它是协议的一部分,漏掉它算出来的结果会完全不对,而且错误信息只会告诉你签名不匹配。
import base64 import hashlib import hmac import uuid from datetime import datetime, timezone from urllib.parse import quote import requests AK_ID = "你的AccessKeyId" AK_SECRET = "你的AccessKeySecret" ENDPOINT = "https://sms.example-gateway.com/" # 替换为你所用平台的服务地址 API_VERSION = "平台文档给定的版本串" # 形如 2017-05-25 的日期串 def encode(value): """严格按 RFC3986 编码:空格转 %20,* 转 %2A,~ 保留""" s = quote(str(value), safe="") return s.replace("+", "%20").replace("*", "%2A").replace("%7E", "~") def sign(params, secret): # 1. 参数名按 ASCII 升序排列 ordered = sorted(params.items(), key=lambda kv: kv[0]) # 2. 拼规范化串,key 和 value 都要编码 canonical = "&".join("{}={}".format(encode(k), encode(v)) for k, v in ordered) # 3. 拼 StringToSign:POST & %2F & 编码后的规范化串 string_to_sign = "POST" + "&" + encode("/") + "&" + encode(canonical) # 4. 密钥末尾必须补一个 &,这是协议硬性要求 key = (secret + "&").encode("utf-8") digest = hmac.new(key, string_to_sign.encode("utf-8"), hashlib.sha1).digest() return base64.b64encode(digest).decode("utf-8"), canonical这段代码里的encode单独抽出来是有意为之,因为它在签名阶段和最终提交阶段都要用,而且规则必须完全一致。sign返回规范化串,是为了让调用方能在日志里留一份,出问题时不用重新推导。string_to_sign里用的是encode("/")而不是直接写%2F,虽然结果相同,但保留了规则的一致性,将来换成 GET 方法时只需要改"POST"这一处。
参数说明上还有两点:SignatureNonce每次请求必须唯一,用 UUID4 最省事,重复的 nonce 会被服务端当作重放直接拒绝;Timestamp必须是 UTC 时间,格式YYYY-MM-DDTHH:MM:SSZ,本地时间直接格式化过去会差 8 小时,触发的是时间戳过期错误,而不是签名错误——这个错法很容易让人往签名方向排查,白白浪费半天。
3. 发送链路落地:参数表、手写调用与 SDK 切换
3.1 公共参数与业务参数对照表
这类接口的参数分两层。公共参数描述「怎么鉴权、发给谁处理」,业务参数描述「发什么内容」。两层参数一起参与签名,所以拼接时必须合并成一个字典,不能分开签名再合并。
| 参数名 | 层级 | 是否必填 | 说明与常见错法 |
|---|---|---|---|
| Action | 公共 | 是 | 固定为发送动作名,写成SendSms之类,大小写敏感 |
| Version | 公共 | 是 | API 版本串,照抄文档,不要自己改成当前日期 |
| Format | 公共 | 否 | 建议显式写JSON,不写时部分实现返回 XML,解析会失败 |
| AccessKeyId | 公共 | 是 | 明文传输,最后一个参数才出现,签名里必须包含 |
| SignatureMethod | 公共 | 是 | HMAC-SHA1,写成HMACSHA1或小写都会失败 |
| SignatureVersion | 公共 | 是 | 1.0,带小数点,别写成整数 1 |
| SignatureNonce | 公共 | 是 | 唯一随机串,UUID 即可,重试时也必须换新的 |
| Timestamp | 公共 | 是 | UTC 时间,格式固定,不能用本地时区 |
| Signature | 公共 | 是 | 最后计算出来的结果,不能参与自身的签名 |
| PhoneNumbers | 业务 | 是 | 国内号码 11 位纯数字,多个号码用英文逗号分隔 |
| SignName | 业务 | 是 | 必须与报备通过的签名完全一致,含标点 |
| TemplateCode | 业务 | 是 | 模板编号,报备后由平台生成,不要手写 |
| TemplateParam | 业务 | 否 | JSON 字符串,紧凑格式,不能带多余空格 |
| OutId | 业务 | 否 | 业务侧流水号,用于对账,不参与内容渲染 |
Signature自身不参与签名计算,这一点看似显然,但不少人在写通用签名函数时把整个参数字典丢进去,先算出签名再塞回字典,结果就是算了两遍。正确顺序是先构造好除Signature外的全部参数,计算完成后追加。
3.2 手写一版可运行的发送函数
把签名函数和请求组装串起来,就是一个完整可运行的调用。这里用requests发 POST,参数走 form body,与签名时使用的POST方法保持一致。
def send_sms(phone, sign_name, template_code, template_param_json): params = { "Action": "SendSms", "Version": API_VERSION, "Format": "JSON", "AccessKeyId": AK_ID, "SignatureMethod": "HMAC-SHA1", "SignatureVersion": "1.0", "SignatureNonce": str(uuid.uuid4()), "Timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "PhoneNumbers": phone, "SignName": sign_name, "TemplateCode": template_code, "TemplateParam": template_param_json, } signature, canonical = sign(params, AK_SECRET) params["Signature"] = signature # 连接超时 3 秒、读超时 5 秒,短信场景不适合长时间挂起 resp = requests.post(ENDPOINT, data=params, timeout=(3, 5)) return resp.json(), canonical if __name__ == "__main__": body, canonical = send_sms( phone="13800000000", sign_name="某演示签名", template_code="SMS_000000001", template_param_json='{"code":"482913"}', ) print(canonical) # 排查签名时第一件事就是把这行打出来 print(body)template_param_json是字符串而不是字典,这是很多人第一次接入时容易忽略的地方。如果你传的是字典,requests会把它序列化,key 顺序和空格都不受控,最终签名用的值和实际发出的值就可能不一致。正确做法是自己json.dumps(obj, separators=(",", ":"), ensure_ascii=False)生成紧凑串,先签名再提交。
超时设置也值得单独说一句。短信发送是用户点「获取验证码」后同步等待的操作,接口卡住比失败更糟。连接超时压到 3 秒、读超时 5 秒,超时后走异步补偿,比让前端等 30 秒体验好得多。
3.3 换成官方 SDK 后要改的三处
手写版适合排查问题和理解协议,真正上生产大多会换官方 SDK,因为它封装了重试、签名、错误码映射。切换时要改的地方其实只有三处,其余逻辑可以原样保留。
# 以主流云通信平台官方 SDK 的通用形态示意,类名与方法名以你实际引入的包为准 from sms_sdk import SmsClient, Config config = Config( access_key_id="你的AccessKeyId", access_key_secret="你的AccessKeySecret", endpoint="sms.example-gateway.com", connect_timeout=3000, # 毫秒 read_timeout=5000, ) client = SmsClient(config) resp = client.send_sms( phone_numbers="13800000000", sign_name="某演示签名", template_code="SMS_000000001", template_param={"code": "482913"}, # SDK 内部负责序列化 ) if resp.code == "OK": print(resp.biz_id, resp.request_id) else: print(resp.code, resp.message, resp.request_id)第一处是模板参数从字符串变回字典,SDK 内部会做紧凑序列化,这一点比自己拼安全。第二处是超时单位从秒变毫秒,不同 SDK 约定不一样,写错会导致「秒」被当成「毫秒」,请求瞬间超时。第三处是异常模型,SDK 通常把网络异常和业务错误码分开抛出,业务码在resp.code里,网络异常走异常捕获,两套逻辑不能混在一起写,否则重试策略会失控。
3.4 回执字段解析与落库设计
发送接口返回的字段不多,但每一个都有用途。Code为OK只代表请求被受理,不代表用户已收到,送达状态要走回执或查询接口。BizId是这条短信的流水号,查送达状态、做对账都靠它。RequestId是本次 HTTP 请求的追踪号,报障给平台时对方第一句就问这个。
| 字段 | 含义 | 建议落库方式 |
|---|---|---|
| Code | 请求受理结果 | 字符串字段,长度为 64,不要只存成功或失败两种 |
| Message | 错误描述 | 保留原文,便于按关键词聚合 |
| RequestId | 请求追踪号 | 建普通索引,报障时按它查 |
| BizId | 短信流水号 | 建唯一索引,回执回来时用它更新状态 |
| OutId | 业务流水号 | 建议存业务主键,做幂等 |
落库时最容易忽略的是 Code 的存储长度。一开始用varchar(16)够用,等接入平台越来越多,错误码前缀越来越长,就会遇到截断。我一般直接给到 64,多占不了多少空间,省得后面改表。
4. 签名与模板报备:上线前绕不开的两道审核
4.1 签名报备:为什么「公司简称」经常被打回
短信签名是显示在短信开头方括号里的那几个字,代表发送方身份。它必须先报备审核通过才能使用,而这一步是纯人工或半人工审核,卡住的天数往往比写代码还长。
被打回最常见的原因是签名与主体资质不匹配。比如营业执照上的全称是「某某网络科技(上海)有限公司」,签名写成「某某科技」,如果「某某科技」不能从全称中直接提取出来,审核方就会要求补充授权材料。稳妥的写法是从主体全称里截取连续片段,或者使用已注册商标名称,并且准备好商标证书。
第二类原因是签名内容本身不合规。纯英文、纯数字、带「客服」「通知」「官方」这类容易被用于冒充的词汇,基本都会被拒。个人开发者申请时,签名通常只能用自己的姓名或已备案的网站名称,不能随便起。
第三类原因是重复。同一主体下已经存在相同的签名,或者签名与已注册的商标、品牌冲突,会被要求说明关系。这类问题没有代码可解,只能提前查平台已有的签名列表,避开。
注意:签名一旦报备通过就不要随意修改,哪怕是加一个空格。修改后需要重新审核,审核期间旧签名可能失效,正在跑的验证码链路会直接中断。
4.2 模板占位符设计:个数、命名与顺序
模板是短信正文的固定格式加变量占位。占位符一般写成${变量名}的形式,报备时提交,通过后按模板编号调用。
设计模板时有三条硬约束。第一,占位符个数必须与调用时传入的变量个数完全一致,少传会报参数缺失,多传会报变量不匹配,两者都是业务错误码,不会自动降级。第二,变量名在同一个模板内不能重复,也不能出现纯数字或数字开头,有些实现允许中文变量名,但跨语言传递时容易出编码问题,我一般坚持用code、name、time这类纯小写英文。第三,占位符顺序与传入顺序无关,但必须按名字对应,所以TemplateParam里传的是键值对而不是数组。
举一个验证码模板的常见形态:正文是「您的验证码是 ${code},5 分钟内有效,请勿泄露」。这里只有一个变量,传{"code":"482913"}即可。如果模板里写了「${code}」和「${minutes}」两个变量,就必须两个都传,哪怕minutes是固定值 5,也得在代码里补上,因为服务端只做存在性校验,不做默认值填充。
4.3 变量内容的长度与内容红线
变量内容不是想填什么就填什么。验证码类模板的变量通常是 4 到 6 位数字,超过长度会被拒;通知类模板的单个变量一般限制在 20 到 35 个字符之间,具体数值各平台不同,报备时文档里会写。超长的表现是返回参数长度超限,而不是截断,所以代码里要提前做长度校验,别指望服务端帮你裁。
内容红线更要紧。变量里不能出现网址、二维码链接、微信号、QQ 号这类导流信息,也不能出现「中奖」「返现」「贷款」等营销诱导词。一旦触发内容审核,轻则单条被拒,重则模板被下线、账号受限。我见过有项目把订单备注直接塞进变量,用户备注里写了个淘宝链接,整条短信发送失败,排查了半天才定位到是内容审核拦的。
稳妥做法是在服务端对变量做一次白名单过滤:验证码类只允许数字,通知类去掉http、www、@这些特征串,长度超限直接拒绝并记日志。这层校验不增加多少成本,但能避免很多莫名其妙的发送失败。
5. 常见问题排查:四类报错的现象、原因与解决
5.1 SignatureDoesNotMatch:签名不匹配的四种成因
现象很统一:HTTP 状态 400,返回体里错误码是签名类错误,提示签名不匹配或签名校验失败。参数看起来全对,就是过不去。
第一种成因是编码不一致。前面说过,空格、星号、波浪号是三个高频点。排查方法是把规范化串打印出来,和平台在线签名工具的输出做字符串比对,第一个不一致的字符位置就是问题所在。第二种成因是密钥拼接漏了末尾的&,这个错误不会报任何提示,只能靠自己检查代码。第三种成因是参数集合不一致,签名时用了 12 个参数,实际提交时多塞了一个或漏了一个,服务端重算的结果自然不同。第四种是时间戳用了本地时区,虽然报的可能是签名错误,但根因在时间。
解决的通用套路是:固定一组参数,逐字符比对规范化串,先排除编码问题,再核对参数集合,最后确认时间和 nonce。
5.2 模板类报错:变量个数与内容格式
现象是错误码里带模板或变量字样,提示模板不存在、变量不匹配、参数非法。这类错误跟签名无关,签名是对的,问题出在内容上。
模板不存在通常是编号抄错,或者模板还在审核中没生效。变量不匹配是传入的 key 和模板占位符对不上,比如模板写${code},代码里传了{"verifyCode":"1234"}。参数非法是变量值本身有问题,长度超限、含非法字符、不是纯数字。
解决方式是在调用前做一次本地校验:模板编号从配置中心读,不硬编码;变量 key 用常量定义,和模板一起维护;变量值按模板类型做正则校验,验证码必须是^\d{4,6}$。
5.3 InvalidTimeStamp 与限流:两类「看起来像网络问题」的错
时间戳过期的现象是请求偶尔成功偶尔失败,或者隔一段时间集中失败。原因是Timestamp用了本地时间,实际比 UTC 快 8 小时,超过服务端允许的偏差窗口就被拒。解决很简单,统一用 UTC 生成,别用datetime.now()。
限流的现象是同一号码短时间内连续发送,前几条成功,后面开始报业务限流。这类限制通常按号码维度做分钟级、小时级、天级三层,三个维度独立计算。解决不能只靠捕获错误码,要在业务侧先做一层本地的发送频率控制,比如同一号码 60 秒内只允许一次,同一 IP 每小时限制次数。本地先挡住大部分,平台侧就不会触发。
提示:限流错误不要盲目重试。业务限流类的错误码重试只会让封禁时间变长,正确做法是返回明确的倒计时给前端。
5.4 手机号格式与批量发送
现象是返回号码非法,或者批量发送时部分成功部分失败。原因是号码带了+86前缀、带了空格或横线、或者长度不对。国内号码必须是 11 位纯数字,国际号码要带国家码但不带+。
批量发送时多个号码用英文逗号分隔,不能用中文逗号、分号或空格。一次提交的号码数量有上限,通常是几百个,超了要分批。更要注意的是,批量接口是整体受理、逐条发送,返回的成功只代表请求受理,具体哪些号码失败要看回执。
处理方式是在入口处统一做号码清洗:去掉空格、横线、括号和+86,正则校验^1[3-9]\d{9}$,不通过的直接在前端提示,不要提交到服务端。批量发送前按上限切分,每批记录对应的号码列表,方便对账。
6. 进阶:把一次发送做成可追溯的链路
短信这条链路短,但涉及的外部依赖不少:本地网络、平台网关、运营商通道。出了问题如果没有留痕,排查全靠猜,很容易变成难以定位的盲区。我在吃过几次亏之后,给每次发送固定记录五类信息:请求时间、规范化串的哈希(不是原文,避免泄露密钥相关材料)、RequestId、BizId、返回码。这五项凑齐,任何一条失败短信都能在三分钟内定位到是参数问题、平台问题还是通道问题。
一个实用的做法是给发送函数包一层重试,但只对特定错误重试。网络超时、连接被重置这类可以重试一到两次,重试时必须重新生成SignatureNonce和Timestamp,否则会被当成重放拒绝。业务类错误码一律不重试,直接在业务层转换成用户能看懂的提示。
import time RETRYABLE = {"Throttling", "ServiceUnavailable", "RequestTimeout"} def send_with_retry(payload, max_attempts=2): last_err = None for attempt in range(max_attempts + 1): try: body, _ = send_sms(**payload) except Exception as exc: # 网络层异常,可重试 last_err = exc time.sleep(0.2 * (attempt + 1)) # 简单退避,别用固定间隔 continue if body.get("Code") == "OK": return body if body.get("Code") in RETRYABLE and attempt < max_attempts: last_err = body time.sleep(0.2 * (attempt + 1)) continue return body # 业务错误直接返回,不重试 raise RuntimeError(f"send failed after retries: {last_err}")这段代码里RETRYABLE集合是核心,它把「值得重试」和「重试也没用」区分开。退避时间用0.2 * (attempt + 1)递增而不是固定值,是为了避免多条短信在同一时刻集中重试,把限流触发得更彻底。
幂等这块靠OutId。把业务主键(比如订单号加场景码)放进OutId,数据库里对BizId建唯一索引,回执回来时按BizId更新状态,重复回执直接忽略。这样即使重试导致同一条短信发了两次,状态也不会错乱。
验证签名是否算对,还有一个笨但有效的办法:拿一组固定参数,本地算出的规范化串和平台在线工具的输出做==比较,逐字符看差异。我习惯在接入新平台的第一天就把这个比对脚本跑通,把结果存成单元测试的固定向量,以后再改编码逻辑,跑一遍测试就知道有没有破坏原有行为。
从那以后,我每次接入新的短信通道,都会先把「固定参数 → 规范化串 → 签名结果」这条链路的比对脚本写出来,再动手写业务代码。这个习惯救过我很多次,因为它把玄学问题变成了可复现的字符串差异。希望帮到你。
本文还有配套的精品资源,点击获取