《独立开发者的商业指南·资本与退出》8.3 代码、域名与数据交割

代码交割不是拉一个仓库压缩包,而是仓库权限、CI/CD、域名与 DNS、数据库备份、密钥轮换五项同时转移。本节用 LedgerLite 的 768,768 美元交易算出停机 1 天的收入损失 658.19 美元、SLA 抵扣 2,002 美元与买方扣款 3,843.84 美元,并说明密钥轮换与权限回收为什么必须在交割后 24 小时内完成。

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

代码交割最容易出现的误会,是把它当成“把仓库转给买方”。仓库只是五项里的一项。买方真正要接手的是:代码怎么构建、怎么部署、域名解析到哪里、数据怎么恢复、密钥由谁持有。只转仓库不转 CI/CD,买方拿到的是不能运行的代码;只转域名不转 DNS,网站会在 48 小时内失效;只转数据库不转密钥,买方要么登不进去,要么继续用卖方的凭据。

对独立开发者,交割的技术风险通常不在“代码写得好不好”,而在“控制权有没有真正换手”。LedgerLite 是一人公司,代码、云账号、支付通道、域名和数据库可能都绑在同一个个人邮箱上;Tidecast 是买断制游戏,代码和美术可以交,Steam 账号却不能过户。两种形态的交割清单不同,但都绕不开同一个动作:交割后 24 小时内轮换密钥、回收权限。所有金额、工时与费率都是教学假设。

仓库、权限与 CI/CD

仓库交割的第一件事不是复制代码,而是确认“仓库归谁”。如果仓库在个人账号下,要先转到组织或新主体;如果在组织下,要转组织所有权、改账单联系人、改管理员。代码本身可以克隆,但 issue、pull request、release、package、secret 和 deploy key 不会跟着克隆走;这些才是买方接手后真正要用的东西。

领域交割内容常见坑计划工时
仓库与权限组织所有权、管理员、分支保护、deploy key仓库在个人账号下,转组织后 CI 失效6 小时
CI/CD流水线所有权、环境变量、部署密钥、制品库密钥写在 CI 变量里,转仓库后仍可部署8 小时
域名与 DNS注册人、授权码、DNS 记录、证书TTL 太长,切换后解析仍旧指向旧站4 小时
数据库与备份逻辑导出、恢复演练、备份保留、PII 清理只导出不演练,恢复时缺扩展或序列6 小时
密钥轮换API key、OAuth token、SSH key、数据库密码只改密码,不撤销旧 token5 小时

CI/CD 是最容易被低估的一项。流水线里通常藏着三类东西:部署凭据、环境变量和制品库令牌。把仓库转给买方,不等于这些凭据自动失效;如果卖方没有主动撤销,旧成员仍然可以通过 CI 部署到生产。买方在尽调时问“谁能部署”,卖方常回答“只有我”;交割后要能证明“只有买方能部署”,这需要撤销旧凭据、重建流水线、重新授权。

分支保护和管理员权限也要单独检查。仓库转让后,原管理员可能仍是管理员;合同里写着“交割后卖方不再访问”,系统里却还留着权限。权限回收不是“改个密码”,而是逐项撤销:组织成员、团队、仓库协作者、deploy key、personal access token、OAuth app、CI secret、云角色。少一项,就多一个交割后仍能进入系统的入口。

基础设施即代码(IaC)的状态文件要特别小心。Terraform state、Kubernetes secret、云模板里可能直接包含数据库密码和 API key;把这些文件交给买方,等于把生产凭据交出去。正确顺序是:先转移代码和状态文件的所有权,再在买方环境里重建密钥,最后撤销旧密钥。顺序反了,密钥会在转移过程中暴露。

域名、DNS 与数据库备份

域名是交割里最“不可逆”的资产之一。注册人转移通常要 5–7 天:解锁域名、获取授权码、确认管理员邮箱、接受转入请求。转移期间 DNS 不应中断,但注册商的后台可能短暂不可用;交割日要避开流量高峰,也不要和支付切换放在同一天。

DNS 的关键参数是 TTL。切换前 48 小时把 TTL 降到 300 秒,让旧记录可以快速失效;切换后再把 TTL 调回正常值。DNSSEC 要在转移前确认签名链,转移后重新签名;邮件记录(SPF、DKIM、DMARC)要逐条核对,否则迁移后事务邮件会进垃圾箱。证书要在新环境重新签发,不要复制旧私钥。

