DMTX / dmd-cloud / PostgreSQL ON CONFLICT

DMTX 联系人组合主键能力 v3:dmd-cloud 参考与 PostgreSQL 适配方案

对比邮件产品线 dmd-cloud 的 unq_combiUNIQUE INDEX unq 与 MySQL upsert 实现,说明 DMTX 在 PostgreSQL/DataClean 架构下可借鉴的边界:局部幂等可借鉴,不能直接替换联系人主表 OR 匹配 + merge 语义。

v3dmd-cloud 参考版
PGON CONFLICT 适配
不照搬contact 主表身份模型
30-45酒店 mapping MVP 人天

版本:v3 参考实现与 PostgreSQL 适配评审版

日期:2026-06-05

需求来源:DMTX 联系人组合主键方案补充评审;参考邮件产品线 [dmd-cloud]unq_combi / UNIQUE INDEX unq / upsert 实现

适用系统:DMTX 联系人、ETL/DataClean、联系人导入、PostgreSQL contact 表、局部幂等写入表、后续组合身份规则设计

与前两版关系:v1 记录原始组合主键诉求;v2 梳理 DMTX upload → DataClean → contact 写入链路和当前多主键 OR 语义;本 v3 专门回答“dmd-cloud 的组合唯一索引方案能否被 DMTX 借鉴,以及在 PostgreSQL 下应该借鉴到什么边界”。


1. 一句话结论

DMTX 应该借鉴 dmd-cloud 的“业务唯一键 + 数据库唯一约束 + upsert 幂等写入”技术思想,但不应直接照搬它作为 DMTX contact 主表的身份解析模型。

原因不是 PostgreSQL 做不到,而是两套系统的联系人身份语义不同:

维度dmd-cloud 邮件产品线DMTX 当前 DataClean
真实主键idid / contact_id / customer_id 组成内部身份体系
业务唯一unq_combi 对应索引 unq(A, B, ...)多个 if_pk=true 字段是多个可替代识别信号
查重方式数据库 UNIQUE INDEX 冲突PostgreSQLSource.search 对多个主键做 OR 查询
写入方式MySQL INSERT ... ON DUPLICATE KEY UPDATE id=LAST_INSERT_ID(id)先查候选,再由 DataClean.handleUsers 决定 add / update / merge
多命中处理命中唯一键即已有联系人多候选会进入 SourceCommon.merge,带标签、事件、删除等副作用
可借鉴程度可借鉴“数据库裁决强唯一键”的思想不能直接替代现有 DataClean 身份解析与 merge 编排

推荐方向:

SQL / 代码片段
保留 DMTX 默认 OR_MERGE 语义
  +
新增显式 SINGLE_UNIQUE / GROUPED_COMPOSITE 策略
  +
在明确策略下使用 PostgreSQL partial/expression/generated-column unique index
  +
局部场景使用 INSERT ... ON CONFLICT ... DO UPDATE ... RETURNING
  +
DataClean 仍负责 merge、副作用、冲突治理和下游一致性

2. v3 为什么单独成文

v2 已经明确 DMTX 当前现状:

  1. DMTX 已支持多个 if_pk=true 主键字段。
  2. Email/Mobile 可以取消主键,自定义字段可以设为主键。
  3. 当前 DataClean 多主键运行时语义是 OR / 任一主键命中
  4. 客户要的酒店场景是 AND / 一组字段同时命中

这次补充 dmd-cloud 后,需要额外回答一个工程问题:

既然邮件产品线已经用 unq_combi + UNIQUE INDEX + upsert 实现了业务组合唯一,DMTX 是否也可以这么做?

答案是:可以借鉴局部技术,不建议直接迁移身份模型。

本方案不是替代 v2,而是 v2 的 companion:

文档定位
v1:2026-06-05-dmtx-composite-contact-key/solution.html原始组合主键业务方案,说明酒店集团诉求。
v2:2026-06-05-dmtx-composite-contact-key-v2/solution.html深入开发方案,梳理 DMTX 现有多主键 OR 语义、DataClean 链路和改造顺序。
v3:本文档对照 dmd-cloud,明确 PostgreSQL 唯一约束 / ON CONFLICT 在 DMTX 中可借鉴的边界。

3. dmd-cloud 已确认实现事实

3.1 unq_combi 来自索引 unq

