Browse Source

feat(opportunity): 票 10 RECENT view_log 排序 + 商机查询 Controller

- 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 全绿。
master
luoweijian 2 weeks ago
parent
commit
67b0490b12
  1. 35
      crm-opportunity/src/main/java/com/crm/opportunity/controller/OpportunityQueryController.java
  2. 52
      crm-opportunity/src/main/java/com/crm/opportunity/query/impl/OpportunityViewQueryImpl.java
  3. 45
      crm-opportunity/src/test/java/com/crm/opportunity/query/impl/OpportunityViewQueryImplTest.java

35
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 视图族
*
* <p>数据隔离由 {@code @DataScope}{@code Opportunity} 实体注解自动注入部门天花板个人四视图
* RECENT/MINE/PARTICIPATED/FOLLOWED另叠团队成员白名单MANAGE/PUBLIC_POOL 严格按 DataScope
* tags 按真实页面入口实挂销售机会工作区 + 商机管理 + 商机公海两个独立 workspace</p>
*/
@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<PageResult<OpportunityDTO>> page(OpportunityPageParam param) {
return Result.success(opportunityViewQuery.pageOpportunities(param));
}
}

52
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.OpportunityFollow;
import com.crm.opportunity.domain.entity.OpportunitySchemeCard; import com.crm.opportunity.domain.entity.OpportunitySchemeCard;
import com.crm.opportunity.domain.entity.OpportunityStageHistory; 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.OpportunityStatus;
import com.crm.opportunity.domain.enums.OpportunityViewType; import com.crm.opportunity.domain.enums.OpportunityViewType;
import com.crm.opportunity.domain.param.OpportunityPageParam; 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.OpportunityMapper;
import com.crm.opportunity.mapper.OpportunitySchemeCardMapper; import com.crm.opportunity.mapper.OpportunitySchemeCardMapper;
import com.crm.opportunity.mapper.OpportunityStageHistoryMapper; import com.crm.opportunity.mapper.OpportunityStageHistoryMapper;
import com.crm.opportunity.mapper.OpportunityViewLogMapper;
import com.crm.opportunity.query.OpportunityViewQuery; import com.crm.opportunity.query.OpportunityViewQuery;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@ -60,10 +62,20 @@ public class OpportunityViewQueryImpl implements OpportunityViewQuery {
private final OpportunityFollowMapper followMapper; private final OpportunityFollowMapper followMapper;
private final OpportunityCustomerMapper customerMapper; private final OpportunityCustomerMapper customerMapper;
private final OpportunitySchemeCardMapper schemeCardMapper; private final OpportunitySchemeCardMapper schemeCardMapper;
private final OpportunityViewLogMapper viewLogMapper;
/** RECENT 视图上限(票 10 P2-2 默认 50;view_log 已被后台 Job 裁剪,此处再兜底截断)。 */
private static final int RECENT_LIMIT = 50;
@Override @Override
public PageResult<OpportunityDTO> pageOpportunities(OpportunityPageParam param) { public PageResult<OpportunityDTO> pageOpportunities(OpportunityPageParam param) {
Long currentUserId = getCurrentUserId(); Long currentUserId = getCurrentUserId();
// RECENT 走 view_log 时间倒序独立路径(跨表排序,无法在主表 wrapper 内表达)
if (OpportunityViewType.fromValue(param.getViewType()) == OpportunityViewType.RECENT) {
return pageRecent(param, currentUserId);
}
LambdaQueryWrapper<Opportunity> wrapper = new LambdaQueryWrapper<>(); LambdaQueryWrapper<Opportunity> wrapper = new LambdaQueryWrapper<>();
applyViewFilters(wrapper, param, currentUserId); applyViewFilters(wrapper, param, currentUserId);
wrapper.orderByDesc(Opportunity::getCreateTime); wrapper.orderByDesc(Opportunity::getCreateTime);
@ -74,6 +86,36 @@ public class OpportunityViewQueryImpl implements OpportunityViewQuery {
return result; return result;
} }
/**
* RECENT 最近访问视图 {@code opportunity_view_log.last_view_time} 倒序取上限 {@link #RECENT_LIMIT}
* 再按该顺序回主表MySQL {@code ORDER BY FIELD} 保序公共筛选statusIn 仍生效
* 分页在裁剪后的最近序列上做recent 天然是短列表
*/
private PageResult<OpportunityDTO> pageRecent(OpportunityPageParam param, Long currentUserId) {
List<Long> orderedIds = viewLogMapper.selectList(
new LambdaQueryWrapper<OpportunityViewLog>()
.eq(OpportunityViewLog::getUserId, currentUserId)
.orderByDesc(OpportunityViewLog::getLastViewTime)
.last("limit " + RECENT_LIMIT))
.stream().map(OpportunityViewLog::getOpportunityId).collect(Collectors.toList());
PageResult<OpportunityDTO> empty = new PageResult<>(new Page<>());
if (orderedIds.isEmpty()) {
return empty;
}
LambdaQueryWrapper<Opportunity> 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<Opportunity> page = opportunityMapper.selectPage(PageConverter.toMpPage(param), wrapper);
PageResult<OpportunityDTO> result = new PageResult<>(page).convert(OpportunityDTO::fromEntity);
fillDisplayFields(result.getContent(), currentUserId);
return result;
}
/** /**
* 六视图共享查询条件viewType 数据集 + 公共筛选不含排序/分页 * 六视图共享查询条件viewType 数据集 + 公共筛选不含排序/分页
* <p>{@code @DataScope} 经拦截器对本 wrapper 自动追加部门天花板MANAGE/PUBLIC_POOL 严格生效</p> * <p>{@code @DataScope} 经拦截器对本 wrapper 自动追加部门天花板MANAGE/PUBLIC_POOL 严格生效</p>
@ -91,9 +133,8 @@ public class OpportunityViewQueryImpl implements OpportunityViewQuery {
// 我关注的:opportunity_focus 命中当前用户 // 我关注的:opportunity_focus 命中当前用户
case FOLLOWED -> wrapper.apply( case FOLLOWED -> wrapper.apply(
"id IN (SELECT opportunity_id FROM opportunity_focus WHERE user_id = {0})", currentUserId); "id IN (SELECT opportunity_id FROM opportunity_focus WHERE user_id = {0})", currentUserId);
// 最近访问:view_log 命中当前用户(排序/去重/上限待后续 join,当前先出数据集) // 最近访问:RECENT 走 pageRecent 独立路径(不入本 switch),此处兵底无额外过滤
case RECENT -> wrapper.apply( case RECENT -> { /* handled by pageRecent */ }
"id IN (SELECT opportunity_id FROM opportunity_view_log WHERE user_id = {0})", currentUserId);
// 商机公海:opp_status=待领取 且 owner 空(逻辑视图,票 05) // 商机公海:opp_status=待领取 且 owner 空(逻辑视图,票 05)
case PUBLIC_POOL -> wrapper case PUBLIC_POOL -> wrapper
.eq(Opportunity::getOppStatus, OpportunityStatus.STATUS_IN_POOL.getValue()) .eq(Opportunity::getOppStatus, OpportunityStatus.STATUS_IN_POOL.getValue())
@ -102,6 +143,11 @@ public class OpportunityViewQueryImpl implements OpportunityViewQuery {
case MANAGE -> { /* @DataScope handles dept ceiling */ } case MANAGE -> { /* @DataScope handles dept ceiling */ }
} }
applyCommonFilters(wrapper, param);
}
/** 公共筛选(列表可筛选列 + keyword,票 14 字段池),与视图数据集正交;RECENT 独立路径亦复用。 */
private void applyCommonFilters(LambdaQueryWrapper<Opportunity> wrapper, OpportunityPageParam param) {
// 公共筛选(列表可筛选列,票 14 字段池) // 公共筛选(列表可筛选列,票 14 字段池)
wrapper.in(CollUtil.isNotEmpty(param.getStatusIn()), Opportunity::getOppStatus, param.getStatusIn()) wrapper.in(CollUtil.isNotEmpty(param.getStatusIn()), Opportunity::getOppStatus, param.getStatusIn())
.eq(StrUtil.isNotBlank(param.getOppSource()), Opportunity::getOppSource, param.getOppSource()) .eq(StrUtil.isNotBlank(param.getOppSource()), Opportunity::getOppSource, param.getOppSource())

45
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.OpportunityFollow;
import com.crm.opportunity.domain.entity.OpportunitySchemeCard; import com.crm.opportunity.domain.entity.OpportunitySchemeCard;
import com.crm.opportunity.domain.entity.OpportunityStageHistory; 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.domain.param.OpportunityPageParam;
import com.crm.opportunity.mapper.OpportunityCustomerMapper; import com.crm.opportunity.mapper.OpportunityCustomerMapper;
import com.crm.opportunity.mapper.OpportunityFocusMapper; 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.OpportunityMapper;
import com.crm.opportunity.mapper.OpportunitySchemeCardMapper; import com.crm.opportunity.mapper.OpportunitySchemeCardMapper;
import com.crm.opportunity.mapper.OpportunityStageHistoryMapper; import com.crm.opportunity.mapper.OpportunityStageHistoryMapper;
import com.crm.opportunity.mapper.OpportunityViewLogMapper;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
@ -60,6 +62,8 @@ class OpportunityViewQueryImplTest {
private OpportunityCustomerMapper customerMapper; private OpportunityCustomerMapper customerMapper;
@Mock @Mock
private OpportunitySchemeCardMapper schemeCardMapper; private OpportunitySchemeCardMapper schemeCardMapper;
@Mock
private OpportunityViewLogMapper viewLogMapper;
@InjectMocks @InjectMocks
private OpportunityViewQueryImpl query; private OpportunityViewQueryImpl query;
@ -69,12 +73,13 @@ class OpportunityViewQueryImplTest {
void setUp() { void setUp() {
securityMock = Mockito.mockStatic(SecurityUtils.class); securityMock = Mockito.mockStatic(SecurityUtils.class);
securityMock.when(SecurityUtils::getRequiredUserId).thenReturn(String.valueOf(CURRENT_USER)); 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(opportunityFocusMapper.selectList(any())).thenReturn(Collections.emptyList());
lenient().when(stageHistoryMapper.selectList(any())).thenReturn(Collections.emptyList()); lenient().when(stageHistoryMapper.selectList(any())).thenReturn(Collections.emptyList());
lenient().when(followMapper.selectList(any())).thenReturn(Collections.emptyList()); lenient().when(followMapper.selectList(any())).thenReturn(Collections.emptyList());
lenient().when(customerMapper.selectList(any())).thenReturn(Collections.emptyList()); lenient().when(customerMapper.selectList(any())).thenReturn(Collections.emptyList());
lenient().when(schemeCardMapper.selectList(any())).thenReturn(Collections.emptyList()); lenient().when(schemeCardMapper.selectList(any())).thenReturn(Collections.emptyList());
lenient().when(viewLogMapper.selectList(any())).thenReturn(Collections.emptyList());
} }
@AfterEach @AfterEach
@ -114,12 +119,6 @@ class OpportunityViewQueryImplTest {
assertThat(captureSql("FOLLOWED")).contains("opportunity_focus"); assertThat(captureSql("FOLLOWED")).contains("opportunity_focus");
} }
@Test
@DisplayName("RECENT:view_log 子查询")
void recent_viewLogSubquery() {
assertThat(captureSql("RECENT")).contains("opportunity_view_log");
}
@Test @Test
@DisplayName("PUBLIC_POOL:opp_status=1 且 owner 空") @DisplayName("PUBLIC_POOL:opp_status=1 且 owner 空")
void publicPool_statusAndOwnerNull() { void publicPool_statusAndOwnerNull() {
@ -136,6 +135,38 @@ class OpportunityViewQueryImplTest {
.doesNotContain("opportunity_view_log"); .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<LambdaQueryWrapper> 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 @Test
@DisplayName("计算列:节点停留天数 / 最近跟进 / 主客户方案卡状态与预算") @DisplayName("计算列:节点停留天数 / 最近跟进 / 主客户方案卡状态与预算")
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")

Loading…
Cancel
Save