Multi-Tenant 上 Prod 铺开 Checklist

v1-gaps.md 缺口 1 指出"上 prod 的步骤没有成文"——这份就是那个成文。当前(2026-07-01)所有业务都在 test 跑,callytics-prod 库无 live 负载;正式上 prod 那天按下面顺序逐步执行,每步验证通过才进下一步。上线不是一天内的原子动作,fail-open 会把漏掉的步骤静默吞成 NULL,所以顺序和验证都不能省。

前置条件(不满足先补,都在 v1-gaps 里追踪)

前置为什么验证方式
缺口 3 运行时修复已验证lead-tracking 部署积压导致 test 环境 leads 100% NULL;prod 上重演就是正式事故test 环境新写入 leads tenant_id 非 NULL
tenant_id NULL 率告警已上线fail-open 的失败没有告警就是隐形的(test 已实证:三周无人发现)按表/按写入方的 NULL 率 metric 存在且有告警阈值
sweeper 回填 job 已上线铺开期间任何解析失败留下的 NULL 行要有自动回填,不靠手工cron 存在,幂等可重跑
mapping 对账 job 已上线(缺口 4)Step 3 seed 完之后靠它保持不变量,不是 seed 一次就完daily job 存在,unmapped/orphan 有告警

Step 1 — Apply 业务表 tenant_id migration 到 callytics-prod

走 CI migration 流程(callytics-common 的 migration workflow),不手动跑 SQL

验证(在 callytics-prod 上跑,期望 = 10,与 test 一致):

SELECT count(*) FROM information_schema.columns
WHERE table_schema = 'public' AND column_name = 'tenant_id';
-- 2026-07-01 实测: test = 10(ai_feedback, ai_task_decision_assessments, calls,
-- coaching_reviews, contact_timeline, contacts, leads, messages, task_suggestions, tasks)
-- prod = 0(未 apply)

Step 2 — 为真实客户建 tenant + tenant_members

  • member 的 user_id 必须是真实 Cognito user_id——test / test b 两个脏 tenant 的教训就是用了字面量字符串,永远解析不出任何东西
  • 每个 prod 店主的 user_id 都要进 tenant_members(2026-07-01 实测:prod 3 个店主 user_id 都不在,mapping 和 derived fallback 双双落空)

验证(control plane retaintive-identity):

SELECT t.id, t.name, t.status, count(m.user_id) AS members
FROM tenants t LEFT JOIN tenant_members m
  ON m.tenant_id = t.id AND m.status = 'active'
GROUP BY t.id, t.name, t.status;

Step 3 — Seed prod 店的 mapping

每家 active 店恰好一条 active mapping。注意:studio-api 的 ensureTenantMappingsForStores(#516)只在建新店时写 mapping,存量店必须显式 seed(source='backfill')。

验证(双向 diff,跨两个 Neon project 手动两步,或直接用缺口 4 的对账 job):

-- data plane(callytics-prod): 拿全部店(按 ID 排序方便对比)
SELECT id FROM rc_stores ORDER BY id;
-- control plane: 拿全部 active mapping(按 store_id 排序方便对比)
SELECT store_id FROM tenant_store_mappings WHERE status = 'active' ORDER BY store_id;
-- 两个集合做差:unmapped = 0 且 orphan = 0 才通过

Step 4 — 确认写入方带 tenant 标注

  • 每个写入 lambda 的 env 有 CONTROL_PLANE_DATABASE_URL_PARAM,且部署 region 的 SSM 里真有那个参数——SSM parameter 是 region-scoped 的,2026-07-01 实测 us-east-1 只有 /studio/prod/control-plane-database-url,没有 test 版;us-west-2 只有 test 版
  • lead-tracking 部署版本必须含 #197/#200(检查 Lambda LastModified 晚于修复部署日,deploy.yml 是 workflow_dispatch 手动触发,merge 不等于 deploy)

验证(上线后按表查新写入 NULL 率):

SELECT 'leads' AS t, count(*) FILTER (WHERE tenant_id IS NULL) AS nulls, count(*) AS total
FROM leads WHERE received_at > now() - interval '1 day'
UNION ALL
SELECT 'calls', count(*) FILTER (WHERE tenant_id IS NULL), count(*)
FROM calls WHERE synced_at > now() - interval '1 day';

Step 5 — 跑一次完整对账

缺口 4 的对账 job(或 Step 3 的手动 SQL)再跑一遍:unmapped = 0 且 orphan = 0。这一步是为了抓 Step 1–4 之间新 sync 出来的店。

Step 6 — tenant status 翻 active

把真实 tenant 从 provisioning 翻成 active。当前没有任何代码读 tenants.status(v1-gaps 缺口 5),翻它不改变行为——但上线前必须把 status 生命周期的 owner 和语义定下来(谁翻、suspended 挡不挡访问),不然"suspend 一个客户就能挡住访问"的误解会变成真事故。

退出条件(= v1-gaps 缺口 1 的验收)

  • prod 全部 active 店 100% 有 active mapping
  • 店主 user_id 全部在 tenant_members
  • 业务表新写入行带 tenant_id(NULL 率告警安静)