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 全量校验清单)
- 单父:全量快照中每个 childId 至多出现一次(公司节点不在快照内,天然豁免)。
- 两点一条边:(customer, parent, child) 快照内查重 + DB 唯一索引兜底。
- 无环:构造上边要求 parentLevel > childLevel(等级严格递减 → 有向无环);save 校验每条边等级关系成立(未定级节点不得作为边端点),DFS 检测降为兜底断言。
- 等级约束(修订⑦口径):有上级连线不能调级;只有下级连线可调但新等级须高于最高下级;撤级须无任何边——后端按全量快照终态校验(前端编辑态即时提示同口径)。
- level ∈ {null, 1..10};contactId 必须属于 customerId 客户。
- 事务边界: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/ 续记修订⑧):
- 砍自动生成边:删 rebuildAutoEdges/autoParentsOf 与等级 100→10 残留逻辑,初始边全部改手动(mock 数据同)。
- 公司边派生化:公司边不显示 ×(不可删),摘线=降级;等级锁定校验豁免公司边。
- 无否决/tombstone 交互(随 auto 边一并消失)。
- 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 与孤立单大写纪律)与端点。