平时写Go后端时,接到短信验证码需求往往是“看着简单、做起来全是暗坑”。发送要调第三方,验证要自己存,前端还要倒计时,稍微哪个环节没接好,线上就是一连串“验证码收不到”“验证码一直报错”“接口被刷爆”的投诉。这篇就把我封装Go短信验证码接口时的请求结构设计和错误码体系完整梳理一遍,包括报文怎么拆、签名怎么算、错误码怎么分级、上线后踩过的坑。希望给准备自己写验证码服务的同学一份可直接参考的落地清单。
我默认你已经是Go服务端的开发者,但对短信验证码这个场景还没深入过,所以后面会尽量把“为什么这么做”也讲清楚,而不是只丢一份接口文档。
1. 为什么不直接用第三方短信SDK,而要自己封装一层
1.1 直接对接第三方,代码会变成一团乱麻
很多项目刚起步时,注册登录直接调某个短信平台提供的SDK,代码确实是能跑的。但一旦业务多起来,问题就暴露了:每个业务方都按自己的习惯去调底层供应商,有的传手机号前面带了+86,有的没带;有的要传templateCode,有的把文案直接写死在代码里。等到供应商要换签名、换模板、换密钥,你就要一个个业务方去找去改,光沟通成本就够喝一壶的。
更麻烦的是,第三方SDK的错误码往往是为他们自己的控制台设计的,返回一个“isv.BUSINESS_LIMIT_CONTROL”,业务后端根本不知道是触发了频控,还是手机号格式错了,还是余额不足。每次对接新业务都要重新翻译一遍,很消磨耐心。
1.2 自建封装层的核心价值不只是“转发请求”
我最后决定在Go服务里单独维护一个短信用例模块,所有发送验证码、校验验证码的请求都走这一个入口。它做的事情包括四件:
- 统一入参校验和报文格式,业务方不需要关心供应商A还是供应商B的差异;
- 内置频控和黑名单逻辑,让“同一手机号60秒内只能发一次”这类规则收口到一处;
- 把第三方错误码翻译成自己的统一错误码,下游只需要处理一套语义;
- 为验证码的存储、过期、防重放、校验次数做统一管理。
这样做的本质是把“短信验证码”当成一个内部标准服务来对待,而不是把第三方供应商的API直接暴露出人。业务团队不用再看供应商文档,后端也能把安全和限流做在更靠前的位置。
1.3 什么情况下才值得自己封装
不是所有项目都需要这一层。如果你只是做个几天的活动页,选个现成SDK直接调也完全合理。但只要是系统性的业务,后面要接App、小程序、Web多个端,未来还可能换供应商,那我建议第一版就把封装层建起来。它的边际成本并不高,一个Go包里几十个函数就能搭出雏形,后面维护收益却很实在。
2. 请求结构设计:我把这套接口的报文拆成了三块
2.1 公共参数为什么是这几个
我设计的发送和校验接口都复用同一套公共参数,包括appId、timestamp、nonce、signature和version。appId是用来区分调用方业务的,比如注册端、找回密码端、支付确认端各有一个appId。这样做的好处是后续要针对某个业务单独调整频控策略或文案模板,不用全局改代码。
timestamp和nonce是一对,用来防重放攻击。timestamp表示请求发生的毫秒时间戳,服务端只接受当前时间前后5分钟内的请求;nonce是客户端生成的随机字符串,服务端会在Redis里缓存一段时间,同一个nonce重复出现就直接拒绝。这两个字段缺一个,签名防重放的效果都会大打折扣。
version是用来做兼容演进的,目前固定为1.0。结构体设计大致长这样:
type SmsRequest struct { AppId string `json:"appId"` Timestamp int64 `json:"timestamp"` Nonce string `json:"nonce"` Signature string `json:"signature"` Version string `json:"version"` BizParams map[string]string `json:"bizParams"` }2.2 业务参数:发送和校验各需哪些字段
发送验证码和校验验证码,所需要的业务参数差别很大。发送时我要求必传phone、scene、templateCode和ttlMinutes。phone是要接收短信的手机号,scene是业务场景枚举,比如register或resetPassword,templateCode对应短信平台上的模板编号,ttlMinutes是验证码有效期,默认给5分钟。
有些团队会把验证码内容也放参数里,我强烈不建议这样设计。验证码应当是纯服务端生成的随机数,调用方传一个code进来,既不安全也容易导致业务方各自定义验证码规则,最终验证逻辑没法统一。
校验时业务参数就简单一些:phone、scene、code,以及一个可选的consumeToken。consumeToken会在发送成功后返回给调用方,校验时带上可以进一步降低验证码被猜解的风险。
2.3 一次完整发送请求的报文长什么样
说了这么多,不如直接看报文。发送验证码的HTTP请求我长这样:
POST /v1/sms/code/send HTTP/1.1 Host: sms.example.com Content-Type: application/json { "appId": "10001", "timestamp": 1710000000000, "nonce": "6a8f3d2c1b9e", "signature": "f6d5f2d9f3ab3412b0f1a2e3d4c5b6a7", "version": "1.0", "bizParams": { "phone": "13800138000", "scene": "register", "templateCode": "TPL_REGISTER", "ttlMinutes": "5" } }注意bizParams里的值我全部用字符串,不用JSON数字类型。原因是签名计算时要保持各端拼接结果一致,数字类型在序列化时容易变出1.0、1e3这种不同形态,导致签名对不上。这个细节看着小,实际上线上排障时最多的就是这种问题。
2.4 为什么timestamp和nonce必须一起用
单独用timestamp会导致一个窗口期问题。攻击者在5分钟内截获请求,仍然可以重放;如果时间窗口缩小到秒级,又容易因为客户端和服务端时钟偏差导致正常请求失败。加上nonce之后就简单多了:哪怕在时间窗口内,同一个nonce第二次出现就给拦截。Redis里setnx,过期时间设置成比时间窗略长一点,比如6分钟,这样能覆盖整个有效窗口。
不过nonce不能无限存,否则Redis内存被塞满。我这边只对最近10分钟的nonce做去重,因为验证码本身有效期很短,超过这个窗口的重放意义不大。
3. 签名算法与安全边界:防刷和防篡改才是重头戏
3.1 签名的计算流程
签名的作用有两点:一是确认请求确实来自合法的调用方,二是确保参数在传输过程中没有被篡改。我采用的方式是HMAC-SHA256,密钥是每个appId独立分配的secretKey,不在网络上明文传输,只保存在服务端和调用方的配置中心里。
具体计算方式是:先把bizParams里的键按字典序排序,用key=value的形式拼成一个字符串,再用&连接;然后把公共参数里的appId、timestamp、nonce、version再加上排序后的bizParams串,一起拼成待签名字符串;最后用HMAC-SHA256做哈希,转成十六进制字符串。
用Go实现核心部分也就二十行左右:
func sign(appId string, timestamp int64, nonce, version, secret string, biz map[string]string) string { keys := make([]string, 0, len(biz)) for k := range biz { keys = append(keys, k) } sort.Strings(keys) var sb strings.Builder sb.WriteString(fmt.Sprintf("appId=%s×tamp=%d&nonce=%s&version=%s", appId, timestamp, nonce, version)) for _, k := range keys { sb.WriteString(fmt.Sprintf("&%s=%s", k, biz[k])) } mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(sb.String())) return hex.EncodeToString(mac.Sum(nil)) }3.2 服务端校验签名时容易被忽略的细节
签名拼接规则必须和文档完全一致,一旦文档和代码有一丁点出入,调用方就会开始疯狂报“签名错误”。但这里我更想提醒的是服务端本身的问题:很多人校验签名用普通字符串比较,这在Go里虽然是常数时间,但如果自己用反射、逐字节比较时忘了判断长度,就可能有被时序攻击的风险。我在代码里直接用crypto/subtle包里的ConstantTimeCompare,省心也安全。
还有一点:签名里的时间戳是参与计算的,所以服务端要先校验时间窗再验签名,顺序不能反。如果先验签,时间戳已经过期了但签名本身是合法的,就会在日志里留下大量“签名错误”的假象,排查浪费很多时间。
3.3 Redis限流:60秒一次不是靠if判断
“同一手机号60秒内只能发一次验证码”这个需求看着简单,但落在并发场景下就不能只在业务代码里做if判断了。两个请求同时进来,都发现Redis里没有发送记录,然后都去发送,就破防了。
我的做法是用Redis的SET NX EX,key设计成sms:limit:{phone}:{scene},value存第一次发送的时间戳,过期时间设60秒。只有SET成功返回true的请求才允许继续调第三方发送。类似地,每个手机号每天的上限用INCR加EXPIRE来实现,当天超过5次直接拒绝。这块代码在并发下验证过,稳得很。
3.4 黑名单和风控策略要预留入口
不要把风控逻辑写死在发送接口里。我留了一个hook接口,在发送前检查黑名单、设备指纹、IP频次等外部条件。一开始可能黑名单只是查Redis,但后续如果需要接风控服务,改起来也方便。短信接口一旦被刷,损失的不只是短信费用,还有用户对产品的信任度,这块值得多投入一点。
4. 错误码体系:从调用方的角度倒着设计
4.1 错误码分类的四个维度
设计错误码时,我从调用方视角反推:他们拿到一个错误码后需要知道什么?无非三件事——这个错误是不是我造成的;如果是我的,我该改什么;如果不是我的,该找谁。
所以我维护四类错误码:参数类、权限安全类、业务状态类、频控类,以及一组系统内部错误。前面三类是调用方需要处理的,最后一类通常是服务端问题,会在错误信息里提示联系服务端负责人。这里有个原则:系统内部错误不暴露堆栈和供应商信息,只给一个traceId,方便排查问题。
4.2 一套完整的验证码错误码表
我目前的错误码表长这样,可以直接抄作业:
| 错误码 | 含义 | 调用方处理建议 |
|---|---|---|
| 0 | 成功 | 无需处理 |
| 40001 | 参数缺失或格式错误 | 检查必传字段是否齐全、手机号格式是否正确 |
| 40002 | 签名校验失败 | 检查签名算法、密钥、时间戳是否在有效期内 |
| 40003 | appId不存在或已被禁用 | 联系服务端确认appId和secretKey是否匹配 |
| 40004 | 请求时间戳超出允许范围 | 校准客户端时钟,确认timestamp是否为毫秒 |
| 40005 | 验证码错误 | 提示用户重新输入,注意不要直接提示“正确验证码” |
| 40006 | 验证码已过期 | 引导用户重新获取 |
| 40007 | 验证码已被使用 | 阻止重复提交,让用户重新获取 |
| 40008 | 发送频率超限 | 提示用户等待倒计时结束再试 |
| 40009 | 当日发送次数超限 | 提示次日再试或走人工申诉 |
| 40010 | 手机号命中黑名单 | 提示联系客服 |
| 50000 | 系统内部错误 | 记录traceId后联系服务端 |
这里的关键点在于:每个错误码都配了“调用方处理建议”。很多团队的错误码表只有码和短语,调用方拿到40005不知道是应该提示“验证码错误”还是“请稍后再试”,最后体验完全靠猜。
4.3 错误信息要可操作,不建议只给一串码
返回报文体我会统一用一个结构,不管成功还是失败都是同一套字段:
{ "code": 40005, "message": "验证码错误,请重新输入", "traceId": "a1b2c3d4e5f6", "data": null }message里的文案一定要是能给到终端用户看的,开发者在排查时再看traceId和日志。不要把内部错误信息直接拼到message里返回,避免泄露供应商信息和服务端路径。
4.4 日志需要额外记录哪些内容
每个验证码接口的请求,我都会在日志里记录appId、phone的脱敏形式、scene、错误码、耗时、第三方供应商的返回码。脱敏后的手机号比如138****8000,既能排查用户问题,又不至于让完整手机号在日志里泛滥。第三方返回码也一定要记,因为很多时候供应商那边其实没报错,是我们自身判断逻辑有问题,没有这个信息,排查效率会很低。
5. 验证码从发起到校验的完整链路
5.1 发送阶段:生成、存储、推送三步
发送验证码服务端做的事情,拆开看是三步:生成一个6位数字验证码,要求不能是简单递增和重复数字;把它存到Redis里,key为sms:code:{phone}:{scene},value为验证码,过期时间为ttlMinutes;然后调第三方接口把验证码推送出去。
生成验证码我建议直接用Go的crypto/rand,而不是math/rand。math/rand的默认种子在服务启动时是固定的,如果攻击者能控制调用时机,存在被预测的风险。生成后还要做一次弱验证码过滤,比如全部相同、连续递增这些直接重生成。
一个偏经验的细节:验证码一定要在调用第三方发送成功之后再写Redis,顺序不要反。如果先写Redis、再调第三方,第三方超时或报错,Redis里却已经有了一个“永远发不出去”的验证码,用户会陷入“验证码不存在但系统提示已发送”的诡异状态。我一开始就是这个顺序搞反了,后来在压测环境里才暴露出来。
5.2 校验阶段:满足“一次性且防猜解”
校验接口的流程是:从Redis取出验证码;比对用户提交的code;如果相同,删除Redis里的key;标记短信流水状态为已使用。这里有一个常见的坑:校验成功就立刻删key,如果后面业务方因为并发原因重复提交,两个请求同时校验,理论上两个都能通过,需要靠Redis的GETDEL操作原子地取出并删除,让第二个请求拿到空结果、自然失败。
5.3 前端配合的时序问题:倒计时、重发、加载态
后端把发送接口稳定下来还不够,前端配合不好一样会翻车。发送按钮要进入60秒倒计时,这个建议由服务端返回的retryAfter字段驱动,而不是前端自己写死。因为服务端可能因为频控、风控临时调整策略,硬编码的时间很容易和服务端不一致,到时候用户拼命点重发,后端点根本不响应,前端又没给任何提示,体验很糟糕。
5.4 并发和幂等:一个手机号不能同时发无数验证码
前面说的限流已经覆盖了“60秒内重复发送”,但还有一个分配场景:验证码在登录时被用于校验,同一秒钟用户点了两次“登录”,两个请求都会拿着同一个验证码来校验。正常情况下第一个成功后Redis key就没了,第二个会失败。但在微服务架构里如果校验请求被负载均衡打到不同实例,而Redis是共用的,问题不大;如果某天你改成本地内存存验证码,并发场景就会直接翻车。所以我坚持验证码必须存Redis,不能用本地缓存代替。
6. 上线之后挖到的几个坑
6.1 Redis key设计没考虑scene隔离
一开始我的Redis key只用了sms:code:{phone},结果注册端发的验证码,用户在找回密码界面上也能用。这倒不是特别严重的安全问题,但业务逻辑上很别扭。后来我统一改成sms:code:{phone}:{scene},并把scene枚举约束成固定几个值。改key的时候注意兼容老数据,上线前我是在凌晨低峰期发布,同时做了临时双读,避免用户刚收到验证码却因为key对不上而校验失败。
6.2 第三方返回成功但短信延迟到达
有一次某短信平台接口返回成功,但用户等了十几分钟才收到短信。排查后发现是该平台的通道拥堵。这个问题的前置处理是:发送接口不能一收到第三方成功就认为用户一定能立刻收到。我在发送逻辑里对第三方响应耗时做了监控,超过3秒就打告警。同时也给前端的文案留了缓冲:“验证码发送可能需要一点时间,请稍等片刻。”
这里还要提醒一句:不要把第三方平台的超时时间设置得太短,尤其不要在网关层再叠一重超时。我见过网关设3秒,短信平台本身5秒才返回,结果网关先超时报错,实际短信又发出去了,用户没收到验证码但系统扣了短信条数。超时配置要有全局观念。
6.3 错误码升级时容易踩破坏性变更的坑
因为一开始设计的错误码只有“成功/失败”两档,后来要细分出验证码过期、已使用、频控等不同情况。改动错误码时要特别注意调用方的兼容逻辑:老客户端可能只判断code是不是0,新增的错误码对它们来说都变成了“失败”,文案上可能显示“系统错误”。为了平滑升级,我做了新老错误码并存,旧的代码段先映射到新码,外层再兼容一段时间的方案。错误码这种上游消费方极多的接口,宁可多留过渡期也不要一刀切。
6.4 压测时频控把测试机器封了
这个坑最哭笑不得。压测脚本不停用同一个手机号请求发送验证码,结果触发了黑名单策略,整个测试手机号段都被风控拦住了,后面手动测试时一直报40010,排查了好一会儿才意识到是压测导致的。建议压测前在配置中心把风控开关打到“只记录不拦截”,或者用专门的测试白名单手机号段,否则全链路压测会把验证码接口的限流数据打脏,后面看监控数据也不准。
结尾:这次封装我保留的几个习惯
接口上线后反复调了几轮,给我留下的最深体会是:短信验证码接口看起来只是“发一串数字”,实际牵扯到安全、频控、分布式状态存储、上下游协作。现在每写一个接口,我都会习惯性地想三件事:调用方拿到错误码能不能自己定位问题;验证码状态是否有原子性保护;整个链路是否能在不泄露敏感信息的前提下被完整追查。这套东西未必是行业标准,但确实是经过实践检验、能帮团队省很多沟通时间的一套做法。