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.
 
 
 
 
 

8.7 KiB

转商机 outbound port 契约(crm-lead → 商机模块)

Type: grilling Status: resolved Blocked by: 06

Question

商机模块尚不存在(见 ticket 01)。转商机不能对接现成服务,只能定义 crm-lead 向未来商机模块期望的 outbound port 契约(定期望、非对接实现)。

需在本 ticket 定清:

  1. 触发条件:仅"跟进中/有效"状态线索可转(对齐 ticket 03 状态矩阵);已转商机线索不可重复转。
  2. 入参契约:转商机弹窗字段——商机名称(必填)、行业(必填,字典 industry)、甲方(可选)、意向客户(必填)、地区(必填)、备注(可选),加上从线索带出的字段(线索 id、客户信息、需求产品、领取人/部门)。
  3. port 接口形态:crm-lead 定义一个 OpportunityCreationPort 接口(入参 DTO + 返回商机 id),实现由商机模块将来提供;未实现时的降级/占位策略(spec 里标注"待商机模块落地")。
  4. 线索侧副作用:线索状态 → 已转商机;写一条"转商机"历史记录(含生成的商机名称/id);线索是否仍保留在我的线索列表(原型:已转商机线索仍在列表,仅隐藏释放按钮)。
  5. 失败/回滚语义:商机创建失败时线索状态是否回滚;幂等(防重复转)。
  6. 依赖 06 的线索字段模型确定入参映射;依赖 03 的状态矩阵确定触发条件与副作用。

产出:outbound port 接口签名 + 入参 DTO + 副作用与失败语义,写入 ## Answer。属 crm-lead spec 的"对外依赖契约"章节。

Decisions(grill 定稿)

  • [Q1=a] 触发条件 = 仅看状态:status IN (已领取, 跟进中)。反馈情况完全不参与判定。推翻原型"跟进中/已联系/有效"+"禁止:无效/未反馈"口径(产品口径改)。
  • [Q2 弹窗字段] 6 字段数据源:
    • 商机名称 = 纯文本
    • 行业 = 字典 code opportunity.industry(商机模块将来创建字典分组,crm-lead spec 仅声明依赖)
    • 甲方 = 纯文本(无客户模块;前端 UX 从线索历史 lead_name 建议候选,最终存文本)
    • 意向客户 = 纯文本(同上,无客户模块)
    • 地区 = region_code varchar(12) 存国标 code;支持前缀 LIKE 祖先查询(见 02 Amendment)
    • 备注 = 纯文本
  • [Q3=a] port 形态 = 同步接口 OpportunityCreationPort#createOpportunity(cmd) → opportunityId;crm-lead 事务内调用;商机模块未落地时 spec 里注明"接口签名待商机模块实现",运行期无 bean 时报明确错误。
  • [Q4=a] 失败/回滚 = 本地事务性回滚@Transactional 包住"改状态+调 port+写历史",port 抛异常整个事务回滚,线索状态不变)。假设商机模块与 crm-lead 同进程/同库。
  • [Q5=a+c] 幂等 = 双保险:
    • crm-lead 侧:前置校验 status IN (已领取, 跟进中),已转商机是终态(03 定),重复点第二次天然被拦下(方案 a)
    • 商机侧:port 契约要求商机表加 UNIQUE(source_lead_id),重复插入自然失败(方案 c)

Answer

一、outbound port 接口签名(crm-lead 定义,商机模块实现)

package com.crm.lead.port.outbound;

/** crm-lead 声明的 outbound port,实现由商机模块提供 */
public interface OpportunityCreationPort {

    /**
     * 由线索创建商机。
     * 同步调用,crm-lead 事务内触发;失败抛异常整个转商机流程回滚。
     * 商机侧须保证 UNIQUE(source_lead_id) 幂等语义。
     *
     * @return 新建商机 id(用于写入 lead_history 详情)
     * @throws OpportunityCreationException 商机侧任何失败均抛此异常
     */
    Long createOpportunity(CreateOpportunityCmd cmd);
}

二、入参 DTO

