← 目录
otter-coordinator · 表结构重构 · 线上核实 + 独立评审 · 2026-07-26

你要的 creator 层
已经建好了,而且填满了。

我去线上把 schema 全查了一遍。「一个 creator 多个社媒、各自带粉丝和中播指标」这件事, 平台库里已经有 445 万行数据在跑。 真正的问题是—— 没有任何一张 coordinator_* 表外键到它。 达人身份恰好在 scouting 交接进 coordinator 的那一刻被丢掉了。

2,469,514contacts
person 240万 · agency 6.5万
4,451,571creator_profiles
已带 followers / 中播
150,705contact_relationships
全部是 agency_creator
35,070contact_merge_reviews
人工合并队列已在用
0coordinator 表到 contacts
的外键数量

我把这份 schema 和你的提案交给了一个独立评审(只给结构和数字,不给我的结论)。 它的第一句话是:「这大约 20% 是新模型,80% 是接线。」本文按这个判断组织。

01
核实

逐条对照:你要的东西哪些已经有了

contacts 达人本人 / 经纪公司。kind 已有 'person' | 'agency',已有 merged_into 合并机制(6.9 万已合并) 2,469,514
creator_profiles 一个社媒账号一行,已带 followers · median_views_recent(中播) · avg_views_recent · engagement · posts_count · metrics_updated_at 4,451,571
social_contents → social_content_metrics 单条内容及其播放/互动指标 143,830
contact_relationships 经纪↔达人关系,relationship_type 100% 是 agency_creator 150,705
contact_merge_reviews 人工合并复核队列,已在生产使用 35,070
contacts.emails / .phone text[] 纯数组,没有 per-item 元数据 → 没地方标「这是经纪的还是本人的」 缺口
coordinator_* → contacts 零外键。 coordinator 自己用 kol_id / creator_handle / creator_platform 三个自由文本字段重新记了一遍 缺口
collaboration 层 今天 engagement 直接挂 deliverable,没有「一次合作」这一层,也没有合同和打包报价 缺口
身份是在哪一步丢的

scouting 侧(scout_*target_creator_import_itemscampaign_scouted_creators)全部正确外键到 creator_profiles。 coordinator 侧一个都没有。身份在交接那一刻被降级成了字符串。

顺带发现两个要处理的问题

contact_socials(321 万)和 creator_profiles(445 万) 在 (contact, handle, platform)重叠 2,079,469 行—— 两套社媒账号表示法。不要再加第三套,以 creator_profiles 为准(scouting 已经在引用它)。
creator_profiles50%(222 万行)的 creator_id 是 NULL, 即一半社媒账号没挂到人。这在回填时要处理。

02
规范

命名:scope 前缀

现有库里的实际分布,新表一律按此:

前缀现有表数归属新表放这里
coordinator_54coordinator 自有:谈判、交付、运行、时间线collaboration / consideration / contract / 回填映射
contact_5联系人域:社媒、关系、合并、组织contact_methods / contact_method_assignments
creator_4达人档案与刷新批次不新增,复用
engagement_6平台共享:里程碑、渠道身份、issue不新增(避免与 coordinator 语义重叠)
social_ / campaign_ / scout_3 / 4 / 9内容 / 活动 / 找人不新增
03
评审的关键反驳

「标 agency 还是个人」该标在哪

我原来打算在联系方式上加一个 ownership 列。评审直接否掉了,理由很硬:

独立评审

「一个经纪邮箱属于经纪公司。它被用于某个具体达人这件事, 是通过经纪↔达人关系派生出来的。把它在达人身上全局标成 agency, 同时丢掉了归属和历史。」

我接受。差别在于:mgmt@tiddle.io 这个邮箱是 tiddle 的, 不是 laura 的;它在 2026-03 到 2026-08 期间被用来联系 laura。 laura 换经纪之后,这条历史必须还在——否则去年那笔合作是谁在谈就查不出来了。

