Browse Source
销售机会工作区消费 crm-preference 自定义视图,收尾票 14: - SavedViewOperator 枚举:六操作符 eq/ne/contains/notContains/isEmpty/ isNotEmpty(demo 0803 customMatches),未知 code → null - OpportunitySavedViewFilter:把 SavedViewCondition 列表翻译成 LambdaQueryWrapper<Opportunity> 片段;字段池白名单=票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 翻译器)。master
5 changed files with 315 additions and 0 deletions
@ -0,0 +1,54 @@ |
|||
package com.crm.opportunity.domain.enums; |
|||
|
|||
import cn.hutool.core.util.StrUtil; |
|||
|
|||
/** |
|||
* 自定义视图检索条件操作符(票 14 定义 2,demo 0803 {@code customMatches} 六档)。 |
|||
* |
|||
* <p>{@code crm-preference} 的 {@code SavedViewCondition.operator} 存的是这些 code(平台侧不校验, |
|||
* 字段池/操作符语义归商机模块)。商机侧翻译器 {@code OpportunitySavedViewFilter} 按此把条件翻译成 |
|||
* {@code LambdaQueryWrapper<Opportunity>} 片段。未知 code 视为无效条件(跳过,不报错)。</p> |
|||
*/ |
|||
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; |
|||
} |
|||
} |
|||
@ -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<Opportunity>} 翻译器(票 14 商机侧接线)。 |
|||
* |
|||
* <p>字段合法性归商机模块(crm-preference 不校验,见票 14 确认点 3)。<b>字段池 = 票 10 |
|||
* {@code OpportunityPageParam} 的可筛选列</b>,本类持有「字段 key → 主表列名」映射;未知字段 / 未知操作符 |
|||
* 的条件<b>静默跳过</b>(防止用户误存的条件把查询打爆或注入)。</p> |
|||
* |
|||
* <p>操作符语义({@link SavedViewOperator} 六档,demo 0803 {@code customMatches}): |
|||
* eq/ne 等值;contains/notContains 走 like;isEmpty/isNotEmpty 判空(null 或空串)。 |
|||
* 条件间为 AND(本期 demo 只 1 组,契约按列表预留复合,多组即 AND 叠加)。</p> |
|||
* |
|||
* <p>翻译只<b>追加过滤</b>,不决定数据集边界——数据集仍由 viewType 走 |
|||
* {@code OpportunityViewQueryImpl#applyViewFilters}(销售机会自定义视图默认叠在 MINE 之上)。</p> |
|||
*/ |
|||
@Component |
|||
public class OpportunitySavedViewFilter { |
|||
|
|||
/** |
|||
* 字段 key → 主表物理列名白名单(字段池,票 10 OpportunityPageParam 可筛选列)。 |
|||
* <p>只允许映射内的字段进入 SQL,杜绝任意列名注入;列名为常量拼接,值走 {0} 占位参数化。</p> |
|||
*/ |
|||
private static final Map<String, String> 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<Opportunity> wrapper, List<SavedViewCondition> conditions) { |
|||
if (conditions == null || conditions.isEmpty()) { |
|||
return; |
|||
} |
|||
for (SavedViewCondition c : conditions) { |
|||
applyOne(wrapper, c); |
|||
} |
|||
} |
|||
|
|||
private void applyOne(LambdaQueryWrapper<Opportunity> 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 + " <> '')"); |
|||
} |
|||
} |
|||
} |
|||
@ -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 商机侧接线)。 |
|||
* |
|||
* <p>用例来源:票 14 定义 2(六操作符 customMatches)+ 确认点 3(字段池 = 票 10 可筛选列, |
|||
* 未知字段/操作符跳过)。断言查 wrapper 拼出的 SQL 片段 + 参数,覆盖:eq/ne/contains/notContains/ |
|||
* isEmpty/isNotEmpty、未知字段跳过、未知操作符跳过、需值操作符空值跳过、空列表不追加、多条件 AND。</p> |
|||
*/ |
|||
@DisplayName("自定义视图条件翻译器(ticket 14 / OpportunitySavedViewFilter)") |
|||
class OpportunitySavedViewFilterTest { |
|||
|
|||
private final OpportunitySavedViewFilter filter = new OpportunitySavedViewFilter(); |
|||
|
|||
private LambdaQueryWrapper<Opportunity> apply(List<SavedViewCondition> conditions) { |
|||
LambdaQueryWrapper<Opportunity> 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<Opportunity> 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<Opportunity> 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<Opportunity> 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<Opportunity> 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<Opportunity> 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<Opportunity> 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<Opportunity> w = apply(List.of(c("secretColumn", "eq", "x"))); |
|||
assertThat(w.getSqlSegment()).isEmpty(); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("未知操作符 → 跳过") |
|||
void unknownOperator_skipped() { |
|||
LambdaQueryWrapper<Opportunity> w = apply(List.of(c("industryCode", "weird", "x"))); |
|||
assertThat(w.getSqlSegment()).isEmpty(); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("需值操作符但值为空 → 跳过(不产生 like %null%)") |
|||
void valueRequiredButBlank_skipped() { |
|||
LambdaQueryWrapper<Opportunity> 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<Opportunity> 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(); |
|||
} |
|||
} |
|||
Loading…
Reference in new issue