从 I 和 L 说起,重新想了一遍兑换码
看见知乎上关于兑换码里 I 和 L 的问题,我回头检查了 AI 绘图模块的积分兑换功能。从 Base24 和 Crockford 的字符集,到输入容错、校验段,再到随机数、限流和重复到账,记录这次重新理解兑换码的过程和准备采用的方案。
最近在知乎看到一个问题:“为什么各种兑换码不能规避IL字母?”
有个答主的回答很直接,大意是很多开发者偷懒。Base24 的码表就已经去掉了容易混淆的数字和字母,没必要让用户对着一串码,研究哪个是字母,哪个是数字。
我马上想到了自己的 AI 绘图模块。里面也做了兑换码换积分的功能,之前这部分是让 Codex 写的,我对兑换码没什么研究。从功能上看,随机生成一串字符,存进数据库,用户输入后查一下,没用过就加积分,似乎就能用了。
于是先问了豆包,又和 Codex 聊了一轮。让 Codex 回头检查代码,现有实现已经避开了一些易混字符,也用了安全随机数、摘要存储和兑换事务。不过,没有专门的校验段。
原来这个看起来很小的功能,还真有不少讲究。
“偷懒”可以解释一种情况,但不能解释所有兑换码。历史格式要兼容,长度可能有限制,不同系统也可能有不同的取舍。只看一串码,没法判断开发者当时为什么这样做。不过,如果是在做一个新功能,确实没必要把这些麻烦留给用户。
English version: Rethinking Redemption Codes, Starting with I and L