public record CreateOpportunityCmd(
    // 从弹窗表单
    Long sourceLeadId,                  // 来源线索 id(幂等 key)
    String opportunityName,             // 商机名称 * 必填
    String industryCode,                // 行业字典 code (opportunity.industry) * 必填
    String partyA,                      // 甲方 (自由文本, 可空)
    String intendedCustomer,            // 意向客户 (自由文本) * 必填
    String regionCode,                  // 地区国标 code (varchar 12) * 必填
    String remark,                      // 备注 (自由文本, 可空)

    // 从线索带出(crm-lead 服务内组装, 不由前端上送)
    String sourceLeadName,              // 线索名称快照
    String sourcePhone,                 // 联系电话快照
    String sourceProductCode,           // 需求产品 code 快照
    Long ownerUserId,                   // 领取人 = 转商机操作人
    Long ownerDeptId,                   // 领取人所属部门快照
    Long poolId                         // 来源公海池 id
) {}

三、副作用(crm-lead 侧, 本地事务内)

BEGIN TX
  1. 前置校验:
     - lead.status IN (已领取, 跟进中)           否则抛 IllegalStateException("状态不允许转商机")
     - 当前用户 = lead.owner_user_id             否则抛权限异常(只有领取人自己能转)
  2. 调 port.createOpportunity(cmd) 拿 opportunityId
  3. UPDATE lead SET status = '已转商机' WHERE id = ? AND status IN ('已领取','跟进中')
     - 行数 = 0 抛并发异常(乐观锁)
  4. INSERT lead_history (lead_id, op_type='CONVERT', op_time=now, op_user_id, detail)
     - detail 含 { opportunityId, opportunityName }
  5. 冻结时效计时器(03 定稿:转商机后 recycle_deadline / expire_deadline 停止判定)
     - 实现:定时任务扫描时 WHERE status != '已转商机',无需 UPDATE
COMMIT

四、后置约束(对齐 03 定稿)

  • 已转商机 = 终态,锁死所有编辑:不再支持反馈、释放、编辑。
  • 已转商机线索仍在"我的线索"列表可见,操作列隐藏【释放】【转商机】【反馈】【编辑】按钮,仅保留【详情】【取消关注】(若已关注)。
  • 时效计时器冻结(不再回收、不再失效)。

五、失败语义

失败点 处理
前置校验(状态/权限) 直接拒绝,无副作用
port 调用异常 整个事务回滚,线索状态不变,无历史日志
port 成功但后续 UPDATE 失败 事务回滚。注意:商机侧因 UNIQUE(source_lead_id) 已插入的记录会被 crm-lead 本地事务回滚看不到——因为若 port 是本地 bean 走同一 DataSource 事务则完全回滚;若 port 跨库/跨进程则需商机侧提供补偿接口(当前假设同库同事务,spec 只留 TODO)

六、幂等保证

  • crm-lead 侧:状态终态保护 + 乐观锁 WHERE 状态条件(第 3 步)。第二次转会因 WHERE 找不到行而抛异常。
  • 商机侧:port 契约要求商机表 UNIQUE(source_lead_id),即使 crm-lead 侧防线被绕过,商机侧也保证一线索最多一商机。

七、原型待 UX 修订

产品口径改(已领取 + 跟进中都能转),原型 3 处文案作废:

  • a2-1-2____.txt:18264.转商机:仅有效 / 跟进中 / 已联系线索可见 → 改为 仅已领取 / 跟进中线索可见
  • a2-1-2____.txt:1898状态为跟进中 / 已联系 / 有效的线索可点击【转商机】按钮 → 改为 状态为已领取 / 跟进中的线索可点击【转商机】按钮
  • a2-1-2____.txt:1929-1930
    • 1.可打开弹窗的线索状态:跟进中、已联系、有效 → 改为 可打开弹窗的线索状态:已领取、跟进中
    • 2.禁止转商机场景:已转商机、过期失效、无效、未反馈 → 改为 禁止转商机场景:待领取、未分发、过期失效、线索作废、已转商机(反馈情况不参与判定)

八、依赖声明

  • 依赖既存:03(状态触发/终态)、06(线索快照字段)、02 Amendment(sys_region.code)
  • 依赖未来:商机模块(OpportunityCreationPort 实现方、opportunity.industry 字典分组、opportunityUNIQUE(source_lead_id)
  • crm-lead spec 交付时OpportunityCreationPort 接口 + CreateOpportunityCmd DTO + 异常类型齐全;实现类不写,商机模块落地时 Spring 扫描注入即可

九、CONTEXT 登记

  • crm-lead/CONTEXT.md:术语补"转商机 = 单向不可逆终态操作,产出商机 id,写 CONVERT 历史"
  • CONTEXT-MAP.md 无需改动(08 属 crm-lead 出站契约,不新增模块)

Status

本 ticket 已定稿可交付 → resolved。