1. 先想清楚"钱怎么变成战力":三层货币与数值闭环
做游戏付费系统,代码其实是最不重要的部分。我在项目里踩过的第一个大坑,不是回调验签失败,也不是订单重复发货,而是数值策划和支付模块根本没说清楚"钱进来之后到底变成什么"。我们这套系统是照着"原神"那套付费模型搭的——创世结晶、原石、抽卡券、月卡、纪行(通行证)——但真正落到服务端,得先把货币分层定死,不然写到一半就会发现到处都是"特殊商品"的分支判断。
1.1 法币、代币、消耗品:三层货币模型
我把整套体系切成三层,每一层的职责和数据来源都完全不同。
| 层级 | 典型名称 | 获取途径 | 是否可交易 | 服务端归属 |
|---|---|---|---|---|
| 第一层 | 真实货币(人民币/美元) | 支付渠道 | 否 | 支付系统,不落游戏库 |
| 第二层 | 创世结晶(付费代币) | 充值获得,1元≈10结晶 | 否 | 充值模块直接发货 |
| 第三层 | 原石、摩拉、抽卡券 | 结晶兑换、任务、活动 | 否 | 游戏内资源模块 |
关键点在于:支付系统只负责把第一层变成第二层,绝不碰第三层。玩家充值648元,服务端发的永远是8080个创世结晶(含首充双倍则是16160),至于玩家把这批结晶换成原石还是买月卡,那是游戏内兑换模块的事。这条边界一旦模糊,后面退款、扣货、补偿全部会乱套——你根本说不清"退他648该扣多少原石"。
我见过有项目为了省事,充值直接发原石。结果遇到一个活动,原石兑换比例临时调了,退款的时候按哪个比例扣?两边都算不平,最后只能人工赔钱。
1.2 为什么不把原石直接当商品卖
最直接的理由是退款可控性。付费代币是"单向闸门":结晶一旦换成原石,就进了消耗池;而原石一旦抽掉,就变成了角色、武器。整条链路越往后,回收成本越高。
第二层货币还有一个隐藏用途:跨渠道统一计价。App Store、各家安卓渠道、PC 端各自抽成不同、结算币种不同,但玩家看到的是"648 元 = 8080 结晶"这个统一锚点。渠道差异被挡在支付系统内部,游戏逻辑完全无感。
还有一点容易被忽略:首充双倍、月卡、礼包这些都是围绕结晶定价的。如果商品直接是原石,那么"首充双倍"就得做成"原石×2",一旦后续需要调整兑换比率,历史订单的追溯会变成一场灾难。
1.3 档位定价与"锚点"设计
付费系统里最值钱的东西不是代码,是那六个价格档位。常见的结构是 6 / 30 / 98 / 198 / 328 / 648,每一档的单位价值递增,制造"买大档更划算"的错觉。
我在配置表里把它做成了这样:
{ "product_id": "gem_648", "price_fen": 64800, "currency": "CNY", "base_gem": 6480, "bonus_gem": 1600, "first_charge_bonus": 8080, "group": "gem_pack", "sort": 60 }base_gem + bonus_gem才是常规到账量,first_charge_bonus是首充额外那一份。注意first_charge_bonus只在group维度首次触发——这里有个设计取舍:首充双倍是按"整个充值组"发一次,还是按"每个档位"各发一次?我们最终选了整组一次,理由是数值上更可控,玩家也不会为了薅首充去挨个买小档。
提示:所有涉及"发多少"的数值都必须落在配置表里,不能硬编码在代码里。版本更新时策划要能直接改,代码只负责读取和执行。
这一层设计定下来之后,后面所有的订单、发货、退货逻辑才有统一的语言。我个人的体会是,付费系统 70% 的线上事故,追根溯源都是货币层级没分清楚。
2. 一笔充值从点击到到账:订单链路的完整拆解
玩家点击"购买 648"到屏幕上跳出 8080 结晶,中间大概有 6 个环节。这条链路上任何一环断掉,玩家看到的都是"我付了钱但没到账",而这类工单的优先级在运营那边永远是最高级。我把整条链路拆开讲一遍,顺便说说每一环最容易翻车的地方。
2.1 预下单:客户端只负责发起,不负责决策
客户端点了按钮之后,做的唯一一件事就是请求服务端创建订单:
POST /api/pay/create { "product_id": "gem_648", "channel": "wxpay", "client_version": "1.4.2", "device_id": "xxxx" }服务端这时候要做四件事:校验product_id是否存在且上架、校验玩家账号状态(封禁中不允许下单)、从配置表读取金额、生成全局唯一的order_id落库,状态置为"待支付"。
金额绝对不能让客户端传。我接手过一个老项目,客户端把price一起传上来,服务端直接用了。这个洞被人发现之后,测试环境被人用 0.01 元买了一堆大档。服务端必须自己查表算钱,客户端传来的任何价格字段都只做交叉校验,不一致直接拒绝下单。
order_id的生成也有讲究。我们用业务前缀 + 时间戳 + 分片ID + 自增序列,比如G64820240518153012000042。不要用 UUID,因为对账的时候需要能肉眼看出时间,也不要纯自增,因为要防遍历。
2.2 回调验签:三种最常见的翻车姿势
支付渠道的回调是整个系统里唯一一个"外部世界主动打进来"的入口,所有安全压力都集中在这。翻车通常翻在这三处:
第一,没验签或者验签写错。有的实现只检查了sign字段存在,没真正做摘要比对。正确的做法是先按渠道文档规定的字段顺序拼接待签串,用渠道给的密钥做一次签名,再和回调里的sign比较,比较时用恒定时间比较,避免时序侧信道。
第二,金额没做二次校验。回调里带了total_fee,必须和订单表里的amount_fen对齐。我见过回调只验签不验金额的实现,攻击者用一笔 1 元订单的合法签名去伪造 648 元订单的发货请求。虽然渠道签名理论上绑定了订单号,但多一道本地校验成本几乎为零。
第三,把回调当同步接口用。渠道回调的超时窗口通常很短,如果在这个请求里做了大量数据库操作、发了邮件、调了别的服务,很容易超时,渠道就会重推,于是就变成了"重推 + 慢处理"的双重压力。正确做法是:回调接口只做验签、落库、投递消息,实际发货交给异步消费者。
func HandleCallback(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) if !verifySign(body, r.Header.Get("X-Sign")) { w.WriteHeader(401) return } var cb CallbackBody json.Unmarshal(body, &cb) // 幂等:同一笔渠道单号只落一次 if err := orderSvc.MarkPaid(cb.OutTradeNo, cb.ChannelOrderNo, cb.TotalFee); err != nil { // 已处理过也要返回成功,否则渠道会一直重推 w.Write([]byte(`{"code":"SUCCESS"}`)) return } mq.Publish("order.paid", cb.OutTradeNo) w.Write([]byte(`{"code":"SUCCESS"}`)) }注意最后那个注释:即使内部处理失败,只要不是"这笔订单不存在"这种硬错误,回调也必须回成功,否则渠道会按照退避策略反复重推,严重的时候能把你的入口打崩。
2.3 幂等发货:用状态机锁死"一份订单一次货"
发货环节的核心只有一句话:用一条带条件的 UPDATE 语句来保证只发一次。
UPDATE t_order SET status = 2, deliver_time = NOW(), version = version + 1 WHERE order_id = ? AND status = 1;如果affected_rows == 1,说明这次是第一个把订单从"已支付"推到"已发货"的调用者,放行发货;如果等于 0,说明有人抢先了,直接返回成功即可。
这套写法比"先查询状态再判断再更新"可靠得多,因为查询和更新之间存在时间窗口。哪怕中间隔了 200 毫秒,两个并发的消费者都可能同时读到status = 1。
发货本身也要落到流水表里,并且order_id上加唯一索引:
CREATE TABLE `t_deliver_log` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `order_id` VARCHAR(32) NOT NULL, `uid` BIGINT NOT NULL, `item_type` VARCHAR(32) NOT NULL COMMENT 'gem/card/pass', `item_count` INT NOT NULL, `create_time` DATETIME NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_item` (`order_id`, `item_type`) ) ENGINE=InnoDB;流水表的唯一索引是最后的保险丝。哪怕前面状态机的逻辑被人改坏了,数据库这一层还会拦一次。
2.4 掉单与补单:定时对账任务怎么写
再严密的实时链路也会掉单。渠道回调丢包、服务重启、消息队列积压,都会导致"钱到了但货没发"。所以必须有一个对账任务,我一般做成两级:
- 分钟级:扫描
status = 1且pay_time超过 2 分钟仍未发货的订单,重新投递发货消息。处理的是短时抖动。 - 小时级:拉取渠道的账单文件,和本地订单表做双向比对。处理的是彻底丢失的回调。
双向比对的"双向"很关键。只做"渠道有、本地没有"会漏掉退款,只做"本地有、渠道没有"会漏掉伪造订单。我一般在差集出来之后,只对金额大于阈值的差异自动补单,小额的走人工,避免对账任务本身被异常数据带偏。
3. 原石消耗端的设计:抽卡、保底与付费深度测算
付费系统的上半段是"钱变成结晶",下半段是"结晶变成原石再变成角色"。下半段虽然不直接碰支付渠道,但它决定了玩家愿不愿意继续付钱,所以在系统设计上同等重要。
3.1 保底计数器存在哪、怎么存
抽卡池的状态比想象中复杂。以一个典型的限定角色池为例,需要记录的东西至少包括:
| 字段 | 含义 | 说明 |
|---|---|---|
pity_5 | 距离上次五星的抽数 | 硬保底 90 时归零 |
pity_4 | 距离上次四星的抽数 | 硬保底 10 时归零 |
guarantee_5 | 下次五星是否为大保底 | 歪了之后置 1 |
pool_id | 卡池标识 | 不同池独立计数 |
CREATE TABLE `t_gacha_counter` ( `uid` BIGINT NOT NULL, `pool_id` INT NOT NULL, `pity_5` INT NOT NULL DEFAULT 0, `pity_4` INT NOT NULL DEFAULT 0, `guarantee_5` TINYINT NOT NULL DEFAULT 0, `total_draw` INT NOT NULL DEFAULT 0, `update_time` DATETIME NOT NULL, PRIMARY KEY (`uid`, `pool_id`) ) ENGINE=InnoDB;这里的设计取舍是:计数器按池独立,还是全局共享?独立计数对玩家更友好,但会让数值策划的付费深度测算变复杂,因为玩家可以在多个池之间"分仓"消耗免费抽数。共享计数则相反。我参与过的项目大多选择独立,因为玩家体验优先,付费意愿其实更高。
3.2 随机数可信性与概率公示
抽卡的随机数必须走服务端,而且要用密码学安全的随机源,不能用普通伪随机。原因不是技术洁癖,而是可申诉性:玩家怀疑概率有问题的时候,你需要能拿出这次抽卡的完整记录。
CREATE TABLE `t_gacha_log` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `uid` BIGINT NOT NULL, `pool_id` INT NOT NULL, `item_id` INT NOT NULL, `rarity` TINYINT NOT NULL, `pity_at` INT NOT NULL COMMENT '本次抽取时的累计计数', `rand_value` INT NOT NULL COMMENT '本次判定用的随机数原始值', `create_time` DATETIME NOT NULL, PRIMARY KEY (`id`), KEY `idx_uid_time` (`uid`, `create_time`) ) ENGINE=InnoDB;把rand_value存下来,是个被低估的好习惯。有玩家申诉"我 89 抽没出五星"的时候,把这一串记录拉出来,能清清楚楚看到每一次判定的原始值落在哪个区间。
概率公示在合规上是硬要求。常见的公示值是:五星基础概率 0.6%,第 74 抽起概率逐步提升,第 90 抽必出,综合概率约 1.6%;四星基础 5.1%,每 10 抽必出,综合约 13%。这些数字一旦公示就不能随便改动,所以概率表必须是配置且带版本号,每次调整都要留档,否则很容易说不清历史数据。
3.3 付费深度的粗算方法
数值侧最关心的一个问题是:一个大 R 抽满一个限定池大概要花多少钱。粗略算法是这样的:
一抽消耗 160 原石;648 档位常规到账 8080 结晶,1 结晶 = 1 原石,也就是约 50.5 抽。硬保底 90 抽出一金,出金期望大约是 62 抽左右。如果限定角色需要"歪一次再保底",那期望落在 120 抽上下,也就是 19200 原石,约合 2.4 个 648 档位。
这个测算决定了礼包怎么设计。如果大 R 两三单就能拿满,那礼包的溢价空间就很小;如果期望值到了五六单,就可以穿插一些"抽卡券礼包"来降低单次决策成本。我在项目里做这块的时候,会专门做一个"消耗-付费"漏斗看板,看每一档的转化率,用来反推是价格问题还是概率问题。
4. 数据表与并发控制:别让同一笔钱发两次货
前面聊了链路的业务流程,这一节讲底层。付费系统的并发压力其实不大——峰值也就每秒几十单——但它对数据一致性的要求极高。少发一次货玩家会投诉,多发一次货是直接的资金损失,而且很难追回。
4.1 订单表设计的几个关键约束
CREATE TABLE `t_order` ( `order_id` VARCHAR(32) NOT NULL COMMENT '自研订单号', `uid` BIGINT NOT NULL, `zone_id` INT NOT NULL, `product_id` VARCHAR(64) NOT NULL, `amount_fen` INT NOT NULL COMMENT '实付金额,单位分', `channel` VARCHAR(16) NOT NULL COMMENT '渠道标识', `channel_order` VARCHAR(64) DEFAULT NULL COMMENT '渠道单号', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已发货 3已退款 4已关闭', `create_time` DATETIME NOT NULL, `pay_time` DATETIME DEFAULT NULL, `deliver_time` DATETIME DEFAULT NULL, `version` INT NOT NULL DEFAULT 0, PRIMARY KEY (`order_id`), UNIQUE KEY `uk_channel_order` (`channel`, `channel_order`), KEY `idx_status_time` (`status`, `create_time`), KEY `idx_uid_time` (`uid`, `create_time`) ) ENGINE=InnoDB;三个索引各有用途:uk_channel_order防止同一个渠道单号落成两笔订单(渠道重推时最有用);idx_status_time给对账和补单任务扫表用;idx_uid_time给客服查玩家充值记录用。
金额统一用"分"存整数,这是老规矩了,用浮点数存钱迟早出事。version字段是给乐观锁留的,虽然发货主路径用的是条件更新,但在一些管理后台的修改场景里还是会用到。
4.2 事务边界划在哪
发货涉及三个写操作:更新订单状态、写发货流水、给玩家加结晶。这三个必须在一个事务里,否则会出现"订单标记已发货但结晶没加上"这种状态。
但有个例外:发邮件通知、打点上报、写日志这类操作不能放在事务里。我见过一个项目因为事务里调了邮件服务,邮件服务一挂,整个发货事务全部回滚,玩家充了几百单全部卡住。异步的东西一律扔消息队列,事务里只留最核心的三个写操作。
4.3 什么时候真的需要分布式锁
很多人的第一反应是"发货要加分布式锁"。我的经验是:先用数据库的条件更新和唯一索引,只有在确实需要跨表、跨服务协调的时候才上锁。
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 单订单发货 | 条件 UPDATE | 数据库原子性足够 |
| 同账号并发下单 | 唯一索引 + 限流 | 避免用锁做业务约束 |
| 跨服发货 | 分布式锁(Redis) | 需要跨进程互斥 |
| 抽卡计数器更新 | 行锁 + 重试 | 热点行,注意死锁 |
Reds 锁这块有个坑值得说:加了锁之后一定要设置过期时间,并且要在 finally 里释放。更稳妥的做法是用带唯一值的锁,释放前校验是不是自己持有的,避免 A 的锁超时后被 B 拿走、A 又把它删了。
另外,抽卡这种热点操作,如果同一个玩家疯狂连点,计数器那一行会成为热点。我们的做法是在网关层就对uid做按抽数粒度的限流,同时在数据库层设置一个短暂的重试退避,实测能把死锁概率压到几乎为零。
5. 月卡、首充双倍、纪行:那些"有状态"的付费权益
一次性购买、一次性发货的商品好做。真正麻烦的是有状态的权益——月卡要连续 30 天发,首充要"一生一次",纪行要按周期结算。这些东西的共同特点是:一次付费,多次发奖,跨天跨版本。
5.1 月卡:跨天重置与漏领补发
月卡的本质是一张 30 天的订阅,每天领一次。表结构我一般这么设计:
CREATE TABLE `t_month_card` ( `uid` BIGINT NOT NULL, `card_id` INT NOT NULL, `start_time` DATETIME NOT NULL, `end_time` DATETIME NOT NULL, `last_claim_day` DATE DEFAULT NULL COMMENT '最后领取的自然日', `total_days` INT NOT NULL DEFAULT 30, `remain_days` INT NOT NULL DEFAULT 30, PRIMARY KEY (`uid`, `card_id`) ) ENGINE=InnoDB;关键点有三个。第一,用自然日而不是 24 小时周期,否则玩家每天领奖时间会漂移,越领越晚。第二,跨天的时间点要用服务器统一时区,不能各服务器各自为政,否则合服的时候会撞车。第三,last_claim_day用来做幂等,同一天重复请求只发一次。
漏领补发是个产品问题而非技术问题。我们的策略是:月卡有效期内不补,但过期时可以把未领取的剩余天数折算成一次性奖励邮件发出去。这个折算比例是策划定的,但技术上要保证"折算"这个操作本身也是幂等的,不能玩家点两次就发两次。
5.2 首充双倍:一次性标记的正确存法
首充双倍看起来最简单,实际上最容易出问题。千万别用"查询该玩家历史订单里有没有成功的充值"来判断首充,因为订单表会分表、会归档、会被清理,而且退款的订单算不算首充?说不清楚。
正确做法是单独存一个标记表:
CREATE TABLE `t_first_charge` ( `uid` BIGINT NOT NULL, `group` VARCHAR(32) NOT NULL, `used_time` DATETIME NOT NULL, PRIMARY KEY (`uid`, `group`) ) ENGINE=InnoDB;发货流程变成:发货前先尝试INSERT,插入成功说明是首充,发双倍;插入失败(唯一键冲突)说明已经用过了,发常规量。这个判断和发货必须在同一个事务里,否则并发下会重复发双倍。
退款的时候要不要清掉这个标记?我们的选择是不清。因为玩家退掉首充单之后再充一次还能拿双倍,那就是个无限套利的口子。
5.3 纪行(通行证):周期结算与奖励追溯
通行证的特点是有"经验进度"和"周期边界"。一个周期内,玩家通过任务累积经验,达到等级后可以领对应档位的奖励,其中免费档和付费档分开。
技术上要注意的是周期切换时刻的处理。如果玩家在周期结束前 1 分钟购买了通行证,但结算任务在结束后 30 秒才跑,会不会导致他买了但没拿到对应奖励?我们的做法是:购买行为绑定购买时刻的周期 ID,奖励发放按周期 ID 追溯,而不是按"当前周期"。这样即使结算延迟,也能准确找到他该拿的那一期奖励。
另外,通行证的付费档奖励最好是购买后一次性补发之前已达成等级的奖励。这需要保存每个等级是否已领取的位图或者记录表:
CREATE TABLE `t_pass_reward` ( `uid` BIGINT NOT NULL, `season_id` INT NOT NULL, `level` INT NOT NULL, `track` TINYINT NOT NULL COMMENT '0免费档 1付费档', `claim_time` DATETIME NOT NULL, PRIMARY KEY (`uid`, `season_id`, `level`, `track`) ) ENGINE=InnoDB;主键本身就是幂等键,玩家重复点领取直接被数据库拦掉,代码里连状态判断都省了。
6. 线上事故排查:从玩家反馈到定位根因的完整链路
付费系统上线之后,最常见的工单就是三类:付了没到账、重复扣款、领不了奖励。这三类的排查路径完全不同,我把完整的排查链路写出来,遇到的时候可以照着走。
6.1 三类高频事故和它们的根因分布
| 现象 | 高频根因 | 排查入口 |
|---|---|---|
| 付了款没到账 | 回调丢失、异步消费者卡住、发货事务失败 | t_order状态 + 消息队列积压量 |
| 重复扣款 | 渠道重复下单、客户端连点、未做下单限流 | t_order同 uid 短时间多单 |
| 奖励领不到 | 权益状态未写入、周期 ID 不匹配、并发冲突 | 权益表 + 领取流水表 |
这三类里,"付了没到账"占七成以上。所以我第一件事永远是看订单表的状态分布:把所有status = 1且pay_time超过 3 分钟的记录拉出来,如果数量在短时间内激增,那基本可以确定是发货链路断了,而不是个别玩家的偶发问题。
6.2 我的固定排查顺序
遇到工单,我会按这个顺序走,不要跳步:
- 先看订单存不存在。如果
order_id在库里根本查不到,说明问题出在下单或回调入口,不是发货。 - 看订单状态和时间戳。
pay_time有值但status还是 1,说明回调到了、发货没走完。 - 看发货流水表。流水有记录但玩家说没收到,那就是加资源的逻辑出问题,去查角色数据。
- 看消息队列。如果有一批订单卡在同样的状态,大概率是消费者挂了或者消息堆积。
- 看渠道账单。前四步都对不上,才去拉渠道那边的流水,确认钱到底有没有到。
这个顺序的价值在于从近到远。先排除自己系统内部的问题,再去查外部渠道,能省掉大量沟通成本。我见过有人一上来就找渠道客服,来回扯皮两天,最后发现是自己消费者线程池被一个慢查询阻塞了。
6.3 风控与开关:事故的止损手段
出事故的时候,第一优先级不是修,是止血。我们平时会准备好几个开关:
- 下单开关:一键关闭某个渠道的下单入口,防止问题订单继续涌入。
- 发货开关:关闭自动发货,转为人工审核后补发。
- 商品下架:某个档位的配置有问题时,直接下架。
- 灰度比例:新版本付费逻辑先对 1% 玩家开启。
风控侧要盯的几个信号:同一uid短时间大量下单、同一设备多账号充值、异常金额的小额订单聚集、退款率突增。这些信号不用做得很复杂,先用简单的阈值规则跑起来,有数据之后再上模型。
7. GM 后台:运营真正需要的那几个按钮
技术侧做完之后,运营能不能自助处理问题,决定了这套系统的实际运营成本。我给后台定的原则是:能用按钮解决的,绝不让运营来找开发。
7.1 补单、扣货、退款三个核心操作
补单是最常用的。运营输入订单号,后台校验订单确实处于"已支付未发货"状态,然后触发一次标准发货流程。注意,补单走的必须是和正常发货同一套逻辑,不能另写一份,否则幂等保证就失效了。
扣货要谨慎。给玩家扣结晶之前,必须检查当前余额够不够,不够的情况要么冻结部分、要么记为负数债务。我们的做法是:结晶不足时不允许扣,改为给账号打一个"待处理"标记,由人工跟进。
退款是三条链路里最复杂的,因为它牵扯渠道。后台发起退款请求给渠道,渠道回调退款结果,然后本地做一次扣货。整个流程必须和发货一样走状态机,状态字段加一个"退款中",避免退款请求重复发起。
7.2 看板上真正有用的几个指标
后台首页我只留了四个数字:
- 当日充值金额与订单数,按渠道拆分
- 待发货订单数(这个必须实时刷新,它是最直接的健康度指标)
- 24 小时内发货失败率
- 退款率
前两个用来发现异常,第三个用来评估链路质量,第四个用来判断有没有人在薅羊毛。指标不求多,求的是每次打开后台都能一眼看出"今天有没有出事"。
后台的权限也要分。普通客服只能查不能改,补单要二级审批,退款要财务角色。付费系统的后台权限一旦松了,内部风险比外部攻击还大。
最后分享一个我在项目里坚持了很久的小习惯:任何一次改动付费相关代码,上线前必须在测试环境跑一遍完整的"下单-支付-回调-发货-对账"全链路,包括模拟回调重复推送和模拟消息丢失两个异常场景。这两个场景覆盖了我遇到过的八成线上事故,跑一遍只要十分钟,比线上出事后凌晨爬起来排查划算太多。