实战篇:基于 ET 框架实现 CDKEY 礼包
礼包码系统是游戏运营侧最高频的需求之一,看似简单,实际上暗坑不少。本文从为什么做、怎么设计、怎么防护三个维度展开,最后用 ET 框架落地一套伪代码实现。
一、为什么需要 CDKEY 礼包
CDKEY(兑换码)礼包是运营与玩家之间最轻量的物质连接。它不依赖支付流程,不需要玩家填写任何个人信息,只需要一串字符就能完成奖励的"从运营侧到玩家背包"这条链路。
常见的使用场景:
| 场景 | 说明 |
|---|---|
| 新服开服活动 | 生成一批通用码,活动页公开发放,限量 10000 次 |
| 媒体合作 | 给每家媒体生成独立批次,通过兑换数据追踪来源 |
| 客服补偿 | 生成单次码,点对点补偿出问题的玩家 |
| 直播活动 | 主播在直播间公布码,限时兑换,制造紧迫感 |
| 付费礼包 | 购买实体商品赠送的游戏内容(一码一用) |
二、CDKEY 的分类
在动手写代码之前,先把类型分清楚,因为不同类型在存储结构和校验逻辑上差异很大。
CDKEY ├── 通用码(Universal Code) │ 同一个码,多个玩家可用,有总次数限制 │ 例:"GAME2026",全服限兑 5000 次 │ └── 专属码(Unique Code) 一码一人,用完即废 例:"A3X9-K7QP-2M8N",只能被一个玩家兑换两种类型可以共存于同一套系统,通过code_type字段区分。
三、核心设计:数据库表结构
-- 礼包批次表(运营后台配置)CREATETABLEcdkey_batch(batch_idINTNOTNULLAUTO_INCREMENT,batch_nameVARCHAR(64)NOTNULLCOMMENT'批次名称,如"4月媒体合作-游侠"',code_typeTINYINTNOTNULLCOMMENT'0=通用码 1=专属码',rewards JSONNOTNULLCOMMENT'奖励列表,如 [{"item_id":1001,"count":10}]',start_atBIGINTNOTNULLCOMMENT'生效时间戳(毫秒),防提前兑换',expire_atBIGINTNOTNULLCOMMENT'过期时间戳(毫秒)',total_countINTUNSIGNEDNOTNULLCOMMENT'批次总生成码数(统计用)',statusTINYINTNOTNULLDEFAULT1COMMENT'1=有效 0=已关闭',operatorVARCHAR(32)NOTNULLCOMMENT'操作人(运营账号)',remarkVARCHAR(256)NOTNULLDEFAULT''COMMENT'备注',created_atBIGINTNOTNULLCOMMENT'创建时间戳(毫秒)',PRIMARYKEY(batch_id))ENGINE=InnoDB;-- 礼包码表CREATETABLEcdkey_code(codeVARCHAR(32)NOTNULLCOMMENT'兑换码,唯一',batch_idINTNOTNULL,code_typeTINYINTNOTNULLCOMMENT'冗余,方便查询',max_use_countINTUNSIGNEDNOTNULLDEFAULT1COMMENT'生成时固化,通用码>1,专属码=1',used_countINTUNSIGNEDNOTNULLDEFAULT0COMMENT'已兑换次数',statusTINYINTNOTNULLDEFAULT0COMMENT'0=未用 1=已用(专属码)',channelVARCHAR(32)NOTNULLDEFAULT''COMMENT'渠道标识,如"bilibili"',expire_atBIGINTNOTNULLDEFAULT0COMMENT'单码独立过期时间(0=跟随批次)',create_timeBIGINTNOTNULLCOMMENT'生成时间戳(毫秒)',PRIMARYKEY(code),INDEXidx_batch(batch_id))ENGINE=InnoDB;-- 玩家兑换记录表CREATETABLEcdkey_record(idBIGINTNOTNULLAUTO_INCREMENT,player_idBIGINTNOTNULL,codeVARCHAR(32)NOTNULL,batch_idINTNOTNULL,server_idINTNOTNULLCOMMENT'兑换时所在区服(合服兼容)',reward_statusTINYINTNOTNULLDEFAULT0COMMENT'0=待发 1=已发 2=失败',ipVARCHAR(64)NOTNULLDEFAULT''COMMENT'客户端IP(审计用)',redeemed_atBIGINTNOTNULLCOMMENT'兑换时间戳(毫秒)',PRIMARYKEY(id),-- 核心:同一玩家在同一批次只能兑换一次(无论用哪个码)UNIQUEKEYuk_player_batch(player_id,batch_id),INDEXidx_player_id(player_id),INDEXidx_redeemed_at(redeemed_at))ENGINE=InnoDB;表设计要点:
max_use_count放在cdkey_code表并在生成时固化,而非从cdkey_batch实时读取。运营中途修改批次配置时,不会影响已生成码的限额uk_player_batch(player_id, batch_id)而非(player_id, code)——运营需求几乎都是"同一批次只能兑一次",玩家不能通过换不同专属码多次领取同批次奖励- 时间字段全部用
BIGINT存毫秒时间戳,彻底规避时区问题 reward_status字段是发奖补偿幂等的关键,发奖前先查、发奖后原子更新
四、要做哪些防护
4.1 防暴力枚举
CDKEY 如果太短、太规律,攻击者可以写脚本遍历尝试。防护措施:
- 码的格式设计:用加密安全随机数(
RandomNumberGenerator,不能用普通Random),Base32 字符集严格去掉易混淆的0/O/I/L。专属码推荐 16 位有效字符(32^16 ≈ 10^24,暴力枚举不可行),通用码可短至 8 位但必须配严格频率限制 - 多层频率限制:单玩家每日 ≤ 10 次、单 IP 每分钟 ≤ 5 次、单设备每日 ≤ 20 次、全局 QPS 限流保护 DB
- Redis 计数器必须用Lua 脚本原子执行
INCR + EXPIRE,分两步执行有竞态风险
4.2 防并发重复兑换
正确的顺序是:先消耗码,再插入兑换记录。
- 先消耗码(原子 UPDATE),成功后再 INSERT 记录
- INSERT 时
uk_player_batch唯一索引兜底,若玩家已有该批次记录则抛异常,回滚码的消耗 - ET Actor 单线程串行,消除单玩家级别的并发;跨进程/跨服并发靠 DB 事务 + 唯一索引
❌ 错误顺序:先查"玩家是否兑换过" → 再消耗码。高并发下两个请求会同时通过查询,都消耗成功,同一玩家兑换两次。
4.3 防通用码超发
-- 原子递增,同时检查是否超限(max_use_count 已在生成时固化在 cdkey_code 表)UPDATEcdkey_codeSETused_count=used_count+1WHEREcode=?ANDused_count<max_use_count;-- 影响行数 = 0 → 已超限,拒绝4.4 防过期码与批次未生效
兑换入口同时校验start_at(未到生效时间)和expire_at(已过期),两种情况都直接拒绝。
4.5 奖励发放幂等
发奖与兑换必须解耦,且发奖操作必须幂等:
1. 事务内:消耗码 + 写 cdkey_record(reward_status=0) 2. 事务提交成功 → 异步发奖 3. 发奖前查 reward_status,仅 0(待发)状态才执行 4. 发奖成功 → 原子 UPDATE reward_status = 1 5. 发奖失败 → reward_status 保持 0,定时任务重试(最多 5 次后置 2=失败,人工介入)五、ET 框架伪代码实现
5.1 消息定义
// 客户端 → 服务端[Message(OuterMessage.C2G_CdkeyRedeemReq)]publicclassC2G_CdkeyRedeemReq:IRequest{publicintRpcId{