3.1 短信接入:腾讯云 SMS

场景:提醒这条命,以前靠嗓子

系统上线第一个月,林姐照老习惯让前台打电话提醒,结果有一天前台请假,八十个预约只提醒了三十个,当天来了六个错时段的客人在店里干等。那天晚上我们就决定:提醒这件事必须交给系统,而且第一条腿必须是短信。

为什么是短信而不是 App 推送?美甲店的客人大多是路过顺手扫码关注的中年女性,手机里未必装得下门店 App,但人人都会看短信。腾讯云 SMS 的到达率在我们后来三个月的统计里稳定在 95% 以上,一条短信四分钱,一天八十条也就三块多,比前台一小时的工资都便宜。

选定方向后还要说服自己不选别的。企微群发和公众号模板消息都试过:群发被折叠在服务通知里,打开率不到 15%;公众号模板消息对未关注客人直接失效,而我们的客人里有四成根本没关注公众号。短信是唯一无视所有前置条件的通道,这也是它成为兜底通道而不是主通道的原因——贵,但一定到。

最先接入的版本只做了一件事:预约成功发一条通知。上线第一天发了三十二条,全店员工盯着各自的手机等短信,那种气氛像是等开奖。第二天客人回门店提反馈说收到了,林姐这才彻底把电话本收起来。

方案:一个 SDK 加一张配置表

接入的总体思路是后台不直接拼短信内容,只填模板编号和参数数组,内容审核交给腾讯云控制台提前过审。发送动作统一封装在一个函数里,预约成功、取消、爽约告警都调它。

腾讯云 SMS 的正文模板分三种类型,审核严格程度和适用场景各不相同,动手申请前先选对类型能少走一半弯路:

类型 审核严格度 单条计费 我们的用途
验证码 最宽松 每条约 4 分 未用
通知类 中等 每条约 4 分 预约提醒
营销类 最严格 略高且需资质 未用

我们全部走通知类,营销类需要额外的资质审核和退订机制,门店活动改为在小程序首页 banner 推送,不碰短信。

步骤 动作 耗时
1 控制台申请签名 审核约 2 小时
2 控制台申请正文模板 审核约 2 小时
3 后端封装 SendSms 代码约 50 行
4 回调确认发送状态 配 Webhook 即可

SDK 走的是 Go 版本,密钥放在环境变量里,绝不进代码仓库。发送函数的参数只有三个:手机号、模板编号、参数切片:

func SendSms(phone string, tplID string, params []string) error {
    cli := sms.NewClient(cred, region)
    req := sms.NewSendSmsRequest()
    req.SmsSdkAppid = common.StringPtr(appID)
    req.Sign = common.StringPtr(signName)
    req.PhoneNumberSet = common.StringPtrs([]*string{&phone})
    req.TemplateID = common.StringPtr(tplID)
    req.TemplateParamSet = common.StringPtrs(toIfaces(params))
    _, err := cli.SendSms(req)
    return err
}

调用入口在预约成功的异步队列里,主流程不阻塞。这一点很重要:短信接口偶尔会抖动,如果同步等它返回,下单接口的耗时会被拉高三百毫秒以上。

异步队列的实现很轻,没有上消息队列,只是数据库里一张待发表,发送协程每秒扫一次,发完就删:

for _, m := range pullPendingSms(100) {
    if err := SendSms(m.phone, m.tpl, m.params); err != nil {
        retryOrFail(m) // 最多重试两次,再失败标红等人工
    }
}

这么做的好处是宕机重启不丢任务,坏处是延迟大约一到三秒,对提醒场景完全够用。上个月QUEUE里积压最久的一条是四秒,客人无感知。

发送结果不看出参,看回调。腾讯云会把每条短信的最终状态 POST 到我们配好的回调地址,成功、失败、拒收都在 body 里带着。我们把回调落库,后面算触达率全靠这张表:

func handleSmsCallback(c *gin.Context) {
    var cb SmsCallback
    c.ShouldBindJSON(&cb)
    saveSmsReport(cb.SerialNo, cb.ReportStatus, cb.UserReceiveTime)
    c.String(200, "SUCCESS") // 必须回包,否则平台会重试
}

