10.1 平台 API 抽象:登录 / 支付 / 广告 / 分享 / 云存档

把登录、支付、广告、分享、云存档五类平台能力收敛成一组统一签名:每个方法返回 ok/fail/cancel 三态结果对象而不抛异常,业务代码里没有一处 try/catch。给出可跑的 PlatformAPI 接口定义、确定性 mock 宿主与三条路径都有断言的单测。

站内已有一篇小游戏开发综述 ,那篇是平台能力的单篇综述,把登录、支付、广告、分享各写一段就带过了;本章给的是可跑、可断言的抽象接口 + 两套宿主实现——把「平台能力」变成一份能写单测、能换宿主、能在浏览器里真跑一遍的代码。另有一组 25 篇的小游戏 SaaS 平台 ,那 25 篇是做一个游戏服务平台(SaaS 创业口径:BP / 融资 / 编辑器);本章是在平台上做一款游戏时,如何把平台 API 抽象成可测试的接口,口径完全不同。

到这里为止,卷二已经做完了渲染、资源、循环、输入、物理、存档、内存。这些系统都有一个共同点:它们跑在我们自己的代码里,输入输出都由自己定义。而平台 API 不一样——登录、支付、广告、分享、云存档这五件事,实现权在平台手里,我们只能调用。这一节要做的,就是给这五件事套上一层统一的外壳,让业务逻辑不必知道宿主到底是浏览器、微信还是抖音。

平台 API 的麻烦在于三态

先看清问题的形状。平台 API 的返回值有三种截然不同的形态。第一种是同步返回:浏览器里 navigator.share() 返回一个 Promise,小游戏宿主里 wx.login() 走回调。第二种是失败:网络断了、广告库存空了、平台限流了,调用会失败。第三种最容易被忽略——取消:用户在内购弹窗上点了「取消」,在激励视频还没看完时划走了,分享面板被用户划掉。取消不是失败:失败是我们该重试、该降级、该上报错误的场景,取消是用户主动放弃,重试没有意义,也不该进错误日志。

绝大多数团队会把「取消」硬塞进「失败」,于是埋点里「支付失败率」虚高,运营看到数字以为收银台坏了,实际上是用户点了取消。所以抽象层的第一个决定是:把结果做成三态——ok / fail / cancel,cancel 是独立的第三态,谁都不能省略。

第二个决定是不抛异常。小游戏宿主里 wx.login 是回调式,requestMidasPayment 的取消走 fail 回调而不是 throw,广告加载失败也是回调。如果用异常表达失败,就要在每个调用点包 try/catch,而且回调式的失败根本不会被 try/catch 捕获——你只能把回调里的错误码手动转成 throw,代码立刻变脏。**结果对象(result object)**能统一这两条路:无论同步还是回调,最终都归一成一个 { status, code, value }。

P 层:把五个方法收成一组签名

下面这段是 P 层(内核层)代码,定义 PlatformAPI 接口与它的确定性 mock 宿主。每个方法返回 OpResult,成功带负载、失败与取消 value 为 null,code 保留平台原始错误码。业务代码只依赖这个接口,任何宿主实现它即可注入。

// file: core/src/e10/platform.ts
// tier: P
// verify: node --test 'core/test/e10_*.test.ts'
//
// 平台能力抽象层:把登录 / 支付 / 广告 / 分享 / 云存档五件事收敛成一组统一签名。
// 每个方法返回结果对象(OpResult)而不是抛异常,调用方永远拿到 status ∈ {ok, fail, cancel}
// 三态,业务代码里没有一处 try/catch,也不需要知道宿主到底是 wx / tt / 浏览器还是 mock。
// 约束:只用可擦除语法(禁 enum / namespace / 参数属性 / 装饰器)。

export type OpName = "login" | "pay" | "showAd" | "share" | "cloudSave";
export type OpStatus = "ok" | "fail" | "cancel";

