《移动游戏产品的架构·商业化与发行工程》9.3 活动结算与数据复盘

活动结束后,参与率和 ARPU 提升只是两个观察值,能不能再办要看结算后的净额。本节以《潮汐消消》42% 参与率、+18% ARPU 提升和《潮汐方舟》61%、+12% 为案例,拆解基线选择、增量收入、活动成本与复盘口径,并给出可运行的结算函数。

结算不是财务报表,是下一次活动的决策依据

活动结束后,运营通常先看两个数字:参与率和 ARPU 提升。

案例事实表给出的组合是:

指标潮汐消消潮汐方舟
活动参与率42%61%
活动 ARPU 提升+18%+12%
活动频率每周 2 个每周 1 个

如果只看这两个数字,很容易得出「消消的活动更成功」或「方舟的玩家更活跃」的结论。但这两个结论都可能错。参与率高不代表增量收入高,ARPU 提升也不代表扣掉成本后仍然赚钱。活动结算要回答的是另一个问题:这次活动带来的增量收入,是否覆盖了它的成本,以及这个结论能不能支持下一次决策。

结算不是财务月报。财务关心的是确认收入和成本归集,运营关心的是「再办一次要不要改」。两者的口径可以共享,但结算函数应该先服务后者:输入可观察的业务量,输出增量收入、成本、净额和是否值得继续。

本节用事实表的 DAU、ARPU、参与率和活动提升率做算术推演。推演中的活动成本是示例参数,不是事实表里的经营数字;它们的用途是演示结算结构,不是宣称案例产品的真实预算。

先定义基线,再谈提升

「ARPU 提升 18%」这句话,至少缺三个限定:相对谁、在什么时间窗、对哪些玩家。

常见的基线有三种:

基线做法优点风险
活动前同期拿活动前 7 天对比简单、易解释受季节性、版本更新影响
匹配对照组找特征相近、未参加活动的玩家更接近因果匹配成本和实现复杂度高
历史同类活动拿上一次同类活动对比可比性强活动之间互相影响,容易高估

案例采用「活动期参与玩家 vs 匹配基线」的口径:参与率 42% 指消消 DAU 中参加活动的比例,ARPU 提升 +18% 指参与玩家在活动期的 ARPU 相对其基线。方舟同理,参与率 61%,提升 +12%。

这个口径有两个重要含义。第一,提升率作用在参与玩家上,不是全体 DAU;把 +18% 直接乘到全量流水上,会高估增量。第二,基线必须和活动期可比:同样的星期几、同样的版本、同样的渠道结构。如果活动期正好赶上版本更新或买量放量,提升率里会混入非活动因素。

归因窗口、点击和浏览的细节属于第 7 章,本节不重复。这里只强调一件事:活动结算的基线不是「活动前的月均」,而是「如果没有这次活动,这批玩家本来会贡献多少」。前者容易算,后者更接近决策需要。

增量收入:从参与人数到金额

结算的第一个公式是把参与率换成人数:

参与人数 = DAU × 参与率
基线日 ARPU = 基线月 ARPU ÷ 30
基线收入 = 参与人数 × 基线日 ARPU × 活动天数
增量收入 = 基线收入 × ARPU 提升率

以消消为例:DAU 80 万、参与率 42%,参与人数约 33.6 万;月 ARPU ¥3.1,折合日 ARPU 约 ¥0.103;活动 7 天,基线收入约 ¥24.3 万;按 +18% 计算,增量收入约 ¥4.37 万。

方舟:DAU 25 万、参与率 61%,参与人数约 15.25 万;月 ARPU ¥28.2,折合日 ARPU 约 ¥0.94;活动 7 天,基线收入约 ¥100.3 万;按 +12% 计算,增量收入约 ¥12.0 万。

这两个结果说明,参与率和提升率不能直接横比。消消的参与人数是方舟的两倍多,但 ARPU 基数低,增量收入反而只有方舟的三分之一左右。方舟的参与率更高、ARPU 更高,单次活动的收入弹性更大;消消的活动更频繁,单次增量小,靠次数累积。

公式里最容易被忽略的是「活动天数」。7 天活动和 3 天活动的增量收入不能直接比较;如果活动延长,基线收入线性增加,但玩家疲劳、边际效果递减也会出现。结算时应该把「日均增量」和「总增量」分开看:总增量决定这次活动赚不赚钱,日均增量决定这个活动值不值得延长。

成本要按边际成本算,不是按面值算

活动成本至少有四块:

成本项内容结算口径
奖励成本邮件、补偿、库存发放的物品按边际成本或获取成本,不按商城标价
固定成本配置、测试、客服、运营人力按活动分摊或按项目计入
补偿成本配置错误、超时、超卖导致的额外发放单独归集,不能混进奖励成本
机会成本活动挤占的其他运营位、玩家疲劳难量化,至少在复盘中定性记录

