以下情境为教学假设,人物与数字不代表经核实的真实案例。
代码交割最容易出现的误会,是把它当成“把仓库转给买方”。仓库只是五项里的一项。买方真正要接手的是:代码怎么构建、怎么部署、域名解析到哪里、数据怎么恢复、密钥由谁持有。只转仓库不转 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、数据库密码 | 只改密码,不撤销旧 token | 5 小时 |
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,768 | 0.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 雇佣与外包合同的承继 。
常见错误与边界
- 把代码交割等同于转仓库。 仓库只是五项之一;CI/CD、域名、数据库、密钥不转,买方拿到的是不能部署的代码。
- 忽略 CI/CD 里的凭据。 部署密钥、环境变量、制品库令牌不会随仓库自动失效;旧成员仍可能通过流水线部署到生产。
- 交割后不撤销管理员。 仓库转让后原管理员可能仍是管理员;权限回收要逐项撤销,不是改一个密码。
- 把 IaC 状态文件直接交出去。 Terraform state 和云模板可能包含数据库密码与 API key;先转移所有权,再重建密钥。
- DNS TTL 太长。 切换前 48 小时没把 TTL 降到 300 秒,旧解析可能停留数小时,停机算在买方头上。
- 只导出数据库,不做恢复演练。 恢复时缺扩展、缺序列、字符集不对,应用起不来;导出成功不等于可恢复。
- 交割后 24 小时不轮换密钥。 卖方、外包、旧脚本里的凭据仍然有效;合同约束不等于技术隔离。
- 只回收“人”的权限,不回收“机器”的权限。 deploy key、CI token、OAuth app、webhook secret 不在成员列表里,最容易漏。
- 只算收入损失,不算 SLA 和买方扣款。 停机 1 天的收入损失是 658.19 美元,但 SLA 抵扣 2,002 美元、买方扣款 3,843.84 美元,后两项更大。
- 把 Tidecast 的账号当可过户资产。 Steam 账号不可转让,买方要重新签发行协议;愿望单、评测和好友关系不会跟着走。
本节的边界是“单体应用、单一云账号、固定小时费率”的静态模型。它假设停机 1 天、SLA 抵扣按月一次性计算、买方扣款按价格固定比例,不含多区域部署、灰度发布、数据库双向同步、第三方 API 的配额重置、平台审核周期和跨境数据删除义务。它足够回答“交割要花多少工时、停机 1 天值多少钱”,但不足以回答“迁移期间如何做到零停机”。后者要把架构、发布流程和回滚方案放在一起看,见 6.3 技术与知识产权尽调 ;客户与合同迁移见 8.2 客户、合同与账号迁移 。本节不构成法律意见,数据迁移与删除义务请由律师审阅。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。