变量短信与长短信怎么发:模板规范、拆分计费与对账口径
变量短信与长短信的工程做法:变量占位符六个细节(位置与报备一致/填充前校验/空值兜底/JSON转义/批量错位防护/链接不动态)、长短信拆分如何改变计费条数、回执与请求一对多导致对账键必须用request_id+分片序号、批量按分片并发与重试幂等,以及变量与长短信叠加时的五步排查顺序与十一项上线检查单。
变量短信和长短信是两个不同的坑:前者解决"同一条模板要给不同的人不同内容",后者解决"一条内容太长会被拆开"。很多团队上线后才发现后者的坑——你以为自己发了 1 千条短信,用户收到了 2 千条,账单也是 2 千条。
这篇讲清楚两类短信的模板规范、拆分计费,以及对账时最容易出错的地方。
一、两个概念的边界
| 变量短信 | 长短信 |
|---|
| 解决什么 | 同模板、不同内容 | 单条装不下的内容 |
|---|---|---|
| 表现 | 模板里有占位符,逐条替换 | 内容超过单条上限,被拆成多条 |
| 计费 | 按条 | 按拆分后的条数 |
| 报备 | 报模板 + 变量位置 | 报模板(内容需与实际发送一致) |
| 常见坑 | 变量为空、JSON 转义、批量时变量错位 | 对账时条数对不上 |
两者可以叠加:一条带变量的长短信,拆成多条后每条的变量还不一样——这是最难调通的情况。
二、变量短信:占位符的六个细节
变量短信的本质是"模板 + 变量"。模板报备时就要把变量位置说清楚,发送时按同样的结构填值。
1. 变量位置必须和报备时一致
报备模板是 您的验证码是 {code},发送时就只能把数字填在那个位置。把变量挪到句子中间,属于"内容与报备不一致",会被驳回或拦截。改模板结构 = 重新报备。
2. 变量值要限制长度和字符集
验证码是 4-6 位数字,订单号是固定格式。变量长度超出预期时,轻则内容被截断,重则整条被拦。工程做法是在填充前校验:
import re
PATTERNS = { "code": re.compile(r"^d{4,8}$"), "order": re.compile(r"^[A-Za-z0-9]{6,32}$"), "name": re.compile(r"^[一-龥A-Za-z·]{2,20}$"), }
def fill(template: str, variables: dict) -> str: for key, pattern in PATTERNS.items(): v = variables.get(key) if v is None: raise ValueError(f"变量 {key} 缺失") if not pattern.match(str(v)): raise ValueError(f"变量 {key} 格式不合法: {v!r}") return template.format(**variables)
先校验再填充,比事后从错误短信里反查便宜得多。
3. 变量为空要有兜底策略
用户没填姓名、订单还没生成,这些是常态。策略要明确:丢弃这条、用默认文案、还是降级成不含变量的模板——选一种并写进代码。默认用 str.format 的话,空值会渲染成 None,用户会看到"您的验证码是 None"。
4. JSON 里的变量要转义
短信内容作为 JSON 字段传递时,引号和反斜杠必须转义,否则请求体直接解析失败。变量里如果混入 " 或换行,会把整条短信截断——验证一次带特殊字符的变量,成本很低。
5. 批量发送时变量与手机号必须成对
批量接口常见两种形态:一次提交一个数组,或一次提交一个模板 + 变量数组。用数组时,顺序错位是最典型的批量事故——用户 A 收到用户 B 的验证码。防御手段:
# 变量数组与手机号数组长度必须一致,且发送前做一次抽样校验
assert len(phones) == len(vars_list)
抽样:用前 3 条做占位符渲染后的长度/格式自检
for p, v in list(zip(phones, vars_list))[:3]: filled = fill(template, v) assert "{{" not in filled and "}}" not in filled, "变量未全部替换"
更稳的做法是传对象数组([{phone, vars}, ...]),让手机号和变量在结构上绑死,从源头消除错位。
6. 变量里的链接要单独考虑
如果模板里带链接,链接也要报备,且不支持动态拼接——变量里塞不同的链接等于每次换链接,等于没报备。带链接的场景见短信带链接怎么发与短链服务设计。
三、长短信:拆分与计费
1. 拆分会改变计费条数
内容超过单条上限时,会被拆成多条下发、按条计费。所以:
你发送:1,000 条长短信(每条内容都超限)
用户收到:约 2,000 条(实际以运营商拆分规则为准) 账单:按 2,000 条计
先确认你的模板是不是长短信——如果模板里只有一句"您的验证码是 123456",它不是长短信;如果模板里塞了活动说明、地址、退换政策,那它很可能就是。上线前把模板内容实际渲染一遍,量一下长度,比事后看账单便宜。
2. 拆分会打乱回执与请求的对应关系
这是最实际的坑。通道回执是"按实际下发的短信条"返回的,而你的请求是"按用户条":
请求:user_001(长短信,拆成 2 条)
回执:可能收到 2 条回执,分别对应这条短信的第 1 段、第 2 段
如果对账逻辑用"手机号 + 验证码"做键,长短信场景下会出现一对多的匹配,轻则重复计费统计,重则把失败当成成功。
正确做法是:对账键 = 请求 ID + 分片序号,而不是业务字段。
-- 请求与回执对账(按分片序号匹配)
SELECT r.request_id, r.phone, COUNT(h.msg_id) AS delivered_parts FROM sms_send_log r LEFT JOIN sms_delivery_log h ON h.request_id = r.request_id WHERE r.created_at >= :since GROUP BY r.request_id, r.phone HAVING delivered_parts <> r.expected_parts; -- 分片数不一致 = 待排查
3. 批量发送要按分片并发,而不是按用户并发
长短信场景下,一个用户对应多条实际下发,速率压力是按分片算的。并发与限流的做法见批量实名核验怎么并发——那套分片 + 退避 + 幂等键的做法在这里完全适用。
4. 重试必须带幂等键
长短信发送失败后重试,如果接口没有幂等能力,用户可能收到两条重复的长短信(还多付一次钱)。重试前先确认这个接口是否支持幂等键;不支持的话,自己在发送侧记录 request_id 并在重试前查询状态。
四、变量 + 长短信叠加时的排查顺序
遇到"用户说收到的内容和预期不一样",按这个顺序查,不要跳步:
1. 渲染后的实际内容长什么样?(拿 request_id 找回原始内容快照)
该内容的长度是多少?是否触发拆分?
回执条数与 expected_parts 是否一致?
变量值与手机号是否错位?(批量场景优先查这个)
模板与报备版本是否一致?(模板改过但没重新报备)
第 1 步最容易被跳过,但它是后面所有判断的基准:没有原始内容快照,排查就只能靠猜。发送日志里至少要留 request_id + 手机号 + 渲染后内容 + 模板版本。
五、上线检查单
□ 变量位置与报备模板一致,改结构即重新报备
□ 变量填充前做格式校验(长度、字符集) □ 变量为空有明确兜底策略(不用 str.format 的默认空值) □ 变量值做过 JSON 转义,含特殊字符的样例已验证 □ 批量用对象数组传参,避免手机号与变量错位 □ 发送前抽样渲染自检,确认无未替换占位符 □ 模板实际渲染长度已量过,确认是否属长短信 □ 对账键 = request_id + 分片序号,不用业务字段 □ 批量按分片并发,限流按实际下发条数计 □ 重试带幂等键(或发送侧自查 request_id 状态) □ 发送日志保留 request_id、渲染后内容、模板版本
相关阅读
短信通道按条计费,签名与模板需报备;具体计费口径(含长短信拆分规则)与配额请与商务确认,新用户有免费额度可用于联调。