奖励成本最容易算错。一个标价 ¥68 的道具,边际成本可能接近零;如果按面值计入,活动永远「亏」。反过来,如果是限量实物或需要向合作方采购的奖品,边际成本可能接近面值,甚至还有物流和履约成本。结算口径应该和财务确认过的成本口径一致,而不是运营凭感觉填一个数。

补偿成本必须单独归集。案例里消消的配置错误率是 3.2%,方舟是 1.1%;这些错误会产生额外补偿,但补偿不是活动预算的一部分,而是质量成本。把补偿混进奖励成本,会掩盖配置流程的问题;把补偿完全忽略,又会高估活动净收益。

固定成本的分摊也有取舍。按活动数平均分摊最简单,但每周两个活动的消消和每周一个活动的方舟,单活动分摊差异很大。更合理的做法是按实际投入工时或按活动复杂度分摊,至少要在复盘中说明口径。

净额与 ROI:一个活动值不值得再办

把收入和成本放在一起,就得到结算的核心指标:

净额 = 增量收入 − 总成本
ROI = 增量收入 ÷ 总成本

按本文示例参数(奖励成本与固定成本是演示输入,不是案例事实),两款产品的活动结算是:

指标潮汐消消潮汐方舟
参与人数33.6 万15.25 万
基线收入(7 天)≈¥24.3 万≈¥100.3 万
增量收入≈¥4.37 万≈¥12.0 万
示例总成本¥4.0 万¥7.0 万
示例净额≈¥0.37 万≈¥5.0 万
示例 ROI1.091.72

这张表最重要的不是具体数字,而是结构:消消的活动增量收入小、成本也小,净额薄;方舟的增量收入大、成本高,但净额更厚。如果只看 ROI,方舟更值得加码;如果只看净额,方舟的单次贡献也更高;但如果看「每周活动次数 × 单次净额」,消消靠频率可以缩小差距。

ROI 的使用要小心三个边界。第一,ROI 小于 1 不一定代表活动失败:拉新、留存、品牌活动可能不直接产生增量收入,它们的收益在其他指标里。第二,ROI 大于 1 也不一定代表可以无限加码:活动频率提高后,玩家疲劳和边际递减会让下一次的 ROI 下降。第三,ROI 的分子是增量收入,不是总流水;用总流水除以成本,会得到一个看起来很漂亮但没有决策意义的数字。

复盘看四张表,不只看一张收入表

活动复盘至少要看四类指标:

维度消消方舟说明
触达与参与参与率 42%参与率 61%活动入口、推送、红点是否有效
收入增量ARPU +18%ARPU +12%是否覆盖成本,是否可持续
质量配置错误率 3.2%配置错误率 1.1%活动越多,配置面越大
补偿时效 2 小时时效 4 小时事故收口能力

四张表要一起读。消消的参与率低,但活动频率高、ARPU 提升更高,说明它的活动更偏「轻量高频刺激」;方舟参与率高、提升率低,说明它的活动更偏「覆盖广但单次拉动有限」。消消的配置错误率 3.2% 明显高于方舟的 1.1%,这和每周两个活动的节奏一致;方舟的补偿时效 4 小时更宽,也和它更高单价、更依赖人工复核的流程一致。

复盘不应该只回答「这次活动好不好」,还要回答「下次要不要改」。一个可用的复盘记录至少包含:

  • 活动目标:拉收入、拉参与、拉留存,还是清库存。
  • 基线口径:活动前同期、匹配对照组,还是历史同类活动。
  • 增量收入与成本:分子分母分别是什么。
  • 质量指标:配置错误、补偿时效、客诉量。
  • 决策:加码、原样再办、缩量、停止,或改机制后重试。

没有决策的复盘只是报表。报表可以交给财务,复盘必须交给下一次活动的设计者。

什么时候该停、该加码、该重做

结算结果可以落成四条简单的决策规则:

结算结果质量指标建议
净额为正、ROI 稳定配置错误率在阈值内原样再办或小幅加码
净额为正、ROI 下降参与率仍高改奖励结构,避免疲劳
净额为负、参与率低配置错误率正常停止或重做机制
净额为负、补偿成本高配置错误率超阈值先修发放与配置流程,再谈活动

这四条规则不是绝对的,但能避免两种常见误判。第一种是「参与率高就是成功」:参与率高但增量收入覆盖不了成本,活动只是把原有付费提前了。第二种是「ROI 高就是成功」:ROI 高但样本小、基线不可比,下一次复制时可能完全不成立。

