Browse Source

feat(preference): 票14 自定义视图服务 list/save/delete/setDefault + 单测

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 全绿。
master
luoweijian 2 weeks ago
parent
commit
d110fd7546
  1. 46
      crm-preference/src/main/java/com/crm/preference/service/SavedViewService.java
  2. 165
      crm-preference/src/main/java/com/crm/preference/service/impl/SavedViewServiceImpl.java
  3. 213
      crm-preference/src/test/java/com/crm/preference/service/impl/SavedViewServiceImplTest.java

46
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 定稿
*
* <p>平台级第二种偏好 {@link ColumnPreferenceService}列偏好并列四个方法均为用户级
* {@code scopeKey} 分组私有字段池合法性检索名称唯一性等业务校验归业务方字段池约定
* 本服务只负责持久化 + 默认视图单值互斥的结构约束</p>
*
* <p>系统<b>内置视图不入本能力</b>内置口径硬编码在业务方本服务只管理用户自建的视图</p>
*/
public interface SavedViewService {
/**
* 列出某用户在某 scope 下的全部自定义视图 seqNo 升序图钉默认视图以 isDefault 标记随行返回
*/
List<SavedView> list(Long userId, String scopeKey);
/**
* 保存自定义视图upsert 语义
* <ul>
* <li>{@code view.viewId()} 为空 新建服务端生成 viewIdseqNo 追加到末尾</li>
* <li>{@code view.viewId()} 非空 覆盖同 (user, scope, viewId) 的记录编辑/另存为</li>
* <li> {@code view.isDefault()} true 先清掉同 (user, scope) 其他默认保证单值互斥</li>
* </ul>
*
* @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);
}

165
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
*
* <p>{@code filter_json} String 存储 ObjectMapper 序列化/反序列化条件列表默认视图单值互斥
* 在应用层保证save(isDefault) / setDefault 前先把同 (user, scope) 其余记录 is_default 0</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SavedViewServiceImpl implements SavedViewService {
private final UserSavedViewMapper mapper;
private final ObjectMapper objectMapper;
@Override
public List<SavedView> list(Long userId, String scopeKey) {
List<UserSavedView> rows = mapper.selectList(
new LambdaQueryWrapper<UserSavedView>()
.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<UserSavedView>()
.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<UserSavedView>()
.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<UserSavedView>()
.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<UserSavedView>()
.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<SavedViewCondition> parseJson(String json) {
if (json == null || json.isBlank()) {
return List.of();
}
try {
return objectMapper.readValue(json, new TypeReference<List<SavedViewCondition>>() {});
} catch (JsonProcessingException e) {
log.warn("解析自定义视图 filter_json 失败: {}", json, e);
return List.of();
}
}
private String toJson(List<SavedViewCondition> conditions) {
try {
return objectMapper.writeValueAsString(conditions == null ? List.of() : conditions);
} catch (JsonProcessingException e) {
throw new IllegalStateException("序列化自定义视图 filter_json 失败", e);
}
}
}

213
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
*
* <p>用例来源ticket 14 定义 2/3保存的检索 + 图钉默认视图+ CONTEXT.md自定义视图
* 覆盖list seqNosave 新建生成 viewId+末尾 seqNosave 覆盖编辑save(isDefault)/setDefault
* 单值互斥先清后置delete 物理删setDefault 视图不存在静默crm-preference 不校验字段池</p>
*/
@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<SavedView> 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<SavedView> 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<UserSavedView> 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));
}
}
Loading…
Cancel
Save