From 67b0490b12bfd0f907ae7e282b10ee0a656c8a68 Mon Sep 17 00:00:00 2001 From: luoweijian <1329394916@qq.com> Date: Tue, 25 Aug 2026 13:40:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(opportunity):=20=E7=A5=A8=2010=20RECENT=20?= =?UTF-8?q?view=5Flog=20=E6=8E=92=E5=BA=8F=20+=20=E5=95=86=E6=9C=BA?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=20Controller?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RECENT 视图独立路径 pageRecent:按 opportunity_view_log.last_view_time 倒序取上限 50(票 10 P2-2),再 ORDER BY FIELD 回主表保序;空 view_log 直接 返回空页不查主表;公共筛选抽 applyCommonFilters 供 RECENT/其它视图复用 - OpportunityQueryController /api/opportunity/page:六视图分页薄适配层, @DataScope 自动注入,tags 按销售机会/管理/公海页面入口实挂 测试补 RECENT 保序 + 空 view_log 短路。crm-opportunity 128→129 全绿。 --- .../OpportunityQueryController.java | 35 +++++++++++++ .../query/impl/OpportunityViewQueryImpl.java | 52 +++++++++++++++++-- .../impl/OpportunityViewQueryImplTest.java | 45 +++++++++++++--- 3 files changed, 122 insertions(+), 10 deletions(-) create mode 100644 crm-opportunity/src/main/java/com/crm/opportunity/controller/OpportunityQueryController.java diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/controller/OpportunityQueryController.java b/crm-opportunity/src/main/java/com/crm/opportunity/controller/OpportunityQueryController.java new file mode 100644 index 0000000..529fb55 --- /dev/null +++ b/crm-opportunity/src/main/java/com/crm/opportunity/controller/OpportunityQueryController.java @@ -0,0 +1,35 @@ +package com.crm.opportunity.controller; + +import com.crm.base.domain.result.PageResult; +import com.crm.base.domain.result.Result; +import com.crm.opportunity.domain.dto.OpportunityDTO; +import com.crm.opportunity.domain.param.OpportunityPageParam; +import com.crm.opportunity.query.OpportunityViewQuery; +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 商机读侧查询接口(薄适配层,票 10 四视图 + 票 14 视图族)。 + * + *

数据隔离由 {@code @DataScope}({@code Opportunity} 实体注解)自动注入部门天花板;个人四视图 + * (RECENT/MINE/PARTICIPATED/FOLLOWED)另叠团队成员白名单,MANAGE/PUBLIC_POOL 严格按 DataScope。 + * tags 按真实页面入口实挂(销售机会工作区 + 商机管理 + 商机公海两个独立 workspace)。

