《Cocos 游戏开发实战》12.3 内购与合规

变现的另一条腿是内购,而内购比广告更麻烦:它牵涉订单、验签、发货,还牵涉实名、防沉迷、消费限额这些合规红线。本节给出「服务端下单、平台支付、服务端验签发货」的完整时序,写一个防重复购买的 IapService,再逐条列出上架前必须过的合规清单。

12.3 内购与合规

内购(IAP)比广告复杂得多,原因不在于技术,而在于它牵涉钱。钱一旦流动,就要求「订单可追溯、发货不重复、失败可退款」,同时还要满足各地监管对实名、防沉迷、消费限额的要求。这一节把内购的客户端骨架和合规清单一次讲清——服务端实现仍不在本卷范围,但接口约定与调用时序会写到可以直接照着对接。

12.3.1 内购的商品形态

先把商品分类,因为三类商品的服务端逻辑完全不同:

类型例子是否可重复购买服务端处理
消耗型金币包、道具包可每次订单都发货
非消耗型去广告、永久皮肤否发货前查「是否已拥有」
订阅月卡、通行证到期可续记录到期时间、处理续订与退款

本项目先做消耗型 + 非消耗型两类。订阅留到运营阶段——它需要处理续订回调、到期提醒、退款,是独立的一大块。

商品设计上有个容易忽略的点:非消耗型的「已拥有」状态必须由服务端维护。玩家买了「去广告」,换台设备登录后应当自动生效——如果这个状态只存在本地存档里,换设备就丢了,玩家会来投诉。所以非消耗型商品在服务端是「账号级的一次性标记」,而消耗型商品是「可累加的余额」。两者在数据模型上根本不同,别混在一张表里。

价格展示则遵循一个原则:客户端只展示,不计算。商品列表从服务端拿,每个商品带上平台返回的本地化价格字符串(如「¥6.00」「$0.99」),客户端原样显示。任何「美元价乘以汇率」的本地计算都可能在促销、税差、区域定价下出错。

12.3.2 订单流程与时序

内购的铁律是:客户端永远不发货,只做 UI。完整的正确时序是:

  1. 玩家在商店点「购买」,客户端把 skuId 发给服务端:POST /iap/order,body 为 { skuId }。
  2. 服务端创建订单、返回 orderId 与平台所需的商品标识。
  3. 客户端调平台支付接口(iOS 走 StoreKit、Android 走渠道 SDK、微信走虚拟支付),拿到平台回执。
  4. 客户端把回执 POST 给服务端:/iap/verify,body 为 { orderId, receipt }。
  5. 服务端向平台验签,验签通过才发货(写进云端存档),并把订单标记为已完成。
  6. 客户端收到「已发货」后刷新 UI、拉取最新存档。

为什么发货必须在服务端? 因为客户端可以被篡改——玩家能伪造一个「支付成功」的回调。只有服务端拿回执向平台验签,才能确认真付了钱。客户端的角色从头到尾只是「传话」。

每一步都有对应的失败路径,实现时要一一想清楚:第 1 步失败(下不了单)应提示「稍后重试」,不要扣任何东西;第 3 步玩家取消支付,回执不会产生,客户端要回到商店页、状态复位;第 5 步验签失败说明回执是伪造或已用过,服务端必须拒绝发货并记录异常;第 6 步刷新失败不影响已发货的事实,下次进游戏拉存档时会补上。只有第 5 步成功,才算交易完成——前四步都只是「进行中」。

客户端要打交道的接口就三个,形状固定:

方法路径请求响应
GET/iap/skus无200 + 商品列表(含平台价格)
POST/iap/order{ skuId }200 + { orderId }
POST/iap/verify{ orderId, receipt }200 + { ok, delivered }

商品价格要从服务端拿,不能硬编码在客户端。 平台会按地区、汇率、促销调整实际价格,客户端硬编码的「¥6」会和玩家实际看到的价格不一致,既可能被拒审,也会引发纠纷。客户端拿到的 priceLabel 应当是平台返回的「本地化价格字符串」,直接展示即可。

12.3.3 掉单与补单

内购最让人头疼的故障叫掉单:玩家付了钱,但客户端在拿到回执前崩了、或网络断了,导致服务端从没收到 /iap/verify。玩家投诉「充了钱没到账」,而你手里没有任何记录。

