diff --git a/db/20261023_wallet_qb_total_index.sql b/db/20261023_wallet_qb_total_index.sql new file mode 100644 index 000000000..efa0db95e --- /dev/null +++ b/db/20261023_wallet_qb_total_index.sql @@ -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, +-- 窗口节点处理的是钱包/历史所属用户候选数,不应是整月之前的全部流水数。 diff --git a/pjcloud-wallet/pjcloud-wallet-biz/docs/wallet-qb-total.md b/pjcloud-wallet/pjcloud-wallet-biz/docs/wallet-qb-total.md new file mode 100644 index 000000000..0ac20859a --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/docs/wallet-qb-total.md @@ -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`,重复测试可重新生成。 diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletManageMapper.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletManageMapper.java index c48f92dfc..ef025d287 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletManageMapper.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletManageMapper.java @@ -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 { @@ -185,5 +186,6 @@ public interface WalletManageMapper extends PjcloudBaseMapper activeBalanceList(@Param("dto") WalletTopupActionDto dto, @Param("unactiveDayNumberStr") String unactiveDayNumberStr); - Long selectTotalBalance(@Param("dto") WalletManageBalanceDto dto); + List selectQbTotalBalances(@Param("dto") WalletManageBalanceDto dto, + @Param("monthEnd") LocalDateTime monthEnd); } diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletQbTypeTotal.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletQbTypeTotal.java new file mode 100644 index 000000000..a9d87b05d --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletQbTypeTotal.java @@ -0,0 +1,10 @@ +package com.bsd.pjcloud.wallet.mapper; + +import lombok.Data; + +/** 数据库按钱包类型汇总的历史余额,金额使用 Long 避免累计溢出。 */ +@Data +public class WalletQbTypeTotal { + private Long walletTypeId; + private Long total; +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletManageServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletManageServiceImpl.java index 67673584b..6e5da4012 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletManageServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletManageServiceImpl.java @@ -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= 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 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 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); diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/WalletManageMapper.xml b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/WalletManageMapper.xml index 49c5e4035..3f348c1bf 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/WalletManageMapper.xml +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/WalletManageMapper.xml @@ -185,129 +185,72 @@ - - - - - - - - + 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} + + AND h.user_id = #{dto.userId} + + + AND h.user_id IN + + #{v} + + + GROUP BY h.tenant_id, h.del_flag, h.wallet_id, h.user_id + ) latest + + INNER JOIN wallet_manage m ON m.id = latest.wallet_id + INNER JOIN wallet_transaction a ON a.id = ( + SELECT MAX(last.id) + FROM wallet_transaction last + WHERE (last.tenant_id = latest.tenant_id + OR (last.tenant_id IS NULL AND latest.tenant_id IS NULL)) + AND last.del_flag = latest.del_flag + AND last.wallet_id = latest.wallet_id + AND last.user_id = latest.user_id + AND last.create_time = latest.latest_time + ) + + + AND m.wallet_type_id = #{dto.walletTypeId} + + + AND m.wallet_type_id IN + + #{v} + + + + + + AND m.dept_id NOT IN + + + AND m.dept_id IN + + + + #{v} + + + + ) ranked + WHERE ranked.rn = 1 + GROUP BY ranked.wallet_type_id - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/total/WalletQbTotalMySqlTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/total/WalletQbTotalMySqlTest.java new file mode 100644 index 000000000..c94d1e77a --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/total/WalletQbTotalMySqlTest.java @@ -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 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 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 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 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 scenario : scenarios.entrySet()) { + WalletManageBalanceDto dto = scenario.getValue(); + List oldMs = new ArrayList<>(); + List newMs = new ArrayList<>(); + Map actual = Map.of(); + for (int run = 0; run < 3; run++) { + session.clearCache(); + long oldStart = System.nanoTime(); + Map 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 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 legacy(SqlSession session, WalletManageBalanceDto dto) throws Exception { + Map 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 selectedTypes(WalletManageBalanceDto dto) throws Exception { + List 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 toMap(List values) { + Map 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 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 parameters) { } + + @Intercepts(@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})) + public static class SqlRecorder implements Interceptor { + final List captures = new ArrayList<>(); + public Object intercept(Invocation invocation) throws Throwable { + BoundSql bound = ((StatementHandler) invocation.getTarget()).getBoundSql(); + List 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(); + } + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/total/WalletQbTotalServiceTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/total/WalletQbTotalServiceTest.java new file mode 100644 index 000000000..4b348f09b --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/total/WalletQbTotalServiceTest.java @@ -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 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 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 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 walletTypes = new ArrayList<>(List.of(11L, 22L)); + List userIds = new ArrayList<>(List.of(71L, 72L)); + List 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 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 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 months = List.of("2024-02", "2025-02", "2026-12"); + List 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 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()); + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/resources/mapper/total/WalletQbLegacyMapper.xml b/pjcloud-wallet/pjcloud-wallet-biz/src/test/resources/mapper/total/WalletQbLegacyMapper.xml new file mode 100644 index 000000000..cc89c3d81 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/resources/mapper/total/WalletQbLegacyMapper.xml @@ -0,0 +1,34 @@ + + + + + +