所以拆成两张表:方法归谁方法用于谁

-- ① 联系方式本身,归属于它真正的主人
create table contact_methods (
  id                uuid primary key default gen_random_uuid(),
  contact_id        uuid not null references contacts(id),   -- 经纪邮箱 → 指向经纪 contact
  method_type       text not null check (method_type in
                      ('email','phone','whatsapp','wechat','telegram','other')),
  raw_value         text not null,
  normalized_value  text not null,
  label             text,
  verification_status text not null default 'unverified',
  mergeable         boolean not null default true,   -- ← agency / 共享信箱设 false
  valid_during      tstzrange not null default tstzrange(now(), null, '[)'),
  created_at        timestamptz not null default now(),
  unique (contact_id, method_type, normalized_value),
  unique (id, contact_id)                                    -- 供下表复合外键引用
);

-- ② 这个方法「被用于」哪个达人 —— agency / personal 标在这里
create table contact_method_assignments (
  id                       uuid primary key default gen_random_uuid(),
  creator_contact_id       uuid not null references contacts(id),  -- 用于谁
  contact_method_id        uuid not null,
  method_owner_contact_id  uuid not null,                          -- 方法归谁
  assignment_type          text not null check (assignment_type in ('personal','agency')),
  source_relationship_id   uuid,                                   -- agency 时必须指向那条经纪关系
  valid_during             tstzrange not null default tstzrange(now(), null, '[)'),
  is_primary               boolean not null default false,

  foreign key (contact_method_id, method_owner_contact_id)
    references contact_methods(id, contact_id),

  check (
    (assignment_type='personal' and method_owner_contact_id = creator_contact_id
       and source_relationship_id is null)
    or
    (assignment_type='agency'   and method_owner_contact_id <> creator_contact_id
       and source_relationship_id is not null)
  )
);

这样白送三件事:

还要补一件评审指出的事

contact_relationships 现有 150,705 条 agency_creator, 但没有生效时间。达人中途换经纪就表达不了。需要补 valid_during

contacts.emails / contacts.phone 这两个 text[] 在迁移期保留为只读投影,不立刻删。

04
新建

Collaboration 层

评审推翻了我的一个假设

我原本设计成「一个 collaboration 属于一个达人」。评审指出这立刻在打包合作上失败—— 一个经纪一次给旗下 5 个达人谈一个总价、签一份合同。所以必须是多对多

-- 一次合作
create table coordinator_collaborations (
  id                uuid primary key default gen_random_uuid(),
  campaign_id       int  not null references campaigns(id),
  lifecycle_status  text not null,
  phase             text not null,
  renewal_of_collaboration_id uuid references coordinator_collaborations(id),
  renewal_round     int  not null default 0 check (renewal_round >= 0),
  agreed_at         timestamptz,
  closed_at         timestamptz,
  legacy_engagement_id uuid unique,        -- 迁移期回指,永不复用
  created_at        timestamptz not null default now(),
  check (renewal_of_collaboration_id is null or renewal_round > 0)
);

-- 参与这次合作的达人(多对多 —— 打包合作靠这张表)
create table coordinator_collaboration_creators (
  id                 uuid primary key default gen_random_uuid(),
  collaboration_id   uuid not null references coordinator_collaborations(id),
  creator_contact_id uuid not null references contacts(id),
  unique (collaboration_id, creator_contact_id),
  unique (id, collaboration_id, creator_contact_id)
);

-- 这次合作里谁在代表谁谈 —— 快照,不靠事后推断当前经纪关系
create table coordinator_collaboration_representatives (
  id               uuid primary key default gen_random_uuid(),
  collaboration_id uuid not null references coordinator_collaborations(id),
  agency_contact_id uuid not null references contacts(id),
  represented_creator_contact_id uuid references contacts(id),  -- null = 代表整个打包
  negotiation_role text not null default 'negotiator'
    check (negotiation_role in ('negotiator','contracting_party','payment_recipient')),
  unique (collaboration_id, agency_contact_id,
          coalesce(represented_creator_contact_id,'00000000-0000-0000-0000-000000000000'::uuid),
          negotiation_role)
);

