《独立开发者的商业指南·合同、应收与风险》10.1 云、支付、平台的关键依赖

关键依赖不是“用了哪家供应商”,而是“换掉要多久、断了损失多少”。本节把云基础设施、支付通道、分发平台拆成可计算的依赖度与中断暴露,用 Tidecast 的 Steam 80% 和 LedgerLite 的云 60%、支付 25%,算清 30 天与 90 天中断分别吃掉多少收入或成本,并指出成本占比与收入暴露为什么不能混用。

以下情境为教学假设,人物与数字不代表经核实的真实案例。

独立开发者常说“我用了某家云”“我走某个平台”,但这只是供应商清单,不是依赖分析。依赖要回答两个问题:这项服务在收入或成本里占多大比例,以及它中断 30 天、90 天会损失多少钱。第一个问题决定你有多难离开,第二个问题决定你离不起时该准备什么。

这一节把三类最常见的依赖拆开:云基础设施、支付通道、分发平台。三类依赖的合同形态不同,但算法相同:先定分母,再定分子,最后乘上中断天数与损失率。算完会发现一个反直觉的结论:成本占比高的依赖,暴露金额可能很小;收入占比高的依赖,合同里往往没有罚金,却最难替换。

三类关键依赖

云、支付、平台提供的不是同一种东西。云提供运行环境,支付提供收款能力,平台提供曝光与结算。三者中断时,先坏掉的环节不同,替代时间也不同。

依赖类型提供什么断了先发生什么替代时间本书案例
云基础设施计算、存储、数据库、网络服务不可用,写入失败,客户看到错误页同构迁移数小时到数天,异构迁移数周到数月LedgerLite 云支出 18,000 美元,60% 在一家
支付通道收款、退款、对账、订阅扣款新单收不到钱,续费失败,退款积压双通道数天,换通道加对账迁移数周LedgerLite 支付费 12,000 美元,25% 在一家
分发平台曝光、下载、评价、结算下架、限流、结算延迟,销量直接归零换平台数月,重建评价与愿望单更久Tidecast Steam 占销量 80%

三类依赖的合同约束也不一样。云和支付通常有最低承诺、SLA 和数据处理条款;分发平台对独立开发者往往是标准化协议,分成比例、退款规则、推荐机制都不可谈。Tidecast 的 Steam 分成 30%、退款率 4%,这两项加起来把标价收入压到净得率 72.0%;这不是谈判结果,是平台条款。独立开发者能改的只有销量从哪来,改不了条款本身。

替代时间也不是一个数,而是一段区间。同构迁移指新供应商的接口、数据库和部署方式与旧供应商接近,主要工作是搬数据和改配置;异构迁移要重写数据访问层、重做权限和备份,工期可能差一个数量级。云和支付都有同构替代的可能,分发平台几乎没有:Steam 的愿望单、评价、推荐位和退款记录不能原样搬到另一个平台,换平台等于重新积累。所以平台的依赖度不能只看 80%,还要看“换过去以后销量能留下多少”;后者是 10.3 的主题。

Nord Studio 的关键依赖不是供应商,而是转介绍:55% 的收入来自推荐,前 3 家客户占 48%。它没有最低承诺、没有解约罚金,退出成本不是账单,而是把 55% 的收入重新用获客补回来的时间;按年收入 154,320 美元算,这部分是 84,876 美元。渠道依赖没有合同,不等于没有成本,只是成本从罚金变成了获客工时。

依赖度:分子、分母与口径

依赖度的公式很简单:

依赖度 = 经该依赖的收入或成本 ÷ 同类收入或同类成本

难点不在除法,在分母。Tidecast 的 Steam 依赖是 80%,分母是毛销售 191,904 美元,分子是 Steam 毛销售 153,523 美元:

153,523 ÷ 191,904 = 80.0%

LedgerLite 的云依赖是 60%,分母是云支出这一行 18,000 美元,分子是其中走同一家的 10,800 美元;支付依赖是 25%,分母是支付费 12,000 美元,分子是其中走同一家的 3,000 美元。这两个 60% 和 25% 都不是“占年收入的比例”,而是“占该成本行的比例”。

依赖分子分母依赖度这个数字回答什么
Steam$153,523$191,90480.0%多少销售走一个平台
云$10,800$18,00060.0%多少云支出集中在一家
支付$3,000$12,00025.0%多少支付费集中在一家
云中断的收入$240,240$240,240100%云停了,多少收入跟着停