/** 统一的平台调用结果:三态 + 平台码 + 成功时的负载。失败 / 取消时 value 为 null。 */
export interface OpResult<T> {
  readonly op: OpName;
  readonly status: OpStatus;
  readonly code: string;
  readonly value: T | null;
}

export interface LoginValue {
  readonly openId: string;
  readonly nickname: string;
}
export interface PayValue {
  readonly orderId: string;
  readonly amountFen: number;
}
export interface AdValue {
  readonly adUnit: string;
  readonly rewardMs: number;
}
export interface ShareValue {
  readonly channel: string;
  readonly cardId: string;
}
export interface CloudValue {
  readonly slot: string;
  readonly bytes: number;
}

/** 业务逻辑只依赖这个接口;任何宿主(mock / 真机 / 浏览器)实现它即可注入。 */
export interface PlatformAPI {
  login(): OpResult<LoginValue>;
  pay(orderId: string, amountFen: number): OpResult<PayValue>;
  showAd(adUnit: string): OpResult<AdValue>;
  share(channel: string, cardId: string): OpResult<ShareValue>;
  cloudSave(slot: string, payload: string): OpResult<CloudValue>;
}

export function ok<T>(op: OpName, code: string, value: T): OpResult<T> {
  return { op, status: "ok", code, value };
}
export function fail<T>(op: OpName, code: string): OpResult<T> {
  return { op, status: "fail", code, value: null };
}
export function cancel<T>(op: OpName, code: string): OpResult<T> {
  return { op, status: "cancel", code, value: null };
}

/** mock 宿主的脚本:每件事预置一个结果状态,让成功 / 失败 / 取消三条路径都可复现。 */
export interface HostPlan {
  readonly openId: string;
  readonly nickname: string;
  readonly rewardMs: number;
  readonly outcomes: {
    readonly login: OpStatus;
    readonly pay: OpStatus;
    readonly showAd: OpStatus;
    readonly share: OpStatus;
    readonly cloudSave: OpStatus;
  };
}

/** mock 宿主额外暴露的观测口,供单测断言「调用了几次、存了什么」。 */
export interface MockHost extends PlatformAPI {
  calls(): OpName[];
  stored(slot: string): string | null;
}

/** 确定性 mock 宿主:纯内存、无副作用、无 Math.random,供单测与 Node 侧回放使用。 */
export function mockPlatform(plan: HostPlan): MockHost {
  const calls: OpName[] = [];
  const store = new Map<string, string>();
  const decide = <T>(op: OpName, value: T): OpResult<T> => {
    calls.push(op);
    const st = plan.outcomes[op];
    if (st === "ok") return ok(op, "OK", value);
    if (st === "fail") return fail(op, "E_PLATFORM");
    return cancel(op, "E_CANCEL");
  };
  return {
    login: () => decide("login", { openId: plan.openId, nickname: plan.nickname }),
    pay: (orderId, amountFen) => decide("pay", { orderId, amountFen }),
    showAd: (adUnit) => decide("showAd", { adUnit, rewardMs: plan.rewardMs }),
    share: (channel, cardId) => decide("share", { channel, cardId }),
    cloudSave: (slot, payload) => {
      const r = decide("cloudSave", { slot, bytes: payload.length });
      if (r.status === "ok") store.set(slot, payload);
      return r;
    },
    calls: () => calls.slice(),
    stored: (slot) => store.get(slot) ?? null,
  };
}

接口只有五个方法,但每个方法都做了三件事:收敛参数(把平台的 wx.login / tt.login 归一成无参 login())、收敛返回(统一成 OpResult)、收敛副作用(cloudSave 只在成功时写盘,失败与取消一律不写)。注意 cloudSave 的实现里有一行 if (r.status === "ok") store.set(...)——这就是「三态」在代码里的价值:失败和取消天然被排除在副作用之外,不需要额外判断。