交付物挂到稳定的账号,不挂 handle 字符串

create table coordinator_collaboration_deliverables (
  id                  uuid primary key default gen_random_uuid(),
  collaboration_id    uuid not null references coordinator_collaborations(id),
  collaboration_creator_id uuid not null,
  creator_contact_id  uuid not null,
  creator_profile_id  uuid,          -- ← 具体发在哪个社媒账号上;签约后才定也允许 null
  sequence            int  not null check (sequence > 0),
  deliverable_type    text not null,
  status              text not null,
  due_at              timestamptz,

  foreign key (collaboration_creator_id, collaboration_id, creator_contact_id)
    references coordinator_collaboration_creators(id, collaboration_id, creator_contact_id),
  -- 保证这个账号确实属于这个达人,不会张冠李戴
  foreign key (creator_profile_id, creator_contact_id)
    references creator_profiles(id, creator_id),
  unique (collaboration_id, sequence)
);

-- 返工 / 改片 / 补拍
create table coordinator_deliverable_attempts (
  id              uuid primary key default gen_random_uuid(),
  deliverable_id  uuid not null references coordinator_collaboration_deliverables(id),
  attempt_number  int not null check (attempt_number > 0),
  reason          text,
  status          text not null,
  submitted_at    timestamptz, approved_at timestamptz,
  unique (deliverable_id, attempt_number)
);

-- 发布的成片 ←→ 已有的内容指标表,白送播放数据
create table coordinator_deliverable_social_contents (
  deliverable_id    uuid not null references coordinator_collaboration_deliverables(id),
  social_content_id uuid not null references social_contents(id),
  primary key (deliverable_id, social_content_id)
);

最后那张表值得注意:接上之后,「这条片子发出去播了多少」不需要新建任何采集链路—— social_content_metrics 已经在采了。

打包报价 ≠ 实付

这直接对应我上一版指出的空洞:4,740 条交付物里只有 16 条填了实付金额(0.3%)。 根因是「谈成多少」和「实付多少」被挤在同一个字段上。拆开:

-- 谈成的对价 —— 支持打包价、单条价、以物易物、纯佣金
create table coordinator_collaboration_considerations (
  id                 uuid primary key default gen_random_uuid(),
  collaboration_id   uuid not null references coordinator_collaborations(id),
  consideration_type text not null check (consideration_type in
    ('cash_package','cash_fee','barter','gift','commission','other')),
  amount             numeric(19,4),
  currency           char(3),
  description        text,
  estimated_value    numeric(19,4),        -- 以物易物时的折算价值
  estimated_value_currency char(3),
  check ((amount is null and currency is null)
      or (amount >= 0 and currency ~ '^[A-Z]{3}$'))
);
-- 一次合作最多一个打包价
create unique index coordinator_one_cash_package
  on coordinator_collaboration_considerations(collaboration_id)
  where consideration_type = 'cash_package';

-- 实际打款 —— 独立账本,可分期、可退款、指回当初谈成的那条
create table coordinator_collaboration_payments (
  id                uuid primary key default gen_random_uuid(),
  collaboration_id  uuid not null references coordinator_collaborations(id),
  deliverable_id    uuid references coordinator_collaboration_deliverables(id),
  consideration_id  uuid references coordinator_collaboration_considerations(id),
  payee_contact_id  uuid not null references contacts(id),   -- 可能是经纪公司
  amount            numeric(19,4) not null,
  currency          char(3) not null,
  direction         text not null default 'outbound'
    check (direction in ('outbound','refund','chargeback')),
  source_kind       text not null,       -- finance_webhook | operator | contract
  source_ref        text not null,       -- 打款回执 / 操作人 / 合同编号
  paid_at           timestamptz not null
);

