一条命令,两份基线
每周一上午,发行、财务、数据三方要对的其实是同一件事:上周的收入结构有没有变化。但三方各自从不同报表取数,会议的前二十分钟通常花在解释「为什么你的 ARPU 和我的不一样」。
把前两节的口径写成代码之后,对账从「人对人」变成「跑一次基线,看差异」。基线不是 BI 看板,它不负责展示趋势,只负责回答两个问题:这个月的收入结构是什么样,以及这组数字能不能用同一套定义推出来。
mga-biz-lab 的定位是后一个问题的可执行版本:输入两份案例夹具,输出两行固定字段的基线,再用门禁拦住结构性错误。
基线固定五件事
| 层 | 固定内容 | 缺失或漂移的后果 |
|---|---|---|
| 输入 | MAU、付费用户、月流水、内购、广告、平台结构 | 缺一项就无法复算,只能回到人工口径 |
| 公式 | 付费率、ARPPU、ARPU、内购占比、净收入 | 各部门各算一套,会议变成对数 |
| 渠道 | 四个渠道的分成率与加权方式 | 到手收入不可比,跨产品对比失真 |
| 输出 | 每个案例一行,字段与顺序固定 | 报表无法逐列对比,只能肉眼找差异 |
| 门禁 | 收入合计、权重和、案例 ID 唯一 | 错误数据静默进入月报,事后无法追溯 |
这五层里最容易省掉的是门禁。很多团队会把口径写成脚本,但不写失败条件;脚本跑通就发月报,跑不通就手工改数。门禁的作用不是让脚本更难跑,而是让「改数」这个动作留下记录。
案例夹具
两份夹具的数值全部来自案例事实表,不做任何推断。平台结构里,消消的海外 7% 按 Google Play 的 30% 计入;这是一个映射约定,不是额外事实。
| 字段 | 消消 | 方舟 |
|---|---|---|
| MAU | 320 万 | 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 游客、账号与三端互通的标识设计 。
常见错误与边界
- 把毛 ARPU 当净 ARPU 与 CPI 比较。 方舟毛 ARPU ¥28.2、净 ARPU ¥18.92,用毛值算回收会低估获客成本的压力。
- 用单渠道分成率替代加权。 消消按 40% 一刀切会少算 34.7 万元/月到手,跨产品对比也会失真。
- 认为 iOS 占比高就一定分成轻。 方舟 iOS 占 45%,加权分成 34.0%,只比消消低 0.3 个百分点。
- 平台权重不校验和为 1。 漏掉海外 7% 会让分成率偏低,且没有任何报错。
- 把买量成本塞进收入基线。 CPI 与 ROI 属于发行侧,混进来会让收入结构的差异无法单独观察。
- 用月度基线解释单日波动。 快照抹平了活动峰值,单日涨跌要回到事件级数据。
- 门禁只做异常不做 finding。 1% 的收入差异被直接阻断,团队会绕过基线手工改数。
- 夹具过期后继续使用。 渠道分成或平台结构变化后不更新夹具,基线会输出一份过期的权威数字。
- 案例 ID 不唯一。 两份夹具用同一个 ID,后一份会覆盖前一份的对比位,报表只剩一行。
- 把净收入当成可支配现金。 基线还没扣退款、掉单、税与汇率,净收入只是分成后的毛利层。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。