Browse Source

feat(opportunity): 票14 商机侧自定义视图接线(filter_json→查询过滤)

销售机会工作区消费 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
luoweijian 2 weeks ago
parent
commit
3e55fa34be
  1. 54
      crm-opportunity/src/main/java/com/crm/opportunity/domain/enums/SavedViewOperator.java
  2. 3
      crm-opportunity/src/main/java/com/crm/opportunity/domain/param/OpportunityPageParam.java
  3. 89
      crm-opportunity/src/main/java/com/crm/opportunity/query/impl/OpportunitySavedViewFilter.java
  4. 29
      crm-opportunity/src/main/java/com/crm/opportunity/query/impl/OpportunityViewQueryImpl.java
  5. 140
      crm-opportunity/src/test/java/com/crm/opportunity/query/impl/OpportunitySavedViewFilterTest.java

54
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 定义 2demo 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;
}
}

3
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)") @Schema(description = "视图类型(RECENT/MINE/PARTICIPATED/FOLLOWED/MANAGE/PUBLIC_POOL,空/非法回落 MINE)")
private String viewType; private String viewType;
@Schema(description = "自定义视图 id(票 14;非空时读当前用户 opportunity.sales scope 下该视图的检索条件,叠在 viewType 数据集之上)")
private String savedViewId;
@Schema(description = "商机状态筛选(多值,支撑复合卡片下钻)") @Schema(description = "商机状态筛选(多值,支撑复合卡片下钻)")
private List<Integer> statusIn; private List<Integer> statusIn;

89
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<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 likeisEmpty/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"));
/**
* 把自定义视图条件列表翻译并追加到 wrapperAND 叠加
* 空列表 / 全非法 不追加任何条件等价无自定义过滤
*/
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 + " <> '')");
}
}
}

29
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.OpportunityStageHistoryMapper;
import com.crm.opportunity.mapper.OpportunityViewLogMapper; import com.crm.opportunity.mapper.OpportunityViewLogMapper;
import com.crm.opportunity.query.OpportunityViewQuery; 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.dto.SysRegionDTO;
import com.crm.rule.domain.entity.OpportunityStageNode; import com.crm.rule.domain.entity.OpportunityStageNode;
import com.crm.rule.service.IOpportunityStageTemplateService; import com.crm.rule.service.IOpportunityStageTemplateService;
@ -77,6 +79,13 @@ public class OpportunityViewQueryImpl implements OpportunityViewQuery {
private final ISysDeptService sysDeptService; private final ISysDeptService sysDeptService;
private final ISysRegionService sysRegionService; private final ISysRegionService sysRegionService;
private final IOpportunityStageTemplateService stageTemplateService; 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 裁剪,此处再兜底截断)。 */ /** RECENT 视图上限(票 10 P2-2 默认 50;view_log 已被后台 Job 裁剪,此处再兜底截断)。 */
private static final int RECENT_LIMIT = 50; private static final int RECENT_LIMIT = 50;
@ -92,6 +101,7 @@ public class OpportunityViewQueryImpl implements OpportunityViewQuery {
LambdaQueryWrapper<Opportunity> wrapper = new LambdaQueryWrapper<>(); LambdaQueryWrapper<Opportunity> wrapper = new LambdaQueryWrapper<>();
applyViewFilters(wrapper, param, currentUserId); applyViewFilters(wrapper, param, currentUserId);
applySavedView(wrapper, param, currentUserId);
wrapper.orderByDesc(Opportunity::getCreateTime); wrapper.orderByDesc(Opportunity::getCreateTime);
Page<Opportunity> page = opportunityMapper.selectPage(PageConverter.toMpPage(param), wrapper); Page<Opportunity> page = opportunityMapper.selectPage(PageConverter.toMpPage(param), wrapper);
@ -121,6 +131,7 @@ public class OpportunityViewQueryImpl implements OpportunityViewQuery {
LambdaQueryWrapper<Opportunity> wrapper = new LambdaQueryWrapper<>(); LambdaQueryWrapper<Opportunity> wrapper = new LambdaQueryWrapper<>();
wrapper.in(Opportunity::getId, orderedIds); wrapper.in(Opportunity::getId, orderedIds);
applyCommonFilters(wrapper, param); applyCommonFilters(wrapper, param);
applySavedView(wrapper, param, currentUserId);
wrapper.last("order by field(id," + orderedIds.stream() wrapper.last("order by field(id," + orderedIds.stream()
.map(String::valueOf).collect(Collectors.joining(",")) + ")"); .map(String::valueOf).collect(Collectors.joining(",")) + ")");
@ -437,6 +448,24 @@ public class OpportunityViewQueryImpl implements OpportunityViewQuery {
}); });
} }
/**
* 自定义视图条件叠加 14{@code param.savedViewId} 非空时读当前用户
* {@code opportunity.sales} scope 下该视图的检索条件翻译后 AND 叠在当前数据集之上
* <p>视图不存在已删 / 非本人 无额外条件等价写回未选自定义视图
* 排序sortField/sortDirection本期不入库级排序列表默认按 createTime留后续实现</p>
*/
private void applySavedView(LambdaQueryWrapper<Opportunity> 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() { private Long getCurrentUserId() {
return Long.valueOf(SecurityUtils.getRequiredUserId()); return Long.valueOf(SecurityUtils.getRequiredUserId());
} }

140
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 商机侧接线
*
* <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…
Cancel
Save