fix(p1r): 稳定 Account 市场记录查询边界
This commit is contained in:
parent
68313e6efe
commit
8c1a895e09
@ -1,6 +1,7 @@
|
||||
package cn.iocoder.muse.module.member.application.account.facade;
|
||||
|
||||
import cn.iocoder.muse.framework.common.exception.ServiceException;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import static cn.iocoder.muse.module.member.enums.ErrorCodeConstants.ACCOUNT_MARKET_PROJECTION_UNAVAILABLE;
|
||||
@ -12,6 +13,7 @@ import static cn.iocoder.muse.module.member.enums.ErrorCodeConstants.ACCOUNT_MAR
|
||||
* owner 提供真实投影同步后才能替换该 Facade,不能在 Account 域里回填假记录。</p>
|
||||
*/
|
||||
@Component
|
||||
@ConditionalOnMissingBean(MarketAccountProjectionFacade.class)
|
||||
public class UnavailableMarketAccountProjectionFacade implements MarketAccountProjectionFacade {
|
||||
|
||||
@Override
|
||||
|
||||
@ -16,12 +16,15 @@ import java.util.List;
|
||||
@Mapper
|
||||
public interface AccountRecordProjectionMapper extends BaseMapperX<AccountRecordProjectionDO> {
|
||||
|
||||
String DISPLAY_TIME_ORDER_SQL = "ORDER BY COALESCE(occurred_at, create_time) DESC, id DESC";
|
||||
|
||||
default List<AccountRecordProjectionDO> selectListByAccountUserIdAndRecordType(Long accountUserId,
|
||||
String recordType) {
|
||||
return selectList(new LambdaQueryWrapperX<AccountRecordProjectionDO>()
|
||||
.eq(AccountRecordProjectionDO::getAccountUserId, accountUserId)
|
||||
.eq(AccountRecordProjectionDO::getRecordType, recordType)
|
||||
.orderByDesc(AccountRecordProjectionDO::getOccurredAt));
|
||||
// 列表顺序必须和 DTO 展示时间一致:occurred_at 为空时按 create_time 兜底,再用 id 保证分页稳定。
|
||||
.last(DISPLAY_TIME_ORDER_SQL));
|
||||
}
|
||||
|
||||
default AccountRecordProjectionDO selectByRecordTypeAndRecordId(String recordType, String recordId) {
|
||||
@ -36,13 +39,13 @@ public interface AccountRecordProjectionMapper extends BaseMapperX<AccountRecord
|
||||
// record_type 是 Account 侧隔离 purchase/license/publish 的硬边界,不能由请求方自由控制。
|
||||
.eq(AccountRecordProjectionDO::getRecordType, recordType)
|
||||
.eqIfPresent(AccountRecordProjectionDO::getAccountUserId, accountUserId)
|
||||
.inIfPresent(AccountRecordProjectionDO::getSourceStatus, sourceStatuses)
|
||||
.orderByDesc(AccountRecordProjectionDO::getOccurredAt)
|
||||
.orderByDesc(AccountRecordProjectionDO::getCreateTime);
|
||||
.inIfPresent(AccountRecordProjectionDO::getSourceStatus, sourceStatuses);
|
||||
if (StringUtils.hasText(assetType)) {
|
||||
// assetType 目前只存在于投影快照;这里仅做白名单筛选,不把 JSONB 原文暴露给 Controller。
|
||||
query.apply("projection_snapshot ->> 'assetType' = {0}", assetType);
|
||||
}
|
||||
// PostgreSQL DESC 默认 NULL 在前;这里按响应展示时间排序,避免 occurred_at 为空的记录插到最新页前面。
|
||||
query.last(DISPLAY_TIME_ORDER_SQL);
|
||||
return selectPage(pageParam, query);
|
||||
}
|
||||
|
||||
|
||||
@ -18,13 +18,16 @@ import cn.iocoder.muse.module.member.dal.mysql.user.MemberUserMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import static cn.iocoder.muse.module.member.enums.ErrorCodeConstants.ACCOUNT_MARKET_PROJECTION_UNAVAILABLE;
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
@ -185,6 +188,15 @@ class AccountMarketRecordServiceTest extends BaseMockitoUnitTest {
|
||||
assertEquals(ACCOUNT_MARKET_PROJECTION_UNAVAILABLE.getCode(), exception.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_registerUnavailableFacadeOnlyWhenRealMarketProjectionFacadeMissing() {
|
||||
ConditionalOnMissingBean condition =
|
||||
UnavailableMarketAccountProjectionFacade.class.getAnnotation(ConditionalOnMissingBean.class);
|
||||
|
||||
assertNotNull(condition);
|
||||
assertArrayEquals(new Class<?>[]{MarketAccountProjectionFacade.class}, condition.value());
|
||||
}
|
||||
|
||||
private static PageParam page(int pageNo, int pageSize) {
|
||||
PageParam pageParam = new PageParam();
|
||||
pageParam.setPageNo(pageNo);
|
||||
|
||||
@ -0,0 +1,70 @@
|
||||
package cn.iocoder.muse.module.member.dal.mysql.account;
|
||||
|
||||
import cn.iocoder.muse.framework.common.pojo.PageParam;
|
||||
import cn.iocoder.muse.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.muse.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import cn.iocoder.muse.module.member.dal.dataobject.account.AccountRecordProjectionDO;
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
/**
|
||||
* Account 市场记录投影 Mapper 语义测试。
|
||||
*/
|
||||
class AccountRecordProjectionMapperTest {
|
||||
|
||||
@BeforeAll
|
||||
static void initMybatisPlusTableInfo() {
|
||||
// 轻量 Mapper 语义测试不启动 Spring,需要手动初始化 LambdaQueryWrapper 字段映射缓存。
|
||||
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
|
||||
AccountRecordProjectionDO.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_selectPageByFiltersUseDisplayTimeOrderAndStableTieBreaker() {
|
||||
AccountRecordProjectionMapper mapper = mock(AccountRecordProjectionMapper.class, CALLS_REAL_METHODS);
|
||||
PageParam pageParam = new PageParam();
|
||||
doReturn(new PageResult<>(List.of(), 0L))
|
||||
.when(mapper).selectPage(eq(pageParam),
|
||||
org.mockito.ArgumentMatchers.<Wrapper<AccountRecordProjectionDO>>any());
|
||||
|
||||
mapper.selectPageByFilters(pageParam, "purchase", 1001L,
|
||||
List.of("external_pending", "pending"), "agent");
|
||||
|
||||
ArgumentCaptor<Wrapper<AccountRecordProjectionDO>> captor = ArgumentCaptor.forClass(Wrapper.class);
|
||||
verify(mapper).selectPage(eq(pageParam), captor.capture());
|
||||
String sqlSegment = normalizeSql(captor.getValue());
|
||||
assertTrue(sqlSegment.contains("record_type"));
|
||||
assertTrue(sqlSegment.contains("account_user_id"));
|
||||
assertTrue(sqlSegment.contains("source_status"));
|
||||
assertTrue(sqlSegment.contains("projection_snapshot"));
|
||||
assertTrue(sqlSegment.contains("order by coalesce(occurred_at, create_time) desc, id desc"));
|
||||
assertFalse(sqlSegment.contains("occurred_at desc, create_time desc"));
|
||||
|
||||
LambdaQueryWrapperX<AccountRecordProjectionDO> wrapper =
|
||||
(LambdaQueryWrapperX<AccountRecordProjectionDO>) captor.getValue();
|
||||
assertTrue(wrapper.getParamNameValuePairs().containsValue("purchase"));
|
||||
assertTrue(wrapper.getParamNameValuePairs().containsValue("external_pending"));
|
||||
assertTrue(wrapper.getParamNameValuePairs().containsValue("pending"));
|
||||
assertTrue(wrapper.getParamNameValuePairs().containsValue("agent"));
|
||||
}
|
||||
|
||||
private static String normalizeSql(Wrapper<AccountRecordProjectionDO> wrapper) {
|
||||
return wrapper.getSqlSegment().replaceAll("\\s+", " ").toLowerCase(Locale.ROOT).trim();
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user