feat:余额排序

This commit is contained in:
dzc committed 2026-11-06 08:53:33 +08:00
1 parent 94a0cd5f92
commit 886d23e3db
6 files changed
+564 -53

No files matched your search

@@ -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<WalletManageEntity
return list;
}
/** 复用钱包筛选与数据权限,查询全量月末余额排序后的当前页;调用方独立统计总数。 */
default Page<WalletManageVo> scopeQbBalancePage(Page<WalletManageVo> page, WalletManageDto dto, LocalDateTime monthEndExclusive, DataScope dataScope) {
LambdaQueryWrapper<WalletManageEntity> wrapper = commonQuery(dto);
// 排序由分页插件在权限查询外层追加,避免默认钱包顺序覆盖余额顺序。
wrapper.getExpression().getOrderBy().clear();
return selectQbBalancePage(page, dto, monthEndExclusive, wrapper, dataScope);
}
/** 查询每个钱包唯一的月末余额;仅接收服务端生成的筛选包装器及可信分页排序。 */
Page<WalletManageVo> selectQbBalancePage(Page<WalletManageVo> page, @Param("dto") WalletManageDto dto, @Param("monthEndExclusive") LocalDateTime monthEndExclusive, @Param(Constants.WRAPPER) LambdaQueryWrapper<WalletManageEntity> wrapper, @Param("dataScope") DataScope dataScope);
/** 钱包数量独立计数:仅查钱包字段供权限拦截器包装 COUNT,不执行流水计算或排序。 */
default Long countQbWallets(WalletManageDto dto, DataScope dataScope) {
LambdaQueryWrapper<WalletManageEntity> wrapper = commonQuery(dto).eq(WalletManageEntity::getDelFlag, "0");
wrapper.getExpression().getOrderBy().clear();
return selectQbWalletCount(wrapper, dataScope);
}
/** 返回钱包筛选及 COUNT 数据权限范围内的总数;dataScope 必须使用 COUNT 模式。 */
Long selectQbWalletCount(@Param(Constants.WRAPPER) LambdaQueryWrapper<WalletManageEntity> 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<WalletManageEntity
List<Long> scopeUserList(@Param("dto") WalletManageDto dto, @Param("dataScope") DataScope dataScope);
List<WalletManageBalanceVo> selectBalance(@Param("dto") WalletManageBalanceDto walletDto);
/** 查询月界之前最新未删除流水,同秒取最大主键;每个用户与钱包类型最多返回一行。 */
List<WalletManageBalanceVo> selectBalance(@Param("dto") WalletManageBalanceDto walletDto, @Param("monthEndExclusive") LocalDateTime monthEndExclusive);
/** 复用历史余额查询口径,流水结果也按固定批大小读取。 */
Page<WalletManageBalanceVo> selectBalanceExportPage(Page page, @Param("dto") WalletManageBalanceDto walletDto);
Page<WalletManageBalanceVo> selectBalanceExportPage(Page page, @Param("dto") WalletManageBalanceDto walletDto, @Param("monthEndExclusive") LocalDateTime monthEndExclusive);
Page<WalletManageVo> activeBalancePage(Page page, @Param("dto") WalletTopupActionDto dto, @Param("unactiveDayNumberStr") String unactiveDayNumberStr);
@@ -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<WalletManageMapper, Wal
return result;
}
/** 按月末余额全量排序时先在数据库排序分页,普通查询仍只计算当前页余额。 */
@Override
public Page<WalletManageVo> scopeQbPage(Page page, WalletManageBalanceDto dto, DataScope dataScope) {
LocalDateTime monthEndExclusive = walletRecordMonthEnd(dto.getWalletRecordMonth());
List<OrderItem> orders = page.orders();
Optional<OrderItem> 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<WalletManageVo> 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<WalletManageMapper, Wal
if (ObjectUtil.isEmpty(walletBalanceDto.getUserId())) {
walletBalanceDto.setUserIds(userIds);
}
List<WalletManageBalanceVo> itemList = baseMapper.selectBalance(walletBalanceDto);
List<WalletManageBalanceVo> 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<WalletManageMapper, Wal
return walletPage;
}
/**
* 只对白名单余额字段排序,独立统计钱包数,避免分页插件再次计算全部历史流水。
* 同额按钱包主键倒序;远程用户、部门资料仅补充数据库返回的当前页。
*/
private Page<WalletManageVo> queryWalletRecordBalancePage(Page<WalletManageVo> 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<WalletManageVo> 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<WalletManageVo> 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<WalletManageMapper, Wal
sqlSession.clearCache();
Page<WalletManageBalanceVo> historyPage = BatchExportSupport.page(current);
historyPage.setSearchCount(false);
List<WalletManageBalanceVo> history = baseMapper.selectBalanceExportPage(historyPage, historyQuery).getRecords();
List<WalletManageBalanceVo> 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<WalletManageMapper, Wal
@Override
public List<WalletManageVo> scopeQbList(WalletManageBalanceDto dto, DataScope dataScope) {
LocalDateTime monthEndExclusive = walletRecordMonthEnd(dto.getWalletRecordMonth());
WalletManageDto walletDto = BeanUtils.toBean(dto, WalletManageDto.class);
List<WalletManageVo> walletList = baseMapper.scopeList(walletDto, dataScope);
if (ObjectUtil.isEmpty(walletList)) return walletList;
@@ -482,7 +547,7 @@ public class WalletManageServiceImpl extends ServiceImpl<WalletManageMapper, Wal
if (ObjectUtil.isEmpty(dto.getUserId())) {
dto.setUserIds(userIds);
}
List<WalletManageBalanceVo> itemList = baseMapper.selectBalance(dto);
List<WalletManageBalanceVo> 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 -> {
@@ -28,60 +28,107 @@
<sql id="whereA">
</sql>
<sql id="selectBalanceQuery">
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
<where>
a.del_flag = 0
<if test="dto.walletRecordMonth != null">
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') &lt;= #{dto.walletRecordMonth}
<if test="dto.userId != null">
AND sub.user_id = #{dto.userId}
</if>
<if test="dto.userIds != null and dto.userIds.size() != 0">
and sub.user_id in
<foreach collection="dto.userIds" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
<if test="dto.walletTypeId != null">
AND wallet.wallet_type_id = #{dto.walletTypeId}
</if>
)
</if>
<!--
复用余额统计的候选收缩方案:先按钱包和历史所属用户取月界之前最大时间,
同秒取最大 ID,再仅对候选行按用户、钱包类型去重,避免排序全部历史流水。
历史钱包不限制删除状态,部门筛选留给外层当前钱包,沿用原列表取值口径。
-->
<sql id="qbLatestBalanceRows">
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 &lt; #{monthEndExclusive}
<if test="dto.userId != null">
AND a.user_id = #{dto.userId}
AND h.user_id = #{dto.userId}
</if>
<if test="dto.userIds != null and dto.userIds.size() != 0">
AND h.user_id IN
<foreach collection="dto.userIds" item="userId" open="(" close=")" separator=",">
#{userId}
</foreach>
</if>
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
)
<where>
<if test="dto.walletTypeId != null">
AND m.wallet_type_id = #{dto.walletTypeId}
</if>
<if test="dto.userIds != null and dto.userIds.size() != 0">
and a.user_id in
<foreach collection="dto.userIds" item="v" open="(" close=")" separator=",">
#{v}
<if test="dto.walletTypeIdList != null and dto.walletTypeIdList.size() != 0">
AND m.wallet_type_id IN
<foreach collection="dto.walletTypeIdList" item="walletTypeId" open="(" close=")" separator=",">
#{walletTypeId}
</foreach>
</if>
</where>
</sql>
<!-- 权限拦截器在外层统一 COUNT,内层须保留部门和创建人,不能先执行一次 COUNT。 -->
<select id="selectQbWalletCount" resultType="java.lang.Long">
SELECT wallet.id, wallet.dept_id, wallet.create_by
FROM wallet_manage wallet
${ew.customSqlSegment}
</select>
<!-- 月末余额先连接到全部匹配钱包,分页插件在租户及数据权限处理之后排序、分页。 -->
<select id="selectQbBalancePage" resultType="com.bsd.pjcloud.wallet.api.vo.WalletManageVo">
SELECT monthly_wallet.*
FROM (
SELECT wallet.id, wallet.user_id, wallet.wallet_type_id, wallet.dept_id,
wallet.card_number, COALESCE(latest.balance, 0) AS balance,
0 AS total_expense, 0 AS total_recharge,
wallet.freeze_price, wallet.is_freeze, wallet.active_bind_time,
wallet.status, wallet.version, wallet.create_by, wallet.create_time,
wallet.update_by, wallet.update_time, wallet.del_flag, wallet.tenant_id,
CASE WHEN latest.balance IS NOT NULL THEN latest.create_time END AS last_top_up_time
FROM wallet_manage wallet
<!-- 普通列表只计算有效类型的余额;缺失或已删除类型的钱包同样保留零余额。 -->
LEFT JOIN wallet_type available_type ON available_type.id = wallet.wallet_type_id
AND available_type.del_flag = '0'
LEFT JOIN (
<include refid="qbLatestBalanceRows"/>
) latest ON latest.user_id = wallet.user_id
AND latest.wallet_type_id = available_type.id
AND latest.row_num = 1
WHERE wallet.del_flag = '0'
) monthly_wallet
${ew.customSqlSegment}
</select>
<!-- 列表与导出共享唯一的最新有效流水,防止切换排序时同一钱包的显示余额变化。 -->
<sql id="selectBalanceQuery">
SELECT latest.user_id, latest.balance, latest.wallet_id, latest.wallet_type_id, latest.create_time
FROM (
<include refid="qbLatestBalanceRows"/>
) latest
WHERE latest.row_num = 1
</sql>
<select id="selectBalance" resultType="com.bsd.pjcloud.wallet.api.vo.WalletManageBalanceVo">
<include refid="selectBalanceQuery"/>
ORDER BY a.user_id;
ORDER BY latest.user_id, latest.wallet_type_id
</select>
<select id="selectBalanceExportPage" resultType="com.bsd.pjcloud.wallet.api.vo.WalletManageBalanceVo">
<include refid="selectBalanceQuery"/>
ORDER BY a.user_id, a.id
ORDER BY latest.user_id, latest.wallet_type_id
</select>
<sql id="scopeUser">
@@ -165,7 +165,7 @@ class WalletLedgerBatchExportTest {
List<WalletManageVo> rows = LongStream.range(from, Math.min(from + page.getSize(), 202)).mapToObj(WalletLedgerBatchExportTest::currentWallet).toList();
return new Page<WalletManageVo>(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<WalletManageBalanceVo> 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<WalletManageVo> 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());
}
@@ -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<Class<?>, 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<WalletManageVo> 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<WalletManageVo> 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<WalletManageVo> 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<WalletManageVo> 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<WalletManageVo> 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<WalletManageVo>().setTotal(2).setRecords(List.of(wallet(1), wallet(2))));
List<WalletManageVo> 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<WalletManageVo>().setRecords(List.of(wallet(1), wallet(2))));
when(mapper.selectBalanceExportPage(any(), any(), any())).thenReturn(new Page<WalletManageBalanceVo>().setRecords(List.of(history)));
List<WalletManageVo> 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<WalletManageVo> sortedPage(boolean ascending) {
return new Page<WalletManageVo>(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;
}
}
@@ -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<WalletManageVo> 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<WalletManageVo> page(WalletManageDto query, boolean ascending, long current, long size, DataScope scope) {
Page<WalletManageVo> 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<Long> ids(Page<WalletManageVo> page) {
return page.getRecords().stream().map(WalletManageVo::getId).toList();
}
}