feat:钱包余额统计查询

This commit is contained in:
dzc committed 2026-10-23 10:28:31 +08:00
1 parent c0285edd4c
commit 9f9e3c6d04
9 files changed
+891 -131

No files matched your search

+32
View File
@@ -0,0 +1,32 @@
-- /wallet/wallet-manage/qb-total 历史余额统计索引(MySQL 8)。
-- 先在钱包库执行本脚本,再发布 Wallet 服务;不修改钱包、流水或余额数据。
-- 大流水表建索引会产生 IO/空间开销,请在低峰执行并观察磁盘和复制延迟。
-- 显式 GROUP BY tenant_id,del_flag,wallet_id,user_id 与此前缀配合使用跳组扫描。
-- create_time 确定月末之前的最新时间,id 解决同秒多笔流水的取值。
-- 不替换既有钱包流水/业务查询索引;其他索引承担不同查询场景。
SET @wallet_qb_ddl = IF(EXISTS (
SELECT 1 FROM (
SELECT INDEX_NAME, GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) AS cols,
SUM(SUB_PART IS NOT NULL) AS prefix_columns,
SUM(COLLATION != 'A') AS descending_columns
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = 'pjcloudx_wallet' AND TABLE_NAME = 'wallet_transaction'
AND IS_VISIBLE = 'YES' AND INDEX_TYPE = 'BTREE'
GROUP BY INDEX_NAME
) indexes
WHERE prefix_columns = 0
AND descending_columns = 0
AND (cols = 'tenant_id,del_flag,wallet_id,user_id,create_time,id'
OR LEFT(cols, LENGTH('tenant_id,del_flag,wallet_id,user_id,create_time,id') + 1)
= 'tenant_id,del_flag,wallet_id,user_id,create_time,id,')
), 'SELECT ''wallet qb: equivalent history index exists''',
'CREATE INDEX idx_wallet_qb_latest ON pjcloudx_wallet.wallet_transaction (tenant_id,del_flag,wallet_id,user_id,create_time,id) ALGORITHM=INPLACE LOCK=NONE');
PREPARE wallet_qb_stmt FROM @wallet_qb_ddl;
EXECUTE wallet_qb_stmt;
DEALLOCATE PREPARE wallet_qb_stmt;
-- 同名异构索引会报错,核对后人工处理,不自动删除旧索引。
-- 发布后用真实 tenant、月份及筛选参数执行 EXPLAIN ANALYZE:
-- 分组子查询应显示 Using index for group-by / Covering index skip scan for grouping,
-- 窗口节点处理的是钱包/历史所属用户候选数,不应是整月之前的全部流水数。
@@ -0,0 +1,90 @@
# 钱包月末余额统计优化
接口:`POST /wallet/wallet-manage/qb-total`。
## 请求链路与瓶颈
管理端钱包记录页点击统计后发起请求;列表、搜索和分页本来就不触发余额总计。
Controller 直接调用 `WalletManageServiceImpl.qbTotal`,此路径没有用户/部门信息的远程查询。
旧服务先查询钱包类型,然后对每个类型串行执行一次 `selectTotalBalance`。
每次 SQL 都对月末以前的历史流水执行 `FIRST_VALUE` 窗口排序及 `DISTINCT`,
月份条件 `DATE_FORMAT(create_time, '%Y-%m') <= ?` 也无法直接使用时间列范围。
流水累计越多,重复扫描、排序和临时结果的成本越大。
## 实现
- 服务校验 `yyyy-MM`,计算下个月一日零点作为排他上界,使用 `create_time < monthEnd`。
- 所有选中钱包类型一次聚合,保留类型列表与单一类型的交集;不再逐类型执行历史查询。
- 先按 `(tenant_id, del_flag, wallet_id, user_id)` 分组取最大时间。
配套索引允许 MySQL 使用 loose index scan 跳过同组的历史流水。
- 对最大时间内的同秒流水取最大 ID,再只对候选余额执行按 `(user_id, wallet_type_id)` 的窗口去重。
- 金额聚合使用 `Long`,无流水的类型返回零。
- 前端统计显示加载状态、避免在途重复提交,关闭/卸载时取消浏览器等待,
请求序号防止过期响应覆盖新结果。浏览器取消不保证终止已经执行的数据库查询。
## 保留的统计口径
余额取流水的 `balance`,不是当前 `wallet_manage.balance`。
只统计未删除流水;沿用原报表对已删除钱包的处理,不新增 `m.del_flag` 条件。
部门包含/排除使用当前钱包的 `dept_id`,不是流水发生时的部门。
同一用户同类型多个钱包仍只取其中最新一笔;同一钱包历史换绑用户时分别保留流水所属用户。
无时间的流水不计入,下一月零点的流水不计入,时间相同按 ID 最大值取余额。
三种返回字段、金额单位分、租户拦截器和原有数据权限行为均保持一致。
## 发布与核验
1. 在钱包库执行 `db/20261023_wallet_qb_total_index.sql`,再发布 Wallet 服务及管理端。
脚本检测等价的可见升序 BTREE 索引,重复执行跳过;同名异构索引报错,需人工核对。
在线建索引仍会消耗 IO 和磁盘空间,大表安排低峰执行。
2. 使用实际租户、月份、类型、用户和部门参数对 Mapper 最终 SQL 执行 `EXPLAIN ANALYZE`。
分组节点应出现 `Using index for group-by` / `Covering index skip scan for grouping`。
窗口节点的输入应是钱包及历史用户候选数,而非全部历史流水数。
3. 用同一筛选比较发布前后三类余额和总余额,并记录 HTTP 耗时和数据库耗时。
本地小数据或合成测试的耗时不能替代线上 24 秒请求的实际复测。
## 测试
服务测试 `WalletQbTotalServiceTest` 覆盖类型交集、单次聚合、金额累计、月份边界和非法参数。
真实数据库测试 `WalletQbTotalMySqlTest` 通过环境变量显式启用,
只读 `pjcloudx_wallet` 源表,在唯一的 `codex_qb_*` 数据库复制结构和样本,并在结束后删除隔离库。
凭据只通过环境变量注入,不保存在代码中。
```sh
MAVEN_OPTS='-Dfile.encoding=UTF-8' mvn -o -pl pjcloud-wallet/pjcloud-wallet-biz -am \
-Dtest=WalletQbTotalServiceTest,WalletQbTotalMySqlTest \
-Dsurefire.failIfNoSpecifiedTests=false -DfailIfNoTests=false \
'-DargLine=-Xmx512m -Dfile.encoding=UTF-8' test
```
真实数据库测试需要设置 `WALLET_QB_TEST_JDBC_URL`、`MYSQL_USER`、`MYSQL_PWD`。
追加 `-Dwallet.qb.stress.rows=1000000` 运行百万流水的旧 SQL 与新 SQL 对照。
## 2026-10-23 本地实测
MySQL 8.0.46,隔离库复制线下 1,660 个钱包、1,134 条流水,
另加 7 个钱包及 18 条边界流水;16 组筛选与旧 SQL 对照一致。
历史换绑、同用户多钱包、同秒流水、月末边界、删除标记、孤儿流水、租户 1/2/空/跳过过滤均通过。
索引脚本连续执行两次通过。
性能样本额外生成 10,000 个钱包及 1,000,000 条流水,每钱包 100 条历史,
包括历史所属用户变化、跨租户流水和删除流水。查询月份为 2026-09;
739,884 条满足租户/删除标记/月界条件的流水先缩为 15,064 个钱包/用户候选。
最终执行计划只对 15,006 个匹配钱包的候选余额做窗口排序,且分组节点使用
`Covering index skip scan for grouping on h using idx_wallet_qb_latest`。
同一连接交替执行旧查询和新查询三轮,每轮核对各类型金额:
| 场景 | 旧查询,三类型串行合计 | 新查询,单次聚合 |
| --- | --- | --- |
| 全部 | 4,772 / 4,992 / 4,911 ms | 311 / 448 / 342 ms |
| 部门筛选 | 221 / 180 / 177 ms | 119 / 110 / 107 ms |
| 单用户筛选 | 9.7 / 8.3 / 5.1 ms | 4.4 / 2.9 / 2.3 ms |
全量统计中位耗时约改善 14 倍。该结果是本地 SQL/Mapper 测量,
未包含线上 HTTP、部署环境或生产数据分布,不能视作线上接口的最终耗时。
后端 33 项测试全部通过,包含本次 12 项及原钱包导出 21 项回归。
管理端新增 5 项统计测试及相关导出回归共 9 项通过,涉及文件 ESLint 检查通过。
所有测试隔离库已删除;线下业务库未修改。
详细耗时和最终 SQL 的执行计划由测试生成于模块的
`target/wallet-qb-benchmark.txt`、`target/wallet-qb-explain.txt`,重复测试可重新生成。
@@ -21,6 +21,7 @@ import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.time.LocalDateTime;
@Mapper
public interface WalletManageMapper extends PjcloudBaseMapper<WalletManageEntity> {
@@ -185,5 +186,6 @@ public interface WalletManageMapper extends PjcloudBaseMapper<WalletManageEntity
List<WalletManageVo> activeBalanceList(@Param("dto") WalletTopupActionDto dto, @Param("unactiveDayNumberStr") String unactiveDayNumberStr);
Long selectTotalBalance(@Param("dto") WalletManageBalanceDto dto);
List<WalletQbTypeTotal> selectQbTotalBalances(@Param("dto") WalletManageBalanceDto dto,
@Param("monthEnd") LocalDateTime monthEnd);
}
@@ -0,0 +1,10 @@
package com.bsd.pjcloud.wallet.mapper;
import lombok.Data;
/** 数据库按钱包类型汇总的历史余额,金额使用 Long 避免累计溢出。 */
@Data
public class WalletQbTypeTotal {
private Long walletTypeId;
private Long total;
}
@@ -41,6 +41,7 @@ import com.bsd.pjcloud.wallet.api.vo.inactive.WalletInactivePageVo;
import com.bsd.pjcloud.wallet.mapper.WalletManageMapper;
import com.bsd.pjcloud.wallet.mapper.WalletTransactionMapper;
import com.bsd.pjcloud.wallet.mapper.WalletTypeMapper;
import com.bsd.pjcloud.wallet.mapper.WalletQbTypeTotal;
import com.bsd.pjcloud.wallet.service.*;
import com.bsd.pjcloud.wallet.service.export.WalletManageBatchExportService;
import com.bsd.pjcloud.wallet.service.inactive.WalletInactiveService;
@@ -56,6 +57,9 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import java.util.*;
import java.time.LocalDateTime;
import java.time.YearMonth;
import java.time.DateTimeException;
/**
* 钱包管理
@@ -390,6 +394,19 @@ public class WalletManageServiceImpl extends ServiceImpl<WalletManageMapper, Wal
@Override
public WalletQbTotalVo qbTotal(WalletManageBalanceDto dto, DataScope dataScope) {
LocalDateTime monthEnd;
try {
if (dto.getWalletRecordMonth() == null || !dto.getWalletRecordMonth().matches("[0-9]{4}-[0-9]{2}")) {
throw new IllegalArgumentException("统计月份须为 yyyy-MM 格式");
}
YearMonth month = YearMonth.parse(dto.getWalletRecordMonth());
if (month.getYear() < 1000 || month.getYear() >= 9999) {
throw new IllegalArgumentException("统计月份超出支持范围");
}
monthEnd = month.plusMonths(1).atDay(1).atStartOfDay();
} catch (DateTimeException e) {
throw new IllegalArgumentException("统计月份须为有效的 yyyy-MM 月份", e);
}
WalletQbTotalVo result = new WalletQbTotalVo();
// 查询钱包类表
WalletTypeDto walletTypeDto = new WalletTypeDto();
@@ -398,13 +415,16 @@ public class WalletManageServiceImpl extends ServiceImpl<WalletManageMapper, Wal
if (ObjectUtil.isNotEmpty(dto.getWalletTypeId())) {
walletTypeList = walletTypeList.stream().filter(item -> ObjectUtil.equals(item.getId(), dto.getWalletTypeId())).toList();
}
// 查询所有钱包类型的流水记录
if (walletTypeList.isEmpty()) return result;
// 一次聚合全部选中类型,避免每种钱包重复扫描、排序整张历史流水表。
WalletManageBalanceDto query = BeanUtils.toBean(dto, WalletManageBalanceDto.class);
query.setWalletTypeIdList(walletTypeList.stream().map(WalletTypeEntity::getId).toList());
Map<Long, Long> totals = new HashMap<>();
for (WalletQbTypeTotal total : baseMapper.selectQbTotalBalances(query, monthEnd)) {
totals.put(total.getWalletTypeId(), total.getTotal() == null ? 0L : total.getTotal());
}
for (WalletTypeEntity i : walletTypeList) {
WalletManageBalanceDto walletBalanceDto = BeanUtils.toBean(dto, WalletManageBalanceDto.class);
walletBalanceDto.setWalletTypeId(i.getId());
Long total = baseMapper.selectTotalBalance(walletBalanceDto);
if (ObjectUtil.isEmpty(total)) total = 0L;
Long total = totals.getOrDefault(i.getId(), 0L);
if (ObjectUtil.equals(i.getUniqueCode(), WalletTypeEnum.EAT.getCode())) {
result.setEatTotal(total);
@@ -185,129 +185,72 @@
</select>
<!-- FROM (-->
<!-- SELECT DISTINCT a.user_id,-->
<!-- m.wallet_type_id,-->
<!-- FIRST_VALUE(a.balance) OVER (-->
<!-- PARTITION BY a.user_id, m.wallet_type_id-->
<!-- ORDER BY a.create_time DESC-->
<!-- ) as balance-->
<select id="selectTotalBalance" resultType="java.lang.Long">
SELECT SUM(latest_balance.balance)
from (select distinct a.user_id,
m.wallet_type_id,
first_value(a.balance) over (
partition by a.user_id, m.wallet_type_id
order by a.create_time desc, a.id desc
) as balance
FROM wallet_transaction a
INNER JOIN wallet_manage m
ON a.wallet_id = m.id
<where>
a.del_flag = '0'
AND DATE_FORMAT(a.create_time, '%Y-%m') &lt;= #{dto.walletRecordMonth}
<if test="dto.userId != null">
AND a.user_id = #{dto.userId}
</if>
<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}
</foreach>
</if>
<if test="dto.deptIds != null and dto.deptIds.size() != 0">
<choose>
<when test="dto.isInclude != null and dto.isInclude == true">
and m.dept_id IN
</when>
<when test="dto.isInclude != null and dto.isInclude == false">
and m.dept_id NOT IN
</when>
<otherwise>
and m.dept_id IN
</otherwise>
</choose>
<foreach collection="dto.deptIds" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
</where>
) latest_balance
<!-- 先按钱包及流水所属用户跳组取最大时间,避免在全部历史流水上执行窗口排序。
GROUP BY 显式包含 tenant_id/del_flag,匹配索引前缀才能使用 MySQL loose index scan。
同一钱包可能换绑用户;同用户同类型也可能存在多个钱包,最后仍按旧口径去重。 -->
<select id="selectQbTotalBalances" resultType="com.bsd.pjcloud.wallet.mapper.WalletQbTypeTotal">
SELECT ranked.wallet_type_id, SUM(ranked.balance) AS total
FROM (
SELECT m.wallet_type_id, a.balance,
ROW_NUMBER() OVER (
PARTITION BY a.user_id, m.wallet_type_id
ORDER BY a.create_time DESC, a.id DESC
) AS rn
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; #{monthEnd}
<if test="dto.userId != null">
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="v" open="(" close=")" separator=",">
#{v}
</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.walletTypeIdList != null and dto.walletTypeIdList.size() != 0">
AND m.wallet_type_id IN
<foreach collection="dto.walletTypeIdList" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
<if test="dto.deptIds != null and dto.deptIds.size() != 0">
<choose>
<when test="dto.isInclude != null and dto.isInclude == false">
AND m.dept_id NOT IN
</when>
<otherwise>
AND m.dept_id IN
</otherwise>
</choose>
<foreach collection="dto.deptIds" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
</where>
) ranked
WHERE ranked.rn = 1
GROUP BY ranked.wallet_type_id
</select>
<!-- SELECT-->
<!-- SUM(a.balance)-->
<!-- 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.deptIds != null and dto.deptIds.size() != 0">-->
<!-- <choose>-->
<!-- <when test="dto.isInclude != null and dto.isInclude == true">-->
<!-- and wallet.dept_id IN-->
<!-- </when>-->
<!-- <when test="dto.isInclude != null and dto.isInclude == false">-->
<!-- and wallet.dept_id NOT IN-->
<!-- </when>-->
<!-- <otherwise>-->
<!-- and wallet.dept_id IN-->
<!-- </otherwise>-->
<!-- </choose>-->
<!-- <foreach collection="dto.deptIds" item="v" open="(" close=")" separator=",">-->
<!-- #{v}-->
<!-- </foreach>-->
<!-- </if>-->
<!-- <if test="dto.walletTypeId != null">-->
<!-- AND wallet.wallet_type_id = #{dto.walletTypeId}-->
<!-- </if>-->
<!-- )-->
<!-- </if>-->
<!-- <if test="dto.userId != null">-->
<!-- AND a.user_id = #{dto.userId}-->
<!-- </if>-->
<!-- <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}-->
<!-- </foreach>-->
<!-- </if>-->
<!-- <if test="dto.deptIds != null and dto.deptIds.size() != 0">-->
<!-- <choose>-->
<!-- <when test="dto.isInclude != null and dto.isInclude == true">-->
<!-- and m.dept_id IN-->
<!-- </when>-->
<!-- <when test="dto.isInclude != null and dto.isInclude == false">-->
<!-- and m.dept_id NOT IN-->
<!-- </when>-->
<!-- <otherwise>-->
<!-- and m.dept_id IN-->
<!-- </otherwise>-->
<!-- </choose>-->
<!-- <foreach collection="dto.deptIds" item="v" open="(" close=")" separator=",">-->
<!-- #{v}-->
<!-- </foreach>-->
<!-- </if>-->
<!-- </where>-->
</mapper>
@@ -0,0 +1,433 @@
package com.bsd.pjcloud.wallet.total;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
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.mapper.WalletManageMapper;
import com.bsd.pjcloud.wallet.mapper.WalletQbTypeTotal;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.builder.xml.XMLMapperBuilder;
import org.apache.ibatis.datasource.pooled.PooledDataSource;
import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.plugin.Interceptor;
import org.apache.ibatis.plugin.Intercepts;
import org.apache.ibatis.plugin.Invocation;
import org.apache.ibatis.plugin.Signature;
import org.apache.ibatis.reflection.SystemMetaObject;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.session.SqlSessionFactory;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import org.junit.jupiter.api.TestMethodOrder;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.Statement;
import java.time.YearMonth;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.TreeMap;
import java.util.UUID;
import java.util.function.Consumer;
import static org.junit.jupiter.api.Assertions.*;
/**
* Real MySQL regression and opt-in million-row benchmark. Only localhost is accepted; source
* business tables are read-only. DDL, fixtures and generated rows live in a disposable codex_qb_* schema.
* WALLET_QB_TEST_JDBC_URL=jdbc:mysql://127.0.0.1:33905/pjcloudx_wallet and MYSQL_USER/MYSQL_PWD enable it.
* Add -Dwallet.qb.stress.rows=1000000 for the generated-data benchmark.
*/
@EnabledIfEnvironmentVariable(named = "WALLET_QB_TEST_JDBC_URL", matches = ".+")
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class WalletQbTotalMySqlTest {
private static final List<String> TABLES = List.of("wallet_type", "wallet_manage", "wallet_transaction");
private static final long EDGE = 8_700_000_000_000_000_000L;
private static final long TYPE = EDGE + 100;
private static final long USER = EDGE + 1000;
private static final long PERF = 6_000_000_000_000_000_000L;
private static final long PERF_USER = PERF + 10_000_000;
private String schema;
private Connection admin;
private PooledDataSource dataSource;
private SqlSessionFactory factory;
private final SqlRecorder recorder = new SqlRecorder();
public interface LegacyMapper {
Long selectTotalBalance(@Param("dto") WalletManageBalanceDto dto);
}
@BeforeAll
void createIsolatedDatabaseAndApplyProductionIndexTwice() throws Exception {
String url = System.getenv("WALLET_QB_TEST_JDBC_URL");
assertTrue(url.matches("jdbc:mysql://(127\\.0\\.0\\.1|localhost):[0-9]+/.*"), "Only localhost MySQL is allowed");
admin = DriverManager.getConnection(url, System.getenv("MYSQL_USER"), System.getenv("MYSQL_PWD"));
schema = "codex_qb_" + UUID.randomUUID().toString().replace("-", "");
execute("CREATE DATABASE " + schema + " CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci");
execute("USE " + schema);
for (String table : TABLES) {
execute("CREATE TABLE " + table + " LIKE pjcloudx_wallet." + table);
execute("INSERT INTO " + table + " SELECT * FROM pjcloudx_wallet." + table);
}
seedEdges();
String ddl = Files.readString(repositoryFile("db/20261023_wallet_qb_total_index.sql"))
.replace("pjcloudx_wallet", schema).replaceAll("(?m)^--.*$", "");
for (int iteration = 0; iteration < 2; iteration++) {
for (String sql : ddl.split(";")) if (!sql.isBlank()) execute(sql);
}
execute("ANALYZE TABLE wallet_manage,wallet_transaction,wallet_type");
int slash = url.indexOf('/', "jdbc:mysql://".length());
int question = url.indexOf('?', slash);
String testUrl = url.substring(0, slash) + "/" + schema + (question < 0 ? "" : url.substring(question));
dataSource = new PooledDataSource("com.mysql.cj.jdbc.Driver", testUrl,
System.getenv("MYSQL_USER"), System.getenv("MYSQL_PWD"));
MybatisConfiguration configuration = new MybatisConfiguration();
configuration.setMapUnderscoreToCamelCase(true);
configuration.setEnvironment(new Environment("wallet-qb-test", new JdbcTransactionFactory(), dataSource));
configuration.addInterceptor(recorder);
PjcloudTenantConfigProperties tenants = new PjcloudTenantConfigProperties();
tenants.setTables(TABLES);
MybatisPlusInterceptor plugins = new MybatisPlusInterceptor();
plugins.addInnerInterceptor(new TenantLineInnerInterceptor(new PjcloudTenantHandler(tenants)));
configuration.addInterceptor(plugins);
for (String resource : List.of("mapper/WalletManageMapper.xml", "mapper/total/WalletQbLegacyMapper.xml")) {
try (InputStream input = getClass().getClassLoader().getResourceAsStream(resource)) {
assertNotNull(input, resource);
new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
}
}
factory = new MybatisSqlSessionFactoryBuilder().build(configuration);
System.out.printf("WALLET_QB_FIXTURE MySQL=%s schema=%s wallets=%d transactions=%d edge_wallets=7 edge_transactions=18%n",
admin.getMetaData().getDatabaseProductVersion(), schema,
scalar("SELECT COUNT(*) FROM wallet_manage"), scalar("SELECT COUNT(*) FROM wallet_transaction"));
}
@AfterAll
void dropOnlyTheDisposableSchema() throws Exception {
TenantContextHolder.clear();
if (dataSource != null) dataSource.forceCloseAll();
if (admin != null) {
try {
if (schema != null && schema.matches("codex_qb_[a-f0-9]{32}")) execute("DROP DATABASE " + schema);
} finally { admin.close(); }
}
}
@Test @Order(1)
void copiedLocalDataMatchesOldTotalsForEveryFilter() throws Exception {
TenantContextHolder.setTenantId(1L);
try (SqlSession session = factory.openSession(true)) {
List<WalletManageBalanceDto> filters = new ArrayList<>();
for (String month : List.of("2025-01", "2026-09", "2026-10", "1800-01")) filters.add(dto(month));
filters.add(with(dto("2026-09"), d -> d.setWalletTypeIdList(List.of(TYPE, TYPE + 1))));
filters.add(with(dto("2026-09"), d -> d.setWalletTypeIdList(List.of())));
filters.add(with(dto("2026-09"), d -> d.setWalletTypeId(TYPE)));
filters.add(with(dto("2026-09"), d -> { d.setWalletTypeId(TYPE); d.setWalletTypeIdList(List.of(TYPE + 1)); }));
filters.add(with(dto("2026-09"), d -> d.setUserId(USER)));
filters.add(with(dto("2026-09"), d -> d.setUserIds(List.of(USER + 1, USER + 2))));
filters.add(with(dto("2026-09"), d -> { d.setUserId(USER); d.setUserIds(List.of(USER + 1)); }));
filters.add(with(dto("2026-09"), d -> d.setUserIds(List.of())));
for (Boolean include : Arrays.asList(Boolean.TRUE, Boolean.FALSE, null)) {
filters.add(with(dto("2026-09"), d -> { d.setDeptIds(List.of(10L)); d.setIsInclude(include); }));
}
filters.add(with(dto("2026-09"), d -> d.setDeptIds(List.of())));
for (WalletManageBalanceDto dto : filters) assertMatchesLegacy(session, dto);
System.out.printf("WALLET_QB_REGRESSION copied_data_filter_scenarios=%d%n", filters.size());
} finally { TenantContextHolder.clear(); }
}
@Test @Order(2)
void historicalOwnersMultipleWalletsSameSecondAndMonthEndKeepExactSemantics() throws Exception {
TenantContextHolder.setTenantId(1L);
try (SqlSession session = factory.openSession(true)) {
WalletManageBalanceDto dto = with(dto("2026-09"), d -> d.setWalletTypeIdList(List.of(TYPE, TYPE + 1)));
// Type A: latest user balance 1350 + two historical owners of the same wallet 700/800
// + 300 from a deleted wallet (the legacy query does not filter wallet_manage.del_flag).
assertEquals(Map.of(TYPE, 3150L, TYPE + 1, 400L), optimized(session, dto));
dto.setUserId(USER);
assertEquals(Map.of(TYPE, 1350L, TYPE + 1, 400L), optimized(session, dto));
dto.setDeptIds(List.of(20L));
assertEquals(Map.of(TYPE, 1200L), optimized(session, dto));
dto.setDeptIds(List.of(10L)); dto.setIsInclude(false);
assertEquals(Map.of(TYPE, 1200L), optimized(session, dto));
dto.setDeptIds(null); dto.setWalletRecordMonth("2026-10");
assertEquals(Map.of(TYPE, 1400L, TYPE + 1, 400L), optimized(session, dto));
dto.setWalletRecordMonth("1800-01");
assertEquals(Map.of(), optimized(session, dto));
} finally { TenantContextHolder.clear(); }
}
@Test @Order(3)
void tenantPluginAppliesToAllLedgerAliasesAndCanStillSkipTenantFiltering() throws Exception {
try (SqlSession session = factory.openSession(true)) {
for (Long tenant : Arrays.asList(1L, 2L, null)) {
TenantContextHolder.clear();
if (tenant != null) TenantContextHolder.setTenantId(tenant);
WalletManageBalanceDto dto = with(dto("2026-09"), d -> d.setWalletTypeIdList(List.of(TYPE, TYPE + 1)));
assertMatchesLegacy(session, dto);
if (Long.valueOf(2).equals(tenant)) assertEquals(Map.of(TYPE, 2200L), optimized(session, dto));
}
TenantContextHolder.setTenantId(1L);
TenantContextHolder.setTenantSkip();
assertMatchesLegacy(session, with(dto("2026-09"), d -> d.setWalletTypeIdList(List.of(TYPE, TYPE + 1))));
} finally { TenantContextHolder.clear(); }
}
@Test @Order(4)
void millionGeneratedTransactionsMatchOldPerTypeCallsAndUseLooseIndexScan() throws Exception {
int rows = Integer.getInteger("wallet.qb.stress.rows", 0);
Assumptions.assumeTrue(rows > 0, "Enable with -Dwallet.qb.stress.rows=1000000");
assertTrue(rows <= 1_000_000, "Generator supports at most one million rows per run");
generateLargeData(rows);
execute("ANALYZE TABLE wallet_transaction,wallet_manage");
TenantContextHolder.setTenantId(1L);
Map<String, WalletManageBalanceDto> scenarios = new LinkedHashMap<>();
scenarios.put("all", with(dto("2026-09"), d -> d.setWalletTypeIdList(List.of(TYPE, TYPE + 1, TYPE + 2))));
scenarios.put("department", with(dto("2026-09"), d -> {
d.setWalletTypeIdList(List.of(TYPE, TYPE + 1, TYPE + 2)); d.setDeptIds(List.of(10L));
}));
scenarios.put("user", with(dto("2026-09"), d -> {
d.setWalletTypeIdList(List.of(TYPE, TYPE + 1, TYPE + 2)); d.setUserId(PERF_USER);
}));
List<String> reports = new ArrayList<>();
StringBuilder plans = new StringBuilder();
String grouping = "SELECT tenant_id,del_flag,wallet_id,user_id,MAX(create_time) "
+ "FROM wallet_transaction WHERE tenant_id=1 AND del_flag='0' AND create_time<'2026-10-01' "
+ "GROUP BY tenant_id,del_flag,wallet_id,user_id";
String dataReport = "MySQL=" + admin.getMetaData().getDatabaseProductVersion() + ", generated=" + rows
+ ", generated_wallets=" + ((rows + 99) / 100) + ", history_per_generated_wallet=100"
+ ", transactions=" + scalar("SELECT COUNT(*) FROM wallet_transaction")
+ ", eligible_transactions=" + scalar("SELECT COUNT(*) FROM wallet_transaction WHERE tenant_id=1 AND del_flag='0' AND create_time<'2026-10-01'")
+ ", wallet_user_candidates=" + scalar("SELECT COUNT(*) FROM (" + grouping + ") candidates");
reports.add(dataReport); System.out.println("WALLET_QB_DATA " + dataReport);
try (SqlSession session = factory.openSession(true)) {
for (Map.Entry<String, WalletManageBalanceDto> scenario : scenarios.entrySet()) {
WalletManageBalanceDto dto = scenario.getValue();
List<Double> oldMs = new ArrayList<>();
List<Double> newMs = new ArrayList<>();
Map<Long, Long> actual = Map.of();
for (int run = 0; run < 3; run++) {
session.clearCache();
long oldStart = System.nanoTime();
Map<Long, Long> expected = legacy(session, dto);
oldMs.add((System.nanoTime() - oldStart) / 1e6);
recorder.captures.clear();
session.clearCache();
long newStart = System.nanoTime();
actual = optimized(session, dto);
newMs.add((System.nanoTime() - newStart) / 1e6);
assertEquals(expected, actual, scenario.getKey() + " run " + run);
assertEquals(1, recorder.captures.size(), "All selected wallet types must use one SQL statement");
if (rows == 1_000_000 && scenario.getKey().equals("all")) {
assertTrue(actual.values().stream().mapToLong(Long::longValue).sum() > Integer.MAX_VALUE);
}
}
String report = String.format(Locale.ROOT,
"%s old_per_type_total_ms=%s old_sql=%d new_once_ms=%s new_sql=1 totals=%s",
scenario.getKey(), oldMs, selectedTypes(dto).size(), newMs, actual);
reports.add(report); System.out.println("WALLET_QB_BENCH " + report);
plans.append(scenario.getKey()).append('\n').append(explain(recorder.captures.get(0))).append('\n');
}
boolean loose = false;
try (Statement statement = admin.createStatement(); ResultSet result = statement.executeQuery("EXPLAIN " + grouping)) {
while (result.next()) {
String extra = result.getString("Extra");
loose |= extra != null && extra.contains("Using index for group-by");
plans.append("grouping: ").append(result.getString("key")).append(' ').append(extra).append('\n');
}
}
assertTrue(loose, "Expected loose index grouping; inspect target/wallet-qb-explain.txt");
Files.createDirectories(Path.of("target"));
Files.write(Path.of("target/wallet-qb-benchmark.txt"), reports);
Files.writeString(Path.of("target/wallet-qb-explain.txt"), plans);
System.out.println("WALLET_QB_PLAN loose_index_scan=true; full plans in target/wallet-qb-explain.txt");
} finally { TenantContextHolder.clear(); }
}
private Map<Long, Long> optimized(SqlSession session, WalletManageBalanceDto dto) {
session.clearCache();
return toMap(session.getMapper(WalletManageMapper.class).selectQbTotalBalances(dto,
YearMonth.parse(dto.getWalletRecordMonth()).plusMonths(1).atDay(1).atStartOfDay()));
}
private Map<Long, Long> legacy(SqlSession session, WalletManageBalanceDto dto) throws Exception {
Map<Long, Long> totals = new TreeMap<>();
for (Long type : selectedTypes(dto)) {
WalletManageBalanceDto query = copy(dto);
query.setWalletTypeId(type);
Long total = session.getMapper(LegacyMapper.class).selectTotalBalance(query);
if (total != null) totals.put(type, total);
}
return totals;
}
private List<Long> selectedTypes(WalletManageBalanceDto dto) throws Exception {
List<Long> types = new ArrayList<>();
// A standalone old query ran once for each requested wallet type.
try (Statement statement = admin.createStatement(); ResultSet result = statement.executeQuery(
"SELECT DISTINCT wallet_type_id FROM wallet_manage ORDER BY wallet_type_id")) {
while (result.next()) {
long type = result.getLong(1);
if (dto.getWalletTypeId() != null && dto.getWalletTypeId() != type) continue;
if (dto.getWalletTypeIdList() != null && !dto.getWalletTypeIdList().isEmpty()
&& !dto.getWalletTypeIdList().contains(type)) continue;
types.add(type);
}
}
return types;
}
private void assertMatchesLegacy(SqlSession session, WalletManageBalanceDto dto) throws Exception {
assertEquals(legacy(session, dto), optimized(session, dto), dto.toString());
}
private static Map<Long, Long> toMap(List<WalletQbTypeTotal> values) {
Map<Long, Long> result = new TreeMap<>();
for (WalletQbTypeTotal value : values) assertNull(result.put(value.getWalletTypeId(), value.getTotal()));
return result;
}
private void seedEdges() throws Exception {
execute("INSERT INTO wallet_type(id,name,icon,unique_code,tenant_id) VALUES (" + TYPE
+ ",'qb-A','','qb-a',1),(" + (TYPE + 1) + ",'qb-B','','qb-b',1),(" + (TYPE + 2) + ",'qb-C','','qb-c',1)");
wallet(1, USER, TYPE, 10, "0", "1");
wallet(2, USER, TYPE, 20, "0", "1");
wallet(3, USER, TYPE + 1, 10, "0", "1");
wallet(4, USER + 1, TYPE, 10, "0", "1");
wallet(5, USER + 3, TYPE, 10, "1", "1");
wallet(6, USER, TYPE, 10, "0", "2");
wallet(7, USER + 6, TYPE, 10, "0", "NULL");
ledger(1, 1, USER, 1000, "2026-08-31 23:59:59", "0", "1");
ledger(2, 1, USER, 1100, "2026-09-15 00:00:00", "0", "1");
ledger(3, 2, USER, 1200, "2026-09-15 00:00:00", "0", "1");
ledger(4, 1, USER, 1300, "2026-09-30 23:59:59", "0", "1");
ledger(5, 2, USER, 1400, "2026-10-01 00:00:00", "0", "1");
ledger(6, 1, USER, 99999, "2026-09-30 23:59:59", "1", "1");
ledger(7, 1, USER, 77777, "2026-09-30 23:59:59", "0", "2");
ledger(8, 3, USER, 400, "2026-09-20 00:00:00", "0", "1");
ledger(9, 4, USER + 2, 700, "2026-09-05 00:00:00", "0", "1");
ledger(10, 4, USER + 1, 800, "2026-09-07 00:00:00", "0", "1");
ledger(11, 5, USER + 3, 300, "2026-09-10 00:00:00", "0", "1");
ledger(12, 999, USER + 4, 999, "2026-09-10 00:00:00", "0", "1");
ledger(13, 6, USER, 888, "2026-09-10 00:00:00", "0", "1");
ledger(14, 6, USER, 2200, "2026-09-20 00:00:00", "0", "2");
ledger(15, 1, USER, 99999, null, "0", "1");
ledger(20, 1, USER, 1350, "2026-09-30 23:59:59", "0", "1");
ledger(50, 1, USER, 99999, "2026-09-01 00:00:00", "0", "1");
ledger(60, 7, USER + 6, 500, "2026-09-20 00:00:00", "0", "NULL");
}
private void wallet(long id, long user, long type, long dept, String deleted, String tenant) throws Exception {
execute("INSERT INTO wallet_manage(id,user_id,wallet_type_id,dept_id,del_flag,tenant_id) VALUES ("
+ (EDGE + id) + "," + user + "," + type + "," + dept + ",'" + deleted + "'," + tenant + ")");
}
private void ledger(long id, long wallet, long user, int balance, String time, String deleted, String tenant) throws Exception {
execute("INSERT INTO wallet_transaction(id,wallet_id,user_id,dept_id,biz_type,biz_id,title,price,balance,create_time,del_flag,tenant_id) VALUES ("
+ (EDGE + 10000 + id) + "," + (EDGE + wallet) + "," + user + ",999,'1','fixture','qb fixture',100,"
+ balance + "," + (time == null ? "NULL" : "'" + time + "'") + ",'" + deleted + "'," + tenant + ")");
}
private void generateLargeData(int rows) throws Exception {
long start = System.nanoTime();
String digits = "(SELECT 0 n UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 "
+ "UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9)";
execute("CREATE TABLE perf_sequence(n INT PRIMARY KEY)");
execute("INSERT INTO perf_sequence SELECT a.n+10*b.n+100*c.n+1000*d.n+10000*e.n+100000*f.n FROM "
+ digits + " a CROSS JOIN " + digits + " b CROSS JOIN " + digits + " c CROSS JOIN " + digits + " d CROSS JOIN "
+ digits + " e CROSS JOIN " + digits + " f WHERE a.n+10*b.n+100*c.n+1000*d.n+10000*e.n+100000*f.n < " + rows);
execute("INSERT INTO wallet_manage(id,user_id,wallet_type_id,dept_id,del_flag,tenant_id) SELECT "
+ PERF + "+n," + PERF_USER + "+FLOOR(n/2)," + TYPE + "+MOD(FLOOR(n/2),3),10+MOD(n,10),"
+ "IF(MOD(n,23)=0,'1','0'),1 FROM perf_sequence WHERE n<" + ((rows + 99) / 100));
execute("INSERT INTO wallet_transaction(id,wallet_id,user_id,dept_id,biz_type,biz_id,title,price,balance,create_time,del_flag,tenant_id) SELECT "
+ (PERF + 100_000_000) + "+n," + PERF + "+FLOOR(n/100)," + PERF_USER + "+FLOOR(n/200)+IF(MOD(n,100)>=90,10000000,0),"
+ "999,'1','perf','qb perf',100,1000000+MOD(n,100000),"
+ "TIMESTAMPADD(SECOND,MOD(n,10),TIMESTAMPADD(DAY,MOD(FLOOR(n/10),40),'2026-09-01')),"
+ "IF(MOD(n,97)=0,'1','0'),IF(MOD(n,211)=0,2,1) FROM perf_sequence");
execute("DROP TABLE perf_sequence");
System.out.printf(Locale.ROOT, "WALLET_QB_GENERATE rows=%d seconds=%.3f%n", rows, (System.nanoTime() - start) / 1e9);
}
private String explain(SqlCapture capture) throws Exception {
StringBuilder plan = new StringBuilder();
try (PreparedStatement statement = admin.prepareStatement("EXPLAIN ANALYZE " + capture.sql)) {
for (int i = 0; i < capture.parameters.size(); i++) statement.setObject(i + 1, capture.parameters.get(i));
try (ResultSet result = statement.executeQuery()) {
while (result.next()) plan.append(result.getString(1)).append('\n');
}
}
return plan.toString();
}
private void execute(String sql) throws Exception {
try (Statement statement = admin.createStatement()) { statement.execute(sql); }
}
private long scalar(String sql) throws Exception {
try (Statement statement = admin.createStatement(); ResultSet result = statement.executeQuery(sql)) {
assertTrue(result.next()); return result.getLong(1);
}
}
private static Path repositoryFile(String relative) {
Path candidate = Path.of("").toAbsolutePath();
while (candidate != null && !Files.exists(candidate.resolve(relative))) candidate = candidate.getParent();
assertNotNull(candidate, relative); return candidate.resolve(relative);
}
private static WalletManageBalanceDto dto(String month) {
WalletManageBalanceDto dto = new WalletManageBalanceDto(); dto.setWalletRecordMonth(month); return dto;
}
private static WalletManageBalanceDto with(WalletManageBalanceDto source, Consumer<WalletManageBalanceDto> change) {
WalletManageBalanceDto dto = copy(source); change.accept(dto); return dto;
}
private static WalletManageBalanceDto copy(WalletManageBalanceDto source) {
WalletManageBalanceDto dto = dto(source.getWalletRecordMonth());
dto.setUserId(source.getUserId()); dto.setUserIds(source.getUserIds());
dto.setWalletTypeId(source.getWalletTypeId()); dto.setWalletTypeIdList(source.getWalletTypeIdList());
dto.setDeptIds(source.getDeptIds()); dto.setIsInclude(source.getIsInclude()); return dto;
}
private record SqlCapture(String sql, List<Object> parameters) { }
@Intercepts(@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class}))
public static class SqlRecorder implements Interceptor {
final List<SqlCapture> captures = new ArrayList<>();
public Object intercept(Invocation invocation) throws Throwable {
BoundSql bound = ((StatementHandler) invocation.getTarget()).getBoundSql();
List<Object> parameters = bound.getParameterMappings().stream().map(mapping -> {
String property = mapping.getProperty();
return bound.hasAdditionalParameter(property) ? bound.getAdditionalParameter(property)
: SystemMetaObject.forObject(bound.getParameterObject()).getValue(property);
}).toList();
captures.add(new SqlCapture(bound.getSql(), parameters));
return invocation.proceed();
}
}
}
@@ -0,0 +1,196 @@
package com.bsd.pjcloud.wallet.total;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.wallet.api.dto.WalletManageBalanceDto;
import com.bsd.pjcloud.wallet.api.dto.WalletTypeDto;
import com.bsd.pjcloud.wallet.api.entity.WalletTypeEntity;
import com.bsd.pjcloud.wallet.api.vo.WalletQbTotalVo;
import com.bsd.pjcloud.wallet.mapper.WalletManageMapper;
import com.bsd.pjcloud.wallet.mapper.WalletQbTypeTotal;
import com.bsd.pjcloud.wallet.mapper.WalletTypeMapper;
import com.bsd.pjcloud.wallet.service.impl.WalletManageServiceImpl;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
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.Objects;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/** 验证对外统计口径、筛选快照和单次聚合调用;SQL 正确性由真实数据库测试覆盖。 */
class WalletQbTotalServiceTest {
private final WalletManageMapper mapper = mock(WalletManageMapper.class);
private final WalletTypeMapper types = mock(WalletTypeMapper.class);
private WalletManageServiceImpl service;
private List<WalletTypeEntity> availableTypes;
@BeforeEach
void setup() throws Exception {
Constructor<?> constructor = WalletManageServiceImpl.class.getConstructors()[0];
Object[] dependencies = Arrays.stream(constructor.getParameterTypes())
.map(type -> type == WalletTypeMapper.class ? types : mock(type)).toArray();
service = (WalletManageServiceImpl) constructor.newInstance(dependencies);
ReflectionTestUtils.setField(service, "baseMapper", mapper);
availableTypes = List.of(type(11L, "1"), type(22L, "2"), type(33L, "3"));
when(types.list(any())).thenAnswer(call -> {
WalletTypeDto dto = call.getArgument(0);
List<Long> ids = dto.getWalletTypeIds();
return availableTypes.stream().filter(type -> ids == null || ids.isEmpty() || ids.contains(type.getId())).toList();
});
}
@Test
void allTypesUseOneAggregateCallAndKeepLongSignedAmounts() {
when(mapper.selectQbTotalBalances(any(), any())).thenReturn(List.of(
balance(11L, 4_294_967_294L), balance(22L, -50L), balance(33L, 100L)));
WalletQbTotalVo result = service.qbTotal(query("2026-10"), DataScope.of());
assertEquals(4_294_967_294L, result.getEatTotal());
assertEquals(-50L, result.getPayTotal());
assertEquals(100L, result.getTopupTotal());
assertEquals(4_294_967_344L, result.getTotal());
ArgumentCaptor<WalletManageBalanceDto> captured = ArgumentCaptor.forClass(WalletManageBalanceDto.class);
verify(mapper).selectQbTotalBalances(captured.capture(), eq(LocalDateTime.of(2026, 11, 1, 0, 0)));
assertEquals(List.of(11L, 22L, 33L), captured.getValue().getWalletTypeIdList());
verifyNoMoreInteractions(mapper);
}
@Test
void scalarTypeAndTypeListIntersectWithoutChangingOriginalFilters() {
WalletManageBalanceDto dto = query("2026-09");
List<Long> walletTypes = new ArrayList<>(List.of(11L, 22L));
List<Long> userIds = new ArrayList<>(List.of(71L, 72L));
List<Long> deptIds = new ArrayList<>(List.of(81L, 82L));
dto.setWalletTypeId(22L);
dto.setWalletTypeIdList(walletTypes);
dto.setUserId(71L);
dto.setUserIds(userIds);
dto.setDeptIds(deptIds);
dto.setIsInclude(false);
when(mapper.selectQbTotalBalances(any(), any())).thenReturn(List.of(balance(22L, 123L)));
WalletQbTotalVo result = service.qbTotal(dto, DataScope.of());
assertEquals(0L, result.getEatTotal());
assertEquals(123L, result.getPayTotal());
assertEquals(0L, result.getTopupTotal());
ArgumentCaptor<WalletManageBalanceDto> captured = ArgumentCaptor.forClass(WalletManageBalanceDto.class);
verify(mapper).selectQbTotalBalances(captured.capture(), eq(LocalDateTime.of(2026, 10, 1, 0, 0)));
WalletManageBalanceDto snapshot = captured.getValue();
assertNotSame(dto, snapshot);
assertEquals(List.of(22L), snapshot.getWalletTypeIdList());
assertEquals(71L, snapshot.getUserId());
assertEquals(userIds, snapshot.getUserIds());
assertEquals(deptIds, snapshot.getDeptIds());
assertEquals(false, snapshot.getIsInclude());
assertEquals("2026-09", dto.getWalletRecordMonth());
assertEquals(22L, dto.getWalletTypeId());
assertSame(walletTypes, dto.getWalletTypeIdList());
assertEquals(List.of(11L, 22L), dto.getWalletTypeIdList());
assertSame(userIds, dto.getUserIds());
assertSame(deptIds, dto.getDeptIds());
}
@Test
void noTypesOrAnEmptyScalarListIntersectionReturnsZeroWithoutReadingTransactions() {
WalletManageBalanceDto dto = query("2026-10");
dto.setWalletTypeIdList(List.of(11L));
dto.setWalletTypeId(22L);
assertZero(service.qbTotal(dto, DataScope.of()));
verifyZeroInteractions(mapper);
availableTypes = List.of();
assertZero(service.qbTotal(query("2026-10"), DataScope.of()));
verifyZeroInteractions(mapper);
}
@Test
void emptyTypeListMeansAllAndUnselectedTypesStayZero() {
WalletManageBalanceDto dto = query("2026-10");
dto.setWalletTypeIdList(List.of());
when(mapper.selectQbTotalBalances(any(), any())).thenReturn(List.of(balance(33L, 98L)));
WalletQbTotalVo result = service.qbTotal(dto, DataScope.of());
assertEquals(0L, result.getEatTotal());
assertEquals(0L, result.getPayTotal());
assertEquals(98L, result.getTopupTotal());
assertEquals(98L, result.getTotal());
ArgumentCaptor<WalletManageBalanceDto> captured = ArgumentCaptor.forClass(WalletManageBalanceDto.class);
verify(mapper).selectQbTotalBalances(captured.capture(), any());
assertEquals(List.of(11L, 22L, 33L), captured.getValue().getWalletTypeIdList());
assertEquals(List.of(), dto.getWalletTypeIdList());
}
@Test
void nullBalancesAndAnEmptyAggregateAreReportedAsZero() {
when(mapper.selectQbTotalBalances(any(), any()))
.thenReturn(List.of(balance(11L, null))).thenReturn(List.of());
assertZero(service.qbTotal(query("2026-10"), DataScope.of()));
assertZero(service.qbTotal(query("2026-10"), DataScope.of()));
verify(mapper, times(2)).selectQbTotalBalances(any(), any());
}
@Test
void monthEndUsesExclusiveNextMonthIncludingLeapFebruaryAndYearChange() {
List<String> months = List.of("2024-02", "2025-02", "2026-12");
List<LocalDateTime> ends = List.of(LocalDateTime.of(2024, 3, 1, 0, 0),
LocalDateTime.of(2025, 3, 1, 0, 0), LocalDateTime.of(2027, 1, 1, 0, 0));
when(mapper.selectQbTotalBalances(any(), any())).thenReturn(List.of());
for (String month : months) service.qbTotal(query(month), DataScope.of());
ArgumentCaptor<LocalDateTime> captured = ArgumentCaptor.forClass(LocalDateTime.class);
verify(mapper, times(3)).selectQbTotalBalances(any(), captured.capture());
assertEquals(ends, captured.getAllValues());
}
@Test
void missingAndMalformedMonthsFailBeforeAnyDatabaseQuery() {
for (String month : Arrays.asList(null, "", " ", "2026-1", "2026-00", "2026-13", "2026-10-01", "bad")) {
assertThrows(IllegalArgumentException.class, () -> service.qbTotal(query(month), DataScope.of()),
"应拒绝非法月份: " + Objects.toString(month));
}
verifyZeroInteractions(types, mapper);
}
@Test
void repeatedKnownCodesRetainLegacyLastTypeAssignmentAndCustomCodesAreIgnored() {
availableTypes = List.of(type(11L, "1"), type(12L, "1"), type(44L, "custom"));
when(mapper.selectQbTotalBalances(any(), any())).thenReturn(List.of(
balance(11L, 100L), balance(12L, 200L), balance(44L, 9999L)));
WalletQbTotalVo result = service.qbTotal(query("2026-10"), DataScope.of());
assertEquals(200L, result.getEatTotal());
assertEquals(0L, result.getPayTotal());
assertEquals(0L, result.getTopupTotal());
assertEquals(200L, result.getTotal());
}
private static WalletManageBalanceDto query(String month) {
WalletManageBalanceDto dto = new WalletManageBalanceDto();
dto.setWalletRecordMonth(month);
return dto;
}
private static WalletTypeEntity type(Long id, String code) {
WalletTypeEntity type = new WalletTypeEntity();
type.setId(id);
type.setUniqueCode(code);
return type;
}
private static WalletQbTypeTotal balance(Long typeId, Long total) {
WalletQbTypeTotal row = new WalletQbTypeTotal();
row.setWalletTypeId(typeId);
row.setTotal(total);
return row;
}
private static void assertZero(WalletQbTotalVo result) {
assertEquals(0L, result.getTotal());
assertEquals(0L, result.getEatTotal());
assertEquals(0L, result.getPayTotal());
assertEquals(0L, result.getTopupTotal());
}
}
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.bsd.pjcloud.wallet.total.WalletQbTotalMySqlTest$LegacyMapper">
<!-- 保留优化前的 FIRST_VALUE / DISTINCT 口径,逐类型调用作为回归对照。 -->
<select id="selectTotalBalance" resultType="java.lang.Long">
SELECT SUM(latest_balance.balance)
FROM (
SELECT DISTINCT a.user_id, m.wallet_type_id,
FIRST_VALUE(a.balance) OVER (
PARTITION BY a.user_id, m.wallet_type_id
ORDER BY a.create_time DESC, a.id DESC
) AS balance
FROM wallet_transaction a
INNER JOIN wallet_manage m ON a.wallet_id = m.id
WHERE a.del_flag = '0'
AND DATE_FORMAT(a.create_time, '%Y-%m') &lt;= #{dto.walletRecordMonth}
AND m.wallet_type_id = #{dto.walletTypeId}
<if test="dto.userId != null">
AND a.user_id = #{dto.userId}
</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}</foreach>
</if>
<if test="dto.deptIds != null and dto.deptIds.size() != 0">
<choose>
<when test="dto.isInclude != null and dto.isInclude == false">AND m.dept_id NOT IN</when>
<otherwise>AND m.dept_id IN</otherwise>
</choose>
<foreach collection="dto.deptIds" item="v" open="(" close=")" separator=",">#{v}</foreach>
</if>
) latest_balance
</select>
</mapper>