在 dmd-cloud 中,unq_combi 不是独立的复杂规则对象,而是“字段是否在唯一索引 unq 中”的展示/配置结果。

关键事实:

文件作用
[dmd-cloud]/src/WebPower/Dmdelivery/Persistence/Legacy/DBTableInfoRepository.php读取表索引,把名为 unq 的索引字段标记为 unq_combi=true
[dmd-cloud]/src/WebPower/Dmdelivery/Core/Contacts/Table/ContactFieldStorer.php从前端提交的 unq_combi[] 构造 $newFields,再 add/drop 索引 unq
[dmd-cloud]/src/WebPower/Dmdelivery/Core/Contacts/Table/TableChanges.phpaddIndex(..., true, ...) 渲染成 ADD UNIQUE KEY

典型逻辑:

SQL / 代码片段
$postedUniqueCombi = isset($post['unq_combi']) ? (array) $post['unq_combi'] : [];

foreach ($tableInfo as $name => $column) {
    if (in_array($name, $postedUniqueCombi, true)) {
        $newFields[$name] = $name;
    }
}

$tableChanges->dropIndex('unq')
    ->addIndex('unq', $newFields, true, $fieldExtras);

最终 SQL 类似:

SQL / 代码片段
ALTER TABLE `recipient_xxx`
  DROP INDEX `unq`,
  ADD UNIQUE INDEX `unq` (`A`, `B`);

3.2 默认 recipient 表也以 unq 作为唯一索引名

在 dmd-cloud 的 recipient 表创建逻辑中,默认 email 唯一索引也叫 unq

SQL / 代码片段
UNIQUE KEY `unq` (email(60))

这说明 unq 是联系人业务唯一边界的固定索引名,unq_combi 是围绕该索引字段列表的配置能力。

3.3 写联系人时依赖 MySQL duplicate key

核心写入在:

[dmd-cloud]/src/WebPower/Dmdelivery/Core/Interactor/Recipient/LegacyRecipientStorer.php

其 SQL 结构是:

SQL / 代码片段
INSERT INTO `recipient_table` (...)
VALUES (...)
ON DUPLICATE KEY UPDATE
  id = LAST_INSERT_ID(id),
  ...

id=LAST_INSERT_ID(id) 的作用:

场景结果
没有冲突插入新联系人,lastInsertId 是新 ID。
命中唯一索引进入 duplicate 分支,LAST_INSERT_ID(id) 把已有联系人 ID 放进 lastInsertId

因此调用方可以用同一套返回逻辑拿到联系人 ID。

3.4 duplicate 不是只针对 unq

需要注意:MySQL ON DUPLICATE KEY UPDATE 不是指定某个索引触发,它会被任何 PRIMARY / UNIQUE 冲突触发。

也就是说,如果表上还有其他唯一索引,例如单字段唯一索引,那么 duplicate 分支不一定是 unq(A, B) 触发。

这点在 PostgreSQL 下不同,因为 PG 通常需要明确 conflict target:

SQL / 代码片段
ON CONFLICT (a, b) DO UPDATE

或:

SQL / 代码片段
ON CONFLICT ON CONSTRAINT uq_name DO UPDATE

3.5 dmd-cloud 的前缀索引是 MySQL 实现细节

dmd-cloud 对文本字段使用前缀长度:

字段类型前缀长度
email60
其他 varchar/text20

这是 MySQL / MyISAM key length 约束下的实现细节,不应照搬到 DMTX PostgreSQL。

在 PostgreSQL 中,更推荐使用:

  • 规范化列
  • 生成列
  • 表达式索引
  • partial unique index

4. DMTX 当前 PostgreSQL / DataClean 真实语义

4.1 当前不是数据库唯一键驱动

DMTX 当前联系人识别主链路是:

SQL / 代码片段
DataClean.searchUserByPks
  -> AttributesUtils.userPks / setPkValue
  -> PostgreSQLSource.search
  -> DataClean.handleUsers
  -> SourceCommon.merge 或新增/更新
  -> CustomerService.saveContact / updateContact

关键代码事实:

文件/方法当前语义
AttributesUtils.userPks返回所有 ifShow && ifPk 的联系人字段。
DataClean.searchUserByPks只要求导入行至少有一个 PK 字段有值。
PostgreSQLSource.search多个 PK 条件之间用 or 拼接。
DataClean.handleUsers0 命中新增;1 命中更新;多命中 merge。
SourceCommon.merge保留第一条候选身份,合并字段、标签、user_ids_str,收集 loser uids/pids

