客服那边最常收到的工单之一是我明明有兑换码为什么提示无效。很多人第一反应是校验逻辑写错了但真去翻日志会发现问题大多出在前面的设计环节字符集里混进了让人看错的字符长度算得太短导致撞码或者生成时的随机源根本不随机。这篇就聊聊自建系统里那套激活码、兑换码生成工具类该怎么写以及校验这一层到底要做几道关卡。内容偏向工程实现适合正在做会员兑换、课程兑换、活动码、授权码这类功能的开发同学也适合产品同学看看为了让码好念、好抄、好校验背后有多少细节要抠。我下面给的方案和代码都是围绕 Java 技术栈展开的思路换成 Python、Go、Node 一样成立核心参数和判定逻辑是通用的。1. 兑换码长什么样的第一步把字符集和长度这两个参数定死几乎所有人写兑换码生成第一版都是这几行随机若干个字符拼起来存库。跑得通但上线三个月后开始出问题。原因在于字符集和长度是最底层的两个参数一旦发出去几万条码再想改就等于所有历史码全部作废。所以这两件事必须在写第一行代码之前就定死。1.1 字符集把 0O1Il 这些看起来一样的字符剔出去先说一个很多人都忽略的事实兑换码的主要消费者是人不是机器。用户会从微信里复制、会从纸质卡片上抄、会打电话报给客服、会截图发群里。只要字符集里有容易看混的字符就一定会有用户抄错。最容易出问题的是这几组数字0和字母O数字1和小写l、大写I数字2和字母Z数字5和字母S数字8和字母B。这五组如果同时出现在字符集里用户抄错的概率高得离谱而且抄错之后校验位大概率能拦住用户只会收到一句码无效然后来投诉。我的做法是把字符集收敛成下面这 32 个23456789ABCDEFGHJKLMNPQRSTUVWXYZ去掉了0、1、I、O、L顺手把2、5、8这些数字保留下来因为数字更容易念字母全部用大写。这样剩下来的是 8 个数字加 24 个字母一共 32 个。32 这个数不是随便挑的它有两个好处。第一是它正好是 2 的 5 次方随机取一个字符只需要 5 个 bit后面做位运算打包、算映射索引都很自然。第二是它的数量刚好够大但不冗余每个字符的权重是一样的不会因为某些字符被剔除导致分布不均——这一点很关键很多人剔完字符忘了调整随机范围结果Z出现的概率是A的两倍这种偏差在小样本里看不出来在百万级码量里就会被人拿去分析。1.2 长度不是拍脑袋用组合数算清楚撞码概率字符集定了接下来是长度。很多人凭感觉写 8 位理由是短一点用户记得住。但 8 位到底能撑多少码得先算清楚。字符集是 32那么 n 位有效载荷的空间就是 32 的 n 次方换成 2 的幂就是 2 的 5n 次方。批量生成 n 条码如果完全不做去重、纯随机出现重复的期望对数大约是n² / (2N)其中 N 是码空间大小。按这个公式算一批 1000 万条的码有效载荷长度码空间大小1000 万条时的期望碰撞对数结论8 位2^40 ≈ 1.10 × 10^12约 45.5 对必然大量重复不可接受10 位2^50 ≈ 1.13 × 10^15约 0.044 对基本安全仍需兜底12 位2^60 ≈ 1.15 × 10^18约 4.3 × 10^-5 对极安全16 位2^80 ≈ 1.21 × 10^24可忽略长度冗余用户难抄这张表说明一件事8 位看着才差了 2 位实际碰撞概率差了三个数量级。如果你打算发千万级的码10 位是及格线12 位是舒服线。具体到 10 位按我们的分组规则会切成XXXX-XXXX-XX的样式一共 12 个字符含两个短横线。用户抄写时是 10 个有效字符念出来大概三口气我觉得是长度和体验的一个不错的平衡点。如果是发量在十万以内的小活动8 位载荷加 1 位校验位其实够用但必须配合数据库唯一索引兜底不能赌概率。1.3 分组显示中间加短横线是为了让人念得出来分组这件事看起来是纯粹的样式问题其实有实际价值。K7M2P9QXBT这样一串连着的字符用户在手机上放大看很容易串行切成K7M2-P9QX-BT之后视觉上有了断点抄错率明显下降。我们内部做过一次小范围对比加了分组之后码无效的客服工单量下降了大约三成。分组的间隔建议取 4因为人一次能记住的短串长度大概就是 3 到 5 个字符。而且 4 的倍数和 32 字符集配合得很好不会出现最后一个分组只有 1 个字符的别扭情况。但这里有个必须注意的点短横线只存在于展示层和用户输入层绝不要存进数据库。库存的是 10 位原始码用户输入什么格式进来都先做归一化去短横线、去空格、转大写再查库。如果一开始就把短横线存进库后面所有查询都要带格式转换索引也用不好纯给自己找麻烦。2. 生成工具类的实现随机源、批量去重和落库兜底参数的账算完了接下来是代码。生成这件事看起来简单但真正上线过的都知道坑主要集中在两个地方随机源选错以及批量生成时的自身撞码没处理好。2.1 Random 和 SecureRandom 的差别不在性能在可预测性java.util.Random用的是线性同余算法给定种子之后整个序列就完全确定了。如果代码里写了new Random(12345)这种固定种子或者在高并发下用System.currentTimeMillis()当种子攻击者拿到几条码之后是有可能反推出种子的进而预测出后面所有还没发出去的码。这在优惠券、兑换码场景是实打实的资损风险。所以生成环节一律用java.security.SecureRandom。它的开销确实比Random高但生成兑换码是低频操作一次批量任务跑几万条也就毫秒级到秒级的差别完全没有优化的必要。顺便提醒一个常见误区SecureRandom在 Linux 上默认可能走/dev/random早期某些环境下会因为熵池不足阻塞。现在绝大多数发行版和 JDK 版本都会自动降级到非阻塞的实现如果你在容器里跑并且观察到生成任务偶发卡顿可以显式指定SecureRandom.getInstanceStrong()之外的默认算法或者直接用new SecureRandom()让它自己挑。我们线上跑下来没有遇到过阻塞问题但这段值得在压测时留意一下。2.2 一批 10 万条生成时怎么避免自己和自己撞前面算过10 位载荷在千万级数据下期望碰撞只有 0.044 对但期望值低不等于不会发生。如果你一个批次生成 10 万条这一批内部要不要去重要。最朴素的做法是拿一个HashSetString存本批已经生成过的码每生成一条就查一下重复就重新生成。10 万条码占用的内存大概几 MB完全可以接受。也可以用更省内存的ConcurrentHashMap.newKeySet()支持并发生成。但内存去重只解决本批内部的问题解决不了和库里已有数据撞的问题。库那一层的兜底只能靠唯一索引这一点下一节展开。如果你要做的是分批生成比如一次 1000 条跑 100 批那么批次之间也要去重最稳妥的方式是每批落库之后从库里读回来校验或者干脆把整批的唯一性交给数据库的唯一索引去判遇到冲突就补生成。2.3 唯一索引是最后一道防线也是最有用的一道不管生成端做了多少层去重数据库表上的code字段必须建唯一索引。理由很简单生成端可能有多个实例同时跑可能有重试逻辑重复执行可能有人手动补数据任何一层出问题都会导致重复。唯一索引是唯一一个不受应用层 bug 影响的保证。配合唯一索引批量插入时要注意两点。第一是插入语句要能捕获重复键冲突MySQL 可以用INSERT IGNORE或者INSERT ... ON DUPLICATE KEY UPDATEPostgreSQL 用ON CONFLICT DO NOTHING。第二是要能识别出这次插入了多少条如果插进去的数量小于预期说明有冲突需要按差额补生成然后循环重试直到插满或者超过重试上限一般设 3 次就够了超过说明有别的 bug。还有一种情况必须考虑如果你用 sharding 分了库或分了表唯一索引只能保证单个分片内唯一。这时候要么把码的生成和路由绑定起来比如按前缀决定分片要么在应用层加一层全局的码注册表。我见过不止一个团队踩这个坑分表之后出现重复码排查了半天才发现是唯一索引只在单表生效。2.4 一份可以直接抄的生成器骨架Java下面这份代码是我在实际项目里用的版本去掉业务定制之后剩下的骨架。它包含了字符集定义、校验位计算、格式化、归一化和静态校验直接拿去改成你要的即可。import java.security.SecureRandom; public final class RedeemCodeGenerator { /** 剔除 0 O 1 I L 之后的 32 个字符正好可以用 5 个 bit 表示 */ private static final char[] CHARSET 23456789ABCDEFGHJKLMNPQRSTUVWXYZ.toCharArray(); private static final int BASE CHARSET.length; private static final int[] WEIGHTS {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13}; private static final SecureRandom RANDOM new SecureRandom(); private RedeemCodeGenerator() { } /** 生成一条码payloadLen 位有效载荷 1 位校验位 */ public static String generate(int payloadLen) { char[] payload new char[payloadLen]; for (int i 0; i payloadLen; i) { payload[i] CHARSET[RANDOM.nextInt(BASE)]; } char check checkChar(payload, 0, payloadLen); return format(new String(payload) check); } /** 计算校验位加权和取模 */ static char checkChar(char[] payload, int from, int len) { int sum 0; for (int i 0; i len; i) { int idx indexOf(payload[from i]); if (idx 0) { throw new IllegalArgumentException(非法字符: payload[from i]); } sum idx * WEIGHTS[i % WEIGHTS.length]; } return CHARSET[sum % BASE]; } private static int indexOf(char c) { for (int i 0; i BASE; i) { if (CHARSET[i] c) { return i; } } return -1; } /** 每 4 位插一个短横线只用于展示不入库 */ static String format(String raw) { StringBuilder sb new StringBuilder(raw.length() raw.length() / 4); for (int i 0; i raw.length(); i) { if (i 0 i % 4 0) { sb.append(-); } sb.append(raw.charAt(i)); } return sb.toString(); } /** 静态校验不碰数据库纯算法判定 */ public static boolean isWellFormed(String input, int payloadLen) { if (input null) { return false; } String raw normalize(input); if (raw.length() ! payloadLen 1) { return false; } char[] arr raw.toCharArray(); return checkChar(arr, 0, payloadLen) arr[payloadLen]; } /** 归一化去短横线、去空白、全角转半角、统一转大写 */ public static String normalize(String input) { StringBuilder sb new StringBuilder(input.length()); for (char c : input.toCharArray()) { if (c - || c || Character.isWhitespace(c)) { continue; } if (c c ) { c (char) (c - 0); } if (c c ) { c (char) (c - a); } if (c c ) { c (char) (c - A); } sb.append(Character.toUpperCase(c)); } return sb.toString(); } }注意normalize里对全角短横线UFF0D和全角数字字母的处理。中文输入法下用户很容易打出全角字符不处理的话就会出现明明输对了却说无效的诡异现象这个坑我在第 5 章会展开讲。3. 校验分层把无效请求尽量挡在数据库之外校验这件事最容易被写成一坨拿到码直接查数据库查到了就通过。这样写的直接后果是数据库 QPS 被恶意请求打满。正确的做法是分层每一层用尽可能小的成本过滤掉尽可能多的无效请求。3.1 第一层格式、字符集和长度最便宜的拦截第一层完全不碰任何外部资源纯内存计算。顺序是判空、去空白、去全角、转大写得到归一化后的字符串判断长度是否等于载荷长度 1校验位不等直接拒逐个字符查是否在字符集内有一个不在就直接拒算校验位和最后一位比对不等直接拒这四步走完能拦掉绝大部分的乱输、抄错、机器扫描请求。成本是几次字符遍历和一次模运算单机每秒能处理几十万次完全不用担心性能。注意这一层必须放在最前面而且不能因为反正后面还要查库就偷懒省略。校验位的拦截率能做到很高单字符错误、相邻字符交换这类常见错误基本都能识别出来。3.2 第二层校验位是怎么算出来的附手算过程很多人对校验位的理解停留在加一位数字防止输错但具体怎么算、为什么这么算说不清楚。我拿上面代码里那套加权和方案手算一遍给你看。假设生成的载荷是K7M2P9QX8 位字符集索引如下20 31 42 53 64 75 86 97 A8 B9 C10 D11 E12 F13 G14 H15 J16 K17 L18 M19 N20 P21 Q22 R23 S24 T25 U26 V27 W28 X29 Y30 Z31权重按位置从 1 开始递增。逐个字符算乘积位置字符索引值权重乘积1K171172752103M19357420405P2151056976427Q2271548X298232求和得 617。617 除以 32商 19 余 919 × 32 608617 − 608 9。索引 9 对应字符B所以校验位就是B完整的码是K7M2-P9QX-B。为什么用加权和而不是简单求和因为简单求和有一个致命缺陷任意两个相邻字符交换位置和不变校验位也相同这类错误完全检测不出来。加上位置相关的权重之后交换位置会导致结果变化错误就能被抓到。另外权重和字符集大小 32 互质也很重要——如果权重是 32 的约数比如 2、4、8、16那么乘法结果在模 32 之后会丢失大量信息检测能力会明显下降。我选的 1 到 13 这些权重里奇数占了大多数整体检测效果不错。还有一点值得注意这套方案能检测出所有单字符错误因为不同字符的索引值不同改变任一位都会改变加权和能检测出绝大部分相邻交换错误但检测不出某些特定的非相邻交换组合。如果你的场景对检错率要求更高可以把权重设计成符合 ISO 7064 系列的方案比如 MOD 11,10代价是计算稍复杂、输出位数可能多一位。一般业务场景上面这套足够了。3.3 校验位、CRC32、SHA-256 三者的定位差异这三个词经常被混在一起说但它们的用途完全不同选错了会出大问题。校验位是给人用的目的是防止输入错误。它的特点是把任意长度的数据压成一两位检错能力强但不是密码学安全的攻击者知道算法之后可以自己伪造出通过校验的码。它只适合放在校验的第一层做快速过滤绝对不能作为这个码是合法的的依据。CRC32也是检错用的常用于文件完整性校验和网络传输校验。它比自定义校验位强能检测出所有单比特错误和大部分多比特错误但它同样是可逆可伪造的。你在网上看到的那种文件 CRC32 值是用来确认下载过程有没有出错的不是用来防篡改的。它的优势是极快——现代 CPU 有专门的指令每秒能算几个 GB。SHA-256是密码学哈希用于防篡改和安全验证。它不可逆、雪崩效应强改动一个比特输出完全不同。如果你要把码存进数据库而且不希望数据库泄露之后有人能反推出原始码那就该存 SHA-256 值而不是明文。所以它们在本项目里的分工是这样的用户输入的码先过自定义校验位第一层快防手误通过之后拿归一化的码去数据库查第二层用唯一索引数据库里如果存的是哈希值就先用同样的算法算哈希再查。CRC32 在这个场景基本用不上除非你要做码文件的导出校验。手段目的是否可伪造典型开销本项目中的角色自定义校验位防输入错误可极低第一层格式校验CRC32传输/存储完整性可极低基本不用SHA-256防篡改、防泄露反推不可中码的哈希存储HMAC防伪造的离线验证不可中离线激活码方案3.4 第三层状态、有效期、绑定关系才是业务真相过了前两层说明这个码格式上是我们发的。但它的业务状态如何还得继续查。这一层的判定项包括状态未使用、已使用、已作废、已冻结时间窗口有没有到生效时间有没有过失效时间适用范围这个码是给哪个活动、哪个商品、哪个会员等级用的当前请求的场景对不对得上绑定关系是通用码还是限定了用户限定了用户的话当前登录用户是不是那个人使用次数有些码设计成可以多次使用比如通用折扣码有些只能用一次这一层必须全部在服务端完成。前端的任何校验都只是给用户省一次请求、改善体验用的攻击者完全可以跳过前端直接调接口。常见的错误是前端做了一堆校验之后后端就默认输入是干净的删掉了一部分边界判断这类问题在安全审计里非常容易被挑出来。还有一个容易忽略的点判定顺序会影响用户体验。比如一个码既是过期的又是不属于当前用户的先报哪个错建议先报码不存在或已失效这类模糊提示避免通过不同的错误信息泄漏这个码是存在的这一事实。这在防爆破场景里很重要第 4 章会详细说。4. 用户点下兑换那一刻并发、幂等和防爆破生成和校验都做对了真正的难点在核销那一瞬间。这是典型的读-判断-写场景也是并发问题最集中的地方。这一章讲清楚三件事怎么防止超发、怎么保证幂等、怎么防住批量试码。4.1 读改写三步走超发就是这么来的最容易写出的代码是这样的// 反例先查状态再更新中间有并发窗口 RedeemCode code mapper.selectByCode(codeStr); if (code.getStatus() 0) { code.setStatus(1); code.setUsedBy(userId); mapper.updateById(code); // 发奖励 }问题出在两个用户同时提交同一个码时两人都查到status 0都判断通过都执行了更新都发了奖励。结果一份奖励发了两次。这就是超发。修法有三种我按推荐程度排。第一种是原子更新一条 SQL 解决UPDATE redeem_code SET status 1, used_by #{userId}, used_at NOW() WHERE code #{code} AND status 0;然后判断影响行数等于 1 说明抢到了等于 0 说明被别人抢先了或者码状态不对。这种方式最轻量不需要额外锁也不依赖事务隔离级别我线上大部分场景用的都是它。唯一需要注意的就是必须带上AND status 0这个条件不然就退化成覆盖更新了。第二种是悲观锁先SELECT ... FOR UPDATE锁住这一行再判断、再更新。好处是逻辑清晰、可以在锁内做复杂的判断和计算坏处是持锁时间变长高并发下容易排队而且必须在一个事务里容易因为事务边界没设好导致锁住了但没提交。第三种是乐观锁表上加一个version字段更新时带上WHERE version #{oldVersion}影响行数为 0 就重试。适合冲突概率低的场景冲突高的时候重试会把压力放大。方案适用场景优点风险原子更新状态单一、判定简单轻量、无锁等待复杂判定不好塞进 SQL悲观锁判定逻辑复杂、涉及多表逻辑直观持锁时间长、易死锁乐观锁冲突概率低无锁、吞吐高高冲突时重试放大4.2 三种方案怎么选看你的判定复杂度选型其实就一个问题核销的时候需要读几张表、做多少计算如果只是把状态从 0 改成 1原子更新毫无疑问是最优解一行 SQL 搞定一切并发问题代码还短。如果核销的同时要扣库存、要算折扣、要写积分流水涉及多个资源那就得上事务加悲观锁把这一整段包进去保证要么都成要么都不成。如果用乐观锁处理多资源场景你得给每个资源都加版本号重试逻辑会复杂到没人愿意维护。有个折中的做法我比较喜欢用原子更新先抢码抢到之后再在同一个事务里做后续的资源操作。这样并发控制只集中在最关键的抢占这一步后面的逻辑就不用考虑多个线程同时进来的问题了。相当于用一个轻量的 CAS 把并发的入口收窄成单点。还有一点要提醒整个核销过程必须在一个事务里。抢到码之后、发奖励之前如果抛异常事务要能回滚把码的状态退回去。我见过有代码是先更新状态再调外部接口发奖励接口超时了状态却已经改了用户没拿到奖励但码作废了这种只能靠人工补。4.3 幂等键同一用户连点两次不能扣两次并发解决的是两个线程同时进来幂等解决的是同一个请求被处理了两次。后者的来源更多用户网络卡顿点了两下、网关重试、消息队列投递重复、前端按钮没置灰。做法是在核销记录表上加一个唯一索引比如(code, user_id)或者一个独立的request_id。插入记录的时候如果冲突了说明这次请求之前已经处理过直接返回上次的结果就行不要再执行一遍业务逻辑。用 Redis 做幂等也可以SET key value NX EX 60设置成功说明是第一次设置失败说明是重复请求。好处是快、不占数据库坏处是 Redis 和数据库的一致性没法用事务保证如果 Redis 写成功了但业务处理失败了那个键在 60 秒内会一直挡着用户真实的重试也会被拦掉。所以我更倾向于用数据库唯一索引做主幂等Redis 只作为前置的快速挡板。值得单独说一句的是幂等键的取值范围。如果你用(code, user_id)做幂等键那同一个用户对同一个码只能核销一次这是符合大多数业务预期的。但如果是可以多次使用的通用码就不能用这个组合得用请求级别的request_id。4.4 限流和防爆破把试码的成本抬高前面反复强调错误信息要模糊但光靠模糊还不够还得限制尝试频率。防爆破的思路分三层。第一层是码空间本身10 位载荷有 1.13 × 10^15 种可能随机猜中的概率低到可以忽略这本身就是最有效的防护。但如果你的码是 6 位纯数字空间只有 100 万那就很容易被扫所以长度和字符集的选择直接决定了防爆破的底线。第二层是失败计数。按用户和按 IP 分别计数比如同一用户 10 分钟内失败超过 10 次就加验证码超过 30 次就临时封禁。计数放 Redis带过期时间自动清理。第三层是整体限流。对核销接口做接口级限流比如单 IP 每秒最多 5 次超出直接拒绝。这一层不是针对某个用户而是防止有人用分布式的方式绕过用户级别的限制。// 失败计数的简化实现 String key redeem:fail: userId; Long fails redis.opsForValue().increment(key); if (fails ! null fails 1L) { redis.expire(key, Duration.ofMinutes(10)); } if (fails ! null fails 10) { // 触发图形验证码或直接拒绝 throw new BizException(操作过于频繁请稍后再试); }这里有个细节值得注意成功的请求要不要重置失败计数。我的做法是重置因为正常用户偶尔输错一两次然后输对了不应该继续累积。攻击者拿不到成功的码计数自然会一直涨。5. 上线后才会暴露的坑几条真实排查链路代码写完、测试通过不代表没问题。下面这几个坑都是上线之后才暴露出来的我把排查过程完整写出来方便你在遇到类似现象时对照。5.1 生成时明明唯一线上却出现重复码现象是运营反馈有两个用户用了同一个码两个人都兑换成功了。第一反应是并发问题但查日志发现两次核销相隔了三个小时完全不重叠。排查思路是这样走的。先确认码本身是不是重复的按码去查表发现确实有两条记录码字符串完全相同但 ID 和创建时间不同。说明生成环节出了问题不是核销环节。接着看两条记录的创建时间相差 12 毫秒而且batch_id不同。说明是两个不同的批次。再往上查发现这个项目用了分库分表码表按batch_id哈希分了 8 个库唯一索引只在单库生效——两个批次恰好落到了不同的库所以唯一索引没能拦住。修复方案是把码的生成和路由绑定改成按码本身的哈希值决定分片这样相同码一定落同一个分片唯一索引就能生效了。代价是核销时需要按码算分片路由不能简单地按用户路由。这个坑的教训是任何分片方案都要重新审视唯一约束还成不成立。不只是码订单号、手机号、身份证号都有同样的问题。5.2 导出 Excel 之后码变了现象是运营从后台导出了码的 CSV发给了渠道方渠道方反馈好多码都是无效的。拿到问题文件一看就明白了Excel 把纯数字的码识别成了数值长数字自动转成了科学计数法比如23456789变成了2.34568E07。还有一部分码丢了前导零。我们的字符集虽然包含字母但纯数字的码是可能出现的9 位全是数字的概率大概是千万分之四看起来很低但发 500 万条就有两条一条一出问题渠道方就会认为整个文件不可信。修法有三个层面。第一导出时把码列强制设成文本格式CSV 里给纯数字的码加一个制表符前缀或者用双引号包起来。第二建议渠道方用程序读取而不是用 Excel 打开或者导入时指定列类型为文本。第三如果业务允许可以在码的前面固定加一个字母前缀这样永远不会被识别成数值同时也起到了区分不同产品线的作用。这个方案我在一个项目里用过前缀不参与校验位计算纯粹是标识效果很好。提示只要是导给外部系统的文件都要假设对方会用 Excel 打开。涉及长数字、前导零、日期格式的列全部按文本处理。5.3 明明有码却提示无效空格、全角、大小写这个是最难排查的一类因为用户坚持说我复制粘贴的不可能输错。排查方式是先把用户输入的原始字节打出来。很多情况下会看到这样的东西用户从一封邮件里复制的码前面带了一个零宽空格U200B或者末尾带了一个不换行空格U00A0。这两个字符trim()都去不掉Character.isWhitespace()对零宽空格也返回 false所以归一化没生效码就多了一个字符长度判断直接失败。全角字符是另一个高频问题。中文输入法下输入的字母和数字默认是全角的和ABC123在字节层面完全不同。如果没做全角转半角用户自己眼睛看着是对的程序判定是错的。解决办法是在normalize里做彻底的清洗我一般会做这几件事去掉所有Character.isWhitespace为 true 的字符显式去掉零宽空格 U200B、零宽非连接符 U200C、零宽连接符 U200D、字节序标记 UFEFF全角数字UFF10 到 UFF19转半角全角字母UFF21 到 UFF3A、UFF41 到 UFF5A转半角全角短横线 UFF0D 和连接号 U2010 到 U2015 统一去掉最后统一toUpperCase()因为 JDK 自带的toUpperCase会正确处理土耳其语等特殊 locale 的问题前提是用Locale.ROOT做完这几步之后用户说输对了但判定失败的工单基本绝迹。同时建议在用户输入框上做一层实时提示把清洗后的结果显示出来让用户自己也能看到程序识别成了什么。5.4 有效期比较的边界和时区有效期看起来是最简单的一段逻辑实际上踩坑的人不少。第一个坑是时区。如果数据库存的是datetime且没有时区信息应用服务器的时区又和数据库不一样跨时区部署之后就会出问题。我的做法是全部用 UTC 存时间戳展示的时候再按用户时区格式化比较逻辑一律用 UTC 时间戳避免任何时区转换。第二个坑是边界。endTime是闭区间还是开区间如果用户在endTime那一秒点击兑换算有效还是无效我的建议是把endTime存成最后一刻的下一纳秒也就是用左闭右开区间[startTime, endTime)这样比较逻辑就是简单的now start now end不用考虑闭区间要加一秒钟这种别扭的写法。第三个坑是时间来源不统一。如果判断用的是应用服务器的System.currentTimeMillis()多实例部署时如果机器时钟有漂移就可能出现某些请求认为没过期、某些认为过期了。解决办法是把时间判断下推到 SQL 里用数据库的NOW()保证所有判断基于同一个时钟源。UPDATE redeem_code SET status 1, used_by #{userId}, used_at NOW() WHERE code #{code} AND status 0 AND (start_time IS NULL OR start_time NOW()) AND (end_time IS NULL OR end_time NOW());把有效期判断直接塞进这条原子更新里既解决了时区问题也解决了并发问题一举两得。6. 纯离线可验证的激活码把校验信息编进码里前面讲的都是有中心库的方案生成的时候把码写进数据库校验的时候查库。这个方案简单可靠但有一个前提——校验方必须能访问到那个数据库。如果你的产品是部署在客户内网、客户又不同意联网回传的那这套就走不通了。这时候需要离线可验证的方案。6.1 有中心库和无中心库怎么选两种方案的取舍其实很清楚我列个表对比一下。维度中心库方案离线方案校验依赖必须联网查库本地计算即可能否提前吊销能改状态即可不能只能靠黑名单补丁码的长度可以很短通常较长要带签名泄露风险库泄露风险高建议存哈希密钥泄露等于全线失守适合场景在线兑换、优惠券内网部署产品、离线授权大部分在线业务选中心库方案就行不要为了看起来高级上离线签名。只有确实有离线校验需求的时候才考虑下面这套。6.2 HMAC 签名码的组成与验证流程离线方案的核心思路是把关键信息直接编进码里再用一个只有发行方知道的密钥算签名附在后面。校验方拿到码之后重新算一遍签名对得上就认为是发行方发的。一个典型的结构是这样前面是载荷产品编号、版本号、有效期、序列号后面是截断的 HMAC。载荷用位域压缩然后用 Base32 编码成可读字符串签名用 HMAC-SHA256 计算载荷之后截取前 10 个字节同样 Base32 编码拼在载荷后面。验证流程分四步解码把字符串还原成字节按约定的长度切出载荷和签名用同样的密钥和算法重新计算载荷的 HMAC取前 10 个字节用恒定时间的方式比较两个签名第四步的恒定时间比较是必须的绝对不能用Arrays.equals。普通比较在遇到第一个不同字节时就返回攻击者可以通过测量响应时间逐字节猜出正确签名这叫时序攻击。Java 里用MessageDigest.isEqual它是恒定时间实现的。byte[] expected hmacSha256(secret, payload); byte[] actual Arrays.copyOfRange(decoded, payload.length, decoded.length); if (!MessageDigest.isEqual(expected, actual)) { return VerifyResult.INVALID_SIGNATURE; }载荷里的有效期字段也值得说一下。为了减小体积通常不会编入完整的年月日而是用一个相对天数比如从某个基准日起算的天数用两到三个字节表示够用一百多年。序列号则用一个自增的整数用三到四个字节。产品编号用一两个字节这样整个载荷控制在 12 到 16 个字节Base32 之后加上签名大概 35 到 40 个字符虽然比不上在线方案的 12 位但也在可接受范围内。6.3 离线方案的代价吊销和回收变得很难离线方案最难受的地方就是发出去的码收不回来。在线方案里发现一个码被滥用了改一下数据库状态立刻失效离线方案里码已经发到客户手里了你唯一能做的是在下一版本软件里内置一个黑名单把泄露的序列号加进去。但用户只要不升级这个黑名单就形同虚设。所以离线方案在设计的时候就要考虑几个补救措施。一是给码加上版本号新版本软件只接受某个版本号以上的码这样可以做到在某个时间点之后彻底废弃一批旧码。二是把码的有效期设短一点宁可定期重新授权也不发永久的。三是密钥分级不同产品线用不同密钥一条产品线泄露不至于牵连全部。还有一点是密钥的存放。离线方案里密钥就相当于全部资产绝对不能硬编码在客户端。如果校验逻辑在客户端做攻击者逆向之后就能拿到密钥然后自己签发任意码。这类方案的密钥应该放在服务端的签发系统里客户端只做验证不持有签发密钥——这种设计叫非对称签名用私钥签发、公钥验证公钥泄露了也没关系。代价是签名长度变长RSA 2048 位签名 Base32 之后有 65 个字符码会很长所以实际项目里通常折中用 ECDSA 的 P-256 曲线签名 64 字节压缩到 40 多个字符还是能接受的。我个人在离线方案上的经验是能不做就不做如果一定要做优先考虑把校验拆成本地校验 首次联网激活。也就是码本身包含签名可以在本地验证但第一次使用的时候要求联网登记一次后续才能离线用。这样既能满足内网的日常使用又能在激活这一步做吊销和统计是个比较实用的折中。最后分享一个小技巧。不管是哪种方案都建议把码的校验和码的核销拆成两个独立的方法校验只读不改核销才写。这样在很多场景下可以先用校验方法做预览——比如用户输完码之后实时显示这是一个 30 天会员兑换码确认兑换吗提升体验的同时也不会因为一次误触就把码用掉了。我在实际项目里加上这个预览之后因为误操作导致的码作废工单基本没有了。