feat:钱包余额统计查询
This commit is contained in:
1 parent
c0285edd4c
commit
9f9e3c6d04
9 files changed
+891
-131
No files matched your search
@@ -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`,重复测试可重新生成。
|
||||
+3
-1
@@ -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);
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package com.bsd.pjcloud.wallet.mapper;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/** 数据库按钱包类型汇总的历史余额,金额使用 Long 避免累计溢出。 */
|
||||
@Data
|
||||
public class WalletQbTypeTotal {
|
||||
private Long walletTypeId;
|
||||
private Long total;
|
||||
}
|
||||
+26
-6
@@ -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);
|
||||
|
||||
+67
-124
@@ -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') <= #{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 < #{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') <= #{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>
|
||||
+433
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+196
@@ -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());
|
||||
}
|
||||
}
|
||||
+34
@@ -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') <= #{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>
|
||||
Reference in new issue
Block a user