HostPlan 是 mock 宿主的「剧本」:它给五件事各预置一个结果状态。mock 宿主不是「假的」,它是一个确定性的宿主——同样的剧本、同样的调用序列,永远得到同样的结果,这正是单测和回放需要的东西。它没有 Math.random(),没有定时器,没有网络,所以它跑在 core/ 里不违反确定性约束。

P 层单测:三条路径都要有断言

光有接口还不够,要证明「成功 / 失败 / 取消都处理了」。下面这段是 P 层单测,逐条覆盖三条路径,并验证失败 / 取消不产生副作用。

// file: core/test/e10_platform.test.ts
// tier: P
// verify: node --test 'core/test/e10_*.test.ts'

import { test } from "node:test";
import assert from "node:assert/strict";
import { mockPlatform, ok, fail, cancel } from "../src/e10/platform.ts";

const plan = (o: {
  login?: "ok" | "fail" | "cancel";
  pay?: "ok" | "fail" | "cancel";
  showAd?: "ok" | "fail" | "cancel";
  share?: "ok" | "fail" | "cancel";
  cloudSave?: "ok" | "fail" | "cancel";
}) => ({
  openId: "u_1024",
  nickname: "岛主",
  rewardMs: 15000,
  outcomes: { login: o.login ?? "ok", pay: o.pay ?? "ok", showAd: o.showAd ?? "ok", share: o.share ?? "ok", cloudSave: o.cloudSave ?? "ok" },
});

test("成功路径:五个方法都返回 ok 且带负载", () => {
  const api = mockPlatform(plan({}));
  const l = api.login();
  assert.equal(l.op, "login");
  assert.equal(l.status, "ok");
  assert.equal(l.code, "OK");
  assert.deepEqual(l.value, { openId: "u_1024", nickname: "岛主" });
  assert.equal(api.pay("o1", 600).value?.amountFen, 600);
  assert.equal(api.showAd("reward-video").value?.rewardMs, 15000);
  assert.equal(api.share("wx", "c1").value?.cardId, "c1");
  const saved = api.cloudSave("slot-0", "island:400");
  assert.equal(saved.status, "ok");
  assert.equal(saved.value?.bytes, 10);
  assert.equal(api.stored("slot-0"), "island:400");
});

test("失败路径:返回 fail 且 value 为 null,不抛异常", () => {
  const api = mockPlatform(plan({ pay: "fail", cloudSave: "fail" }));
  const p = api.pay("o1", 600);
  assert.equal(p.status, "fail");
  assert.equal(p.code, "E_PLATFORM");
  assert.equal(p.value, null);
  assert.equal(api.cloudSave("slot-0", "island:400").status, "fail");
  // 失败不应写盘
  assert.equal(api.stored("slot-0"), null);
});

test("取消路径:返回 cancel 且 value 为 null", () => {
  const api = mockPlatform(plan({ showAd: "cancel", share: "cancel" }));
  const a = api.showAd("reward-video");
  assert.equal(a.status, "cancel");
  assert.equal(a.code, "E_CANCEL");
  assert.equal(a.value, null);
  assert.equal(api.share("wx", "c1").status, "cancel");
});

test("三态互斥:同一结果对象的 status 只可能是 ok/fail/cancel 之一", () => {
  const api = mockPlatform(plan({ login: "fail", showAd: "cancel" }));
  for (const r of [api.login(), api.showAd("reward-video"), api.pay("o", 1), api.share("wx", "c"), api.cloudSave("s", "p")]) {
    assert.ok(["ok", "fail", "cancel"].includes(r.status), `非法状态 ${r.status}`);
  }
});

test("mock 宿主记录调用顺序,便于断言「谁被调了几次」", () => {
  const api = mockPlatform(plan({}));
  api.login();
  api.showAd("reward-video");
  api.login();
  assert.deepEqual(api.calls(), ["login", "showAd", "login"]);
});