先别让人猜字母
普通字母和数字直接混在一起,最明显的问题就是 I、l、1,以及 O、0。字体稍微不合适,或者图片压缩得厉害一点,就很难分清。
如果兑换码需要手动输入,生成时就应该考虑这件事。等用户输错了,再提示“兑换码不存在”,已经晚了一步。
Base24 Bravo Niner 使用的字符集是:
BCDFGHJKLMNPQRSTVWXZ6789
这里只留下 24 个字符,去掉了元音字母,包括 Y,以及数字 0 到 5。
有意思的是,它其实还保留了 L。只是 I 和数字 1 都没了,原来那组容易混淆的字符,就不用再互相辨认。这也说明,规避混淆不一定意味着把某个字母彻底删掉,而是不要让长得相似的几个字符同时出现。
豆包还提到了另一个方案:Crockford Base32。它使用的字符集是:
0123456789ABCDEFGHJKMNPQRSTVWXYZ
它保留了 0 和 1,排除了 I、L、O、U。生成结果只用大写字母,输入时不区分大小写,而且允许把 O 当成 0,把 I、L 当成 1。
这个思路我更喜欢。
用户把数字 0 看成字母 O,也能输入正确。系统没有要求他先学会区分,只是约定这些写法代表同一个值。
我的需求是“不容易看错”,并没有要求生成结果里不能出现 0。如果生成结果里没有 O,输入时又接受 O 作为 0 的别名,那保留 0 没什么问题。
字符删得越多,同样长度能表示的随机空间就越小。想保持同样的随机空间,码就要变长。字符集也有取舍,不能只比谁删得多。
当然,选了字符集也不能就此收工。显示时每四位分一组,用字形清楚的字体,提供复制按钮,允许整串粘贴,都比让用户盯着一长串字符逐个抄写好。输入框也没必要拆成七八个小格子,改中间一位时,光标能正常移动就很舒服。
去掉 I,还是会输错
Base24 里还留着 B 和 D,也留着 M 和 N。Crockford 同样如此。
这几个字符在清晰的屏幕上没什么问题,换成一张拍歪了的照片,或者用户只是匆匆扫了一眼,还是可能看错。更不用说漏输一位、重复输一位,或者把相邻两个字符敲反。
所以字符集只能减少错误,不能保证人不犯错。剩下的错误,要靠校验来发现。
校验段可以理解成字符串自己带的一份核对信息。生成时,根据前面的内容算出几个校验字符,一起交给用户。输入时重新算一遍,两边对不上,就知道这串码中间出了问题。
它必须随码一起提供,前端才能不查数据库就检查。服务端也要再检查一次,不能因为页面已经校验过就省掉。
这和正则表达式还不一样。正则能检查长度对不对、有没有非法字符,但把 B 输成 D,长度和字符范围都没变,正则看不出问题。
豆包推荐的是 Crockford Base32 加 CRC16。CRC 是成熟的检错算法,这个方向能用。但“CRC16”还不够具体:使用哪个多项式、数据多长,关系到能保证检出哪些错误;初始值、位序等也得约定好,否则两端算出的结果会不同。CRC 的选型研究里讨论了多项式和数据长度对检错能力的影响。
我更想要一个能直接对应用户行为的保证:看错一位能不能发现?两位交换能不能发现?不用只说一个“漏检概率很低”。
因此,这次准备采用的是字符级的 Reed–Solomon 校验,简称 RS。
名字看起来比兑换码这个功能还大,但这里的用法很小:每个 Crockford 字符对应 0 到 31 的一个值,再计算出四个校验字符。
按这套固定长度和参数实现,任意两个合法码,至少有五个字符位置不同。这个性质叫最小距离,RS 的距离公式是 n − k + 1,四个校验符号对应距离 5。编码理论课程讲义给出了这个性质和证明。
这样一来,只改错一到四个字符,就不可能变成另一个合法码,校验一定失败。相邻两个不同字符交换,改变了两个位置,也能发现。单独漏输或多输一个字符,则先被长度检查拦住。
这个保证针对的是规范化之后的字符替换。插入和删除混在一起、总长度又恰好没变,或者改错更多字符,就不能都套用这个结论。
RS 也能用来纠错,但这里我只准备用它检错。发现问题就请用户核对原码,不替他猜一串“可能正确”的兑换码。积分凭证没有必要做这种热心帮忙。
输入容错,要有个限度
既然可以接受 O 作为 0 的别名,那是不是也可以把 B 和 D、M 和 N 互相替换?
这里就不能了。
Crockford 根本不会生成 O、I、L,它们只是输入别名,分别指向唯一的 0 和 1。但 B、D、M、N 都会出现在生成结果里,表示不同的值。如果把它们合并,原本不同的两串码也会被合并,系统反而分不清了。
我准备让输入处理做这些确定的事:统一大小写,接受约定好的别名,忽略分组横线和允许的空白,全角字母数字转成对应的半角字符。前后端使用同一套规则。
其余字符应该明确提示,而不是悄悄删掉。
比如用户粘贴时多带了一个感叹号,系统直接把它删了,再告诉用户兑换失败,他很难知道刚才发生了什么。超长输入也一样,不应该截取前面一段就继续兑换。
容错的目的,是让同一个值的不同写法能被接受。至于用户原本想输入哪个值,系统不能随便替他决定。
我准备怎么选
给 AI 绘图积分兑换用的格式,我准备定成下面这样:
ART2-0123-4567-89AB-CDEF-GHJK-XXAP
这是一条本地验证过的教学示例,没有在业务系统里发行。中间用了顺序字符,方便看结构,实际生成时当然不能这样做。
ART2 是产品和格式版本前缀。中间五组,共 20 个字符,是安全随机生成的正文。最后一组 XXAP 是四个 RS 校验字符。前缀也参与校验,横线只负责分组显示。
每个随机字符提供 5 bit 的随机空间,20 个就是 100 bit。校验段由前面的内容算出来,不增加秘密,也不能把它算进随机空间。
这个长度是我针对积分兑换做的取舍。代价很直白:比一串没有校验的短码更长,所以复制和粘贴入口更要做好。它也不是什么“行业统一标准”,只是用公开的字符集和成熟的检错算法,给自己的业务选一组具体参数。
Crockford 原规范本身还有一个可选的 modulo 37 校验符号,会用到额外字符。这里借用的是它的字符集和输入别名规则,校验部分另用 RS,两者要说清楚,不能只写“用了 Crockford”就当整个格式都有了定义。
实现时,RS 的参数也要固定。这份参考编解码使用 GF(32),本原多项式为 x⁵ + x² + 1,本原元为 2;生成多项式的根取 α¹ 到 α⁴,系数按高次在前排列,得到缩短后的 RS(28,24)。这些细节不需要用户知道,但写编解码的人得使用同一套参数,否则两端会算出不同的校验段。
我和 Codex 已经对这份独立的参考编解码做了验证,包括单字符和双字符替换的穷举、相邻换位、输入别名,以及用另一种计算方式核对校验结果。三、四字符替换做了抽样回归;完整的检错保证来自距离性质,不能把抽样测试说成证明。这些验证只针对编解码,还不代表业务接口已经接好。
校验也有一个必须讲明白的边界。下面两串示例都能通过校验,正好有五个字符不同:
ART2-0123-4567-89AB-CDEF-GHJK-XXAP
ART2-0123-4567-89AB-CDEF-GHJJ-3V37
如果用户最终输入了另一串完整的合法码,校验不知道他本来想输哪一串。不能因为加了校验,就承诺“绝不会误兑别人的码”。它能做的是,在明确的错误范围内保证检出。
校验通过,和能兑换是两回事
到这里,解决的还是输入问题。
校验算法是公开的。攻击者完全可以随便猜一段正文,再算出正确的校验段。所以“通过校验”只能说明格式自洽,不能说明它是系统发行的,更不能说明它还能领取积分。
防猜码,靠的是安全随机数、足够大的随机空间,以及兑换前的限流。
正文不能用时间戳、自增 ID 或普通随机函数凑出来。数据库主键用自增没关系,但交给用户的凭证需要难以预测。Node.js 可以用 crypto.randomInt 均匀选取字符,它也会避免取模偏差。官方文档把这一点说明得很清楚。
码有多长、同时有多少个未兑换码、攻击者能尝试多少次,都会影响猜中风险。校验字符再多,也代替不了这几个条件。
限流尤其不能只写成“失败后计数”。如果请求每次都先查码、先尝试兑换,失败以后才检查次数,那么超过限制的人仍然能继续尝试。真正的拦截要在业务兑换之前执行,再配合账户、可信来源 IP 和失败后的冷却处理。
这类持有者凭证,可以参考 OWASP 对一次性随机令牌的建议:安全生成、长度充足、妥善存储、一次使用,并限制尝试。虽然它讨论的是密码重置,关于随机凭证的这些要求,同样值得兑换码借鉴。
查库之后,不存在、过期、停用、已经被别人使用的码,可以统一提示“兑换码无效或已失效”。没必要让一个反复试码的人,顺便摸清每串码的业务状态。格式和校验错误则可以说清楚,因为这些信息本来就是由输入决定的。
服务端保存规范化完整码的 SHA-256 摘要,查询时算同样的摘要,也能减少数据库直接保存可兑换明文的风险。前提仍然是正文有足够的随机性,短而可枚举的码不会因为哈希一下就安全。
日志也得一起看。数据库里只存摘要,请求日志、错误上报和埋点里却留下完整兑换码,相当于换了个地方存明文。
积分只到账一次
用户终于把码输对了,还剩最后一件事:别把积分加错。
用户连点两次,两个人同时兑换同一码,请求成功了但响应在网络里丢了,客户端超时重试。这些都很普通,不需要等到什么“大并发”才会出现。
兑换码变成已使用、账户增加积分、写入积分流水,应该在同一个事务里完成。任何一步失败都回滚。再用行锁或者带状态条件的原子更新,保证同一个码只能成功消耗一次。
账户余额本身也要保护。两个不同的码同时给一个账户加分,也不能出现后一笔把前一笔覆盖掉的情况。锁住兑换码,并不等于所有余额变更都安全了。