回调接口有个细节:返回体必须是 SUCCESS 这几个字符,否则腾讯云会按失败重试,同一条状态会入库多次。我们见过一张表里同一条流水出现八条记录,全部来自重试。

回调落库之后还有一步是核对。每天凌晨跑一次对账任务,把发送成功的条数和控制台账单对一遍,差超过十条就告警。这项对账抓出过一次套餐包余量扣减异常的 bug,不核对根本发现不了。

配置:控制台与后台两侧

控制台一侧要做的事不多,但每一件都有讲究。先在短信控制台创建应用拿到 SdkAppid,再申请签名,最后申请模板。签名是短信开头【】里的内容,我们用的是门店品牌的简称,审核要营业执照;模板正文里所有可变内容必须写成 {1}、{2} 这种占位符,不允许直接写死变量。整个流程走完大概半个工作日,其中审核等待约两小时,其余时间在改文案。

后台一侧的配置统一收敛在一个结构体里,从环境变量读取,切换签名或模板只改配置重启即可:

配置项 值 说明
SMS_APPID 1400xxxxxx 控制 SdkAppid
SMS_SIGN 某某美甲 已审过签名
SMS_TPL_BOOK 1234567 预约成功模板
SMS_TPL_REMIND 1234568 到店提醒模板
SMS_REGION ap-guangzhou 服务地域

频率限制也顺手配上。同一个手机号同模板一分钟内只发一条、一天最多十条,这是控制台自带的规则,不用写代码,但它救过我们一次:有一年春节活动模板编号配错串线了,差点给一个客人连发六十条拜年短信。频率规则像保险丝,平时嫌它碍事,出事时才知道真香。

计费方式建议提前想清楚。SMS 支持按条计费和套餐包两种,我们算过一笔账:每月两千五百条左右的量,买五千条的套餐包比按条付便宜三成,用不完的不过期。账单可以在控制台的用量统计里按模板拆开看,哪条模板最烧钱一清二楚。

发送量的季节波动也值得摸一遍。我们的短信量随预约量走,周末是工作日的两倍,国庆当月比平时翻番。套餐包按月买、不够再补的策略比一次性囤半年的灵活,补购这件事后来交给运营每周看一次用量报表决定。

踩坑:接入现场的四个坑

第一个坑是手机号格式。小程序拿到的手机号是带国家码的 86 前缀还是裸十一位,不同授权接口返回不一致。我们统一在入库时剥掉前缀,发送前再补 +86,SMS 接口要求 E.164 格式,格式不对直接报错,而且报错信息藏在一长串英文里,好在有 errorCode 可以查表。

第二个坑是发送频率与业务节奏冲突。预约成功的通知和到店前的提醒如果在同一分钟内触发,会被频率规则挡掉第二条。后来我们把提醒任务安排在服务前两小时,与成功通知天然错开。挡掉的时候接口不会报错,只是回调里告诉你失败,所以这种静默失败最坑——要在回调表里按失败原因聚合统计才看得见。

第三个坑是短信内容超过签名模板的长度上限。一条短信 70 个字按一条计费,超过按两条收。提醒模板我们改了三版,从 92 字压到 66 字,每条省一分钱,一年下来省出一顿部门聚餐。压字数是有套路的:先砍客套话,再合并门店名和地址,时间精确到分钟就够了,最后连标点都数着用。

第四个坑是欠费不停服但不报警。有一次账户余额不足,短信悄悄停发了一整个下午,前台不得不重操旧业打电话。从那以后告警里加了一条:连续十分钟无短信回调即触发值班通知。

小结

短信接入本身不难,难的是把它当成业务的一部分来运营:模板要审、回调要收、频率要配、余额要盯。一个 SendSms 调用背后是四章控制台配置和四条踩坑记录,把这些都归档到代码仓库的 docs 目录里,后来接手的同事半天就能复现整套链路。

回头看整套接入,最值得做的一件事是把发送、回调、对账三件事拆成三张表:待发表管重试、回调表管状态、账单表管钱。三张表对上账之后,每个月短信费用从一笔糊涂账变成了可以按门店拆开的明细,林姐第一次能说清三家店各自的提醒成本。下一篇细讲模板和签名怎么过审——我们被驳回三次才摸清审核规则。

延伸阅读