Browse Source
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
3 changed files with 424 additions and 0 deletions
@ -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()} 为空 → 新建:服务端生成 viewId,seqNo 追加到末尾;</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); |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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 按 seqNo;save 新建生成 viewId+末尾 seqNo;save 覆盖编辑;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…
Reference in new issue