当前查询更接近:

SQL / 代码片段
select *
from contact
where email = ?
   or mobile = ?
   or card_no = ?
order by create_date asc
limit 1000;

而不是:

SQL / 代码片段
select *
from contact
where email = ?
  and card_no = ?;

4.2 merge 是业务编排,不只是 DB update

DMTX 的 merge 不是单纯更新一行,它还包含:

副作用说明
survivor 选择PostgreSQLSource.searchcreate_date asc 返回,通常最早创建的联系人保留。
字段合并SourceCommon.merge 合并非空字段。
标签合并label_ids union,并计算新增标签。
历史身份user_ids_str 合并历史 customer_id。
删除 loserDeleteStorageDTO / DataStorage / batchDelete 清理被合并联系人。
事件归并merge event / eventMergePush 等逻辑。
渠道定制WeCom、WeChat、Huawei 等定制字段和同步逻辑。
导入统计SFTP/import 成功失败计数。

因此,不能把:

SQL / 代码片段
DataClean 搜索 + merge + side effects

直接替换成:

SQL / 代码片段
INSERT ... ON CONFLICT DO UPDATE

数据库 upsert 只能完成“写哪一行”,无法自动完成 DMTX 当前 merge 的业务副作用。

4.3 DMTX 已经局部使用 PostgreSQL upsert

DMTX 并不是不能用 PG upsert,仓库里已有类似模式:

文件用法
UpgradeV20240328Service.javacontact_event ... ON CONFLICT (event_uuid) DO UPDATE,事件幂等更新。
DmarTechHighSwarmService.javaON CONFLICT (customer_id) DO UPDATE,current-state 类写入。
AbstractBufferFreeImportService.java标签/状态聚合类 upsert。
ContactSubscriptionService.java对订阅状态使用 partial conflict target,例如 where customer_id is not nullwhere customer_id is null and email is not null

这说明:DMTX 可以使用 PG upsert,但更适合局部幂等表、状态表、事件表,而不是直接替代联系人身份解析主链路。


5. 核心语义差距

5.1 dmd-cloud 是唯一边界驱动

在 dmd-cloud 中,组合字段的含义是:

SQL / 代码片段
A + B + ... 命中 UNIQUE INDEX unq
  => 这是同一个 recipient
  => 通过 duplicate key 拿到已有 id
  => 按 overwrite 策略更新

5.2 DMTX 是候选集合 + merge 编排

在 DMTX 中,多个 PK 字段的含义是:

SQL / 代码片段
A 命中联系人 1 或 B 命中联系人 2
  => 都是候选
  => 多候选进入 merge
  => 触发标签、历史 ID、删除、事件等副作用

5.3 直接套用会改变业务行为

如果 DMTX 直接引入:

SQL / 代码片段
unique(email, card_no)

那么行为会变成:

场景当前 DMTX直接组合唯一后
email 相同、cardNo 不同email 命中同一联系人,可能更新/合并不冲突,可能新增多条
email 命中 A、cardNo 命中 BA/B 多候选后 merge不一定冲突;若冲突也只能更新一个 conflict target
只有 email 有值可按 email 查找不满足组合唯一,可能不能匹配
多个候选DataClean 统一 mergeDB upsert 不知道如何合并多个候选

这不是“实现方式变化”,而是“身份语义变化”。


6. PostgreSQL 适配原则

6.1 不照搬 MySQL LAST_INSERT_ID

PostgreSQL 等价思路是:

SQL / 代码片段
INSERT INTO contact_identity_map (...)
VALUES (...)
ON CONFLICT (company_id, identity_key)
DO UPDATE SET
  update_date = now()
RETURNING contact_id, created_at, update_date;

如果业务需要区分“新建还是已有”,不能依赖 MySQL affected_rows 规则,需要显式设计,例如:

  • RETURNING xmax = 0 AS inserted,但这依赖 PG MVCC 细节,不建议作为长期产品契约。
  • 在应用层先尝试 insert,捕获冲突后 update/select。
  • 使用额外字段记录 first_seen_at / last_seen_at,由业务判断是否首次。
  • 用 CTE 显式返回插入/更新状态。

推荐在方案层定义清楚:RETURNING 只能保证返回最终行,不天然等价于 dmd-cloud 的 isDuplicate

