系统设计
SYSTEM DESIGN

DMTX 联系人组合主键能力技术方案

将联系人身份识别从单字段 Email/Mobile 升级为管理员可配置的组合匹配键,解决酒店集团同邮箱不同会员卡导致属性覆盖的问题。

solution.md 2026-06-05 ~18 分钟阅读

1. 需求概述

要点

客户真正要解决的不是数据库物理联合主键,而是联系人身份识别规则从“单字段命中即同一人”升级为“多字段组合同时命中才同一人”。

验收项明确定义
管理员配置管理员可以在联系人字段管理中选择 2 到 5 个字段组成一套身份识别规则,例如 cardNo + email
导入识别导入同一 cardNo + email 时更新同一联系人;导入同邮箱但不同 cardNo 时创建/保留不同联系人。
属性不覆盖酒店 A、酒店 B 的偏好字段、标签、分群结果不因邮箱相同而互相覆盖。
兼容存量未启用组合规则的租户,现有 Email/Mobile 主键、导入、API、分群、旅程、退订查询行为不变。
冲突可见cardNo 命中联系人 A、email 命中联系人 B 且无法判断同一人时,不静默合并,进入冲突池或返回明确错误。
可回退规则启用前有预检,启用后可禁用或回滚到旧规则;历史冲突和规则变更有审计记录。

2. 竞品方案调研

竞品实现方式优点缺点DMTX 可借鉴之处
HubSpot联系人默认以 Email 自动去重;API 支持 Record ID、Email 或自定义唯一属性 idProperty 读取/Upsert。导入缺少 Email 或其他唯一标识时会创建新联系人;多个联系人共享同一导入 Email 时会报错跳过。规则清晰,默认去重易理解;自定义唯一属性适合集成。Contact Email 仍是强唯一语义,不适合“同邮箱多会员关系”。提供明确唯一标识配置、导入预检、冲突提示,而不是静默合并。
KlaviyoProfile 至少包含 emailphone_numberexternal_id 之一;Create or Update Profile 可按标识创建或更新。官方建议优先用 email/phone,external_id 属高级用法,使用不一致会产生难清理重复 Profile。API 同步友好;明确提示 external_id 使用风险;提供 merge 能力。external_id 与 email/phone 使用不一致时可能产生重复,不自动兜底。组合规则字段必须稳定、一致地随数据写入,并提供冲突/重复治理。
Salesforce Marketing Cloud使用 Contact Key / Subscriber Key 作为跨 Email、SMS、Push 的稳定唯一身份;明确建议避免用 email 作为 Subscriber Key,同一 email 可以绑定多个 Subscriber Key。企业级身份模型成熟,支持共享邮箱、多关系、多渠道。对前期身份设计要求高,后期改 Key 成本大。把组合主键定位为“稳定业务身份键”,不要把可变 email 当唯一事实源。

✓ 优点

  • 三类竞品都强调稳定身份或唯一属性,而不是把渠道字段无条件当作人。
  • Salesforce 明确支持同一 email 对应多个 Subscriber Key,符合酒店共享邮箱/多会员关系。
  • HubSpot、Klaviyo 的导入和 API 经验说明:预检、冲突提示、唯一属性治理很关键。

✕ 缺点

  • HubSpot 默认 Email 去重不适合本场景,照搬会继续覆盖不同酒店偏好。
  • Klaviyo 的 external_id 若使用不一致,会产生难清理重复 Profile。
  • Contact Key 类能力一旦上线,后续改规则成本高,DMTX 必须先做预检和灰度。

3. 当前系统现状与影响面分析

