1.4 技术栈对齐:后端框架与短信地图

场景:五个产品要在一周内接通

机器和网络都就位,接下来要把五个云产品串进同一套后端:轻量应用服务器跑服务、云数据库 MySQL 存数据、短信服务 SMS 发通知、对象存储 COS 放素材、日志服务 CLS 收日志。团队只有三个人,没人专精某一个产品,所以我们的做法是先画一张接入地图,把每个产品的鉴权方式、调用入口、失败回执方式全部列清楚,再动代码。地图没用什么画图工具,就是一张共享表格,五列产品五行关注点,谁负责谁填,填完一起 review。

前后端分工也一并定下:小程序端只渲染接口给的数据,一切业务规则都在 Go 服务里实现。这条线一旦画好,后面就算前端换人重写,后端一行代码都不用动,两端的迭代节奏彻底解耦。

画地图的那个下午,我们把五个产品的文档入口全部翻了一遍,重点只看三件事:请求怎么鉴权、SDK 还是裸 HTTP、配额和限频是多少。这三个问题的答案决定了后面代码里要写多少兜底逻辑。

林姐对这张地图毫无兴趣,她只关心一件事情:发短信要不要钱。我们回答完每条几毛钱之后,她立刻提了两条要求:验证码少发、提醒别漏发。于是触达策略的所有设计都围绕这个朴素诉求展开,最后算下来每个月短信费不到一杯咖啡钱。

方案:一张接入地图管五个产品

最终地图定稿如下,每个产品后面的数字是我们实际对接的耗时,供后来人参考:

产品 鉴权方式 我们的选择 对接耗时 主要坑点
轻量应用服务器 SSH 密钥 自部署 Go 半天 端口与防火墙
云数据库 MySQL 账号密码加白名单 database/sql 直连 1 天 时区与字符集
短信服务 SMS SDKAppID 加密签名 裸 HTTP 调 API 2 天 模板签名审核
对象存储 COS SecretId 加 SecretKey Go SDK 1 天 权限与防盗链
日志服务 CLS SecretId 加 SecretKey 日志采集 Agent 半天 主题与索引

短信 SMS 是唯一花了两天的产品,一天写代码一天等签名审核,详细过程见 模板与签名:审核实操。其余四个都在一天内搞定,秘诀是不要执着于高级特性,先把最短路径跑通。

各产品的默认限频和配额也必须提前摸清,不然高峰期被限流了还以为是代码的 bug:

产品 默认限制 我们的应对
SMS 单号码每分钟一条 触达策略合并同号码
COS 每秒请求数万级 前端直传减少中转
CLS 写入按主题限流 攒批 200 条刷一次
MySQL 连接数 200 到 800 连接池封顶 50

这些数字不查不知道,比如 SMS 对同一号码一分钟内只能发一条,我们的预约成功提醒和验证码要是撞在同一分钟,必然有一条发不出去。知道规则之后才好设计触达节奏,这块在 触达策略 里展开。

地图上还有一条隐线:轻量、MySQL、COS、CLS 全部买在同一地域,互相之间走内网,延迟低又不花公网流量。只有 SMS 是全局产品不受地域限制。这条隐线没写进任何文档,但它是整个架构里最省钱的设计之一。新项目每次开工,同地域采购已经成了我们的默认动作,比事后迁移桶和库省心太多。

后端框架最后定了 Go 加 Gin,中间件链路如下:

r := gin.Default()
r.Use(mwTimeout(3*time.Second))  // 全局超时
r.Use(mwRateLimit(50))           // 每秒 50 次限流
r.Use(mwReqLog())                // 请求日志推 CLS
r.Use(mwRecover())               // panic 兜底

四个中间件的顺序折腾了两回:超时必须放最外层,否则限流器里的等待时间会先耗光超时额度;日志放限流之后,这样打点能区分被拒请求和正常请求。这种顺序细节不写在文档里,全是疼过之后才记住的。

