1. 从一个真实需求说起:为什么短信服务调用值得单独记录
做后端开发的朋友大概率都遇到过这样的场景:用户注册需要验证码、订单状态变更需要通知、营销活动需要触达用户。这些功能背后都指向同一个基础能力——短信服务。而在国内的技术栈里,云厂商的短信服务几乎是绕不开的选择。
我最近在一个用 Go 语言构建的用户系统项目里,需要接入短信验证码功能。项目本身不复杂,但真正动手写的时候,我发现事情没有想象中那么“调个 SDK 就完事”。签名机制怎么处理、模板参数怎么传、错误码怎么映射、并发场景下怎么保证不超发、发送频率怎么控制——这些问题在官方文档里往往一笔带过,但在实际项目里每一个都可能让你加班到深夜。
这篇文章就是把我整个接入过程完整记录下来。从前期选型思考,到 SDK 集成、核心代码实现,再到上线后踩过的坑和排查经验,全部摊开来讲。适合正在做 Go 后端开发、需要接入短信能力的同学参考,也适合已经接过但想看看有没有更好实践的朋友对照检查。文章里涉及的所有代码和配置都经过实际验证,你可以直接拿去改改就用。
需要提前说明的是,不同云厂商的短信服务在 API 设计上大同小异,核心思路是相通的。我以阿里云短信服务为例来展开,但其中的设计思路、参数处理方式、异常处理策略,换到其他平台同样适用。重点是理解“为什么这么做”,而不是死记某个 SDK 的方法名。
2. 接入前的整体设计与选型思考
2.1 为什么选择 SDK 而不是直接调 HTTP 接口
阿里云短信服务提供了两种调用方式:一种是直接发 HTTP 请求到 API 网关,另一种是使用官方维护的 Go SDK。我一开始也犹豫过,毕竟直接发 HTTP 请求看起来更“轻量”,不用引入额外依赖。但实际对比之后,我还是选了 SDK,原因有几个。
第一是签名问题。阿里云的 API 请求需要对参数做 HMAC-SHA1 签名,签名过程涉及参数排序、URL 编码、时间戳格式化等一堆细节。手写这套逻辑不是不行,但很容易在某个边界情况上出错,比如参数值里包含特殊字符时编码方式不对,导致签名验证失败。SDK 把这些都封装好了,你只需要传业务参数。
第二是错误处理。短信服务的错误码非常多,光是我遇到的就有十几种,比如签名不匹配、模板未审核、手机号格式错误、余额不足、触发流控等等。SDK 会把 HTTP 响应解析成结构化的错误对象,你可以直接用类型断言拿到错误码,而不用自己去解析 JSON 响应体。
第三是维护成本。云厂商的 API 偶尔会有调整,用 SDK 的话升级依赖版本就行,手写 HTTP 请求的话每次都要跟着改。当然 SDK 也不是没有缺点,它会引入一些间接依赖,编译出来的二进制文件会大一点。但对于大多数项目来说,这点开销完全可以接受。
如果你对二进制体积特别敏感,或者项目有特殊的 HTTP 客户端配置需求(比如自定义连接池、超时策略),那手写 HTTP 请求也是合理的选择。关键是评估自己的团队能不能维护好签名逻辑。
2.2 项目结构怎么组织才不乱
短信功能虽然不复杂,但如果随手写在业务代码里,后面维护会很痛苦。我的做法是单独抽一个sms包,把发送逻辑、配置管理、错误处理都收拢在一起。这样做的直接好处是:业务层只需要调用一个简单的方法,不用关心底层用的是哪家云厂商,将来如果要换服务商,改一个包就行。
具体来说,我在项目里建了这样一个目录结构:
project/ ├── internal/ │ ├── sms/ │ │ ├── client.go // 短信客户端封装 │ │ ├── config.go // 配置结构定义 │ │ ├── errors.go // 错误码映射 │ │ └── template.go // 模板参数构建 │ └── service/ │ └── user.go // 业务层调用 ├── configs/ │ └── config.yaml // 配置文件 └── go.modclient.go里封装了 SDK 的初始化和发送方法,对外暴露一个SendVerificationCode这样的接口。config.go定义了 AccessKey、签名名称、模板 Code 这些配置项。errors.go把云厂商的错误码转成项目内部的业务错误,这样上层不用关心底层是哪个服务商。template.go负责把验证码、过期时间这些参数组装成模板需要的格式。
这种分层的好处在于,业务代码里看不到任何云厂商的痕迹。user.go里只需要写smsClient.SendVerificationCode(ctx, phone, code),至于底层是阿里云还是腾讯云,业务层完全不感知。
2.3 配置管理:别把 AccessKey 硬编码在代码里
这是新手最容易犯的错误,也是安全问题的高发区。我见过不少项目直接把 AccessKey 和 Secret 写在代码里,然后提交到了代码仓库。一旦仓库泄露,别人就能用你的账号发短信,轻则余额被刷光,重则被用来发垃圾短信导致账号被封。
正确的做法是把敏感信息放在环境变量或者配置中心里。我的做法是在配置文件里只写占位符,实际值通过环境变量注入:
# config.yaml sms: access_key_id: ${SMS_ACCESS_KEY_ID} access_key_secret: ${SMS_ACCESS_KEY_SECRET} sign_name: "你的签名名称" template_code: "SMS_123456789" region_id: "cn-hangzhou"然后在代码里读取环境变量。如果是容器化部署,就在 K8s 的 Secret 里配置;如果是传统部署,就用系统的环境变量或者配置管理工具。这样代码仓库里永远不会出现真实的密钥。
另外提醒一点:阿里云建议使用 RAM 子账号的 AccessKey,而不是主账号的。给子账号只授予短信发送权限,这样即使密钥泄露,损失也是可控的。主账号的 AccessKey 权限太大,一旦泄露后果不堪设想。
3. 核心细节拆解:签名、模板与参数处理
3.1 签名机制到底在做什么
阿里云短信服务的签名机制,简单来说就是:你用 AccessKey Secret 对请求参数做一次 HMAC-SHA1 计算,把结果作为签名一起发给服务端。服务端用同样的 Secret 和参数再算一遍,如果结果一致,就说明请求是合法的。
这个过程有几个关键点容易出错。第一是参数排序,所有请求参数(包括公共参数和业务参数)要按参数名的字典序排列。第二是编码方式,参数名和参数值都要做 URL 编码,而且编码规则要符合 RFC 3986,空格要编码成%20而不是+。第三是时间戳格式,必须是 UTC 时间的 ISO 8601 格式,比如2024-01-15T08:30:00Z。
SDK 把这些都封装好了,你不需要手动处理。但理解这个机制有助于排查问题。比如你遇到SignatureDoesNotMatch错误,大概率是因为系统时间不准,导致时间戳偏差太大。这时候检查一下服务器的时间同步就行了。
3.2 模板参数:一个容易踩坑的地方
短信模板是提前在控制台创建并审核通过的,比如“您的验证码是 ${code},${minutes} 分钟内有效。”这里的${code}和${minutes}就是模板参数。发送的时候需要把这些参数以 JSON 格式传给 API。
我踩过的坑是参数值的类型问题。模板参数的值必须是字符串,即使你传的是数字,也要转成字符串。比如验证码是123456,你要传"123456"而不是123456。有些 SDK 版本对类型要求比较严格,传数字会直接报参数错误。
另一个坑是参数个数必须和模板定义完全一致。模板里定义了三个参数,你就必须传三个,少一个会报错,多一个也会报错。而且参数名要完全匹配,大小写敏感。我建议在代码里把模板参数的定义和模板 Code 放在一起管理,这样不容易搞混。
// template.go package sms // 验证码模板参数 type VerifyCodeParams struct { Code string // 验证码 Minutes string // 有效分钟数 } func (p VerifyCodeParams) ToMap() map[string]string { return map[string]string{ "code": p.Code, "minutes": p.Minutes, } }这样每次发送的时候,参数结构是固定的,不会因为手误漏传或者多传。
3.3 手机号格式:看似简单实则暗藏玄机
手机号格式校验看起来是个小问题,但实际项目中经常出问题。国内手机号是 11 位数字,以 1 开头。但用户输入的时候可能带空格、带+86前缀、带横线,这些都需要在发送前做规范化处理。
我的做法是在client.go里加一个normalizePhone函数,把各种格式统一成纯 11 位数字:
func normalizePhone(phone string) (string, error) { // 去掉所有非数字字符 re := regexp.MustCompile(`\D`) cleaned := re.ReplaceAllString(phone, "") // 处理 +86 前缀 if len(cleaned) == 13 && strings.HasPrefix(cleaned, "86") { cleaned = cleaned[2:] } // 校验长度和开头 if len(cleaned) != 11 || !strings.HasPrefix(cleaned, "1") { return "", ErrInvalidPhone } return cleaned, nil }这个函数虽然简单,但能避免很多因为格式问题导致的发送失败。另外要注意的是,国际手机号的格式和国内不同,如果你的业务涉及海外用户,需要单独处理。
还有一个细节:阿里云短信服务的手机号参数支持批量发送,多个号码用逗号分隔,一次最多 1000 个。但批量发送的响应是整体成功或失败,不会告诉你具体哪个号码失败了。如果需要精确知道每个号码的发送结果,建议还是单个发送。
4. 完整实操:从零搭建短信发送模块
4.1 环境准备与依赖安装
先确保你的 Go 版本在 1.18 以上,因为后面会用到泛型和context的一些特性。然后初始化项目(如果还没有的话):
go mod init your-project-name安装阿里云短信 SDK:
go get github.com/aliyun/alibaba-cloud-sdk-go/services/dysmsapi这个包名里的dysmsapi就是“短信 API”的缩写。安装完成后,go.mod里会多出一行依赖。我建议锁定一个稳定的版本,不要用latest,避免自动升级引入不兼容的改动。
接下来在阿里云控制台完成几项准备工作:创建签名(比如“某某科技”)、创建模板(比如验证码模板)、获取 AccessKey。这些步骤在控制台都有引导,按提示操作即可。需要注意的是,签名和模板都需要审核,一般几个小时到一天不等,建议提前准备。
4.2 客户端封装:让调用变得简单
下面是我封装的核心客户端代码。先定义配置结构:
// config.go package sms type Config struct { AccessKeyID string AccessKeySecret string SignName string TemplateCode string RegionID string }然后是客户端结构:
// client.go package sms import ( "context" "encoding/json" "fmt" "github.com/aliyun/alibaba-cloud-sdk-go/sdk/requests" "github.com/aliyun/alibaba-cloud-sdk-go/services/dysmsapi" ) type Client struct { apiClient *dysmsapi.Client config Config } func NewClient(cfg Config) (*Client, error) { apiClient, err := dysmsapi.NewClientWithAccessKey( cfg.RegionID, cfg.AccessKeyID, cfg.AccessKeySecret, ) if err != nil { return nil, fmt.Errorf("初始化短信客户端失败: %w", err) } return &Client{ apiClient: apiClient, config: cfg, }, nil }这里用的是NewClientWithAccessKey,它会自动处理签名和请求构造。RegionID一般填cn-hangzhou,这是短信服务的默认接入点,和你的服务器所在地无关。
接下来是发送方法:
func (c *Client) SendVerifyCode(ctx context.Context, phone, code string) error { normalized, err := normalizePhone(phone) if err != nil { return err } params := VerifyCodeParams{ Code: code, Minutes: "5", } paramBytes, err := json.Marshal(params.ToMap()) if err != nil { return fmt.Errorf("序列化模板参数失败: %w", err) } request := dysmsapi.CreateSendSmsRequest() request.Scheme = "https" request.PhoneNumbers = normalized request.SignName = c.config.SignName request.TemplateCode = c.config.TemplateCode request.TemplateParam = string(paramBytes) response, err := c.apiClient.SendSms(request) if err != nil { return fmt.Errorf("发送短信请求失败: %w", err) } if response.Code != "OK" { return mapError(response.Code, response.Message) } return nil }这段代码里有几个关键点。request.Scheme = "https"确保请求走加密通道。TemplateParam是 JSON 字符串,不是 map,所以要先序列化。响应里的Code字段是字符串"OK"表示成功,其他值都是错误码。
4.3 错误码映射:让上层知道到底出了什么问题
阿里云返回的错误码有几十个,如果直接把原始错误码抛给上层,业务代码里就会充斥着一堆魔法字符串。我的做法是定义一个内部错误类型,把常见的错误码映射成有意义的业务错误:
// errors.go package sms import "errors" var ( ErrInvalidPhone = errors.New("手机号格式不正确") ErrRateLimit = errors.New("发送频率过高,请稍后再试") ErrInsufficient = errors.New("短信余额不足") ErrTemplateInvalid = errors.New("短信模板无效") ErrSignInvalid = errors.New("短信签名无效") ErrUnknown = errors.New("短信发送失败") ) func mapError(code, message string) error { switch code { case "isv.MOBILE_NUMBER_ILLEGAL": return ErrInvalidPhone case "isv.BUSINESS_LIMIT_CONTROL": return ErrRateLimit case "isv.AMOUNT_NOT_ENOUGH": return ErrInsufficient case "isv.TEMPLATE_MISSING_PARAMETERS", "isv.INVALID_PARAMETERS": return ErrTemplateInvalid case "isv.SMS_SIGNATURE_ILLEGAL", "isv.SIGNATURE_NOT_MATCH": return ErrSignInvalid default: return fmt.Errorf("%w: [%s] %s", ErrUnknown, code, message) } }这样业务层只需要判断errors.Is(err, sms.ErrRateLimit)就知道是频率限制,可以给用户返回“操作太频繁”的提示。底层的错误码变化不会影响业务逻辑。
注意
isv.BUSINESS_LIMIT_CONTROL这个错误码,它表示触发了流控。阿里云对同一个手机号有发送频率限制(默认 1 分钟 1 条,1 小时 5 条,1 天 10 条),对同一个签名也有总量限制。这个限制是为了防止短信被滥用,但有时候会误伤正常用户。我的做法是在业务层加一个本地缓存,记录每个手机号的发送时间,在调用短信服务之前先做一次检查,避免不必要的 API 调用。
4.4 业务层调用:简洁才是王道
有了上面的封装,业务层的代码就非常干净了:
// service/user.go package service func (s *UserService) SendLoginCode(ctx context.Context, phone string) error { // 生成 6 位随机验证码 code := generateCode(6) // 先存到 Redis,设置 5 分钟过期 if err := s.redis.Set(ctx, "sms:code:"+phone, code, 5*time.Minute); err != nil { return fmt.Errorf("存储验证码失败: %w", err) } // 发送短信 if err := s.smsClient.SendVerifyCode(ctx, phone, code); err != nil { // 发送失败,删除 Redis 里的验证码 s.redis.Del(ctx, "sms:code:"+phone) return err } return nil }这里有一个重要的细节:先存 Redis 再发短信,发送失败时回滚。如果顺序反过来,先发短信再存 Redis,万一 Redis 写入失败,用户收到了验证码但系统里没有记录,用户输入验证码会提示错误,体验很差。
5. 上线后踩过的坑与排查实录
5.1 常见错误码速查表
下面这张表是我在实际项目中遇到过的错误码和对应的处理方法,整理出来方便大家快速定位问题:
| 错误码 | 含义 | 排查方向 | 解决方案 |
|---|---|---|---|
isv.MOBILE_NUMBER_ILLEGAL | 手机号格式错误 | 检查号码是否 11 位、是否以 1 开头 | 在发送前做格式规范化 |
isv.BUSINESS_LIMIT_CONTROL | 触发流控 | 检查是否短时间内重复发送 | 加本地频率控制,提示用户稍后再试 |
isv.AMOUNT_NOT_ENOUGH | 余额不足 | 登录控制台查看余额 | 及时充值,设置余额告警 |
isv.TEMPLATE_MISSING_PARAMETERS | 模板参数缺失 | 检查参数个数和名称是否匹配 | 对照模板定义逐一核对 |
isv.SMS_SIGNATURE_ILLEGAL | 签名不合法 | 检查签名名称是否与控制台一致 | 确认签名已审核通过且名称完全匹配 |
isv.INVALID_PARAMETERS | 参数无效 | 检查参数类型和格式 | 确保所有参数值都是字符串 |
SignatureDoesNotMatch | 签名计算错误 | 检查系统时间是否准确 | 同步服务器时间,检查 AccessKey |
Throttling.User | 用户级流控 | 检查调用频率是否过高 | 降低调用频率或申请提额 |
这张表建议收藏,遇到问题先查表,能省不少时间。
5.2 那些文档里不会告诉你的坑
第一个坑是时间同步。我遇到过一台服务器因为 NTP 服务没配好,时间慢了 10 分钟,导致所有短信请求都返回SignatureDoesNotMatch。排查了半天才发现是时间问题。所以上线前一定要确保服务器开启了时间同步。
第二个坑是并发场景下的重复发送。用户快速点击“获取验证码”按钮,前端如果没有做防抖,会瞬间发出多个请求。虽然后端有流控,但用户体验很差。我的做法是在 Redis 里加一个锁,同一个手机号在 60 秒内只能发送一次:
func (s *UserService) SendLoginCode(ctx context.Context, phone string) error { lockKey := "sms:lock:" + phone ok, err := s.redis.SetNX(ctx, lockKey, "1", 60*time.Second) if err != nil { return fmt.Errorf("获取发送锁失败: %w", err) } if !ok { return ErrRateLimit } // ... 后续发送逻辑 }这个锁的过期时间设为 60 秒,和短信服务的流控窗口对齐。这样即使用户狂点,也只有第一次会真正发送。
第三个坑是模板审核状态。模板提交后需要审核,审核通过前调用会返回模板无效的错误。我有一次在测试环境用了一个刚创建的模板,没等审核通过就测试,结果一直报错,还以为是代码问题。后来才发现是模板还在审核中。所以创建模板后要确认状态是“已通过”再使用。
第四个坑是AccessKey 权限。如果用的是 RAM 子账号,需要确保子账号有AliyunDysmsFullAccess权限。我有一次只给了读权限,发送一直失败,错误信息也不够明确,排查了很久才发现是权限问题。
5.3 监控与告警:别等用户投诉才知道短信发不出去
短信服务是外部依赖,随时可能因为各种原因不可用。如果没有监控,可能用户收不到验证码来投诉了,你才知道出了问题。我的做法是加几个关键的监控指标:
- 发送成功率:每分钟统计一次,低于 95% 就告警
- 发送量突增:如果发送量突然比平时高很多,可能是被刷了
- 余额监控:每天检查一次余额,低于阈值就提醒充值
- 错误码分布:按错误码分类统计,及时发现异常
这些指标可以用 Prometheus 采集,配合 Grafana 做面板。如果项目规模不大,也可以简单地打日志,然后用日志服务做告警。
// 在发送方法里加埋点 func (c *Client) SendVerifyCode(ctx context.Context, phone, code string) error { start := time.Now() err := c.doSend(ctx, phone, code) // 记录指标 smsSendTotal.WithLabelValues(statusLabel(err)).Inc() smsSendDuration.Observe(time.Since(start).Seconds()) return err }这样每次发送都会记录成功或失败,以及耗时。时间长了就能看出规律,比如某个时间段失败率特别高,可能是运营商的问题。
5.4 成本控制:短信是要花钱的
短信服务按条计费,虽然单价不高,但量大了一个月也是不小的开销。除了前面说的频率控制,还有几个省钱技巧。
一是验证码有效期不要太长。5 分钟足够了,设置太长用户可能会重复使用,增加发送量。二是同一手机号每天发送次数限制,比如最多 10 次,超过就拒绝。三是区分业务优先级,营销短信和验证码短信走不同的通道,营销短信可以设置更严格的发送限制。
还有一个容易被忽略的点:失败重试。如果发送失败就立即重试,可能会因为流控导致连续失败,白白浪费调用次数。我的做法是失败后不立即重试,而是让用户手动触发,或者等待一段时间后再试。
6. 一些进阶思考与扩展方向
6.1 多服务商容灾
只用一家短信服务商是有风险的,万一服务商出故障,你的验证码就发不出去了。比较稳妥的做法是接入两家服务商,主备切换。实现上可以在sms包里定义一个接口:
type Sender interface { Send(ctx context.Context, phone, template string, params map[string]string) error }然后分别实现阿里云和另一家的 Sender,用一个FailoverSender包装起来,主服务商失败时自动切到备用。这样即使一家出问题,业务也不会中断。
6.2 短信内容的安全与合规
短信内容需要符合相关规定,不能包含违规信息。验证码短信相对简单,但营销短信就要注意了。另外,短信签名和模板都需要审核,审核不通过的内容不能发送。建议在开发阶段就用正式的签名和模板,避免测试用的临时内容上线后不能用。
6.3 国际化支持
如果业务涉及海外用户,需要考虑国际短信。国际短信的计费方式、发送限制、模板要求都和国内不同。而且不同国家的手机号格式差异很大,不能简单套用国内的校验规则。这块我目前经验有限,但建议有海外业务的朋友提前调研,不要等到上线了才发现问题。
我个人在实际操作中的体会是,短信服务接入本身技术难度不高,但细节特别多。真正花时间的不是写代码,而是处理各种边界情况和异常场景。把错误处理做好、把监控加上、把频率控制住,这个功能才算真正可用。另外,不要等到上线后才考虑安全和成本问题,这些应该在设计阶段就想清楚。