《移动游戏产品的架构·商业化与发行工程》1.3 mga-biz-lab 的商业化基线

前两节分别定义了收入线与单位经济,本节把它们固化成一条可直接运行的基线:两份案例夹具、加权渠道分成、净收入与门禁检查。基线对《潮汐消消》与《潮汐方舟》输出可逐字段对比的行,并能在收入合计对不上、平台权重不为 1 或案例 ID 重复时直接报错。

一条命令,两份基线

每周一上午,发行、财务、数据三方要对的其实是同一件事:上周的收入结构有没有变化。但三方各自从不同报表取数,会议的前二十分钟通常花在解释「为什么你的 ARPU 和我的不一样」。

把前两节的口径写成代码之后,对账从「人对人」变成「跑一次基线,看差异」。基线不是 BI 看板,它不负责展示趋势,只负责回答两个问题:这个月的收入结构是什么样,以及这组数字能不能用同一套定义推出来。

mga-biz-lab 的定位是后一个问题的可执行版本:输入两份案例夹具,输出两行固定字段的基线,再用门禁拦住结构性错误。

基线固定五件事

层固定内容缺失或漂移的后果
输入MAU、付费用户、月流水、内购、广告、平台结构缺一项就无法复算,只能回到人工口径
公式付费率、ARPPU、ARPU、内购占比、净收入各部门各算一套,会议变成对数
渠道四个渠道的分成率与加权方式到手收入不可比,跨产品对比失真
输出每个案例一行,字段与顺序固定报表无法逐列对比,只能肉眼找差异
门禁收入合计、权重和、案例 ID 唯一错误数据静默进入月报,事后无法追溯

这五层里最容易省掉的是门禁。很多团队会把口径写成脚本,但不写失败条件;脚本跑通就发月报,跑不通就手工改数。门禁的作用不是让脚本更难跑,而是让「改数」这个动作留下记录。

案例夹具

两份夹具的数值全部来自案例事实表,不做任何推断。平台结构里,消消的海外 7% 按 Google Play 的 30% 计入;这是一个映射约定,不是额外事实。

字段消消方舟
MAU320 万100 万
付费用户8.96 万6.5 万
月流水≈987 万元≈2,820 万元
内购 / 广告≈609 万 / ≈378 万≈2,730 万 / ≈90 万
平台结构安卓 55%、iOS 30%、TapTap 8%、海外 7%iOS 45%、安卓 40%、Google Play 15%

夹具里没有 DAU、CPI、ROI。这不是遗漏,而是基线的作用域:本节只固定「收入怎么分、到手多少」,买量与回收留给后面的发行章节。把太多字段塞进一个基线,会让每次渠道规则变化都触发全量重算,最后没人愿意维护。

加权渠道分成:两种平台结构,一个到手率

先把平台结构折成一个加权分成率:

消消:0.55×40% + 0.30×30% + 0.08×15% + 0.07×30%
    = 22.0% + 9.0% + 1.2% + 2.1% = 34.3%

方舟:0.45×30% + 0.40×40% + 0.15×30%
    = 13.5% + 16.0% + 4.5% = 34.0%

两个产品的平台结构差很多,加权分成却只差 0.3 个百分点:消消的安卓占比高,但 TapTap 的 8% 把均值拉低;方舟的 iOS 占比高,但安卓的 40% 又把它推回去。不能凭「iOS 占比高」推断分成更轻,必须按结构加权。

把加权分成代回收入:

指标消消方舟
内购流水≈609 万元≈2,730 万元
加权分成34.3%34.0%
内购到手≈400.1 万元≈1,801.8 万元
广告净额≈378 万元≈90 万元
净收入≈778.1 万元≈1,891.8 万元
净 ARPU≈¥2.43≈¥18.92

净 ARPU 是基线的最后一列,也是最有用的对比列。方舟 CPI 是 ¥28、净 ARPU 是 ¥18.92,首月单用户净收入低于获客成本,回收依赖后续月份;消消 CPI 是 ¥3.5、净 ARPU 是 ¥2.43,量级接近。与 CPI 比较必须用净 ARPU,毛值里有一部分从来不属于发行方。

把基线写成代码

下面的实现自包含,不导入前两节的产物,原因是实验室文件要能单独拷走运行。它导出两份夹具、加权分成、基线构建、审计与格式化五个部分。