接口纪律我们也定了几条:所有返回包外面套一层固定结构,code 为 0 表示成功,msg 只给提示不给调试信息;版本前缀一律 /api/v1,省得以后升级时无处安放。小团队没有 API 网关,纪律就是网关。接口入口统一收口之后,后面接 CLS 日志和限流统计都只改一处代码,改动半径控制在一个文件里。

配置/代码:一个客户端管理全部外部依赖

所有外部产品的客户端在进程启动时初始化一次,存进一个全局结构体,避免每次请求都新建连接:

type Clients struct {
  DB  *sql.DB
  SMS *SmsClient
  COS *cos.Client
}
var C Clients

短信调用的最短路径,发送一条预约成功通知只要一个函数:

func (s *SmsClient) SendTpl(mobile string, tplID string, params []string) error {
  body := map[string]interface{}{
    "PhoneNumberSet":   []string{mobile},
    "SmsSdkAppId":      s.appID,
    "TemplateId":       tplID,
    "TemplateParamSet": params,
  }
  return s.post(host+"/sms/v3/sendisms", s.sign(body), body)
}

签名算法那一坨是整个接入里唯一有技术含量的部分,照着文档抄一遍即可,不要试图自己优化,验签失败给的信息极少。对象存储 COS 和日志服务 CLS 的 SDK 用官方 Go 库,省掉了自己造轮子的风险。

短信发送之前还要确认一下签名内容能不能过审:签名内容就是短信结尾那行小字,比如我们用的是林姐美甲。body 里的参数顺序必须和模板里的占位顺序一一对应,差一个位置发出去的内容就是错的。我们在自测环境里做了一张对照表,把每个模板 ID、参数名和示例值钉在一起:

模板用途 模板 ID 参数示例 触发时机
预约成功 1234567 店名,时段 支付完成时
到店提醒 2234567 店名,时段 服务前两小时
排队超时 3234567 店名,电话 释放锁位时
验证码 4234567 四位数字 登录注册时

(模板 ID 为脱敏示例。)

踩坑:五件与签名有关的事

第一个坑是时区。MySQL 默认时区是 UTC,而门店的档期全是北京时间,第一周所有预约时间差了八小时,客人下午约的时段被存进了上午,前台看着排班表怀疑人生。修完之后我们在连接串里写死 loc=Local,并在所有跨进程传递的时间上一律用 Unix 时间戳,彻底杜绝时区翻译问题。

第二个坑是字符集。建库时没指定字符集,默认的 latin1 存美甲项目名里的生僻字直接乱码,客人看到菜单上出现问号还以为系统坏了。统一改成 utf8mb4,顺手把排序规则设为 utf8mb4_unicode_ci,问题消失。新库做好这件事,比老库整改容易一百倍。

第三个坑是密钥管理。SecretId 和 SecretKey 一开始写在代码里方便调试,后来一次提交差点推到公开仓库,惊出一身冷汗。从此密钥只待在 env 文件里,代码里一律从环境变量读。

第四个坑是 SDK 版本漂移。SMS 的 API 某次升级后我们的签名算法失效,排查半天才发现是指纹用的方式变了。因此建议裸调 HTTP 反而更可控,接口版本完全握在自己手里。另一个好处是依赖只剩标准库,编译出来的二进制丢到任何机器上都能跑。

第五个坑是 COS 的跨地域访问。门店素材最早存在另一个地域的桶里,每张作品图加载都要绕远路,店里的 iPad 相册轮播卡出幻灯片效果。把桶迁到同地域并开启 CDN 加速后,图片秒开。

这五个坑合起来花掉了三天,但每个坑都只付了一次学费。后来项目加 Stage 新功能时,地图上的经验直接复用,接入新组件再没超过半天。

小结

技术栈对齐本质上是把五个产品的接入路径化成一张地图,谁用 SDK、谁用裸 HTTP、超时和限流放哪一层,都想清楚再写代码。我们一周接完五个产品,中间没返工,靠的就是这张地图和最短路径原则。地图画完贴在团队 wiki,后来有新人加入,照地图半天就能跑通全链路,这比任何口口相传都高效。下一章正式进入最核心的部分:档期与占位的数据模型设计,那里才是预约系统真正的深水区。

延伸阅读