我去线上把 schema 全查了一遍。「一个 creator 多个社媒、各自带粉丝和中播指标」这件事, 平台库里已经有 445 万行数据在跑。 真正的问题是—— 没有任何一张 coordinator_* 表外键到它。 达人身份恰好在 scouting 交接进 coordinator 的那一刻被丢掉了。
我把这份 schema 和你的提案交给了一个独立评审(只给结构和数字,不给我的结论)。 它的第一句话是:「这大约 20% 是新模型,80% 是接线。」本文按这个判断组织。
scouting 侧(scout_*、target_creator_import_items、 campaign_scouted_creators)全部正确外键到 creator_profiles。 coordinator 侧一个都没有。身份在交接那一刻被降级成了字符串。
① contact_socials(321 万)和 creator_profiles(445 万)
在 (contact, handle, platform) 上重叠 2,079,469 行——
两套社媒账号表示法。不要再加第三套,以 creator_profiles 为准(scouting 已经在引用它)。
② creator_profiles 里 50%(222 万行)的 creator_id 是 NULL,
即一半社媒账号没挂到人。这在回填时要处理。
现有库里的实际分布,新表一律按此:
| 前缀 | 现有表数 | 归属 | 新表放这里 |
|---|---|---|---|
| coordinator_ | 54 | coordinator 自有:谈判、交付、运行、时间线 | collaboration / consideration / contract / 回填映射 |
| contact_ | 5 | 联系人域:社媒、关系、合并、组织 | contact_methods / contact_method_assignments |
| creator_ | 4 | 达人档案与刷新批次 | 不新增,复用 |
| engagement_ | 6 | 平台共享:里程碑、渠道身份、issue | 不新增(避免与 coordinator 语义重叠) |
| social_ / campaign_ / scout_ | 3 / 4 / 9 | 内容 / 活动 / 找人 | 不新增 |
我原来打算在联系方式上加一个 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[] 在迁移期保留为只读投影,不立刻删。
我原本设计成「一个 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) );
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)
);
今天的约束是 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) -- 永久唯一,不看状态
);
上面的 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 行我建议明确留白,不要为了「看起来完备」现在就建—— 使用权和税费模型都值得独立设计。
拿 51,455 条现有 engagement 按 (handle, platform) 去撞 creator_profiles:
99.05% 能自动接上 profile;76.6% 能一路接到 contact。 中间那 11,184 条是「有社媒账号但没建人」——这是机械操作,可脚本化,不需要判断。 真正需要人看的只有 862 条(373 + 489)。
评审的原话:「一次错误匹配会把一个达人的谈判记录暴露给另一个达人。 这比暂时留一条未解析的记录糟糕得多。」
所以顺序必须是:确定性匹配优先(scouting 来源 → platform_user_id → 唯一 handle+platform), 邮箱和手机只产生候选,不自动合并。 并且——迁移允许「身份未解析」长期存在,绝不允许为了凑满 100% 而猜。
你的定义是:Engagement = 达人本身,名下挂多次 collaboration。评审明确反对沿用这个词:
「engagement 在生产里已经意味着『一次交易』,而且被广泛这样理解。 coordinator_creator_relationships 不够优雅,但明显更安全、更清楚。」
我认为可以两全,而且这是我的建议:
| 叫什么 | 理由 | |
|---|---|---|
| 界面 / 运营口径 | Engagement | 你的语义,运营看到的就是「这个达人」。词汇迁移照做 |
| 数据库表名 | coordinator_creator_relationships | 迁移期 coordinator_engagements 还活着并且是旧语义。同一个词在同一个库里同时有两个意思,是迁移事故的经典来源 |
等旧表退役之后,要不要改名成 coordinator_engagements 再说—— 那时改名是纯粹的重命名,没有语义歧义。
| # | 动作 | 为什么在这个位置 |
|---|---|---|
| 1 | 先止血:scouting 交接时把 contact_id + creator_profile_id 传下来,加可空列(NOT VALID 外键),不改任何读逻辑 | 不停止制造新的身份债,后面的回填才有终点 |
| 2 | 新表全部旁路建好,每条 collaboration 记 legacy_engagement_id。不改名、不动 coordinator_engagements | 保留随时回退的能力 |
| 3 | contact_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 写) | 两个独立写必然漂移 |
| 8 | 33,169 条在跑的谈判分批迁,从确定性匹配的开始;未解析的继续走旧路径 | 允许长期共存 |
| 9 | 持续比对生命周期状态、会话数、价格条目、交付物;按 campaign 灰度切读 | — |
| 10 | 全部对齐后再 VALIDATE 外键、停旧写、考虑改名 | — |
schema 与规模数据取自生产库直查(2026-07-26)。
独立评审:GPT-5.6(codex, reasoning=high),仅提供结构与数字、不提供结论。
前序文档:01 架构 · 02 CRM 拆分 ·
03 Campaign 统一 · 04 身份翻转