diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/query/OpportunityViewQuery.java b/crm-opportunity/src/main/java/com/crm/opportunity/query/OpportunityViewQuery.java
new file mode 100644
index 0000000..b419315
--- /dev/null
+++ b/crm-opportunity/src/main/java/com/crm/opportunity/query/OpportunityViewQuery.java
@@ -0,0 +1,18 @@
+package com.crm.opportunity.query;
+
+import com.crm.base.domain.result.PageResult;
+import com.crm.opportunity.domain.dto.OpportunityDTO;
+import com.crm.opportunity.domain.param.OpportunityPageParam;
+
+/**
+ * 商机读侧:六视图分页查询 + 展示字段拼装(票 10 四视图口径 + 票 14 视图族)。
+ *
+ *
与写侧(状态机/命令编排)分离。视图数据集见 {@link com.crm.opportunity.domain.enums.OpportunityViewType};
+ * {@code @DataScope}({@code Opportunity} 实体注解)经拦截器自动追加部门天花板。个人四视图叠加团队成员
+ * 白名单可见性,MANAGE/PUBLIC_POOL 严格按 DataScope(PRD §1.3)。
+ */
+public interface OpportunityViewQuery {
+
+ /** 六视图分页(RECENT/MINE/PARTICIPATED/FOLLOWED/MANAGE/PUBLIC_POOL),回填展示字段 + 计算列。 */
+ PageResult pageOpportunities(OpportunityPageParam 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
new file mode 100644
index 0000000..e802ca1
--- /dev/null
+++ b/crm-opportunity/src/main/java/com/crm/opportunity/query/impl/OpportunityViewQueryImpl.java
@@ -0,0 +1,131 @@
+package com.crm.opportunity.query.impl;
+
+import cn.hutool.core.collection.CollUtil;
+import cn.hutool.core.util.StrUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.crm.base.domain.result.PageResult;
+import com.crm.base.security.SecurityUtils;
+import com.crm.base.utils.PageConverter;
+import com.crm.opportunity.domain.dto.OpportunityDTO;
+import com.crm.opportunity.domain.entity.Opportunity;
+import com.crm.opportunity.domain.entity.OpportunityFocus;
+import com.crm.opportunity.domain.enums.OpportunityStatus;
+import com.crm.opportunity.domain.enums.OpportunityViewType;
+import com.crm.opportunity.domain.param.OpportunityPageParam;
+import com.crm.opportunity.mapper.OpportunityFocusMapper;
+import com.crm.opportunity.mapper.OpportunityMapper;
+import com.crm.opportunity.query.OpportunityViewQuery;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * {@link OpportunityViewQuery} 默认实现(票 10 四视图 + 票 14 视图族)。
+ *
+ * 读侧独占 {@link OpportunityMapper} + {@link OpportunityFocusMapper}(关注读)。六视图数据集见
+ * {@link #applyViewFilters};{@code @DataScope}({@code Opportunity} 实体注解)经拦截器对 wrapper 自动
+ * 追加部门天花板。当前步已落:视图 filter + 公共筛选 + 分页 + followed 回显。
+ *
+ * TODO(后续步骤):① 字典/部门/区划名回显(DictQueryService/SysDeptService/SysRegionService,
+ * 对称 crm-lead fillDisplayFields);② 票 10 §二计算列 join(节点停留/最近跟进/方案卡状态/方案预算/入池前节点);
+ * ③ RECENT 视图按 {@code opportunity_view_log.last_view_time} 倒序取上限(当前 RECENT 先按 create_time
+ * 兜底排序,待接 view_log join);④ PARTICIPATED 团队白名单可见性叠加详情链路。
+ */
+@Component
+@RequiredArgsConstructor
+public class OpportunityViewQueryImpl implements OpportunityViewQuery {
+
+ private final OpportunityMapper opportunityMapper;
+ private final OpportunityFocusMapper opportunityFocusMapper;
+
+ @Override
+ public PageResult pageOpportunities(OpportunityPageParam param) {
+ Long currentUserId = getCurrentUserId();
+ LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>();
+ applyViewFilters(wrapper, param, currentUserId);
+ wrapper.orderByDesc(Opportunity::getCreateTime);
+
+ 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 严格生效)。
+ */
+ private void applyViewFilters(LambdaQueryWrapper wrapper,
+ OpportunityPageParam param, Long currentUserId) {
+ switch (OpportunityViewType.fromValue(param.getViewType())) {
+ // 我负责的:owner = 当前用户
+ case MINE -> wrapper.eq(Opportunity::getOwnerUserId, currentUserId);
+ // 我参与的:团队成员且非负责人(team 存活行命中 AND owner <> 当前用户),票 14
+ case PARTICIPATED -> wrapper
+ .ne(Opportunity::getOwnerUserId, currentUserId)
+ .apply("id IN (SELECT opportunity_id FROM opportunity_team "
+ + "WHERE user_id = {0} AND deleted = 0)", currentUserId);
+ // 我关注的: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);
+ // 商机公海:opp_status=待领取 且 owner 空(逻辑视图,票 05)
+ case PUBLIC_POOL -> wrapper
+ .eq(Opportunity::getOppStatus, OpportunityStatus.STATUS_IN_POOL.getValue())
+ .isNull(Opportunity::getOwnerUserId);
+ // 商机管理:无额外过滤,@DataScope 兜底部门天花板
+ case MANAGE -> { /* @DataScope handles dept ceiling */ }
+ }
+
+ // 公共筛选(列表可筛选列,票 14 字段池)
+ wrapper.in(CollUtil.isNotEmpty(param.getStatusIn()), Opportunity::getOppStatus, param.getStatusIn())
+ .eq(StrUtil.isNotBlank(param.getOppSource()), Opportunity::getOppSource, param.getOppSource())
+ .eq(StrUtil.isNotBlank(param.getOppType()), Opportunity::getOppType, param.getOppType())
+ .eq(StrUtil.isNotBlank(param.getIndustryCode()), Opportunity::getIndustryCode, param.getIndustryCode())
+ .eq(StrUtil.isNotBlank(param.getBidForm()), Opportunity::getBidForm, param.getBidForm())
+ .eq(StrUtil.isNotBlank(param.getLocalityType()), Opportunity::getLocalityType, param.getLocalityType())
+ .eq(StrUtil.isNotBlank(param.getProvinceCode()), Opportunity::getProvinceCode, param.getProvinceCode())
+ .eq(StrUtil.isNotBlank(param.getCityCode()), Opportunity::getCityCode, param.getCityCode())
+ .eq(param.getOwnerDeptId() != null, Opportunity::getOwnerDeptId, param.getOwnerDeptId())
+ .eq(param.getOwnerUserId() != null, Opportunity::getOwnerUserId, param.getOwnerUserId())
+ .eq(StrUtil.isNotBlank(param.getPoolReason()), Opportunity::getPoolReason, param.getPoolReason())
+ .eq(param.getCurrentStageId() != null, Opportunity::getCurrentStageId, param.getCurrentStageId());
+
+ // keyword:商机名称/甲方/主客户名快照模糊
+ if (StrUtil.isNotBlank(param.getKeyword())) {
+ String kw = param.getKeyword().trim();
+ wrapper.and(w -> w.like(Opportunity::getOppName, kw)
+ .or().like(Opportunity::getPartyA, kw)
+ .or().like(Opportunity::getPrimaryCustomerNameSnapshot, kw));
+ }
+ }
+
+ /**
+ * 出参回显字段填充。当前步:followed(当前用户是否关注)。
+ * TODO 后续:字典/部门/区划名 + 票 10 §二计算列(节点停留/最近跟进/方案卡状态/方案预算/入池前节点)。
+ */
+ private void fillDisplayFields(List dtos, Long currentUserId) {
+ if (CollUtil.isEmpty(dtos)) {
+ return;
+ }
+ List ids = dtos.stream().map(OpportunityDTO::getId).collect(Collectors.toList());
+
+ // 当前用户是否关注
+ Set followedIds = opportunityFocusMapper.selectList(
+ new LambdaQueryWrapper()
+ .in(OpportunityFocus::getOpportunityId, ids)
+ .eq(OpportunityFocus::getUserId, currentUserId))
+ .stream().map(OpportunityFocus::getOpportunityId).collect(Collectors.toSet());
+ dtos.forEach(d -> d.setFollowed(followedIds.contains(d.getId())));
+ }
+
+ private Long getCurrentUserId() {
+ return Long.valueOf(SecurityUtils.getRequiredUserId());
+ }
+}
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
new file mode 100644
index 0000000..2801319
--- /dev/null
+++ b/crm-opportunity/src/test/java/com/crm/opportunity/query/impl/OpportunityViewQueryImplTest.java
@@ -0,0 +1,113 @@
+package com.crm.opportunity.query.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.crm.base.security.SecurityUtils;
+import com.crm.opportunity.domain.param.OpportunityPageParam;
+import com.crm.opportunity.mapper.OpportunityFocusMapper;
+import com.crm.opportunity.mapper.OpportunityMapper;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+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.MockedStatic;
+import org.mockito.Mockito;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.Collections;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.when;
+
+/**
+ * 商机六视图 filter 规格验证(票 10/14):断言各 viewType 生成正确的 WHERE 片段。
+ */
+@DisplayName("商机视图查询 OpportunityViewQuery(票 10 六视图 filter)")
+@ExtendWith(MockitoExtension.class)
+class OpportunityViewQueryImplTest {
+
+ private static final Long CURRENT_USER = 1001L;
+
+ @Mock
+ private OpportunityMapper opportunityMapper;
+ @Mock
+ private OpportunityFocusMapper opportunityFocusMapper;
+ @InjectMocks
+ private OpportunityViewQueryImpl query;
+
+ private MockedStatic securityMock;
+
+ @BeforeEach
+ 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(opportunityFocusMapper.selectList(any())).thenReturn(Collections.emptyList());
+ }
+
+ @AfterEach
+ void tearDown() {
+ securityMock.close();
+ }
+
+ @SuppressWarnings("unchecked")
+ private String captureSql(String viewType) {
+ OpportunityPageParam param = new OpportunityPageParam();
+ param.setViewType(viewType);
+ query.pageOpportunities(param);
+ ArgumentCaptor captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+ org.mockito.Mockito.verify(opportunityMapper).selectPage(any(), captor.capture());
+ LambdaQueryWrapper> w = captor.getValue();
+ String sql = w.getSqlSegment();
+ // 参数值内联进 paramNameValuePairs,SQL segment 用占位符;apply 片段直接含子查询
+ return sql;
+ }
+
+ @Test
+ @DisplayName("MINE:owner_user_id = 当前用户")
+ void mine_filtersByOwner() {
+ assertThat(captureSql("MINE")).contains("owner_user_id");
+ }
+
+ @Test
+ @DisplayName("PARTICIPATED:team 子查询 + owner <> 当前用户")
+ void participated_teamSubqueryExcludeOwner() {
+ String sql = captureSql("PARTICIPATED");
+ assertThat(sql).contains("opportunity_team").contains("deleted = 0").contains("owner_user_id");
+ }
+
+ @Test
+ @DisplayName("FOLLOWED:focus 子查询")
+ void followed_focusSubquery() {
+ 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() {
+ String sql = captureSql("PUBLIC_POOL");
+ assertThat(sql).contains("opp_status").contains("owner_user_id").containsIgnoringCase("null");
+ }
+
+ @Test
+ @DisplayName("MANAGE / 空 viewType(回落 MINE):不含公海/子查询片段")
+ void manage_noExtraFilter() {
+ String sql = captureSql("MANAGE");
+ assertThat(sql).doesNotContain("opportunity_team")
+ .doesNotContain("opportunity_focus")
+ .doesNotContain("opportunity_view_log");
+ }
+}