层级证据文件现状改造含义
元数据MetadataFieldDTO.java:91
AbsMetaDataService.java:109
ContactMetaDataService.java:40
字段级 if_pk/ifPk 标记和 pkFieldMapping 缓存。新增规则级模型,不能继续用多个布尔字段表达组合语义。
Open APIOpenAttributeDTO.java:11
OpenAttributeDTO.java:13
openResource.java:87/124/202/236
只接收单个 name/value 并调用 getContactId(companyId, name, value)扩展为 keyAttributes,保留旧单字段兼容。
动态 SQLContactInfoService.java:1085
ContactInfoService.java:1089
ContactInfoService.java:1094
字段名拼接到 SQL,值参数化。字段名必须经过元数据/规则白名单映射,避免非法字段和 SQL 注入风险。
导入合并ContacatImportDisposeService.java:49
ContacatImportDisposeService.java:120
ContacatImportDisposeService.java:266
ContacatImportDisposeService.java:311
onlyEmailonlyMobileemailMobile 三桶处理;email/mobile 命中不同联系人时会合并并删除一条。组合规则启用后,不能再因为 email 相同就合并酒店 A 和酒店 B 的会员。
物理表create_table_contact.sql:1
create_table_contact.sql:3
create_table_contact.sql:11
create_table_contact.sql:12
contact.id 是物理主键,contact_id/customer_id 是业务稳定 ID。不建议把表物理主键改成 (cardNo, email),应新增组合业务身份索引。
受影响模块主要文件/模块影响说明
元数据MetadataFieldDTOAbsMetaDataServiceContactMetaDataServiceCustomerFieldListDTO当前只有字段级 ifPk,需新增规则级模型。
手工保存ContactInfoService.saveSingleContactverifyEmailverifyMobileupdateInOtherGroup从单字段重复校验升级为统一身份解析。
批量导入ContacatImportDisposeServiceContactGroupUploadServiceEmail/Mobile 三桶逻辑需替换为规则驱动匹配/新增/冲突。
Open APIOpenAttributeDTOopenResourceContactServiceOpenApiImplContactServiceApiImpl单字段 name/value 需要兼容扩展为多字段 keyAttributes
外部同步ScrmImportApiServiceELTApiServiceUpdateContactFieldArrayServiceCustomerInfoFieldControlServiceSCRM、企微、标签、画像字段同步需明确稳定 ID 与组合键优先级。
下游消费rtjourneyscoreratingContactSubscriptionService、报表/导出不建议替换 customer_id,但必须回归验证统计、旅程、退订一致性。

4. 用户体验设计

1

进入身份识别规则

入口建议放在 系统设置 / 数据管理 / 联系人字段管理 / 身份识别规则,延续当前字段主键配置心智。

管理员设置入口
2

选择模板或字段组合并预检

酒店模板默认推荐 会员卡号 cardNo + 邮箱 email;预检展示可识别人数、缺失字段、重复组合键、部分冲突。

≤3步预检
3

确认启用并查看冲突入口

规则启用按钮必须在预检通过后才可点击;冲突数据进入冲突池,不自动合并。

审计可回退
配置项默认值说明
规则状态未启用存量租户不受影响。
规则模板当前单字段主键兼容模式初次进入时显示“当前仍按 Email/Mobile 等单字段识别”。
组合字段数2 到 5 个字段v1 不建议超过 5 个,避免索引、缺失率和解释成本过高。
字段必填组合字段全部必填v1 建议 all-match,缺字段进入“缺失主键字段”提示,不自动回退。
字符串规范化trim + 小写 email邮箱默认 trim/lowercase;手机号可 E.164;卡号默认 trim,保留大小写可配置。
冲突策略阻断并进入冲突池不做静默合并,保护客户数据。
规则变更必须预检变更字段前展示影响人数、冲突数、缺失数。
导入页指标示例用户看到的含义
本次识别规则cardNo + email本次不是单独按 email 合并。
预计新增1,240组合键不存在,将创建新联系人。
预计更新8,700组合键已存在,将更新同一联系人。
缺失主键字段86缺少 cardNo 或 email,需下载错误明细。
冲突12部分字段命中不同联系人,需管理员处理。

5. 技术方案

推荐采用 “稳定内部 ID + 管理员可配置组合匹配键” 的双层身份方案:继续使用 contact.id 作为物理主键,继续使用 customer_id/contact_id 作为 DMTX 内部稳定身份和下游关联键;新增规则表、规则字段表、身份索引表和冲突表;组合键只决定“找到谁、更新谁、新建谁、是否冲突”,不直接替换下游 customer_id

身份解析流程

联系人组合主键身份解析流程 导入、API 或手工保存先读取启用规则,经过字段白名单、规范化、hash,再根据身份索引唯一命中、未命中或冲突执行不同动作。 导入/API/保存 读取规则 白名单+规范化 生成 hash 身份索引命中? 更新 新建 冲突 唯一未命中多命中
组合主键启用后,所有写入和查询入口先进入统一身份解析器。

数据模型变更

