diff --git a/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/application/account/facade/UnavailableMarketAccountProjectionFacade.java b/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/application/account/facade/UnavailableMarketAccountProjectionFacade.java index 1b81a762..86c94d03 100644 --- a/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/application/account/facade/UnavailableMarketAccountProjectionFacade.java +++ b/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/application/account/facade/UnavailableMarketAccountProjectionFacade.java @@ -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 域里回填假记录。

*/ @Component +@ConditionalOnMissingBean(MarketAccountProjectionFacade.class) public class UnavailableMarketAccountProjectionFacade implements MarketAccountProjectionFacade { @Override diff --git a/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/dal/mysql/account/AccountRecordProjectionMapper.java b/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/dal/mysql/account/AccountRecordProjectionMapper.java index 59607633..f86d1699 100644 --- a/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/dal/mysql/account/AccountRecordProjectionMapper.java +++ b/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/dal/mysql/account/AccountRecordProjectionMapper.java @@ -16,12 +16,15 @@ import java.util.List; @Mapper public interface AccountRecordProjectionMapper extends BaseMapperX { + String DISPLAY_TIME_ORDER_SQL = "ORDER BY COALESCE(occurred_at, create_time) DESC, id DESC"; + default List selectListByAccountUserIdAndRecordType(Long accountUserId, String recordType) { return selectList(new LambdaQueryWrapperX() .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> 'assetType' = {0}", assetType); } + // PostgreSQL DESC 默认 NULL 在前;这里按响应展示时间排序,避免 occurred_at 为空的记录插到最新页前面。 + query.last(DISPLAY_TIME_ORDER_SQL); return selectPage(pageParam, query); } diff --git a/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/application/account/AccountMarketRecordServiceTest.java b/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/application/account/AccountMarketRecordServiceTest.java index 9be83604..f2e83c29 100644 --- a/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/application/account/AccountMarketRecordServiceTest.java +++ b/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/application/account/AccountMarketRecordServiceTest.java @@ -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); diff --git a/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/dal/mysql/account/AccountRecordProjectionMapperTest.java b/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/dal/mysql/account/AccountRecordProjectionMapperTest.java new file mode 100644 index 00000000..d3140dd7 --- /dev/null +++ b/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/dal/mysql/account/AccountRecordProjectionMapperTest.java @@ -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.>any()); + + mapper.selectPageByFilters(pageParam, "purchase", 1001L, + List.of("external_pending", "pending"), "agent"); + + ArgumentCaptor> 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 wrapper = + (LambdaQueryWrapperX) 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 wrapper) { + return wrapper.getSqlSegment().replaceAll("\\s+", " ").toLowerCase(Locale.ROOT).trim(); + } +}