DNS 动作提前量检查项失败后果
降低 TTL切换前 48 小时所有 A/CNAME/MX 记录切换后旧解析停留过久
转移注册人切换前 5–7 天授权码、管理员邮箱、解锁状态域名转不过去
重建 DNS切换日A、CNAME、MX、TXT、DNSSEC网站或邮件中断
重签证书切换日域名验证、私钥不复制HTTPS 告警
验证邮件切换后 24 小时SPF、DKIM、DMARC事务邮件进垃圾箱

数据库交割的核心不是“导出一份 SQL”,而是“买方能不能独立恢复”。导出要在只读副本或低峰期做,记录行数、序列、扩展、字符集和时区;恢复演练要在买方环境里跑一遍,确认应用能启动、能登录、能读到最近的数据。只导出不演练,交割后第一次恢复才发现缺扩展,停机时间会算在买方头上。

备份交割还要处理保留与删除。买方通常要历史备份,卖方则要删掉自己手里的客户数据;两边的时间点要在合同里写清。PII 的删除不是“删库”,而是“删备份、删导出、删日志、删分析平台里的副本”。如果卖方在交割后还留着客户数据,买方会认为数据治理没有完成,托管释放可能被扣。

数据交割还包括第三方系统里的数据:支付通道的客户和订阅记录、客服工单、邮件列表、分析事件、对象存储附件。这些数据不在主数据库里,却直接影响客户体验。迁移清单要按“系统 × 数据类别 × 负责人”列,而不是只列一个数据库。

密钥轮换与权限回收:交割后 24 小时

交割后 24 小时是技术交接的“止血窗口”。原因很直接:卖方团队、外包、离职员工、旧 CI 流水线、个人脚本里可能还留着生产凭据。交割前不轮换,买方继承的不仅是一套系统,还有一套卖方仍然能进入的入口。合同里的“交割后不得访问”是法律约束,不是技术约束;技术约束只能靠轮换和回收。

动作对象教学假设数量单位工时总工时
轮换密钥API key、OAuth token、SSH key、数据库密码12 个10 分钟2.0 小时
回收权限组织成员、协作者、deploy key、CI 账号18 个5 分钟1.5 小时
合计30 个动作3.5 小时

12 个密钥、18 个账号、3.5 小时,是这笔交易的教学假设。真实数量可能更多:一个 SaaS 产品通常有云访问密钥、数据库密码、支付 API key、邮件服务 key、对象存储 key、监控 token、CI secret、OAuth client secret,每一项还可能有测试环境和生产环境两套。密钥轮换要按“生产优先、读权限优先、对外暴露优先”排序,不能平均用力。

24 小时不是“建议”,而是交割条件。买方在合同里常写“交割后 24 小时内完成密钥轮换与权限回收”,并把托管释放或一部分价款挂在完成证明上。卖方要在交割日准备好密钥清单、账号清单、轮换脚本和验证记录;交割后按清单逐项执行,把“已轮换/已回收”的截图或日志交给买方。没有清单,24 小时会变成 24 天。

轮换的顺序也有讲究。先转移所有权,再轮换密钥,最后回收旧权限;如果先回收再转移,买方可能进不去系统;如果先轮换再转移,旧密钥可能在新所有权生效前失效,导致部署中断。对数据库,先创建新用户、验证连接,再禁用旧用户;对支付通道,先创建新 API key、切换应用,再撤销旧 key。每一步都要有回退路径。

权限回收要覆盖“人”和“机器”。人包括组织成员、仓库协作者、云账号、客服后台、分析后台;机器包括 deploy key、CI token、OAuth app、webhook secret、cron 任务、监控告警。机器权限最容易被漏掉,因为它们不在“成员列表”里。交割清单要单独列“机器身份”,并逐项确认已撤销或已转给买方。

停机 1 天的收入损失与买方扣款

迁移期间停机 1 天,成本分三块:收入损失、SLA 抵扣、买方扣款。收入损失按日收入算;SLA 抵扣按月度费用的约定比例算;买方扣款按交易价格的一定比例算。三块可以同时发生,不能只算其中一块。

项目基数费率或天数金额
收入损失658.19/天1 天658.19
SLA 抵扣20,020/月10%2,002.00
买方扣款768,7680.5%3,843.84
计划交割工时29 小时150/小时4,350.00
密钥轮换2 小时150/小时300.00
权限回收1.5 小时150/小时225.00
合计11,379.03

