结算不是财务报表,是下一次活动的决策依据
活动结束后,运营通常先看两个数字:参与率和 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 万 |
| 示例 ROI | 1.09 | 1.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 商业化埋点体系与事件规范 。
常见错误与边界
- 把参与率当成活动成功。参与率高只说明触达有效,增量收入覆盖不了成本时,活动仍然不值得再办。
- 把 ARPU 提升率直接乘到全量流水。提升率作用在参与玩家上,直接乘全量 DAU 会系统性地高估增量。
- 基线选活动前月均,却不检查可比性。版本更新、买量放量、节假日都会污染基线,提升率可能来自活动之外。
- 奖励按商城标价计入成本。虚拟道具的边际成本通常远低于标价,按面值算会让所有活动看起来都亏损。
- 补偿成本混进奖励成本。配置错误导致的额外发放是质量成本,混在一起会掩盖流程问题。
- 只看 ROI、不看净额。小成本活动可能有很高的 ROI,但绝对贡献很小;大活动相反。
- 只看净额、不看 ROI 和频率。单次净额高不代表可以无限复制,玩家疲劳和边际递减会让下一次变差。
- 把总流水当增量收入。总流水包含原本就会发生的付费,用它除以成本会得到虚高的回报。
- 活动延长后直接线性外推。基线收入会随时长增加,但玩家疲劳和边际效果递减不会保持线性。
- 复盘没有决策。只有报表没有「加码、原样、缩量、停止」的结论,下一次活动不会变好。
- 用活动级结算替代用户级评估。活动级口径适合快速判断,但会掩盖玩家层面的提前付费和长期流失。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。