简介:阿里云短信接口demo.zip 面向需要快速接入短信能力的 PHP 开发者与中小团队,聚焦验证码、通知消息等场景下的接口集成与调试。压缩包约 3.35MB,文件总数与主要文件类型明细暂未提供,核心内容为 PHP SDK 演示工程,便于在项目中引入 SDK、配置 Access Key 后调用发送接口。资源围绕短信发送的关键环节展开:Access Key 鉴权、短信模板与签名配置、SendSms 参数传递,以及同步/异步调用和错误码处理思路,都可作为联调参考。它不提供现成业务系统,而是给出可运行的最小示例,帮助读者理解请求构造、返回结果解析与常见异常定位。目前已有 274 人学习。适合具备 PHP 基础、正准备接入阿里云短信服务的开发者用作上手样板,也可为模板变量替换、发送频率控制与安全性设置提供对照思路。
1. 阿里云短信接口 demo 的第一层价值:把「能发」和「发对」拆开看
很多人拿到阿里云短信接口 demo.zip 的第一反应,是改掉 AccessKey、跑一次发送、看到返回Code=OK,就认为这件事已经做完了。真正上线后反过来影响你的,往往不是 SDK 调用写错,而是签名没过审、模板变量超长、同一个号码一分钟内被连点三次、请求超时后重试把一条验证码发成了两条。这份 demo 的价值不在教你 import 哪个包,而在于它把「初始化客户端 → 组装参数 → 判返回码」这条最短链路固定下来,你在这条链路上加频控、加日志、加幂等,比从零拼一个要少踩很多坑。
它适合两类人:第一次接短信服务的后端,以及接手了别人留下的半成品短信模块、打开只看见一堆硬编码密钥和没有兜底的try-catch的人。前者需要知道哪些字段是必须从控制台拿的、哪些是自己造的;后者需要知道这套接口的失败模式长什么样,才不会把「调用成功」当成「用户收到」。
2. 跑通第一条短信:三处必改配置与最小调用链
拿到压缩包之后,先别急着看业务代码,先看它把配置放在哪。绝大多数短信 demo 的结构都一样:一个客户端初始化类或者函数、一个参数组装的地方、一个返回值判断的地方。三处都改对,第一条短信才能出去。
2.1 三处必改配置与一张参数对照表
demo 里通常有一份写死的配置,可能叫config、constant或者干脆写在main里。这几个字段你得逐个对照自己的账号改,缺一个都发不出去:
| 字段 | 从哪来 | 典型错误 | 后果 |
|---|---|---|---|
| AccessKeyId | RAM 控制台的子账号密钥 | 直接用了主账号密钥 | 权限过大,密钥泄露后影响范围不可控 |
| AccessKeySecret | 同上,只在创建时可见一次 | 复制时带了空格或换行 | SignatureDoesNotMatch |
| RegionId / endpoint | 短信服务是全局服务,常用dysmsapi.aliyuncs.com | 照抄了其他产品的cn-hangzhou | 请求打到错误网关,超时或 404 |
| SignName | 控制台「签名管理」里审核通过的名称 | 自己随手写公司简称 | isv.SMS_SIGNATURE_ILLEGAL |
| TemplateCode | 控制台「模板管理」里的SMS_xxxxxxx | 用了模板 ID 而不是模板 CODE | isv.SMS_TEMPLATE_ILLEGAL |
| PhoneNumbers | 业务传入 | 带了+86或分隔符 | 国内短信报号码非法 |
| TemplateParam | 业务生成 | 拼成code=123456而不是 JSON | isv.INVALID_JSON_PARAM |
这张表里最容易出问题的是RegionId和TemplateCode。短信服务的 endpoint 跟 ECS、OSS 那套地域概念不是一回事,很多人把cn-hangzhou抄过来,本地能跑是因为 SDK 做了兜底,换到某些语言的老版本 SDK 上就直接连不上。至于TemplateCode,控制台列表里同时显示「模板 ID」和「模板 CODE」,长得很像,但接口只认SMS_开头那个。
提示:签名和模板必须是「已审核通过」状态。审核中的签名调接口一样会返回非法,不要以为提交了就能用。
2.2 凭证外置:RAM 子账号加环境变量
demo 为了让你一键运行,通常把密钥明文写在代码里。这一步必须改掉,否则这份代码一旦进了 Git 历史,后面就很难清理干净了。常见做法是建一个 RAM 子账号,只授予短信服务相关权限,然后把密钥注入环境变量。
# 只在当前 shell 生效,避免写进 ~/.bashrc 被其他进程读到 export SMS_AK_ID="你的AccessKeyId" export SMS_AK_SECRET="你的AccessKeySecret" export SMS_SIGN_NAME="审核通过的签名" export SMS_TEMPLATE_CODE="SMS_000000000" # 验证是否注入成功,注意不要 echo secret 本身 env | grep '^SMS_' | sed 's/=.*/=***/'这里有个取舍要说清楚:为什么不直接用主账号密钥。主账号密钥等同于账号全权限,一旦被打包进镜像、被日志打印、被提交到仓库,损失面是整个账号而不只是短信。子账号的授权策略按最小权限给,只需要短信发送相关的那几个 Action。环境变量也不是终点,生产环境更稳的做法是走配置中心或者密钥管理服务,进程启动时拉取,内存里只留一份,不落盘。
代码里读环境变量时,建议在启动阶段就做一次校验,缺了就抛异常退出,而不是等到第一次发短信才报错。这种「启动即失败」比「运行到一半才发现配置漏了」要好排查得多。
2.3 Python 版 SendSms 逐行拆
Python 是 demo 里最常见的语言之一。新版 SDK 用alibabacloud_dysmsapi20170525,安装两个包:
pip install alibabacloud_dysmsapi20170525 alibabacloud_tea_openapi调用代码如下,注意template_param是字符串不是字典:
import json import os from alibabacloud_dysmsapi20170525.client import Client from alibabacloud_dysmsapi20170525 import models as sms_models from alibabacloud_tea_openapi import models as open_api_models def build_client() -> Client: # 凭证从环境变量读,缺失时直接失败,避免带病运行 config = open_api_models.Config( access_key_id=os.environ["SMS_AK_ID"], access_key_secret=os.environ["SMS_AK_SECRET"], ) # 短信服务走全局 endpoint,不要填地域 ID config.endpoint = "dysmsapi.aliyuncs.com" # 连接和读超时分开设,短信接口正常在百毫秒级返回 config.connect_timeout = 3000 config.read_timeout = 5000 return Client(config) def send_code(phone: str, code: str, out_id: str) -> dict: client = build_client() request = sms_models.SendSmsRequest( phone_numbers=phone, # 国内号码不带 +86 sign_name=os.environ["SMS_SIGN_NAME"], template_code=os.environ["SMS_TEMPLATE_CODE"], # 必须序列化成 JSON 字符串,键名要和模板里 ${code} 的变量名一致 template_param=json.dumps({"code": code}, ensure_ascii=False), out_id=out_id, # 业务流水号,用于后续对账 ) response = client.send_sms(request) body = response.body # 只有 Code 严格等于 OK 才算受理成功,其余一律按失败处理 return { "ok": body.code == "OK", "code": body.code, "message": body.message, "biz_id": body.biz_id, "request_id": body.request_id, }逻辑上有三处值得说明。第一,template_param的类型是字符串,SDK 不会帮你做序列化,直接传{"code": code}会序列化失败或者发出一个空参数;ensure_ascii=False是为了变量里含中文时不出现\uXXXX。第二,out_id是调用方自定义的流水号,最长 128 字符,它不会影响发送,但会在发送记录里回显,是后续对账的唯一抓手。第三,body.code == "OK"这个判断必须严格相等,SDK 的异常和接口的业务失败是两套东西——网络层异常会抛异常,业务层失败是正常返回但Code不是OK,两者都得处理。
注意:
biz_id是回执查询的关键字段,拿到之后一定要和业务单号一起落库,否则后面查「这条短信到底发没发出去」只能靠手机号加日期翻页。
2.4 Java 工程:新版 SDK 与老版 SDK 的依赖岔路
Java 这边有个历史包袱,市面上大量存量代码用的是老包aliyun-java-sdk-dysmsapi配合aliyun-java-sdk-core,初始化方式是DefaultProfile.getProfile("cn-hangzhou", ak, secret)再拿IAcsClient。新项目建议直接用dysmsapi20170525,依赖声明大致如下,版本号交给父 pom 统一管理,别在子模块里散着写:
<dependency> <groupId>com.aliyun</groupId> <artifactId>dysmsapi20170525</artifactId> <version>${dysmsapi.version}</version> </dependency>客户端初始化与调用:
import com.aliyun.dysmsapi20170525.Client; import com.aliyun.dysmsapi20170525.models.SendSmsRequest; import com.aliyun.dysmsapi20170525.models.SendSmsResponse; import com.aliyun.teaopenapi.models.Config; public class SmsSender { private final Client client; public SmsSender() throws Exception { Config config = new Config() .setAccessKeyId(System.getenv("SMS_AK_ID")) .setAccessKeySecret(System.getenv("SMS_AK_SECRET")); config.endpoint = "dysmsapi.aliyuncs.com"; config.connectTimeout = 3000; config.readTimeout = 5000; // Client 内部持有连接池,构造一次复用,不要每次发送都 new this.client = new Client(config); } public SendSmsResponse send(String phone, String paramJson, String outId) throws Exception { SendSmsRequest request = new SendSmsRequest() .setPhoneNumbers(phone) .setSignName(System.getenv("SMS_SIGN_NAME")) .setTemplateCode(System.getenv("SMS_TEMPLATE_CODE")) .setTemplateParam(paramJson) .setOutId(outId); return client.sendSms(request); } }这段代码里两个点容易翻车。一个是Client的复用,它内部带连接池和路由缓存,每次发短信都 new 一个,在高并发下会明显拖慢响应。另一个是Config的endpoint赋值方式,它是公有字段不是 setter,写成config.setEndpoint(...)在某些版本上编译不过。从老包迁移过来的话,这两处是改动最集中的地方。
3. 签名与模板:两个字段填错,SDK 调通也发不出去
第二章跑通了链路,但真实项目里最常见的失败不是代码问题,是签名和模板对不上。现象很迷惑:本地测试能发,一换模板就报错;或者报错信息里写着「签名不合法」,你反复确认签名没错,其实是模板和签名不匹配。
3.1 SignName 从哪来:资质、审核与常见驳回
签名是短信开头那个【某某】。它不是随便写的,必须与你提交的资质对应,比如已备案的网站名称、已上架的 App 名称、已认证的公众号或小程序名称。审核通常几个工作日内出结果,驳回原因集中在三类:名称与资质主体不一致、名称含营销或违规词汇、个人认证账号申请了经营性质的签名。
实操上有个容易忽略的点:签名和模板是绑定关系的松散约束。你有一个通过的签名 A 和模板 B,如果模板 B 在申请时提交的适用场景和签名 A 的主体对不上,调用时一样会被拦。所以换签名之前,先确认要用它的那条模板是不是也能接受这个签名,别等到用户反馈收不到才回头查。
个人认证主体发营销类短信是受限的,验证码、通知类没问题。这个限制会影响你的签名命名——带「商城」「优惠」这类词的签名,个人认证基本过不了,别在这上面浪费审核次数。
3.2 TemplateCode 与变量:为什么 TemplateParam 必须是 JSON
模板长这样:您的验证码是${code},5分钟内有效。接口传的是{"code":"123456"}。这里有两个硬约束:一是 JSON 键名必须与模板里的变量名完全一致,大小写敏感;二是整串必须以 JSON 字符串形式传,不能是对象、不能是多层嵌套。
常见错误写法有三种。第一种拼成code=123456,报isv.INVALID_JSON_PARAM。第二种键名写成Code而模板里是code,报模板变量不匹配。第三种最隐蔽:变量值本身带特殊字符或换行,序列化后虽然合法,但模板渲染时超长被拒。
import json def build_param(code: str) -> str: # 键名必须与模板中的 ${xxx} 完全一致,大小写敏感 payload = {"code": code} text = json.dumps(payload, ensure_ascii=False, separators=(",", ":")) # 序列化后先自检长度,变量内容一般不宜超过 20 个字符 if len(code) > 20: raise ValueError("模板变量过长,短信会被拒绝") return textseparators的作用是去掉多余空格,虽然接口能容忍空格,但在日志里更干净,也少一点被网关截断的风险。至于长度限制,验证码类模板的变量内容通常只接受较短的数字串,具体上限以模板审核时给的要求为准,超过就是isv.TEMPLATE_PARAMS_ILLEGAL或者内容不合规。这条限制经常在「把验证码位数从 4 位改成 6 位」的时候暴露出来,改之前先确认模板变量长度的余量。
3.3 号码格式与地域分流:+86 加不加
国内短信的PhoneNumbers是纯 11 位手机号,不带+86、不带空格、不带横线。国际及港澳台短信是另一个接口、另一套模板,号码必须带国家码。这两条链路在代码里应该分流,而不是在一个函数里靠 if 拼字符串——拼接逻辑一多,号码格式错误就会变成高频故障。
分流函数大致这样写:
import re DOMESTIC = re.compile(r"^1[3-9]\d{9}$") def normalize(phone: str) -> tuple[str, str]: raw = (phone or "").strip().replace(" ", "").replace("-", "") if DOMESTIC.match(raw): return raw, "domestic" if raw.startswith("+"): # 国际号码保留 + 和国家码,交给国际短信接口 digits = raw[1:] if digits.isdigit() and 6 <= len(digits) <= 15: return raw, "international" raise ValueError(f"号码格式非法: {phone}")正则只做初步过滤,真正的合法性由服务端判断。但这一步能挡掉大量脏数据——Excel 导入带空格、前端表单带横线、用户手输带+86,这几种情况在实际项目里占比不低。分流之后,国内走SendSms,国际走国际短信对应的接口,模板也分开维护,别指望一条模板两边通用。
另外要注意,号码格式正确不等于号码有效。空号、停机、虚拟号段,接口会正常受理但最终投递失败,这类失败只能通过回执或者发送记录查询发现,接口调用的返回值里看不出来。
3.4 变量内容的字符与场景限制
模板变量里能放什么,是审核时定下来的。验证码类模板通常只允许数字;通知类模板可以放字母、数字和少量符号;含链接、含联系方式、含营销引导词的内容会被直接拦。这条规则的实际影响是:你不能用一条「通知模板」去发营销内容,哪怕变量拼出来的文案看着像通知。
有个真实场景值得提:订单通知模板里放商品名称,商品名里带「特价」「清仓」这类词,整条短信可能被判内容违规。解决办法不是换模板,而是在业务侧对变量值做敏感词过滤,过滤后再发。这一步不做,你会看到「大部分订单正常、个别订单失败」的诡异现象,排查起来很费时间。
4. 把 demo 补成业务闭环:验证码的生成、频控与一次性消费
demo 到这一步已经能发短信了,但把它放进登录注册流程还差三块:验证码存哪、怎么限制发送频率、校验后如何保证不能用第二次。这三块做不扎实,短信接口跑得再顺也没意义。
4.1 验证码的四个状态与 TTL 设计
一次验证码从生到死有四个状态:已生成未发送、已发送待校验、校验通过、失效。用 Redis 存的时候,键的设计决定了后面好不好排查。
# 键:sms:code:{场景}:{手机号} # 值:验证码明文(简单场景)或 哈希后的值 # TTL:300 秒,与短信文案里写的有效期严格一致 SET sms:code:login:13800000000 "123456" EX 300 # 发送冷却键,防止 60 秒内重复点击 SET sms:cd:login:13800000000 "1" EX 60 NXTTL 必须和短信文案一致,这个细节经常被忽略。文案写「5 分钟内有效」,代码里 TTL 设了 600 秒,用户在第 6 分钟回来校验通过,用户体验上是系统出 bug;反过来 TTL 设 120 秒,文案写 5 分钟,用户按提示操作却提示过期,投诉更多。
同一个手机号不同场景要用不同的键,登录和改密码的验证码不能互通。这个约束看起来多余,但它防止了一类越权:用登录接口拿到的验证码去完成改密操作。
4.2 发送前的三道闸:号码校验、图形验证码、频控
真正上线的发送接口,前面得挂三道闸。第一道是号码格式和黑名单校验,第二道是图形验证码或者行为验证,第三道是频率控制。前两道挡住机器,第三道挡住人。
频控要分两个维度做:业务侧和自己的 Redis 计数,以及服务端自带的流控。业务侧的计数让你能在被服务端拒绝之前就给出友好提示,而不是把isv.BUSINESS_LIMIT_CONTROL直接透给用户。
import redis r = redis.Redis(...) def allow_send(phone: str, scene: str) -> tuple[bool, str]: minute_key = f"sms:q:{scene}:{phone}:m" day_key = f"sms:q:{scene}:{phone}:d" pipe = r.pipeline() pipe.incr(minute_key) pipe.expire(minute_key, 60) pipe.incr(day_key) pipe.expire(day_key, 86400) m, _, d, _ = pipe.execute() if m > 1: # 立刻回滚,避免这次计数占掉用户下一分钟的名额 r.decr(minute_key) r.decr(day_key) return False, "发送过于频繁,请一分钟后再试" if d > 10: return False, "今日发送次数已达上限" return True, ""这段逻辑的取舍在于「先加后判」。反过来做「先判后加」会有一个并发窗口:两个请求同时读到计数为 0,都判断通过,然后都加一,实际发了两条。先 incr 再判,Redis 的单线程模型保证了原子性,超限时再回滚。回滚不是必须的,但不回滚的话,用户被拒一次就白白消耗了一分钟额度,体验上会更差。
至于服务端流控,同一号码同一模板的默认限制是分钟级、小时级、自然日级三层,具体阈值以控制台展示为准。开发阶段频繁测试同一个号码,很容易在十分钟内撞到小时级限制,这时候别怀疑代码,换个号码或者等一会儿。
4.3 校验侧的原子性:Redis 加 Lua 一次性消费
校验环节最容易出的问题是「一个验证码用了两次」。典型场景是用户连点两次提交,两个请求同时读到验证码、同时比对成功、同时放行。用GET再DEL两步在并发下必然出问题。
稳妥做法是把「读取、比对、删除」塞进一段 Lua,让 Redis 单线程原子执行:
-- KEYS[1] = sms:code:{scene}:{phone} -- ARGV[1] = 用户输入的验证码 -- 返回 1 成功,0 不存在或已过期,-1 不匹配 local stored = redis.call('GET', KEYS[1]) if not stored then return 0 end if stored ~= ARGV[1] then return -1 end redis.call('DEL', KEYS[1]) return 1Python 侧用eval调,注意把键名拼对,KEYS和ARGV的区分别搞混:
SCRIPT = """...""" # 上面的 lua 脚本 def verify(phone: str, scene: str, code: str) -> str: key = f"sms:code:{scene}:{phone}" result = r.eval(SCRIPT, 1, key, code) return {1: "ok", 0: "expired", -1: "mismatch"}[result]这里还有个设计要提醒:比对失败时不删除键。如果错了三次,可以额外加一个失败计数键,超过次数直接让验证码失效,防止暴力枚举。验证码一般是 6 位数字,不加失败次数限制,理论上几十万次请求能穷举出来,虽然成本不低,但加上限制只多几行代码。
4.4 超时重试与幂等:OutId 的正确用法
短信接口的超时是真实存在的。网络抖动、对端限流、DNS 解析慢,任何一种都会让客户端在读到超时的时候,不确定服务端到底有没有受理。这时候如果直接重试,用户可能收到两条短信。
处理方式分两种。如果业务能容忍重复(比如通知类),配一次重试就够了,重试间隔别太短。如果业务不能容忍(验证码),建议不自动重试,把失败暴露给用户,让他手动点重新发送,同时用冷却键挡住连点。这是最省事也最不容易出错的策略。
OutId在这里的作用是让重复可追溯。每条发送请求带一个全局唯一流水号,落库时和服务端返回的BizId一起存。事后如果用户投诉收到两条,拿流水号去查发送记录,能明确区分是「一次调用被重试了」还是「用户点了两次」还是「服务端重复投递」。没有这个字段,你手上只有手机号和时间,翻页翻到眼花也未必能对上。
5. 排查手册:五类高频报错的现象、成因与处置
下面这几条是我在实际项目里反复遇到的,按触发频率大致排序。每条都按「现象 → 原因 → 解决」写,遇到时可以直接对着定位。
5.1 现象:返回 Code=OK,但手机没收到
原因通常有四个层次。第一层是号码本身:空号、停机、关机、虚拟号段被拦截。第二层是手机端:拦截软件把 106 开头的短信归到了垃圾短信,或者手机存储满、短信中心号码配置错误。第三层是运营商:跨网延迟,尤其是一些小运营商,几分钟后才到。第四层才是服务端,比如模板内容被判为疑似营销,投递阶段被丢弃。
处置顺序建议从服务端往外查:先拿BizId去短信控制台的发送记录里查这条的状态。状态显示「发送成功」表示已经交给运营商,剩下的是运营商和终端环节,这时把BizId给用户,让他联系运营商查短信详单。状态显示「发送失败」,说明原因在平台侧,记录里一般会附着具体原因码。
有一条经验值得记:切换模板后第一天上线,前几十条短信的到达率要重点盯。新模板的变量如果来自用户输入,前几条可能是脏数据导致的投递失败,越早发现越好。
5.2 现象:isv.BUSINESS_LIMIT_CONTROL,测试时频繁出现
原因是对同一号码的发送触发了服务端流控。常见阈值是同一号码同一模板一分钟一条、一小时五条、一天十条,开发阶段反复用自己手机号测试,十分钟就能把小时级额度打满。
解决分两步。短期是换号测试,或者等窗口过去;长期是给测试环境配一批专门的测试号码,同时在业务侧加冷却键,让「一分钟内不能重复发」这条规则在调用接口之前就生效,而不是等接口返回错误再提示用户。后端日志里把Code和手机号一起打出来,出现这个码时能一眼看出是流控而不是别的错。
另外提醒一点:这个限制是按「号码 + 模板」维度算的,不是按号码算。同一个号码用两条不同模板,各自独立计数。有人因此绕过限制,但这么做会触发更上层的风控,不建议。
5.3 现象:isv.SMS_TEMPLATE_ILLEGAL 或变量超长
原因有三类。一是模板 CODE 抄错,多一位少一位,或者误用了模板 ID。二是模板还处在审核中,接口不认。三是模板参数不匹配,最常见的是键名对不上、变量值超出长度限制、JSON 里多了一个模板里没有的键。
解决时先把TemplateCode和TemplateParam两个字符串原样打日志。键名逐字符对照模板里的${xxx},注意前后不要有空格。变量值长度按模板审核时的要求卡住,验证码类只放数字。如果日志里看到的是{"code":"123456","code":"123456"}这种重复键,说明上游传了两个参数,JSON 序列化后只保留最后一个,但某些语言的行为不一致,也会触发这个错误。
还有一种是模板被停用。平台会定期清理长期未使用或者被投诉的模板,清理之后调用就是非法。这类情况不会提前很久通知,所以模板不要只留一条,主备两条能应急。
5.4 现象:本地正常,服务器上超时或 SignatureDoesNotMatch
超时的原因多半在网络。服务器的出网策略、DNS 解析、代理设置,任何一环不对都会让请求出不去。先用curl直接打 endpoint 的 443 端口确认连通性,再确认 DNS 解析到的 IP 是否正常。
SignatureDoesNotMatch则要怀疑两件事。一是密钥复制时带了不可见字符,尤其是从网页复制到服务器配置文件时,尾部换行会被读进密钥里。二是服务器时钟偏差过大,签名计算依赖时间戳,偏差超过一定范围直接判定签名无效。容器环境里时钟不同步的情况并不少见,检查一下date和标准时间的差值。
这两类问题有个共同点:报错信息不会告诉你具体哪里不对,得靠排除法。所以第一件事永远是把请求的RequestId记下来,找平台侧排查时这是唯一能定位到具体请求的凭据。
5.5 现象:isv.AMOUNT_NOT_ENOUGH 与额度预警
这条不是技术问题,但排查时极易被忽略。账户余额不足或者套餐包用尽,接口返回的就是这个错误码,代码写得再对也发不出去。
处置上,短期充值或者买套餐包,长期是接入额度预警。做法很简单:定时查询账户余额或者套餐余量,低于阈值就告警。更稳的做法是在发送失败的错误码里单独识别这一类,触发一条高优先级告警,因为它影响的是全量用户,不是某个号码。
6. 上线前的最后一公里:批量发送、发送记录对账与一张自检表
单条发送跑顺之后,还有两件事决定这套东西能不能长期用:批量场景怎么发,以及出了问题怎么对账。
6.1 SendBatchSms 的 JSON 数组坑
批量发送用的是另一个接口,参数形态完全不同:号码、签名、模板参数都是 JSON 数组,而且三个数组的长度必须一一对应。这里最常见的错误是把单条发送的参数结构直接搬过来,结果报参数非法。
import json def build_batch(rows: list[dict]) -> dict: # rows 中每项包含 phone / sign / params(dict) return { # 号码数组,字符串形式,不要带国家码 "phone_number_json": json.dumps([r["phone"] for r in rows], ensure_ascii=False), # 签名数组,长度必须与号码数组一致 "sign_name_json": json.dumps([r["sign"] for r in rows], ensure_ascii=False), # 模板参数数组,每个元素是一段 JSON 字符串 "template_param_json": json.dumps( [json.dumps(r["params"], ensure_ascii=False) for r in rows], ensure_ascii=False, ), }注意template_param_json是「数组里套字符串」,不是直接套对象。这一层嵌套是最容易写错的地方,写成对象的话接口会解析失败。另外批量接口单次号码数量有上限,常见是 100 个,超了要自己切片分批。分批时如果中间某批失败,要能定位到具体是哪几个号码,所以每批的结果都要单独记录,不能只记一个总成功数。
批量发送适合通知类场景,验证码千万不要用批量——不同用户的验证码不同,一旦数组对错位,A 的验证码发给了 B,这是安全事故级别的问题。真要用,必须保证号码和参数在同一行数据里绑定,拼装过程不允许重新排序。
6.2 用 QuerySendDetails 做每日对账
发送记录查询接口是按号码加日期查的,参数里SendDate是yyyyMMdd格式,分页每页最多 50 条。这个接口不适合实时查,适合做每日对账:把当天所有标记为「已受理」的记录,按号码批量拉一遍状态,把「发送失败」的挑出来,该补发的补发,该退款的退款。
对账脚本的关键是别把接口打成压测。控制台有调用频率限制,按号码逐个查,几千条就要跑很久。合理的做法是按业务批次分组,只查失败率异常的批次;或者只查OutId缺失回执的那部分。对账的目标是发现问题,不是把所有数据搬一遍。
6.3 一张上线自检表
上线前,把这几个问题过一遍,比事后救火便宜得多:
| 检查项 | 判断标准 |
|---|---|
| 凭证来源 | 环境变量或配置中心,代码和仓库里搜不到明文密钥 |
| 主体资质 | 签名已通过且与模板场景匹配,个人认证未用于营销类内容 |
| 模板健壮性 | 主备两条模板,变量长度留有余量 |
| 频控 | 业务侧冷却键 + 日限额,服务端流控错误码有专门分支 |
| 幂等 | 每条请求带唯一OutId,超时不自动重试或重试有去重 |
| 日志 | 记录RequestId、BizId、Code、脱敏后的手机号 |
| 告警 | 额度不足、失败率突增、SignatureDoesNotMatch三类单独告警 |
| 对账 | 每日拉一次失败记录,有明确的补发或退款流程 |
最后说个教训。我早期做这类接入,图快,直接把 demo 的main方法包了一层就上线了,觉得「能发出去就行」。结果第一次出问题是有用户反馈验证码收不到,我打开日志,发现只有一行send success,既没有RequestId也没有BizId,手机号还是明文,那一刻是真的没有后悔药可吃。从那以后我每次接短信,不管多急,都强制把上面这张表走一遍,尤其是日志字段和幂等这两项——它们不影响你当天能不能跑通,但决定了你出问题时能不能查清。希望帮到你。
本文还有配套的精品资源,点击获取