You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

14 KiB

客户联系人图谱与联系人页签整节 spec(冻结版)

created: 2026-09-10(票 01 grilling 终稿,12 项用户拍板 + 2 项技术自定) 上游:票 00 原型 demo(活参照,7 轮验收修订)+ 需求权威 .scratch/opportunity-bugfix/lanhu-pages/A4-4-2_客户详情(联系人).md(u533 列表侧 / u536 图谱 15 条) 下游:02~07 全部实现票的唯一输入;与原型冲突时以本 spec 为准(spec 冻结晚于原型验收)。


一、决策全景(D1–D12 用户拍板 / T1–T2 技术自定)

# 决策点 拍板
D1 等级真值 双值模型:字典档位 = 等级初始值来源;图谱拖格子定级只改当前客户的联系人等级,永不回写字典
D2 职务变更流转 来源区分:等级来自字典(未手工定级)→ 职务变更按新职务档位重算;手工定级过 → 锁定不动,职务变更不覆盖。加 level_source 标记列实现
D3 分档表 三档制 10/6/3 原型转正:董事长/股东=10,采购·市场·人事经理=6,采购·市场专员与投标商务=3,「其他」无档(落右侧栏)。字典档位仅作初始值
D4 保存模型 保存才入库:编辑态全部变更(连线增删/定级/撤级)驻前端 pending,单一 save 端点事务落库;重置 = 前端丢弃。后端全量校验
D5 位移持久化 不持久化:布局纯由 level 推导(y=等级行常量,x=行内稳定序),零坐标列
D6 边模型 只有手动边,没有自动边——推翻原型「紧邻更高档」自动生成机制(u536 第 2 条)与删除否决机制(第 9 条);所有上下级连线由用户手动拉出;边表 source 列砍掉
D7 公司边 等级 10 自动挂公司 = 唯一保留的自动规则(u536 第 7 条)
D8 公司边语义 派生视图,不可删:不落库,读端点/前端按 level==10 实时算出;摘线 = 把节点拖离等级 10 格子(降级);豁免所有锁定约束(不算上级边也不算下级边,否则等级 10 节点被不可删的边永久锁死)
D9 并发冲突 独立图谱版本号customer_contact_graph meta 表(customer_id PK + version),save 乐观锁比对;页内编辑改职务致 level 跟随时也 bump version
D10 明文埋点 独立表 customer_contact_reveal_log(crm-customer 域内,结构化联系人/客户维度);采集失败自吞不阻塞明文返回(OpLogPort 纪律)
D11 联系人导入 独立表镜像形态contact_import_task / contact_import_fail 镜像 customer_import 全套(三段式/Analyzer 预检/fail 明细/clip500),Executor 骨架拍平复用
D12 contact_source 四项含其他:group_meeting(组会)/group_activity(组局)/referral(转介绍)/other(其他),与既有列注释一致;W-01 销号
T1 错误码段 沿用 crm-customer 67xxx(已用至 67016),从 67017 起(号位见 §五)
T2 规模上限 记录性预案(见 §六),票 03 读端点 + 票 08 demo 双验证,不提前实现

作废清单(spec 冻结即从原型行为中移除,demo 修订⑧回写,见 §七):自动生成边算法、重建触发(rebuildAutoEdges)、删除否决/tombstone、auto 多候选歧义规则(待钉点④随之作废)、边表 source 列。


二、数据模型

2.1 既有表变更:customer_contact

变更 形态
新增列 level_source tinyint not null default 1 comment '等级来源:1=字典跟随 2=手工锁定'(D2;手工拖格子定级后置 2,此后职务变更不覆盖 job_title_level;撤级清空 level 时一并回到 1)
job_title_level 语义 当前生效等级真值(1–10 或 null);null = 未设置等级(右侧栏)。已有四列留位(D5 遗产)直接启用

孤立单大写字母检查:levelSource / jobTitleLevel 均无孤立单大写,无需显式 @Column name。

2.2 新表:customer_contact_edge(手动边,唯一边存储)

id              bigint PK
customer_id     bigint not null          -- 边所属客户(冗余,免 join 校验)
parent_id       bigint not null          -- 上级联系人 id(level 高者)
child_id        bigint not null          -- 下级联系人 id(level 低者)
+ BaseEntity(deleted 软删 / create_time / …)
唯一索引 uk_edge_pair (customer_id, parent_id, child_id, delete_key)  -- 两点一条边兜底
索引 idx_edge_customer (customer_id)

约束(save 事务内全量校验):每 child 至多一条 parent 边(单父,公司节点豁免条款随 D8 派生化而无需存储层特例);无自连;无环(构造上 parentLevel > childLevel 严格递减保证,DFS 兜底);parent/child 必须同属该 customer 且 level 均非空。