6.2 PostgreSQL UNIQUE 对 NULL 的语义不同

PG 普通唯一索引允许多条 NULL:

SQL / 代码片段
create unique index uq_contact_email_card
on contact (email_norm, card_no_norm);

如果 email_normcard_no_norm 为 NULL,多条记录可能不会冲突。

因此必须明确:

值类型建议处理
NULL不参与业务身份唯一。
空字符串规范化为 NULL。
纯空白btrim 后转 NULL。
Email 大小写lower 规范化。
手机号统一国家码、去空格/横线。

推荐使用 partial unique index:

SQL / 代码片段
create unique index concurrently uq_contact_card_email_norm
on contact (card_no_norm, email_norm)
where card_no_norm is not null
  and email_norm is not null;

6.3 表达式索引 vs 生成列

PG 可以写表达式唯一索引:

SQL / 代码片段
create unique index concurrently uq_contact_email_norm
on contact (lower(nullif(btrim(email), '')))
where nullif(btrim(email), '') is not null;

但如果后续要稳定使用 ON CONFLICT、易排查、易迁移,推荐使用生成列或显式规范化列:

SQL / 代码片段
alter table contact add column email_norm varchar;
alter table contact add column card_no_norm varchar;

create unique index concurrently uq_contact_card_email_norm
on contact (card_no_norm, email_norm)
where card_no_norm is not null
  and email_norm is not null;

原因:

  • 查询语句更简单。
  • 冲突目标更清晰。
  • 可做 backfill 和冲突审计。
  • 避免表达式与应用规范化逻辑不一致。

6.4 partial index 与 ON CONFLICT 的约束

如果使用 partial unique index,ON CONFLICT 也要匹配对应 conflict target 和 predicate,或者用约束名/索引推断方式设计清楚。

示意:

SQL / 代码片段
insert into contact_subscription (...)
values (...)
on conflict (company_id, customer_id, subscription_type_id)
where customer_id is not null
  do update set ...;

DMTX 已在 ContactSubscriptionService 中使用过类似模式:

SQL / 代码片段
on conflict (company_id, customer_id, subscription_type_id)
where customer_id is not null

以及:

SQL / 代码片段
on conflict (company_id, email, subscription_type_id)
where customer_id is null and email is not null

这比直接在 contact 主表上盲目加唯一索引更符合 DMTX 现有 PG 风格。


7. 哪些场景适合借鉴

场景是否适合借鉴 dmd-cloud 思路原因
事件幂等表推荐event_uuid 这类键天然是唯一幂等边界,PG ON CONFLICT 非常适合。
mapping 表推荐例如外部 ID → customer_id/contact_id 的映射,冲突目标明确。
current-state 表推荐例如按 customer_id 维护当前状态,仓库已有类似 ON CONFLICT (customer_id) 用法。
标签/订阅状态聚合推荐DMTX 已在局部使用 partial conflict target,可继续沿用。
legacy 联系人导入局部重构有条件推荐若某个来源有单一权威键,可用 PG upsert;但必须补齐 DataClean 副作用或明确不走 merge。
contact 主表默认身份解析不推荐直接借鉴当前是 OR 匹配 + merge 编排,直接换 upsert 会改变语义。
酒店 cardNo + email 组合身份有条件推荐只有在新增显式 GROUPED_COMPOSITE 模式、完成数据审计和冲突策略后,才适合用组合唯一索引兜底。

8. 不建议直接改 contact 主表的原因

8.1 会弱化或改变当前去重

当前 DMTX 的 Email/Mobile 多主键是 OR 语义。直接改成组合唯一,会让“只命中 email”或“只命中 mobile”的行不再等价于同一联系人。

这可能使联系人数量上升,影响:

  • 分群人数
  • 旅程触达
  • 评分结果
  • 退订状态
  • SCRM/企微联系人归属

8.2 会绕过 merge 副作用

SourceCommon.merge 和 DataClean merge 相关逻辑当前承担业务一致性。数据库 upsert 无法自动复刻:

  • 标签 union
  • WeCom 字段合并
  • user_ids_str 历史身份维护
  • loser 联系人删除
  • merge event
  • import 计数
  • Huawei/Wechat 定制逻辑

因此即使未来用 PG unique 兜底,也应由 DataClean/Resolver 决定身份,再由 DB 兜底并发一致性,而不是 DB 单独决定完整业务流程。