用途关键字段关键约束/说明
contact_identity_rule保存每个租户启用的联系人身份规则。company_idobject_typerule_namerule_codematch_modestatusversionmissing_key_strategyconflict_strategylegacy_fallback_enabled建议唯一启用规则:(company_id, object_type) where status='ENABLED'
contact_identity_rule_field保存规则字段顺序、规范化方式和必填性。rule_idfield_namecolumn_namefield_typeordinalrequirednormalizerblank_as_nullv1 默认所有组合字段必填,ordinal 参与 hash 生成。
contact_identity_value承载组合键到 contact_id/customer_id 的快速定位。company_idrule_idrule_versioncontact_idcustomer_idcomposite_key_hashkey_payload_jsondisplay_keystatussource建议唯一索引:(company_id, rule_id, composite_key_hash) where status='ACTIVE'
contact_identity_conflict记录冲突和人工处理状态。conflict_typeincoming_payload_jsonmatched_contacts_jsonstatusresolution_actionresolved_by冲突类型包括 PARTIAL_MATCH_DIFFERENT_CONTACTDUPLICATE_COMPOSITE_KEYMISSING_REQUIRED_KEY
CREATE UNIQUE INDEX ux_contact_identity_rule_enabled
ON contact_identity_rule(company_id, object_type)
WHERE status = 'ENABLED';

CREATE UNIQUE INDEX ux_contact_identity_value_active
ON contact_identity_value(company_id, rule_id, composite_key_hash)
WHERE status = 'ACTIVE';

接口设计

管理端:查询当前身份规则
GET /api/contact/identity-rules/current?companyId=10001
{
  "code": 200,
  "message": "success",
  "data": {
    "enabled": true,
    "ruleId": 9001,
    "ruleName": "酒店会员身份规则",
    "matchMode": "ALL_FIELDS",
    "status": "ENABLED",
    "version": 3,
    "fields": [
      { "fieldName": "cardNo", "displayName": "会员卡号", "required": true, "normalizer": "TRIM", "ordinal": 1 },
      { "fieldName": "email", "displayName": "邮箱", "required": true, "normalizer": "LOWER_TRIM", "ordinal": 2 }
    ],
    "conflictStrategy": "BLOCK_AND_REVIEW",
    "legacyFallbackEnabled": false
  }
}
管理端:预检规则
POST /api/contact/identity-rules/preview
{
  "companyId": 10001,
  "ruleName": "酒店会员身份规则",
  "matchMode": "ALL_FIELDS",
  "fields": [
    { "fieldName": "cardNo", "normalizer": "TRIM", "required": true, "ordinal": 1 },
    { "fieldName": "email", "normalizer": "LOWER_TRIM", "required": true, "ordinal": 2 }
  ],
  "conflictStrategy": "BLOCK_AND_REVIEW"
}
{
  "code": 200,
  "message": "preview success",
  "data": {
    "totalContacts": 1200000,
    "canBuildIdentity": 1130000,
    "missingRequiredKey": 62000,
    "duplicateCompositeKey": 180,
    "partialConflict": 27,
    "estimatedIndexRows": 1130000,
    "canEnable": false,
    "downloadConflictFileUrl": "/api/contact/identity-rules/preview/9001/conflicts.csv"
  }
}
Open API:兼容扩展 OpenAttributeDTO
public class OpenAttributeDTO implements Serializable {
    private Long userId;
    private Long companyId;

    // 旧单字段模式,继续兼容
    private String name;
    private String value;

    // 新组合键模式
    private Map<String, String> keyAttributes;
    private Long identityRuleId;
}
{
  "companyId": 10001,
  "userId": 88,
  "keyAttributes": {
    "cardNo": "A001",
    "email": "zhangsan@example.com"
  }
}
{
  "code": 409,
  "message": "identity conflict",
  "data": {
    "conflictType": "PARTIAL_MATCH_DIFFERENT_CONTACT",
    "conflictId": 778812,
    "hint": "cardNo and email match different existing contacts; manual review required"
  }
}

后端与前端实现建议

contact/src/main/java/com/qdum/dmtx/biz/contact/service/identity/
├── ContactIdentityRuleService.java
├── ContactIdentityResolverService.java
├── ContactIdentityNormalizer.java
├── ContactIdentityIndexService.java
├── ContactIdentityConflictService.java
├── dto/ContactIdentityRuleDTO.java
├── dto/ContactIdentityResolveRequest.java
├── dto/ContactIdentityResolveResult.java
└── enums/IdentityResolveStatus.java
返回状态含义写路径动作
MATCHED组合键唯一命中一条联系人。更新该 contact_id/customer_id
NO_MATCH组合键未命中。新建联系人并写身份索引。
MISSING_REQUIRED_KEY缺少必填组合字段。导入错误或 API 400。
CONFLICT部分字段或索引命中冲突。写冲突池,API 409,不自动合并。
LEGACY_MATCHED未启用组合规则时按旧单键命中。保持兼容。