处理掉单有两条防线:

  1. 客户端补报:每次启动时,把本地缓存的「未确认订单」(orderId + 回执)重新 POST /iap/verify 一次。回执要持久化——存进 11.1 的存档或单独的键,不能只放内存。
  2. 服务端主动对账:定期向平台拉取「已支付但未发货」的订单,补发货。这是兜底,因为玩家可能卸载重装,客户端再也没机会补报。

对应地,服务端必须支持幂等发货:同一 orderId 重复 verify 只发一次货,第二次返回「已发货」。这样客户端大胆重试也不会重复发奖。客户端的补报逻辑因此可以写得很「激进」——不确定就重报,反正服务端会去重。

补报的触发时机要选在「玩家还没进入付费场景之前」:启动加载完成、进主菜单时跑一次。这样即便上一次购买掉了单,玩家在打开商店前就已经看到道具到账,而不是进了商店才发现「我明明买过却没有」。补报本身要静默执行,成功不弹提示,避免打扰正常玩家。

12.3.4 平台差异

三端的内购入口完全不同,这是客户端唯一需要分平台的地方:

平台支付通道客户端拿到的回执注意
iOSApp Store IAPtransactionReceipt虚拟商品必须走苹果内购,否则拒审
AndroidGoogle Play / 国内渠道渠道订单号 + 签名国内渠道各有 SDK,需分别接
微信小游戏微信虚拟支付支付票据iOS 端小游戏不支持虚拟支付

最后一条要特别记住:微信小游戏的 iOS 端不能做虚拟支付。这意味着同一份代码在 iOS 微信里要么隐藏商店入口、要么改成「广告解锁」,否则玩家点了购买会直接失败。判断方式是 sys.platform === sys.Platform.WECHAT_GAME 之后再看操作系统——sys.os 会是 iOS 对应的值。

平台差异还带来一个工程问题:多渠道打包。国内 Android 有几十个应用市场,每个市场的支付 SDK 和审核规则都不同。不要为每个渠道复制一份工程,而是把「渠道差异」收敛成一组配置(渠道 ID、支付 SDK 标识、隐私政策链接),构建时通过构建面板的参数注入。这样新增一个渠道,改的是配置而不是代码——第 14 章的多端构建会接着讲这套参数怎么落进构建流程。

12.3.5 IapService(L2)

内购服务要防两件事:重复点击(玩家狂点购买按钮)与重复发货(回调重放)。前者靠客户端状态锁,后者靠服务端按 orderId 幂等:

// assets/scripts/platform/iap.ts
import { sys } from 'cc';

export interface Sku {
  id: string;
  priceLabel: string;
}

export interface PurchaseReceipt {
  skuId: string;
  orderId: string;
  payload: string;
}

interface PlatformIapApi {
  purchase(skuId: string): Promise<PurchaseReceipt>;
}

declare function createNativeIap(): PlatformIapApi;
declare function createWeChatIap(): PlatformIapApi;
// 服务端约定:POST /iap/order 创建订单;POST /iap/verify 验签并发货
declare function createOrder(skuId: string): Promise<{ orderId: string }>;
declare function verifyAndDeliver(receipt: PurchaseReceipt): Promise<boolean>;

let purchasing = false;

function createPlatformIap(): PlatformIapApi | null {
  if (sys.platform === sys.Platform.IOS || sys.platform === sys.Platform.ANDROID) {
    return createNativeIap();
  }
  if (sys.platform === sys.Platform.WECHAT_GAME) {
    return createWeChatIap();
  }
  return null; // H5 无内购通道
}

export async function buy(sku: Sku): Promise<boolean> {
  if (purchasing) return false; // 防狂点:同一时刻只允许一笔
  const api = createPlatformIap();
  if (!api) return false;
  purchasing = true;
  let delivered = false;
  try {
    const order = await createOrder(sku.id);
    const receipt = await api.purchase(sku.id);
    delivered = await verifyAndDeliver({ ...receipt, orderId: order.orderId });
  } catch {
    delivered = false;
  } finally {
    purchasing = false; // 无论成败都解锁,允许重试
  }
  return delivered;
}