+ */ +@RestController +@RequestMapping("/api/opportunity") +@RequiredArgsConstructor +public class OpportunityQueryController { + + private final OpportunityViewQuery opportunityViewQuery; + + @Operation(summary = "商机分页(六视图:RECENT/MINE/PARTICIPATED/FOLLOWED/MANAGE/PUBLIC_POOL)", + tags = {"销售机会/最近访问", "销售机会/我负责的", "销售机会/我参与的", "销售机会/我关注的", + "商机管理", "商机公海"}) + @PostMapping("/page") + public Result> page(OpportunityPageParam param) { + return Result.success(opportunityViewQuery.pageOpportunities(param)); + } +} diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/query/impl/OpportunityViewQueryImpl.java b/crm-opportunity/src/main/java/com/crm/opportunity/query/impl/OpportunityViewQueryImpl.java index b77c5ab..dde0a87 100644 --- a/crm-opportunity/src/main/java/com/crm/opportunity/query/impl/OpportunityViewQueryImpl.java +++ b/crm-opportunity/src/main/java/com/crm/opportunity/query/impl/OpportunityViewQueryImpl.java @@ -14,6 +14,7 @@ import com.crm.opportunity.domain.entity.OpportunityFocus; import com.crm.opportunity.domain.entity.OpportunityFollow; import com.crm.opportunity.domain.entity.OpportunitySchemeCard; import com.crm.opportunity.domain.entity.OpportunityStageHistory; +import com.crm.opportunity.domain.entity.OpportunityViewLog; import com.crm.opportunity.domain.enums.OpportunityStatus; import com.crm.opportunity.domain.enums.OpportunityViewType; import com.crm.opportunity.domain.param.OpportunityPageParam; @@ -23,6 +24,7 @@ import com.crm.opportunity.mapper.OpportunityFollowMapper; import com.crm.opportunity.mapper.OpportunityMapper; import com.crm.opportunity.mapper.OpportunitySchemeCardMapper; import com.crm.opportunity.mapper.OpportunityStageHistoryMapper; +import com.crm.opportunity.mapper.OpportunityViewLogMapper; import com.crm.opportunity.query.OpportunityViewQuery; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -60,10 +62,20 @@ public class OpportunityViewQueryImpl implements OpportunityViewQuery { private final OpportunityFollowMapper followMapper; private final OpportunityCustomerMapper customerMapper; private final OpportunitySchemeCardMapper schemeCardMapper; + private final OpportunityViewLogMapper viewLogMapper; + + /** RECENT 视图上限(票 10 P2-2 默认 50;view_log 已被后台 Job 裁剪,此处再兜底截断)。 */ + private static final int RECENT_LIMIT = 50; @Override public PageResult pageOpportunities(OpportunityPageParam param) { Long currentUserId = getCurrentUserId(); + + // RECENT 走 view_log 时间倒序独立路径(跨表排序,无法在主表 wrapper 内表达) + if (OpportunityViewType.fromValue(param.getViewType()) == OpportunityViewType.RECENT) { + return pageRecent(param, currentUserId); + } + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); applyViewFilters(wrapper, param, currentUserId); wrapper.orderByDesc(Opportunity::getCreateTime); @@ -74,6 +86,36 @@ public class OpportunityViewQueryImpl implements OpportunityViewQuery { return result; } + /** + * RECENT 最近访问视图:按 {@code opportunity_view_log.last_view_time} 倒序取上限 {@link #RECENT_LIMIT}, + * 再按该顺序回主表(MySQL {@code ORDER BY FIELD} 保序)。公共筛选(statusIn 等)仍生效; + * 分页在裁剪后的最近序列上做(recent 天然是短列表)。 + */ + private PageResult pageRecent(OpportunityPageParam param, Long currentUserId) { + List orderedIds = viewLogMapper.selectList( + new LambdaQueryWrapper() + .eq(OpportunityViewLog::getUserId, currentUserId) + .orderByDesc(OpportunityViewLog::getLastViewTime) + .last("limit " + RECENT_LIMIT)) + .stream().map(OpportunityViewLog::getOpportunityId).collect(Collectors.toList()); + + PageResult empty = new PageResult<>(new Page<>()); + if (orderedIds.isEmpty()) { + return empty; + } + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.in(Opportunity::getId, orderedIds); + applyCommonFilters(wrapper, param); + wrapper.last("order by field(id," + orderedIds.stream() + .map(String::valueOf).collect(Collectors.joining(",")) + ")"); + + Page page = opportunityMapper.selectPage(PageConverter.toMpPage(param), wrapper); + PageResult result = new PageResult<>(page).convert(OpportunityDTO::fromEntity); + fillDisplayFields(result.getContent(), currentUserId); + return result; + } + /** * 六视图共享查询条件(viewType 数据集 + 公共筛选),不含排序/分页。 *

{@code @DataScope} 经拦截器对本 wrapper 自动追加部门天花板(MANAGE/PUBLIC_POOL 严格生效)。

@@ -91,9 +133,8 @@ public class OpportunityViewQueryImpl implements OpportunityViewQuery { // 我关注的:opportunity_focus 命中当前用户 case FOLLOWED -> wrapper.apply( "id IN (SELECT opportunity_id FROM opportunity_focus WHERE user_id = {0})", currentUserId); - // 最近访问:view_log 命中当前用户(排序/去重/上限待后续 join,当前先出数据集) - case RECENT -> wrapper.apply( - "id IN (SELECT opportunity_id FROM opportunity_view_log WHERE user_id = {0})", currentUserId); + // 最近访问:RECENT 走 pageRecent 独立路径(不入本 switch),此处兵底无额外过滤 + case RECENT -> { /* handled by pageRecent */ } // 商机公海:opp_status=待领取 且 owner 空(逻辑视图,票 05) case PUBLIC_POOL -> wrapper .eq(Opportunity::getOppStatus, OpportunityStatus.STATUS_IN_POOL.getValue()) @@ -102,6 +143,11 @@ public class OpportunityViewQueryImpl implements OpportunityViewQuery { case MANAGE -> { /* @DataScope handles dept ceiling */ } } + applyCommonFilters(wrapper, param); + } + + /** 公共筛选(列表可筛选列 + keyword,票 14 字段池),与视图数据集正交;RECENT 独立路径亦复用。 */ + private void applyCommonFilters(LambdaQueryWrapper wrapper, OpportunityPageParam param) { // 公共筛选(列表可筛选列,票 14 字段池) wrapper.in(CollUtil.isNotEmpty(param.getStatusIn()), Opportunity::getOppStatus, param.getStatusIn()) .eq(StrUtil.isNotBlank(param.getOppSource()), Opportunity::getOppSource, param.getOppSource()) diff --git a/crm-opportunity/src/test/java/com/crm/opportunity/query/impl/OpportunityViewQueryImplTest.java b/crm-opportunity/src/test/java/com/crm/opportunity/query/impl/OpportunityViewQueryImplTest.java index cfc870d..de1bcf4 100644 --- a/crm-opportunity/src/test/java/com/crm/opportunity/query/impl/OpportunityViewQueryImplTest.java +++ b/crm-opportunity/src/test/java/com/crm/opportunity/query/impl/OpportunityViewQueryImplTest.java @@ -9,6 +9,7 @@ import com.crm.opportunity.domain.entity.OpportunityCustomer; import com.crm.opportunity.domain.entity.OpportunityFollow; import com.crm.opportunity.domain.entity.OpportunitySchemeCard; import com.crm.opportunity.domain.entity.OpportunityStageHistory; +import com.crm.opportunity.domain.entity.OpportunityViewLog; import com.crm.opportunity.domain.param.OpportunityPageParam; import com.crm.opportunity.mapper.OpportunityCustomerMapper; import com.crm.opportunity.mapper.OpportunityFocusMapper; @@ -16,6 +17,7 @@ import com.crm.opportunity.mapper.OpportunityFollowMapper; import com.crm.opportunity.mapper.OpportunityMapper; import com.crm.opportunity.mapper.OpportunitySchemeCardMapper; import com.crm.opportunity.mapper.OpportunityStageHistoryMapper; +import com.crm.opportunity.mapper.OpportunityViewLogMapper; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -60,6 +62,8 @@ class OpportunityViewQueryImplTest { private OpportunityCustomerMapper customerMapper; @Mock private OpportunitySchemeCardMapper schemeCardMapper; + @Mock + private OpportunityViewLogMapper viewLogMapper; @InjectMocks private OpportunityViewQueryImpl query; @@ -69,12 +73,13 @@ class OpportunityViewQueryImplTest { void setUp() { securityMock = Mockito.mockStatic(SecurityUtils.class); securityMock.when(SecurityUtils::getRequiredUserId).thenReturn(String.valueOf(CURRENT_USER)); - when(opportunityMapper.selectPage(any(), any())).thenReturn(new Page<>()); + lenient().when(opportunityMapper.selectPage(any(), any())).thenReturn(new Page<>()); lenient().when(opportunityFocusMapper.selectList(any())).thenReturn(Collections.emptyList()); lenient().when(stageHistoryMapper.selectList(any())).thenReturn(Collections.emptyList()); lenient().when(followMapper.selectList(any())).thenReturn(Collections.emptyList()); lenient().when(customerMapper.selectList(any())).thenReturn(Collections.emptyList()); lenient().when(schemeCardMapper.selectList(any())).thenReturn(Collections.emptyList()); + lenient().when(viewLogMapper.selectList(any())).thenReturn(Collections.emptyList()); } @AfterEach @@ -114,12 +119,6 @@ class OpportunityViewQueryImplTest { assertThat(captureSql("FOLLOWED")).contains("opportunity_focus"); } - @Test - @DisplayName("RECENT:view_log 子查询") - void recent_viewLogSubquery() { - assertThat(captureSql("RECENT")).contains("opportunity_view_log"); - } - @Test @DisplayName("PUBLIC_POOL:opp_status=1 且 owner 空") void publicPool_statusAndOwnerNull() { @@ -136,6 +135,38 @@ class OpportunityViewQueryImplTest { .doesNotContain("opportunity_view_log"); } + @Test + @DisplayName("RECENT:空 view_log → 空页,不查主表") + void recent_emptyViewLog_returnsEmpty() { + when(viewLogMapper.selectList(any())).thenReturn(Collections.emptyList()); + OpportunityPageParam param = new OpportunityPageParam(); + param.setViewType("RECENT"); + assertThat(query.pageOpportunities(param).getContent()).isEmpty(); + org.mockito.Mockito.verify(opportunityMapper, org.mockito.Mockito.never()).selectPage(any(), any()); + } + + @Test + @DisplayName("RECENT:view_log 命中 → 按 last_view_time 倒序的 id IN + ORDER BY FIELD 保序") + @SuppressWarnings("unchecked") + void recent_ordersByViewLog() { + OpportunityViewLog v1 = new OpportunityViewLog(); + v1.setOpportunityId(7002L); + OpportunityViewLog v2 = new OpportunityViewLog(); + v2.setOpportunityId(7001L); + // mapper 已带 orderByDesc(lastViewTime),返回顺序即最近优先 + when(viewLogMapper.selectList(any())).thenReturn(List.of(v1, v2)); + when(opportunityMapper.selectPage(any(), any())).thenReturn(new Page<>()); + + OpportunityPageParam param = new OpportunityPageParam(); + param.setViewType("RECENT"); + query.pageOpportunities(param); + + ArgumentCaptor captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class); + org.mockito.Mockito.verify(opportunityMapper).selectPage(any(), captor.capture()); + String sql = captor.getValue().getSqlSegment(); + assertThat(sql).containsIgnoringCase("order by field(id,7002,7001)"); + } + @Test @DisplayName("计算列:节点停留天数 / 最近跟进 / 主客户方案卡状态与预算") @SuppressWarnings("unchecked")