导入改造后的目标流程:

读取启用规则
  → 对每行提取组合字段
  → 规范化并生成 composite_key_hash
  → 批量查 contact_identity_value
  → 按结果分桶:更新 / 新增 / 缺失字段 / 冲突
  → 批量写 contact 和 contact_identity_value
  → 生成导入结果报告
页面/组件功能
联系人字段管理 / 身份识别规则 Tab展示当前规则、创建规则、启用/禁用、查看版本。
规则字段选择器只能选择可参与身份识别的字段,展示字段类型、缺失率、重复率。
规则预检结果页展示可启用、缺失字段、重复组合键、冲突数量。
导入预检摘要卡在联系人导入页展示本次数据按当前规则的新增/更新/冲突统计。
冲突池列表按冲突类型、导入批次、字段值筛选和处理。

6. 边界情况与风险

场景处理策略优先级
同邮箱不同卡号组合键不同,应保留为不同联系人,不覆盖属性。P0
同卡号同邮箱重复导入命中同一组合键,更新同一联系人。P0
缺少 cardNo 或 emailv1 默认拒绝导入该行,导出错误明细;不自动回退 email 单键。P0
cardNo 命中 A、email 命中 B写入冲突池并阻断,不静默合并。P0
存量数据已有重复组合键预检时阻断启用,管理员先处理冲突或标记忽略。P0
email 大小写/空格不同邮箱默认 trim + lowercase,hash 使用规范化值。P1
卡号前导零卡号按文本处理,默认 trim 但不转数字,避免 00123123P1
API 只传旧 name/value未启用组合规则时按旧逻辑;启用组合规则后建议返回提示或仅唯一命中时兼容。P1
性能问题使用 contact_identity_value.composite_key_hash 唯一索引,批量导入按 hash 批查。P1
动态字段 SQL 注入字段名必须从规则字段白名单映射到列名,禁止直接拼接外部 keyP0
回滚禁用规则只影响后续写入;已创建/更新数据通过审计和冲突池处理,不自动删除。P1

7. 工作量评估

模块工作项预估人天
产品/技术梳理字段来源确认、酒店场景规则确认、存量数据预检口径、接口兼容策略。3-5
数据模型与迁移新增规则表、规则字段表、身份索引表、冲突表、DDL、索引、回填脚本。4-7
后端核心能力ContactIdentityRuleServiceContactIdentityResolverService、规范化、hash、冲突判断、审计。6-9
导入改造替换 Email/Mobile 三桶逻辑,导入预检,批量写身份索引,错误明细。6-9
手工/API 保存改造改造 ContactInfoService.saveSingleContact、旧 verifyEmail/verifyMobile 兼容、字段白名单。3-5
Open API/内部 API扩展 OpenAttributeDTO,改造按主键查询属性、标签、评分、退订接口。3-5
SCRM/企微同步明确稳定 ID 与组合键优先级,改造相关同步服务,回归外部链路。5-8
前端管理端身份规则页、字段选择、预检结果、冲突池入口、导入摘要卡。5-8
测试单元测试、导入集成测试、API 测试、回归矩阵、性能测试。6-8
联调/灰度酒店试点、数据回填校验、监控看板、灰度开关、上线支持。4-6
合计完整平台化可复用能力。45-70
MVP 模块工作项预估人天
后端规则与索引固定组合规则、hash、身份索引、冲突表。5-7
导入/手工保存导入预检、写路径 resolver、禁用 email 单独合并。5-7
查询/API 兼容keyAttributes 查询、旧接口兼容、字段白名单。2-4
简化配置/实施脚本后台配置、回填脚本、冲突导出。3-4
测试/灰度酒店样例、批量导入、回归、上线验证。5-6
合计单客户可交付 MVP。20-28

8. 方案对比

方案 A:多个 ifPk=true

不推荐。实现看似快,但语义不清,容易继续按单字段命中并合并。

方案 C:数据库物理联合主键

不推荐。会影响大量外键、缓存、旅程、评分、导出、API 和历史数据。