合同做成版本化 + 多对多

因为存在一份总合同覆盖多次合作的情况,合同不能是 collaboration 的子表。

create table coordinator_contracts (
  id uuid primary key default gen_random_uuid(),
  status text not null check (status in ('draft','sent','partially_signed','signed','voided'))
);
create table coordinator_contract_versions (
  id uuid primary key default gen_random_uuid(),
  contract_id uuid not null references coordinator_contracts(id),
  version int not null check (version > 0),
  document_uri text not null, content_sha256 text,
  unique (contract_id, version)
);
create table coordinator_contract_collaborations (
  contract_id uuid not null references coordinator_contracts(id),
  collaboration_id uuid not null references coordinator_collaborations(id),
  primary key (contract_id, collaboration_id)
);
05
评审发现了一个现存 bug

reference_id 的「仅活跃时唯一」是错的

今天的约束是 unique (reference_id) where lifecycle_status='active' (coord_eng_active_ref_uq)。评审指出了它的后果:

独立评审

「不要保留『仅活跃时唯一』。一条迟到的入站消息,属于一个已结束的合作, 绝不能被路由到一个复用了同一个值的新合作上。

这不是理论问题:续约刻意复用同一个 reference_id 并把上一轮归档,正是为了满足这条索引。所以「上一轮的晚到邮件飘到新一轮」今天是可以发生的。 改成永久别名表:

create table coordinator_collaboration_external_references (
  id               uuid primary key default gen_random_uuid(),
  collaboration_id uuid not null references coordinator_collaborations(id),
  external_system  text not null,        -- 'scheduler'
  external_account text not null,        -- 上游租户,防止跨租户撞号
  reference_id     text not null,
  created_at       timestamptz not null default now(),
  unique (external_system, external_account, reference_id)   -- 永久唯一,不看状态
);
06
诚实清单

这个模型仍然覆盖不了什么

上面的 DDL 解决了身份、打包、多平台、返工、非现金对价。但下面这些还不够:

场景现状需要什么
一次打包覆盖多个达人已解决collaboration_creators 多对多
经纪代多个达人谈一个总价已解决+ representatives 快照
一个达人一次合作发多个平台已解决deliverable 各自挂 creator_profile_id
签约后才定用哪个账号已解决creator_profile_id 可空
返工 / 改片 / 换片已解决deliverable_attempts
以物易物 / 送产品 / 纯佣金已解决considerations.consideration_type
分期 / 定金 / 退款 / 付给经纪已解决payments 独立账本
达人改 handle已解决handle 只是别名,身份是 contacts.id
合作中途换经纪已解决representatives 快照 + valid_during
使用权 / 独家期 / 授权范围 / 白名单投流未建模这是合同条款,不是交付物状态。需要单独的 terms 模型
税、经纪抽成、跨币种结算未建模payments 只记了毛额,需要 fee/tax 分解
一次合作跨多个 campaign未建模今天 campaign_id 是单值。真出现要改多对多
多达人合拍同一条内容未建模deliverable 现在只挂一个 creator
谈判进行中发生 contact 合并需流程合并事务里要一并合并 coordinator 侧关系

前 9 行是这次要做的;后 5 行我建议明确留白,不要为了「看起来完备」现在就建—— 使用权和税费模型都值得独立设计。

07
迁移可行性

回填能做到什么程度

拿 51,455 条现有 engagement 按 (handle, platform) 去撞 creator_profiles:

39,409唯一命中一个 contact
76.6% 直接接上
11,184命中 profile 但它
没挂 contact(21.7%)
373命中 2 个 contact
需人工判断(0.7%)
489完全没命中
(0.95%)
结论

99.05% 能自动接上 profile;76.6% 能一路接到 contact。 中间那 11,184 条是「有社媒账号但没建人」——这是机械操作,可脚本化,不需要判断。 真正需要人看的只有 862 条(373 + 489)。