test("结果构造器:ok / fail / cancel 三态与 value 语义", () => {
  assert.deepEqual(ok("pay", "OK", { orderId: "o1", amountFen: 1 }), {
    op: "pay", status: "ok", code: "OK", value: { orderId: "o1", amountFen: 1 },
  });
  assert.deepEqual(fail("pay", "E_PLATFORM"), { op: "pay", status: "fail", code: "E_PLATFORM", value: null });
  assert.deepEqual(cancel("pay", "E_CANCEL"), { op: "pay", status: "cancel", code: "E_CANCEL", value: null });
});

这六条断言里,第二条和第三条是这一节的重点:失败路径断言 value === null 且 stored("slot-0") === null(失败不写盘),取消路径断言 status === "cancel" 且 value === null。如果把「取消」硬塞成「失败」,第三条测试根本写不出来——你没有区分二者的能力。第四条「三态互斥」是一道防呆:任何返回值的 status 都必须落在三个合法值里,将来谁不小心返回了一个 "error",测试立刻报红。

实跑输出

在本机跑这段单测,输出是这样的(node --test 的汇总行):

✔ 成功路径:五个方法都返回 ok 且带负载
✔ 失败路径:返回 fail 且 value 为 null,不抛异常
✔ 取消路径:返回 cancel 且 value 为 null
✔ 三态互斥:同一结果对象的 status 只可能是 ok/fail/cancel 之一
✔ mock 宿主记录调用顺序,便于断言「谁被调了几次」
✔ 结果构造器:ok / fail / cancel 三态与 value 语义
ℹ tests 6
ℹ pass 6
ℹ fail 0

六条全绿。这里要强调一句:这段测试跑在 Node 上,没有真机、没有平台 SDK。这正是抽象层的第一个回报——平台能力的最核心逻辑(三态、副作用边界、参数收敛)在完全离线的情况下就被钉死了。真机宿主只是把 mockPlatform 换成「真的去调 wx.login」,接口与断言一个字都不用改。

抽象层把平台差异挡在哪一层

把这一节的抽象层接回全书的脉络,能看清它的位置。6.3 的平台输入差异 把事件层的差异压进了适配器;这一节把能力层的差异压进了 PlatformAPI 的实现类。8.1 的存档与云存档 是五件事里唯一有状态的——它的「版本迁移」逻辑跑在抽象层之上,而「读写云 KV」落在抽象层之下。

也就是说,整条链被切成两截:上面是平台无关的(会话逻辑、存档结构、奖励口径),下面是平台相关的(适配器实现)。适配器是唯一允许出现 wx / tt / window 的地方,其余代码一律不碰。这条边界一旦画清,「一份逻辑、两种宿主」就变成了一句可以验证的话——不是口号,而是一条断言。下一节就来把这条断言真跑出来。

必须补一句边界:本节的 PlatformAPI 只描述我们这一侧要调用的接口形状,不描述平台真实 API 的调用细节。平台侧的 API 名、参数、配额是公开可观察机制(观察时点 2026-10),真实的调用签名与限制须在真机与官方文档上核对,本节不做、也不能做「实测」。抽象层能保证的是我们这一侧的形状稳定,不是平台那一侧的绝对行为。

小结与下一步

这一节把平台能力从「一堆回调」收成了一组签名:五个方法、一个 OpResult、三态 ok/fail/cancel、失败与取消 value 为 null、副作用只在 ok 时发生。配上一个确定性 mock 宿主和六条单测,平台能力的核心逻辑在 Node 上就被钉死了,一行真机代码都不需要。

但抽象层只是「形状」,还没证明「一份逻辑真的能在两种宿主上跑出同一个结果」。下一节是本卷的核心:把同一份会话逻辑分别注入 Node mock 宿主和浏览器宿主,用同一组输入序列驱动,断言两侧的终局摘要逐位相同。

阅读导航:上一节:弱机适配与降级策略 · 下一节:一份逻辑两种宿主:mock 宿主与真机宿主 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制