
踩了3个坑才搞定短信字数限制:手写实现避坑实录
刚把同事发来的短信发送代码复制进项目,测试环境跑通了,一上生产环境直接炸了。用户投诉说短信发了一半,关键验证码缺失,后台日志却显示发送成功。这种“复制来的代码跑不通不知道怎么调”的噩梦,谁没经历过?我盯着那段看似正常的字符串截取逻辑看了半小时,发现根本问题不在网络,而在对短信字数限制的底层理解偏差。别急,今天不堆砌理论,直接拆解这个高频坑点,带你手写实现一套真正健壮的短信编码与分割逻辑,把那些隐藏在字符集背后的坑一次性填平。
坑的现象:明明没超限,为什么短信被截断
很多开发者在写短信模块时,第一反应是 len(content) 70 就报错或分割。这在纯英文短信里没问题,但一遇到中文就原形毕露。我见过最离谱的案例,是一个电商平台的促销短信,文案里混了 Emoji 表情和少量英文,后台统计长度只有 65 个字符,自信满满地以为在单条 70 字限制内。结果用户收到的是两截乱码:第一截正常,第二截直接丢失了后半部分促销码。
更隐蔽的坑在于多字节字符的切割。有些实现直接按字节偏移量截取字符串,比如 content[0:160]。在 UTF-8 编码下,一个中文字符占 3 个字节,一个英文占 1 个。如果你在第 160 个字节处切断,极有可能正好切在一个中文字符的中间,导致解码失败,出现 ? 或乱码。这种错误在单元测试里很难发现,因为测试数据往往是整齐的短文案,一旦上线面对用户自定义的复杂文案,立刻翻车。
还有一个常见现象是网关返回“发送成功”但用户未收到。这往往是因为运营商的 SMS 网关在接收到超长但未正确分割的短信时,会静默丢弃后续片段,而不是返回明确的错误码。你以为代码没 bug,其实是协议层的沉默让你产生了虚假的安全感。
根本原因:字符集混淆与 3GPP 规范盲区
要解决短信字数限制问题,必须先撕开“70 字”和“160 字”这两个数字的表象。这不是简单的数字游戏,而是由 3GPP TS 23.040 规范(常被通俗引用为 RFC 风格的电信标准)严格定义的编码规则。核心矛盾在于 GSM 7-bit 默认字符集与 UCS-2(即 UTF-16)扩展字符集的混合使用。
GSM 7-bit 字符集包含了 ASCII 可见字符以及部分特殊符号(如 £、€ 在某些区域配置下)。在这种模式下,单条短信最多容纳 160 个字符。但一旦短信中包含任何 GSM 7-bit 不支持的字符——比如中文、日文、韩文,甚至某些 Emoji——整个短信就必须降级为 UCS-2 编码。此时,每个字符占 2 个字节,单条短信的容量直接腰斩至 70 个字符。
很多出错的代码犯了一个致命错误:它们没有动态判断字符集,而是硬编码了长度限制。或者,它们虽然做了判断,但判断逻辑错误。例如,认为只要包含一个中文,整条短信就按 70 字算,这没错;但错在计算“已发送字符数”时,没有区分当前片段是 GSM 7-bit 还是 UCS-2 编码。
更深层的原因是**多段短信(Concatenated SMS)**的协议开销被忽视。当短信长度超过单条限制时,需要发送多条短信并组合。这要求每条短信头部增加 UDH(User Data Header),用于标识这是一个多段消息的一部分。这个头部通常占用 6 个字节。在 GSM 7-bit 模式下,6 字节约等于 6.4 个字符,意味着每条分片实际只能承载 \(160 - 6 = 154\) 个字符(部分网关实现可能更保守,按 153 或 152 计算)。在 UCS-2 模式下,6 字节正好等于 3 个字符(因为 1 字符=2 字节),所以每条分片实际只能承载 \(70 - 3 = 67\) 个字符。如果你的代码没有扣除这 3 或 6 个字符的开销,最后一条短信就会因为超长而被网关拒收或截断。
正确写法对比:从硬编码到动态编码
让我们通过代码对比,看看错误实现与健壮实现的本质区别。
错误写法:静态长度判断与暴力截取
# 错误示例:Python
def send_sms_wrong(content):# 坑点1:未考虑字符集,假设所有字符等宽# 坑点2:未扣除多段短信的 UDH 头部开销max_len = 160 if all(ord(c) 128 for c in content) else 70if len(content) max_len:# 暴力切片,可能在多字节字符中间切断chunks = [content[i:i+max_len] for i in range(0, len(content), max_len)]else:chunks = [content]for chunk in chunks:# 假设网关能自动处理多段拼接,实际往往不能gateway.send(chunk)这段代码的问题在于:all(ord(c) 128 ...) 只能判断纯 ASCII,无法覆盖 GSM 7-bit 中的特殊符号(如 £),导致误判为 UCS-2。
切片 content[i:i+max_len] 在 Python 中虽然能处理 Unicode 字符串的字符边界,但在其他语言(如 Java 的 String.substring 按 UTF-16 码元切割,C# 的 String.Substring)中,如果涉及代理对(Surrogate Pairs)或底层字节操作,极易出错。
完全没有处理 UDH 开销,导致分片后总长度超限。正确写法:动态编码检测与 UDH 开销扣除
# 正确示例:Python
import reGSM_7BIT_CHARSET = set(ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 .@#£$§/()+-?!\'^_`,{|}~ # 简化的 GSM 7-bit 特殊字符,实际应使用完整表
)def is_gsm_7bit_compatible(text):检查文本是否完全兼容 GSM 7-bit 编码for char in text:if char not in GSM_7BIT_CHARSET and ord(char) = 128:return Falsereturn Truedef encode_sms_correct(content):# 1. 确定编码类型use_gsm_7bit = is_gsm_7bit_compatible(content)# 2. 确定单条容量与多段容量if use_gsm_7bit:# GSM 7-bit: 1 字节/字符single_capacity = 160# 多段短信时,UDH 占 6 字节,每段净容量 154 字符# 注意:某些网关可能要求每段严格小于 160,此处按 154 保守计算multi_capacity = 154 else:# UCS-2: 2 字节/字符single_capacity = 70# 多段短信时,UDH 占 6 字节 = 3 字符,每段净容量 67 字符multi_capacity = 67# 3. 判断是否需要分割if len(content) = single_capacity:return [content], use_gsm_7bit# 4. 分割逻辑:按 multi_capacity 切片chunks = []for i in range(0, len(content), multi_capacity):chunks.append(content[i:i+multi_capacity])return chunks, use_gsm_7bit# 使用示例
content = Hello 世界 ! 这是一个包含中英文的短信。
chunks, is_gsm = encode_sms_correct(content)
print(fIs GSM 7-bit: {is_gsm}) # False, 因为包含中文
print(fChunks: {len(chunks)})
for c in chunks:print(f Length: {len(c)}, Content: {c})关键改进点:动态字符集检测:明确区分 GSM 7-bit 与 UCS-2,避免误判。
UDH 开销扣除:在多段短信场景下,强制扣除 3 或 6 个字符的头部空间,确保每段都能被网关完整接收。
字符安全切片:在 Python 中,字符串切片基于字符索引,天然安全。在其他语言中,需确保使用 Unicode 代码点(Code Point)而非字节偏移进行切片。复现与修复代码:全栈实战演示
为了更直观地展示修复过程,我们以 Java 为例,模拟一个完整的短信发送服务。Java 的 String 基于 UTF-16,处理 Emoji 时需要特别注意代理对,这也是很多 Java 开发者踩坑的重灾区。
复现 Bug 的场景:
用户发送一条包含 Emoji 👍 的短信。在 Java 中,👍.length() 返回 2,因为它由两个 UTF-16 码元(Surrogate Pair)组成。如果你的逻辑简单地用 length() 判断并切片,极有可能在代理对中间切断,导致 Character.isHighSurrogate 检查失败,抛出异常或产生乱码。
修复后的 Java 实现:
import java.util.ArrayList;
import java.util.List;public class SmsEncoder {// GSM 7-bit 兼容字符集(简化版,实际应使用完整映射表)private static final String GSM_CHARS = ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 .@#£$§/()+-?!\'^_`,{|}~;public static boolean isGsm7BitCompatible(String text) {for (int i = 0; i text.length(); i++) {char c = text.charAt(i);// 检查是否为 GSM 7-bit 字符if (GSM_CHARS.indexOf(c) == -1 c = 128) {return false;}}return true;}public static ListString encodeSms(String content) {boolean isGsm = isGsm7BitCompatible(content);int singleCapacity = isGsm ? 160 : 70;int multiCapacity = isGsm ? 154 : 67; // 扣除 UDH 开销ListString chunks = new ArrayList();if (content.length() = singleCapacity) {chunks.add(content);return chunks;}// 关键:使用 codePoint 遍历,避免切断代理对int index = 0;int start = 0;while (index content.length()) {int codePoint = content.codePointAt(index);int charCount = Character.charCount(codePoint);// 如果加上当前字符会超过单段容量,则结束当前段if (index - start + charCount multiCapacity) {// 截取从 start 到 index 的字符串chunks.add(content.substring(start, index));start = index;}index += charCount;}// 添加最后一段if (start content.length()) {chunks.add(content.substring(start));}return chunks;}public static void main(String[] args) {// 测试包含 Emoji 的短信String testMsg = Hello 👍 World! 这是中文测试。;ListString encoded = encodeSms(testMsg);System.out.println(Total Chunks: + encoded.size());for (String chunk : encoded) {System.out.println(Chunk Length: + chunk.length() + - + chunk);}}
}代码解析:codePointAt 与 charCount:这是 Java 中处理 Unicode 字符串的标准姿势。它确保我们按“用户感知的字符”进行计数和切片,而不是底层的 UTF-16 码元。
动态容量计算:根据是否包含 GSM 7-bit 兼容字符,动态选择 154 或 67 作为多段短信的单段容量。
切片逻辑:substring(start, index) 确保每一段都是合法的 Unicode 字符串,不会出现半截 Emoji 或乱码。规避建议:从编码到网关的全链路防御
解决短信字数限制问题,不能只盯着代码里的 if-else。你需要建立一套全链路的防御机制。
1. 建立字符集白名单
不要依赖运行时判断。在项目初期,整理一份完整的 GSM 7-bit 字符映射表。如果业务场景明确只涉及中文,可以直接强制使用 UCS-2 编码,简化逻辑。但如果是国际化项目,必须支持动态检测。
2. 网关配置一致性
不同运营商(中国移动、联通、电信)及第三方网关(阿里云、腾讯云)对 UDH 的处理略有差异。有些网关要求 UDH 占 6 字节,有些可能因厂商私有协议占 8 字节。务必查阅你所用网关的官方文档,确认其多段短信的头部开销。在代码中将其配置化,而不是硬编码。
3. 监控与告警
在发送日志中记录每个短信片段的实际长度、编码类型和网关返回码。如果频繁出现“发送成功但用户投诉未收到”的情况,检查是否有片段长度恰好等于网关阈值。建议设置一个“安全余量”,比如将多段容量从 154 降至 150,从 67 降至 65,牺牲少量容量换取更高的兼容性。
4. 单元测试覆盖边界
编写专门的单元测试用例,覆盖以下场景:纯英文 160 字(单条)
纯英文 161 字(双段,每段 154 字)
纯中文 70 字(单条)
纯中文 71 字(双段,每段 67 字)
中英混合,总长刚好跨越阈值
包含 Emoji 的长文本你公司项目里是怎么处理短信字数限制的?是硬编码 70/160,还是做了动态编码检测?如果在多段短信拼接上遇到过网关兼容性问题,欢迎评论区分享你的解决方案,一起避坑。