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