// file: src/lab/biz-lab.ts
// tier: L1
// verify: node --test test/lab/biz-lab.test.ts

export type Channel = "app_store" | "google_play" | "android_cn" | "taptap";

export const PLATFORM_CUT: Readonly<Record<Channel, number>> = {
  app_store: 0.30,
  google_play: 0.30,
  android_cn: 0.40,
  taptap: 0.15,
};

export interface PlatformShare {
  readonly channel: Channel;
  readonly share: number;
}

export interface CaseConfig {
  readonly id: string;
  readonly label: string;
  readonly mau: number;
  readonly payingUsers: number;
  readonly monthlyRevenue: number;
  readonly iapRevenue: number;
  readonly adRevenue: number;
  readonly platforms: readonly PlatformShare[];
}

export interface Baseline {
  readonly id: string;
  readonly label: string;
  readonly revenue: number;
  readonly iapShare: number;
  readonly payRate: number;
  readonly arppu: number;
  readonly arpu: number;
  readonly blendedCut: number;
  readonly netRevenue: number;
  readonly netArpu: number;
}

export interface LabReport {
  readonly baselines: readonly Baseline[];
  readonly findings: readonly string[];
  readonly ok: boolean;
}

const SHARE_TOLERANCE = 1e-6;

function assertRevenue(name: string, value: number): void {
  if (!Number.isFinite(value) || value < 0) {
    throw new RangeError(`${name} must be a finite non-negative number, got ${value}`);
  }
}

export function weightedCut(platforms: readonly PlatformShare[]): number {
  if (platforms.length === 0) {
    throw new Error("weightedCut needs at least one platform");
  }
  let shareSum = 0;
  let cut = 0;
  for (const platform of platforms) {
    if (!Number.isFinite(platform.share) || platform.share < 0) {
      throw new RangeError(`invalid share: ${platform.share}`);
    }
    const rate = PLATFORM_CUT[platform.channel];
    if (rate === undefined) {
      throw new RangeError(`unknown channel: ${String(platform.channel)}`);
    }
    shareSum += platform.share;
    cut += platform.share * rate;
  }
  if (Math.abs(shareSum - 1) > SHARE_TOLERANCE) {
    throw new RangeError(`platform shares must sum to 1, got ${shareSum}`);
  }
  return cut;
}

export function buildBaseline(config: CaseConfig): Baseline {
  if (!Number.isInteger(config.mau) || config.mau <= 0) {
    throw new RangeError(`mau must be a positive integer, got ${config.mau}`);
  }
  if (!Number.isInteger(config.payingUsers) || config.payingUsers < 0 || config.payingUsers > config.mau) {
    throw new RangeError(`payingUsers out of range: ${config.payingUsers}`);
  }
  assertRevenue("monthlyRevenue", config.monthlyRevenue);
  assertRevenue("iapRevenue", config.iapRevenue);
  assertRevenue("adRevenue", config.adRevenue);
  if (config.monthlyRevenue <= 0) {
    throw new RangeError("monthlyRevenue must be positive");
  }
  const blendedCut = weightedCut(config.platforms);
  const netRevenue = config.adRevenue + config.iapRevenue * (1 - blendedCut);
  return {
    id: config.id,
    label: config.label,
    revenue: config.monthlyRevenue,
    iapShare: config.iapRevenue / config.monthlyRevenue,
    payRate: config.payingUsers / config.mau,
    arppu: config.payingUsers === 0 ? 0 : config.iapRevenue / config.payingUsers,
    arpu: config.monthlyRevenue / config.mau,
    blendedCut,
    netRevenue,
    netArpu: netRevenue / config.mau,
  };
}

export function auditBaseline(config: CaseConfig, tolerance = 0.01): readonly string[] {
  if (!Number.isFinite(tolerance) || tolerance < 0 || tolerance > 1) {
    throw new RangeError(`tolerance must be in [0, 1], got ${tolerance}`);
  }
  const notes: string[] = [];
  if (config.monthlyRevenue <= 0) {
    notes.push(`${config.id}:monthly_revenue<=0`);
    return notes;
  }
  if (config.iapRevenue < 0 || config.adRevenue < 0) {
    notes.push(`${config.id}:negative_stream`);
  }
  const streamSum = config.iapRevenue + config.adRevenue;
  if (Math.abs(streamSum - config.monthlyRevenue) / config.monthlyRevenue > tolerance) {
    notes.push(`${config.id}:revenue_mismatch`);
  }
  return notes;
}

