From 886d23e3db193ec48c43f17f7c9ee771c19e37fc Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Fri, 6 Nov 2026 08:53:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E4=BD=99=E9=A2=9D=E6=8E=92=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../wallet/mapper/WalletManageMapper.java | 27 ++- .../service/impl/WalletManageServiceImpl.java | 75 ++++++- .../resources/mapper/WalletManageMapper.xml | 123 ++++++---- .../export/WalletLedgerBatchExportTest.java | 16 +- .../total/WalletQbBalanceSortServiceTest.java | 166 ++++++++++++++ .../total/WalletQbBalanceSortSqlTest.java | 210 ++++++++++++++++++ 6 files changed, 564 insertions(+), 53 deletions(-) create mode 100644 pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/total/WalletQbBalanceSortServiceTest.java create mode 100644 pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/total/WalletQbBalanceSortSqlTest.java diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletManageMapper.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletManageMapper.java index ef025d287..53bc2c4ae 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletManageMapper.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletManageMapper.java @@ -4,6 +4,7 @@ import cn.hutool.core.util.ObjectUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.toolkit.Constants; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.bsd.pjcloud.common.core.util.BeanUtils; import com.bsd.pjcloud.common.data.datascope.DataScope; @@ -164,6 +165,27 @@ public interface WalletManageMapper extends PjcloudBaseMapper scopeQbBalancePage(Page page, WalletManageDto dto, LocalDateTime monthEndExclusive, DataScope dataScope) { + LambdaQueryWrapper wrapper = commonQuery(dto); + // 排序由分页插件在权限查询外层追加,避免默认钱包顺序覆盖余额顺序。 + wrapper.getExpression().getOrderBy().clear(); + return selectQbBalancePage(page, dto, monthEndExclusive, wrapper, dataScope); + } + + /** 查询每个钱包唯一的月末余额;仅接收服务端生成的筛选包装器及可信分页排序。 */ + Page selectQbBalancePage(Page page, @Param("dto") WalletManageDto dto, @Param("monthEndExclusive") LocalDateTime monthEndExclusive, @Param(Constants.WRAPPER) LambdaQueryWrapper wrapper, @Param("dataScope") DataScope dataScope); + + /** 钱包数量独立计数:仅查钱包字段供权限拦截器包装 COUNT,不执行流水计算或排序。 */ + default Long countQbWallets(WalletManageDto dto, DataScope dataScope) { + LambdaQueryWrapper wrapper = commonQuery(dto).eq(WalletManageEntity::getDelFlag, "0"); + wrapper.getExpression().getOrderBy().clear(); + return selectQbWalletCount(wrapper, dataScope); + } + + /** 返回钱包筛选及 COUNT 数据权限范围内的总数;dataScope 必须使用 COUNT 模式。 */ + Long selectQbWalletCount(@Param(Constants.WRAPPER) LambdaQueryWrapper wrapper, @Param("dataScope") DataScope dataScope); + default Long count(WalletManageDto dto, DataScope dataScope) { return selectCountByScope(commonQuery(dto), dataScope); } @@ -177,10 +199,11 @@ public interface WalletManageMapper extends PjcloudBaseMapper scopeUserList(@Param("dto") WalletManageDto dto, @Param("dataScope") DataScope dataScope); - List selectBalance(@Param("dto") WalletManageBalanceDto walletDto); + /** 查询月界之前最新未删除流水,同秒取最大主键;每个用户与钱包类型最多返回一行。 */ + List selectBalance(@Param("dto") WalletManageBalanceDto walletDto, @Param("monthEndExclusive") LocalDateTime monthEndExclusive); /** 复用历史余额查询口径,流水结果也按固定批大小读取。 */ - Page selectBalanceExportPage(Page page, @Param("dto") WalletManageBalanceDto walletDto); + Page selectBalanceExportPage(Page page, @Param("dto") WalletManageBalanceDto walletDto, @Param("monthEndExclusive") LocalDateTime monthEndExclusive); Page activeBalancePage(Page page, @Param("dto") WalletTopupActionDto dto, @Param("unactiveDayNumberStr") String unactiveDayNumberStr); diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletManageServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletManageServiceImpl.java index 6e5da4012..7aa4ead17 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletManageServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletManageServiceImpl.java @@ -7,6 +7,7 @@ import cn.hutool.core.util.ObjectUtil; import cn.hutool.extra.spring.SpringUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.metadata.OrderItem; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.bsd.pjcloud.admin.api.dto.UserInfo; @@ -24,6 +25,7 @@ import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.asynctask.service.AsyncTaskException; import com.bsd.pjcloud.common.asynctask.service.BatchExportSupport; import com.bsd.pjcloud.common.data.datascope.DataScope; +import com.bsd.pjcloud.common.data.datascope.DataScopeFuncEnum; import com.bsd.pjcloud.common.data.resolver.ParamResolver; import com.bsd.pjcloud.common.data.tenant.TenantContextHolder; import com.bsd.pjcloud.wallet.api.constant.WalletTypeEnum; @@ -309,11 +311,18 @@ public class WalletManageServiceImpl extends ServiceImpl scopeQbPage(Page page, WalletManageBalanceDto dto, DataScope dataScope) { + LocalDateTime monthEndExclusive = walletRecordMonthEnd(dto.getWalletRecordMonth()); + List orders = page.orders(); + Optional balanceOrder = orders.stream().filter(order -> "balance".equals(order.getColumn())).findFirst(); + if (balanceOrder.isPresent()) { + return queryWalletRecordBalancePage(page, dto, dataScope, monthEndExclusive, balanceOrder.get().isAsc()); + } WalletManageDto walletDto = BeanUtils.toBean(dto, WalletManageDto.class); Page walletPage = baseMapper.scopePage(page, walletDto, dataScope); - if (walletPage.getTotal() == 0) return walletPage; + if (ObjectUtil.isEmpty(walletPage.getRecords())) return walletPage; walletPage.getRecords().forEach(v -> { v.setBalance(0); v.setTotalRecharge(0); @@ -332,10 +341,10 @@ public class WalletManageServiceImpl extends ServiceImpl itemList = baseMapper.selectBalance(walletBalanceDto); + List itemList = baseMapper.selectBalance(walletBalanceDto, monthEndExclusive); if (ObjectUtil.isEmpty(itemList)) continue; for (WalletManageBalanceVo j : itemList) { - walletPage.getRecords().stream().filter(v -> ObjectUtil.equals(i.getId(), v.getWalletTypeId()) && ObjectUtil.equals(v.getUserId(), j.getUserId())).findFirst().ifPresent(v -> { + walletPage.getRecords().stream().filter(v -> ObjectUtil.equals(i.getId(), v.getWalletTypeId()) && ObjectUtil.equals(v.getUserId(), j.getUserId())).forEach(v -> { if (ObjectUtil.isNotEmpty(j.getBalance())) { v.setBalance(j.getBalance()); v.setLastTopUpTime(j.getCreateTime()); @@ -347,8 +356,63 @@ public class WalletManageServiceImpl extends ServiceImpl queryWalletRecordBalancePage(Page page, WalletManageBalanceDto dto, DataScope dataScope, LocalDateTime monthEndExclusive, boolean ascending) { + if (page.getCurrent() < 1 || page.getSize() <= 0) { + throw new IllegalArgumentException("余额排序必须使用有效页码和正数分页大小"); + } + WalletManageDto walletDto = BeanUtils.toBean(dto, WalletManageDto.class); + page.setOrders(new ArrayList<>(List.of(ascending ? OrderItem.asc("balance") : OrderItem.desc("balance"), OrderItem.desc("id")))); + boolean searchCount = page.searchCount(); + DataScope queryScope = dataScope == null ? DataScope.of() : dataScope; + if (searchCount) { + // 权限计算可能扩展部门列表,计数使用独立副本,避免污染随后列表的数据范围。 + DataScope countScope = new DataScope(); + countScope.setScopeDeptName(queryScope.getScopeDeptName()); + countScope.setScopeUserName(queryScope.getScopeUserName()); + countScope.setDeptList(new ArrayList<>(queryScope.getDeptList())); + countScope.setUsername(queryScope.getUsername()); + countScope.setIsOnly(queryScope.getIsOnly()); + countScope.setFunc(DataScopeFuncEnum.COUNT); + page.setTotal(baseMapper.countQbWallets(walletDto, countScope)); + if (page.getTotal() == 0) { + page.setRecords(Collections.emptyList()); + return page; + } + } + try { + page.setSearchCount(false); + Page result = baseMapper.scopeQbBalancePage(page, walletDto, monthEndExclusive, queryScope); + disposeList(result.getRecords()); + return result; + } finally { + page.setSearchCount(searchCount); + } + } + + /** 将有效 YYYY-MM 月份转为下月零点排他上界,让流水时间条件能够使用索引。 */ + private LocalDateTime walletRecordMonthEnd(String month) { + if (month == null || !month.matches("[0-9]{4}-[0-9]{2}")) { + throw new IllegalArgumentException("请选择有效月份(YYYY-MM)"); + } + try { + YearMonth recordMonth = YearMonth.parse(month); + YearMonth nextMonth = recordMonth.plusMonths(1); + if (recordMonth.getYear() < 1000 || nextMonth.getYear() > 9999) { + throw new IllegalArgumentException("统计月份超出支持范围"); + } + return nextMonth.atDay(1).atStartOfDay(); + } catch (DateTimeException exception) { + throw new IllegalArgumentException("请选择有效月份(YYYY-MM)", exception); + } + } + @Override public Page scopeQbExportPage(Page page, WalletManageBalanceDto dto, DataScope dataScope) { + LocalDateTime monthEndExclusive = walletRecordMonthEnd(dto.getWalletRecordMonth()); // 长导出任务的事务会保留 MyBatis 一级缓存,每批读取前释放上一批结果。 sqlSession.clearCache(); WalletManageDto walletQuery = BeanUtils.toBean(dto, WalletManageDto.class); @@ -376,7 +440,7 @@ public class WalletManageServiceImpl extends ServiceImpl historyPage = BatchExportSupport.page(current); historyPage.setSearchCount(false); - List history = baseMapper.selectBalanceExportPage(historyPage, historyQuery).getRecords(); + List history = baseMapper.selectBalanceExportPage(historyPage, historyQuery, monthEndExclusive).getRecords(); for (WalletManageBalanceVo balance : history) { WalletManageVo target = targets.get(balance.getUserId()); if (target != null && balance.getBalance() != null) { @@ -464,6 +528,7 @@ public class WalletManageServiceImpl extends ServiceImpl scopeQbList(WalletManageBalanceDto dto, DataScope dataScope) { + LocalDateTime monthEndExclusive = walletRecordMonthEnd(dto.getWalletRecordMonth()); WalletManageDto walletDto = BeanUtils.toBean(dto, WalletManageDto.class); List walletList = baseMapper.scopeList(walletDto, dataScope); if (ObjectUtil.isEmpty(walletList)) return walletList; @@ -482,7 +547,7 @@ public class WalletManageServiceImpl extends ServiceImpl itemList = baseMapper.selectBalance(dto); + List itemList = baseMapper.selectBalance(dto, monthEndExclusive); if (ObjectUtil.isEmpty(itemList)) continue; for (WalletManageBalanceVo j : itemList) { walletList.stream().filter(v -> ObjectUtil.equals(i.getId(), v.getWalletTypeId()) && ObjectUtil.equals(v.getUserId(), j.getUserId())).findFirst().ifPresent(v -> { diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/WalletManageMapper.xml b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/WalletManageMapper.xml index 3f348c1bf..67052e3df 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/WalletManageMapper.xml +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/WalletManageMapper.xml @@ -28,60 +28,107 @@ - - SELECT a.user_id, - a.balance, - a.wallet_id, - m.wallet_type_id, - a.create_time - FROM wallet_transaction a - LEFT JOIN wallet_manage m on a.wallet_id = m.id - - a.del_flag = 0 - - AND a.create_time = ( - SELECT MAX(sub.create_time) - FROM wallet_transaction sub - LEFT JOIN wallet_manage wallet on wallet.id = sub.wallet_id - WHERE sub.user_id = a.user_id - AND DATE_FORMAT(sub.create_time, '%Y-%m') <= #{dto.walletRecordMonth} - - AND sub.user_id = #{dto.userId} - - - and sub.user_id in - - #{v} - - - - AND wallet.wallet_type_id = #{dto.walletTypeId} - - ) - + + + SELECT a.user_id, a.wallet_id, m.wallet_type_id, a.balance, a.create_time, + ROW_NUMBER() OVER ( + PARTITION BY a.user_id, m.wallet_type_id + ORDER BY a.create_time DESC, a.id DESC + ) AS row_num + FROM ( + SELECT h.tenant_id, h.del_flag, h.wallet_id, h.user_id, + MAX(h.create_time) AS latest_time + FROM wallet_transaction h + WHERE h.del_flag = '0' + AND h.create_time < #{monthEndExclusive} - AND a.user_id = #{dto.userId} + AND h.user_id = #{dto.userId} + + AND h.user_id IN + + #{userId} + + + GROUP BY h.tenant_id, h.del_flag, h.wallet_id, h.user_id + ) latest + INNER JOIN wallet_manage m ON m.id = latest.wallet_id + INNER JOIN wallet_transaction a ON a.id = ( + SELECT MAX(last.id) + FROM wallet_transaction last + WHERE (last.tenant_id = latest.tenant_id + OR (last.tenant_id IS NULL AND latest.tenant_id IS NULL)) + AND last.del_flag = latest.del_flag + AND last.wallet_id = latest.wallet_id + AND last.user_id = latest.user_id + AND last.create_time = latest.latest_time + ) + AND m.wallet_type_id = #{dto.walletTypeId} - - and a.user_id in - - #{v} + + AND m.wallet_type_id IN + + #{walletTypeId} + + + + + + + + + SELECT latest.user_id, latest.balance, latest.wallet_id, latest.wallet_type_id, latest.create_time + FROM ( + + ) latest + WHERE latest.row_num = 1 + + diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/export/WalletLedgerBatchExportTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/export/WalletLedgerBatchExportTest.java index 872f7280f..572b94f5e 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/export/WalletLedgerBatchExportTest.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/export/WalletLedgerBatchExportTest.java @@ -165,7 +165,7 @@ class WalletLedgerBatchExportTest { List rows = LongStream.range(from, Math.min(from + page.getSize(), 202)).mapToObj(WalletLedgerBatchExportTest::currentWallet).toList(); return new Page(page.getCurrent(), page.getSize()).setRecords(rows).setTotal(page.getCurrent() == 1 ? 202 : 0); }); - when(mapper.selectBalanceExportPage(any(), any())).thenAnswer(call -> { + when(mapper.selectBalanceExportPage(any(), any(), any())).thenAnswer(call -> { Page page = call.getArgument(0); checkPage(page, false); WalletManageBalanceDto query = call.getArgument(1); assertEquals("2026-09", query.getWalletRecordMonth()); assertEquals(1L, query.getWalletTypeId()); @@ -181,12 +181,12 @@ class WalletLedgerBatchExportTest { real.exportQb(response, new Page<>(8, 10), dto); assertNull(dto.getUserIds()); assertNull(dto.getWalletTypeId()); assertEquals("2026-09", dto.getWalletRecordMonth()); assertEquals(List.of(1L), dto.getWalletTypeIdList()); assertEquals(List.of(10L), dto.getDeptIds()); - verify(mapper, times(4)).selectBalanceExportPage(any(), any()); + verify(mapper, times(4)).selectBalanceExportPage(any(), any(), any()); // 三个钱包查询(含结束检查)和四个历史流水查询均释放上一批会话缓存。 verify(context.getBean(SqlSessionTemplate.class), times(7)).clearCache(); // 复用原业务服务生成固定参考样本,当前余额 99999 不应进入历史导出。 when(mapper.scopeList(any(), any())).thenAnswer(call -> IntStream.range(0, 202).mapToObj(WalletLedgerBatchExportTest::currentWallet).toList()); - when(mapper.selectBalance(any())).thenReturn(balances); + when(mapper.selectBalance(any(), any())).thenReturn(balances); List oldRows = real.scopeQbList(BeanUtil.toBean(dto, WalletManageBalanceDto.class), DataScope.of()); long total = 0; for (int i = 0; i < oldRows.size(); i++) { @@ -225,21 +225,21 @@ class WalletLedgerBatchExportTest { } @Test - void historySqlSharesLegacyMonthAndDeletionFiltersWithStablePageOrdering() throws Exception { + void historySqlSharesLatestValidBalanceAndStablePageOrdering() throws Exception { MybatisConfiguration configuration = new MybatisConfiguration(); try (InputStream input = new ClassPathResource("mapper/WalletManageMapper.xml").getInputStream()) { new XMLMapperBuilder(input, configuration, "mapper/WalletManageMapper.xml", configuration.getSqlFragments()).parse(); } - for (String month : Arrays.asList("2026-09", null)) { + for (String month : List.of("2026-09", "2026-10")) { WalletManageBalanceDto dto = new WalletManageBalanceDto(); dto.setWalletRecordMonth(month); dto.setWalletTypeId(1L); dto.setUserId(1000L); dto.setUserIds(List.of(1000L, 999L)); String prefix = WalletManageMapper.class.getName() + "."; - var before = configuration.getMappedStatement(prefix + "selectBalance").getBoundSql(Map.of("dto", dto)); - var after = configuration.getMappedStatement(prefix + "selectBalanceExportPage").getBoundSql(Map.of("dto", dto)); + var before = configuration.getMappedStatement(prefix + "selectBalance").getBoundSql(Map.of("dto", dto, "monthEndExclusive", java.time.YearMonth.parse(month).plusMonths(1).atDay(1).atStartOfDay())); + var after = configuration.getMappedStatement(prefix + "selectBalanceExportPage").getBoundSql(Map.of("dto", dto, "monthEndExclusive", java.time.YearMonth.parse(month).plusMonths(1).atDay(1).atStartOfDay())); String original = before.getSql().replaceAll("\\s+", " ").trim(); String paged = after.getSql().replaceAll("\\s+", " ").trim(); assertEquals(original.replaceFirst("ORDER BY.*$", ""), paged.replaceFirst("ORDER BY.*$", "")); - assertTrue(paged.endsWith("ORDER BY a.user_id, a.id")); + assertTrue(paged.endsWith("ORDER BY latest.user_id, latest.wallet_type_id")); assertEquals(before.getParameterMappings().stream().map(item -> item.getProperty()).toList(), after.getParameterMappings().stream().map(item -> item.getProperty()).toList()); } diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/total/WalletQbBalanceSortServiceTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/total/WalletQbBalanceSortServiceTest.java new file mode 100644 index 000000000..30d811a3a --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/total/WalletQbBalanceSortServiceTest.java @@ -0,0 +1,166 @@ +package com.bsd.pjcloud.wallet.total; + +import com.baomidou.mybatisplus.core.metadata.OrderItem; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.admin.api.feign.RemoteDeptService; +import com.bsd.pjcloud.admin.api.feign.RemoteUserService; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.data.datascope.DataScope; +import com.bsd.pjcloud.common.data.datascope.DataScopeFuncEnum; +import com.bsd.pjcloud.wallet.api.dto.WalletManageBalanceDto; +import com.bsd.pjcloud.wallet.api.entity.WalletTypeEntity; +import com.bsd.pjcloud.wallet.api.vo.WalletManageBalanceVo; +import com.bsd.pjcloud.wallet.api.vo.WalletManageVo; +import com.bsd.pjcloud.wallet.mapper.WalletManageMapper; +import com.bsd.pjcloud.wallet.mapper.WalletTypeMapper; +import com.bsd.pjcloud.wallet.service.WalletTypeService; +import com.bsd.pjcloud.wallet.service.impl.WalletManageServiceImpl; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.lang.reflect.Constructor; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** 验证全量排序查询边界、权限隔离及仅补充当前页资料,查询结果由 SQL 测试验证。 */ +class WalletQbBalanceSortServiceTest { + private final WalletManageMapper mapper = mock(WalletManageMapper.class); + private final WalletTypeMapper types = mock(WalletTypeMapper.class); + private final WalletTypeService typeService = mock(WalletTypeService.class); + private final RemoteUserService users = mock(RemoteUserService.class); + private final RemoteDeptService departments = mock(RemoteDeptService.class); + private WalletManageServiceImpl service; + + @BeforeEach + void setup() throws Exception { + Constructor constructor = WalletManageServiceImpl.class.getConstructors()[0]; + Map, Object> supplied = Map.of(WalletTypeMapper.class, types, WalletTypeService.class, typeService, + RemoteUserService.class, users, RemoteDeptService.class, departments); + Object[] dependencies = Arrays.stream(constructor.getParameterTypes()) + .map(type -> supplied.containsKey(type) ? supplied.get(type) : mock(type)).toArray(); + service = (WalletManageServiceImpl) constructor.newInstance(dependencies); + ReflectionTestUtils.setField(service, "baseMapper", mapper); + WalletTypeEntity type = new WalletTypeEntity(); + type.setId(11L); + when(types.list(any())).thenReturn(List.of(type)); + when(typeService.list()).thenReturn(List.of(type)); + when(users.getInnerUserById(any())).thenReturn(R.ok(List.of())); + when(departments.getDeptByDeptIds(any())).thenReturn(R.ok(Map.of())); + } + + @Test + void bothDirectionsUseIndependentCountStablePagingAndOnlyCurrentPageProfiles() { + for (boolean ascending : List.of(true, false)) { + reset(mapper, users); + when(users.getInnerUserById(any())).thenReturn(R.ok(List.of())); + Page page = sortedPage(ascending); + page.addOrder(OrderItem.asc("untrusted_field")); + DataScope scope = DataScope.of().deptIds(new ArrayList<>(List.of(7L))); + when(mapper.countQbWallets(any(), any())).thenAnswer(call -> { + DataScope countScope = call.getArgument(1); + assertNotSame(scope, countScope); + assertEquals(DataScopeFuncEnum.COUNT, countScope.getFunc()); + countScope.getDeptList().add(8L); + return 100L; + }); + when(mapper.scopeQbBalancePage(any(), any(), any(), any())).thenAnswer(call -> { + Page query = call.getArgument(0); + assertFalse(query.searchCount()); + assertEquals(List.of("balance", "id"), query.orders().stream().map(OrderItem::getColumn).toList()); + assertEquals(ascending, query.orders().get(0).isAsc()); + assertFalse(query.orders().get(1).isAsc()); + assertSame(scope, call.getArgument(3)); + assertEquals(List.of(7L), scope.getDeptList()); + assertEquals(LocalDateTime.of(2026, 11, 1, 0, 0), call.getArgument(2)); + return query.setRecords(List.of(wallet(1), wallet(2))); + }); + assertSame(page, service.scopeQbPage(page, query(), scope)); + assertTrue(page.searchCount()); + assertEquals(100, page.getTotal()); + verify(users).getInnerUserById(List.of(71L)); + verify(mapper, never()).scopePage(any(), any(), any()); + verify(mapper, never()).selectBalance(any(), any()); + } + } + + @Test + void emptyPagesSkipHistoryAndProfileQueries() { + for (long count : List.of(0L)) { + reset(mapper); + when(mapper.countQbWallets(any(), any())).thenReturn(count); + Page page = sortedPage(true); + page.setCurrent(2); + assertTrue(service.scopeQbPage(page, query(), DataScope.of()).getRecords().isEmpty()); + verify(mapper, never()).scopeQbBalancePage(any(), any(), any(), any()); + } + verifyZeroInteractions(users, departments); + } + + @Test + void countDisabledStillReadsPageAndFailuresRestoreCountFlag() { + Page page = sortedPage(true); + page.setSearchCount(false); + when(mapper.scopeQbBalancePage(any(), any(), any(), any())).thenReturn(page); + service.scopeQbPage(page, query(), DataScope.of()); + verify(mapper, never()).countQbWallets(any(), any()); + assertFalse(page.searchCount()); + page.setSearchCount(true); + when(mapper.countQbWallets(any(), any())).thenReturn(10L); + when(mapper.scopeQbBalancePage(any(), any(), any(), any())).thenThrow(new IllegalStateException("database unavailable")); + assertThrows(IllegalStateException.class, () -> service.scopeQbPage(page, query(), DataScope.of())); + assertTrue(page.searchCount()); + } + + @Test + void invalidMonthAndUnboundedSortingAreRejectedBeforeDatabaseAccess() { + for (String month : Arrays.asList(null, "", "2026-1", "2026-13", "0999-01", "9999-12")) { + WalletManageBalanceDto dto = query(); dto.setWalletRecordMonth(month); + assertThrows(IllegalArgumentException.class, () -> service.scopeQbPage(sortedPage(true), dto, DataScope.of())); + } + Page page = sortedPage(true); page.setSize(-1); + assertThrows(IllegalArgumentException.class, () -> service.scopeQbPage(page, query(), DataScope.of())); + verifyZeroInteractions(mapper); + } + + @Test + void ordinaryPageFillsDuplicateBalancesWhileExportKeepsExistingDeduplication() { + WalletManageBalanceVo history = new WalletManageBalanceVo(); + history.setUserId(71L); history.setWalletTypeId(11L); history.setBalance(-123); + history.setCreateTime(LocalDateTime.of(2026, 10, 31, 12, 0)); + when(mapper.selectBalance(any(), any())).thenReturn(List.of(history)); + when(mapper.scopePage(any(), any(), any())).thenAnswer(call -> + new Page().setTotal(2).setRecords(List.of(wallet(1), wallet(2)))); + List ordinary = service.scopeQbPage(new Page<>(1, 20), query(), DataScope.of()).getRecords(); + assertEquals(List.of(-123, -123), ordinary.stream().map(WalletManageVo::getBalance).toList()); + when(mapper.scopeExportPage(any(), any(), any())).thenAnswer(call -> + new Page().setRecords(List.of(wallet(1), wallet(2)))); + when(mapper.selectBalanceExportPage(any(), any(), any())).thenReturn(new Page().setRecords(List.of(history))); + List exported = service.scopeQbExportPage(new Page<>(1, 20), query(), DataScope.of()).getRecords(); + assertEquals(List.of(-123, 0), exported.stream().map(WalletManageVo::getBalance).toList()); + assertEquals(history.getCreateTime(), exported.get(0).getLastTopUpTime()); + assertNull(exported.get(1).getLastTopUpTime()); + } + + private static Page sortedPage(boolean ascending) { + return new Page(1, 2).addOrder(ascending ? OrderItem.asc("balance") : OrderItem.desc("balance")); + } + + private static WalletManageBalanceDto query() { + WalletManageBalanceDto dto = new WalletManageBalanceDto(); dto.setWalletRecordMonth("2026-10"); + return dto; + } + + private static WalletManageVo wallet(long id) { + WalletManageVo row = new WalletManageVo(); row.setId(id); row.setUserId(71L); row.setDeptId(7L); + row.setWalletTypeId(11L); row.setBalance(999999); + return row; + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/total/WalletQbBalanceSortSqlTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/total/WalletQbBalanceSortSqlTest.java new file mode 100644 index 000000000..0bf2320fe --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/total/WalletQbBalanceSortSqlTest.java @@ -0,0 +1,210 @@ +package com.bsd.pjcloud.wallet.total; + +import com.baomidou.mybatisplus.annotation.DbType; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder; +import com.baomidou.mybatisplus.core.metadata.OrderItem; +import com.baomidou.mybatisplus.core.toolkit.GlobalConfigUtils; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.common.data.datascope.DataScope; +import com.bsd.pjcloud.common.data.datascope.DataScopeFuncEnum; +import com.bsd.pjcloud.common.data.datascope.DataScopeInnerInterceptor; +import com.bsd.pjcloud.common.data.datascope.DataScopeSqlInjector; +import com.bsd.pjcloud.common.data.tenant.PjcloudTenantConfigProperties; +import com.bsd.pjcloud.common.data.tenant.PjcloudTenantHandler; +import com.bsd.pjcloud.common.data.tenant.TenantContextHolder; +import com.bsd.pjcloud.wallet.api.dto.WalletManageBalanceDto; +import com.bsd.pjcloud.wallet.api.dto.WalletManageDto; +import com.bsd.pjcloud.wallet.api.vo.WalletManageVo; +import com.bsd.pjcloud.wallet.mapper.WalletManageMapper; +import org.apache.ibatis.builder.xml.XMLMapperBuilder; +import org.apache.ibatis.datasource.unpooled.UnpooledDataSource; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.session.LocalCacheScope; +import org.apache.ibatis.session.SqlSession; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.InputStream; +import java.sql.Connection; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +/** 以生产 XML 和租户、数据权限、分页拦截器验证月末余额排序;数据全部位于独立 H2 内存库。 */ +class WalletQbBalanceSortSqlTest { + /** 十月月末的排他上界,十一月零点流水不能参与十月余额。 */ + private static final LocalDateTime MONTH_END = LocalDateTime.of(2026, 11, 1, 0, 0); + /** 每个测试单独创建并关闭的数据库会话。 */ + private SqlSession session; + /** 指向生产 SQL 的 Mapper,不使用模拟余额查询。 */ + private WalletManageMapper mapper; + /** 仅控制测试操作者是否拥有全部数据权限;有限权限仍由生产拦截器拼装。 */ + private boolean unrestricted = true; + + /** 建立生产相同的拦截顺序,并写入跨租户、重复钱包和历史流水边界。 */ + @BeforeEach + void setup() throws Exception { + MybatisConfiguration configuration = new MybatisConfiguration(); + configuration.setMapUnderscoreToCamelCase(true); + configuration.setLocalCacheScope(LocalCacheScope.STATEMENT); + configuration.setEnvironment(new Environment("wallet-balance-sort", new JdbcTransactionFactory(), + new UnpooledDataSource("org.h2.Driver", "jdbc:h2:mem:wallet_balance_sort_" + UUID.randomUUID() + + ";MODE=MySQL;DATABASE_TO_LOWER=TRUE", "sa", ""))); + GlobalConfigUtils.getGlobalConfig(configuration).setSqlInjector(new DataScopeSqlInjector()); + PjcloudTenantConfigProperties tenants = new PjcloudTenantConfigProperties(); + tenants.setTables(List.of("wallet_manage", "wallet_transaction", "wallet_type")); + MybatisPlusInterceptor plugins = new MybatisPlusInterceptor(); + plugins.addInnerInterceptor(new TenantLineInnerInterceptor(new PjcloudTenantHandler(tenants))); + DataScopeInnerInterceptor permissions = new DataScopeInnerInterceptor(); + permissions.setDataScopeHandle(scope -> unrestricted); + plugins.addInnerInterceptor(permissions); + plugins.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); + configuration.addInterceptor(plugins); + String resource = "mapper/WalletManageMapper.xml"; + try (InputStream input = getClass().getClassLoader().getResourceAsStream(resource)) { + assertNotNull(input); + new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse(); + } + session = new MybatisSqlSessionFactoryBuilder().build(configuration).openSession(true); + mapper = session.getMapper(WalletManageMapper.class); + TenantContextHolder.setTenantId(7L); + Connection database = session.getConnection(); + try (var statement = database.createStatement()) { + statement.execute(""" + create table wallet_manage(id bigint primary key,user_id bigint,wallet_type_id bigint,dept_id bigint, + card_number varchar(64),balance int,total_expense int,total_recharge int,freeze_price int, + is_freeze varchar(8),active_bind_time timestamp,status varchar(8),version int, + create_by varchar(64),create_time timestamp,update_by varchar(64),update_time timestamp, + del_flag varchar(1),tenant_id bigint); + create table wallet_type(id bigint primary key,tenant_id bigint,del_flag varchar(1)); + create table wallet_transaction(id bigint primary key,wallet_id bigint,user_id bigint, + balance int,create_time timestamp,del_flag varchar(1),tenant_id bigint); + insert into wallet_type values (11,7,'0'),(22,7,'0'),(33,7,'1'),(81,8,'0'); + insert into wallet_manage(id,user_id,wallet_type_id,dept_id,balance,create_by,del_flag,tenant_id) values + (101,1,11,10,999,'admin','0',7),(102,2,11,10,999,'admin','0',7), + (103,3,11,20,999,'admin','0',7),(104,4,22,20,999,'admin','0',7), + (105,1,11,20,999,'admin','0',7),(106,5,11,10,999,'admin','0',7), + (107,6,33,10,999,'admin','0',7),(108,7,44,30,999,'self','0',7), + (109,1,11,30,999,'admin','1',7),(201,1,81,10,999,'admin','0',8); + insert into wallet_transaction values + (1,101,1,100,'2026-10-01 12:00:00','0',7), + (2,101,1,120,'2026-10-20 12:00:00','0',7), + (3,101,1,140,'2026-10-20 12:00:00','0',7), + (4,101,1,9999,'2026-10-31 23:59:59','1',7), + (5,101,1,9999,'2026-11-01 00:00:00','0',7), + (6,109,1,160,'2026-10-25 12:00:00','0',7), + (7,102,2,-50,'2026-10-31 23:59:59','0',7), + (8,104,4,0,'2026-10-15 12:00:00','0',7), + (9,106,5,null,'2026-10-15 12:00:00','0',7), + (10,107,6,7000,'2026-10-15 12:00:00','0',7), + (11,108,7,8000,'2026-10-15 12:00:00','0',7), + (12,201,1,700,'2026-10-15 12:00:00','0',8), + (13,101,1,9999,'2026-10-31 23:59:59','0',8), + (14,101,1,9999,null,'0',7); + """); + } + } + + /** 关闭连接即销毁该测试的内存数据库,清理线程租户状态。 */ + @AfterEach + void close() { + TenantContextHolder.clear(); + if (session != null) session.close(); + } + + /** 第一页先比较全部历史余额,翻页保留余额顺序和同额主键顺序。 */ + @Test + void ascendingAndDescendingSortAllRowsBeforePagination() { + assertEquals(List.of(102L, 108L, 107L), ids(page(new WalletManageDto(), true, 1, 3, DataScope.of()))); + assertEquals(List.of(106L, 104L, 103L), ids(page(new WalletManageDto(), true, 2, 3, DataScope.of()))); + assertEquals(List.of(105L, 101L), ids(page(new WalletManageDto(), true, 3, 3, DataScope.of()))); + assertEquals(List.of(105L, 101L, 108L), ids(page(new WalletManageDto(), false, 1, 3, DataScope.of()))); + assertEquals(List.of(-50, 0, 0, 0, 0, 0, 160, 160), + page(new WalletManageDto(), true, 1, 50, DataScope.of()).getRecords().stream().map(WalletManageVo::getBalance).toList()); + WalletManageVo nullBalance = page(new WalletManageDto(), true, 1, 50, DataScope.of()).getRecords().stream() + .filter(wallet -> wallet.getId().equals(106L)).findFirst().orElseThrow(); + assertNull(nullBalance.getLastTopUpTime(), "空余额的历史时间不能在切换排序后突然显示"); + assertEquals(8L, mapper.countQbWallets(new WalletManageDto(), DataScope.of().func(DataScopeFuncEnum.COUNT))); + } + + /** 历史跨钱包取值不受当前部门过滤影响,类型单值与列表取交集。 */ + @Test + void departmentUserAndTypeFiltersKeepHistoricalBalance() { + WalletManageDto query = new WalletManageDto(); + query.setDeptIds(List.of(20L)); + query.setIsInclude(true); + assertEquals(List.of(105L, 104L, 103L), ids(page(query, false, 1, 50, DataScope.of()))); + assertEquals(160, page(query, false, 1, 50, DataScope.of()).getRecords().get(0).getBalance()); + query.setIsInclude(false); + assertEquals(List.of(101L, 108L, 107L, 106L, 102L), ids(page(query, false, 1, 50, DataScope.of()))); + query = new WalletManageDto(); + query.setUserIds(List.of(1L, 2L)); + query.setWalletTypeId(11L); + query.setWalletTypeIdList(List.of(11L, 22L)); + assertEquals(List.of(105L, 101L, 102L), ids(page(query, false, 1, 50, DataScope.of()))); + query.setWalletTypeIdList(List.of(22L)); + assertTrue(page(query, false, 1, 50, DataScope.of()).getRecords().isEmpty()); + } + + /** 真实权限拦截器先限制候选钱包,再由分页插件在最外层排序和截取。 */ + @Test + void departmentSelfAndDeniedPermissionsApplyBeforePageLimit() { + unrestricted = false; + assertEquals(List.of(101L, 107L), ids(page(new WalletManageDto(), false, 1, 2, DataScope.of().deptIds(List.of(10L))))); + assertEquals(4L, mapper.countQbWallets(new WalletManageDto(), DataScope.of().deptIds(List.of(10L)).func(DataScopeFuncEnum.COUNT))); + DataScope ownOrDepartment = DataScope.of().deptIds(List.of(10L)); + ownOrDepartment.setUsername("self"); + assertEquals(List.of(101L, 108L), ids(page(new WalletManageDto(), false, 1, 2, ownOrDepartment))); + DataScope own = DataScope.of(); + own.setUsername("self"); + assertEquals(List.of(108L), ids(page(new WalletManageDto(), false, 1, 10, own))); + assertTrue(page(new WalletManageDto(), false, 1, 10, DataScope.of()).getRecords().isEmpty()); + assertEquals(0L, mapper.countQbWallets(new WalletManageDto(), DataScope.of().func(DataScopeFuncEnum.COUNT))); + } + + /** 切换租户后,同一用户 ID 和异常跨租户流水不会污染余额及分页总数。 */ + @Test + void productionTenantInterceptorScopesWalletsTypesAndHistory() { + TenantContextHolder.setTenantId(8L); + Page result = page(new WalletManageDto(), false, 1, 10, DataScope.of()); + assertEquals(List.of(201L), ids(result)); + assertEquals(700, result.getRecords().get(0).getBalance()); + assertEquals(1L, mapper.countQbWallets(new WalletManageDto(), DataScope.of().func(DataScopeFuncEnum.COUNT))); + } + + /** 同秒取最大 ID,月界、删除和无时间流水不改变普通列表与排序的共同余额来源。 */ + @Test + void ordinaryBalanceQueryUsesSameDeterministicHistory() { + WalletManageBalanceDto query = new WalletManageBalanceDto(); + query.setWalletTypeId(11L); + query.setUserId(1L); + var beforeLaterWallet = mapper.selectBalance(query, LocalDateTime.of(2026, 10, 21, 0, 0)); + assertEquals(1, beforeLaterWallet.size()); + assertEquals(140, beforeLaterWallet.get(0).getBalance()); + var monthEnd = mapper.selectBalance(query, MONTH_END); + assertEquals(1, monthEnd.size()); + assertEquals(160, monthEnd.get(0).getBalance()); + assertEquals(109L, monthEnd.get(0).getWalletId()); + assertEquals(LocalDateTime.of(2026, 10, 25, 12, 0), monthEnd.get(0).getCreateTime()); + } + + /** 仅使用服务端固定排序字段,关闭自动统计以覆盖实际服务的独立计数路径。 */ + private Page page(WalletManageDto query, boolean ascending, long current, long size, DataScope scope) { + Page page = new Page<>(current, size, false); + page.addOrder(ascending ? OrderItem.asc("balance") : OrderItem.desc("balance"), OrderItem.desc("id")); + return mapper.scopeQbBalancePage(page, query, MONTH_END, scope); + } + + /** 提取页面顺序,断言分页边界和同额主键顺序。 */ + private List ids(Page page) { + return page.getRecords().stream().map(WalletManageVo::getId).toList(); + } +}