8.3 旧导入器是独立分叉

ContacatImportDisposeService 当前有自己的 Email/Mobile 查询、合并、删除逻辑,未走 DataClean 通用 ifPk 机制。

如果 contact 表上突然新增唯一约束,旧导入器可能出现:

  • 建索引失败:历史数据已重复。
  • insert/update 冲突异常:旧逻辑未预期。
  • 双重合并:应用层先 merge,DB 又触发 conflict。
  • 统计错误:导入成功/失败与真实写入不一致。

所以 legacy 导入必须先纳入身份策略框架,不能让它先碰撞新约束。


9. 推荐 DMTX 身份策略分层

建议不要把 ifPk 直接映射为数据库唯一约束,而是显式建模策略。

策略语义数据库能力默认性
OR_MERGE当前 DMTX 语义,任一主键命中即可,多个候选 merge。可用普通索引加速,不建议唯一约束。存量默认。
SINGLE_UNIQUE一个强身份键,如 external_id、CRM ID。可用规范化唯一索引 + ON CONFLICT显式开启。
GROUPED_COMPOSITE一组字段 AND 命中,如 cardNo + email可用组合唯一索引兜底。显式开启。
MAPPING_TABLE外部身份映射到 DMTX customer_id/contact_id强烈推荐 unique + upsert。新能力推荐。

9.1 OR_MERGE 保持现状

继续使用:

SQL / 代码片段
AttributesUtils.userPks
  -> PostgreSQLSource.search OR
  -> DataClean.handleUsers
  -> SourceCommon.merge

只补充:

  • 查询异常不能当作无命中新增。
  • 写入失败不能记成功。
  • 定时合并要与新策略隔离。

9.2 SINGLE_UNIQUE 使用 PG upsert

适合:

  • 外部会员 ID
  • CRM ID
  • DMD Contact Key
  • unionid/openid 的规范化派生键

示例:

SQL / 代码片段
create unique index concurrently uq_contact_external_id_norm
on contact (external_id_norm)
where external_id_norm is not null;

写入:

SQL / 代码片段
insert into contact (contact_id, customer_id, external_id_norm, ...)
values (?, ?, ?, ...)
on conflict (external_id_norm)
where external_id_norm is not null
do update set
  update_date = now(),
  update_by = excluded.update_by
returning contact_id, customer_id;

9.3 GROUPED_COMPOSITE 使用组合唯一兜底

适合酒店:

SQL / 代码片段
cardNo + email

建议先落在 identity mapping 表,而不是直接在 contact 主表上改:

SQL / 代码片段
create table contact_identity_key (
  id bigserial primary key,
  company_id bigint not null,
  rule_code varchar(64) not null,
  identity_hash varchar(128) not null,
  contact_id varchar not null,
  customer_id varchar not null,
  normalized_values jsonb not null,
  create_date timestamp not null default current_timestamp,
  update_date timestamp not null default current_timestamp
);

create unique index concurrently uq_contact_identity_key
on contact_identity_key (company_id, rule_code, identity_hash);

写入:

SQL / 代码片段
insert into contact_identity_key (
  company_id,
  rule_code,
  identity_hash,
  contact_id,
  customer_id,
  normalized_values
)
values (?, ?, ?, ?, ?, ?::jsonb)
on conflict (company_id, rule_code, identity_hash)
do update set
  update_date = now()
returning contact_id, customer_id;

这样可以借鉴 dmd-cloud 的“唯一边界 + upsert”思想,同时避免直接改 contact 主表导致历史数据和 merge 副作用失控。


10. 推荐落地路径

P0:事实校准与数据审计

动作说明
固化当前语义明确 DMTX 当前默认是 OR_MERGE
审计重复数据email、mobile、email+cardNo、external_id 等维度统计重复。
审计空值与格式NULL、空字符串、大小写、空格、手机号格式。
审计来源哪些导入来源有强单键,哪些依赖 DataClean merge。

P1:局部幂等场景先试点

优先选择:

  • 事件幂等表
  • current-state 表
  • identity mapping 表
  • 订阅/标签聚合类表

这些场景 conflict target 明确,比较适合 PG ON CONFLICT

P2:引入身份策略配置

新增策略枚举:

SQL / 代码片段
OR_MERGE
SINGLE_UNIQUE
GROUPED_COMPOSITE
MAPPING_TABLE