export function runLab(configs: readonly CaseConfig[]): LabReport {
  if (configs.length === 0) {
    throw new Error("runLab needs at least one case");
  }
  const baselines = configs.map(buildBaseline);
  const findings = configs.flatMap((config) => [...auditBaseline(config)]);
  const ids = new Set(baselines.map((baseline) => baseline.id));
  if (ids.size !== baselines.length) {
    findings.push("duplicate_case_id");
  }
  return { baselines, findings, ok: findings.length === 0 };
}

export function formatBaseline(baseline: Baseline): string {
  const iapPct = (baseline.iapShare * 100).toFixed(1);
  const payPct = (baseline.payRate * 100).toFixed(2);
  const cutPct = (baseline.blendedCut * 100).toFixed(1);
  return `${baseline.id} iap=${iapPct}% pay=${payPct}% arppu=¥${baseline.arppu.toFixed(2)} arpu=¥${baseline.arpu.toFixed(2)} cut=${cutPct}% net_arpu=¥${baseline.netArpu.toFixed(2)}`;
}

export function formatLab(report: LabReport): string {
  const rows = report.baselines.map(formatBaseline);
  return [...rows, `findings=${report.findings.length} ok=${report.ok}`].join("\n");
}

export const CASE_FIXTURES: readonly CaseConfig[] = [
  {
    id: "chaoxi-xiaoxiao",
    label: "潮汐消消",
    mau: 3_200_000,
    payingUsers: 89_600,
    monthlyRevenue: 9_870_000,
    iapRevenue: 6_090_000,
    adRevenue: 3_780_000,
    platforms: [
      { channel: "android_cn", share: 0.55 },
      { channel: "app_store", share: 0.30 },
      { channel: "taptap", share: 0.08 },
      { channel: "google_play", share: 0.07 },
    ],
  },
  {
    id: "chaoxi-fangzhou",
    label: "潮汐方舟",
    mau: 1_000_000,
    payingUsers: 65_000,
    monthlyRevenue: 28_200_000,
    iapRevenue: 27_300_000,
    adRevenue: 900_000,
    platforms: [
      { channel: "app_store", share: 0.45 },
      { channel: "android_cn", share: 0.40 },
      { channel: "google_play", share: 0.15 },
    ],
  },
];

跑出来的基线

chaoxi-xiaoxiao iap=61.7% pay=2.80% arppu=¥67.97 arpu=¥3.08 cut=34.3% net_arpu=¥2.43
chaoxi-fangzhou iap=96.8% pay=6.50% arppu=¥420.00 arpu=¥28.20 cut=34.0% net_arpu=¥18.92
findings=0 ok=true

每一列都能追到一个定义:iap 是内购除以月流水,pay 是付费用户除以 MAU,arppu 是内购除以付费用户,arpu 是月流水除以 MAU,cut 是平台加权分成,net_arpu 是净收入除以 MAU。最后一行是门禁结果,findings=0 表示两份案例都通过了收入合计检查。

这份输出可以直接粘进周报,但要注意它是「月度快照」:固定自然月、固定平台结构、固定分成率。任何一项变化,比如渠道把分成从 40% 调到 35%,都要先改夹具再重跑,而不是在 Excel 里手改结果。

门禁:什么时候基线算失败

基线的失败分两级。结构性错误直接抛异常,不产出任何一行;业务性差异记为 finding,产出照常生成,但 ok=false。

触发条件行为目的
平台权重和不为 1(容差 1e-6)抛 RangeError防止漏算或重复计算平台
未知渠道抛 RangeError防止新渠道按 0 分成静默通过
mau <= 0 或付费用户大于 MAU抛 RangeError防止分母错误
收入为负或月流水为 0抛 RangeError防止脏数据进入基线
内购加广告与月流水差异超过 1%记 revenue_mismatch抓住分项与合计不同步
案例 ID 重复记 duplicate_case_id防止后一份覆盖前一份

两级失败的区别很重要。结构性错误说明输入不可用,继续算只会得到更精致的错误;业务性差异说明输入可用但可疑,应该让人看一眼再决定。把两者都做成异常,会让小差异阻断整条流水线;都做成 warning,又会让真正的错误混过去。