LedgerLite 的日收入是 240,240 ÷ 365 = 658.19 美元;月收入 20,020 美元,SLA 抵扣 10% 是 2,002 美元;买方按价格 0.5% 扣款是 3,843.84 美元。三项合计 6,504.03 美元,加上计划工时 4,350 美元、轮换 300 美元、回收 225 美元,总成本 11,379.03 美元,占价格 768,768 美元的 1.48%。

买方扣款是教学假设的 0.5%,真实交易可能是 0.5%–2%,也可能写成“每停机 1 小时扣 1% 托管”。对一笔 768,768 美元的交易,1% 扣款是 7,687.68 美元,2% 是 15,375.36 美元;停机时间越长,扣款越接近托管金额。卖方在谈判时要问清“扣款基数是什么、上限是多少、是否与 SLA 抵扣叠加”,而不是只接受一个百分比。

Tidecast 的停机风险不同。它 80% 销量来自 Steam,平台负责分发和托管,代码交割本身不会让商店页下线;真正的风险是账号和发行协议切换时的结算暂停。

Tidecast 的净得是 138,171 美元,日净得约 378.55 美元;一天停售的损失小于 LedgerLite,但平台账号不可转让,买方要重新签协议,切换期可能更长。

对独立开发者,停机的“收入损失”往往小于“买方扣款”和“SLA 抵扣”。这也是为什么交割前要做恢复演练、DNS 预切换和密钥预创建:花 3,000 美元的准备工作,可能避免 11,379 美元的停机账单。准备工作不是额外成本,而是停机成本的替代。

写成代码:code-handover.ts

下面的 code-handover.ts 把五类交割项、密钥轮换、权限回收和停机成本放在同一个函数里。codeHandover 先检查五个领域是否都有条目、是否全部完成;再把计划工时、轮换工时、回收工时分别乘以小时费率;最后加收入损失、SLA 抵扣和买方扣款。urgentActions 是密钥数加账号数,urgentDeadlineHours 固定为 24,提醒这部分必须在交割后一天内完成。

// file: src/capital/code-handover.ts
// tier: L1
// verify: node --test test/capital/code-handover.test.ts

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

export type HandoverArea =
  | "repo-access"
  | "ci-cd"
  | "domain-dns"
  | "database-backup"
  | "secret-rotation";

export const HANDOVER_AREAS: HandoverArea[] = [
  "repo-access",
  "ci-cd",
  "domain-dns",
  "database-backup",
  "secret-rotation",
];

export type HandoverItem = {
  area: HandoverArea;
  label: string;
  hours: number;
  done: boolean;
};

export type HandoverInput = {
  priceCents: Cents;
  dailyRevenueCents: Cents;
  monthlyRevenueCents: Cents;
  downtimeHours: number;
  slaCreditBps: Bps;
  buyerDeductionBps: Bps;
  hourlyRateCents: Cents;
  items: HandoverItem[];
  secretsToRotate: number;
  minutesPerSecret: number;
  accountsToRevoke: number;
  minutesPerAccount: number;
};

export type AreaProgress = {
  total: number;
  done: number;
  pending: number;
};

export type HandoverOutcome = {
  itemCount: number;
  doneCount: number;
  pendingCount: number;
  missingAreas: HandoverArea[];
  areaProgress: Record<HandoverArea, AreaProgress>;
  readyForHandover: boolean;
  laborHours: number;
  laborCostCents: Cents;
  rotationHours: number;
  rotationCostCents: Cents;
  revocationHours: number;
  revocationCostCents: Cents;
  revenueLossCents: Cents;
  slaCreditCents: Cents;
  buyerDeductionCents: Cents;
  totalCostCents: Cents;
  urgentActions: number;
  urgentDeadlineHours: number;
};

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 emptyProgress(): Record<HandoverArea, AreaProgress> {
  return {
    "repo-access": { total: 0, done: 0, pending: 0 },
    "ci-cd": { total: 0, done: 0, pending: 0 },
    "domain-dns": { total: 0, done: 0, pending: 0 },
    "database-backup": { total: 0, done: 0, pending: 0 },
    "secret-rotation": { total: 0, done: 0, pending: 0 },
  };
}

/**
 * Code handover cost has a visible layer and a hidden layer. The visible layer
 * is planned labour: repository, CI/CD, DNS, database and secret items, priced
 * at an hourly rate. The hidden layer only appears when the migration goes
 * wrong: one day of downtime, the SLA credit owed to customers, and the price
 * deduction the buyer takes for it. Rotation and revocation are counted
 * separately because they must finish inside 24 hours after closing.
 */