对消消这种每周两个活动的产品,决策周期应该短:一次活动结束后几天内就要决定下一次是否复用。对方舟这种每周一个、单活动更重的产品,决策周期可以长一些,但基线匹配要更严格,因为单次活动的收入占比更高。

把结算写成可运行的纯函数

下面的实现把「参与人数」「基线收入」「增量收入」「成本」「净额」「ROI」串成一个纯函数。它不读取数据库,也不判断活动是否真实发生;输入是结算口径,输出是决策指标。

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

export type SettlementVerdict = "profitable" | "break-even" | "loss";

export interface CampaignSettlementInput {
  readonly campaignId: string;
  readonly dau: number;
  readonly participationRate: number;
  readonly baselineMonthlyArpuCents: number;
  readonly observedLiftRate: number;
  readonly durationDays: number;
  readonly rewardCostCents: number;
  readonly fixedCostCents: number;
}

export interface CampaignSettlement {
  readonly campaignId: string;
  readonly participants: number;
  readonly baselineRevenueCents: number;
  readonly incrementalRevenueCents: number;
  readonly totalCostCents: number;
  readonly netCents: number;
  readonly roi: number;
  readonly verdict: SettlementVerdict;
}

const DAYS_PER_MONTH = 30;

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

function assertRate(name: string, value: number): void {
  if (!Number.isFinite(value) || value < 0 || value > 1) {
    throw new RangeError(`${name} must be between 0 and 1, got ${value}`);
  }
}

export function settleCampaign(input: CampaignSettlementInput): CampaignSettlement {
  if (input.campaignId.trim() === "") {
    throw new RangeError("campaignId must not be empty");
  }
  assertNonNegativeInteger("dau", input.dau);
  assertRate("participationRate", input.participationRate);
  if (!Number.isFinite(input.baselineMonthlyArpuCents) || input.baselineMonthlyArpuCents < 0) {
    throw new RangeError("baselineMonthlyArpuCents must be a finite non-negative number");
  }
  if (!Number.isFinite(input.observedLiftRate) || input.observedLiftRate < -1) {
    throw new RangeError("observedLiftRate must be finite and no lower than -1");
  }
  if (!Number.isFinite(input.durationDays) || input.durationDays <= 0) {
    throw new RangeError(`durationDays must be positive, got ${input.durationDays}`);
  }
  assertNonNegativeInteger("rewardCostCents", input.rewardCostCents);
  assertNonNegativeInteger("fixedCostCents", input.fixedCostCents);

  const participants = Math.round(input.dau * input.participationRate);
  const baselineDailyArpuCents = input.baselineMonthlyArpuCents / DAYS_PER_MONTH;
  const baselineRevenueCents = Math.round(
    participants * baselineDailyArpuCents * input.durationDays,
  );
  const incrementalRevenueCents = Math.round(baselineRevenueCents * input.observedLiftRate);
  const totalCostCents = input.rewardCostCents + input.fixedCostCents;
  const netCents = incrementalRevenueCents - totalCostCents;
  const roi =
    totalCostCents === 0
      ? incrementalRevenueCents === 0
        ? 0
        : Number.POSITIVE_INFINITY
      : incrementalRevenueCents / totalCostCents;
  const verdict: SettlementVerdict =
    netCents > 0 ? "profitable" : netCents < 0 ? "loss" : "break-even";

  return {
    campaignId: input.campaignId,
    participants,
    baselineRevenueCents,
    incrementalRevenueCents,
    totalCostCents,
    netCents,
    roi,
    verdict,
  };
}

export function compareSettlements(
  left: CampaignSettlement,
  right: CampaignSettlement,
): CampaignSettlement {
  if (left.netCents === right.netCents) {
    return left.roi >= right.roi ? left : right;
  }
  return left.netCents > right.netCents ? left : right;
}

export function formatSettlement(settlement: CampaignSettlement): string {
  const roi = Number.isFinite(settlement.roi) ? settlement.roi.toFixed(2) : "inf";
  return `${settlement.campaignId} participants=${settlement.participants} incremental=${settlement.incrementalRevenueCents} cost=${settlement.totalCostCents} net=${settlement.netCents} roi=${roi} verdict=${settlement.verdict}`;
}

代码里有几个刻意的边界。

第一,参与人数用 Math.round 取整。人数是离散的,33.6 万在账本里必须落到具体的人;取整会让结果与手算略有差异,但比保留小数更接近可执行的口径。

第二,基线日 ARPU 用月 ARPU 除以 30。这是一个结算近似,不是自然月天数。用 30 的好处是跨月可比;如果活动跨月,应该改用实际天数,或者在复盘中注明口径。

