fix(p1r): 稳定 Account 市场记录查询边界

This commit is contained in:
zizi 2026-05-29 15:20:40 +08:00
parent 68313e6efe
commit 8c1a895e09
4 changed files with 91 additions and 4 deletions

View File

@ -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

View File

@ -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);
}

View File

@ -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);

View File

@ -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();
}
}