维度方案 A:多个 ifPk=true方案 B:规则对象 + 身份解析器 + 身份索引方案 C:数据库联合主键
实现复杂度中高极高
用户体验表面简单,但解释不清“或/且”关系。管理员配置清晰,运营只看预检结果。用户无感,但系统迁移风险巨大。
数据安全高风险,容易继续按单字段合并。可控,冲突阻断并审计。高风险,历史关联和下游外键易断。
扩展性差,只能堆字段布尔标记。好,可支持模板、规则版本、冲突治理。差,物理主键与业务规则强耦合。
工期1-2 周但不可控。MVP 3-4 周,平台化 4-7 周。2-3 月以上且风险高。

9. 实施阶段计划

0

客户场景与字段确认

确认 cardNo 是真实列还是动态扩展字段;确认 cardNo + email 是否全部必填;确认同邮箱不同卡号必须允许重复邮箱。

需求确认
1

规则模型和身份解析器骨架

新增规则表、规则字段表、身份索引表、冲突表;新增 ContactIdentityResolverService 和 normalizer;字段名校验收敛到元数据白名单。

后端核心
2

导入和手工保存改造

改造 ContacatImportDisposeService.ContactImportSaveOrUpdateContactInfoService.saveSingleContact;组合规则启用后禁用 email 单独合并和 deleteContactIds 静默删除。

写路径
3

Open API 和查询链路改造

扩展 OpenAttributeDTO.keyAttributes;改造按主键查询属性、标签、评分、退订接口;旧 name/value 保留兼容。

API
4

外部同步和下游回归

SCRM/企微同步接入统一 resolver;明确 customer_id/contact_id/external_userid 高于组合键;回归分群、旅程、评分、退订、导出。

回归
5

灰度上线与治理

酒店客户灰度启用,监控导入命中率、冲突率、重复创建率、API 查询失败率;冲突池处理流程上线。

灰度监控

10. 测试与验证计划

测试类型重点用例
单元测试normalizer、hash 生成、字段白名单、resolver 状态判断。
导入集成测试cardNo+email 更新;同 email 不同 cardNo 新增;缺字段报错;部分命中冲突。
手工保存测试新增联系人、编辑组合字段、编辑非组合字段、旧租户兼容。
Open API 测试name/value、新 keyAttributes、冲突 409、字段非法 400。
SCRM/企微测试有稳定 ID 优先、无稳定 ID 走组合键、冲突阻断。
下游回归分群人数、旅程触发、评分记录、退订状态、标签读取、导出字段。
性能测试10 万/100 万联系人身份索引回填;批量导入按 hash 查询;冲突查询分页。
安全测试动态字段 SQL 注入、越权字段查询、冲突明细脱敏。

11. 待确认问题

  1. cardNo 是标准字段、扩展字段,还是客户导入时动态生成字段?
  2. 酒店业务中 cardNo 是否全集团唯一,还是只在酒店/品牌维度内唯一?如果只在酒店内唯一,组合键可能需要 hotelCode + cardNo + email
  3. 缺少 cardNo 但有 email 的数据,客户希望拒绝、进入错误表,还是按旧 email 逻辑导入?本方案建议 v1 拒绝。
  4. 客户是否已有历史数据被 email 合并覆盖?如果有,是否需要历史拆分工具?本方案当前只覆盖后续识别,不自动还原历史覆盖。
  5. Open API 调用方是否能同步升级到 keyAttributes?如果不能,需要多长兼容期?
  6. 组合字段后续是否允许修改?如果允许,需要明确变更流程和历史 alias 策略。

12. 质量自检评分

维度检查项分值自评
A. 需求理解客户原始需求已记录;验收标准明确。44
B. 竞品调研至少 2 个竞品;有 DMTX 差异化策略。44
C. 用户体验操作流清晰;核心步骤 ≤ 3;默认值合理。66
D. 技术完整度数据模型;API;边界情况。66
E. 可行性工作量;风险点。44
F. 附加分多方案对比;扩展性。22
总分2626

13. 最终建议

要点

本需求应按“联系人身份治理能力”立项,而不是按“字段主键小优化”处理。建议采用两级范围:客户 MVP 先支持酒店 cardNo + email,预计 20-28 人天;平台能力沉淀为可配置组合身份规则、冲突池、规则版本和 Open API 兼容,预计 45-70 人天。