这套基线不做什么

  • 不含买量成本与 ROI。CPI ¥3.5 / ¥28、D30 ROI 82% / 62% 是发行侧指标,进入基线会让「收入结构」和「投放效率」两个问题缠在一起。
  • 不含退款、掉单、税与汇率。1.2 已经给出这些减项的定义,基线只做到「分成后、减项前」的毛利层。
  • 不含日内波动与活动峰值。月度快照抹平了活动带来的尖峰,适合做版本间对比,不适合解释某一天为什么涨。
  • 不做 cohort 与跨月留存。同一个用户跨月付费会被计入两个自然月,这是月活口径的固有性质,不是基线的缺陷。

代价与取舍

把口径写成代码的代价是维护成本。渠道分成率会变,平台结构会变,案例夹具要跟着改;如果基线没人维护,它会变成一份过期的「权威数字」,比没有基线更危险,因为大家会默认它是对的。

缓解办法是让门禁足够吵:权重和不为 1、收入对不上、ID 重复都直接失败,迫使维护者在合并前处理。另一个办法是让输出足够短:每个案例一行,字段不超过八个。字段越多,越容易有人只改其中一列,留下一条自相矛盾的行。

这套基线牺牲了覆盖面,换来了可对账性。它不能回答「这个月为什么涨」,但能回答「这个月的数字能不能用同一套定义推出来」。对第 1 章来说,后一个问题更基础。

与相邻章节的关系

本章三节到此收口:1.1 把收入分成内购、广告与混合三条线,1.2 把每条线的分子分母钉死,1.3 把两者固化成可运行的基线。第 2 章从这个基线接账号与支付:游客转正、多端登录与订单归属都会复用本节的付费用户与内购流水定义,不再另起一套口径。

小结

  • 基线固定输入、公式、渠道、输出、门禁五层,任何一层漂移都会在结果里留下差异。
  • 消消的平台加权分成是 34.3%,方舟是 34.0%,结构差很多但到手率接近。
  • 净 ARPU 消消约 ¥2.43、方舟约 ¥18.92,与 CPI 比较必须同口径。
  • 门禁分两级:结构性错误直接抛错,业务性差异记为 finding 并保留输出。
  • 基线是月度快照,不做归因、不做 cohort、不含买量与退款。
  • 输出字段越少越可维护,一行八列比一张大表更不容易被手改。

阅读导航:上一节:1.2 ARPU、ARPPU、付费率与 eCPM 的口径 · 下一节:2.1 游客、账号与三端互通的标识设计 。

常见错误与边界

  1. 把毛 ARPU 当净 ARPU 与 CPI 比较。 方舟毛 ARPU ¥28.2、净 ARPU ¥18.92,用毛值算回收会低估获客成本的压力。
  2. 用单渠道分成率替代加权。 消消按 40% 一刀切会少算 34.7 万元/月到手,跨产品对比也会失真。
  3. 认为 iOS 占比高就一定分成轻。 方舟 iOS 占 45%,加权分成 34.0%,只比消消低 0.3 个百分点。
  4. 平台权重不校验和为 1。 漏掉海外 7% 会让分成率偏低,且没有任何报错。
  5. 把买量成本塞进收入基线。 CPI 与 ROI 属于发行侧,混进来会让收入结构的差异无法单独观察。
  6. 用月度基线解释单日波动。 快照抹平了活动峰值,单日涨跌要回到事件级数据。
  7. 门禁只做异常不做 finding。 1% 的收入差异被直接阻断,团队会绕过基线手工改数。
  8. 夹具过期后继续使用。 渠道分成或平台结构变化后不更新夹具,基线会输出一份过期的权威数字。
  9. 案例 ID 不唯一。 两份夹具用同一个 ID,后一份会覆盖前一份的对比位,报表只剩一行。
  10. 把净收入当成可支配现金。 基线还没扣退款、掉单、税与汇率,净收入只是分成后的毛利层。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《移动游戏产品的架构·商业化与发行工程》附录 C 商业化与发行评审清单
  2. 《移动游戏产品的架构·商业化与发行工程》附录 B 双案例商业化事实表
  3. 《移动游戏产品的架构·商业化与发行工程》附录 A 商业化与发行术语速查