一、问题:短信一断,业务就断
上线的教培系统在好些场景下需要发送短信通知或短信验证码:
-
新用户注册 -
用户绑定手机号或修改手机号 -
学员约课后,短信通知学员或家长、授课老师 -
学员购买课程或课程周边商品后的短信通知
目前,教培系统对接某第三方短信平台。在实践过程中发现,该平台的短信发送非常不稳定,其它第三方平台也或多或少存在同样的问题。因为造成短信发送失败的原因很多情况下不可控,比如监管政策变化、原来已经审核通过的签名失效,以及来自电信运营商或管理部门的风控措施。
一旦出现这种情况,处理起来非常耗时,都不是一时半会可以解决的。作为开发者,能采取的办法不多,最多也就是临时更换成其它第三方短信平台。而其它短信平台也有同样的问题,甚至往往是各个平台同时出问题。比如,由于政策变化,需要补充更多的认证材料,新材料需要重新审核;有时候,短信签名和模板都需要重新申请。整个过程少则几天,多则一两个星期,甚至更长。这对正在上线运行的应用系统来说,简直是灭顶之灾。
更糟的是,失败的短信照样收费。我们最近一次碰到的情况就是:连续好几天,平台对我们发出的短信统一返回“用户不存在”,钱照扣,短信却没有发出去。
二、思路:不换平台,给短信买一份“保险”
既然换平台解决不了问题,我们换了个角度:短信平台照常用,同时准备一部自己的安卓手机,用手机里的 SIM 卡兜底。平台发失败了,手机补上;平台彻底不能用了,就全部由手机发。
手机发短信走的是普通的个人短信通道,不需要申请签名和模板,也不受短信平台审核的影响。它不适合大量群发,但对一所培训学校每天几十到上百条的通知和验证码来说,绰绰有余。
落实到设计上,有几个关键选择:
- 手机主动轮询,而不是服务器推送。
手机每隔几秒问一次服务器“有没有要发的短信”。手机在家用 Wi-Fi 也好、用 4G 也好,不需要公网 IP,也不依赖任何第三方推送服务。 - 两个独立的开关。
一个是“短信平台开关”,确认平台不可用时一刀切关掉,全部由手机发,避免白白扣费;一个是“手机补发开关”,打开后平台发失败的短信自动由手机补发。两者不是二选一,而是叠加的保险。 - 只补发“明确失败”的短信。
平台当场报错,或者事后查到发送失败,才由手机补发。空号、停机这类换通道也发不到的不自动补,状态不明的由人工判断,宁可少发,不要重复发。 - 验证码过期就不再发。
一条半小时前的验证码补发出去,只会让用户更困惑。通知类短信则可以晚一点到。 - 手机上能看到发送记录,并手动重发。
管理员不用登录后台,拿起手机就能看到最近哪些短信没发出去,点一下“重发”。
还有一点很重要:这个安卓应用是我们从头自己写的,只参考了开源社区里类似项目的思路,没有复用他人的代码。
三、整体架构
整套方案只有三个角色:教培系统服务端、第三方短信平台、一部装了网关应用的安卓手机。
短信应急网关整体架构
开关开启时,短信先交给第三方平台;平台当场报错、事后查到发送失败,或者开关关闭时,短信进入手机任务队列,由安卓手机领取并用 SIM 卡发出。
四、关键实现
服务端:一张任务表,四个接口
服务端在原有教培系统(ThinkPHP 6 + Swoole)里新增了一张“手机短信任务表”。每条要由手机发的短信是一行任务,状态依次是:待发 → 已领取 → 已发送 / 失败 / 已过期。
手机和服务器之间只有四个接口,都用一个只有我们自己知道的密钥保护:
|
|
|
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
几个容易忽略的细节:
- 正文在服务端生成。
短信平台用的是“模板 ID + 参数”,手机发的却是完整文字。所以我们在配置里为每个模板写了一份文字版本,由服务端拼好签名和正文再交给手机。手机只管发,不管内容。 - 领取后不回报就回收。
手机领了任务却迟迟没有回报(例如中途断电),3 分钟后服务器把它放回队列,最多重试 3 次。已经回报过的任务重复回报会被忽略,避免一条短信被记两次。 - 每条任务都有有效期。
验证码过了有效期就标记为“已过期”,不再下发给手机。 - 两个通道共用一份发送记录。
无论走平台还是走手机,都写入同一张记录表,并和手机任务关联。这样一条短信的完整经过一目了然,比如“平台失败 → 手机补发成功”。
自动补发:什么时候补,补哪些
短信平台发送是异步的:接口返回成功,只代表平台收下了这条短信,是否送达要过一会儿才知道。所以补发分两种情况:
- 平台当场报错
:立即放入手机队列,几秒内由手机发出。 - 平台接了但没发成
:系统原有的定时器每 30 秒向平台查询一次最近短信的状态。查到“发送失败”,并且发出不到 30 分钟、内容未过期、也没有补发过,就交给手机。
状态查询故意不放在手机轮询的请求里:平台接口一慢,就会拖住手机。
安卓端:稳定比功能重要
安卓应用用 Kotlin + Jetpack Compose 编写,只有四个页面:首页、发送记录、设置、检查清单。功能不多,功夫花在“长时间挂着不出问题”上:
- 前台服务 + 常驻通知。
网关开启后以前台服务运行,默认每 5 秒轮询一次。“检查清单”页提醒用户关闭电池优化、授予短信权限,这是国产手机上最容易被“杀后台”的地方。 - 限速。
两条短信之间至少间隔 3 秒,每小时最多 60 条(可调)。本小时额度用完时,手机只发心跳不领任务,留给下一个小时。这是为了不触发运营商对个人号码的垃圾短信风控。 - 连续失败就暂停。
连续 5 条发送失败(比如欠费、被限制),网关自动暂停并弹出提醒,等人来处理,而不是一直失败下去。 - 结果先落盘,再回报。
每发一条就把结果存进手机本地数据库,网络通了再统一回报。断网或者应用被杀,已经发出的短信也不会被服务器当成“没发”再派一遍。 - 长短信自动拆分。
超过一条长度的通知自动拆成多段发送,每条最多等待 30 秒的发送结果。 - 界面上的手机号打码显示。
记录页和日志里只显示“138****0000”这样的号码,减少学员信息外泄的风险。
发送记录与手动重发
“发送记录”页有三个筛选:平台失败、平台全部、手机发送。前两个直接来自短信平台自己的发送记录接口,和后台看到的一致;第三个是手机发过的短信。
点“重发”时,服务器会重新从平台读取这条记录的号码和正文,不信任手机传来的内容。重发有三条规则:只能重发 7 天内的失败短信;验证码超过 30 分钟不能重发;一条短信正在重发时不能再点。不能重发的,页面上直接写明原因。
运维:一条命令切换通道
两个开关既可以在后台“短信配置”里切换,也可以在服务器上用命令行切换,立即生效,不用重启:
php think sms:gateway status # 查看开关、手机是否在线、今日发送情况
php think sms:gateway provider off # 关闭短信平台,全部由手机发送
php think sms:gateway fallback on # 平台失败的短信由手机自动补发
五、踩过的坑
从开发到上线,有几件事值得记下来,做类似系统的朋友可以少走弯路:
- 测试短信被真的发出去了。
跑自动化测试时,真实手机正好在线,把测试任务领走并发了出去。此后测试脚本一开始先检查:有真实手机在线就拒绝运行。 - 测试“平台失败”也要花钱。
平台失败也收费,而且可能真有一条短信发到用户手上。我们专门准备了一个平台本地就会拒绝的测试模板,用它来模拟“平台报错”,不花钱、也不会发出短信。 - 回报晚了,差点重复发送。
手机已经发出,但回报因为断网晚到,服务器以为没发、把任务又派了一遍。修正后:手机先把结果落盘,服务器对“晚到的成功回报”也照单全收。 - 发送记录一开始是空的。
第一版只读我们自己的记录表,对新功能上线之前的短信一无所知。管理员真正想看的,是短信平台里那几百条失败记录,于是改为直接读平台的发送记录接口。 - 平台接口并不总是像文档写的那样。
平台的“按 ID 查单条记录”接口返回“无权限”,“状态查询”接口对已经失败的短信也返回空。我们只好改为翻查失败记录列表。对接第三方接口,一定要用真实数据验证,不要只信文档。
六、总结
一部安卓手机、一张 SIM 卡、一张任务表和四个接口,就给教培系统的短信加上了一份不依赖任何短信平台的保险。平台正常时,它安静地待命;平台出问题时,它自动补上;平台彻底不可用时,一条命令就能全部切换到手机。从“干等一两个星期”到“几秒钟切换”,这就是它的价值。
它适合: 发送量不大(每天几十到几百条)、以通知和验证码为主的中小型应用,比如培训机构、社区门店、内部系统。
它的局限,也要说清楚:
-
发件人显示的是一个普通手机号,不是短信平台的特服号,用户第一次收到可能会怀疑。正文里带上机构名称的签名会好很多。 -
个人号码有运营商的发送频率限制。只用于用户主动触发的通知和验证码,千万不要用来发营销短信,既违反规定,也会很快被封号。 -
一部手机就是一个单点:没电、没信号、欠费,都会停摇。所以服务端会显示手机是否在线,手机端连续失败会报警。 -
手机号应当归机构所有、实名登记,并办一个包含足够短信条数的套餐。