2.3 新表:customer_contact_graph(图谱版本 meta,D9)

customer_id     bigint PK               -- 一客户一行
version         int not null default 0  -- 乐观锁版本
+ BaseEntity

首次读时懒建行;save 比对 version 不符 → 67017 冲突;save 成功 / 页内编辑 level 跟随 / 联系人增删(影响图谱节点集)均 bump version。

2.4 新表:customer_contact_reveal_log(明文查看埋点,D10)

id              bigint PK
customer_id     bigint not null
contact_id      bigint not null
operator_id     varchar(64) not null    -- 操作人用户 id(快照语义对齐 crm-log)
operator_name   varchar(64)            -- 姓名快照(可空,补全失败时)
op_time         datetime not null
ip              varchar(64)
索引 idx_reveal_contact (contact_id, op_time) / idx_reveal_time (op_time)

只写不管理(无查询页);e2e 直查表断言。

2.5 新表:contact_import_task / contact_import_fail(D11,镜像 customer_import 形态)

contact_import_task:customer_id(导入目标客户,任务语义与客户导入的差异点)+ import_mode
    (APPEND_ONLY/UPDATE_ONLY/UPSERT,查重键=客户内手机号)+ status/total/success/fail
    /file_id(模板留档)/creator_id/finish_time —— 其余列镜像 customer_import_task
contact_import_fail:task_id + row_num + name + phone + fail_reason(镜像 customer_import_fail)

2.6 crm-dict 缝合(票 02 落地,需 ADR + crm-dict CONTEXT.md 双文档化)

变更 形态
dict_item 新增列 level int comment '档位 1–10(仅 job_title 组职务项使用,空=无档)';其他组不用留空
种子组 job_title(两级树,复用行业 parentId 先例) 一级类别 5 项:executive(高层管理)/purchase(采购)/marketing(市场)/hr(人事)/other(其他);二级职务 9 项:chairman(董事长,10)/shareholder(股东,10)/purchase_manager(采购经理,6)/purchase_specialist(采购专员,3)/marketing_manager(市场经理,6)/marketing_specialist(市场专员,3)/hr_manager(人事经理,6)/bid_officer(投标商务,3)/other_job(其他职务,无档)——其他类别下 2 项(投标商务/其他职务),其余各类别 1–2 项;二级兑底 code 用 other_job 避免与一级 other 撞组内 code/name 唯一性
种子组 contact_source group_meeting(组会)/group_activity(组局)/referral(转介绍)/other(其他),全部无档位

三、端点契约(ADR-0017 纪律:flat 动词 + 查询参数传 id;写 POST / 读 GET;隐式表单绑定,禁 @PathVariable/PUT/DELETE/@RequestBody;深 tag 一级 = A4 客户管理)

3.1 图谱组(A4-4-2 §6.4,票 03)

端点 Method 入参 出参 / 错误
/api/customer/contact/graph/detail GET customerId GraphDTO{version, nodes[{contactId,name,jobTitleName,jobTitleCategory,level,levelSource,phoneMasked,isKeyContact}], edges[{parentId,childId}]}——公司边不含(前端按 level==10 派生渲染,D8);越权客户 → 67002
/api/customer/contact/graph/save POST customerId, version, nodes[i].contactId, nodes[i].level(全量快照,level 可空=撤级), edges[i].parentId, edges[i].childId(全量最终边集) Result<Long>(新 version);version 不符 → 67017;边非法(自连/重复/两点多条/等级倒挂/越界联系人)→ 67018;单父违反/环 → 67019

save 语义:全量快照 diff(后端按 customer_id 比对现库边集,增删落库;level 变更集批写 customer_contact 并维护 level_source;联系人被删过的边级联软删);成功后 version+1。

3.2 列表侧组(§6.1/6.2/6.3,票 04)

端点 Method 入参 出参 / 错误
/api/customer/contact/reveal GET id(contactId) 明文 phone +(记 reveal_log,失败自吞不阻塞);联系人不存在/越权 → 67008
/api/customer/contact/quickAdd POST customerId, rows[i].name/jobTitleCode/phone/source/isKeyContact 批量结果(成功数+失败明细行);行级非法(姓名/职务缺失、手机号格式)→ 67020
/api/customer/contact/batchEdit POST rows[i].id + 变更字段(name/jobTitleCode/phone/source/isKeyContact) 批量结果;职务变更按 D2 规则联动 level(字典来源重算 + bump graph version);行级非法 → 67021;越权 → 67008
/api/customer/contact/export POST customerId(全量导出该客户联系人) 走 BatchOpLog + MinIO 结果文件留档(LogBatch 先例,票 04 对齐 LeadPool 导出实现)
既有列表/详情端点 phone 出参一律脱敏(138****1234);等级徽标列 = job_title_level 回显

