From d110fd7546294c465312408475df5ad535587e9f Mon Sep 17 00:00:00 2001
From: luoweijian <1329394916@qq.com>
Date: Tue, 25 Aug 2026 13:57:14 +0800
Subject: [PATCH] =?UTF-8?q?feat(preference):=20=E7=A5=A814=20=E8=87=AA?=
=?UTF-8?q?=E5=AE=9A=E4=B9=89=E8=A7=86=E5=9B=BE=E6=9C=8D=E5=8A=A1=20list/s?=
=?UTF-8?q?ave/delete/setDefault=20+=20=E5=8D=95=E6=B5=8B?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
SavedViewService 契约(平台级第二种偏好,与列偏好并列):
- list:按 seqNo 升序返回该用户此 scope 视图,解析 filter_json
- save:upsert;viewId 空=新建(生成 viewId+末尾 seqNo),非空=覆盖编辑;
isDefault=true 先清同 scope 其他默认(单值互斥)
- delete:按 (user,scope,viewId) 物理删
- setDefault:图钉,先清其他再置该条;视图不存在静默返回
crm-preference 不校验字段池合法性(归业务方)。9 用例覆盖 list/新建/编辑/
默认互斥/删/setDefault/静默/任意字段。mvn -pl crm-preference test 16 全绿。
---
.../preference/service/SavedViewService.java | 46 ++++
.../service/impl/SavedViewServiceImpl.java | 165 ++++++++++++++
.../impl/SavedViewServiceImplTest.java | 213 ++++++++++++++++++
3 files changed, 424 insertions(+)
create mode 100644 crm-preference/src/main/java/com/crm/preference/service/SavedViewService.java
create mode 100644 crm-preference/src/main/java/com/crm/preference/service/impl/SavedViewServiceImpl.java
create mode 100644 crm-preference/src/test/java/com/crm/preference/service/impl/SavedViewServiceImplTest.java
diff --git a/crm-preference/src/main/java/com/crm/preference/service/SavedViewService.java b/crm-preference/src/main/java/com/crm/preference/service/SavedViewService.java
new file mode 100644
index 0000000..2d56289
--- /dev/null
+++ b/crm-preference/src/main/java/com/crm/preference/service/SavedViewService.java
@@ -0,0 +1,46 @@
+package com.crm.preference.service;
+
+import com.crm.preference.domain.dto.SavedView;
+
+import java.util.List;
+
+/**
+ * 自定义视图服务契约(票 14 定稿)。
+ *
+ *
平台级第二种偏好,与 {@link ColumnPreferenceService}(列偏好)并列。四个方法均为用户级、
+ * 按 {@code scopeKey} 分组、私有。字段池合法性、检索名称唯一性等业务校验归业务方(同「字段池」约定),
+ * 本服务只负责持久化 + 默认视图单值互斥的结构约束。
+ *
+ * 系统内置视图不入本能力(内置口径硬编码在业务方),本服务只管理用户自建的视图。
+ */
+public interface SavedViewService {
+
+ /**
+ * 列出某用户在某 scope 下的全部自定义视图,按 seqNo 升序(图钉默认视图以 isDefault 标记随行返回)。
+ */
+ List list(Long userId, String scopeKey);
+
+ /**
+ * 保存自定义视图;upsert 语义:
+ *
+ * - {@code view.viewId()} 为空 → 新建:服务端生成 viewId,seqNo 追加到末尾;
+ * - {@code view.viewId()} 非空 → 覆盖同 (user, scope, viewId) 的记录(编辑/另存为);
+ * - 若 {@code view.isDefault()} 为 true → 先清掉同 (user, scope) 其他默认,保证单值互斥。
+ *
+ *
+ * @return 落库后的 viewId(新建时为服务端生成值)
+ */
+ String save(Long userId, String scopeKey, SavedView view);
+
+ /**
+ * 删除某自定义视图(物理删)。删默认视图后该 scope 无默认(业务方回落内置 mine)。
+ * 视图不存在时静默返回。
+ */
+ void delete(Long userId, String scopeKey, String viewId);
+
+ /**
+ * 图钉:把某视图设为该用户在此 scope 的默认视图(单值互斥,先清其他再置该条)。
+ * 视图不存在时静默返回。
+ */
+ void setDefault(Long userId, String scopeKey, String viewId);
+}
diff --git a/crm-preference/src/main/java/com/crm/preference/service/impl/SavedViewServiceImpl.java b/crm-preference/src/main/java/com/crm/preference/service/impl/SavedViewServiceImpl.java
new file mode 100644
index 0000000..42f5e0a
--- /dev/null
+++ b/crm-preference/src/main/java/com/crm/preference/service/impl/SavedViewServiceImpl.java
@@ -0,0 +1,165 @@
+package com.crm.preference.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.crm.preference.domain.dto.SavedView;
+import com.crm.preference.domain.dto.SavedViewCondition;
+import com.crm.preference.domain.entity.UserSavedView;
+import com.crm.preference.mapper.UserSavedViewMapper;
+import com.crm.preference.service.SavedViewService;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.UUID;
+
+/**
+ * 自定义视图服务实现(票 14)。
+ *
+ * {@code filter_json} 以 String 存储,用 ObjectMapper 序列化/反序列化条件列表。默认视图单值互斥
+ * 在应用层保证:save(isDefault) / setDefault 前先把同 (user, scope) 其余记录 is_default 清 0。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class SavedViewServiceImpl implements SavedViewService {
+
+ private final UserSavedViewMapper mapper;
+ private final ObjectMapper objectMapper;
+
+ @Override
+ public List list(Long userId, String scopeKey) {
+ List rows = mapper.selectList(
+ new LambdaQueryWrapper()
+ .eq(UserSavedView::getUserId, userId)
+ .eq(UserSavedView::getScopeKey, scopeKey)
+ .orderByAsc(UserSavedView::getSeqNo));
+ return rows.stream().map(this::toDto).toList();
+ }
+
+ @Override
+ @Transactional
+ public String save(Long userId, String scopeKey, SavedView view) {
+ boolean isNew = view.viewId() == null || view.viewId().isBlank();
+ UserSavedView existing = isNew ? null : findOne(userId, scopeKey, view.viewId());
+
+ if (view.isDefault()) {
+ clearDefault(userId, scopeKey);
+ }
+
+ LocalDateTime now = LocalDateTime.now();
+ if (existing == null) {
+ UserSavedView entity = new UserSavedView();
+ entity.setUserId(userId);
+ entity.setScopeKey(scopeKey);
+ entity.setViewId(isNew ? generateViewId() : view.viewId());
+ applyFields(entity, view);
+ entity.setSeqNo(isNew ? nextSeqNo(userId, scopeKey) : view.seqNo());
+ entity.setCreateTime(now);
+ entity.setUpdateTime(now);
+ mapper.insert(entity);
+ return entity.getViewId();
+ } else {
+ applyFields(existing, view);
+ existing.setSeqNo(view.seqNo());
+ existing.setUpdateTime(now);
+ mapper.updateById(existing);
+ return existing.getViewId();
+ }
+ }
+
+ @Override
+ public void delete(Long userId, String scopeKey, String viewId) {
+ mapper.delete(new LambdaQueryWrapper()
+ .eq(UserSavedView::getUserId, userId)
+ .eq(UserSavedView::getScopeKey, scopeKey)
+ .eq(UserSavedView::getViewId, viewId));
+ }
+
+ @Override
+ @Transactional
+ public void setDefault(Long userId, String scopeKey, String viewId) {
+ UserSavedView target = findOne(userId, scopeKey, viewId);
+ if (target == null) {
+ return;
+ }
+ clearDefault(userId, scopeKey);
+ target.setIsDefault(true);
+ target.setUpdateTime(LocalDateTime.now());
+ mapper.updateById(target);
+ }
+
+ // ==================== 内部 ====================
+
+ private UserSavedView findOne(Long userId, String scopeKey, String viewId) {
+ return mapper.selectOne(new LambdaQueryWrapper()
+ .eq(UserSavedView::getUserId, userId)
+ .eq(UserSavedView::getScopeKey, scopeKey)
+ .eq(UserSavedView::getViewId, viewId));
+ }
+
+ /** 清掉同 (user, scope) 的所有默认标记(默认视图单值互斥)。 */
+ private void clearDefault(Long userId, String scopeKey) {
+ mapper.update(null, new LambdaUpdateWrapper()
+ .eq(UserSavedView::getUserId, userId)
+ .eq(UserSavedView::getScopeKey, scopeKey)
+ .eq(UserSavedView::getIsDefault, true)
+ .set(UserSavedView::getIsDefault, false));
+ }
+
+ private int nextSeqNo(Long userId, String scopeKey) {
+ Long count = mapper.selectCount(new LambdaQueryWrapper()
+ .eq(UserSavedView::getUserId, userId)
+ .eq(UserSavedView::getScopeKey, scopeKey));
+ return count == null ? 0 : count.intValue();
+ }
+
+ private String generateViewId() {
+ return UUID.randomUUID().toString().replace("-", "");
+ }
+
+ private void applyFields(UserSavedView entity, SavedView view) {
+ entity.setName(view.name());
+ entity.setFilterJson(toJson(view.conditions()));
+ entity.setSortField(view.sortField());
+ entity.setSortDirection(view.sortDirection());
+ entity.setIsDefault(view.isDefault());
+ }
+
+ private SavedView toDto(UserSavedView entity) {
+ return new SavedView(
+ entity.getViewId(),
+ entity.getName(),
+ parseJson(entity.getFilterJson()),
+ entity.getSortField(),
+ entity.getSortDirection(),
+ Boolean.TRUE.equals(entity.getIsDefault()),
+ entity.getSeqNo() == null ? 0 : entity.getSeqNo());
+ }
+
+ private List parseJson(String json) {
+ if (json == null || json.isBlank()) {
+ return List.of();
+ }
+ try {
+ return objectMapper.readValue(json, new TypeReference>() {});
+ } catch (JsonProcessingException e) {
+ log.warn("解析自定义视图 filter_json 失败: {}", json, e);
+ return List.of();
+ }
+ }
+
+ private String toJson(List conditions) {
+ try {
+ return objectMapper.writeValueAsString(conditions == null ? List.of() : conditions);
+ } catch (JsonProcessingException e) {
+ throw new IllegalStateException("序列化自定义视图 filter_json 失败", e);
+ }
+ }
+}
diff --git a/crm-preference/src/test/java/com/crm/preference/service/impl/SavedViewServiceImplTest.java b/crm-preference/src/test/java/com/crm/preference/service/impl/SavedViewServiceImplTest.java
new file mode 100644
index 0000000..3636f48
--- /dev/null
+++ b/crm-preference/src/test/java/com/crm/preference/service/impl/SavedViewServiceImplTest.java
@@ -0,0 +1,213 @@
+package com.crm.preference.service.impl;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.crm.preference.domain.dto.SavedView;
+import com.crm.preference.domain.dto.SavedViewCondition;
+import com.crm.preference.domain.entity.UserSavedView;
+import com.crm.preference.mapper.UserSavedViewMapper;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeAll;
+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.Spy;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 自定义视图契约规格验证(票 14 / crm-preference)。
+ *
+ * 用例来源:ticket 14 定义 2/3(保存的检索 + 图钉默认视图)+ CONTEXT.md【自定义视图】。
+ * 覆盖:list 按 seqNo;save 新建生成 viewId+末尾 seqNo;save 覆盖编辑;save(isDefault)/setDefault
+ * 单值互斥先清后置;delete 物理删;setDefault 视图不存在静默。crm-preference 不校验字段池。
+ */
+@DisplayName("自定义视图契约规格验证(ticket 14 / crm-preference)")
+@ExtendWith(MockitoExtension.class)
+class SavedViewServiceImplTest {
+
+ private static final Long USER_ID = 1001L;
+ private static final String SCOPE_KEY = "opportunity.sales";
+
+ @Mock
+ private UserSavedViewMapper mapper;
+
+ @Spy
+ private ObjectMapper objectMapper = new ObjectMapper();
+
+ @InjectMocks
+ private SavedViewServiceImpl service;
+
+ @BeforeAll
+ static void initLambdaCache() {
+ MapperBuilderAssistant assistant =
+ new MapperBuilderAssistant(new MybatisConfiguration(), "");
+ TableInfoHelper.initTableInfo(assistant, UserSavedView.class);
+ }
+
+ private UserSavedView entity(String viewId, String name, int seqNo, boolean isDefault) {
+ UserSavedView e = new UserSavedView();
+ e.setUserId(USER_ID);
+ e.setScopeKey(SCOPE_KEY);
+ e.setViewId(viewId);
+ e.setName(name);
+ e.setSeqNo(seqNo);
+ e.setIsDefault(isDefault);
+ return e;
+ }
+
+ // ==================== list ====================
+
+ @Test
+ @DisplayName("list:返回该用户此 scope 的视图并解析 filter_json")
+ void list_returnsParsed() {
+ UserSavedView e = entity("v1", "高价值行业", 0, true);
+ e.setFilterJson("[{\"field\":\"industryCode\",\"operator\":\"eq\",\"value\":\"IT\"}]");
+ e.setSortField("projectAmount");
+ e.setSortDirection("desc");
+ when(mapper.selectList(any())).thenReturn(List.of(e));
+
+ List result = service.list(USER_ID, SCOPE_KEY);
+
+ assertThat(result).hasSize(1);
+ SavedView v = result.get(0);
+ assertThat(v.viewId()).isEqualTo("v1");
+ assertThat(v.name()).isEqualTo("高价值行业");
+ assertThat(v.isDefault()).isTrue();
+ assertThat(v.sortField()).isEqualTo("projectAmount");
+ assertThat(v.conditions()).hasSize(1);
+ assertThat(v.conditions().get(0).field()).isEqualTo("industryCode");
+ assertThat(v.conditions().get(0).operator()).isEqualTo("eq");
+ assertThat(v.conditions().get(0).value()).isEqualTo("IT");
+ }
+
+ @Test
+ @DisplayName("list:filter_json 为空/null → 条件列表为空(优雅降级)")
+ void list_emptyFilter_returnsEmptyConditions() {
+ UserSavedView e = entity("v1", "空条件", 0, false);
+ e.setFilterJson(null);
+ when(mapper.selectList(any())).thenReturn(List.of(e));
+
+ List result = service.list(USER_ID, SCOPE_KEY);
+
+ assertThat(result.get(0).conditions()).isEmpty();
+ }
+
+ // ==================== save 新建 ====================
+
+ @Test
+ @DisplayName("save:viewId 为空 → 新建,服务端生成 viewId,seqNo 追加末尾")
+ void save_new_generatesViewIdAndSeqNo() {
+ when(mapper.selectCount(any())).thenReturn(2L);
+
+ SavedView view = new SavedView(null, "我的检索",
+ List.of(new SavedViewCondition("oppType", "eq", "A")),
+ null, null, false, 0);
+ String viewId = service.save(USER_ID, SCOPE_KEY, view);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(UserSavedView.class);
+ verify(mapper).insert(captor.capture());
+ verify(mapper, never()).updateById(any(UserSavedView.class));
+ UserSavedView saved = captor.getValue();
+ assertThat(saved.getViewId()).isNotBlank().isEqualTo(viewId);
+ assertThat(saved.getSeqNo()).isEqualTo(2);
+ assertThat(saved.getName()).isEqualTo("我的检索");
+ assertThat(saved.getUserId()).isEqualTo(USER_ID);
+ }
+
+ @Test
+ @DisplayName("save:新建且 isDefault=true → 先清同 scope 其他默认(单值互斥)")
+ void save_newDefault_clearsOthers() {
+ when(mapper.selectCount(any())).thenReturn(0L);
+
+ SavedView view = new SavedView(null, "默认视图", List.of(), null, null, true, 0);
+ service.save(USER_ID, SCOPE_KEY, view);
+
+ // clearDefault 走 mapper.update(null, wrapper)
+ verify(mapper).update(isNull(), any());
+ verify(mapper).insert(any(UserSavedView.class));
+ }
+
+ // ==================== save 覆盖编辑 ====================
+
+ @Test
+ @DisplayName("save:viewId 非空且命中既有 → 覆盖更新(编辑)")
+ void save_existing_updates() {
+ UserSavedView existing = entity("v1", "旧名", 3, false);
+ when(mapper.selectOne(any())).thenReturn(existing);
+
+ SavedView view = new SavedView("v1", "新名",
+ List.of(new SavedViewCondition("bidForm", "ne", "X")),
+ "createTime", "asc", false, 3);
+ String viewId = service.save(USER_ID, SCOPE_KEY, view);
+
+ assertThat(viewId).isEqualTo("v1");
+ verify(mapper).updateById(any(UserSavedView.class));
+ verify(mapper, never()).insert(any(UserSavedView.class));
+ assertThat(existing.getName()).isEqualTo("新名");
+ assertThat(existing.getSortField()).isEqualTo("createTime");
+ }
+
+ // ==================== delete ====================
+
+ @Test
+ @DisplayName("delete:按 (user, scope, viewId) 物理删")
+ void delete_removesByKey() {
+ service.delete(USER_ID, SCOPE_KEY, "v1");
+ verify(mapper).delete(any());
+ }
+
+ // ==================== setDefault ====================
+
+ @Test
+ @DisplayName("setDefault:命中 → 先清其他默认再置该条(单值互斥)")
+ void setDefault_clearsThenSets() {
+ UserSavedView target = entity("v2", "视图2", 1, false);
+ when(mapper.selectOne(any())).thenReturn(target);
+
+ service.setDefault(USER_ID, SCOPE_KEY, "v2");
+
+ verify(mapper).update(isNull(), any());
+ verify(mapper).updateById(any(UserSavedView.class));
+ assertThat(target.getIsDefault()).isTrue();
+ }
+
+ @Test
+ @DisplayName("setDefault:视图不存在 → 静默返回,不写库")
+ void setDefault_notFound_noop() {
+ when(mapper.selectOne(any())).thenReturn(null);
+
+ service.setDefault(USER_ID, SCOPE_KEY, "nope");
+
+ verify(mapper, never()).update(isNull(), any());
+ verify(mapper, never()).updateById(any(UserSavedView.class));
+ }
+
+ // ==================== 字段池不校验 ====================
+
+ @Test
+ @DisplayName("ticket 14:任意 field/operator 均可保存(字段池合法性归业务方,preference 不拦)")
+ void save_arbitraryField_accepted() {
+ when(mapper.selectCount(any())).thenReturn(0L);
+
+ SavedView view = new SavedView(null, "任意字段",
+ List.of(new SavedViewCondition("unknownField", "weirdOp", "v")),
+ null, null, false, 0);
+ service.save(USER_ID, SCOPE_KEY, view);
+
+ verify(mapper, times(1)).insert(any(UserSavedView.class));
+ }
+}