玩家付了钱,东西没到:Google Play 内购的服务端正确姿势
2026/9/7 2:12:46 网站建设 项目流程

先记住一句话:客户端只是"送信人",能不能发货、发几次,必须由你的服务端问过 Google 之后说了算。


一、一分钟看懂整条链路

① 客户端 launchBillingFlow → 玩家在 Google 弹窗里付钱 ② PurchasesUpdatedListener 回调,拿到一个 purchaseToken(一串长字符串) ③ 客户端把 purchaseToken 发给【你的服务端】 ④ 你的服务端拿它去问 Google:"这单是真的吗?付了吗?" ⑤ Google 说"真的,已付款" → 服务端发货,写流水 ⑥ 服务端(或客户端)调 acknowledge / consume,告诉 Google"我发货了" ⑦ 客户端刷新背包

新手最容易漏的是第 ⑥ 步。很多人以为发完货就结束了——没有第 ⑥ 步,Google 会在 3 天后自动把钱退给玩家,而你的东西已经发出去了。

比喻:整个流程像快递签收。第 ④ 步是"打电话给快递公司核实运单号真伪",第 ⑥ 步是"签收单交回去"。不签收,快递公司默认派送失败,自动退款。


二、五个必须先记住的事实

事实 1:3 天不确认 = 自动退款

Google Play Billing 明确规定:购买后必须在3 天内确认,否则交易被撤销、钱退还给玩家

  • 消耗型(点券、复活币):调consumeAsync,消耗本身就隐含了确认
  • 非消耗型 / 订阅(战令、去广告、月卡):调acknowledgePurchase

这条规则是最贵的一条,后面案例 1 会讲一个真实的翻车。

事实 2:不要只在客户端验签

Play Console 里有个 RSA 公钥,可以在客户端本地验证收据签名。能用,但不能只用它。

原因很直接:客户端的代码可以被 Hook。用 Frida 或 Lucky Patcher 把verifySignature()的返回值改成true,或者直接把某个真实收据的 JSON 替换掉 productId,本地验签就形同虚设。

结论:本地验签只能当"提前过滤明显垃圾请求"的第一道筛子,发货决策必须走服务端。

事实 3:purchaseToken的语义分两种,别搞混

商品类型purchaseToken 行为该用什么做幂等键
一次性商品(消耗/非消耗)每笔购买一个新 tokenpurchaseToken
订阅续订时 token 不变,只有 orderId 变(...0...1...2)orderId

这是很多人踩过的坑:订阅用 purchaseToken 做幂等,结果第二个月的续订被当成重复请求拦掉了,玩家续费了但没收到月卡奖励。

事实 4:purchaseState有三种,必须用白名单判断

0 = Purchased(已付款) ← 只有这个能发货 1 = Canceled(已取消) 2 = Pending(待付款) ← 现金/网银支付,还没真正付钱

危险写法:

if(purchaseState!=1)grant();// ❌ Pending 也被放行了 = 白送

正确写法:

if(purchaseState==0)grant();// ✅ 白名单

Pending主要出现在支持现金支付的地区(巴西、印度、部分东南亚国家)。玩家在便利店付款前,状态就是 Pending。要处理它必须在客户端调enablePendingPurchases(),然后等 Google 通知你付款完成再发货。

事实 5:绝不能相信客户端传的 productId 和价格

客户端传来的 JSON 里的productIdprice全部忽略掉

只用 Google 接口返回的productId,拿它去查你自己服务端的配置表得到"该发多少东西"。

否则一个改包玩家把productIdcoin_60改成coin_12000,你就送了。


三、服务端怎么问 Google

接口

一次性商品:

GET https://androidpublisher.googleapis.com/androidpublisher/v3 /applications/{packageName}/purchases/products/{productId}/tokens/{purchaseToken}

订阅用purchases.subscriptionsv2.get

鉴权:在 Google Cloud 建一个 Service Account,开启 Google Play Android Developer API,再到 Play Console → 用户和权限里授权它。走标准 OAuth2 拿 access token。

返回里真正要看的字段