第三,observedLiftRate 允许负数。活动可能因为机制设计、奖励冲突或玩家疲劳导致收入下降;结算函数不应该假设提升一定为正,否则亏损活动会被算成零。

第四,成本为零时 ROI 返回 Infinity,而不是抛错或返回 0。零成本活动的 ROI 在数学上没有定义,但在决策上「有增量就是无限回报」是可解释的;格式化时输出 inf,避免 NaN 污染报表。

第五,净额恰好为零时判定为 break-even,不是 profitable。这个边界看起来吹毛求疵,但结算报表如果四舍五入后把「刚好打平」写成「盈利」,会诱导团队继续加码。

第六,compareSettlements 先比净额,再比 ROI。净额代表绝对贡献,ROI 代表资金效率;两个活动净额相同时,ROI 高的那个更值得复制。这个排序规则应该在结算层固定,而不是让每个运营自己选。

双案例结算对照

消消的 42% 参与率、80 万 DAU 和 ¥3.1 月 ARPU,决定了它的单次增量收入有限,必须靠每周两个活动的频率累积;方舟的 61% 参与率、25 万 DAU 和 ¥28.2 月 ARPU,决定了它单次活动的收入弹性更大,但配置和补偿的人工成本也更高。两者不能用同一套 ROI 阈值管理。

取舍:对照组让当期收入看起来更低

最严格的结算需要对照组:一部分玩家不参加活动,用来估计「没有活动时他们会贡献多少」。对照组的代价是直接的——这部分玩家在活动期不会产生增量收入,当期报表会变低;运营也会担心「少赚的钱谁负责」。

但不设对照组的代价更隐蔽:你无法区分活动带来的增量和季节性、版本更新、买量放量带来的自然增长。活动做得越多,基线污染越严重,最后所有活动都看起来「有效」,却无法解释为什么整体收入没有同比例增长。

折中的做法是分层:高价值、可复制的活动用对照组;低价值、一次性的活动用活动前同期基线,并在复盘中标注不确定性。这样既控制实验成本,也保留对核心活动的因果判断。

另一个取舍是结算粒度。按活动结算简单,但会掩盖玩家层面的蚕食:一个玩家可能只是因为活动提前充值,活动结束后几个月不付费。按玩家结算更准确,但需要更长的观察窗口和更复杂的数据管道。案例里的月度 ARPU 和活动 ARPU 提升是活动级口径,适合做「要不要再办」的快速判断,不适合做用户终身价值评估。

小结

  • 活动结算回答的是「增量收入是否覆盖成本」,不是「参与率高不高」。
  • 基线要和活动期可比:活动前同期、匹配对照组或历史同类活动,各有适用场景。
  • 增量收入 = 参与人数 × 基线日 ARPU × 活动天数 × 提升率;参与率和提升率不能直接横比。
  • 成本按边际成本计,奖励、固定、补偿、机会成本分开归集;补偿成本是质量成本,不是活动预算。
  • 净额决定绝对贡献,ROI 决定资金效率;两者要一起看,且不能脱离活动频率和样本量。
  • 复盘至少覆盖参与、收入、质量、补偿四张表,并以明确的决策结束。

阅读导航:上一节:9.2 邮件、补偿与库存发放 · 下一节:10.1 商业化埋点体系与事件规范 。

常见错误与边界

  1. 把参与率当成活动成功。参与率高只说明触达有效,增量收入覆盖不了成本时,活动仍然不值得再办。
  2. 把 ARPU 提升率直接乘到全量流水。提升率作用在参与玩家上,直接乘全量 DAU 会系统性地高估增量。
  3. 基线选活动前月均,却不检查可比性。版本更新、买量放量、节假日都会污染基线,提升率可能来自活动之外。
  4. 奖励按商城标价计入成本。虚拟道具的边际成本通常远低于标价,按面值算会让所有活动看起来都亏损。
  5. 补偿成本混进奖励成本。配置错误导致的额外发放是质量成本,混在一起会掩盖流程问题。
  6. 只看 ROI、不看净额。小成本活动可能有很高的 ROI,但绝对贡献很小;大活动相反。
  7. 只看净额、不看 ROI 和频率。单次净额高不代表可以无限复制,玩家疲劳和边际递减会让下一次变差。
  8. 把总流水当增量收入。总流水包含原本就会发生的付费,用它除以成本会得到虚高的回报。
  9. 活动延长后直接线性外推。基线收入会随时长增加,但玩家疲劳和边际效果递减不会保持线性。
  10. 复盘没有决策。只有报表没有「加码、原样、缩量、停止」的结论,下一次活动不会变好。
  11. 用活动级结算替代用户级评估。活动级口径适合快速判断,但会掩盖玩家层面的提前付费和长期流失。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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