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 语义: + * + * + * @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)); + } +}