这些问题和之前写过的锁与共享资源是相通的:先看清楚会被同时修改的是什么,再决定把约束放在哪里。
还有幂等。
同一个用户重试已经成功的兑换,应该能拿到原来的兑换结果和当前余额,不能再加一次积分。换成另一个用户,就不能领取。同一兑换对应的积分流水,也应该有数据库唯一约束。
“网络超时”和“兑换失败”不能直接画等号。用户没收到响应,后台事务可能已经提交了。如果一重试就提示“码已使用”,用户很容易以为积分被吞了;如果再发一次积分,那就轮到开发者怀疑人生。
管理员批量生成兑换码,也有同样的重试问题。第一次请求没收到结果,重试时不应该凭空多出第二批。生成请求要有幂等标识,而一次性展示的明文结果如果丢失,如何恢复或作废重发,也得先想好。
新格式接入时,已经发出的旧码还要继续按旧规则识别。版本前缀能帮忙分流,但新码校验失败,不能再退回旧规则试着接受。前端的长度限制和输入处理也得同步改,否则后端支持了新码,输入框先把它截掉了。
回到那个知乎问题
最开始,我只是想知道兑换码里为什么还会有 I 和 L。
现在看,去掉易混字符确实不难。真正需要多想一点的,是去掉之后怎么办:剩下的字符仍然可能看错,用户可能粘贴出错,也可能在到账成功后因为网络问题再试一次。
对我的 AI 绘图模块来说,已有的安全随机数、摘要存储和事务可以继续沿用;接下来要补的是明确的检错规则、前后端一致的输入处理,以及兑换前真正生效的限流,再把并发和重试场景验证一遍。
用户只是想领一点积分,没必要先辨认字体、理解编码,更不应该猜自己刚才到底有没有兑换成功。这些事,还是应该在做功能的时候想清楚。
正在加载讨论...
讨论加载失败。 重新加载