Redis 动态核销码设计与实现:从 6 位码生成到并发安全核销
摘要
订单核销如果直接使用长期不变的订单号、订单 ID 或静态核销码,一旦截图、转发或泄露,就可能被他人重复尝试。动态核销码通过短有效期和服务端状态校验,缩短了凭证暴露后的可利用时间。
本文以团队预约订单为例,实现一套基于 Redis 的 6 位动态核销码方案:
动态码为 6 位数字,首位不能为
0;前端通过订单号获取当前动态码;
动态码默认有效 60 秒;
核销时同时提交订单号和动态码;
Redis 只保存一个订单维度的单向 Key;
MySQL 条件更新负责最终的并发防重;
Redis 异常时关闭核销能力,不降级为按订单 ID 核销。
方案的核心不是“生成一个随机数”,而是明确动态码的身份校验、生命周期、并发边界以及 Redis 和数据库各自承担的职责。
一、需求拆解
本次动态码的业务规则如下:
这里采用的是“首次请求开始计算 60 秒”的滑动生命周期,不要求所有订单都在整分钟边界统一换码。这样不需要定时任务,也不会为没有打开核销页面的订单提前生成大量无用数据。
二、动态码解决什么问题
静态凭证常见的问题包括:
用户提前截图后长期有效;
静态核销码被转发后可以持续尝试;
订单详情接口泄露后,攻击者可能直接调用按 ID 核销接口;
前端虽然隐藏核销按钮,但旧接口仍然可以被重放;
两个核销请求并发到达时,可能重复执行业务动作。
动态码可以缩短泄露凭证的有效窗口,但它并不能单独解决所有问题。完整核销仍然需要:
当前用户只能获取自己的订单动态码;
核销人员必须有对应任务或订单的核销权限;
订单必须处于允许核销的状态;
数据库使用条件更新防止并发重复核销;
核销接口限流,降低 6 位码被暴力尝试的风险。
因此应该把动态码理解为一个短期业务凭证,而不是订单状态和权限校验的替代品。
三、先决定使用单向 Key 还是双向 Key
动态码方案最重要的结构选择,是核销时前端究竟提交什么。
1. 只提交动态码
如果核销端只提交:
{
"verifyCode": "483921"
}后端需要通过动态码反查订单,因此至少需要反向 Key:
team:booking:verify:{tenantId}:code:{verifyCode} -> orderId为了让用户查询当前动态码,通常还需要订单到动态码的正向 Key,最终形成双向结构:
orderId -> verifyCode
verifyCode -> orderId这种模式适合核销人员只手工输入 6 位码的场景,但会增加以下复杂度:
同一有效范围内动态码必须唯一;
需要处理随机码碰撞;
两个方向的 Key 要保持一致;
创建和清理最好使用 Lua 或其他原子方案;
Redis Cluster 下还要考虑两个 Key 的 Slot。
2. 同时提交订单号和动态码
本次核销请求会同时提交:
{
"orderNo": "BK202608010001",
"verifyCode": "483921"
}后端先通过订单号查询订单,再读取该订单对应的 Redis Key 进行比较,因此只需要一个单向 Key:
team:booking:verify:{tenantId}:order:{orderId} -> verifyCode这也是本次最终采用的方案。
3. 两种方案对比
由于 Key 已经按订单隔离,即使两个订单恰好生成同一个 483921,核销时也不会发生歧义。这让 6 位数字只承担“证明当前持有码”的作用,不再承担“全局定位订单”的作用。
四、Redis 数据结构设计
本次使用的 Key 格式为:
team:booking:verify:{tenantId}:order:{orderId}示例:
team:booking:verify:1:order:100 = 483921
TTL = 60 秒各部分含义如下:
虽然前端传入的是业务订单号,Redis Key 仍然使用数据库主键。服务端先根据订单号查询订单,再从订单对象中获得 tenantId 和 id 组装 Key。
这样设计有几个好处:
主键长度较短且稳定;
订单号格式变化不会影响 Redis Key;
租户之间不会串码;
Redis 中只保存动态码,不需要复制其他订单信息。
五、6 位动态码如何正确生成
动态码范围应该是:
100000 ~ 999999总共有 90 万种组合。推荐使用 SecureRandom:
private static final int VERIFY_CODE_BOUND = 900_000;
private static final int VERIFY_CODE_OFFSET = 100_000;
private final SecureRandom secureRandom = new SecureRandom();
private String generateVerifyCode() {
return String.valueOf(
VERIFY_CODE_OFFSET + secureRandom.nextInt(VERIFY_CODE_BOUND));
}计算过程为:
secureRandom.nextInt(900000) -> 0 ~ 899999
加上 100000 -> 100000 ~ 999999不要先生成任意 6 位字符串再检查第一位,因为类似 012345 在字符串意义上虽然有 6 位,转换为数字后只有 5 位。直接限制数值范围更简单可靠。
对于普通页面展示类随机数,ThreadLocalRandom 在性能上也够用;但核销码属于短期安全凭证,使用 SecureRandom 更符合语义。
六、为什么使用 SET NX
用户可能连续点击、页面并发请求,或者同一账号在两个设备上同时打开核销页。如果代码采用普通 GET 后 SET:
请求 A:GET,发现不存在
请求 B:GET,发现不存在
请求 A:生成 483921 并 SET
请求 B:生成 725310 并 SET请求 A 收到的 483921 会立刻被请求 B 覆盖,导致页面刚显示就失效。
因此创建动态码时使用:
Boolean created = valueOperations.setIfAbsent(
key,
candidate,
expireTime);它等价于 Redis 的“Key 不存在才写入,并同时设置过期时间”。并发请求中只有一个能创建成功,其他请求重新读取已经存在的码。
核心流程可以概括为:
String verifyCode = valueOperations.get(key);
if (verifyCode == null) {
String candidate = generateVerifyCode();
if (Boolean.TRUE.equals(
valueOperations.setIfAbsent(key, candidate, expireTime))) {
verifyCode = candidate;
} else {
// 其他请求已抢先创建,重新读取
}
}这里的重试主要处理同一个订单的并发创建竞争,不是处理不同订单之间的数字碰撞。因为本方案按订单号核销,不要求不同订单的动态码唯一。
七、用户获取动态码的安全边界
取码接口不能只根据订单号查询订单,否则知道他人订单号的用户也可能获取动态码。
正确查询条件应同时包含:
订单号;
当前登录用户 ID;
当前租户上下文。
示意代码:
TeamBookingOrderDO order = bookingOrderMapper
.selectByOrderNoAndUserId(orderNo.trim(), loginUserId);
if (order == null) {
throw exception(BOOKING_ORDER_NOT_EXISTS);
}
if (!TeamBookingOrderStatusEnum.PAID.getStatus()
.equals(order.getStatus())) {
throw exception(BOOKING_ORDER_STATUS_NOT_ALLOW_CONSUME);
}
return bookingVerifyCodeService.getOrCreateVerifyCode(order);这里体现了两个重要原则:
订单号不是秘密,不能仅凭订单号授权;
动态码只对已支付、待核销订单开放。
订单处于待支付、退款中、已退款、已取消或已核销状态时,都不应该继续生成动态码。
八、接口设计
1. 用户端获取动态码
GET /app-api/team/booking/booking/verify-code请求参数:
orderNo=BK202608010001响应示例:
{
"code": 0,
"msg": "",
"data": {
"orderNo": "BK202608010001",
"verifyCode": "483921",
"serverTime": 1785556800000,
"expireTime": 1785556860000
}
}返回服务端时间的目的,是避免用户手机时间不准导致倒计时提前或延后。
2. 讲解员核销
POST /app-api/team/guide-task/booking/consume
Content-Type: application/json请求体:
{
"orderNo": "BK202608010001",
"verifyCode": "483921"
}3. 管理端核销
POST /admin-api/team/booking-order/booking/consume
Content-Type: application/json请求结构与讲解员端一致。
4. 参数校验
动态码请求对象使用 Bean Validation:
@NotBlank(message = "核销码不能为空")
@Pattern(
regexp = "[1-9]\\d{5}",
message = "核销码必须是首位非 0 的 6 位数字")
private String verifyCode;订单号也需要限制为空和最大长度,避免异常大参数进入数据库查询。
九、前端倒计时与自动刷新
前端拿到以下两个时间:
{
"serverTime": 1785556800000,
"expireTime": 1785556860000
}初始剩余时间可以直接计算:
const remainingMs = expireTime - serverTime前端本地按秒递减即可。倒计时归零后:
立即隐藏旧码和旧二维码;
显示“正在刷新”;
重新调用取码接口;
使用新码重建二维码和倒计时。
页面从后台切回前台时,应重新请求一次,防止定时器在后台被冻结后继续展示过期码。
普通“刷新”不应该强制换码。有效期内重复调用接口返回同一个码,这可以避免用户刚向讲解员展示动态码时,因为页面刷新导致正在核销的码立即失效。
如果将来确实存在“怀疑泄露、立即作废”的需求,应该设计独立的强制换码接口,并增加二次确认和频率限制,而不是改变普通查询接口的幂等语义。
十、二维码应该包含什么
二维码可以直接包含订单号和动态码:
{
"type": "TEAM_BOOKING_VERIFY",
"orderNo": "BK202608010001",
"verifyCode": "483921"
}讲解员端扫码后:
解析二维码
→ 校验 type
→ 提取 orderNo 和 verifyCode
→ 调用核销接口
→ 成功后刷新任务和订单状态动态码更新后必须同步重建二维码。不要只更新页面上的 6 位数字而继续展示旧二维码。
十一、完整核销流程
数据库订单服务讲解员端Redis用户端数据库订单服务讲解员端Redis用户端alt[Key 不存在]alt[更新成功][更新失败]订单号 + 当前登录用户,请求动态码按订单号和用户 ID 查询订单已支付订单GET 订单动态码 KeySET NX 动态码并设置 60 秒 TTL当前动态码动态码、服务端时间、失效时间展示数字或二维码订单号 + 动态码查询订单并校验讲解员权限读取订单当前动态码当前动态码比较动态码条件更新 paid → consumed返回影响行数删除动态码 Key核销成功订单状态不允许核销这里先校验讲解员权限,再校验动态码。即使攻击者知道订单号,也不能利用核销接口探测不属于自己的任务。
十二、动态码比较
读取 Redis 后不能只判断 Key 存在,还必须比较请求中的码和 Redis 当前值。
实现中可以使用 MessageDigest.isEqual:
boolean matched = MessageDigest.isEqual(
expectedCode.getBytes(StandardCharsets.UTF_8),
verifyCode.getBytes(StandardCharsets.UTF_8));它提供相对稳定的字节比较过程,避免普通字符串比较过早结束。对于只有 6 位的业务码,这不是最主要的防线,但属于成本很低的安全习惯。
真正更重要的防护仍然是:
短 TTL;
订单号和动态码组合;
登录身份和任务权限;
接口限流;
数据库状态条件更新。
十三、为什么数据库仍然是最终防线
两个核销请求可能几乎同时到达:
请求 A:Redis 动态码校验通过
请求 B:Redis 动态码校验通过
请求 A:准备更新数据库
请求 B:准备更新数据库因此不能采用无条件更新:
UPDATE team_booking_order
SET status = 'consumed'
WHERE id = :id;正确做法是把旧状态写入更新条件:
UPDATE team_booking_order
SET status = 'consumed'
WHERE id = :id
AND status = 'paid';对应 Mapper 逻辑:
int updated = bookingOrderMapper.updateStatus(
order.getId(),
TeamBookingOrderStatusEnum.PAID.getStatus(),
TeamBookingOrderStatusEnum.CONSUMED.getStatus());
if (updated == 0) {
throw exception(BOOKING_ORDER_STATUS_NOT_ALLOW_CONSUME);
}数据库单条条件更新具有原子性。并发请求中只有一个能把 paid 更新为 consumed,另一个请求影响行数为 0。
这说明 Redis 动态码解决的是短期凭证问题,数据库条件更新解决的是最终业务状态并发问题,两者不能互相替代。
十四、Redis 与数据库的一致性处理
Redis 不属于数据库本地事务,因此不能假设订单状态和动态码一定同时提交或回滚。本次采用的顺序是:
校验 Redis 动态码
→ 数据库条件更新核销状态
→ 数据库更新成功后删除 Redis Key1. 为什么不能先删除动态码
如果先删除 Redis Key,之后数据库更新失败,用户的有效动态码已经丢失,只能重新获取,甚至可能造成现场核销中断。
2. 数据库成功、Redis删除失败怎么办
清理 Redis 时捕获异常并记录日志,不回滚已经完成的数据库核销:
try {
stringRedisTemplate.delete(key);
} catch (DataAccessException ex) {
log.warn("清理 Redis 动态码失败", ex);
}即使旧 Key 暂时残留,也不会造成重复核销,因为数据库状态已经是 consumed,后续条件更新无法再次成功,而且 Key 最多 60 秒后自动过期。
3. Redis 不可用时怎么办
动态码生成和校验都依赖 Redis。Redis 不可用时应该失败关闭:
返回“核销码服务暂不可用,请稍后重试”不能自动降级为按订单号、订单 ID 直接核销,否则故障会变成绕过动态码安全控制的通道。
十五、接口限流与防爆破
首位非 0 的 6 位码只有 90 万种组合。如果攻击者已经知道订单号,可以对动态码进行尝试,因此核销接口必须限流。
本次按操作人限制:
每分钟最多尝试 10 次示意注解:
@RateLimiter(
time = 1,
timeUnit = TimeUnit.MINUTES,
count = 10,
keyResolver = ExpressionRateLimiterKeyResolver.class,
keyArg = "'team:booking:consume:user:' + loginUserId",
message = "核销尝试过于频繁,请稍后重试")在更高风险场景还可以叠加:
操作人维度限流;
IP 维度限流;
单个订单连续失败次数限制;
异常失败次数监控和告警;
讲解员账号临时冻结策略。
不要在日志、前端埋点或异常信息中打印完整动态码,否则动态码可能通过日志平台再次泄露。
十六、错误码设计
本次与动态码直接相关的业务错误包括:
“动态码不存在”“动态码错误”和“动态码已过期”统一返回同一类错误,可以减少接口向攻击者暴露内部状态。
十七、几个容易踩坑的点
1. 每次查询都生成新码
错误做法:前端每请求一次,服务端都覆盖 Redis 中的动态码。
后果:重复请求、页面抖动或多个设备同时打开时,旧页面上的码会立即失效。
正确做法:有效期内返回同一个码,只有 Key 过期后才生成新码。
2. 只在前端判断是否过期
前端倒计时只是展示逻辑。真正校验必须读取 Redis 当前 Key。客户端提交的 expireTime 不能作为服务端判断依据。
3. 把动态码写入订单表
动态码属于高频变化、短生命周期数据。写入数据库会带来频繁更新、清理任务和历史残留问题。订单表只保留长期业务事实,动态码交给 Redis TTL 管理更合适。
4. 把订单号当成秘密
订单号通常会出现在页面、短信、客服记录和日志中,不能把“知道订单号”视为有权查看动态码。用户取码必须同时验证当前登录用户。
5. Redis 校验通过就直接返回成功
Redis 中的动态码不能代表订单一定仍是待核销状态。订单可能已经取消、退款或被另一个请求核销,必须重新查询并条件更新数据库。
6. 核销成功后强制依赖 Redis 删除
Redis 清理是缓存清理,不是核销成功的最终条件。数据库更新成功后,即使 Redis 删除失败,也应保持核销成功,并依赖状态校验和 TTL 兜底。
7. 保留外部按 ID 核销入口
如果新增了动态码接口,却仍对外暴露原来的 consume?id=100,调用方可以完全绕过动态码。旧的外部核销入口必须替换或收紧为仅内部、高权限补偿用途。
十八、测试策略
动态码测试不能只验证“能生成一个数字”,还要覆盖生命周期、并发和失败路径。
Redis 动态码服务测试
Redis 已有动态码时原样返回;
Redis 无值时生成首位非 0 的 6 位码;
创建时同时写入 TTL;
动态码不匹配时拒绝核销;
Redis 不可用时返回服务不可用错误;
租户 ID 和订单 ID 正确进入 Redis Key。
订单服务测试
当前用户可以获取自己已支付订单的动态码;
非已支付订单不能获取动态码;
订单号前后空格被规范化;
管理端动态码核销成功;
讲解员只能核销分配给自己的任务订单;
数据库状态并发变化时核销失败;
数据库更新成功后清理 Redis Key。
定向测试命令:
mvn -pl yudao-module-team \
-Dtest=TeamBookingVerifyCodeServiceImplTest,TeamBookingOrderServiceImplTest \
test本次实现使用 Java 17 完成编译验证,相关 47 个单元测试全部通过,其中动态码服务 4 个、订单服务 43 个。
十九、上线检查清单
动态码生成范围严格为
100000~999999;使用
SecureRandom生成短期凭证;Redis Key 包含租户 ID 和订单 ID;
Redis 写入使用
SET NX并同时设置 TTL;有效期内重复请求返回同一个动态码;
用户取码同时校验订单号、登录用户和订单状态;
核销请求同时提交订单号和动态码;
讲解员核销前校验任务归属;
动态码格式在接口层和服务层都进行防御性校验;
数据库使用
WHERE status = 'paid'条件更新;数据库成功后再清理 Redis Key;
Redis 清理失败不回滚已经完成的核销;
Redis 不可用时不允许降级绕过动态码;
外部按订单 ID 直接核销入口已经移除;
核销接口已配置操作人维度限流;
前端倒计时结束后隐藏旧码并重新请求;
页面回到前台时重新同步动态码;
动态码不进入持久化缓存、日志和埋点。
二十、后续可以如何演进
1. 增加旧码宽限期
当前方案没有旧码宽限期,Redis Key 到期后旧码立即失效。如果现场网络延迟明显,可以设计“当前码 + 上一个码”的短暂宽限,例如保留旧码 5~10 秒。
但这会扩大凭证有效窗口,也会增加 Redis 结构和校验逻辑,应在真实业务需要下再实现。
2. 改为只输入 6 位码
如果将来产品要求讲解员只输入动态码,不再扫描订单号,就需要增加动态码到订单的反向索引,并保证有效范围内的动态码唯一。
此时应重新设计:
双向 Key;
随机码冲突重试;
Lua 原子写入和删除;
Redis Cluster Slot;
当前码和历史宽限码的清理。
3. 增加核销审计
订单表或独立核销记录表可以补充:
核销时间;
核销操作人 ID;
操作人类型;
核销入口;
客户端 IP;
设备信息;
核销前后的订单状态。
动态码解决“能不能核销”,审计记录解决“谁在什么时间、通过什么入口完成了核销”。
二十一、最终总结
这套 Redis 动态核销码方案可以归纳为以下几条工程经验:
先确定核销请求携带哪些字段,再决定 Redis 使用单向还是双向 Key。
订单号 + 动态码模式不要求动态码全局唯一,一个订单 Key 即可。
动态码使用
SecureRandom生成,并通过 Redis TTL 管理生命周期。使用
SET NX避免并发查询相互覆盖动态码。用户身份、讲解员权限和订单状态仍然必须由服务端校验。
Redis 负责短期凭证,数据库条件更新负责最终并发防重。
数据库成功后再清理 Redis,清理失败由订单状态和 TTL 兜底。
Redis 故障不能成为绕过动态码的降级通道。
动态码功能表面上只是一个 6 位随机数,真正的难点却在凭证生命周期、身份权限、并发请求和跨存储一致性。把这些边界划分清楚,才能让动态码既方便用户现场使用,又不会成为新的订单安全漏洞。
默认评论
Halo系统提供的评论