From 83299113e7cfb87e068c3e87ab47be3e72dfb7d0 Mon Sep 17 00:00:00 2001 From: luoweijian <1329394916@qq.com> Date: Tue, 25 Aug 2026 22:11:09 +0800 Subject: [PATCH] =?UTF-8?q?refactor(lead/opportunity):=20ConvertibleLeadCa?= =?UTF-8?q?talogPort=20=E6=94=B6=E7=AA=84=E8=B7=A8=E5=9F=9F=E5=A5=91?= =?UTF-8?q?=E7=BA=A6=EF=BC=88=E6=9E=B6=E6=9E=84=E5=AE=A1=E6=9F=A5=E5=80=99?= =?UTF-8?q?=E9=80=892=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - crm-lead 新增 port/inbound:ConvertibleLeadCatalogPort(单方法)+ ConvertibleLeadView(随端口同包)+ ConvertibleLeadCatalogPortImpl (逻辑自 LeadServiceImpl 原样迁入,票 12 口径不变) - 删 ILeadService.listConvertibleLeads + LeadServiceImpl 实现 + domain/dto/ConvertibleLeadView + LeadConvertibleQueryTest (宽服务接口不再挂为外域服务的方法,商机侧不再 import ILeadService) - 商机侧 4 处改注入端口:CreateServiceImpl / IOpportunityCreateService / CreateController / 测试;占用过滤(查 opportunity.source_lead_id)留商机侧 - 验证:crm-lead 119 + crm-opportunity 165 全绿 --- .../inbound/ConvertibleLeadCatalogPort.java | 26 ++++++++++++++ .../inbound}/ConvertibleLeadView.java | 8 +++-- .../impl/ConvertibleLeadCatalogPortImpl.java | 36 +++++++++++++++++++ .../com/crm/lead/service/ILeadService.java | 10 ------ .../lead/service/impl/LeadServiceImpl.java | 11 ------ .../ConvertibleLeadCatalogPortImplTest.java} | 33 ++++++++--------- .../OpportunityCreateController.java | 2 +- .../service/IOpportunityCreateService.java | 12 ++++--- .../impl/OpportunityCreateServiceImpl.java | 14 ++++---- .../OpportunityCreateServiceImplTest.java | 11 +++--- 10 files changed, 102 insertions(+), 61 deletions(-) create mode 100644 crm-lead/src/main/java/com/crm/lead/port/inbound/ConvertibleLeadCatalogPort.java rename crm-lead/src/main/java/com/crm/lead/{domain/dto => port/inbound}/ConvertibleLeadView.java (57%) create mode 100644 crm-lead/src/main/java/com/crm/lead/port/inbound/impl/ConvertibleLeadCatalogPortImpl.java rename crm-lead/src/test/java/com/crm/lead/{service/impl/LeadConvertibleQueryTest.java => port/inbound/ConvertibleLeadCatalogPortImplTest.java} (66%) diff --git a/crm-lead/src/main/java/com/crm/lead/port/inbound/ConvertibleLeadCatalogPort.java b/crm-lead/src/main/java/com/crm/lead/port/inbound/ConvertibleLeadCatalogPort.java new file mode 100644 index 0000000..9c9cb1d --- /dev/null +++ b/crm-lead/src/main/java/com/crm/lead/port/inbound/ConvertibleLeadCatalogPort.java @@ -0,0 +1,26 @@ +package com.crm.lead.port.inbound; + +import java.util.List; + +/** + * 可转商机候选线索目录入站端口(票 12 数据源 / Candidate #2:商机侧消费的窄查询契约)。 + * + *

商机侧新建入口(oppSource=线索转入)的【关联线索】下拉数据源。此前商机侧 import + * 线索域宽服务接口 {@code ILeadService}(20+ 方法)仅为此一处查询——本端口把契约收窄到 + * 单方法,宽接口退回线索域内部(ADR-0031,对称 {@code port/outbound} 的 + * {@code OpportunityCreationPort}:出站契约 lead 声明他域实现,入站契约本域实现他域调用)。

+ * + *

职责边界(同 ADR-0025):准入 = 归属用户名下 {@code status IN (已领取 3 / 跟进中 4)} + * (与转商机前置对称,线索域判定);「未被商机占用」过滤由商机侧补(本端口不知 + * opportunity 表)。

+ */ +public interface ConvertibleLeadCatalogPort { + + /** + * 列出该用户可关联到商机的候选线索。 + * + * @param ownerUserId 归属用户 id(商机侧传当前登录人) + * @return 轻量投影(id/名称/电话),无候选时返回空列表 + */ + List listConvertibleLeads(Long ownerUserId); +} diff --git a/crm-lead/src/main/java/com/crm/lead/domain/dto/ConvertibleLeadView.java b/crm-lead/src/main/java/com/crm/lead/port/inbound/ConvertibleLeadView.java similarity index 57% rename from crm-lead/src/main/java/com/crm/lead/domain/dto/ConvertibleLeadView.java rename to crm-lead/src/main/java/com/crm/lead/port/inbound/ConvertibleLeadView.java index c76b197..296ce78 100644 --- a/crm-lead/src/main/java/com/crm/lead/domain/dto/ConvertibleLeadView.java +++ b/crm-lead/src/main/java/com/crm/lead/port/inbound/ConvertibleLeadView.java @@ -1,11 +1,13 @@ -package com.crm.lead.domain.dto; +package com.crm.lead.port.inbound; /** * 可转商机候选线索视图(票 12:商机侧新建入口「关联线索」下拉数据源)。 * *

轻量投影,仅够下拉展示 + 回填:线索 id + 名称 + 电话。候选准入 - * (status IN 已领取/跟进中)由 {@code ILeadService.listConvertibleLeads} 在线索域判定; - * 「未被商机占用」过滤由商机侧补(查 opportunity.source_lead_id)。

+ * (status IN 已领取/跟进中)由 {@link ConvertibleLeadCatalogPort} 在线索域判定; + * 「未被商机占用」过滤由商机侧补(查 opportunity.source_lead_id)。 + * 随端口同包(ADR-0031,对称 port/outbound 契约 cmd/exception 同包先例)—— + * 本包类型是线索域对商机侧发布的契约面,商机侧不得 import 线索域 service/domain 内部包。

* * @param id 线索 id(回填 source_lead_id) * @param leadName 线索名称(下拉展示) diff --git a/crm-lead/src/main/java/com/crm/lead/port/inbound/impl/ConvertibleLeadCatalogPortImpl.java b/crm-lead/src/main/java/com/crm/lead/port/inbound/impl/ConvertibleLeadCatalogPortImpl.java new file mode 100644 index 0000000..425b6e3 --- /dev/null +++ b/crm-lead/src/main/java/com/crm/lead/port/inbound/impl/ConvertibleLeadCatalogPortImpl.java @@ -0,0 +1,36 @@ +package com.crm.lead.port.inbound.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.crm.lead.constant.LeadConstants; +import com.crm.lead.domain.entity.Lead; +import com.crm.lead.mapper.LeadMapper; +import com.crm.lead.port.inbound.ConvertibleLeadCatalogPort; +import com.crm.lead.port.inbound.ConvertibleLeadView; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * 可转商机候选线索目录端口实现(逻辑自 {@code LeadServiceImpl.listConvertibleLeads} + * 原样迁入,票 12 口径不变:归属 + status IN (已领取 3 / 跟进中 4))。 + * + *

窄查询契约的实现(ADR-0031):直查 {@code LeadMapper},不复用宽服务接口; + * 「未被商机占用」过滤留商机侧(本实现不知 opportunity 表)。

+ */ +@Service +@RequiredArgsConstructor +public class ConvertibleLeadCatalogPortImpl implements ConvertibleLeadCatalogPort { + + private final LeadMapper leadMapper; + + @Override + public List listConvertibleLeads(Long ownerUserId) { + List leads = leadMapper.selectList(new LambdaQueryWrapper() + .eq(Lead::getOwnerUserId, ownerUserId) + .in(Lead::getStatus, LeadConstants.STATUS_CLAIMED, LeadConstants.STATUS_FOLLOWING)); + return leads.stream() + .map(l -> new ConvertibleLeadView(l.getId(), l.getLeadName(), l.getPhone())) + .toList(); + } +} diff --git a/crm-lead/src/main/java/com/crm/lead/service/ILeadService.java b/crm-lead/src/main/java/com/crm/lead/service/ILeadService.java index 0b60cc2..13b9ff8 100644 --- a/crm-lead/src/main/java/com/crm/lead/service/ILeadService.java +++ b/crm-lead/src/main/java/com/crm/lead/service/ILeadService.java @@ -2,7 +2,6 @@ package com.crm.lead.service; import com.crm.base.domain.result.PageResult; import com.crm.base.domain.result.BatchResult; -import com.crm.lead.domain.dto.ConvertibleLeadView; import com.crm.lead.domain.dto.LeadDTO; import com.crm.lead.domain.dto.LeadBatchFailItem; import com.crm.lead.domain.dto.LeadFeedbackDTO; @@ -34,15 +33,6 @@ public interface ILeadService { /** 视图统计卡片(与 {@link #pageLeads} 同一套筛选口径,口径随视图,ADR-0023) */ LeadStatsDTO countStats(LeadPageParam param); - /** - * 可转商机候选线索(票 12:商机侧新建入口【关联线索】下拉数据源)。 - *

准入 = 该用户名下 {@code status IN (已领取 3 / 跟进中 4)} 的线索(对齐转商机准入)。 - * “未被商机占用”过滤由商机侧补(本方法不知 opportunity 表)。

- * - * @param ownerUserId 归属用户 id(商机侧传当前登录人) - */ - List listConvertibleLeads(Long ownerUserId); - // ==================== 创建/编辑/删除 ==================== /** diff --git a/crm-lead/src/main/java/com/crm/lead/service/impl/LeadServiceImpl.java b/crm-lead/src/main/java/com/crm/lead/service/impl/LeadServiceImpl.java index f1f2532..c80a475 100644 --- a/crm-lead/src/main/java/com/crm/lead/service/impl/LeadServiceImpl.java +++ b/crm-lead/src/main/java/com/crm/lead/service/impl/LeadServiceImpl.java @@ -14,7 +14,6 @@ import com.crm.lead.creation.LeadCreateContext; import com.crm.lead.creation.LeadCreationPlanner; import com.crm.lead.deadline.DeadlineBudget; import com.crm.lead.deadline.LeadDeadlines; -import com.crm.lead.domain.dto.ConvertibleLeadView; import com.crm.lead.domain.dto.LeadDTO; import com.crm.lead.domain.dto.LeadBatchFailItem; import com.crm.lead.domain.dto.LeadFeedbackDTO; @@ -105,16 +104,6 @@ public class LeadServiceImpl extends BaseServiceImpl return leadViewQuery.countStats(param); } - @Override - public List listConvertibleLeads(Long ownerUserId) { - List leads = getBaseMapper().selectList(new LambdaQueryWrapper() - .eq(Lead::getOwnerUserId, ownerUserId) - .in(Lead::getStatus, LeadConstants.STATUS_CLAIMED, LeadConstants.STATUS_FOLLOWING)); - return leads.stream() - .map(l -> new ConvertibleLeadView(l.getId(), l.getLeadName(), l.getPhone())) - .toList(); - } - // ==================== 创建/编辑/删除 ==================== @Override diff --git a/crm-lead/src/test/java/com/crm/lead/service/impl/LeadConvertibleQueryTest.java b/crm-lead/src/test/java/com/crm/lead/port/inbound/ConvertibleLeadCatalogPortImplTest.java similarity index 66% rename from crm-lead/src/test/java/com/crm/lead/service/impl/LeadConvertibleQueryTest.java rename to crm-lead/src/test/java/com/crm/lead/port/inbound/ConvertibleLeadCatalogPortImplTest.java index 961273e..d49edee 100644 --- a/crm-lead/src/test/java/com/crm/lead/service/impl/LeadConvertibleQueryTest.java +++ b/crm-lead/src/test/java/com/crm/lead/port/inbound/ConvertibleLeadCatalogPortImplTest.java @@ -1,43 +1,38 @@ -package com.crm.lead.service.impl; +package com.crm.lead.port.inbound; import com.crm.lead.constant.LeadConstants; -import com.crm.lead.domain.dto.ConvertibleLeadView; import com.crm.lead.domain.entity.Lead; import com.crm.lead.mapper.LeadMapper; +import com.crm.lead.port.inbound.impl.ConvertibleLeadCatalogPortImpl; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.test.util.ReflectionTestUtils; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** - * 票 12:可转商机候选线索查询({@link LeadServiceImpl#listConvertibleLeads})规格验证。 + * 票 12 / Candidate #2:可转商机候选线索目录端口 + * ({@link ConvertibleLeadCatalogPortImpl},逻辑自 LeadServiceImpl 原样迁入)规格验证。 *

准入 = 该用户名下 status IN (已领取 3 / 跟进中 4);投影为轻量 view。

*/ @ExtendWith(MockitoExtension.class) -@DisplayName("票12 可转商机候选线索查询") -class LeadConvertibleQueryTest { +@DisplayName("票12 可转商机候选线索目录端口") +class ConvertibleLeadCatalogPortImplTest { private static final Long OWNER = 100L; - @Mock private LeadMapper leadMapper; + @Mock + private LeadMapper leadMapper; - private LeadServiceImpl service() { - // 仅测 listConvertibleLeads:其余构造依赖本方法不触及,传 null 即可 - LeadServiceImpl svc = new LeadServiceImpl( - null, null, null, null, null, null, null, null, null, null, null); - ReflectionTestUtils.setField(svc, "baseMapper", leadMapper); - return svc; - } + @InjectMocks + private ConvertibleLeadCatalogPortImpl catalog; private Lead lead(Long id, String name, String phone) { Lead l = new Lead(); @@ -54,7 +49,7 @@ class LeadConvertibleQueryTest { lead(1L, "线索甲", "13800000001"), lead(2L, "线索乙", "13800000002"))); - List views = service().listConvertibleLeads(OWNER); + List views = catalog.listConvertibleLeads(OWNER); assertThat(views).extracting(ConvertibleLeadView::id).containsExactly(1L, 2L); assertThat(views).extracting(ConvertibleLeadView::leadName).containsExactly("线索甲", "线索乙"); @@ -62,7 +57,7 @@ class LeadConvertibleQueryTest { } @Test - @DisplayName("准入状态常量对齐:STATUS_CLAIMED=3 / STATUS_FOLLOWING=4") + @DisplayName("准入状态常量对齐:STATUS_CLAIMED=3 / STATUS_FOLLOWING=4(与转商机前置对称)") void statusConstantsAlignWithConvertPrecondition() { assertThat(LeadConstants.STATUS_CLAIMED).isEqualTo(3); assertThat(LeadConstants.STATUS_FOLLOWING).isEqualTo(4); @@ -73,6 +68,6 @@ class LeadConvertibleQueryTest { void listConvertibleLeads_emptyWhenNoCandidates() { when(leadMapper.selectList(any())).thenReturn(List.of()); - assertThat(service().listConvertibleLeads(OWNER)).isEmpty(); + assertThat(catalog.listConvertibleLeads(OWNER)).isEmpty(); } } diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/controller/OpportunityCreateController.java b/crm-opportunity/src/main/java/com/crm/opportunity/controller/OpportunityCreateController.java index 29c9cb3..252ee89 100644 --- a/crm-opportunity/src/main/java/com/crm/opportunity/controller/OpportunityCreateController.java +++ b/crm-opportunity/src/main/java/com/crm/opportunity/controller/OpportunityCreateController.java @@ -2,7 +2,7 @@ package com.crm.opportunity.controller; import com.crm.base.domain.result.Result; import com.crm.base.security.SecurityUtils; -import com.crm.lead.domain.dto.ConvertibleLeadView; +import com.crm.lead.port.inbound.ConvertibleLeadView; import com.crm.opportunity.domain.dto.CreateOpportunityRequest; import com.crm.opportunity.service.IOpportunityCreateService; import io.swagger.v3.oas.annotations.Operation; diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/service/IOpportunityCreateService.java b/crm-opportunity/src/main/java/com/crm/opportunity/service/IOpportunityCreateService.java index 307f576..97e7118 100644 --- a/crm-opportunity/src/main/java/com/crm/opportunity/service/IOpportunityCreateService.java +++ b/crm-opportunity/src/main/java/com/crm/opportunity/service/IOpportunityCreateService.java @@ -1,5 +1,6 @@ package com.crm.opportunity.service; +import com.crm.lead.port.inbound.ConvertibleLeadView; import com.crm.opportunity.domain.dto.CreateOpportunityRequest; import java.util.List; @@ -7,9 +8,9 @@ import java.util.List; /** * 商机侧直接新建商机服务(票 12:新建入口 A3-1-1-2-1)。 * - *

与线索侧转商机 port 并列的第二条建商机入口。核心建商机逻辑(主表 + 阶段落位 + - * 初始日志 + 团队成员,票 02/16)两条入口对称;本入口额外负责 oppSource 驱动的 - * 关联线索必填校验 + source_lead_id 唯一性预检。

+ *

与线索侧转商机 port 并列的第二条建档入口。核心建档逻辑收敛于深模块 OpportunityIntake + * (ADR-0030);本入口额外负责 oppSource 驱动的关联线索必填校验;候选线索准入由 + * crm-lead 入站端口 {@code ConvertibleLeadCatalogPort} 提供(ADR-0031),本侧补占用过滤。

*/ public interface IOpportunityCreateService { @@ -23,9 +24,10 @@ public interface IOpportunityCreateService { Long createOpportunity(CreateOpportunityRequest request, Long ownerUserId); /** - * 可关联线索下拉候选(票 12):该用户 status IN(3,4) 且未被任何商机占用的线索。 + * 可关联线索下拉候选(票 12):该用户 status IN(3,4) 且未被任何商机占用的线索 + * (准入来自 {@code ConvertibleLeadCatalogPort},占用过滤在本侧)。 * * @param ownerUserId 当前登录人 */ - List listConvertibleLeads(Long ownerUserId); + List listConvertibleLeads(Long ownerUserId); } diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/service/impl/OpportunityCreateServiceImpl.java b/crm-opportunity/src/main/java/com/crm/opportunity/service/impl/OpportunityCreateServiceImpl.java index 29cfa65..cda4e67 100644 --- a/crm-opportunity/src/main/java/com/crm/opportunity/service/impl/OpportunityCreateServiceImpl.java +++ b/crm-opportunity/src/main/java/com/crm/opportunity/service/impl/OpportunityCreateServiceImpl.java @@ -2,8 +2,8 @@ package com.crm.opportunity.service.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.crm.base.domain.exception.BusinessErrorException; -import com.crm.lead.domain.dto.ConvertibleLeadView; -import com.crm.lead.service.ILeadService; +import com.crm.lead.port.inbound.ConvertibleLeadCatalogPort; +import com.crm.lead.port.inbound.ConvertibleLeadView; import com.crm.opportunity.domain.dto.CreateOpportunityRequest; import com.crm.opportunity.domain.entity.Opportunity; import com.crm.opportunity.intake.IntakeSource; @@ -34,9 +34,9 @@ import java.util.stream.Collectors; *

事务:本层不再标 @Transactional——{@code open} 自带 REQUIRED 事务保证原子性。 * ownerDeptId 传 null:直接新建不带部门快照,阶段模板走默认兜底(票 15),与收敛前行为一致。

* - *

候选线索(票 12 架构甲):准入(status IN 3/4)由 crm-lead - * {@link ILeadService#listConvertibleLeads} 在线索域判定;「未被商机占用」由本侧补 - * (查 opportunity.source_lead_id),职责边界干净。此能力属于 Candidate #2 范围,维持原状。

+ *

候选线索(票 12 架构甲):准入(status IN 3/4)由 crm-lead 入站端口 + * {@link ConvertibleLeadCatalogPort} 在线索域判定(ADR-0031,不再 import 宽服务接口); + * 「未被商机占用」由本侧补(查 opportunity.source_lead_id),职责边界干净。

*/ @Service @RequiredArgsConstructor @@ -44,7 +44,7 @@ public class OpportunityCreateServiceImpl implements IOpportunityCreateService { private final OpportunityIntake intake; private final OpportunityMapper oppMapper; - private final ILeadService leadService; + private final ConvertibleLeadCatalogPort convertibleLeadCatalog; @Override public Long createOpportunity(CreateOpportunityRequest request, Long ownerUserId) { @@ -80,7 +80,7 @@ public class OpportunityCreateServiceImpl implements IOpportunityCreateService { @Override public List listConvertibleLeads(Long ownerUserId) { - List candidates = leadService.listConvertibleLeads(ownerUserId); + List candidates = convertibleLeadCatalog.listConvertibleLeads(ownerUserId); if (candidates.isEmpty()) { return candidates; } diff --git a/crm-opportunity/src/test/java/com/crm/opportunity/service/impl/OpportunityCreateServiceImplTest.java b/crm-opportunity/src/test/java/com/crm/opportunity/service/impl/OpportunityCreateServiceImplTest.java index aa77d2d..b0872fc 100644 --- a/crm-opportunity/src/test/java/com/crm/opportunity/service/impl/OpportunityCreateServiceImplTest.java +++ b/crm-opportunity/src/test/java/com/crm/opportunity/service/impl/OpportunityCreateServiceImplTest.java @@ -3,8 +3,8 @@ package com.crm.opportunity.service.impl; import com.baomidou.mybatisplus.core.MybatisConfiguration; import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; import com.crm.base.domain.exception.BusinessErrorException; -import com.crm.lead.domain.dto.ConvertibleLeadView; -import com.crm.lead.service.ILeadService; +import com.crm.lead.port.inbound.ConvertibleLeadCatalogPort; +import com.crm.lead.port.inbound.ConvertibleLeadView; import com.crm.opportunity.domain.dto.CreateOpportunityRequest; import com.crm.opportunity.domain.entity.Opportunity; import com.crm.opportunity.intake.IntakeSource; @@ -37,7 +37,8 @@ import static org.mockito.Mockito.when; *

覆盖:oppSource=线索转入时 sourceLeadId 必填校验(不进深模块)、 * {@code CreateOpportunityRequest} → {@code OpportunityIntakeSpec} 全字段映射、 * {@code OpportunityIntakeException} → {@code BusinessErrorException} seam 翻译、 - * 候选线索剔除已占用。建档五步机制与唯一性防护的断言已上移 + * 候选线索剔除已占用(准入来自 {@code ConvertibleLeadCatalogPort},ADR-0031)。 + * 建档五步机制与唯一性防护的断言已上移 * {@code OpportunityIntakeImplTest},本类不再重复。

*/ @ExtendWith(MockitoExtension.class) @@ -51,7 +52,7 @@ class OpportunityCreateServiceImplTest { @Mock private OpportunityIntake intake; @Mock private OpportunityMapper oppMapper; - @Mock private ILeadService leadService; + @Mock private ConvertibleLeadCatalogPort convertibleLeadCatalog; @InjectMocks private OpportunityCreateServiceImpl service; @@ -131,7 +132,7 @@ class OpportunityCreateServiceImplTest { @Test @DisplayName("候选线索:剔除已被商机占用的 source_lead_id") void listConvertibleLeads_excludesTaken() { - when(leadService.listConvertibleLeads(OWNER)).thenReturn(List.of( + when(convertibleLeadCatalog.listConvertibleLeads(OWNER)).thenReturn(List.of( new ConvertibleLeadView(1L, "线索甲", "138"), new ConvertibleLeadView(2L, "线索乙", "139"))); // 商机侧查已占用:lead 2 已被占用