但这里是整个迁移最危险的地方

评审的原话:「一次错误匹配会把一个达人的谈判记录暴露给另一个达人。 这比暂时留一条未解析的记录糟糕得多。」

所以顺序必须是:确定性匹配优先(scouting 来源 → platform_user_id → 唯一 handle+platform), 邮箱和手机只产生候选,不自动合并。 并且——迁移允许「身份未解析」长期存在,绝不允许为了凑满 100% 而猜。

08
一处需要你拍板的分歧

那个持久记录该不该叫 Engagement

你的定义是:Engagement = 达人本身,名下挂多次 collaboration。评审明确反对沿用这个词:

独立评审

engagement 在生产里已经意味着『一次交易』,而且被广泛这样理解。 coordinator_creator_relationships 不够优雅,但明显更安全、更清楚。」

我认为可以两全,而且这是我的建议:

叫什么理由
界面 / 运营口径Engagement你的语义,运营看到的就是「这个达人」。词汇迁移照做
数据库表名coordinator_creator_relationships迁移期 coordinator_engagements 还活着并且是旧语义。同一个词在同一个库里同时有两个意思,是迁移事故的经典来源

等旧表退役之后,要不要改名成 coordinator_engagements 再说—— 那时改名是纯粹的重命名,没有语义歧义。

09
落地

迁移顺序

#动作为什么在这个位置
1先止血:scouting 交接时把 contact_id + creator_profile_id 传下来,加可空列(NOT VALID 外键),不改任何读逻辑不停止制造新的身份债,后面的回填才有终点
2新表全部旁路建好,每条 collaboration 记 legacy_engagement_id不改名、不动 coordinator_engagements保留随时回退的能力
3contact_methods + assignments 上线,经纪渠道打 mergeable=false合并前必须先能表达「这个不算身份」
4确定性回填(来源 → platform_user_id → 唯一 handle+platform)。邮箱/手机只进候选队列76.6% 免费拿到
5写 coordinator 关系前先解 merged_into;建独立的 coordinator 身份复核队列评审提醒:别复用 contact_merge_reviews——「这条谈判归谁」不总是一个 contact 合并问题
6外部引用表回填并校验完,才动消息路由。新路由先查永久引用表,查不到回落旧路径,双跑唯一会导致「消息进错人」的改动
7新谈判双写(同一事务或 outbox,不要两个独立 best-effort 写)两个独立写必然漂移
833,169 条在跑的谈判分批迁,从确定性匹配的开始;未解析的继续走旧路径允许长期共存
9持续比对生命周期状态、会话数、价格条目、交付物;按 campaign 灰度切读
10全部对齐后再 VALIDATE 外键、停旧写、考虑改名
10
需要你回答

四个问题

  1. 表名分歧怎么定? 我建议界面叫 Engagement、库里叫 coordinator_creator_relationships(§08)。如果你坚持库里也叫 engagement,那必须先把旧表改名腾出这个词,迁移风险会明显上升。
  2. contact_socialscreator_profiles 重叠 208 万行,谁退役? 我建议保 creator_profiles(scouting 在用、带指标),contact_socials 降为只读兼容视图。但要确认没有别的服务在写它。
  3. organizations 表是空的(0 行)。 评审建议给关系加一个 principal/租户维度。如果短期内只有一个主体(JellyOtter),我倾向先不加,避免过度设计——你确认一下有没有多主体计划。
  4. 使用权 / 独家期 / 税费抽成 要不要这次一起做? 我建议留白,单独设计。但如果合同里已经在谈独家期,那它其实已经是刚需了。

schema 与规模数据取自生产库直查(2026-07-26)。
独立评审:GPT-5.6(codex, reasoning=high),仅提供结构与数字、不提供结论。
前序文档:01 架构 · 02 CRM 拆分 · 03 Campaign 统一 · 04 身份翻转