export function codeHandover(input: HandoverInput): Result<HandoverOutcome> {
  const priceError = checkAmount(input.priceCents, "price");
  if (priceError) {
    return fail(priceError);
  }
  if (input.priceCents === 0) {
    return fail("price must be positive");
  }
  for (const [value, label] of [
    [input.dailyRevenueCents, "daily revenue"],
    [input.monthlyRevenueCents, "monthly revenue"],
    [input.hourlyRateCents, "hourly rate"],
  ] as Array<[Cents, string]>) {
    const error = checkAmount(value, label);
    if (error) {
      return fail(error);
    }
  }
  if (
    !Number.isInteger(input.downtimeHours) ||
    input.downtimeHours < 0 ||
    input.downtimeHours > 720
  ) {
    return fail("downtime hours must be an integer between 0 and 720");
  }
  for (const [value, label] of [
    [input.slaCreditBps, "SLA credit"],
    [input.buyerDeductionBps, "buyer deduction"],
  ] as Array<[Bps, string]>) {
    const error = checkBps(value, label);
    if (error) {
      return fail(error);
    }
  }
  if (input.items.length === 0) {
    return fail("handover list must contain at least one item");
  }
  if (
    !Number.isInteger(input.secretsToRotate) ||
    input.secretsToRotate < 0 ||
    !Number.isInteger(input.accountsToRevoke) ||
    input.accountsToRevoke < 0 ||
    !Number.isInteger(input.minutesPerSecret) ||
    input.minutesPerSecret < 0 ||
    !Number.isInteger(input.minutesPerAccount) ||
    input.minutesPerAccount < 0
  ) {
    return fail("rotation and revocation counts must be non-negative integers");
  }

  const areaProgress = emptyProgress();
  let doneCount = 0;
  let laborHours = 0;
  for (const item of input.items) {
    if (!HANDOVER_AREAS.includes(item.area)) {
      return fail(`unknown handover area: ${item.area}`);
    }
    if (!Number.isInteger(item.hours) || item.hours < 0) {
      return fail("item hours must be a non-negative integer");
    }
    const progress = areaProgress[item.area];
    progress.total += 1;
    laborHours += item.hours;
    if (item.done) {
      progress.done += 1;
      doneCount += 1;
    } else {
      progress.pending += 1;
    }
  }

  const missingAreas = HANDOVER_AREAS.filter(
    (area) => areaProgress[area].total === 0,
  );
  const itemCount = input.items.length;
  const rotationHours =
    (input.secretsToRotate * input.minutesPerSecret) / 60;
  const revocationHours =
    (input.accountsToRevoke * input.minutesPerAccount) / 60;
  const laborCostCents = Math.round(laborHours * input.hourlyRateCents);
  const rotationCostCents = Math.round(rotationHours * input.hourlyRateCents);
  const revocationCostCents = Math.round(
    revocationHours * input.hourlyRateCents,
  );
  const revenueLossCents = Math.round(
    (input.dailyRevenueCents * input.downtimeHours) / 24,
  );
  const slaCreditCents =
    input.downtimeHours === 0
      ? 0
      : applyRate(input.monthlyRevenueCents, input.slaCreditBps);
  const buyerDeductionCents = applyRate(
    input.priceCents,
    input.buyerDeductionBps,
  );

  return ok({
    itemCount,
    doneCount,
    pendingCount: itemCount - doneCount,
    missingAreas,
    areaProgress,
    readyForHandover: doneCount === itemCount && missingAreas.length === 0,
    laborHours,
    laborCostCents,
    rotationHours,
    rotationCostCents,
    revocationHours,
    revocationCostCents,
    revenueLossCents,
    slaCreditCents,
    buyerDeductionCents,
    totalCostCents:
      laborCostCents +
      rotationCostCents +
      revocationCostCents +
      revenueLossCents +
      slaCreditCents +
      buyerDeductionCents,
    urgentActions: input.secretsToRotate + input.accountsToRevoke,
    urgentDeadlineHours: 24,
  });
}

代码里有三个刻意的选择。第一,readyForHandover 要求所有条目完成且五个领域齐全;缺一个领域,买方接手后就会出现“代码能跑但域名没转”或“数据库有备份但密钥没换”的断点。第二,轮换与回收工时单独算,不混进 29 小时的计划工时;它们的截止时间是 24 小时,优先级高于普通交接项。第三,SLA 抵扣只在 downtimeHours > 0 时产生,且按月度费用一次性计算,不按小时线性放大;真实 SLA 可能按小时阶梯计算,替换 slaCreditCents 的公式即可。