最后一行是压力口径,不是成本口径。云支出 60% 集中在一家,只能说明“换供应商要重做六成云账单”;它不能说明“云停了只损失六成收入”。LedgerLite 是订阅 SaaS,云一停,所有客户的登录、对账、报税协作都停,收入暴露接近 100%,不是 60%。把成本集中度当成收入暴露,是这一节最容易犯的错。

口径混用的后果可以直接算。按成本口径,云 60% 对应年暴露 10,800 美元;按收入口径,云中断对应年暴露 240,240 美元,再乘 50% 的损失率是 120,120 美元。两者差 11 倍。不是说哪个数字错了,而是它们回答不同问题:前者是“迁移账单”,后者是“停服损失”。做预算时两个都要,但不能相加,也不能互相替代。

分子也要选对。Tidecast 的 80% 可以用毛销售算,也可以用净得算:Steam 毛销售 153,523 美元占总毛销售 191,904 美元的 80.0%;Steam 净得 103,167 美元占总净得 138,171 美元的 74.7%。前者回答“多少销售走这个平台”,后者回答“多少到手收入靠这个平台”。两个都对,但谈判和风险预算要用净得,因为平台分成和退款不会在你失去平台后还给你。LedgerLite 同理:云支出 18,000 美元是成本,收入 240,240 美元是收入,把两者放进同一个百分比里比较,等于拿尺子量重量。

中断 30 天与 90 天的暴露

暴露的公式是:

中断暴露 = 年暴露金额 × 中断天数 ÷ 365 × 中断损失率

中断损失率是教学假设,不是统计值。它表示中断期间有多大比例的收入真的拿不回来,或有多大比例的成本要额外再付一次。Steam 取 75%,因为大部分销售会错过窗口,只有少量玩家会转到官网;云成本取 100%,因为旧账单照付,还要临时买应急资源;支付成本取 100%,因为失败扣款要重新处理;支付收入取 50%,因为部分扣款可以延后;云收入取 50%,因为短期停服会流失一部分客户,但不会全丢。

依赖年暴露金额30 天暴露90 天暴露
Tidecast Steam 收入$77,375$6,360$19,079
LedgerLite 云成本$10,800$888$2,663
LedgerLite 云收入(压力)$120,120$9,873$29,619
LedgerLite 支付成本$3,000$247$740
LedgerLite 支付收入$30,030$2,468$7,405

先看 Tidecast。Steam 年净得 103,167 美元,按 75% 损失率算,年暴露 77,375 美元;30 天暴露 6,360 美元,90 天暴露 19,079 美元。19,079 美元相当于年净得 138,171 美元的 13.8%。如果中断落在 Q4,Q4 占全年销量 38%,同样 90 天的实际损失会明显高于线性折算,因为圣诞窗口错过就不会再来。

再看 LedgerLite。云成本的 90 天暴露只有 2,663 美元,支付成本只有 740 美元,看起来都很小。但云收入的压力口径是 90 天 29,619 美元,是云成本暴露的 11 倍。支付收入的 90 天暴露是 7,405 美元,也远大于支付成本的 740 美元。成本依赖和收入依赖的金额不在一个量级,原因不是计算错误,而是分母不同:成本行的金额本来就小,收入行的金额才是业务的主体。

这张表还有一个用途:比较“先修哪个”。如果只有一笔预算,先修收入暴露最大的那条,而不是依赖度最高的那条。LedgerLite 的云依赖度 60% 高于支付 25%,但云收入 90 天暴露 29,619 美元,支付收入 90 天暴露 7,405 美元,差距没有依赖度看起来那么大;因为支付的 25% 直接对应收入份额,而云的 60% 只是成本集中度,收入暴露要单独按 100% 算。依赖度告诉你集中在哪里,暴露告诉你钱在哪里,两个排序不一定一致。

90 天不是 30 天的三倍。模型只做线性折算,实际 90 天会触发替代供应商、客户退款、评价下滑和续费流失,这些二阶效应不在公式里。所以 90 天那一列应当当成下限,不是上限。真正要放进风险登记册的,是“30 天能不能恢复”这个二值问题;90 天的数字用来提醒你,拖过一个季度,损失会从现金流问题变成客户关系问题。

把依赖度写成代码

下面的 dependency.ts 把两个口径分开:measureDependency 用 baseCents 和 dependentCents 算依赖度,用 exposureCents 和 outageLossBps 算暴露;rankDependencies 只按依赖度排序,不按金额排序。这样就不会把“成本行里的大比例”误当成“收入里的大金额”。

