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")