purchasing 这个锁只解决客户端并发,不能替代服务端幂等——因为回调可能重复投递。服务端的正确做法是:以 orderId 为唯一键,发货前查「这个订单是否已发货」,已发货就跳过。客户端这一层锁,只是让玩家在等待期间点不出第二笔订单。

结合 12.3.3 的掉单处理,客户端还应在启动时调一次补报:

// assets/scripts/platform/iap-recover.ts
export interface PendingOrder {
  orderId: string;
  skuId: string;
  payload: string;
}

// 服务端约定:POST /iap/verify,幂等
declare function verifyAndDeliver(order: PendingOrder): Promise<boolean>;

export async function recoverPending(
  pending: PendingOrder[],
  clear: (orderId: string) => void,
): Promise<void> {
  for (const order of pending) {
    try {
      const ok = await verifyAndDeliver(order);
      if (ok) clear(order.orderId); // 服务端确认已发货,本地才清
    } catch {
      // 保留在 pending 里,下次启动继续补报
    }
  }
}

补报的顺序是「服务端先确认、本地后清除」——只要服务端还没确认发货,这条订单就一直在待补列表里。宁可多报几次(服务端幂等),也不要漏报一次。

12.3.6 合规清单

内购一旦上线,合规就不是「可选项」。下面这张清单是上架前必须逐条核对的,缺一条都可能被打回或下架:

合规项要求工程落点
实名认证玩家必须实名才能进入付费登录后接入实名接口
防沉迷未成年人限定时段游玩服务端按时段拦截
消费限额未成年人单次/每月限额服务端下单前校验
隐私政策首次启动弹窗、可拒绝冷启动首个弹窗
SDK 清单明示收集了哪些数据隐私政策附页
广告合规不诱导点击、可跳过广告位设计(12.2)
概率公示涉及随机抽取必须公示本项目暂不涉及

消费限额是硬性红线,具体额度以最新监管口径为准,量级是:未满 8 周岁不得提供付费;8–16 周岁单次充值不超过 50 元、每月不超过 200 元;16–18 周岁单次不超过 100 元、每月不超过 400 元。这些判断必须在服务端做——客户端的年龄不可信,限额状态也要防篡改。

实名与防沉迷也不是「加个弹窗」就完事。它要求游戏能识别玩家年龄并据此改变行为:未实名的玩家进不了付费、未成年人在非允许时段进不了游戏。这意味着登录之后、进入玩法之前,必须有一次「合规检查」的网络请求;请求失败时要有明确降级策略(比如按最严格处理),而不是放行。

隐私政策弹窗是很多团队会漏的一步:首次启动、进入任何玩法之前,必须让玩家看到并同意隐私政策;玩家拒绝时,游戏应能进入「仅本地、不联网」的降级模式,而不是直接崩掉或卡死。

数据收集要遵守最小必要原则:只收玩法必需的信息,别为了「以后可能有用」而滥采。每引入一个第三方 SDK(广告、统计、崩溃上报),都要在隐私政策里写明它收集什么、用于什么。工程上建议维护一份 configs/third-party-sdks.json,把 SDK 名称、用途、收集项、隐私政策链接登记在案——上架审核和监管抽查都会要这份清单,临时拼凑很容易漏。

还有一条与广告相关的合规要求容易被忽视:广告不得伪装成游戏内容诱导点击。按钮文案不能写「点击领取奖励」却弹广告,广告区域要能一眼区分、且提供关闭入口。12.2 里把插屏限制为「冷启动一次」,除了体验考量,本身也是合规上的稳妥做法。

把这张清单落地成工程动作,其实只有三个落点:冷启动的隐私弹窗、登录后的合规检查请求、服务端的下单前限额校验。前两个在客户端,第三个在服务端。客户端的两个落点都要保证「失败不阻断基本玩法」——隐私弹窗拒绝时进本地模式,合规检查失败时按最严格策略放行。

退款与未成年人退款要提前设计,而不是等玩家投诉了才想。要点三条:(1)消耗型商品已消费的不退,未消费的按平台规则退;(2)未成年人未经监护人同意的充值,监护人有权要求退款,服务端要能按 orderId 追溯订单与发货记录;(3)退款后要回扣已发的道具,所以发货记录必须持久、可查,不能只写进玩家存档就完事。这三点都指向同一个工程要求:订单与发货要有独立的、服务端权威的流水表,别把发货状态塞进玩家的存档里。