// file: src/contract/dependency.ts
// tier: L1
// verify: node --test test/contract/dependency.test.ts

import { type Cents } from "../core/money.ts";
import { type Bps, applyRate, divide } from "../core/rate.ts";
import { type Result, fail, ok } from "../core/result.ts";

export type DependencyKind = "cloud" | "payments" | "distribution";

export type DependencyInput = {
  kind: DependencyKind;
  label: string;
  baseCents: Cents;
  dependentCents: Cents;
  exposureCents: Cents;
  outageLossBps: Bps;
};

export type DependencyProfile = {
  kind: DependencyKind;
  label: string;
  dependencyBps: Bps;
  annualExposureCents: Cents;
  dailyExposureCents: Cents;
  exposure30Cents: Cents;
  exposure90Cents: Cents;
};

const KINDS: DependencyKind[] = ["cloud", "payments", "distribution"];

function checkAmount(value: Cents, label: string): string | null {
  if (!Number.isInteger(value) || value < 0) {
    return `${label} must be a non-negative integer amount in cents`;
  }
  return null;
}

function checkBps(value: Bps, label: string): string | null {
  if (!Number.isInteger(value) || value < 0 || value > 10000) {
    return `${label} must be between 0 and 10000 bps`;
  }
  return null;
}

function validate(input: DependencyInput): string | null {
  if (!KINDS.includes(input.kind)) {
    return "unknown dependency kind";
  }
  if (input.label.trim() === "") {
    return "dependency label must not be empty";
  }
  const baseError = checkAmount(input.baseCents, "dependency base");
  if (baseError) {
    return baseError;
  }
  if (input.baseCents === 0) {
    return "dependency base must be positive";
  }
  for (const [value, label] of [
    [input.dependentCents, "dependent amount"],
    [input.exposureCents, "exposure amount"],
  ] as Array<[Cents, string]>) {
    const error = checkAmount(value, label);
    if (error) {
      return error;
    }
  }
  if (input.dependentCents > input.baseCents) {
    return "dependent amount must not exceed the dependency base";
  }
  return checkBps(input.outageLossBps, "outage loss");
}

/**
 * Dependency has two denominators. The ratio answers how much of a revenue or
 * cost category flows through one vendor; the exposure answers how much money
 * is at risk when that vendor fails. They are separate on purpose: a vendor can
 * be 60% of a small cost line and still stop 100% of the revenue that depends
 * on the service being online.
 */
export function measureDependency(
  input: DependencyInput,
): Result<DependencyProfile> {
  const error = validate(input);
  if (error) {
    return fail(error);
  }

  const annualExposureCents = applyRate(
    input.exposureCents,
    input.outageLossBps,
  );

  return ok({
    kind: input.kind,
    label: input.label,
    dependencyBps: divide(input.dependentCents, input.baseCents),
    annualExposureCents,
    dailyExposureCents: Math.round(annualExposureCents / 365),
    exposure30Cents: Math.round((annualExposureCents * 30) / 365),
    exposure90Cents: Math.round((annualExposureCents * 90) / 365),
  });
}

/**
 * Ranking uses the dependency ratio, not the absolute exposure. The report
 * keeps both numbers so a high ratio on a small cost line cannot be confused
 * with a large revenue exposure.
 */
export function rankDependencies(
  inputs: DependencyInput[],
): Result<DependencyProfile[]> {
  const profiles: DependencyProfile[] = [];
  for (const input of inputs) {
    const result = measureDependency(input);
    if (!result.ok) {
      return fail(result.error);
    }
    profiles.push(result.value);
  }
  profiles.sort((a, b) => {
    if (b.dependencyBps !== a.dependencyBps) {
      return b.dependencyBps - a.dependencyBps;
    }
    return a.label.localeCompare(b.label);
  });
  return ok(profiles);
}

把案例数字代进去:Steam 的依赖度是 8,000 基点,年暴露 7,737,525 美分,30 天 635,961 美分,90 天 1,907,883 美分;云成本的依赖度是 6,000 基点,年暴露 1,080,000 美分,30 天 88,767 美分,90 天 266,301 美分;支付成本的依赖度是 2,500 基点,年暴露 300,000 美分,30 天 24,658 美分,90 天 73,973 美分。云收入压力口径的依赖度是 10,000 基点,年暴露 12,012,000 美分,30 天 987,288 美分,90 天 2,961,863 美分。

