From 3e55fa34be0843c4c66ad34ef289c238d97ca798 Mon Sep 17 00:00:00 2001 From: luoweijian <1329394916@qq.com> Date: Tue, 25 Aug 2026 14:36:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(opportunity):=20=E7=A5=A814=20=E5=95=86?= =?UTF-8?q?=E6=9C=BA=E4=BE=A7=E8=87=AA=E5=AE=9A=E4=B9=89=E8=A7=86=E5=9B=BE?= =?UTF-8?q?=E6=8E=A5=E7=BA=BF=EF=BC=88filter=5Fjson=E2=86=92=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=E8=BF=87=E6=BB=A4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 销售机会工作区消费 crm-preference 自定义视图,收尾票 14: - SavedViewOperator 枚举:六操作符 eq/ne/contains/notContains/isEmpty/ isNotEmpty(demo 0803 customMatches),未知 code → null - OpportunitySavedViewFilter:把 SavedViewCondition 列表翻译成 LambdaQueryWrapper 片段;字段池白名单=票10可筛选列 (字段→主表列名映射),未知字段/操作符/空值静默跳过,杜绝列名注入; 多条件 AND 叠加(本期 demo 1 组,契约按列表预留复合) - OpportunityPageParam 新增 savedViewId;ViewQueryImpl 注入 SavedViewService+翻译器,savedViewId 非空时读当前用户 opportunity.sales scope 该视图条件,翻译后 AND 叠在 viewType 数据集之上(含 RECENT 路径) 进入工作区读默认视图 = 前端调 /api/preference/view/list 取 isDefault 项, 再带其 viewId 调 /page(无需商机侧新端点)。mvn -pl crm-opportunity -am test 144 全绿(原 132 + 12 翻译器)。 --- .../domain/enums/SavedViewOperator.java | 54 +++++++ .../domain/param/OpportunityPageParam.java | 3 + .../impl/OpportunitySavedViewFilter.java | 89 +++++++++++ .../query/impl/OpportunityViewQueryImpl.java | 29 ++++ .../impl/OpportunitySavedViewFilterTest.java | 140 ++++++++++++++++++ 5 files changed, 315 insertions(+) create mode 100644 crm-opportunity/src/main/java/com/crm/opportunity/domain/enums/SavedViewOperator.java create mode 100644 crm-opportunity/src/main/java/com/crm/opportunity/query/impl/OpportunitySavedViewFilter.java create mode 100644 crm-opportunity/src/test/java/com/crm/opportunity/query/impl/OpportunitySavedViewFilterTest.java diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/domain/enums/SavedViewOperator.java b/crm-opportunity/src/main/java/com/crm/opportunity/domain/enums/SavedViewOperator.java new file mode 100644 index 0000000..b2009d9 --- /dev/null +++ b/crm-opportunity/src/main/java/com/crm/opportunity/domain/enums/SavedViewOperator.java @@ -0,0 +1,54 @@ +package com.crm.opportunity.domain.enums; + +import cn.hutool.core.util.StrUtil; + +/** + * 自定义视图检索条件操作符(票 14 定义 2,demo 0803 {@code customMatches} 六档)。 + * + *

{@code crm-preference} 的 {@code SavedViewCondition.operator} 存的是这些 code(平台侧不校验, + * 字段池/操作符语义归商机模块)。商机侧翻译器 {@code OpportunitySavedViewFilter} 按此把条件翻译成 + * {@code LambdaQueryWrapper} 片段。未知 code 视为无效条件(跳过,不报错)。

+ */ +public enum SavedViewOperator { + + /** 等于 */ + EQ("eq"), + /** 不等于 */ + NE("ne"), + /** 包含(like %v%) */ + CONTAINS("contains"), + /** 不包含(not like %v%) */ + NOT_CONTAINS("notContains"), + /** 为空(is null or '') */ + IS_EMPTY("isEmpty"), + /** 不为空(is not null and <> '') */ + IS_NOT_EMPTY("isNotEmpty"); + + private final String code; + + SavedViewOperator(String code) { + this.code = code; + } + + public String getCode() { + return code; + } + + /** 是否为不需要值的操作符(为空/不为空)。 */ + public boolean isValueless() { + return this == IS_EMPTY || this == IS_NOT_EMPTY; + } + + /** 把 code 解析为操作符;空白或未知返回 null(调用方跳过该条件)。 */ + public static SavedViewOperator fromCode(String code) { + if (StrUtil.isBlank(code)) { + return null; + } + for (SavedViewOperator op : values()) { + if (op.code.equals(code)) { + return op; + } + } + return null; + } +} diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/domain/param/OpportunityPageParam.java b/crm-opportunity/src/main/java/com/crm/opportunity/domain/param/OpportunityPageParam.java index 47102b5..125103d 100644 --- a/crm-opportunity/src/main/java/com/crm/opportunity/domain/param/OpportunityPageParam.java +++ b/crm-opportunity/src/main/java/com/crm/opportunity/domain/param/OpportunityPageParam.java @@ -21,6 +21,9 @@ public class OpportunityPageParam extends BaseParam { @Schema(description = "视图类型(RECENT/MINE/PARTICIPATED/FOLLOWED/MANAGE/PUBLIC_POOL,空/非法回落 MINE)") private String viewType; + @Schema(description = "自定义视图 id(票 14;非空时读当前用户 opportunity.sales scope 下该视图的检索条件,叠在 viewType 数据集之上)") + private String savedViewId; + @Schema(description = "商机状态筛选(多值,支撑复合卡片下钻)") private List statusIn; diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/query/impl/OpportunitySavedViewFilter.java b/crm-opportunity/src/main/java/com/crm/opportunity/query/impl/OpportunitySavedViewFilter.java new file mode 100644 index 0000000..a522d23 --- /dev/null +++ b/crm-opportunity/src/main/java/com/crm/opportunity/query/impl/OpportunitySavedViewFilter.java @@ -0,0 +1,89 @@ +package com.crm.opportunity.query.impl; + +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.crm.opportunity.domain.entity.Opportunity; +import com.crm.opportunity.domain.enums.SavedViewOperator; +import com.crm.preference.domain.dto.SavedViewCondition; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; + +/** + * 自定义视图检索条件 → {@code LambdaQueryWrapper} 翻译器(票 14 商机侧接线)。 + * + *

字段合法性归商机模块(crm-preference 不校验,见票 14 确认点 3)。字段池 = 票 10 + * {@code OpportunityPageParam} 的可筛选列,本类持有「字段 key → 主表列名」映射;未知字段 / 未知操作符 + * 的条件静默跳过(防止用户误存的条件把查询打爆或注入)。

+ * + *

操作符语义({@link SavedViewOperator} 六档,demo 0803 {@code customMatches}): + * eq/ne 等值;contains/notContains 走 like;isEmpty/isNotEmpty 判空(null 或空串)。 + * 条件间为 AND(本期 demo 只 1 组,契约按列表预留复合,多组即 AND 叠加)。

+ * + *

翻译只追加过滤,不决定数据集边界——数据集仍由 viewType 走 + * {@code OpportunityViewQueryImpl#applyViewFilters}(销售机会自定义视图默认叠在 MINE 之上)。

+ */ +@Component +public class OpportunitySavedViewFilter { + + /** + * 字段 key → 主表物理列名白名单(字段池,票 10 OpportunityPageParam 可筛选列)。 + *

只允许映射内的字段进入 SQL,杜绝任意列名注入;列名为常量拼接,值走 {0} 占位参数化。

+ */ + private static final Map FIELD_COLUMN = Map.ofEntries( + Map.entry("oppSource", "opp_source"), + Map.entry("oppType", "opp_type"), + Map.entry("industryCode", "industry_code"), + Map.entry("bidForm", "bid_form"), + Map.entry("localityType", "locality_type"), + Map.entry("provinceCode", "province_code"), + Map.entry("cityCode", "city_code"), + Map.entry("ownerDeptId", "owner_dept_id"), + Map.entry("ownerUserId", "owner_user_id"), + Map.entry("poolReason", "pool_reason"), + Map.entry("currentStageId", "current_stage_id"), + Map.entry("oppStatus", "opp_status"), + Map.entry("oppName", "opp_name"), + Map.entry("partyA", "party_a")); + + /** + * 把自定义视图条件列表翻译并追加到 wrapper(AND 叠加)。 + * 空列表 / 全非法 → 不追加任何条件(等价无自定义过滤)。 + */ + public void apply(LambdaQueryWrapper wrapper, List conditions) { + if (conditions == null || conditions.isEmpty()) { + return; + } + for (SavedViewCondition c : conditions) { + applyOne(wrapper, c); + } + } + + private void applyOne(LambdaQueryWrapper wrapper, SavedViewCondition c) { + if (c == null || StrUtil.isBlank(c.field())) { + return; + } + String column = FIELD_COLUMN.get(c.field()); + if (column == null) { + return; // 字段不在字段池 → 跳过 + } + SavedViewOperator op = SavedViewOperator.fromCode(c.operator()); + if (op == null) { + return; // 未知操作符 → 跳过 + } + // 需值的操作符但值为空 → 跳过(避免 like %null% 之类无意义查询) + if (!op.isValueless() && StrUtil.isBlank(c.value())) { + return; + } + String value = c.value(); + switch (op) { + case EQ -> wrapper.apply(column + " = {0}", value); + case NE -> wrapper.apply(column + " <> {0}", value); + case CONTAINS -> wrapper.apply(column + " LIKE {0}", "%" + value + "%"); + case NOT_CONTAINS -> wrapper.apply(column + " NOT LIKE {0}", "%" + value + "%"); + case IS_EMPTY -> wrapper.apply("(" + column + " IS NULL OR " + column + " = '')"); + case IS_NOT_EMPTY -> wrapper.apply("(" + column + " IS NOT NULL AND " + column + " <> '')"); + } + } +} 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 8325dd8..a56d4e2 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 @@ -30,6 +30,8 @@ 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 com.crm.preference.domain.dto.SavedView; +import com.crm.preference.service.SavedViewService; import com.crm.rule.domain.dto.SysRegionDTO; import com.crm.rule.domain.entity.OpportunityStageNode; import com.crm.rule.service.IOpportunityStageTemplateService; @@ -77,6 +79,13 @@ public class OpportunityViewQueryImpl implements OpportunityViewQuery { private final ISysDeptService sysDeptService; private final ISysRegionService sysRegionService; private final IOpportunityStageTemplateService stageTemplateService; + private final SavedViewService savedViewService; + + /** 自定义视图翻译器(票 14):把 SavedView 条件列表翻译成 wrapper 片段。 */ + private final OpportunitySavedViewFilter savedViewFilter; + + /** 销售机会工作区自定义视图的 scope(票 14,crm-preference 按此分组)。 */ + private static final String SAVED_VIEW_SCOPE = "opportunity.sales"; /** RECENT 视图上限(票 10 P2-2 默认 50;view_log 已被后台 Job 裁剪,此处再兜底截断)。 */ private static final int RECENT_LIMIT = 50; @@ -92,6 +101,7 @@ public class OpportunityViewQueryImpl implements OpportunityViewQuery { LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); applyViewFilters(wrapper, param, currentUserId); + applySavedView(wrapper, param, currentUserId); wrapper.orderByDesc(Opportunity::getCreateTime); Page page = opportunityMapper.selectPage(PageConverter.toMpPage(param), wrapper); @@ -121,6 +131,7 @@ public class OpportunityViewQueryImpl implements OpportunityViewQuery { LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); wrapper.in(Opportunity::getId, orderedIds); applyCommonFilters(wrapper, param); + applySavedView(wrapper, param, currentUserId); wrapper.last("order by field(id," + orderedIds.stream() .map(String::valueOf).collect(Collectors.joining(",")) + ")"); @@ -437,6 +448,24 @@ public class OpportunityViewQueryImpl implements OpportunityViewQuery { }); } + /** + * 自定义视图条件叠加(票 14):{@code param.savedViewId} 非空时,读当前用户 + * {@code opportunity.sales} scope 下该视图的检索条件,翻译后 AND 叠在当前数据集之上。 + *

视图不存在(已删 / 非本人)→ 无额外条件(等价写回未选自定义视图)。 + * 排序(sortField/sortDirection)本期不入库级排序(列表默认按 createTime),留后续实现。

+ */ + private void applySavedView(LambdaQueryWrapper wrapper, + OpportunityPageParam param, Long currentUserId) { + if (StrUtil.isBlank(param.getSavedViewId())) { + return; + } + savedViewService.list(currentUserId, SAVED_VIEW_SCOPE).stream() + .filter(v -> param.getSavedViewId().equals(v.viewId())) + .findFirst() + .map(SavedView::conditions) + .ifPresent(conditions -> savedViewFilter.apply(wrapper, conditions)); + } + private Long getCurrentUserId() { return Long.valueOf(SecurityUtils.getRequiredUserId()); } diff --git a/crm-opportunity/src/test/java/com/crm/opportunity/query/impl/OpportunitySavedViewFilterTest.java b/crm-opportunity/src/test/java/com/crm/opportunity/query/impl/OpportunitySavedViewFilterTest.java new file mode 100644 index 0000000..25123b7 --- /dev/null +++ b/crm-opportunity/src/test/java/com/crm/opportunity/query/impl/OpportunitySavedViewFilterTest.java @@ -0,0 +1,140 @@ +package com.crm.opportunity.query.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.crm.opportunity.domain.entity.Opportunity; +import com.crm.opportunity.domain.enums.SavedViewOperator; +import com.crm.preference.domain.dto.SavedViewCondition; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 自定义视图条件翻译器规格验证(票 14 商机侧接线)。 + * + *

用例来源:票 14 定义 2(六操作符 customMatches)+ 确认点 3(字段池 = 票 10 可筛选列, + * 未知字段/操作符跳过)。断言查 wrapper 拼出的 SQL 片段 + 参数,覆盖:eq/ne/contains/notContains/ + * isEmpty/isNotEmpty、未知字段跳过、未知操作符跳过、需值操作符空值跳过、空列表不追加、多条件 AND。

+ */ +@DisplayName("自定义视图条件翻译器(ticket 14 / OpportunitySavedViewFilter)") +class OpportunitySavedViewFilterTest { + + private final OpportunitySavedViewFilter filter = new OpportunitySavedViewFilter(); + + private LambdaQueryWrapper apply(List conditions) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + filter.apply(wrapper, conditions); + return wrapper; + } + + private SavedViewCondition c(String field, String op, String value) { + return new SavedViewCondition(field, op, value); + } + + // ==================== 六操作符 ==================== + + @Test + @DisplayName("eq:字段 = 值(值参数化占位)") + void eq_producesEquals() { + LambdaQueryWrapper w = apply(List.of(c("industryCode", "eq", "IT"))); + assertThat(w.getSqlSegment()).contains("industry_code = "); + assertThat(w.getParamNameValuePairs().values()).contains("IT"); + } + + @Test + @DisplayName("ne:字段 <> 值") + void ne_producesNotEquals() { + LambdaQueryWrapper w = apply(List.of(c("oppType", "ne", "A"))); + assertThat(w.getSqlSegment()).contains("opp_type <> "); + assertThat(w.getParamNameValuePairs().values()).contains("A"); + } + + @Test + @DisplayName("contains:字段 LIKE %值%") + void contains_producesLike() { + LambdaQueryWrapper w = apply(List.of(c("oppName", "contains", "网络"))); + assertThat(w.getSqlSegment()).contains("opp_name LIKE "); + assertThat(w.getParamNameValuePairs().values()).contains("%网络%"); + } + + @Test + @DisplayName("notContains:字段 NOT LIKE %值%") + void notContains_producesNotLike() { + LambdaQueryWrapper w = apply(List.of(c("partyA", "notContains", "测试"))); + assertThat(w.getSqlSegment()).contains("party_a NOT LIKE "); + assertThat(w.getParamNameValuePairs().values()).contains("%测试%"); + } + + @Test + @DisplayName("isEmpty:字段 IS NULL OR = ''(无值参数)") + void isEmpty_producesNullOrEmpty() { + LambdaQueryWrapper w = apply(List.of(c("poolReason", "isEmpty", null))); + assertThat(w.getSqlSegment()).contains("pool_reason IS NULL OR pool_reason = ''"); + } + + @Test + @DisplayName("isNotEmpty:字段 IS NOT NULL AND <> ''") + void isNotEmpty_producesNotNullAndNotEmpty() { + LambdaQueryWrapper w = apply(List.of(c("bidForm", "isNotEmpty", null))); + assertThat(w.getSqlSegment()).contains("bid_form IS NOT NULL AND bid_form <> ''"); + } + + // ==================== 跳过规则 ==================== + + @Test + @DisplayName("未知字段(不在字段池)→ 跳过,不追加条件") + void unknownField_skipped() { + LambdaQueryWrapper w = apply(List.of(c("secretColumn", "eq", "x"))); + assertThat(w.getSqlSegment()).isEmpty(); + } + + @Test + @DisplayName("未知操作符 → 跳过") + void unknownOperator_skipped() { + LambdaQueryWrapper w = apply(List.of(c("industryCode", "weird", "x"))); + assertThat(w.getSqlSegment()).isEmpty(); + } + + @Test + @DisplayName("需值操作符但值为空 → 跳过(不产生 like %null%)") + void valueRequiredButBlank_skipped() { + LambdaQueryWrapper w = apply(List.of(c("industryCode", "eq", " "))); + assertThat(w.getSqlSegment()).isEmpty(); + } + + @Test + @DisplayName("空/null 条件列表 → 不追加任何条件") + void emptyConditions_noop() { + assertThat(apply(List.of()).getSqlSegment()).isEmpty(); + assertThat(apply(null).getSqlSegment()).isEmpty(); + } + + // ==================== 多条件 AND ==================== + + @Test + @DisplayName("多条件 → AND 叠加(本期 demo 1 组,契约按列表预留复合)") + void multipleConditions_andJoined() { + LambdaQueryWrapper w = apply(List.of( + c("industryCode", "eq", "IT"), + c("oppStatus", "ne", "4"))); + String sql = w.getSqlSegment(); + assertThat(sql).contains("industry_code = "); + assertThat(sql).contains("opp_status <> "); + assertThat(sql).contains("AND"); + } + + // ==================== 操作符枚举 ==================== + + @Test + @DisplayName("SavedViewOperator.fromCode:空/未知 → null;isValueless 判空档") + void operator_fromCodeAndValueless() { + assertThat(SavedViewOperator.fromCode(null)).isNull(); + assertThat(SavedViewOperator.fromCode("nope")).isNull(); + assertThat(SavedViewOperator.fromCode("eq")).isEqualTo(SavedViewOperator.EQ); + assertThat(SavedViewOperator.IS_EMPTY.isValueless()).isTrue(); + assertThat(SavedViewOperator.IS_NOT_EMPTY.isValueless()).isTrue(); + assertThat(SavedViewOperator.EQ.isValueless()).isFalse(); + } +}