12.3.7 内购的测试方法

内购不能靠「打钱」来测。三端都提供沙盒/测试环境:iOS 用 StoreKit 的沙盒账号,Android 用测试轨道与测试卡,微信小游戏用开发者工具的虚拟支付模拟。测试要覆盖的路径有四条:正常购买、支付中途取消、验签失败、掉单后补报。最后一条最容易被漏,却恰恰是线上投诉最多的一类。

沙盒环境还有一个陷阱:沙盒的验签接口与生产不同,服务端要能区分环境。建议在订单里带一个 env 字段,避免测试订单在生产环境被当成真实订单发货。

最后补一句工程纪律:内购代码的每一处「发货」都要能追溯到一条订单流水。上线后如果出现「金币莫名变多」的投诉,第一件事就是拿 orderId 去流水表里查——如果查不到,说明有人绕过了验签;如果查得到但重复了,说明幂等没做对。没有流水表,这类问题只能靠猜,而涉及钱的问题不能靠猜。

12.3.8 商店面板预制体

商店面板 ShopPanel.prefab 放在 assets/prefabs/ 下。以下为它的资源结构摘录。需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。

{
  "__type__": "cc.Prefab",
  "_name": "ShopPanel",
  "data": { "__id__": 1 },
  "optimizationPolicy": 0,
  "persistent": false
}

在编辑器里双击打开,根节点 ShopPanel 挂 UITransform + Widget,子节点是 ScrollView(挂 ScrollView + Sprite),其 content 下按商品数量动态生成 SkuItem 子预制体。每个 SkuItem 挂 UITransform + Sprite,子节点有 Icon(Sprite)、Name(Label)、Price(Label,文案形如「¥6」)、BtnBuy(Button,Click Events 绑定到 IapService 所在节点的方法)。属性检查器里选中 ScrollView,Content 指向 content 节点、Horizontal 不勾选、Vertical 勾选——商店是竖向列表。商品列表不要硬编码在预制体里,而应从 configs/shop.json 读出来动态生成,这样改价格、加商品不用重新编辑预制体。

SkuItem 之所以单独做成预制体、由 ShopPanel 在运行时实例化,是因为商品数量会变。用 instantiate 按列表长度生成、塞进 content 节点,再让 ScrollView 自己算滚动范围——这是 Cocos 里做动态列表的标准做法。注意实例化后要设置每个节点的 parent 为 content,否则它们不会参与布局,ScrollView 也量不到正确的内容高度。

12.3.9 第 12 章收口

第 12 章到此结束。工程里新增三个平台模块(login.ts / share.ts / ads.ts)、一个内购模块(iap.ts 与 iap-recover.ts)、若干 UI 组件与预制体,以及 configs/ 下的 AppID、adUnitId、商品表、第三方 SDK 清单。这一章的代码大部分无法在本地浏览器里真跑——它们依赖平台运行时,只能在对应平台的真机或开发者工具里验证。所以第 16 章会把「冒烟测试」和「截图回归」补上,让多端发布前的验证有章可循。

把第 11、12 章放在一起看,它们解决的是同一件事的两半:第 11 章让游戏「记得住」玩家,第 12 章让游戏「认识」玩家所在的平台。前者靠本地与云端的存档体系,后者靠一层层接口约定把平台差异挡住。两章的代码都有一个共同特征——核心逻辑(迁移、合并、频控、补单)是纯函数或薄封装,可以单测;平台调用被收敛到最小的接口后面,换平台不改业务。这个分层,是后面所有工程化章节的底色。

下一章进入性能优化:先讲清楚 Draw Call 与合批,这是《方块冲刺》在低端机上能不能跑满帧的第一道门槛。而在这之前,建议先把第 11、12 章新增的存档、云同步、平台模块各写一条冒烟路径——它们是发布前的「必过项」,早一天能自动跑,就早一天安心。

阅读导航:上一节:12.2 广告与激励视频 · 下一节:13.1 Draw Call 与合批 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画