字段怎么用
purchaseState必须 == 0
productId用它查自己的发货配置表
orderId订阅的幂等键 / 客服查单凭证
quantity★ 多买(一次买 5 份)时不是 1,漏了会少发
acknowledgementState0 = 还没确认,该赶紧确认了
consumptionState消耗型是否已被消耗
obfuscatedExternalAccountId★ 防跨账号盗用,见下节
purchaseType0 = 测试单,1 = 兑换码,2 = 激励。测试单在生产环境要拦掉或打标,不然测试机能白刷
regionCode风控、税务分析

一段能用的验证逻辑

GpVerifyResultverify(Stringpkg,StringproductId,Stringtoken){varr=androidPublisher.purchases().products().get(pkg,productId,token).execute();// 1. 白名单判断,不是黑名单if(r.getPurchaseState()!=0)returnreject("state="+r.getPurchaseState());// 2. 生产环境拦测试单(或打标不计入营收)if(Integer.valueOf(0).equals(r.getPurchaseType())&&isProd())returnreject("test_purchase");// 3. 校验这单是不是这个玩家的if(!accountId.equals(r.getObfuscatedExternalAccountId()))returnreject("account_mismatch");// 4. 数量以 Google 为准intqty=r.getQuantity()==null?1:r.getQuantity();returnok(r.getOrderId(),r.getProductId(),qty,r.getAcknowledgementState());}

关键前置动作:下单时把玩家 ID 带上

BillingFlowParams.newBuilder().setProductDetailsParamsList(list).setObfuscatedAccountId(hashedAccountId)// ★ 别传明文 UID,传 hash.build();

这样 Google 会把它写进收据。服务端就能验证"这个 token 属于这个账号"。没有它,一个有效的 purchaseToken 可以被拿去给任意小号发货。


四、幂等:靠数据库主键,不靠 if 判断

同一个purchaseToken会因为各种原因被上报多次:

  • 客户端回调 +queryPurchasesAsync补单同时触发
  • 玩家弱网下连点购买按钮
  • 客户端重试策略
  • 玩家抓包故意重放

如果幂等靠"先 select 再 insert",在并发下必然漏。靠数据库唯一约束才是硬保证。

表设计

CREATETABLEgp_purchase(purchase_tokenVARCHAR(255)NOTNULL,order_idVARCHAR(64),product_idVARCHAR(128)NOTNULL,account_idBIGINTNOTNULL,quantityINTNOTNULLDEFAULT1,stateTINYINTNOTNULL,-- 0待验 1已验 2已发货 3已确认purchase_typeTINYINT,raw_payload JSON,created_atDATETIME,updated_atDATETIME,PRIMARYKEY(purchase_token),-- ★ 幂等的物理保证UNIQUEKEYuk_order(order_id),-- ★ 订阅续订用它KEYidx_ack(state,created_at)-- 给补 ack 的定时任务用);

状态机(必须持久化,不能只存内存)

CREATED ──验证通过──> VERIFIED ──发货成功──> GRANTED ──ack成功──> ACKED │ │ └──验证失败──> REJECTED └──ack失败──> 重试队列

为什么状态机要落库?因为任何一步都可能进程崩溃。落库之后,重启后的定时任务能看到"这单卡在 GRANTED 没 ACK",继续往下推。

幂等 + 发货代码

// ===== 第一步:抢占,靠唯一键当并发闸门 =====introws=mapper.insertIgnore(token,accountId,productId,STATE_CREATED);if(rows==0){varrec=mapper.selectByToken(token);if(!rec.accountId.equals(accountId))returnfail("TOKEN_ACCOUNT_MISMATCH");// ★ 跨账号盗用if(rec.state>=STATE_GRANTED)returnok(rec.deliveredItems);// ★ 幂等:返回成功,不重发// state < GRANTED:上次挂在中间了,往下继续走(可重入)}// ===== 第二步:问 Google =====varv=verify(pkg,productId,token);if(!v.ok){mapper.markRejected(token,v.reason);returnfail(v.reason);}// ===== 第三步:发货,和状态变更在同一个事务里 =====tx.execute(()->{varrec=mapper.selectForUpdate(token);// ★ 行锁,二次兜底if(rec.state>=STATE_GRANTED)return;// 双检mapper.updateState(token,STATE_GRANTED,v.orderId,v.quantity);walletService.add(accountId,cfg.coins*v.quantity,/*业务单号*/token);// ★ 流水用 token 当唯一号});// ===== 第四步:确认,失败不回滚发货,进重试队列 =====ackQueue.submit(token);returnok();

四个设计要点:

  1. insertIgnore抢占:并发时只有一条能插入成功,其余走幂等分支。
  2. 发货和状态变更同事务:避免"钱包加了但状态没改",下次重试再加一次。
  3. 钱包流水用 purchaseToken 当业务单号,并加唯一索引 ——双保险
  4. ack 失败不能回滚发货。发货是给玩家的,ack 是给 Google 的,两者失败处理策略不同。ack 必须异步 + 无限重试,因为它有 3 天死线。

五、补单:三条防线

"补单"就是玩家钱付了但货没到,怎么找回来。要有三层。

第一防线:客户端每次启动/回前台都查一次

// 冷启动 + onResume 都要调billingClient.queryPurchasesAsync(QueryPurchasesParams.newBuilder().setProductType(ProductType.INAPP).build(),(result,purchases)->{for(varp:purchases){// 这里返回的是"未消耗 / 未确认"的单子 → 全部重新上报服务端reportToServer(p.getPurchaseToken(),p.getProducts().get(0));}});

这里有个致命细节:queryPurchasesAsync只返回还没被 consume 的消耗型商品。一旦 consume 了,它就永远消失了。

所以顺序绝对不能错:验证 → 发货 → 才能 consume。
先 consume 再上报,一旦上报失败,这笔单从客户端角度彻底人间蒸发,只能人工客服处理。案例 2 就是这个。

第二防线:RTDN(让 Google 主动推给你)

在 Play Console 配一个 Cloud Pub/Sub 主题,Google 会实时推送:

通知含义
ONE_TIME_PRODUCT_PURCHASEDPending 单终于付款了 →这时才发货
ONE_TIME_PRODUCT_CANCELEDPending 单被取消
SUBSCRIPTION_RENEWED订阅续费 → 发月卡奖励
SUBSCRIPTION_CANCELED / EXPIRED停止权益
VOIDED_PURCHASE退款/拒付 → 该追回了

RTDN 是处理 Pending 状态的唯一优雅方案,也是替代"定时轮询 Google API"的省配额做法。

注意:RTDN 消息可能重复、可能乱序。所以收到通知后不要直接信,要拿 token 重新调一次 Google API 确认当前状态,再走上面那套幂等逻辑。

第三防线:服务端对账任务

-- 每 10 分钟跑:找出卡住的单SELECTpurchase_tokenFROMgp_purchaseWHEREstate=2-- GRANTED 但没 ACKANDcreated_at<NOW()-INTERVAL30MINUTELIMIT500;-- → 重新调 acknowledgeSELECTpurchase_tokenFROMgp_purchaseWHEREstate=0-- 卡在待验证ANDcreated_at<NOW()-INTERVAL5MINUTE;-- → 重新走验证流程

再配一条告警:state=2 且超过 24 小时未 ACK的数量 > 0 就报警。这条告警能救命——它是 3 天自动退款的最后一道预警线。


六、射击游戏实战案例

案例 1:战令白送了 4200 份 —— 3 天窗口的代价

背景:某手游 FPS,新赛季战令(非消耗型,$9.99)。

Bug 代码:

// 客户端,BattlePassManager.javavoidonPurchaseVerified(){if(GameState.current==State.IN_MATCH){pendingAck=purchase;// ★ 对局中先不 ack,回大厅再说return;}billingClient.acknowledgePurchase(...);}

为什么会炸:FPS 玩家的行为模式是在对局间隙买东西,然后直接切后台/杀进程。很多人买完战令立刻进下一局,打完直接退出游戏——pendingAck在内存里,进程一死就没了。

结果:上线 5 天,4218 笔购买从未被 acknowledge,Google 全部自动退款。玩家保留了战令(服务端已发货),钱退回去了。直接损失约$42,000

修复:

  1. ack 移到服务端,和游戏状态彻底解耦:
// 发货成功后立刻进 ack 队列,与玩家是否在对局无关ackQueue.submit(token);// 独立线程池,指数退避重试,最多重试 48h
  1. 加上第五节那条state=2 超 24h的告警。
  2. 客户端保留queryPurchasesAsync兜底(ack 过的单不会再返回)。

教训:确认(ack)是财务动作,不是游戏逻辑动作。永远不要让它依赖客户端状态、玩家行为或网络时机。


案例 2:先 consume 后上报 —— 每天 40 单客诉

背景:点券(消耗型,类似 COD Points)。

Bug 代码:

// ❌ 顺序完全倒了billingClient.consumeAsync(params,(r,token)->{if(r.getResponseCode()==OK){api.reportPurchase(token);// ★ 这一步失败了怎么办?}});

为什么是灾难:consume成功后,这个 token 从 Google 的"未消耗列表"里移除,queryPurchasesAsync再也查不到它。如果紧接着的reportPurchase因为切网、后台被杀、服务端 500 而失败:

  • 玩家的钱付了 ✅
  • Google 认为已发货 ✅(consume 隐含 ack,所以不会自动退款)
  • 你的服务端完全不知道这笔单存在
  • 客户端没有任何办法再找回这个 token

唯一补救途径:玩家提供 Google 邮件里的 orderId → 客服 → 人工在 Play Console 查单 → 手动补发。日均 30~60 单客诉,单笔处理成本远超商品本身。

修复 —— 严格顺序:

// ✅ 服务端确认发货成功后,客户端才 consumeapi.reportPurchase(token,resp->{if(resp.code==OK||resp.code==ALREADY_GRANTED){billingClient.consumeAsync(params,...);// 这时消耗才安全}// 失败则什么都不做 —— 下次启动 queryPurchasesAsync 还能查到,自动补单});

更稳的做法:服务端在发货成功后自行调用 Google 的确认接口,客户端的consume只作为清理动作,失败也无所谓。

修复后客诉降到日均 0~2 单(剩下的是玩家真的退款了)。


案例 3:限时武器箱被双发 —— 并发与重放

背景:限时活动"传说武器箱 ×10",$19.99。上线首小时。

两个同时发生的问题:

(a) 并发双发:玩家在弱网下连点购买,加上PurchasesUpdatedListener回调和queryPurchasesAsync补单几乎同时上报同一个 token。服务端原逻辑是:

if(mapper.selectByToken(token)==null){// ❌ 检查verify();grant();insert();// ❌ 和执行之间有窗口}

两条请求都通过了selectByToken == null,双倍发货。灰度期实测0.7% 的订单被双发

(b) 跨账号重放:一个玩家用抓包工具拿到自己那笔真实购买的purchaseToken,然后用小号登录,把这个 token 重放到服务端。原逻辑没校验obfuscatedExternalAccountId,成功给小号也发了一份。这个手法在测试服被玩家发现后 3 小时内传遍社群。

修复(就是第四节那套):

  • purchase_token做主键 +insertIgnore抢占 → 解决 (a)
  • 事务内SELECT ... FOR UPDATE双检 → 兜底 (a)
  • 下单时setObfuscatedAccountId,验证时比对 → 解决 (b)
  • 钱包流水表biz_no唯一索引 → 最后一道防线

修复后双发率 0,跨账号重放全部被拦并进风控黑名单。


案例 4:对局中购买 —— 发货时机的架构问题

这是 FPS 特有的问题,其他品类不一定遇到。

FPS 的对局是由独立战斗服托管的,进局时会做一次背包/配装快照。如果玩家在对局中买了"复活币",服务端直接改数据库,战斗服内存里的快照并不知道,会出现:

  • 玩家点复活 → 战斗服检查内存快照,余额为 0 → 拒绝
  • 玩家看到商城显示已购买,但用不了 → 客诉
  • 更糟:战斗结束时战斗服用旧快照回写数据库 →刚买的东西被覆盖没了

设计原则:发货必须区分"钱包类"和"局内物品类"。

voiddeliver(longaccountId,DeliverPlanplan){// ① 钱包/通用货币:钱包服独立,战斗服不缓存,可立即入账walletService.add(accountId,plan.currency,bizNo);// ② 局内消耗品/配装物品:写待发货队列,不直接改if(!plan.inMatchItems.isEmpty()){if(matchService.isInMatch(accountId)){pendingDeliveryRepo.enqueue(accountId,plan.inMatchItems,bizNo);// 回大厅时(或战斗服回写完成后)统一结算}else{inventoryService.add(accountId,plan.inMatchItems,bizNo);}}}

关键点:即使延迟发货,服务端状态也必须立刻推进到GRANTED,ack 也必须立刻做。否则又会掉进案例 1 的坑。

"已确认收款"和"物品已进背包"是两件事,不要绑在一起。

客户端在对局中购买后,UI 应明确提示"将在返回大厅后发放",而不是显示已到账。


案例 5:买点券 → 换皮肤 → 退款 —— 追回机制

背景:玩家买 10,000 点券($99.99),全部换成永久武器皮肤,然后向 Google 申请退款。

为什么在射击游戏里格外恶劣:

  1. 点券已消耗,余额为 0,没得扣
  2. 永久皮肤是账号资产,玩家继续在用
  3. 部分武器皮肤(纯色、低干扰的准星贴合款)在竞技中有可读性优势,退款白拿等于影响竞技公平
  4. 惯犯会批量操作,原始退款率一度到1.8%

追回实现 —— Voided Purchases API:

// 每 15 分钟拉一次(或直接消费 RTDN 的 VOIDED_PURCHASE)varlist=androidPublisher.purchases().voidedpurchases().list(pkg).setStartTime(lastCursor).execute();for(varv:list.getVoidedPurchases()){varrec=mapper.selectByToken(v.getPurchaseToken());if(rec==null||rec.state==STATE_CLAWED)continue;// 幂等clawback(rec,v.getVoidedReason());// 0=其他 1=退款 2=拒付}

分级追回策略:

情况处理
点券余额充足直接扣回,写流水
余额不足置为负余额,冻结商城,下次充值先抵扣
已换成永久物品回收物品+ 记入风控档案
拒付(chargeback,reason=2)立即回收 +限制支付渠道
累计退款 ≥ 3 次加入名单,只允许非消耗型购买

必须做幂等:Voided Purchases API 会在时间窗内重复返回同一笔,不去重会把玩家扣穿。

结果:上线追回机制 2 个月后,退款率从1.8% 降到 0.35%—— 主要因为惯犯被识别并限制,以及"退款会被回收"在社群传开后的劝退效应。


七、检查清单

客户端

  • 下单必须setObfuscatedAccountId(传 hash,不传明文 UID)
  • 必须调enablePendingPurchases()
  • 冷启动 +onResume都调queryPurchasesAsync
  • 顺序:上报服务端 → 服务端返回成功 → 才 consume
  • 不在客户端做发货决策,不信任本地价格/productId

服务端验证

  • purchaseState == 0白名单判断
  • 用返回的productId查自己的配置表定价
  • quantity,不要写死 1
  • 校验obfuscatedExternalAccountId与请求账号一致
  • 生产环境拦掉purchaseType == 0(测试单)

幂等

  • purchase_token主键;订阅额外用order_id唯一键
  • INSERT IGNORE抢占,不用"先查再插"
  • 发货 + 状态变更在同一事务
  • 钱包流水biz_no唯一索引兜底
  • 重复请求返回成功(幂等),不是失败

确认与补单

  • ack在服务端做,与游戏状态/玩家行为完全解耦
  • ack 失败进重试队列,指数退避,窗口设 48h(留 24h 余量)
  • 告警:state=GRANTED 且 >24h 未 ACK的单数 > 0
  • 配置 RTDN,收到通知后重新查一次 API再处理
  • 对账任务每 10 分钟捞卡单

退款

  • 接 Voided Purchases API /VOIDED_PURCHASE通知
  • 追回逻辑幂等(会重复推送)
  • 支持负余额 + 物品回收 + 风控分级

八、三句话总结

第一句:客户端负责"付款",服务端负责"发货",两者之间只传一个 purchaseToken。
凡是让客户端决定"发多少"的设计,都会在上线两周内被玩家找到。

第二句:幂等不是写几个 if,是靠数据库唯一约束 + 事务 + 行锁。
purchaseToken天生就是完美的幂等键,把它做成主键,并发问题一次性解决。订阅例外——用orderId

第三句:acknowledge 是财务动作,3 天没做就是真金白银的损失。
它必须在服务端、必须异步无限重试、必须有告警。不要让它依赖玩家是否回到大厅、是否切了后台、是否有网。案例 1 那 $42,000 的教训,起因只是一行"对局中先不 ack"。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询