3.3 联系人导入组(§6.5,票 05,镜像客户导入六端点)

端点 Method 入参 出参
/api/customer/contact/import/template GET byte[] xlsx(列:姓名*、职务(字典 code)、手机号*、来源、关键联系人)
/api/customer/contact/import/upload POST file(multipart), customerId, importMode, duplicateStrategy? ImportPreviewDTO(预检明细 + fail 行);预检失败 → 67022
/api/customer/contact/import/confirm POST taskId task id(DRAFT→RUNNING 异步执行,对齐客户导入)
/api/customer/contact/import/result GET taskId ImportResultDTO;状态不允许 → 67023
/api/customer/contact/import/failures GET taskId List<ImportFailDTO>
/api/customer/contact/import/page GET ImportPageParam(keyword/status 分页) PageResult<ImportResultDTO>

Analyzer 预检清单(D-05/D-04 教训前移):NOT NULL 无默认列必填校验、手机号 11 位格式、职务 code 存在于 job_title 字典、来源 code 存在于 contact_source 字典、客户内手机号查重(按 importMode/duplicateStrategy 判定)、行数 clip500。导入落库的联系人 level 按 D1 初始值填充(字典档位),level_source=1。

3.4 字典组(票 02,零新端点)

  • 职务二级级联:复用 GET /api/dict/item/tree?groupCode=job_title(行业两级树先例,DictItemDTO 增 level 回显);选择器落库写 customer_contact 四列(job_title_id/name/category/level)。
  • 来源下拉:复用 GET /api/dict/item/enabled-list?groupCode=contact_source

四、校验与规则快照(后端 save 全量校验清单)

  1. 单父:全量快照中每个 childId 至多出现一次(公司节点不在快照内,天然豁免)。
  2. 两点一条边:(customer, parent, child) 快照内查重 + DB 唯一索引兜底。
  3. 无环:构造上边要求 parentLevel > childLevel(等级严格递减 → 有向无环);save 校验每条边等级关系成立(未定级节点不得作为边端点),DFS 检测降为兜底断言。
  4. 等级约束(修订⑦口径):有上级连线不能调级;只有下级连线可调但新等级须高于最高下级;撤级须无任何边——后端按全量快照终态校验(前端编辑态即时提示同口径)。
  5. level ∈ {null, 1..10};contactId 必须属于 customerId 客户。
  6. 事务边界:save 单事务(diff + level 批写 + version CAS +1);任一校验失败整体回滚。

五、错误码分配(T1:67xxx 从 67017 起)

语义
67017 图谱版本冲突(save 乐观锁;提示刷新后重试)
67018 图谱边非法(自连/重复/两点多条/等级倒挂/端点越界)
67019 图谱结构校验失败(单父违反/环)
67020 联系人快速添加行级非法
67021 联系人页内编辑行级非法
67022 联系人导入预检失败(文件/表头/行数/字典 code/查重策略)
67023 联系人导入任务状态不允许

号位实现期可顺延微调,段位 67xxx 不变。


六、规模上限预案(T2,记录性)

  • 设计基线:单客户 ≤500 联系人、≤1000 边。图全量读端点一次返回(数百节点 JSON < 1MB),demo 前端绝对定位卡片 + SVG 连线渲染可行(票 08 验证)。
  • 超限形态:10 个等级行 × 50+ 列的水平挤压——预案 = 行内虚拟滚动或卡片收缩视图,不在本期实现;票 03 读端点预留 nodes 数组结构不变,票 08 用 500 节点 mock 压测 demo 渲染。

七、demo 修订⑧(spec 冻结行为对齐,票 06 前置)

原型 prototype-contact-graph.html 与本 spec 的行为差异,票 06 接真实 API 前回写(直改 prototype/ 续记修订⑧):

  1. 砍自动生成边:删 rebuildAutoEdges/autoParentsOf 与等级 100→10 残留逻辑,初始边全部改手动(mock 数据同)。
  2. 公司边派生化:公司边不显示 ×(不可删),摘线=降级;等级锁定校验豁免公司边。
  3. 无否决/tombstone 交互(随 auto 边一并消失)。
  4. level 来源标记(字典跟随/手工锁定)在页内编辑-职务变更联动上体现(demo mock 可简化为提示文案)。

八、交付物落点

  • 本 spec = 02(字典前置)/ 03(图谱后端)/ 04(列表侧增强)/ 05(联系人导入)/ 06(demo 接 API)/ 07(E2E)的唯一契约输入。
  • 票 02 产出:crm-dict 缝合 ADR + dict_item.level 列 + 两组种子 + CONTEXT.md 更新。
  • 票 03/04/05 按 §二/§三 落 DDL(ddl-auto: update 建表,注意 BOM 与孤立单大写纪律)与端点。