把 LedgerLite 代入:价格 76,876,800 美分、日收入 65,819 美分、月收入 2,002,000 美分、停机 24 小时、SLA 抵扣 1,000 基点、买方扣款 50 基点、小时费率 15,000 美分、计划 29 小时、12 个密钥各 10 分钟、18 个账号各 5 分钟。返回计划工时 29 小时、工时成本 435,000 美分;轮换 2 小时、30,000 美分;回收 1.5 小时、22,500 美分;收入损失 65,819 美分、SLA 抵扣 200,200 美分、买方扣款 384,384 美分;总成本 1,137,903 美分,即 11,379.03 美元。

小结

  • 代码交割有五项:仓库与权限、CI/CD、域名与 DNS、数据库与备份、密钥轮换;只转仓库,买方拿到的是不能运行的系统。
  • 计划工时 29 小时、每小时 150 美元,是 4,350 美元;这部分是可见成本,停机与扣款才是隐藏成本。
  • 域名转移要 5–7 天,DNS TTL 要提前 48 小时降到 300 秒;邮件记录和证书要在切换日逐项验证。
  • 数据库交割的标准不是“导出成功”,而是“买方环境恢复演练成功”;只导出不演练,恢复时缺扩展就会停机。
  • 密钥轮换与权限回收必须在交割后 24 小时内完成:12 个密钥 2 小时、18 个账号 1.5 小时,合计 30 个动作、3.5 小时。
  • 停机 1 天的收入损失是 658.19 美元,SLA 抵扣是 2,002 美元,买方扣款是 3,843.84 美元;三项合计 6,504.03 美元。
  • 加上计划工时、轮换和回收,总成本 11,379.03 美元,占 768,768 美元价格的 1.48%;买方扣款比例从 0.5% 提到 2%,扣款会从 3,843.84 美元升到 15,375.36 美元。
  • Tidecast 的 Steam 账号不可转让,买方要重新签发行协议;平台托管降低了停机风险,但切换期的结算暂停仍会影响现金流。

阅读导航:上一节:8.2 客户、合同与账号迁移 · 下一节:9.1 雇佣与外包合同的承继 。

常见错误与边界

  1. 把代码交割等同于转仓库。 仓库只是五项之一;CI/CD、域名、数据库、密钥不转,买方拿到的是不能部署的代码。
  2. 忽略 CI/CD 里的凭据。 部署密钥、环境变量、制品库令牌不会随仓库自动失效;旧成员仍可能通过流水线部署到生产。
  3. 交割后不撤销管理员。 仓库转让后原管理员可能仍是管理员;权限回收要逐项撤销,不是改一个密码。
  4. 把 IaC 状态文件直接交出去。 Terraform state 和云模板可能包含数据库密码与 API key;先转移所有权,再重建密钥。
  5. DNS TTL 太长。 切换前 48 小时没把 TTL 降到 300 秒,旧解析可能停留数小时,停机算在买方头上。
  6. 只导出数据库,不做恢复演练。 恢复时缺扩展、缺序列、字符集不对,应用起不来;导出成功不等于可恢复。
  7. 交割后 24 小时不轮换密钥。 卖方、外包、旧脚本里的凭据仍然有效;合同约束不等于技术隔离。
  8. 只回收“人”的权限,不回收“机器”的权限。 deploy key、CI token、OAuth app、webhook secret 不在成员列表里,最容易漏。
  9. 只算收入损失,不算 SLA 和买方扣款。 停机 1 天的收入损失是 658.19 美元,但 SLA 抵扣 2,002 美元、买方扣款 3,843.84 美元,后两项更大。
  10. 把 Tidecast 的账号当可过户资产。 Steam 账号不可转让,买方要重新签发行协议;愿望单、评测和好友关系不会跟着走。

本节的边界是“单体应用、单一云账号、固定小时费率”的静态模型。它假设停机 1 天、SLA 抵扣按月一次性计算、买方扣款按价格固定比例,不含多区域部署、灰度发布、数据库双向同步、第三方 API 的配额重置、平台审核周期和跨境数据删除义务。它足够回答“交割要花多少工时、停机 1 天值多少钱”,但不足以回答“迁移期间如何做到零停机”。后者要把架构、发布流程和回滚方案放在一起看,见 6.3 技术与知识产权尽调 ;客户与合同迁移见 8.2 客户、合同与账号迁移 。本节不构成法律意见,数据迁移与删除义务请由律师审阅。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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