12.3 内购与合规
内购(IAP)比广告复杂得多,原因不在于技术,而在于它牵涉钱。钱一旦流动,就要求「订单可追溯、发货不重复、失败可退款」,同时还要满足各地监管对实名、防沉迷、消费限额的要求。这一节把内购的客户端骨架和合规清单一次讲清——服务端实现仍不在本卷范围,但接口约定与调用时序会写到可以直接照着对接。
12.3.1 内购的商品形态
先把商品分类,因为三类商品的服务端逻辑完全不同:
| 类型 | 例子 | 是否可重复购买 | 服务端处理 |
|---|---|---|---|
| 消耗型 | 金币包、道具包 | 可 | 每次订单都发货 |
| 非消耗型 | 去广告、永久皮肤 | 否 | 发货前查「是否已拥有」 |
| 订阅 | 月卡、通行证 | 到期可续 | 记录到期时间、处理续订与退款 |
本项目先做消耗型 + 非消耗型两类。订阅留到运营阶段——它需要处理续订回调、到期提醒、退款,是独立的一大块。
商品设计上有个容易忽略的点:非消耗型的「已拥有」状态必须由服务端维护。玩家买了「去广告」,换台设备登录后应当自动生效——如果这个状态只存在本地存档里,换设备就丢了,玩家会来投诉。所以非消耗型商品在服务端是「账号级的一次性标记」,而消耗型商品是「可累加的余额」。两者在数据模型上根本不同,别混在一张表里。
价格展示则遵循一个原则:客户端只展示,不计算。商品列表从服务端拿,每个商品带上平台返回的本地化价格字符串(如「¥6.00」「$0.99」),客户端原样显示。任何「美元价乘以汇率」的本地计算都可能在促销、税差、区域定价下出错。
12.3.2 订单流程与时序
内购的铁律是:客户端永远不发货,只做 UI。完整的正确时序是:
- 玩家在商店点「购买」,客户端把
skuId发给服务端:POST /iap/order,body 为{ skuId }。 - 服务端创建订单、返回
orderId与平台所需的商品标识。 - 客户端调平台支付接口(iOS 走 StoreKit、Android 走渠道 SDK、微信走虚拟支付),拿到平台回执。
- 客户端把回执
POST给服务端:/iap/verify,body 为{ orderId, receipt }。 - 服务端向平台验签,验签通过才发货(写进云端存档),并把订单标记为已完成。
- 客户端收到「已发货」后刷新 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。玩家投诉「充了钱没到账」,而你手里没有任何记录。
处理掉单有两条防线:
- 客户端补报:每次启动时,把本地缓存的「未确认订单」(
orderId+ 回执)重新POST /iap/verify一次。回执要持久化——存进 11.1 的存档或单独的键,不能只放内存。 - 服务端主动对账:定期向平台拉取「已支付但未发货」的订单,补发货。这是兜底,因为玩家可能卸载重装,客户端再也没机会补报。
对应地,服务端必须支持幂等发货:同一 orderId 重复 verify 只发一次货,第二次返回「已发货」。这样客户端大胆重试也不会重复发奖。客户端的补报逻辑因此可以写得很「激进」——不确定就重报,反正服务端会去重。
补报的触发时机要选在「玩家还没进入付费场景之前」:启动加载完成、进主菜单时跑一次。这样即便上一次购买掉了单,玩家在打开商店前就已经看到道具到账,而不是进了商店才发现「我明明买过却没有」。补报本身要静默执行,成功不弹提示,避免打扰正常玩家。
12.3.4 平台差异
三端的内购入口完全不同,这是客户端唯一需要分平台的地方:
| 平台 | 支付通道 | 客户端拿到的回执 | 注意 |
|---|---|---|---|
| iOS | App Store IAP | transactionReceipt | 虚拟商品必须走苹果内购,否则拒审 |
| Android | Google 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 与合批 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。