存量默认 OR_MERGE,新策略必须显式启用。

P3:酒店组合键 MVP

建议路径:

SQL / 代码片段
cardNo + email
  -> 规范化
  -> identity_hash
  -> contact_identity_key unique(company_id, rule_code, identity_hash)
  -> 返回 contact_id/customer_id
  -> DataClean 决定更新/新增/冲突

不要第一步就直接在 contact 主表建:

SQL / 代码片段
unique(card_no, email)

P4:是否进入 contact 主表改革

只有满足以下条件才考虑:

  • 已完成历史重复清理。
  • 已明确 survivor 规则。
  • 已明确冲突回写策略。
  • 已迁移 legacy 导入器。
  • 已证明 DataClean 副作用可以完整保留。
  • 已跑过灰度与回滚演练。

11. 测试与回滚

11.1 测试矩阵

类型用例
PG 唯一约束同 identity_hash 并发写入只生成一条映射。
ON CONFLICT RETURNING插入和冲突更新都返回正确 contact_id/customer_id
NULL/空值空 email/cardNo 不参与唯一识别。
规范化A@EXAMPLE.COMa@example.com 命中同一规范化值。
DataClean 兼容OR_MERGE 租户行为完全不变。
组合模式cardNo+email 完整一致更新,同 email 不同 cardNo 不合并。
冲突处理identity mapping 指向不同 contact 时进入冲突,不静默覆盖。
legacy 导入未迁移前明确阻断或标注不支持;迁移后结果与 ETL 一致。
下游回归分群、旅程、评分、退订、SCRM 仍通过 customer_id/contact_id/user_ids_str 正常运行。

11.2 回滚策略

对象回滚方式
策略配置公司从 SINGLE_UNIQUE / GROUPED_COMPOSITE 切回 OR_MERGE
新映射表保留不删除,作为审计;运行时停止读取。
新索引不立即 drop,确认无依赖后再清理。
写入逻辑feature flag 切回旧 DataClean 路径。
冲突记录保留,作为数据治理输入。

12. 工作量评估

范围内容预估
方案与数据审计固化 dmd-cloud 参考边界、DMTX 当前语义、重复数据审计 SQL。3-5 人天
局部 PG upsert 试点选一个 mapping/current-state 表,引入 unique + ON CONFLICT RETURNING5-8 人天
身份策略配置骨架OR_MERGE / SINGLE_UNIQUE / GROUPED_COMPOSITE 配置与读取。8-12 人天
identity mapping 表 MVP规范化、hash、unique、upsert、冲突记录。8-12 人天
DataClean 接入在组合模式下调用 mapping/Resolver,不破坏 OR_MERGE。10-16 人天
legacy 导入处理阻断、标识不支持,或迁移到新 Resolver。6-15 人天
测试与灰度PG 并发、DataClean 回归、下游回归、发布回滚。8-12 人天

总估算:

版本预估
只做局部 PG upsert 试点8-13 人天
酒店组合键 mapping MVP30-45 人天
完整接入 DataClean + legacy 导入 + 下游回归55-80 人天

13. 最终建议

  1. dmd-cloud 值得借鉴,但借鉴的是思想,不是直接搬 SQL。
  • MySQL LAST_INSERT_ID 换成 PG RETURNING
  • MySQL 前缀索引换成 PG 规范化列 / partial unique index。
  1. DMTX 不能直接把 contact 主表身份解析改成 unique + upsert。
  • 当前 DataClean 是 OR 匹配 + merge 副作用链。
  • 直接替换会改变联系人数量、合并规则和下游一致性。
  1. 推荐先从局部幂等能力借鉴。
  • mapping 表、事件表、current-state 表、标签/状态聚合表。
  • 这些场景 conflict target 清晰,更适合 PG ON CONFLICT
  1. 若要支持酒店组合键,建议先做 identity mapping 表。
  • company_id + rule_code + identity_hash 唯一。
  • 返回 contact_id/customer_id 给 DataClean。
  • DataClean 仍负责新增、更新、冲突和副作用。
  1. contact 主表组合唯一只能作为后续高级阶段。
  • 必须先清理历史重复。
  • 必须迁移 legacy 导入。
  • 必须证明 DataClean merge 副作用不会丢失。

一句话定稿:

DMTX 应选择性借鉴 dmd-cloud 的“业务唯一边界 + 数据库幂等写入”模式,在 PostgreSQL 下用 partial/expression/generated-column unique index 与 ON CONFLICT ... RETURNING 落地;但不能直接用它替换 DMTX 当前 contact 主表的 OR 匹配 + DataClean merge 语义。酒店组合键建议先通过 identity mapping 表和显式 GROUPED_COMPOSITE 策略实现,而不是直接在 contact 表上照搬 UNIQUE(email, cardNo)


14. 源码证据路径

dmd-cloud

文件证据
[dmd-cloud]/src/WebPower/Dmdelivery/Persistence/Legacy/DBTableInfoRepository.php从索引 unq 反推出字段 unq_combi=true
[dmd-cloud]/src/WebPower/Dmdelivery/Core/Campaign/Create/TableCreatorService.phprecipient 表默认 UNIQUE KEY unq(email(60))
[dmd-cloud]/src/WebPower/Dmdelivery/Core/Contacts/Table/ContactFieldStorer.phpunq_combi[] 构造组合字段,drop/add unq 唯一索引。
[dmd-cloud]/src/WebPower/Dmdelivery/Core/Contacts/Table/TableChanges.phpaddIndex(..., true, ...) 渲染为 ADD UNIQUE KEY
[dmd-cloud]/src/WebPower/Dmdelivery/Core/Interactor/Recipient/LegacyRecipientStorer.phpINSERT ... ON DUPLICATE KEY UPDATE id=LAST_INSERT_ID(id)
[dmd-cloud]/src/WebPower/Dmdelivery/Core/Tests/Interactor/Recipient/LegacyRecipientStorerTest.php测试断言生成 SQL 包含 ON DUPLICATE KEY UPDATE id=LAST_INSERT_ID(id)

DMTX

文件证据
[dmtx-biz-projects]/etl/src/main/java/com/qdum/dmtx/biz/etl/service/AttributesUtils.javauserPks 返回所有 ifShow && ifPk 字段。
[dmtx-biz-projects]/etl/src/main/java/com/qdum/dmtx/biz/etl/service/source/PostgreSQLSource.javasearch 对多个主键使用 OR 查询。
[dmtx-biz-projects]/etl/src/main/java/com/qdum/dmtx/biz/etl/service/kafka/DataClean.javahandleUsers 负责 add/update/merge 分流。
[dmtx-biz-projects]/etl/src/main/java/com/qdum/dmtx/biz/etl/service/source/SourceCommon.javamerge 负责 survivor、字段、标签、user_ids_str 等合并。
[dmtx-biz-projects]/contact/src/main/java/com/qdum/dmtx/biz/contact/service/ContacatImportDisposeService.javalegacy 导入有独立 Email/Mobile 合并逻辑。
[dmtx-biz-projects]/contact/src/main/java/com/qdum/dmtx/biz/contact/service/ContactSubscriptionService.java局部使用 partial ON CONFLICT,说明 PG upsert 在 DMTX 中适合明确状态表。
[dmtx-biz-projects]/contact/src/main/java/com/qdum/dmtx/biz/contact/service/dmartechservice/DmarTechHighSwarmService.java存在 ON CONFLICT (customer_id) DO UPDATE 的 current-state 用法。
[dmtx-biz-projects]/etl/src/main/java/com/qdum/dmtx/biz/etl/service/metadata/upgrade/UpgradeV20240328Service.java存在 ON CONFLICT (event_uuid) DO UPDATE 的事件幂等用法。

15. 质量自检

检查项得分说明
回答用户核心问题2/2明确 DMTX 是否借鉴 dmd-cloud。
dmd-cloud 事实核对2/2覆盖 unq_combiunqON DUPLICATE KEYLAST_INSERT_ID
PostgreSQL 适配2/2覆盖 ON CONFLICTRETURNING、NULL、partial/expression/generator 取舍。
DMTX 当前语义2/2明确 OR 匹配 + DataClean merge。
不照搬边界2/2明确不替换 contact 主链路。
可借鉴场景2/2给出 mapping/current-state/event/订阅等局部场景。
数据模型建议2/2给出 identity mapping 表和唯一索引。
风险和回滚2/2覆盖 legacy、DataClean 副作用、历史重复、灰度。
工作量评估2/2按试点、MVP、完整版本拆分。
源码证据2/2附录列出 dmd-cloud 与 DMTX 文件路径。

总分:20 / 20。可进入研发/架构评审。