代码里有三个刻意限制。第一,依赖度和暴露用不同的输入字段,避免把成本占比直接乘到收入上;第二,dependentCents 不能超过 baseCents,因为比例超过 100% 通常意味着分母选错了;第三,30 天和 90 天都按年暴露线性折算,不模拟替代供应商、退款和客户流失,这些二阶效应要另做情景。rankDependencies 只按依赖度排序,如果按金额排序,Steam 和云收入会排在前面,成本行会被挤到后面,那就看不到“成本集中度”这个维度了。

小结

  • 关键依赖要回答两个问题:占收入或成本的多少比例,以及中断 30 天、90 天损失多少钱;两个问题不能用一个数字回答。
  • Tidecast 的 Steam 占毛销售 80.0%,但只占净得 74.7%;平台分成 30% 和退款 4% 把净得率压到 72.0%,这些条款不可谈,能改的只有销量来源。
  • LedgerLite 的云依赖是云支出行的 60.0%,支付依赖是支付费行的 25.0%;这两个数字不是收入占比。
  • 云成本 90 天暴露只有 2,663 美元,但云收入压力口径的 90 天暴露是 29,619 美元,差 11 倍;成本口径和收入口径不能混用。
  • Tidecast 的 Steam 收入 90 天暴露是 19,079 美元,相当于年净得 138,171 美元的 13.8%;落在 Q4 时实际损失更高。
  • 依赖度排序和暴露排序不一定一致:先修收入暴露最大的依赖,而不是依赖度最高的依赖。
  • Nord Studio 的转介绍依赖 55% 没有合同和罚金,退出成本是把这部分收入重新用获客补回来的时间,按年收入算约 84,876 美元。
  • 90 天暴露是下限,不是上限;线性模型不含替代延迟、退款、评价下滑和续费流失。

阅读导航:上一节:9.3 渠道合同与退出 · 下一节:10.2 供应商合同与退出条款 。

常见错误与边界

  1. 把供应商清单当依赖分析。 列出“用了哪家云、哪家支付”不等于知道依赖度;没有分子、分母和暴露金额,清单不能排序。
  2. 把成本占比当收入暴露。 云支出 60% 集中在一家,不等于云停了只损失 60% 收入;LedgerLite 的收入暴露接近 100%,两者差 11 倍。
  3. 跨分母比较依赖度。 Steam 的 80% 分母是毛销售,云的 60% 分母是云支出,支付的 25% 分母是支付费;三个百分比不能直接排名。
  4. 只用毛收入算平台暴露。 Steam 的 80% 是毛销售占比,真正损失的是净得 103,167 美元;用毛收入 153,523 美元会高估约 49%。
  5. 忽略平台条款的不可谈性。 Steam 的 30% 分成、4% 退款和推荐机制不是谈判项;把“和平台谈更好的分成”写进计划,等于没有计划。
  6. 把 90 天当 30 天的三倍。 线性折算不含替代延迟、退款、评价下滑和续费流失;90 天那一列是下限。
  7. 只看年暴露,不看季节。 Tidecast 的 Q4 占全年销量 38%,同样 90 天落在 Q4 的损失远高于月均;年化数字会抹平季节。
  8. 忽略支付依赖的收入侧。 支付费只占成本一小行,但支付通道停了,走该通道的那部分收入也停;LedgerLite 的支付收入 90 天暴露是 7,405 美元,不是 740 美元。
  9. 把 Nord 的转介绍当成免费渠道。 55% 的转介绍没有合同和罚金,但断了要用获客补回来;按年收入算约 84,876 美元,不是 0。
  10. 算完暴露不设阈值。 没有“30 天恢复”与“90 天恢复”的分界,暴露数字只是一张表;要先定可接受的中断天数,再决定是否准备冗余。

本节的边界是“单一依赖、线性中断、损失率外生”的静态模型。它假设中断天数已知、损失率由教学档位给定、替代供应商在同构环境下可用,不含跨云数据迁移的真实工期、不含平台封号后的申诉周期、不含客户合同里的 SLA 抵扣连锁反应。它足够回答“哪条依赖先修、30 天和 90 天大概损失多少”,但不足以回答“平台永久封号后业务还能不能继续”。后者要把平台结算余额放进破产受偿和合同终止一起看,见 4.3 对方破产或失联:能拿回多少 ;渠道合同的退出条款见 9.3 渠道合同与退出 。客户集中度的财务口径见 7.2 集中度风险:前五大客户占比 。本节不构成法律意见,具体条款请由律师审阅。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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