diff --git a/db/20261022_hard_cs_statistics_indexes.sql b/db/20261022_hard_cs_statistics_indexes.sql new file mode 100644 index 000000000..695ceba5e --- /dev/null +++ b/db/20261022_hard_cs_statistics_indexes.sql @@ -0,0 +1,57 @@ +-- /hard/hard-total/page-list-cs 配套索引,线上目标为 PolarDB MySQL 8;已在 MySQL 8.0.46 验证。 +-- 应与本次 SQL 改造一同发布。大表低峰期执行,先核对剩余磁盘和实际版本的在线 DDL 支持。 +-- 不删除现有索引;只跳过前导列及覆盖列均等效的可见完整 BTREE 索引。 +-- 窄索引 (tenant_id,del_flag,pay_time) 不能替代本次支付覆盖索引。 +-- 同一个支付索引覆盖页面的钱包、用户部门和用餐时段筛选,避免这些场景重新逐行读取订单主表。 +-- PolarDB DDL 行为参考:https://help.aliyun.com/zh/polardb/polardb-for-mysql/user-guide/support-for-ddl-in-polardb-mysql + +SET @hard_cs_pay_cover_exists = ( + SELECT COUNT(*) + FROM ( + SELECT INDEX_NAME, + GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX SEPARATOR ',') AS columns_in_order, + SUM(COLUMN_NAME IN ('tenant_id','del_flag','pay_time','order_status','merchant_type','merchant_id','merchant_dept_id','pay_price','wallet_id','user_dept_id','hour_type')) AS covered_columns, + SUM(SUB_PART IS NOT NULL) AS prefix_columns + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = 'pjcloudx_hardware' AND TABLE_NAME = 'hard_order' + AND INDEX_TYPE = 'BTREE' AND IS_VISIBLE = 'YES' + GROUP BY INDEX_NAME + ) existing_indexes + WHERE columns_in_order LIKE 'tenant_id,del_flag,pay_time,%' + AND covered_columns = 11 AND prefix_columns = 0 +); +SET @hard_cs_pay_cover_ddl = IF(@hard_cs_pay_cover_exists = 0, + 'CREATE INDEX idx_hard_order_cs_pay_cover ON pjcloudx_hardware.hard_order (tenant_id,del_flag,pay_time,order_status,merchant_type,merchant_id,merchant_dept_id,pay_price,wallet_id,user_dept_id,hour_type) ALGORITHM=INPLACE LOCK=NONE', + 'SELECT ''商户支付覆盖索引已存在,跳过'' AS message'); +PREPARE hard_cs_pay_cover_statement FROM @hard_cs_pay_cover_ddl; +EXECUTE hard_cs_pay_cover_statement; +DEALLOCATE PREPARE hard_cs_pay_cover_statement; + +SET @hard_cs_refund_audit_exists = ( + SELECT COUNT(*) + FROM ( + SELECT INDEX_NAME, + GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX SEPARATOR ',') AS columns_in_order, + SUM(COLUMN_NAME IN ('tenant_id','del_flag','status','audit_time','order_id','refund_price')) AS covered_columns, + SUM(SUB_PART IS NOT NULL) AS prefix_columns + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = 'pjcloudx_hardware' AND TABLE_NAME = 'hard_after_sale' + AND INDEX_TYPE = 'BTREE' AND IS_VISIBLE = 'YES' + GROUP BY INDEX_NAME + ) existing_indexes + WHERE columns_in_order LIKE 'tenant_id,del_flag,status,audit_time,%' + AND covered_columns = 6 AND prefix_columns = 0 +); +SET @hard_cs_refund_audit_ddl = IF(@hard_cs_refund_audit_exists = 0, + 'CREATE INDEX idx_hard_after_sale_cs_audit ON pjcloudx_hardware.hard_after_sale (tenant_id,del_flag,status,audit_time,order_id,refund_price) ALGORITHM=INPLACE LOCK=NONE', + 'SELECT ''商户退款时间覆盖索引已存在,跳过'' AS message'); +PREPARE hard_cs_refund_audit_statement FROM @hard_cs_refund_audit_ddl; +EXECUTE hard_cs_refund_audit_statement; +DEALLOCATE PREPARE hard_cs_refund_audit_statement; + +-- 发布后对真实租户、日期和筛选参数生成的 SQL 执行 EXPLAIN ANALYZE(执行真实查询)。 +-- 默认时间汇总:支付走 idx_hard_order_cs_pay_cover 范围覆盖扫描;退款先走 +-- idx_hard_after_sale_cs_audit 的 audit_time 范围,再按主键读取关联订单。 +-- 定向商户、钱包等筛选允许选择其他更合适的索引,不要求强制使用上述索引。 +-- SQL 中 NO_GROUP_INDEX / JOIN_PREFIX 提示已在 MySQL 8.0.46 验证;PolarDB 上用实际小版本、租户参数重新验计划。 +-- 若先建索引再灰度代码,过渡期旧 SQL 可能改变执行计划,应检查旧服务延迟,尽快完成同批灰度。 diff --git a/db/20261022_hard_cs_statistics_verification.md b/db/20261022_hard_cs_statistics_verification.md new file mode 100644 index 000000000..c84611334 --- /dev/null +++ b/db/20261022_hard_cs_statistics_verification.md @@ -0,0 +1,111 @@ +# 商户交易汇总优化与验证记录 + +2026-10-22,接口 `POST /hard/hard-total/page-list-cs`。线上数据库由用户确认为 PolarDB MySQL 8 兼容版;本次直接连接的测试数据库为 MySQL Community 8.0.46。本次没有连接生产数据库,也没有重启正在运行的服务。 + +## 查询链路与瓶颈 + +前端商户统计页面默认查询近三个月,返回整个商户汇总列表。接口名称虽然带 `page`,实际响应为 `List`,没有分页 COUNT SQL。 + +请求依次经过子商户展开、用户可见商户类型处理、支付汇总、退款汇总、Java 合并、商户及部门信息补全、平台抽成,最后由前端计算表尾合计。商户信息已按 200 个 ID 分批查询;钱包服务补全商户时还会查询部门,因此接口总耗时不能直接等同于两条 SQL 的耗时。 + +原查询存在以下开销: + +- 未筛选钱包类型时,支付和退款仍逐条关联 `wallet_manage`。 +- 售后表只有 `(order_id,del_flag,id)` 索引,无法直接按退款统计时间定位。 +- 已有支付时间窄索引需要读取订单主表;钱包、用户部门和用餐时段筛选也需要额外字段。 +- 实测发现优化器可能为商户分组选择商户索引,扫描全部历史订单;增加售后索引后,也可能改为从全部历史订单逐条查售后。仅加索引或仅删关联并不稳定。 +- Java 合并退款及匹配抽成商户反复遍历列表;前端挂载和切换子表有多个查询触发点。 + +## 最终实现 + +`HardOrderMapper.xml` 仅在筛选钱包类型时关联钱包表,移除 VO 不使用的 `max(user_dept_id)`,支付聚合使用 `NO_GROUP_INDEX(a)` 避免为分组顺序扫描整个历史商户索引。退款有日期范围且未指定订单、商户、用户或设备时使用 `JOIN_PREFIX(r)`,先扫描售后日期范围;定向筛选保留优化器自行选择驱动表的能力。 + +配套脚本 `db/20261022_hard_cs_statistics_indexes.sql` 建立两个索引,并识别同名或异名的等效索引,避免重复创建: + +```sql +hard_order ( + tenant_id, del_flag, pay_time, order_status, merchant_type, + merchant_id, merchant_dept_id, pay_price, wallet_id, user_dept_id, hour_type +) +hard_after_sale ( + tenant_id, del_flag, status, audit_time, order_id, refund_price +) +``` + +支付索引同时覆盖页面常用筛选,退款索引覆盖售后时间、金额及订单关联字段。新增索引会增加磁盘占用和写入维护成本,脚本保留所有既有索引,未自动删除窄支付时间索引。 + +`HardOrderTotalServiceImpl` 使用保留插入顺序的 Map 合并商户,抽成匹配也改为 Map,合并开销为 O(支付商户数 + 退款商户数)。保留每 200 个 ID 的远程查询边界。Controller 和 Service 对超过一秒的请求记录子商户解析、权限、支付查询、退款查询、合并、商户 RPC、部门 RPC 的分段耗时。 + +前端统一在异步子表挂载后查询,移除重复的 mounted/tab-click 触发,日/月切换每次只请求一次,并保留手动刷新和部门筛选。 + +同时修复两个边界问题:整个区间只有退款时原实现会漏掉全部商户;指定设备类型时原支付 SQL 引用了未关联的 `m` 别名。现在退款商户能显示负净额,设备类型筛选同时作用于支付和退款。 + +## 统计口径与正确性 + +支付仍使用状态 `5、20、10` 和订单 `pay_time`;退款仍使用完成状态 `50` 和原 SQL 的售后 `audit_time`,独立于订单支付时间。一笔订单多次退款按退款记录分别计次。支付与退款分别汇总后按商户相减,金额使用 BigDecimal,抽成计算及取舍位置保持原实现。 + +金额单位、时间范围的包含边界、商户类型排序规则、商户子级展开、用户部门和商户部门的包含/排除、缺失或已删除钱包的历史行为均有回归覆盖。继续使用现有租户及 DataScope 插件;没有将原先分组后的部门权限改为不同的行级语义。 + +保留优化前两条 SQL 作为测试对照,36 组筛选逐项比较支付及退款结果。MySQL 测试库现有业务数据另做 9 组对比。HTTP 回归实际执行 Controller、Service、Mapper 和权限插件,外部服务使用替身;没有将该测试当作真实网关或远程服务的性能测量。 + +还比较了“退款先按订单归集再关联”的候选。在一单一退款分布、已建立退款索引时,交替测量中直接关联约 175ms、预聚合约 274ms;最终没有将该物化方案用于业务查询。测试资源保留候选,供以后重新评估其他数据分布。 + +## 百万级 MySQL 测试 + +连接内临时表复制真实业务表的 `SHOW CREATE TABLE`,包括原有索引,再生成 100 万订单、100 万售后及合成钱包数据。数据覆盖两年、两个租户、997 个商户、不同状态及软删除标记,包含日期与支付状态过滤。最终验收使用一笔订单一条售后,避免多次退款的缓存复用使测试过于乐观;多次部分退款另有功能回归及前轮百万级数据验证。 + +最终 SQL 和索引下,每个场景先与旧 SQL 比较完整商户金额/次数,再测量三次取中位数。下表时间只包含 Mapper 查询及 SQL,不包含真实权限服务、商户/部门 RPC、网关和浏览器网络。 + +| 场景 | 支付中位数 | 退款中位数 | 两条查询合计 | +| --- | ---: | ---: | ---: | +| 三个月,全部商户 | 144.52ms | 354.35ms | 498.87ms | +| 两天范围 | 5.27ms | 10.78ms | 16.05ms | +| 指定一个商户 | 4.80ms | 7.12ms | 11.92ms | +| 商户部门筛选 | 39.63ms | 593.03ms | 632.66ms | +| 用户部门筛选 | 39.51ms | 279.40ms | 318.91ms | +| 用餐时段筛选 | 58.90ms | 374.20ms | 433.10ms | +| 钱包类型筛选 | 143.37ms | 745.15ms | 888.52ms | + +同一合成数据生成规则的原索引、旧 SQL 阶段,三个月支付约 1461.51ms、退款约 566.49ms,合计约 2028ms。不同轮次受本机并发负载、缓存和临时表统计信息影响,以上为离线阶段对比,不能推算线上接口必然从 5s 降至 0.5s。 + +最终实际执行计划: + +```text +支付:Covering index range scan on a using idx_hard_order_cs_pay_cover + 日期范围扫描 111182 条索引记录,筛选后 106348 笔支付,997 个商户 +退款:Covering index range scan on r using idx_hard_after_sale_cs_audit + 日期范围 101079 条售后,再按 hard_order PRIMARY 查对应订单 +钱包筛选:订单仍使用支付覆盖索引,之后按 wallet_manage PRIMARY 检查钱包类型 +``` + +后端验收共 44 项:43 项执行通过,0 失败、0 错误;1 项与本次无关的旧 H2 性能用例默认跳过。本次 MySQL 百万级性能用例已实际执行。前端 19 项测试通过,其中新增 3 项执行页面真实 setup、异步子组件挂载及切换生命周期;页面 SFC 脚本和模板编译通过。 + +索引迁移在独立测试 schema 中连续执行,并重命名为异名等效索引再次执行,均未重复建索引。百万级临时表随连接关闭清理,独立 schema 已删除。验收后业务表仍为 833 条订单、158 条售后,业务表没有新增本次候选索引。 + +## PolarDB 发布与验收 + +PolarDB 官方 [Statement Outline 文档](https://help.aliyun.com/zh/polardb/polardb-for-mysql/user-guide/statement-outline)明确提供 `JOIN_PREFIX` 并说明支持 MySQL 8.0 的 Hint 类型;[DDL 指南](https://help.aliyun.com/zh/polardb/polardb-for-mysql/user-guide/support-for-ddl-in-polardb-mysql)列明 8.0.1/8.0.2 创建二级索引支持并发 DML。脚本使用 `ALGORITHM=INPLACE LOCK=NONE`,不支持时会失败,不会自动降级为锁写入的 COPY。实际建索引开始/结束仍有元数据锁,并消耗 I/O 和 CPU,应安排低峰执行。 + +建议先灰度后端查询改造,再在同一发布窗口建立两个配套索引,随后发布前端。不要只给旧服务加索引:本次实测旧 SQL 曾因新增索引改变驱动表,明显变慢。不要自动回滚或删除其他既有索引。 + +在实际 PolarDB 节点上记录内核小版本及路由节点,用大租户近三个月、商户、钱包和部门条件执行 EXPLAIN,紧接 SHOW WARNINGS 检查提示是否被忽略;确认默认支付为日期覆盖范围扫描、默认退款先走售后时间索引。EXPLAIN ANALYZE 会实际执行查询,应选择验收时段。若该内核支持 EXPLAIN DDL,也可先预览建索引的算法和并发 DML 行为。 + +灰度时比较同一筛选的商户数、支付/退款金额及次数、负净额、表尾合计;结合新增分段日志记录端到端 P50/P95、数据库扫描行数和并发负载。线上执行计划、端到端性能和真实 RPC 的验证仍需在发布环境完成;本次没有使用生产连接或执行生产数据库操作。 + +复现后端测试前设置提供的 `MYSQL_HOST、MYSQL_PORT、MYSQL_USER、MYSQL_PWD` 环境变量,不将密码写入文件。在仓库根目录运行: + +```sh +HARD_CS_MYSQL_TEST=true HARD_CS_MYSQL_BENCHMARK=true mvn \ + -pl pjcloud-hardware/pjcloud-hardware-biz -am \ + -Dtest=HardOrderCsSqlTest,HardOrderCsServiceTest,HardOrderCsMySqlTest,HardBatchExportQueryTest,HardBatchExportFileTest,HardOrderStatisticsSqlTest,HardOrderStatisticsServiceTest,HardOrderStatisticsScopeTest,HardOrderStatisticsControllerTest \ + -Dhard.cs.benchmark.finalOnly=true \ + -Dsurefire.failIfNoSpecifiedTests=false -DfailIfNoTests=false test +``` + +不设置 `hard.cs.benchmark.finalOnly` 可重跑原索引、只加退款索引、完整索引三个阶段,并交替比较预聚合候选,耗时更长。管理端仓库执行下列命令可复现本次 19 项前端回归: + +```sh +node --test tests/batch-export.test.cjs tests/hard-sh-query-lifecycle.test.cjs \ + tests/hard-third-order.test.cjs tests/wallet-ledger-exports.test.cjs \ + tests/wallet-manage-export.test.cjs +``` diff --git a/db/20261022_hard_dept_statistics_indexes.sql b/db/20261022_hard_dept_statistics_indexes.sql new file mode 100644 index 000000000..a76cd7255 --- /dev/null +++ b/db/20261022_hard_dept_statistics_indexes.sql @@ -0,0 +1,88 @@ +-- /hard/hard-total/page-list-dept 与部门导出配套索引;目标:PolarDB MySQL 8。 +-- 本脚本只读取索引元数据并按需创建索引,不写业务数据,不删除或替换现有索引。 +-- 索引名称不是等效判断依据:已有其他名称的可见 BTREE 索引满足条件时同样跳过。 +-- 若目标名称已被不同定义占用且不存在等效索引,CREATE INDEX 会报错并停止该条; +-- 应先核对 SHOW INDEX,不能把不同定义的同名索引当作本次所需索引。 +-- 大表低峰逐条执行。INPLACE/LOCK=NONE 仍需短暂元数据锁,应先排查长事务, +-- 核对实际 PolarDB 小版本的在线 DDL 支持、可用空间和复制延迟,不并行建大表索引。 +-- PolarDB DDL:https://help.aliyun.com/zh/polardb/polardb-for-mysql/user-guide/support-for-ddl-in-polardb-mysql +-- +-- 代码发布依赖:先发布 Wallet 的 /wallet-transaction/department-statistics 新内部接口, +-- 再发布 Hardware。Hardware 新支付聚合直接读取 pjcloudx_wallet.wallet_transaction, +-- 两库须位于同一实例,Hardware 应用数据库账号须具备该表 SELECT 权限。 +-- 测试使用的 root 账号权限不能作为生产应用账号权限的验证依据。 + +-- 一、订单按创建时间做范围覆盖扫描。 +-- 等效索引须以前三列 tenant_id,del_flag,create_time 按此顺序开头,包含以下全部七列, +-- 所有显式索引列均为完整列(无前缀截断),且索引可见、类型为 BTREE。 +-- 后续覆盖列允许不同顺序或额外列;只有前三列的窄索引不能替代本次覆盖索引。 +SET @hard_dept_order_cover_exists = ( + SELECT COUNT(*) + FROM ( + SELECT INDEX_NAME, + SUM(SEQ_IN_INDEX = 1 AND COLUMN_NAME = 'tenant_id') AS tenant_first, + SUM(SEQ_IN_INDEX = 2 AND COLUMN_NAME = 'del_flag') AS deletion_second, + SUM(SEQ_IN_INDEX = 3 AND COLUMN_NAME = 'create_time') AS created_third, + COUNT(DISTINCT CASE WHEN COLUMN_NAME IN + ('tenant_id','del_flag','create_time','order_status','wallet_id','user_dept_id','pay_price') + THEN COLUMN_NAME END) AS covered_columns, + SUM(SUB_PART IS NOT NULL) AS prefix_columns + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = 'pjcloudx_hardware' AND TABLE_NAME = 'hard_order' + AND INDEX_TYPE = 'BTREE' AND IS_VISIBLE = 'YES' + GROUP BY INDEX_NAME + ) existing_indexes + WHERE tenant_first = 1 AND deletion_second = 1 AND created_third = 1 + AND covered_columns = 7 AND prefix_columns = 0 +); +SET @hard_dept_order_cover_ddl = IF(@hard_dept_order_cover_exists = 0, + 'CREATE INDEX idx_hard_order_dept_ct_cover ON pjcloudx_hardware.hard_order (tenant_id,del_flag,create_time,order_status,wallet_id,user_dept_id,pay_price) ALGORITHM=INPLACE LOCK=NONE', + 'SELECT ''部门订单创建时间覆盖索引已存在,跳过'' AS message'); +PREPARE hard_dept_order_cover_statement FROM @hard_dept_order_cover_ddl; +EXECUTE hard_dept_order_cover_statement; +DEALLOCATE PREPARE hard_dept_order_cover_statement; + +-- 二、按租户和业务编号定位少量支付流水,再校验钱包、业务类型、删除状态和部门,取最新有效记录。 +-- 复用 20261022_wallet_rule_transaction_page_index.sql 中的 idx_wt_tenant_bizid_type, +-- 不为部门报表再创建另一条更宽的同类索引。 +-- 任何以完整 tenant_id,biz_id 两列开头的可见 BTREE 索引均视为可复用;后续列可有前缀。 +-- biz_type 是 TINYTEXT,新增候选必须写 biz_type(8),该候选不是完整覆盖索引。 +-- biz_id 高度选择性,用索引定位后执行精确字节匹配及最新有效快照筛选。 +SET @hard_dept_wallet_biz_lookup_exists = ( + SELECT COUNT(*) + FROM ( + SELECT INDEX_NAME, + SUM(SEQ_IN_INDEX = 1 AND COLUMN_NAME = 'tenant_id' AND SUB_PART IS NULL) AS tenant_first, + SUM(SEQ_IN_INDEX = 2 AND COLUMN_NAME = 'biz_id' AND SUB_PART IS NULL) AS complete_biz_second + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = 'pjcloudx_wallet' AND TABLE_NAME = 'wallet_transaction' + AND INDEX_TYPE = 'BTREE' AND IS_VISIBLE = 'YES' + GROUP BY INDEX_NAME + ) existing_indexes + WHERE tenant_first = 1 AND complete_biz_second = 1 +); +SET @hard_dept_wallet_biz_lookup_ddl = IF(@hard_dept_wallet_biz_lookup_exists = 0, + 'CREATE INDEX idx_wt_tenant_bizid_type ON pjcloudx_wallet.wallet_transaction (tenant_id,biz_id,biz_type(8)) ALGORITHM=INPLACE LOCK=NONE', + 'SELECT ''钱包租户业务编号索引已存在,复用并跳过'' AS message'); +PREPARE hard_dept_wallet_biz_lookup_statement FROM @hard_dept_wallet_biz_lookup_ddl; +EXECUTE hard_dept_wallet_biz_lookup_statement; +DEALLOCATE PREPARE hard_dept_wallet_biz_lookup_statement; + +-- 三、钱包六类流水合并统计先复用既有 idx_wallet_tx_tenant_del_biz_ct_uid +-- (tenant_id,del_flag,biz_type(8),create_time,user_id),本脚本不额外创建宽聚合索引。 +-- 该索引来自 20261004_wallet_transaction_page_index.sql / 20260826 索引脚本, +-- 不是包含 dept_id、price 的覆盖索引。日期范围汇总使用 INDEX 优化器提示建议采用该索引, +-- 防止业务号索引仅按 tenant_id 扫描整个租户。请核实同名索引存在且定义正确; +-- 提示要求引擎支持 MySQL 8.0.20+ 的 INDEX hint,未知提示或缺失索引时会忽略并回退, +-- 因此发布须以实际 PolarDB 的 EXPLAIN 和 SHOW WARNINGS 验收,不能仅凭 SQL 成功。 +-- 定向钱包或没有合法日期范围时不加此提示。 +-- 对大批导入/临时造数后索引统计失真,应刷新统计后再核对计划; +-- 本脚本不自动对业务大表执行 ANALYZE,生产按维护窗口和实际统计情况处理。 +-- +-- 发布验收使用真实租户、日期、部门包含/排除及钱包类型参数执行 EXPLAIN ANALYZE: +-- 1. 订单时间范围扫描应能使用创建时间覆盖索引,避免逐条回表读取订单字段。 +-- 2. 历史流水应通过 tenant_id,biz_id 等值定位,不能逐单扫描全部钱包流水; +-- 源 SQL 保留普通 biz_id 字符串等值,额外 BINARY 等值只作精确匹配校验。 +-- 3. 检查 NO_MERGE 派生表及相关子查询实际 loops,确认历史部门不会被重复求值。 +-- 4. 钱包合并统计只执行一次;定向钱包或其他高选择性条件允许选择更合适的既有索引。 +-- 5. 核对页面与部门导出全部金额、次数、删除部门及零交易部门结果后再扩大灰度。 diff --git a/db/20261022_hard_dept_statistics_verification.md b/db/20261022_hard_dept_statistics_verification.md new file mode 100644 index 000000000..9971f966c --- /dev/null +++ b/db/20261022_hard_dept_statistics_verification.md @@ -0,0 +1,127 @@ +# 部门交易统计优化验证记录(2026-10-22) + +## 范围与数据库 + +目标接口:`POST /hard/hard-total/page-list-dept`,同时优化共用统计逻辑的同步/异步部门导出。用户反馈线上超过 30 秒未返回。线上为 PolarDB MySQL 8 兼容版;本次实际连接的是用户提供的本机 MySQL 8.0.46 测试库,未连接或修改生产数据库。 + +本次仅新增部门统计链路;保留此前 `page-list-cs` 的改动和工作区其他并行任务的改动。部门前端 `src/views/hard/hard-dept-total/index.vue` 初始化没有商户统计页面的重复请求问题,因此本次不修改部门前端。 + +## 根因与实现 + +旧链路先查询 UPMS 部门,再依次执行六次 Wallet 汇总 RPC;随后把全部候选订单明细读入 JVM,按每组最多 10000 订单、每 RPC 最多 10 组请求历史流水,Wallet 每组单独查询和排序,最后在 Java 按历史部门累加。订单量和历史流水量同时影响数据库扫描、结果映射、RPC 传输及 JVM 内存。原流水索引不能直接按租户、业务编号定位,订单缺少以租户/删除标记/创建时间开头的覆盖索引。 + +新链路:一次 UPMS 部门查询 + 一次 Wallet 条件聚合 RPC + 一次 Hardware 支付部门聚合 SQL。只返回部门级金额、次数,不再回传逐笔订单和流水快照。导出共用这条链路,不再每 200 订单重复请求历史快照;Excel 仍按部门结果分批写入。 + +- Wallet 新增内部 `/wallet-transaction/department-statistics`,用 CASE 同时统计六类收支;原内部接口保留。 +- Hardware 新增 `HardDepartmentStatisticsMapper`,逐单用高选择性业务编号定位最新有效支付流水,回退订单部门后再按部门筛选/汇总。 +- 原始 `biz_id` 普通等值保证可用索引,额外 BINARY 等值模拟旧 Java Map 的精确字符串匹配;`biz_type` 同样校验精确字节。订单编号用 `concat('', a.id)`,保持完整大整数并让列侧排序规则优先,避免连接与历史列的字符排序规则冲突。 +- 显式限制订单租户、历史流水租户和钱包表租户,避免带库名前缀的表被租户插件忽略。入口要求有效租户上下文。对于历史异常的跨租户钱包引用,有钱包类型筛选时新查询会按同租户条件排除;这是对异常关联的隔离收紧。 +- 超过 1 秒的成功统计记录部门 RPC、钱包聚合 RPC、订单聚合、合并各段耗时,便于灰度定位。 +- 部门/钱包 RPC 失败或 SQL 异常终止统计,不返回假零。空部门结果保留原行为。 + +## 保留的统计口径 + +| 项目 | 口径 | +|---|---| +| 订单范围 | `hard_order.create_time`,状态 `5,20,10`;金额 `pay_price`、每单一次,包含零金额 | +| 历史部门 | 同租户、同钱包、精确字符串业务编号、PAYMENT=`4`,有效且部门非空的最大流水 ID;快照不限时间和金额符号;没有快照回退 `user_dept_id` | +| 部门筛选 | 还原历史部门后才做包含/排除,`isInclude=null` 与包含一致;`deptId` 继续只控制展示部门查询 | +| 钱包类型 | 当前钱包类型,标量和列表同时传入时取交集;不新增删除钱包过滤 | +| 批量充值/扣减 | BATCH=`2`,严格按金额正/负拆分 | +| 单次充值/扣减 | UPDATE=`1` 和 RECHARGE=`3` 合并,严格按金额正/负拆分 | +| 退款 | REFUND=`5` 原符号合计,零金额也计次数 | +| 退还食堂 | RETURN_CANTEEN=`8` 先带符号求和,组装时取绝对值;零金额也计次数 | +| 部门列表 | 保留 UPMS 顺序、已删除标记、零交易部门;支付或退款单边存在也保留 | +| 隔离范围 | 保持租户和所选部门条件;未为此接口新增原本不存在的 DataScope 权限规则 | + +部门 Excel 同时修正原有问题:8 个次数合计未累计、Long 类型扣减金额转元时截断小数、转换直接修改原 VO;改为独立导出 Map,全部金额/次数完整累计,并修正消费净金额标题。页面响应仍用原字段和分单位。 + +## 正确性与文件验证 + +- 新 Hardware SQL 回归 11 项:逐笔旧 SQL + 独立 Java 历史归属为基线,覆盖 21 组可选条件、24 组最终部门组合、时间/状态边界、现金回退、删除钱包、大整数编号、异常字符串和租户隔离。 +- 新 Hardware Service/Controller 回归 13 项:16 个金额/次数字段、RPC 次数/失败、筛选快照、空/删除部门、退款单边结果、页面与导出一致性和进度。 +- 新 Wallet SQL 回归 8 项:真实聚合 XML + 生产租户插件,对照原六次聚合,覆盖符号/零金额/钱包类型交集。 +- 部门真实 Excel 回归:201 部门跨 200 行批边界逐列验证 16 个数值及合计,重复导出不污染统计对象,统计异常不返回部分附件;原订单/商户导出测试保留。 +- 真实 MySQL 回归 3 项全部通过:既有数据 23 组筛选(14 个初始部门);源 SQL 精确业务号/业务类型、最新有效快照和大整数边界;索引迁移重复、改名、覆盖列重排、窄索引、业务号前缀截断和不可见索引判断。后两项写入只在 TEMPORARY 表或独立 scratch 库。 +- H2 测试只适配其 AS CHAR/AS BINARY 默认长度为 1 的方言差异;真实 MySQL 测试直接执行源 XML,无字符转换影子 SQL。 + +最终联合回归 **106 项实际执行通过,0 失败/错误**,覆盖 18 个测试类。另 2 项独立性能用例未在联合轮启用:原订单统计的百万性能开关未开启;钱包索引候选测试已在独立轮实际执行通过,不把联合轮跳过算作通过。 + +联合日志:`/private/tmp/hard-dept-final-verification.log`;Wallet 候选对照日志:`/private/tmp/hard-dept-mysql-wallet-candidates.log`。真实 MySQL 3 项回归和百万部门性能测试均实际启用并执行。索引 scratch 库清理后数量为 0;所有连接临时表在测试结束关闭连接后删除;任务文件凭证检查未发现提供的数据库密码。 + +## 真实 MySQL 性能验证 + +压测克隆测试库真实表结构及原索引到连接临时表,生成 100 万订单、100 万钱包流水、1 万钱包,时间分布 730 天、两个租户;包含历史部门变化、重复业务号、尾空格、错钱包、区间外快照、现金支付、删除记录及零金额。默认查询最近三个月,再分别测试部门包含、部门排除和钱包类型筛选。每项交替执行新/旧版本 3 次取中位数;计时旧订单链路总预算 30 秒、新 SQL 单次预算不超过 30 秒。部门包含/排除在旧链路仅改变最终 Java 过滤,最终回归复用默认查询已验证的全部门旧结果作为对照,避免重复搬运相同 10 万订单。旧计时样本超时会明确记录;若没有完整旧结果,仅为正确性另给旧链路 90 秒预算,绝不把该额外执行伪装为性能样本。 + +**必须代码与索引配套上线。** 原索引下默认三个月旧支付链路 12372.47 ms、新支付 SQL 14921.99 ms,说明仅修改 SQL 不能解决历史流水定位成本;钱包六次汇总则为 2222.59 ms,新一次聚合为 175.51 ms。支付必须添加业务号定位及订单时间覆盖索引后验收,不能仅发布代码。 + +造数后必须刷新临时表统计。初轮未刷新时,原空表索引误估每个钱包只有 1 条流水,新支付查询误选钱包编号索引并在新增索引阶段超过 30 秒。ANALYZE 仅作用于本次连接临时表;刷新后支付查询按业务编号定位,默认三个月中位数 550.01 ms。生产不自动执行 ANALYZE,应结合实际统计维护窗口验收计划。 + +还发现业务号索引会干扰 Wallet 汇总:它仅按 tenant_id 扫描 90 万行,默认约 1.2 秒。对照现有业务时间索引与普通时间索引后,前者只读取约 62015 行(后者约 110551 行),采用有日期范围且未指定 walletId 时的 INDEX 优化器提示,保留定向钱包自由选择索引。提示针对 MySQL 8.0.20+;不支持提示或没有同名索引时可回退,正确性不依赖提示,但灰度必须确认实际 PolarDB 计划已采用时间范围扫描。 + +钱包候选 3 次采样中位数(ms),每项均完整对照原六次查询的 12 个字段: + +| 场景 | 无提示 | 业务类型时间索引 | 普通时间索引 | +|---|---:|---:|---:| +| 默认三个月 | 966.70 | 186.04 | 179.19 | +| 部门包含 | 1016.21 | 83.94 | 129.76 | +| 部门排除 | 1161.30 | 130.97 | 164.09 | +| 钱包类型 | 1095.75 | 120.85 | 150.13 | + +默认场景约 7ms 的两候选差距不作为决策依据;业务类型时间索引扫描量更少,其余三场景更快,故选择该既有索引。 + +最终源 SQL + 配套索引 + 刷新临时统计后的验收结果(ms): + +| 场景 | 旧支付链路 | 新支付聚合 | 旧钱包六次汇总 | 新钱包一次汇总 | 新核心阶段中位数之和 | +|---|---:|---:|---:|---:|---:| +| 默认三个月 | 19442.08 | 508.69 | 2222.16 | 159.73 | 668.42 | +| 部门包含 | 复用全部门基线 | 477.83 | 2278.88 | 159.31 | 637.14 | +| 部门排除 | 复用全部门基线 | 475.08 | 2243.06 | 177.53 | 652.61 | +| 钱包类型 | 948.86 | 242.78 | 1762.37 | 140.42 | 383.20 | + +默认场景旧、新共用相同数据与配套索引,核心阶段中位数之和约 **21.66 秒 → 0.67 秒**;“之和”是两个阶段各自 3 次中位数相加,**不是完整 HTTP 接口中位数**。新增索引也会改变旧大 IN 查询的计划,因此不能假定仅给旧链路加索引就会提速。前面原索引的 12.37 秒支付样本与这里 19.44 秒样本属于不同索引/统计阶段,应分开解释。 + +所有新支付结果与旧逐单归属一致;钱包全部 12 个统计字段与旧六次 SQL 一致。默认旧流程向 JVM/服务间返回 106348 订单、43922 快照,执行 11 次快照 SQL;新 SQL 返回 57 个支付部门和 25 个钱包部门,服务中保留 UPMS 的零交易部门。钱包类型筛选旧流程返回 34294 订单、14561 快照并执行 4 次快照 SQL。 + +最终 EXPLAIN ANALYZE 已确认: + +- 订单走 `idx_hard_order_dept_ct_cover` 创建时间覆盖范围扫描,默认读 111182 索引项、106348 候选订单。 +- 历史流水走 `idx_wt_tenant_bizid_type` 的 tenant_id + biz_id + biz_type 定位;默认 loops=106348,平均每次仅约 0.432 条候选;四场景均不存在逐单全表扫描。 +- NO_MERGE 生效,先物化部门结果,只有一个历史标量子查询;外层部门筛选/分组没有再次执行该表达式。 +- Wallet 走现有 `idx_wallet_tx_tenant_del_biz_ct_uid` 的五个业务类型/日期区间,默认读取约 62015 行后聚合,不再按租户扫描 90 万行。 +- 缺失索引提示的只读探针在真实 MySQL 返回 warning 3128 并正常生成回退计划,无 SQL 执行错误;PolarDB 是否识别提示仍需按具体版本验收。 + +## 索引、权限与发布顺序 + +配套脚本:`db/20261022_hard_dept_statistics_indexes.sql`。本次未在任何业务表加索引;脚本实际执行验证仅使用测试专用 scratch 库,结束删除;百万数据仅在当前连接 TEMPORARY 表中生成并建索引,关闭连接自动删除。 + +1. 订单候选覆盖索引 `(tenant_id,del_flag,create_time,order_status,wallet_id,user_dept_id,pay_price)`;普通窄时间索引不足以覆盖读取字段。 +2. 钱包业务编号定位索引 `(tenant_id,biz_id,biz_type(8))`,复用其他任务的 `idx_wt_tenant_bizid_type`,不创建第二条同类宽索引。`biz_type` 为 TINYTEXT,前缀必须保留,该索引不是完整覆盖索引。 +3. 钱包六类合并统计复用现有 `idx_wallet_tx_tenant_del_biz_ct_uid`,日期范围查询使用前述优化器提示,不追加大宽索引;需确认同名索引及提示在具体 PolarDB 小版本生效。 +4. 脚本识别其他名称的等效可见索引、完整列前缀,重复运行跳过;同名但定义不同会报错,应核对后处理。大表低峰逐条在线建索引,核对长事务、元数据锁、空间和具体 PolarDB 版本支持。 +5. **先准备索引,再发布所有 Wallet 实例的新内部接口,最后滚动发布 Hardware。** 原 Wallet 内部 API 未删除,先发布 Wallet 兼容旧 Hardware。 +6. **Hardware 与 Wallet 库必须同实例;Hardware 生产数据库账号需要 `pjcloudx_wallet.wallet_transaction` 的 SELECT 权限。** 这是新增依赖,原本查询 wallet_manage 的权限不能代替;本地 root 测试不能证明应用账号权限。请用实际 Hardware 账号先执行只读查询和 EXPLAIN。 +7. 在真实租户上灰度验证默认日期、部门包含/排除、钱包类型的 16 项金额/次数与导出;检查历史流水业务编号索引和相关子查询实际 loops。观察分段日志、DB CPU/IO、RPC 时延后扩大范围。 + +本次结果是本机 MySQL 的 SQL、Java 映射/归集和文件回归结果,**不是生产 PolarDB 接口时延承诺**;不包含网关、UPMS/Wallet 的真实网络与连接池排队。大范围无日期查询仍要读取该范围全部相关订单,不能把百万样本耗时直接外推到线上全部历史数据。 + +## 复现 + +先在 shell 中设置本地测试库的 `MYSQL_HOST`、`MYSQL_PORT`、`MYSQL_USER`、`MYSQL_PWD`,不要把凭证写入仓库。仓库根目录执行: + +```bash +HARD_DEPT_MYSQL_TEST=true mvn -o -pl pjcloud-hardware/pjcloud-hardware-biz -am \ + -Dtest=HardDepartmentStatisticsMySqlTest \ + -Dsurefire.failIfNoSpecifiedTests=false -DfailIfNoTests=false \ + '-DargLine=-Xmx768m -Dfile.encoding=UTF-8 -Djava.awt.headless=true' test + +HARD_DEPT_MYSQL_TEST=true HARD_DEPT_MYSQL_BENCHMARK=true \ + mvn -o -pl pjcloud-hardware/pjcloud-hardware-biz -am \ + -Dtest=HardDepartmentStatisticsMySqlBenchmarkTest -Dhard.dept.benchmark.finalOnly=true \ + -Dsurefire.failIfNoSpecifiedTests=false -DfailIfNoTests=false \ + '-DargLine=-Xmx1536m -Dfile.encoding=UTF-8 -Djava.awt.headless=true' test +``` + +环境开关未启用时真实 MySQL 测试跳过;不能把跳过视为验证通过。macOS 文件回归需保留 `java.awt.headless=true`,避免 AWT 初始化导致测试 JVM 退出。 + +去掉 `hard.dept.benchmark.finalOnly` 可连同原索引阶段一起执行;设置 `hard.dept.benchmark.walletCandidates=true` 可单独复现三个钱包索引选择方案。所有模式仍要求两个 MySQL 环境开关显式开启。 diff --git a/db/20261022_wallet_inactive_index.sql b/db/20261022_wallet_inactive_index.sql new file mode 100644 index 000000000..5758c1b5d --- /dev/null +++ b/db/20261022_wallet_inactive_index.sql @@ -0,0 +1,51 @@ +-- 不活跃钱包统计专用索引。仅新增索引,不修改业务数据;大表请低峰逐条执行并观察 IO。 +-- 每段先检查可见 BTREE 索引的列前缀,等价索引已存在时跳过。 +-- 若同名索引定义不同,CREATE INDEX 将报错,应检查后人工处理,禁止盲目删除重建。 + +SET @wallet_inactive_ddl = IF(EXISTS ( + SELECT 1 FROM ( + SELECT INDEX_NAME, GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) AS cols + 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 cols = 'tenant_id,user_id,del_flag,create_time' + OR cols LIKE 'tenant_id,user_id,del_flag,create_time,%' +), 'SELECT ''wallet inactive: equivalent transaction index exists''', +'CREATE INDEX idx_wallet_inactive_tx_user_time ON pjcloudx_wallet.wallet_transaction (tenant_id,user_id,del_flag,create_time)'); +PREPARE wallet_inactive_stmt FROM @wallet_inactive_ddl; +EXECUTE wallet_inactive_stmt; +DEALLOCATE PREPARE wallet_inactive_stmt; + +SET @wallet_inactive_ddl = IF(EXISTS ( + SELECT 1 FROM ( + SELECT INDEX_NAME, GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) AS cols + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = 'pjcloudx_wallet' AND TABLE_NAME = 'wallet_manage' + AND IS_VISIBLE = 'YES' AND INDEX_TYPE = 'BTREE' + GROUP BY INDEX_NAME + ) indexes WHERE cols = 'tenant_id,del_flag,user_id,create_time' + OR cols LIKE 'tenant_id,del_flag,user_id,create_time,%' +), 'SELECT ''wallet inactive: equivalent user index exists''', +'CREATE INDEX idx_wallet_inactive_user ON pjcloudx_wallet.wallet_manage (tenant_id,del_flag,user_id,create_time)'); +PREPARE wallet_inactive_stmt FROM @wallet_inactive_ddl; +EXECUTE wallet_inactive_stmt; +DEALLOCATE PREPARE wallet_inactive_stmt; + +SET @wallet_inactive_ddl = IF(EXISTS ( + SELECT 1 FROM ( + SELECT INDEX_NAME, GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) AS cols + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = 'pjcloudx_wallet' AND TABLE_NAME = 'wallet_manage' + AND IS_VISIBLE = 'YES' AND INDEX_TYPE = 'BTREE' + GROUP BY INDEX_NAME + ) indexes WHERE cols = 'tenant_id,del_flag,dept_id,user_id,create_time' + OR cols LIKE 'tenant_id,del_flag,dept_id,user_id,create_time,%' +), 'SELECT ''wallet inactive: equivalent department index exists''', +'CREATE INDEX idx_wallet_inactive_dept_user ON pjcloudx_wallet.wallet_manage (tenant_id,del_flag,dept_id,user_id,create_time)'); +PREPARE wallet_inactive_stmt FROM @wallet_inactive_ddl; +EXECUTE wallet_inactive_stmt; +DEALLOCATE PREPARE wallet_inactive_stmt; + +-- 执行后核对 SHOW INDEX,并对 Mapper 的 countUsers / pageUsers / listWallets +-- 分别进行 EXPLAIN ANALYZE,验证近期流水存在性检查和最后流水取首行使用用户时间索引。 diff --git a/db/20261022_wallet_rule_transaction_page_index.sql b/db/20261022_wallet_rule_transaction_page_index.sql new file mode 100644 index 000000000..f4fdd67ee --- /dev/null +++ b/db/20261022_wallet_rule_transaction_page_index.sql @@ -0,0 +1,46 @@ +-- /wallet/wallet-rule-transaction/page-join 分页、准确计数及当前页详情索引。 +-- 先发布配套 SQL 改造才能消除 GROUP BY 和 biz_id 的隐式数字转换。 +-- 请在 pjcloudx_wallet 库核对 SHOW INDEX;同名同列索引已存在时跳过对应语句, +-- 同名但定义不同应先核实。大表低峰逐条执行,观察 IO 和复制延迟。 +-- LOCK=NONE 仍需要短暂元数据锁;执行前排查长事务,勿并发创建索引。 + +-- 默认列表、时间范围、COUNT;等值前缀后按 create_time/id 反向扫描。 +ALTER TABLE pjcloudx_wallet.wallet_rule_transaction + ADD INDEX idx_wrt_tenant_del_ct_id (tenant_id, del_flag, create_time, id), + ALGORITHM=INPLACE, LOCK=NONE; + +-- 按转账类型筛选时仍保持稳定时间排序,并使该条件的 COUNT 使用覆盖索引。 +ALTER TABLE pjcloudx_wallet.wallet_rule_transaction + ADD INDEX idx_wrt_tenant_del_biz_ct_id (tenant_id, del_flag, biz_type, create_time, id), + ALGORITHM=INPLACE, LOCK=NONE; + +-- 退款/未退款状态查询:避免准确计数时扫描并回表全部转账记录。 +ALTER TABLE pjcloudx_wallet.wallet_rule_transaction + ADD INDEX idx_wrt_tenant_del_refund_ct_id (tenant_id, del_flag, is_refund, create_time, id), + ALGORITHM=INPLACE, LOCK=NONE; + +-- 退回日期范围:该筛选不要求传 is_refund,使用独立的日期前缀。 +ALTER TABLE pjcloudx_wallet.wallet_rule_transaction + ADD INDEX idx_wrt_tenant_del_rt_ct_id (tenant_id, del_flag, refund_time, create_time, id), + ALGORITHM=INPLACE, LOCK=NONE; + +-- EXISTS 和当前页流水定位;biz_type 是 TINYTEXT,必须使用前缀长度。 +-- 必须保留 biz_id 的精确字符串等值条件;SIGNED 等值仅用于反向主键定位,不能替代它。 +ALTER TABLE pjcloudx_wallet.wallet_transaction + ADD INDEX idx_wt_tenant_bizid_type (tenant_id, biz_id, biz_type(8)), + ALGORITHM=INPLACE, LOCK=NONE; + +-- 部门树、部门权限的流水存在性判断;也覆盖按租户/类型筛选的子查询。 +ALTER TABLE pjcloudx_wallet.wallet_transaction + ADD INDEX idx_wt_tenant_type_dept_bizid (tenant_id, biz_type(8), dept_id, biz_id, user_id), + ALGORITHM=INPLACE, LOCK=NONE; + +-- 执行后用实际租户和日期 EXPLAIN 验证: +-- SELECT a.id FROM wallet_rule_transaction a +-- WHERE a.tenant_id = ? AND a.del_flag = '0' +-- AND a.create_time BETWEEN ? AND ? +-- ORDER BY a.create_time DESC, a.id DESC LIMIT 10; +-- 预期使用 idx_wrt_tenant_del_ct_id,无临时表和 filesort。 +-- SELECT MIN(t.id) FROM wallet_transaction t +-- WHERE t.tenant_id = ? AND t.biz_id = ? AND t.biz_type = '6'; +-- 预期使用 idx_wt_tenant_bizid_type,通过业务编号定位少量流水。 diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderByAndroidCodeVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderByAndroidCodeVo.java new file mode 100644 index 000000000..e892f9694 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderByAndroidCodeVo.java @@ -0,0 +1,31 @@ +package com.bsd.pjcloud.hard.api.vo.order; + +import lombok.Builder; +import lombok.Data; + +/** 终端下单响应丢失后的最小对账快照,不执行订单详情的远程用户/商户补全。 */ +@Data +@Builder +public class HardOrderByAndroidCodeVo { + + /** 服务端订单主键;沿用统一 Long 序列化规则。 */ + private Long orderId; + + /** 订单支付状态,只有已支付状态可由新终端恢复为支付成功。 */ + private String orderStatus; + + /** 支付方式编码,用于防止错误复用其他支付渠道的结果。 */ + private String payType; + + /** 订单归属用户,匿名现金单为 -1。 */ + private Long userId; + + /** 按下单商品快照计算的总额,单位分。 */ + private Integer totalPrice; + + /** 下单额外餐费,单位分。 */ + private Integer extraPrice; + + /** 订单实际支付额,单位分;现金等既有渠道保留原来记录为零的约定。 */ + private Integer payPrice; +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/hard/HardApiController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/hard/HardApiController.java index 712fcdf3e..ac4d36f2b 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/hard/HardApiController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/hard/HardApiController.java @@ -26,6 +26,7 @@ import com.bsd.pjcloud.hard.api.vo.android.HardOfflineUserSyncSnapshotVo; import com.bsd.pjcloud.hard.api.vo.order.HardCarVo; import com.bsd.pjcloud.hard.api.vo.order.HardCreateHardQrCodeScanOrderVo; import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo; +import com.bsd.pjcloud.hard.api.vo.order.HardOrderByAndroidCodeVo; import com.bsd.pjcloud.hard.service.*; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.security.SecurityRequirement; @@ -147,9 +148,17 @@ public class HardApiController { return R.ok(hardwareOrderService.join(id)); } + /** 新终端可在网络超时后查询结果;来源设备必须与统一 DEVICE-ID 头一致。 */ + @GetMapping("/get-order-by-android-code") + @Operation(summary = "终端按本地订单号对账查询服务端订单") + public R getOrderByAndroidCode(@RequestParam String androidOrderCode, @RequestParam Long hardId, @RequestHeader("DEVICE-ID") Long deviceId) { + if (!java.util.Objects.equals(deviceId, hardId)) throw new RuntimeException("只能查询当前设备订单"); + return R.ok(hardwareOrderService.getOrderByAndroidCode(androidOrderCode, deviceId)); + } + @PostMapping("/create-order") @Operation(summary = "创建订单") - @Idempotent(expireTime = 60, delKey = true, info = "正在创建订单中,请勿重复操作") + // Service 按设备和 androidOrderCode 加自动续期锁并复用已支付订单,旧终端无需新增字段。 public R createOrder(@Valid @RequestBody HardCreateOrderDto dto) { // 处理商品类型 if (ObjectUtil.isNotEmpty(dto.getList())) { diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderTotalController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderTotalController.java index 7cfb3e9ee..c8fa063de 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderTotalController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderTotalController.java @@ -37,6 +37,7 @@ import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springdoc.core.annotations.ParameterObject; import org.springframework.http.HttpHeaders; import org.springframework.security.access.prepost.PreAuthorize; @@ -52,6 +53,7 @@ import java.util.Map; @RestController @RequiredArgsConstructor +@Slf4j @RequestMapping("/hard-total") @Tag(description = "hard-total", name = "统计") @SecurityRequirement(name = HttpHeaders.AUTHORIZATION) @@ -130,6 +132,7 @@ public class HardOrderTotalController { @PostMapping("/page-list-cs") @Operation(summary = "商户交易记录") public R> pageListCs(@RequestBody HardOrderDto dto) { + long started = System.nanoTime(); dto.setOrderStatusCodes(HardOrderStatusEnum.getPaySuccessCodes()); // 查询商户和下面的子商户 List mchDtoIds = new ArrayList<>(); @@ -143,7 +146,15 @@ public class HardOrderTotalController { dto.setMchIds(mchDtoIds); } } - return R.ok(hardOrderTotalService.listCs(dto)); + long merchantResolved = System.nanoTime(); + List rows = hardOrderTotalService.listCs(dto); + long completed = System.nanoTime(); + if (completed - started >= 1_000_000_000L) { + log.info("商户统计接口慢请求:总耗时={}ms,子商户解析={}ms,统计及补全={}ms,商户数={}", + (completed - started) / 1_000_000, (merchantResolved - started) / 1_000_000, + (completed - merchantResolved) / 1_000_000, rows.size()); + } + return R.ok(rows); } @PostMapping("/page-list-cs-by-month") diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardDepartmentStatisticsMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardDepartmentStatisticsMapper.java new file mode 100644 index 000000000..b20cec17f --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardDepartmentStatisticsMapper.java @@ -0,0 +1,15 @@ +package com.bsd.pjcloud.hard.mapper.order; + +import com.bsd.pjcloud.hard.api.dto.order.HardOrderSumAndCountDto; +import com.bsd.pjcloud.hard.api.vo.order.HardOrderSumAndCountVo; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** 部门报表在数据库还原支付历史部门,只将部门级聚合结果返回应用。 */ +@Mapper +public interface HardDepartmentStatisticsMapper { + List paymentStatistics(@Param("dto") HardOrderSumAndCountDto dto, + @Param("tenantId") Long tenantId); +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCarService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCarService.java index 5255951d8..796591c25 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCarService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCarService.java @@ -11,6 +11,7 @@ import com.bsd.pjcloud.hard.api.entity.order.HardCarEntity; import com.bsd.pjcloud.hard.api.entity.order.HardParentCarEntity; import com.bsd.pjcloud.hard.api.vo.order.HardCarVo; import com.bsd.pjcloud.hard.api.vo.order.HardCreateCardComputeVo; +import com.bsd.pjcloud.hard.service.order.HardOrderCartSnapshot; import com.bsd.pjcloud.thirdparty.dto.pay.PayDto; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; @@ -49,6 +50,21 @@ public interface HardCarService extends IService { */ HardCreateCardComputeVo createCardWithCompute(Long userId, Long merchantId, HardTypeEnum sysType, HardOrderTypeEnum orderType, List list); + /** + * 只读准备食堂、超市收银商品快照,不开启写事务;其他业态返回 null 以沿用原有计价方式。 + */ + HardOrderCartSnapshot prepareCardWithCompute(HardTypeEnum sysType, HardOrderTypeEnum orderType, List list); + + /** + * 按原购物车规则查询商品并组装未落库明细,供旧业态在事务外准备使用。 + */ + List prepareCard(HardTypeEnum sysType, HardOrderTypeEnum orderType, List list); + + /** + * 在调用方写事务内保存父购物车和已准备的明细,不再查询商品或重新计价。 + */ + Long createCardFromSnapshot(Long userId, Long merchantId, HardTypeEnum sysType, HardOrderCartSnapshot snapshot); + /** * 创建第三方支付购物车 */ @@ -58,4 +74,4 @@ public interface HardCarService extends IService { * 生成购买商品的描述 */ String computerCarSubject(HardTypeEnum sysType, HardOrderTypeEnum orderType, List list); -} \ No newline at end of file +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java index e7470af96..92f1b02bc 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java @@ -26,6 +26,7 @@ import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity; import com.bsd.pjcloud.hard.api.vo.order.HardCarVo; import com.bsd.pjcloud.hard.api.vo.order.HardCreateHardQrCodeScanOrderVo; import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo; +import com.bsd.pjcloud.hard.api.vo.order.HardOrderByAndroidCodeVo; import com.bsd.pjcloud.thirdparty.dto.ks.KsRecognitionCallbackDto; import com.bsd.pjcloud.wallet.api.vo.MchManageVo; import jakarta.validation.Valid; @@ -65,6 +66,9 @@ public interface HardOrderService extends IService { */ Long createOrder(HardCreateOrderDto dto); + /** 按当前设备的本地订单号读取最小对账快照,查无订单返回 null。 */ + HardOrderByAndroidCodeVo getOrderByAndroidCode(String androidOrderCode, Long hardId); + /** * 创建设备二维码扫一扫订单 */ @@ -185,4 +189,4 @@ public interface HardOrderService extends IService { * 是否需要支付 */ boolean isPay(HardOrderTypeEnum type, HardPayTypeEnum payType); -} \ No newline at end of file +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardBatchExportService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardBatchExportService.java index 6382a7739..87525e8de 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardBatchExportService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardBatchExportService.java @@ -131,7 +131,7 @@ public class HardBatchExportService { row -> departmentRow(row, values, sums), null, file -> { if (reporter != null) reporter.advance("文件已生成,正在保存导出结果"); return consumer.consume(file); - }, workbook -> { }); + }, workbook -> workbook.getSheetAt(0).getRow(1).getCell(16).setCellValue("消费净金额")); } /** 按原有年度、商户筛选取商户编号,再以每五家为一组读取月份数据。 */ @@ -312,69 +312,29 @@ public class HardBatchExportService { return BeanUtil.beanToMap(v); } - /** 沿用旧 exportDept 的字段、数值类型及合计规则。 */ + /** 保留部门模板字段;在独立导出 Map 中转换金额,完整累计全部金额和次数。 */ private Map departmentRow(HardDeptTotalVo v, Map values, BigDecimal[] sums) { - // 批量充值 - if (ObjectUtil.isNotEmpty(v.getWalletPrice())) { - sums[0] = sums[0].add(v.getWalletPrice()); - v.setWalletPrice(v.getWalletPrice().divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP)); + Map row = BeanUtil.beanToMap(v); + String[] fields = { + "walletPrice", "walletCount", "walletReducePrice", "walletReduceCount", + "walletOncePrice", "walletOnceCount", "walletOnceReducePrice", "walletOnceReduceCount", + "orderPayPrice", "orderPayCount", "orderRefundPrice", "orderRefundCont", + "orderProfitPrice", "orderProfitCont", "walletReturnCanteenPrice", "walletReturnCanteenCount" + }; + for (int index = 0; index < fields.length; index++) { + String field = fields[index]; + Object raw = row.get(field); + BigDecimal value = raw == null ? BigDecimal.ZERO : new BigDecimal(raw.toString()); + sums[index] = sums[index].add(value); + if (index % 2 == 0) { + // walletReducePrice 在接口 VO 中为 Long 分值,直接改写 VO 会截断分转元后的小数。 + row.put(field, value.divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP)); + values.put(field + "Sum", sums[index].divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toPlainString()); + } else { + row.put(field, value.longValueExact()); + values.put(field + "Sum", sums[index].toPlainString()); + } } - // 批量扣减 - if (ObjectUtil.isNotEmpty(v.getWalletReducePrice())) { - sums[2] = sums[2].add(BigDecimal.valueOf(v.getWalletReducePrice())); - v.setWalletReducePrice(BigDecimal.valueOf(v.getWalletReducePrice()).divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).longValue()); - } - // 单次充值 - if (ObjectUtil.isNotEmpty(v.getWalletOncePrice())) { - sums[4] = sums[4].add(v.getWalletOncePrice()); - v.setWalletOncePrice(v.getWalletOncePrice().divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP)); - } - // 单次扣减 - if (ObjectUtil.isNotEmpty(v.getWalletOnceReducePrice())) { - sums[6] = sums[6].add(v.getWalletOnceReducePrice()); - v.setWalletOnceReducePrice(v.getWalletOnceReducePrice().divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP)); - } - // 消费金额 - if (ObjectUtil.isNotEmpty(v.getOrderPayPrice())) { - sums[8] = sums[8].add(v.getOrderPayPrice()); - v.setOrderPayPrice(v.getOrderPayPrice().divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP)); - } - // - if (ObjectUtil.isNotEmpty(v.getOrderRefundPrice())) { - sums[10] = sums[10].add(v.getOrderRefundPrice()); - v.setOrderRefundPrice(v.getOrderRefundPrice().divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP)); - } - // - if (ObjectUtil.isNotEmpty(v.getOrderProfitPrice())) { - sums[12] = sums[12].add(v.getOrderProfitPrice()); - v.setOrderProfitPrice(v.getOrderProfitPrice().divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP)); - } - // 退还各自食堂金额 - if (ObjectUtil.isNotEmpty(v.getWalletReturnCanteenPrice())) { - sums[14] = sums[14].add(v.getWalletReturnCanteenPrice()); - v.setWalletReturnCanteenPrice(v.getWalletReturnCanteenPrice().divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP)); - } - // 批量充值 - values.put("walletPriceSum", sums[0].divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString()); - values.put("walletCountSum", sums[1].toString()); - // 批量扣减 - values.put("walletReducePriceSum", sums[2].divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString()); - values.put("walletReduceCountSum", sums[3].toString()); - // 单次充值 - values.put("walletOncePriceSum", sums[4].divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString()); - values.put("walletOnceCountSum", sums[5].toString()); - // 单次扣减 - values.put("walletOnceReducePriceSum", sums[6].divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString()); - values.put("walletOnceReduceCountSum", sums[7].toString()); - values.put("orderPayPriceSum", sums[8].divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString()); - values.put("orderPayCountSum", sums[9].toString()); - values.put("orderRefundPriceSum", sums[10].divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString()); - values.put("orderRefundContSum", sums[11].toString()); - values.put("orderProfitPriceSum", sums[12].divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString()); - values.put("orderProfitContSum", sums[13].toString()); - // 退还各自食堂金额 - values.put("walletReturnCanteenPriceSum", sums[14].divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString()); - values.put("walletReturnCanteenCountSum", sums[15].toString()); - return BeanUtil.beanToMap(v); + return row; } } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCarServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCarServiceImpl.java index 112818622..414d719ed 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCarServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCarServiceImpl.java @@ -30,6 +30,7 @@ import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper; import com.bsd.pjcloud.hard.mapper.order.HardParentCarMapper; import com.bsd.pjcloud.hard.service.HardCarService; import com.bsd.pjcloud.hard.service.HardSpuService; +import com.bsd.pjcloud.hard.service.order.HardOrderCartSnapshot; import com.bsd.pjcloud.supermarket.api.feign.RemoteMallProductService; import com.bsd.pjcloud.supermarket.api.feign.RemoteShopSkuService; import com.bsd.pjcloud.supermarket.api.feign.RemoteShopSpuService; @@ -296,6 +297,17 @@ public class HardCarServiceImpl extends ServiceImpl list) { + HardOrderCartSnapshot snapshot = prepareCardWithCompute(sysType, orderType, list); + if (snapshot == null) return null; + Long parentCarId = createCardFromSnapshot(userId, merchantId, sysType, snapshot); + return HardCreateCardComputeVo.builder().parentCarId(parentCarId).totalPrice(snapshot.totalPrice()).subject(snapshot.subject()).build(); + } + + /** + * 收银商品仅查询一次,准备过程不落库,避免远程商品查询占用订单写事务。 + */ + @Override + public HardOrderCartSnapshot prepareCardWithCompute(HardTypeEnum sysType, HardOrderTypeEnum orderType, List list) { List productIds = list.stream().map(HardCreateCarDto::getProductId).toList(); List carObjectList = new ArrayList<>(); StringBuffer subject = new StringBuffer(); @@ -363,12 +375,23 @@ public class HardCarServiceImpl extends ServiceImpl item.setParentCarId(parentCarObj.getId())); - baseMapper.insert(carObjectList); - return HardCreateCardComputeVo.builder().parentCarId(parentCarObj.getId()).totalPrice(totalPrice.get()).subject(subject.toString()).build(); + if (carObjectList.size() != list.size()) { + throw new RuntimeException("部分商品不存在,请刷新商品后重新结算"); + } + return new HardOrderCartSnapshot(totalPrice.get(), subject.toString(), carObjectList); + } + + /** + * 保存与计价完全一致的商品快照;调用方事务同时包含父购物车和全部明细。 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Long createCardFromSnapshot(Long userId, Long merchantId, HardTypeEnum sysType, HardOrderCartSnapshot snapshot) { + HardParentCarEntity parent = HardParentCarEntity.builder().userId(userId).merchantId(merchantId).hardType(sysType.getType()).orderStatus(HardOrderStatusEnum.PAY.getType()).build(); + hardParentCarMapper.insert(parent); + snapshot.items().forEach(item -> item.setParentCarId(parent.getId())); + if (!snapshot.items().isEmpty()) baseMapper.insert(snapshot.items()); + return parent.getId(); } private String buildIsPriceIncreased(HardCreateCarDto v) { @@ -394,8 +417,21 @@ public class HardCarServiceImpl extends ServiceImpl productList) { + List items = prepareCard(sysType, orderType, productList); + items.forEach(item -> item.setParentCarId(parentCarId)); + if (!items.isEmpty()) baseMapper.insert(items); + } + + /** + * 原购物车各业态的只读组装逻辑;返回值未绑定父购物车,不产生数据库写入。 + */ + @Override + public List prepareCard(HardTypeEnum sysType, HardOrderTypeEnum orderType, List productList) { List productIds = productList.stream().map(HardCreateCarDto::getProductId).toList(); // 食堂 if (ObjectUtil.equals(sysType, HardTypeEnum.CANTEEN)) { @@ -404,7 +440,7 @@ public class HardCarServiceImpl extends ServiceImpl supList = onlineCanteenSpuService.getSpuListByIds(productIds).getData(); for (HardCreateCarDto v : productList) { - HardCarEntity item = HardCarEntity.builder().parentCarId(parentCarId).build(); + HardCarEntity item = HardCarEntity.builder().build(); supList.stream().filter(i -> ObjectUtil.equals(i.getId(), v.getProductId())).findFirst().ifPresent(spu -> { int totalPrice = v.getProductCount() * spu.getPrice(); String isPriceIncreased = ObjectUtil.isNotEmpty(v.getIsPriceIncreased()) && v.getIsPriceIncreased() ? YesNoEnum.YES.getCode() : YesNoEnum.NO.getCode(); @@ -428,7 +464,7 @@ public class HardCarServiceImpl extends ServiceImpl supList = canteenSpuService.getSpuListByIds(productIds).getData(); for (HardCreateCarDto v : productList) { - HardCarEntity item = HardCarEntity.builder().parentCarId(parentCarId).build(); + HardCarEntity item = HardCarEntity.builder().build(); supList.stream().filter(i -> ObjectUtil.equals(i.getId(), v.getProductId())).findFirst().ifPresent(spu -> { int totalPrice = v.getProductCount() * spu.getPrice(); String isPriceIncreased = ObjectUtil.isNotEmpty(v.getIsPriceIncreased()) && v.getIsPriceIncreased() ? YesNoEnum.YES.getCode() : YesNoEnum.NO.getCode(); @@ -448,8 +484,7 @@ public class HardCarServiceImpl extends ServiceImpl skuList = remoteShopSkuService.getSelectListAll(productIds).getData(); List spuList = remoteShopSpuService.getShopSpuDetailByIds(skuList.stream().map(ShopSkuEntity::getSpuId).toList()).getData(); for (HardCreateCarDto v : productList) { - HardCarEntity item = HardCarEntity.builder().parentCarId(parentCarId).build(); + HardCarEntity item = HardCarEntity.builder().build(); skuList.stream().filter(i -> ObjectUtil.equals(i.getId(), v.getProductId())).findFirst().ifPresent(sku -> { Integer price = getSupermarket(sku); int totalPrice = 0; @@ -487,7 +522,7 @@ public class HardCarServiceImpl extends ServiceImpl spuList = supermarketUnitSkuService.getSupermarketUnits(productIds).getData(); for (HardCreateCarDto v : productList) { - HardCarEntity item = HardCarEntity.builder().parentCarId(parentCarId).build(); + HardCarEntity item = HardCarEntity.builder().build(); spuList.stream().filter(i -> ObjectUtil.equals(i.getId(), v.getProductId())).findFirst().ifPresent(spu -> { Integer price = getSupermarket(spu); int totalPrice = 0; @@ -515,15 +550,14 @@ public class HardCarServiceImpl extends ServiceImpl carObjectList = new ArrayList<>(); List supList = hardSpuService.getByIds(productIds); for (HardCreateCarDto v : productList) { - HardCarEntity item = HardCarEntity.builder().parentCarId(parentCarId).build(); + HardCarEntity item = HardCarEntity.builder().build(); supList.stream().filter(i -> ObjectUtil.equals(i.getId(), v.getProductId())).findFirst().ifPresent(spu -> { Integer price = spu.getPrice(); int totalPrice = 0; @@ -551,15 +585,14 @@ public class HardCarServiceImpl extends ServiceImpl carObjectList = new ArrayList<>(); List spuList = remoteMallProductService.getSkusWithSpuBySkuIds(productIds).getData(); for (HardCreateCarDto v : productList) { - HardCarEntity item = HardCarEntity.builder().parentCarId(parentCarId).build(); + HardCarEntity item = HardCarEntity.builder().build(); spu: for (MallProductDetailAppVo spu : spuList) { if (ObjectUtil.isEmpty(spu) || ObjectUtil.isEmpty(spu.getSkuList())) { @@ -600,9 +633,9 @@ public class HardCarServiceImpl extends ServiceImpl walletResult = remoteWalletService.getWalletById(dto.getWalletId()); - wallet = Optional.of(walletResult).map(R::getData).orElseThrow(() -> new RuntimeException("用户钱包不存在")); + wallet = requireCreateOrderData(walletResult, "用户钱包不存在"); } else { if (ObjectUtil.isEmpty(mch.getDefaultWalletTypeId())) { if (dto.getSysType().equals(HardTypeEnum.CANTEEN)) { R walletResult = remoteWalletService.getWallet(dto.getUserId(), WalletTypeEnum.EAT); - wallet = Optional.of(walletResult).map(R::getData).orElseThrow(() -> new RuntimeException("用户钱包不存在")); + wallet = requireCreateOrderData(walletResult, "用户钱包不存在"); } else { R walletResult = remoteWalletService.getWallet(dto.getUserId(), WalletTypeEnum.PAY); - wallet = Optional.of(walletResult).map(R::getData).orElseThrow(() -> new RuntimeException("用户钱包不存在")); + wallet = requireCreateOrderData(walletResult, "用户钱包不存在"); } } else { R walletResult = remoteWalletService.getWalletByWalletTypeId(dto.getUserId(), mch.getDefaultWalletTypeId()); - wallet = Optional.of(walletResult).map(R::getData).orElseThrow(() -> new RuntimeException("用户钱包不存在")); + wallet = requireCreateOrderData(walletResult, "用户钱包不存在"); } } if (ObjectUtil.isEmpty(wallet)) { @@ -321,32 +367,39 @@ public class HardOrderServiceImpl extends ServiceImpl= price 的更新原子判断。 + if (wallet.getBalance() == null || wallet.getBalance() < payPrice) throw new RuntimeException("钱包余额不足"); } + return new CreateOrderContext(mch, userDetail, sysUser, wallet, cart, extraPrice, isWalletPay, isOffline, isVerify, isWeChatPay, isAliPay); + } + + /** + * 写事务仅保存商品快照、订单以及必要的库存/扣款/核销副作用;不重新远程查询商品。 + * 必须通过 Spring 代理调用,确保购物车与订单使用同一个事务提交或回滚。 + */ + @Transactional(rollbackFor = Exception.class) + public Long createOrderInTransaction(HardCreateOrderDto dto, CreateOrderContext context) { + MchManageVo mch = context.mch; + UserInfo userDetail = context.userDetail; + SysUser sysUser = context.sysUser; + WalletManageEntity wallet = context.wallet; + boolean isWalletPay = context.walletPay, isOffline = context.offline, isVerify = context.verify; + boolean isWeChatPay = context.weChatPay, isAliPay = context.aliPay; + int totalPrice = context.cart.totalPrice(), extraPrice = context.extraPrice; + int payPrice = Math.addExact(totalPrice, extraPrice); + String subject = context.cart.subject(); + Long parentCarId = hardCarService.createCardFromSnapshot(dto.getUserId(), dto.getMerchantId(), dto.getSysType(), context.cart); // 添加硬件订单 HardOrderEntity orderObj = HardOrderEntity.builder() .androidOrderCode(dto.getAndroidOrderCode()) @@ -384,7 +437,19 @@ public class HardOrderServiceImpl extends ServiceImpl() + .eq(HardOrderEntity::getAndroidOrderCode, androidOrderCode) + .eq(HardOrderEntity::getHardId, hardId) + .orderByDesc(HardOrderEntity::getId).last("limit 1")); + } + + /** 按本设备范围对账,不触发远程用户、商户查询或任何支付副作用。 */ + @Override + public HardOrderByAndroidCodeVo getOrderByAndroidCode(String androidOrderCode, Long hardId) { + if (androidOrderCode == null || androidOrderCode.isBlank() || hardId == null) return null; + HardOrderEntity order = findTerminalOrder(androidOrderCode, hardId); + if (order == null) return null; + return HardOrderByAndroidCodeVo.builder().orderId(order.getId()).orderStatus(order.getOrderStatus()) + .payType(order.getPayType()).userId(order.getUserId()).totalPrice(order.getTotalPrice()) + .extraPrice(order.getExtraPrice()).payPrice(order.getPayPrice()).build(); + } + + /** + * 对比已落库的请求内容,而非重新查询当前商品价格;改价、余额扣空、核销码已使用均不影响重试。 + * 退款和其他未确认状态禁止用同号重新扣款,调用方需使用新的本地订单号重新结算。 + */ + private void validateReusableOrder(HardOrderEntity order, HardCreateOrderDto dto) { + boolean userRequired = isPay(dto.getOrderType(), dto.getOrderPayType()) || isVerify(dto.getOrderPayType()); + int extraPrice = isExtraPrice(dto.getSysType()) && dto.getExtraPrice() != null ? dto.getExtraPrice() : 0; + if (!Objects.equals(order.getMerchantId(), dto.getMerchantId()) + || !Objects.equals(order.getSysType(), dto.getSysType().getType()) + || !Objects.equals(order.getOrderType(), dto.getOrderType().getType()) + || !Objects.equals(order.getPayType(), dto.getOrderPayType().getType()) + || !Objects.equals(order.getHourType(), String.valueOf(dto.getHourType())) + || !Objects.equals(order.getOrderSourceTypeId(), dto.getOrderSourceTypeId()) + || !Objects.equals(order.getExtraPrice(), extraPrice) + || (userRequired && !Objects.equals(order.getUserId(), dto.getUserId())) + || (contextUsesSelectedWallet(dto) && !Objects.equals(order.getWalletId(), dto.getWalletId()))) { + throw new RuntimeException("该终端订单号已存在不同内容的订单,请重新结算"); + } + List savedItems = hardCarService.getByParentCarId(order.getParentCarId()); + if (savedItems == null) savedItems = List.of(); + Map saved = new HashMap<>(), requested = new HashMap<>(); + for (HardCarEntity item : savedItems) { + saved.merge(terminalCartLine(item.getProductId(), item.getProductCount(), item.getProductWeight(), YesNoEnum.YES.getCode().equals(item.getIsDiscountsProduct())), 1, Integer::sum); + } + for (HardCreateCarDto item : dto.getList()) { + BigDecimal weight = dto.getSysType() == HardTypeEnum.CANTEEN ? BigDecimal.ZERO : item.getProductWeight(); + requested.merge(terminalCartLine(item.getProductId(), item.getProductCount(), weight, Boolean.TRUE.equals(item.getIsPriceIncreased())), 1, Integer::sum); + } + // 旧食堂票券核销链路可能只有父购物车,没有商品明细;该场景使用前面已经比对的 + // 人员、支付类型和不可变核销来源号识别原结果,不能因明细缺失重新核销一次。 + boolean legacyVerification = saved.isEmpty() && isVerify(dto.getOrderPayType()); + if (!saved.equals(requested) && !legacyVerification) throw new RuntimeException("该终端订单号已存在不同商品的订单,请重新结算"); + if (!Objects.equals(order.getOrderStatus(), HardOrderStatusEnum.PAY.getType()) + && !Objects.equals(order.getOrderStatus(), HardOrderStatusEnum.CANCEL.getType())) { + throw new RuntimeException("该终端订单号已有订单,请先核对支付或退款结果"); + } + } + + /** 当前请求是否实际使用显式选择的钱包;食堂仍按 PJ 原有商户默认钱包规则执行。 */ + private boolean contextUsesSelectedWallet(HardCreateOrderDto dto) { + return dto.getSysType() == HardTypeEnum.SUPERMARKET && dto.getWalletId() != null + && isPay(dto.getOrderType(), dto.getOrderPayType()); + } + + /** 统一数量空值及称重精度,保留重复商品行的数量以识别购物车内容变化。 */ + private TerminalCartLine terminalCartLine(Long productId, Integer count, BigDecimal weight, boolean increased) { + return new TerminalCartLine(productId, count == null ? 0 : count, weight == null ? BigDecimal.ZERO : weight.stripTrailingZeros(), increased); + } + + /** + * 商品幂等比较键,不包含会随后台改价变化的价格和名称。 + * @param productId 商品主键 + * @param count 商品件数,缺省为零 + * @param weight 重量,单位公斤,忽略小数尾零 + * @param increased 是否沿用终端加价商品标记 + */ + private record TerminalCartLine(Long productId, int count, BigDecimal weight, boolean increased) { + } + + /** + * 事务外准备的单次请求上下文,不在请求间共享。 + * @param mch 已校验商户 + * @param userDetail 支付或核销人员,现金单允许为空 + * @param sysUser 人员主档快照,现金单允许为空 + * @param wallet 选定钱包快照,余额仅用于预检 + * @param cart 商品价格和待保存明细快照 + * @param extraPrice PJ 既有额外餐费,单位分 + * @param walletPay 是否需要钱包扣款 + * @param offline 是否为现金等线下渠道 + * @param verify 是否需要核销二维码 + * @param weChatPay 是否微信付款码支付 + * @param aliPay 是否支付宝付款码支付 + */ + record CreateOrderContext(MchManageVo mch, UserInfo userDetail, SysUser sysUser, WalletManageEntity wallet, + HardOrderCartSnapshot cart, int extraPrice, boolean walletPay, boolean offline, + boolean verify, boolean weChatPay, boolean aliPay) { + } + + /** 检查远程统一响应的业务码与空数据,失败时不进入写事务。 */ + private T requireCreateOrderData(R response, String message) { + if (response == null || response.getCode() != 0 || response.getData() == null) throw new RuntimeException(message); + return response.getData(); + } + + /** 钱包 SQL 明确拒绝余额不足,与远程超时等资金结果未知的异常区分。 */ + private static final class InsufficientWalletBalanceException extends RuntimeException { + /** 保留旧终端可展示的余额不足业务文案。 */ + private InsufficientWalletBalanceException() { + super("钱包余额不足"); + } + } + /** * 仅为人脸支付订单在主事务提交后登记证据占位,证据库异常不回滚已成功的订单和资金结果。 * @@ -1427,6 +1603,14 @@ public class HardOrderServiceImpl extends ServiceImpl reduceRes = walletTransactionService.reduceWalletBalance(reduceBuild); - if (reduceRes.getCode() != 0 || ObjectUtil.isEmpty(reduceRes.getData())) { + reduceBuild.setForce(force); + R reduceRes; + try { + reduceRes = walletTransactionService.reduceWalletBalance(reduceBuild); + } catch (FeignException rejected) { + // 钱包的全局异常处理器把余额不足包装成 HTTP 500 + R;Feign 会直接抛异常, + // 必须只识别这个明确业务结果,不能把一般 500 或网关超时当作扣款未发生。 + if (isInsufficientWalletBalance(rejected)) throw new InsufficientWalletBalanceException(); + throw rejected; + } + if (reduceRes == null || reduceRes.getCode() != 0 || ObjectUtil.isEmpty(reduceRes.getData())) { + if (reduceRes != null && reduceRes.getCode() != 0 && "钱包余额不足".equals(reduceRes.getMsg())) { + throw new InsufficientWalletBalanceException(); + } throw new RuntimeException("钱包扣款错误"); } } @@ -1456,6 +1651,16 @@ public class HardOrderServiceImpl extends ServiceImpl response = JSON.parseObject(exception.contentUTF8(), R.class); + return response != null && response.getCode() != 0 && "钱包余额不足".equals(response.getMsg()); + } catch (Exception malformedResponse) { + return false; + } + } + /** * 订单成功 */ @@ -2035,17 +2240,14 @@ public class HardOrderServiceImpl extends ServiceImpl mch = remoteMchService.getById(dto.getMerchantId()); - if (ObjectUtil.isEmpty(mch.getData())) { - throw new RuntimeException("商户不存在"); - } + MchManageVo mch = requireCreateOrderData(remoteMchService.getById(dto.getMerchantId()), "商户不存在"); // 此处只取 deptId,按主键直查即可;getByHardId 还会联查部门和商户两次远程调用 HardManagerEntity hard = hardManagerService.getById(dto.getHardwareId()); if (ObjectUtil.isEmpty(hard)) { throw new RuntimeException("设备不存在"); } dto.setDeptId(hard.getDeptId()); - return mch.getData(); + return mch; } // 是否有二维码 diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderTotalServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderTotalServiceImpl.java index cb78e49b3..dabea1caa 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderTotalServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderTotalServiceImpl.java @@ -2,7 +2,6 @@ package com.bsd.pjcloud.hard.service.impl.order; import com.bsd.pjcloud.common.asynctask.service.AsyncTaskProgressReporter; -import com.bsd.pjcloud.common.asynctask.service.BatchExportSupport; import com.bsd.pjcloud.common.excel.stream.BatchExcelExportTemplate; import java.util.function.Function; import java.util.LinkedHashMap; @@ -27,6 +26,7 @@ import com.bsd.pjcloud.common.core.util.CollectionUtils; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.data.datascope.DataScope; import com.bsd.pjcloud.common.data.datascope.DataScopeFuncEnum; +import com.bsd.pjcloud.common.data.tenant.TenantContextHolder; import com.bsd.pjcloud.common.security.service.PjcloudUser; import com.bsd.pjcloud.common.security.util.SecurityUtils; import com.bsd.pjcloud.hard.api.constant.HardTypeEnum; @@ -41,24 +41,22 @@ import com.bsd.pjcloud.hard.api.utils.HardUtil; import com.bsd.pjcloud.hard.api.vo.order.*; import com.bsd.pjcloud.hard.mapper.manager.HardManagerMapper; import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper; -import com.bsd.pjcloud.hard.mapper.order.model.HardOrderDepartmentStatisticsDetail; +import com.bsd.pjcloud.hard.mapper.order.HardDepartmentStatisticsMapper; import com.bsd.pjcloud.hard.service.HardManagerService; import com.bsd.pjcloud.hard.service.HardOrderTotalService; import com.bsd.pjcloud.hard.utils.RemoteBatchQueryUtil; -import com.bsd.pjcloud.wallet.api.constant.WalletBizEnum; -import com.bsd.pjcloud.wallet.api.dto.WalletTransactionDepartmentSnapshotBatchDto; -import com.bsd.pjcloud.wallet.api.dto.WalletTransactionDepartmentSnapshotDto; import com.bsd.pjcloud.wallet.api.dto.WalletTransactionSumAndCountDto; import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; import com.bsd.pjcloud.wallet.api.feign.RemoteWalletTransactionService; import com.bsd.pjcloud.wallet.api.utils.WalletDataUtil; import com.bsd.pjcloud.wallet.api.vo.MchManageVo; -import com.bsd.pjcloud.wallet.api.vo.WalletTransactionDepartmentSnapshotVo; +import com.bsd.pjcloud.wallet.api.vo.WalletDepartmentStatisticsVo; import com.bsd.pjcloud.wallet.api.vo.WalletTransactionSumAndCountVo; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import org.springframework.util.StopWatch; import org.springframework.validation.annotation.Validated; import java.io.ByteArrayOutputStream; @@ -88,6 +86,7 @@ public class HardOrderTotalServiceImpl implements HardOrderTotalService { private final RemoteMchService remoteMchService; private final RemoteWalletTransactionService remoteWalletTransactionService; private final RemoteDeptService remoteDeptService; + private final HardDepartmentStatisticsMapper departmentStatisticsMapper; // private final DelegatingSecurityContextExecutor securityContextExecutor; @@ -233,19 +232,22 @@ public class HardOrderTotalServiceImpl implements HardOrderTotalService { } private List listCsInternal(HardOrderDto dto, boolean exporting, AsyncTaskProgressReporter reporter) { + StopWatch timing = new StopWatch("hard-total/page-list-cs"); + timing.start("merchantTypePermission"); PjcloudUser user = SecurityUtils.getUser(); if (ObjectUtil.isEmpty(dto.getMchTypeCodes())) { CurrentUserHardTypeInfoDto hardTypeInfo = hardManagerService.getCurrentUserHardTypeInfo(user); dto.setMchTypeCodes(hardTypeInfo.getHardTypeList()); - } else { - dto.setMchTypeCodes(dto.getMchTypeCodes()); } + timing.stop(); // 流水与退款的业务发生时间不同,必须分别查询后再按商户合并,避免直接关联造成一笔订单被多笔售后重复放大。 HardOrderDto totalDto = BeanUtil.toBean(dto, HardOrderDto.class); totalDto.setOrderStatusCodes(HardOrderStatusEnum.getPayPrice()); DataScope totalDataScope = DataScope.of(); totalDataScope.setScopeDeptName("merchant_dept_id"); + timing.start("paymentQueryWithDataScope"); List totalList = exporting ? queryCsExportSlices(totalDto, false, totalDataScope, reporter) : hardOrderMapper.listCs(totalDto, totalDataScope); + timing.stop(); // 扣款总流水 HardOrderDto refundDto = BeanUtil.toBean(dto, HardOrderDto.class); refundDto.setPayTimeList(null); @@ -256,27 +258,27 @@ public class HardOrderTotalServiceImpl implements HardOrderTotalService { refundDto.setRefundStatusCodes(List.of(HardSaleStatusEnum.COMPLETE.getStatus())); DataScope refundDataScope = DataScope.of(); refundDataScope.setScopeDeptName("merchant_dept_id"); + timing.start("refundQueryWithDataScope"); List refundList = exporting ? queryCsExportSlices(refundDto, true, refundDataScope, reporter) : hardOrderMapper.listRefundCs(refundDto, refundDataScope); - // 有退款没有流水的情况 - if (ObjectUtil.isAllNotEmpty(totalList, refundList)) { - List totalIds = totalList.stream().map(HardOrderCsVo::getMerchantId).toList(); - for (HardOrderCsVo refund : refundList) { - if (!totalIds.contains(refund.getMerchantId())) { - HardOrderCsVo entity = BeanUtils.toBean(refund, HardOrderCsVo.class); - entity.setTotalPayPrice(BigDecimal.ZERO); - entity.setTotalPayCount(BigDecimal.ZERO); - totalList.add(entity); - } - } + timing.stop(); + timing.start("merge"); + // 保留支付查询的商户顺序,退款用 Map 线性合并;整个区间只有退款时也必须显示。 + Map totalsByMerchant = new LinkedHashMap<>(); + for (HardOrderCsVo total : totalList) { + totalsByMerchant.put(total.getMerchantId(), total); } + for (HardOrderCsVo refund : refundList) { + HardOrderCsVo total = totalsByMerchant.computeIfAbsent(refund.getMerchantId(), id -> { + HardOrderCsVo row = BeanUtils.toBean(refund, HardOrderCsVo.class); + row.setTotalPayPrice(BigDecimal.ZERO); + row.setTotalPayCount(BigDecimal.ZERO); + return row; + }); + total.setTotalRefundPrice(refund.getTotalPayPrice()); + total.setTotalRefundCount(refund.getTotalPayCount()); + } + totalList = new ArrayList<>(totalsByMerchant.values()); for (HardOrderCsVo v : totalList) { - // 退款 - if (ObjectUtil.isNotEmpty(refundList)) { - refundList.stream().filter(i -> ObjectUtil.equals(i.getMerchantId(), v.getMerchantId())).findFirst().ifPresent(i -> { - v.setTotalRefundPrice(i.getTotalPayPrice()); - v.setTotalRefundCount(i.getTotalPayCount()); - }); - } // 消费净金额 if (ObjectUtil.isNotEmpty(v.getTotalPayPrice())) { BigDecimal refundPrice = BigDecimal.ZERO; @@ -294,8 +296,10 @@ public class HardOrderTotalServiceImpl implements HardOrderTotalService { v.setTotalProfitCount(v.getTotalPayCount().subtract(refundCount)); } } + timing.stop(); // 聚合一下数据 if (ObjectUtil.isNotEmpty(totalList)) { + timing.start("merchantMetadataRpc"); // 商户 List mchIds = totalList.stream().map(HardOrderCsVo::getMerchantId).distinct().toList(); if (ObjectUtil.isNotEmpty(mchIds)) { @@ -305,6 +309,8 @@ public class HardOrderTotalServiceImpl implements HardOrderTotalService { computeMchPlatformRanke(totalList, mchList); } } + timing.stop(); + timing.start("departmentMetadataRpc"); // 商户部门 List mchDeptIds = totalList.stream().map(HardOrderCsVo::getMerchantDeptId).distinct().toList(); if (ObjectUtil.isNotEmpty(mchDeptIds)) { @@ -319,6 +325,10 @@ public class HardOrderTotalServiceImpl implements HardOrderTotalService { } } } + timing.stop(); + } + if (!exporting && timing.getTotalTimeMillis() >= 1000) { + log.info("商户汇总慢查询,商户数={},总耗时={}ms,分段耗时:{}", totalList.size(), timing.getTotalTimeMillis(), timing.prettyPrint()); } return totalList; } @@ -538,99 +548,79 @@ public class HardOrderTotalServiceImpl implements HardOrderTotalService { /** * 部门交易记录 * - *

钱包操作与退款金额直接按 wallet_transaction 流水汇总;订单支付先取逐笔明细, - * 再按支付流水创建时固化的部门重新归集,现金、第三方支付回退订单部门快照。 + *

钱包操作与退款金额由一次 Wallet 聚合返回;订单支付在数据库按支付流水创建时固化的部门归集, + * 现金、第三方支付回退订单部门快照。应用内只保留部门级结果。 * 退款金额保留 REFUND 流水原始正负号,审核中的退款尚未生成流水,不会计入任何金额。

* - * @param dto 当前用户权限范围内的部门、时间和钱包类型筛选条件 + * @param dto 当前租户的部门、时间和钱包类型筛选条件 * @return 部门交易统计;筛选范围内没有部门时返回空列表 * @throws IllegalStateException 部门或钱包远程统计调用失败时抛出,避免返回不完整报表 */ @Override public List pageListDept(HardDeptTotalDto dto) { - return departmentRows(dto, false, null); + return departmentRows(dto, null); } - /** 导出专用历史部门归集,原始订单最多在内存保留一个批次。 */ + /** 导出和页面共用部门级聚合,原始订单与历史快照不跨服务传输。 */ public List listDeptForExport(HardDeptTotalDto dto, AsyncTaskProgressReporter reporter) { - return departmentRows(dto, true, reporter); + return departmentRows(dto, reporter); } - private List departmentRows(HardDeptTotalDto dto, boolean exporting, AsyncTaskProgressReporter reporter) { + private List departmentRows(HardDeptTotalDto dto, AsyncTaskProgressReporter reporter) { + Long tenantId = TenantContextHolder.getTenantId(); + if (tenantId == null) throw new IllegalStateException("查询部门交易统计缺少租户上下文"); + StopWatch timing = new StopWatch("hard-total/page-list-dept"); + if (reporter != null) reporter.updateTotal(6, "正在汇总部门交易"); + timing.start("departmentRpc"); List deptList = queryDepartmentList(dto); - if (ObjectUtil.isEmpty(deptList)) { - return List.of(); - } + timing.stop(); + if (reporter != null) reporter.advance("部门范围已确认"); + if (ObjectUtil.isEmpty(deptList)) return List.of(); - List walletBatchList = queryWalletStatistics( - buildWalletStatisticsDto(dto, List.of(WalletBizEnum.BATCH.getCode()), WalletTransactionSumAndCountDto.TYPE_INCOME)); - List walletReduceList = queryWalletStatistics( - buildWalletStatisticsDto(dto, List.of(WalletBizEnum.BATCH.getCode()), WalletTransactionSumAndCountDto.TYPE_EXPENSE)); - List walletReturnCanteenList = queryWalletStatistics( - buildWalletStatisticsDto(dto, List.of(WalletBizEnum.RETURN_CANTEEN.getCode()), null)); - List walletOnceBatchList = queryWalletStatistics( - buildWalletStatisticsDto(dto, List.of(WalletBizEnum.UPDATE.getCode(), WalletBizEnum.RECHARGE.getCode()), WalletTransactionSumAndCountDto.TYPE_INCOME)); - List walletOnceReduceList = queryWalletStatistics( - buildWalletStatisticsDto(dto, List.of(WalletBizEnum.UPDATE.getCode(), WalletBizEnum.RECHARGE.getCode()), WalletTransactionSumAndCountDto.TYPE_EXPENSE)); - List walletRefundList = queryWalletStatistics( - buildWalletStatisticsDto(dto, List.of(WalletBizEnum.REFUND.getCode()), null)); + timing.start("walletAggregationRpc"); + List walletRows = queryWalletDepartmentStatistics( + buildWalletStatisticsDto(dto)); + timing.stop(); + if (reporter != null) reporter.advance("钱包六类流水汇总完成"); + timing.start("paymentAggregation"); + List payRows = departmentStatisticsMapper.paymentStatistics(buildPayStatisticsDto(dto), tenantId); + timing.stop(); + if (reporter != null) reporter.advance("订单历史部门归集完成"); - List payList; - if (exporting) { - payList = queryExportPayments(dto, reporter); - } else { - List payDetails = hardOrderMapper.listPayStatisticsDetails(buildPayStatisticsDto(dto)); - Map> groups = new LinkedHashMap<>(); - groups.put(WalletBizEnum.PAYMENT.getCode(), payDetails); - Map> snapshots = queryTransactionDepartmentSnapshotMaps(groups); - payList = aggregatePaymentsByTransactionDepartment(payDetails, snapshots.getOrDefault(WalletBizEnum.PAYMENT.getCode(), Map.of()), dto); - } - - Map walletBatchMap = indexWalletStatistics(walletBatchList); - Map walletReduceMap = indexWalletStatistics(walletReduceList); - Map walletReturnCanteenMap = indexWalletStatistics(walletReturnCanteenList); - Map walletOnceBatchMap = indexWalletStatistics(walletOnceBatchList); - Map walletOnceReduceMap = indexWalletStatistics(walletOnceReduceList); - Map walletRefundMap = indexWalletStatistics(walletRefundList); - Map payMap = indexOrderStatistics(payList); - - List resultList = new ArrayList<>(deptList.size()); + timing.start("merge"); + Map walletMap = CollectionUtils.convertMap(walletRows, WalletDepartmentStatisticsVo::getDeptId); + Map payMap = indexOrderStatistics(payRows); + List result = new ArrayList<>(deptList.size()); for (SysDept dept : deptList) { - HardDeptTotalVo statistics = new HardDeptTotalVo(); - statistics.setDeptId(dept.getDeptId()); - statistics.setDeptName(dept.getName()); - - WalletTransactionSumAndCountVo walletBatch = walletBatchMap.get(dept.getDeptId()); - if (walletBatch != null) { - statistics.setWalletPrice(BigDecimal.valueOf(ObjectUtil.defaultIfNull(walletBatch.getSum(), 0L))); - statistics.setWalletCount(ObjectUtil.defaultIfNull(walletBatch.getCount(), 0L)); + HardDeptTotalVo row = new HardDeptTotalVo(); + row.setDeptId(dept.getDeptId()); + row.setDeptName(dept.getName()); + WalletDepartmentStatisticsVo wallet = walletMap.get(dept.getDeptId()); + WalletTransactionSumAndCountVo refund = null; + if (wallet != null) { + row.setWalletPrice(BigDecimal.valueOf(ObjectUtil.defaultIfNull(wallet.getBatchIncomeSum(), 0L))); + row.setWalletCount(ObjectUtil.defaultIfNull(wallet.getBatchIncomeCount(), 0L)); + row.setWalletReducePrice(Math.abs(ObjectUtil.defaultIfNull(wallet.getBatchExpenseSum(), 0L))); + row.setWalletReduceCount(ObjectUtil.defaultIfNull(wallet.getBatchExpenseCount(), 0L)); + row.setWalletOncePrice(BigDecimal.valueOf(ObjectUtil.defaultIfNull(wallet.getOnceIncomeSum(), 0L))); + row.setWalletOnceCount(ObjectUtil.defaultIfNull(wallet.getOnceIncomeCount(), 0L)); + row.setWalletOnceReducePrice(BigDecimal.valueOf(Math.abs(ObjectUtil.defaultIfNull(wallet.getOnceExpenseSum(), 0L)))); + row.setWalletOnceReduceCount(ObjectUtil.defaultIfNull(wallet.getOnceExpenseCount(), 0L)); + row.setWalletReturnCanteenPrice(BigDecimal.valueOf(Math.abs(ObjectUtil.defaultIfNull(wallet.getReturnCanteenSum(), 0L)))); + row.setWalletReturnCanteenCount(ObjectUtil.defaultIfNull(wallet.getReturnCanteenCount(), 0L)); + refund = new WalletTransactionSumAndCountVo(); + refund.setSum(wallet.getRefundSum()); + refund.setCount(wallet.getRefundCount()); } - WalletTransactionSumAndCountVo walletReduce = walletReduceMap.get(dept.getDeptId()); - if (walletReduce != null) { - statistics.setWalletReducePrice(Math.abs(ObjectUtil.defaultIfNull(walletReduce.getSum(), 0L))); - statistics.setWalletReduceCount(ObjectUtil.defaultIfNull(walletReduce.getCount(), 0L)); - } - WalletTransactionSumAndCountVo walletReturnCanteen = walletReturnCanteenMap.get(dept.getDeptId()); - if (walletReturnCanteen != null) { - statistics.setWalletReturnCanteenPrice(BigDecimal.valueOf(Math.abs( - ObjectUtil.defaultIfNull(walletReturnCanteen.getSum(), 0L)))); - statistics.setWalletReturnCanteenCount(ObjectUtil.defaultIfNull(walletReturnCanteen.getCount(), 0L)); - } - WalletTransactionSumAndCountVo walletOnceBatch = walletOnceBatchMap.get(dept.getDeptId()); - if (walletOnceBatch != null) { - statistics.setWalletOncePrice(BigDecimal.valueOf(ObjectUtil.defaultIfNull(walletOnceBatch.getSum(), 0L))); - statistics.setWalletOnceCount(ObjectUtil.defaultIfNull(walletOnceBatch.getCount(), 0L)); - } - WalletTransactionSumAndCountVo walletOnceReduce = walletOnceReduceMap.get(dept.getDeptId()); - if (walletOnceReduce != null) { - statistics.setWalletOnceReducePrice(BigDecimal.valueOf(Math.abs( - ObjectUtil.defaultIfNull(walletOnceReduce.getSum(), 0L)))); - statistics.setWalletOnceReduceCount(ObjectUtil.defaultIfNull(walletOnceReduce.getCount(), 0L)); - } - applyOrderStatistics(statistics, payMap.get(dept.getDeptId()), walletRefundMap.get(dept.getDeptId())); - resultList.add(statistics); + applyOrderStatistics(row, payMap.get(dept.getDeptId()), refund); + result.add(row); } - return resultList; + timing.stop(); + if (reporter != null) reporter.advance("部门统计合并完成"); + if (timing.getTotalTimeMillis() >= 1000) { + log.info("部门统计慢查询,部门数={},总耗时={}ms,分段耗时:{}", result.size(), timing.getTotalTimeMillis(), timing.prettyPrint()); + } + return result; } /** @@ -658,46 +648,34 @@ public class HardOrderTotalServiceImpl implements HardOrderTotalService { } /** - * 构建钱包部门统计条件,保证六类钱包统计使用完全一致的权限、时间和钱包类型范围。 + * 构建钱包部门统计条件,保证六类钱包统计使用完全一致的部门、时间和钱包类型范围。 * * @param dto 部门统计页面筛选条件 - * @param bizTypes 钱包流水业务类型编码集合 - * @param type 收支方向;为空时不限制流水正负方向 * @return 钱包流水汇总条件 */ - private WalletTransactionSumAndCountDto buildWalletStatisticsDto(HardDeptTotalDto dto, List bizTypes, Integer type) { + private WalletTransactionSumAndCountDto buildWalletStatisticsDto(HardDeptTotalDto dto) { WalletTransactionSumAndCountDto walletDto = new WalletTransactionSumAndCountDto(); walletDto.setDeptIds(dto.getDeptIds()); walletDto.setIsInclude(dto.getIsInclude()); - walletDto.setBizTypes(bizTypes); - walletDto.setType(type); walletDto.setCreateTimeList(dto.getCreateTimeList()); walletDto.setWalletTypeId(dto.getWalletTypeId()); walletDto.setWalletTypeIdList(dto.getWalletTypeIdList()); return walletDto; } - /** - * 调用钱包服务查询部门流水统计;远程业务失败时终止本次汇总,避免把失败数据误展示为零。 - * - * @param walletDto 钱包流水汇总条件 - * @return 钱包部门统计,远程响应数据为空时返回空列表 - * @throws IllegalStateException 钱包流水远程调用失败时抛出 - */ - private List queryWalletStatistics(WalletTransactionSumAndCountDto walletDto) { - R> response = remoteWalletTransactionService.sumAndCount(walletDto); - if (response == null || !response.isOk()) { - throw new IllegalStateException("查询钱包部门交易统计失败"); - } + /** 钱包六类统计一次返回;远程失败必须终止报表,不能当作零金额。 */ + private List queryWalletDepartmentStatistics(WalletTransactionSumAndCountDto dto) { + R> response = remoteWalletTransactionService.departmentStatistics(dto); + if (response == null || !response.isOk()) throw new IllegalStateException("查询钱包部门交易统计失败"); return response.getData() == null ? List.of() : response.getData(); } /** - * 构建硬件订单支付部门统计条件。订单金额仍保留非钱包支付能力,钱包支付会在后续按流水历史部门重新归属; - * 明细查询不做部门过滤,部门包含/排除条件在重归集后统一应用。 + * 构建硬件订单支付部门统计条件。数据库先还原支付流水的历史部门,缺失时回退订单部门, + * 再应用部门包含/排除条件并分组汇总。 * * @param dto 部门统计页面筛选条件 - * @return 支付明细查询条件 + * @return 支付部门聚合查询条件 */ private HardOrderSumAndCountDto buildPayStatisticsDto(HardDeptTotalDto dto) { HardOrderSumAndCountDto orderDto = new HardOrderSumAndCountDto(); @@ -710,162 +688,6 @@ public class HardOrderTotalServiceImpl implements HardOrderTotalService { return orderDto; } - /** - * 将订单支付明细按钱包流水发生时保存的部门重新归集,并在重新归属后应用页面部门条件。 - * - *

现金、第三方支付或历史异常记录没有对应钱包流水时,继续使用订单创建时保存的部门快照, - * 避免丢失原有非钱包交易能力。退款不再进入本方法,统一由 Wallet 按 REFUND 流水直接汇总。

- * - * @param details 符合订单状态、商户和时间条件的支付明细 - * @param transactionSnapshotMap 以订单业务号为键的钱包支付流水快照 - * @param dto 页面部门包含或排除条件 - * @return 按最终历史部门归集后的金额和次数 - */ - private List aggregatePaymentsByTransactionDepartment( - List details, - Map transactionSnapshotMap, - HardDeptTotalDto dto) { - if (ObjectUtil.isEmpty(details)) { - return List.of(); - } - Set departmentFilter = ObjectUtil.isEmpty(dto.getDeptIds()) - ? Set.of() : new HashSet<>(dto.getDeptIds()); - Map aggregateMap = new LinkedHashMap<>(); - for (HardOrderDepartmentStatisticsDetail detail : details) { - if (detail == null) { - continue; - } - WalletTransactionDepartmentSnapshotVo snapshot = transactionSnapshotMap.get(detail.getBizId()); - Long departmentId = snapshot == null ? detail.getDeptId() : snapshot.getDeptId(); - if (departmentId == null || !matchesDepartmentFilter(departmentId, departmentFilter, dto.getIsInclude())) { - continue; - } - HardOrderSumAndCountVo aggregate = aggregateMap.computeIfAbsent(departmentId, key -> { - HardOrderSumAndCountVo value = new HardOrderSumAndCountVo(); - value.setDeptId(key); - value.setSum(0L); - value.setCount(0L); - return value; - }); - long amount = ObjectUtil.defaultIfNull(detail.getSum(), 0L); - aggregate.setSum(Math.addExact(aggregate.getSum(), amount)); - aggregate.setCount(Math.addExact(aggregate.getCount(), ObjectUtil.defaultIfNull(detail.getCount(), 0L))); - } - return new ArrayList<>(aggregateMap.values()); - } - - /** - * 将需要历史归属的交易明细整理成受控查询组,并用一次或极少数几次 Wallet 调用查询流水历史部门。 - * - *

单个 SQL 查询组最多一万条,单次远程请求最多十组。常规报表只产生一次远程调用, - * 极端大数据量会自动拆分远程请求,避免请求体或 SQL 参数失控。远程失败时终止统计, - * 禁止静默回退造成历史部门和订单部门混合口径。

- * - * @param detailsByBizType 以钱包业务类型编码为键的待归属业务明细;部门页当前只传入支付订单 - * @return 第一层以业务类型编码为键、第二层以业务 ID 为键的钱包流水快照 - * @throws IllegalStateException 钱包历史部门查询失败时抛出 - */ - private Map> queryTransactionDepartmentSnapshotMaps( - Map> detailsByBizType) { - Map> expectedWalletMaps = new LinkedHashMap<>(); - Map> resultMaps = new LinkedHashMap<>(); - List queryGroups = new ArrayList<>(); - for (Map.Entry> entry : detailsByBizType.entrySet()) { - List sourceDetails = - entry.getValue() == null ? List.of() : entry.getValue(); - List walletDetails = sourceDetails.stream() - .filter(ObjectUtil::isNotEmpty) - .filter(detail -> ObjectUtil.isAllNotEmpty(detail.getBizId(), detail.getWalletId())) - .collect(Collectors.toMap(HardOrderDepartmentStatisticsDetail::getBizId, - detail -> detail, (first, ignored) -> first, LinkedHashMap::new)) - .values().stream() - .toList(); - Map expectedWalletMap = walletDetails.stream().collect(Collectors.toMap( - HardOrderDepartmentStatisticsDetail::getBizId, - HardOrderDepartmentStatisticsDetail::getWalletId, - (first, ignored) -> first, LinkedHashMap::new)); - expectedWalletMaps.put(entry.getKey(), expectedWalletMap); - resultMaps.put(entry.getKey(), new LinkedHashMap<>()); - for (int start = 0; start < walletDetails.size(); - start += WalletTransactionDepartmentSnapshotDto.MAX_QUERY_BATCH_SIZE) { - int end = Math.min(start + WalletTransactionDepartmentSnapshotDto.MAX_QUERY_BATCH_SIZE, - walletDetails.size()); - List batchDetails = walletDetails.subList(start, end); - WalletTransactionDepartmentSnapshotDto snapshotDto = new WalletTransactionDepartmentSnapshotDto(); - snapshotDto.setBizType(entry.getKey()); - snapshotDto.setBizIds(batchDetails.stream() - .map(HardOrderDepartmentStatisticsDetail::getBizId).toList()); - snapshotDto.setWalletIds(batchDetails.stream() - .map(HardOrderDepartmentStatisticsDetail::getWalletId).distinct().toList()); - queryGroups.add(snapshotDto); - } - } - for (int start = 0; start < queryGroups.size(); - start += WalletTransactionDepartmentSnapshotBatchDto.MAX_QUERY_GROUP_SIZE) { - int end = Math.min(start + WalletTransactionDepartmentSnapshotBatchDto.MAX_QUERY_GROUP_SIZE, - queryGroups.size()); - WalletTransactionDepartmentSnapshotBatchDto batchDto = - new WalletTransactionDepartmentSnapshotBatchDto(); - batchDto.setQueries(new ArrayList<>(queryGroups.subList(start, end))); - R> response = - remoteWalletTransactionService.listDepartmentSnapshotsBatch(batchDto); - if (response == null || !response.isOk()) { - throw new IllegalStateException("查询钱包流水历史部门失败"); - } - if (ObjectUtil.isEmpty(response.getData())) { - continue; - } - for (WalletTransactionDepartmentSnapshotVo snapshot : response.getData()) { - if (snapshot != null && ObjectUtil.isAllNotEmpty( - snapshot.getBizType(), snapshot.getBizId(), snapshot.getWalletId(), snapshot.getDeptId())) { - Map expectedWalletMap = expectedWalletMaps.get(snapshot.getBizType()); - Map resultMap = resultMaps.get(snapshot.getBizType()); - if (expectedWalletMap == null || resultMap == null - || !ObjectUtil.equals(expectedWalletMap.get(snapshot.getBizId()), snapshot.getWalletId())) { - continue; - } - // 钱包服务按流水主键倒序返回,异常重复业务号保留最新一笔历史快照。 - resultMap.putIfAbsent(snapshot.getBizId(), snapshot); - } - } - } - return resultMaps; - } - - /** - * 判断重新归属后的历史部门是否命中页面包含或排除条件。 - * - * @param departmentId 流水历史部门或非钱包订单部门 - * @param departmentFilter 页面部门主键集合 - * @param isInclude true 表示包含,false 表示排除,空值兼容旧接口的包含语义 - * @return 当前历史部门是否参与统计 - */ - private boolean matchesDepartmentFilter(Long departmentId, Set departmentFilter, Boolean isInclude) { - if (departmentFilter.isEmpty()) { - return true; - } - boolean contains = departmentFilter.contains(departmentId); - return Boolean.FALSE.equals(isInclude) ? !contains : contains; - } - - /** - * 将钱包统计按部门建立索引,避免每个部门重复遍历全部统计结果。 - * - * @param statisticsList 钱包部门统计列表 - * @return 以部门 ID 为键的统计索引 - */ - private Map indexWalletStatistics(List statisticsList) { - Map statisticsMap = new LinkedHashMap<>(); - if (ObjectUtil.isNotEmpty(statisticsList)) { - for (WalletTransactionSumAndCountVo statistics : statisticsList) { - if (statistics != null && statistics.getDeptId() != null) { - statisticsMap.put(statistics.getDeptId(), statistics); - } - } - } - return statisticsMap; - } - /** * 将订单支付统计按部门建立索引,供支付结果与 Wallet 退款结果按相同键合并。 * @@ -1224,9 +1046,10 @@ public class HardOrderTotalServiceImpl implements HardOrderTotalService { } private void computeMchPlatformRanke(List totalList, List mchList) { + Map merchants = CollectionUtils.convertMap(mchList, MchManageVo::getId); // 平台抽成计算 for (HardOrderCsVo item : totalList) { - MchManageVo targetMch = mchList.stream().filter(v -> ObjectUtil.equals(v.getId(), item.getMerchantId())).findFirst().orElse(null); + MchManageVo targetMch = merchants.get(item.getMerchantId()); if (ObjectUtil.isEmpty(targetMch)) continue; Double mchPlatformRanke = targetMch.getPlatformRanke(); if (ObjectUtil.isNotEmpty(mchPlatformRanke) && ObjectUtil.isNotEmpty(item.getTotalProfitPrice())) { @@ -1248,38 +1071,6 @@ public class HardOrderTotalServiceImpl implements HardOrderTotalService { return RemoteBatchQueryUtil.queryMapByIds(deptIds, ids -> remoteDeptService.getDeptByDeptIds(ids).getData()); } - /** 同一订单批次补齐钱包历史部门后立即累加,不保留全部订单及流水快照。 */ - private List queryExportPayments(HardDeptTotalDto dto, AsyncTaskProgressReporter reporter) { - Map aggregate = new LinkedHashMap<>(); - HardOrderSumAndCountDto query = buildPayStatisticsDto(dto); - long processed = 0; - long expected = 0; - for (long current = 1; ; current++) { - Page page = hardOrderMapper.pagePayStatisticsDetails(BatchExportSupport.page(current), query); - if (current == 1) { - expected = page.getTotal(); - if (reporter != null) reporter.updateTotal(expected == 0 ? 3 : BatchExcelExportTemplate.calculateProgressTotal(expected, BatchExportSupport.BATCH_SIZE, 3), "正在归集部门订单及钱包历史快照"); - } - List details = page.getRecords(); - if (details.isEmpty()) break; - processed += details.size(); - Map> groups = new LinkedHashMap<>(); - groups.put(WalletBizEnum.PAYMENT.getCode(), details); - Map snapshots = queryTransactionDepartmentSnapshotMaps(groups).getOrDefault(WalletBizEnum.PAYMENT.getCode(), Map.of()); - for (HardOrderSumAndCountVo row : aggregatePaymentsByTransactionDepartment(details, snapshots, dto)) { - HardOrderSumAndCountVo existing = aggregate.get(row.getDeptId()); - if (existing == null) aggregate.put(row.getDeptId(), row); - else { - existing.setSum(Math.addExact(existing.getSum(), row.getSum())); - existing.setCount(Math.addExact(existing.getCount(), row.getCount())); - } - } - if (reporter != null) reporter.advance("已归集 " + processed + " / " + expected + " 笔订单"); - } - if (processed != expected) throw new com.bsd.pjcloud.common.asynctask.service.AsyncTaskException("导出期间订单数据发生变化,请重试"); - return new ArrayList<>(aggregate.values()); - } - /** 日分片只累计原始金额与次数;平台抽成在完整合并后按原函数计算。 */ private List queryCsExportSlices(HardOrderDto dto, boolean refund, DataScope scope, AsyncTaskProgressReporter reporter) { LocalDateTime[] range = refund ? dto.getRefundTimeList() : dto.getPayTimeList(); diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/order/HardOrderCartSnapshot.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/order/HardOrderCartSnapshot.java new file mode 100644 index 000000000..424c05dd7 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/order/HardOrderCartSnapshot.java @@ -0,0 +1,15 @@ +package com.bsd.pjcloud.hard.service.order; + +import com.bsd.pjcloud.hard.api.entity.order.HardCarEntity; + +import java.util.List; + +/** + * 事务外读取的商品快照;金额、摘要和落库明细使用同一份价格,写事务不再重新查询商品。 + * + * @param totalPrice 商品总额,单位为分,不含额外餐费 + * @param subject 沿用终端订单原有格式的商品摘要 + * @param items 尚未绑定父购物车的明细,仅允许当前下单请求使用 + */ +public record HardOrderCartSnapshot(int totalPrice, String subject, List items) { +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardDepartmentStatisticsMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardDepartmentStatisticsMapper.xml new file mode 100644 index 000000000..c25c5db86 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardDepartmentStatisticsMapper.xml @@ -0,0 +1,60 @@ + + + + + diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml index b6174f003..7da78614c 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml @@ -578,14 +578,15 @@ + + + + inner join pjcloudx_wallet.wallet_manage w_m on w_m.id = a.wallet_id + + + inner join hard_manager m on m.id = a.hard_id and m.type in + + #{v} + + + + + + r.del_flag = '0' + + and r.audit_time between #{dto.refundTimeList[0]} and #{dto.refundTimeList[1]} + + + and r.status in + + #{v} + + + + ", start) + "".length()); + for (String candidate : List.of("unhinted", "biz_time", "time")) { + String hint = candidate.equals("biz_time") ? "/*+ INDEX(a idx_wallet_tx_tenant_del_biz_ct_uid) */ " + : candidate.equals("time") ? "/*+ INDEX(a idx_wallet_tx_tenant_del_ct_uid) */ " : ""; + String sql = select.replace("/*+ INDEX(a idx_wallet_tx_tenant_del_biz_ct_uid) */", "") + .replaceAll("select\\s+a\\.dept_id", "select " + hint + "a.dept_id"); + String xml = "" + + "" + + "" + sql + ""; + try (var input = new java.io.ByteArrayInputStream(xml.getBytes(java.nio.charset.StandardCharsets.UTF_8))) { + new XMLMapperBuilder(input, database.configuration, candidate, database.configuration.getSqlFragments()).parse(); + } + } + for (String scenario : List.of("three_months", "department_include", "department_exclude", "wallet_type")) { + var dto = walletQuery(query(scenario)); + var expected = legacyWallet(database, dto); + for (String candidate : List.of("unhinted", "biz_time", "time")) { + database.catalog(WALLET); + database.execute("set session max_execution_time=30000"); + String statementId = "walletIndexCandidate." + candidate + ".departmentStatistics"; + var parameters = Map.of("dto", dto); + var samples = new ArrayList(); + for (int run = 0; run < 3; run++) { + long started = System.nanoTime(); + List rows = database.session.selectList(statementId, parameters); + var actual = new TreeMap(); + for (var row : rows) actual.put(row.getDeptId(), row); + samples.add((System.nanoTime() - started) / 1_000_000D); + assertEquals(expected, actual, scenario + " " + candidate); + } + print("wallet_candidates", scenario, "wallet", candidate, samples); + System.out.printf("MYSQL_DEPT_WALLET_CANDIDATE_PLAN scenario=%s candidate=%s%n%s%n", scenario, candidate, + database.explain(statementId, parameters)); + } + } + } + } + + private void benchmark(Database database, HardOrderSumAndCountDto query, String stage, String scenario) throws Exception { + // 部门筛选只在旧 Java 归集的最后一步发生,复用相同原始 SQL 得到的全部门基线。 + LegacyPayment legacy = null; + boolean filterOnly = scenario.startsWith("department_") && paymentOracles.containsKey(stage); + if (filterOnly) { + LegacyPayment original = paymentOracles.get(stage); + Map filtered = new TreeMap<>(); + original.totals().forEach((department, totals) -> { + if (matchesDepartment(query, department)) filtered.put(department, totals); + }); + legacy = new LegacyPayment(filtered, original.detailRows(), original.snapshotRows(), original.snapshotQueries()); + System.out.printf("MYSQL_DEPT_ORACLE stage=%s scenario=%s source=validated_unfiltered_legacy_same_source_sql%n", stage, scenario); + } + List oldPaymentSamples = new ArrayList<>(); + List newPaymentSamples = new ArrayList<>(); + List oldWalletSamples = new ArrayList<>(); + List newWalletSamples = new ArrayList<>(); + Map payment = null; + Map wallet = null; + boolean oldPaymentTimedOut = false; + boolean newPaymentTimedOut = false; + for (int run = 0; run < 3; run++) { + // Alternate legacy/optimized order so cache warming does not always favor one candidate. + for (boolean baseline : run % 2 == 0 ? List.of(true, false) : List.of(false, true)) { + if (baseline && !oldPaymentTimedOut && !filterOnly) { + try { + Sample sample = timed(() -> legacyPayment(database, query)); + legacy = sample.value(); + oldPaymentSamples.add(sample.millis()); + } catch (Exception error) { + if (!isTimeout(error)) throw error; + oldPaymentTimedOut = true; + System.out.printf("MYSQL_DEPT_BENCHMARK stage=%s scenario=%s query=payment variant=legacy timeoutMillis=30000%n", stage, scenario); + } + } else if (!baseline && !newPaymentTimedOut) { + try { + Sample> sample = timed(() -> optimizedPayment(database, query)); + payment = sample.value(); + newPaymentSamples.add(sample.millis()); + } catch (Exception error) { + if (!isTimeout(error) || stage.equals("recommended_indexes")) throw error; + newPaymentTimedOut = true; + System.out.printf("MYSQL_DEPT_BENCHMARK stage=%s scenario=%s query=payment variant=optimized timeoutMillis=30000%n", stage, scenario); + } + } + } + Sample> oldWallet = timed(() -> legacyWallet(database, walletQuery(query))); + Sample> newWallet = timed(() -> optimizedWallet(database, walletQuery(query))); + assertEquals(oldWallet.value(), newWallet.value(), stage + " " + scenario + " all twelve wallet fields"); + wallet = newWallet.value(); + oldWalletSamples.add(oldWallet.millis()); + newWalletSamples.add(newWallet.millis()); + if (legacy != null && payment != null) assertEquals(legacy.totals(), payment, stage + " " + scenario + " payment"); + } + if (stage.equals("recommended_indexes")) { + if (legacy == null) { + // 计时基线保留 30s 超时事实;正确性对照额外给旧链路 90s,不把它伪装成性能样本。 + legacy = legacyPayment(database, query, 90_000_000_000L); + System.out.printf("MYSQL_DEPT_ORACLE stage=%s scenario=%s extendedCorrectnessBudgetMillis=90000%n", stage, scenario); + } + assertNotNull(legacy); + assertNotNull(payment); + assertEquals(legacy.totals(), payment, stage + " " + scenario); + } + if (scenario.equals("three_months") && legacy != null) paymentOracles.put(stage, legacy); + print(stage, scenario, "payment", "legacy", oldPaymentSamples); + print(stage, scenario, "payment", "optimized", newPaymentSamples); + print(stage, scenario, "wallet", "legacy_six_queries", oldWalletSamples); + print(stage, scenario, "wallet", "optimized_one_query", newWalletSamples); + System.out.printf("MYSQL_DEPT_CARDINALITY stage=%s scenario=%s legacyDetailRows=%d legacySnapshotRows=%d legacySnapshotSqlQueries=%d optimizedPaymentRows=%d optimizedWalletRows=%d%n", + stage, scenario, legacy == null ? -1 : legacy.detailRows(), legacy == null ? -1 : legacy.snapshotRows(), + legacy == null ? -1 : legacy.snapshotQueries(), payment == null ? -1 : payment.size(), wallet == null ? -1 : wallet.size()); + } + + private LegacyPayment legacyPayment(Database database, HardOrderSumAndCountDto query) throws Exception { + return legacyPayment(database, query, TIME_LIMIT_NANOS); + } + + private LegacyPayment legacyPayment(Database database, HardOrderSumAndCountDto query, long budgetNanos) throws Exception { + long deadline = System.nanoTime() + budgetNanos; + database.catalog(HARDWARE); + database.limit(deadline); + List details = database.session.getMapper(HardOrderMapper.class).listPayStatisticsDetails(query); + List walletDetails = details.stream() + .filter(row -> row.getWalletId() != null && row.getBizId() != null && !row.getBizId().isEmpty()).toList(); + Map expectedWallets = new LinkedHashMap<>(); + for (var detail : walletDetails) expectedWallets.putIfAbsent(detail.getBizId(), detail.getWalletId()); + Map snapshots = new LinkedHashMap<>(); + int snapshotRows = 0; + int snapshotQueries = 0; + database.catalog(WALLET); + for (int start = 0; start < walletDetails.size(); start += WalletTransactionDepartmentSnapshotDto.MAX_QUERY_BATCH_SIZE) { + var group = walletDetails.subList(start, Math.min(walletDetails.size(), start + WalletTransactionDepartmentSnapshotDto.MAX_QUERY_BATCH_SIZE)); + var dto = new WalletTransactionDepartmentSnapshotDto(); + dto.setBizType("4"); + dto.setBizIds(group.stream().map(HardOrderDepartmentStatisticsDetail::getBizId).toList()); + dto.setWalletIds(group.stream().map(HardOrderDepartmentStatisticsDetail::getWalletId).distinct().toList()); + database.limit(deadline); + List rows = database.session.selectList(WALLET_MAPPER + "listDepartmentSnapshots", Map.of("dto", dto)); + snapshotRows += rows.size(); + snapshotQueries++; + for (var snapshot : rows) { + // Original Java maps use exact bizType/bizId strings, even with a PAD SPACE database collation. + if ("4".equals(snapshot.getBizType()) && snapshot.getDeptId() != null + && Objects.equals(expectedWallets.get(snapshot.getBizId()), snapshot.getWalletId())) { + snapshots.putIfAbsent(snapshot.getBizId(), snapshot.getDeptId()); + } + } + } + Map result = new TreeMap<>(); + for (var detail : details) { + Long department = snapshots.getOrDefault(detail.getBizId(), detail.getDeptId()); + if (department == null || !matchesDepartment(query, department)) continue; + add(result, department, Objects.requireNonNullElse(detail.getSum(), 0L), Objects.requireNonNullElse(detail.getCount(), 0L)); + } + database.limit(deadline); + return new LegacyPayment(result, details.size(), snapshotRows, snapshotQueries); + } + + private Map optimizedPayment(Database database, HardOrderSumAndCountDto query) throws Exception { + database.catalog(HARDWARE); + database.execute("set session max_execution_time=30000"); + Map result = new TreeMap<>(); + for (HardOrderSumAndCountVo row : database.session.getMapper(HardDepartmentStatisticsMapper.class).paymentStatistics(query, TENANT)) { + assertNull(result.put(row.getDeptId(), new Totals(Objects.requireNonNullElse(row.getSum(), 0L), Objects.requireNonNullElse(row.getCount(), 0L)))); + } + return result; + } + + private Map legacyWallet(Database database, WalletTransactionSumAndCountDto query) throws Exception { + database.catalog(WALLET); + long deadline = System.nanoTime() + TIME_LIMIT_NANOS; + Map result = new TreeMap<>(); + category(database, query, deadline, List.of("2"), 1, result, (target, row) -> { + target.setBatchIncomeSum(sum(row)); target.setBatchIncomeCount(row.getCount()); + }); + category(database, query, deadline, List.of("2"), 2, result, (target, row) -> { + target.setBatchExpenseSum(sum(row)); target.setBatchExpenseCount(row.getCount()); + }); + category(database, query, deadline, List.of("1", "3"), 1, result, (target, row) -> { + target.setOnceIncomeSum(sum(row)); target.setOnceIncomeCount(row.getCount()); + }); + category(database, query, deadline, List.of("1", "3"), 2, result, (target, row) -> { + target.setOnceExpenseSum(sum(row)); target.setOnceExpenseCount(row.getCount()); + }); + category(database, query, deadline, List.of("8"), null, result, (target, row) -> { + target.setReturnCanteenSum(sum(row)); target.setReturnCanteenCount(row.getCount()); + }); + category(database, query, deadline, List.of("5"), null, result, (target, row) -> { + target.setRefundSum(sum(row)); target.setRefundCount(row.getCount()); + }); + return result; + } + + private void category(Database database, WalletTransactionSumAndCountDto base, long deadline, List types, Integer direction, + Map result, BiConsumer merge) throws Exception { + var dto = new WalletTransactionSumAndCountDto(); + dto.setCreateTimeList(base.getCreateTimeList()); + dto.setDeptIds(base.getDeptIds()); + dto.setIsInclude(base.getIsInclude()); + dto.setWalletTypeId(base.getWalletTypeId()); + dto.setWalletTypeIdList(base.getWalletTypeIdList()); + dto.setBizTypes(types); + dto.setType(direction); + database.limit(deadline); + List rows = database.session.selectList(WALLET_MAPPER + "sumAndCount", Map.of("dto", dto)); + for (var row : rows) merge.accept(result.computeIfAbsent(row.getDeptId(), id -> { + var value = new WalletDepartmentStatisticsVo(); value.setDeptId(id); return value; + }), row); + } + + private Map optimizedWallet(Database database, WalletTransactionSumAndCountDto dto) throws Exception { + database.catalog(WALLET); + database.execute("set session max_execution_time=30000"); + List rows = database.session.selectList(WALLET_MAPPER + "departmentStatistics", Map.of("dto", dto)); + Map result = new TreeMap<>(); + for (var row : rows) assertNull(result.put(row.getDeptId(), row)); + return result; + } + + private void explain(Database database, HardOrderSumAndCountDto query, String scenario) throws Exception { + database.catalog(HARDWARE); + String plan = database.explain(HardDepartmentStatisticsMapper.class.getName() + ".paymentStatistics", Map.of("dto", query, "tenantId", TENANT)); + System.out.printf("MYSQL_DEPT_PLAN scenario=%s query=payment%n%s%n", scenario, plan); + assertTrue(plan.contains("idx_wt_tenant_bizid_type"), "History must use the business-id lookup index: " + plan); + assertFalse(plan.contains("Table scan on history"), "No per-order full history scan: " + plan); + if (scenario.equals("three_months")) assertTrue(plan.contains("idx_hard_order_dept_ct_cover"), plan); + database.catalog(WALLET); + String walletPlan = database.explain(WALLET_MAPPER + "departmentStatistics", Map.of("dto", walletQuery(query))); + System.out.printf("MYSQL_DEPT_PLAN scenario=%s query=wallet%n%s%n", scenario, walletPlan); + assertTrue(walletPlan.contains("idx_wallet_tx_tenant_del_biz_ct_uid"), walletPlan); + } + + private static void seed(Database database) throws Exception { + String numbers = """ + with digits as (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), + numbers as (select 1+a.n+10*b.n+100*c.n+1000*d.n+10000*e.n+100000*f.n n + from digits a cross join digits b cross join digits c cross join digits d cross join digits e cross join digits f) + """; + System.out.println("MYSQL_DEPT_BENCHMARK preparing temporary exact-DDL tables orders=1000000 ledger=1000000 wallets=10000 dateSpreadDays=730 tenants=2"); + database.execute(""" + insert into pjcloudx_hardware.hard_order + (id,user_id,user_dept_id,merchant_id,merchant_type,merchant_dept_id,hard_id,dept_id,wallet_id, + order_type,order_status,pay_type,hour_type,total_price,pay_price,pay_time,create_time,del_flag,tenant_id) + """ + numbers + """ + select n,1+mod(n,10000),20+mod(n,50),1000+mod(n,997),'1','10',501,99, + if(mod(n,31)=0,null,100000+mod(n,10000)), + '1',case when mod(n,23)=0 then '1' when mod(n,13)=0 then '10' when mod(n,7)=0 then '20' else '5' end, + '1','1',100+mod(n,10000),if(mod(n,47)=0,0,100+mod(n,10000)), + timestamp('2026-10-21 12:00:00')-interval mod(n+17,730) day, + timestamp('2026-10-21 12:00:00')-interval mod(n,730) day, + if(mod(n,97)=0,'1','0'),if(mod(n,10)=0,8,7) from numbers + """); + database.execute(""" + insert into pjcloudx_wallet.wallet_manage (id,user_id,wallet_type_id,dept_id,tenant_id,del_flag) + """ + numbers + """ + select 100000+n-1,n,111+mod(n-1,3),10,if(mod(n-1,10)=0,8,7),if(mod(n,53)=0,'1','0') + from numbers where n<=10000 + """); + database.execute(""" + insert into pjcloudx_wallet.wallet_transaction + (id,wallet_id,user_id,dept_id,biz_type,biz_id,title,price,balance,create_time,del_flag,tenant_id) + """ + numbers + """ + select n,100000+mod(if(mod(n,137)=0,n+1,if(mod(n,101)=0,n-1,n)),10000),1+mod(n,10000), + 50+mod(n,50),case when mod(n,2)=0 then if(mod(n,127)=0,'4 ','4') + else elt(1+mod(floor(n/2),5),'1','2','3','5','8') end, + case when mod(n,113)=0 then concat(n,' ') when mod(n,101)=0 then cast(n-1 as char) else cast(n as char) end, + 'department benchmark',case when mod(n,29)=0 then 0 when mod(n,3)=0 then 1+mod(n,10000) else -1-mod(n,10000) end, + 0,timestamp('2026-10-21 12:00:00')-interval if(mod(n,211)=0,1400,mod(n,730)) day, + if(mod(n,89)=0,'1','0'),if(mod(n,10)=0,8,7) from numbers + """); + assertEquals(1_000_000L, database.count(HARDWARE + ".hard_order")); + assertEquals(1_000_000L, database.count(WALLET + ".wallet_transaction")); + assertEquals(10_000L, database.count(WALLET + ".wallet_manage")); + } + + private static HardOrderSumAndCountDto query(String scenario) { + var dto = new HardOrderSumAndCountDto(); + dto.setCreateTimeList(new LocalDateTime[]{LocalDateTime.of(2026, 7, 22, 0, 0), LocalDateTime.of(2026, 10, 22, 23, 59, 59)}); + dto.setOrderStatusCodes(HardOrderStatusEnum.getPayPrice()); + if (scenario.startsWith("department_")) { + dto.setUserDeptIds(List.of(20L, 21L, 50L, 51L, 60L)); + dto.setIsInclude(scenario.equals("department_include")); + } + if (scenario.equals("wallet_type")) dto.setWalletTypeIdList(List.of(111L)); + return dto; + } + + private static WalletTransactionSumAndCountDto walletQuery(HardOrderSumAndCountDto source) { + var dto = new WalletTransactionSumAndCountDto(); + dto.setCreateTimeList(source.getCreateTimeList()); + dto.setDeptIds(source.getUserDeptIds()); + dto.setIsInclude(source.getIsInclude()); + dto.setWalletTypeId(source.getWalletTypeId()); + dto.setWalletTypeIdList(source.getWalletTypeIdList()); + return dto; + } + + private static boolean matchesDepartment(HardOrderSumAndCountDto query, Long department) { + if (query.getUserDeptIds() == null || query.getUserDeptIds().isEmpty()) return true; + boolean contains = query.getUserDeptIds().contains(department); + return Boolean.FALSE.equals(query.getIsInclude()) ? !contains : contains; + } + + private static void add(Map totals, Long department, long sum, long count) { + Totals existing = totals.getOrDefault(department, new Totals(0, 0)); + totals.put(department, new Totals(Math.addExact(existing.sum(), sum), Math.addExact(existing.count(), count))); + } + + private static long sum(WalletTransactionSumAndCountVo row) { return Objects.requireNonNullElse(row.getSum(), 0L); } + private static Sample timed(CheckedSupplier supplier) throws Exception { + long started = System.nanoTime(); + T value = supplier.get(); + return new Sample<>(value, (System.nanoTime() - started) / 1_000_000D); + } + + private static void print(String stage, String scenario, String query, String variant, List samples) { + if (samples.isEmpty()) return; + samples.sort(Comparator.naturalOrder()); + System.out.printf("MYSQL_DEPT_BENCHMARK stage=%s scenario=%s query=%s variant=%s medianMillis=%.2f samples=%s%n", + stage, scenario, query, variant, samples.get(samples.size() / 2), samples); + } + + private static boolean isTimeout(Throwable error) { + for (Throwable cause = error; cause != null; cause = cause.getCause()) { + if (cause instanceof SQLTimeoutException) return true; + if (cause instanceof SQLException sql && (sql.getErrorCode() == 3024 || "70100".equals(sql.getSQLState()))) return true; + } + return false; + } + + private static final class Database implements AutoCloseable { + private final MybatisConfiguration configuration = new MybatisConfiguration(); + private final TenantLineInnerInterceptor tenant; + private final SqlSession session; + private final Connection connection; + + private Database() throws Exception { + TenantContextHolder.setTenantId(TENANT); + String host = Objects.requireNonNull(System.getenv("MYSQL_HOST"), "MYSQL_HOST required"); + String port = Objects.requireNonNull(System.getenv("MYSQL_PORT"), "MYSQL_PORT required"); + var source = new UnpooledDataSource("com.mysql.cj.jdbc.Driver", "jdbc:mysql://" + host + ":" + port + "/" + HARDWARE + + "?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai", + Objects.requireNonNull(System.getenv("MYSQL_USER"), "MYSQL_USER required"), + Objects.requireNonNull(System.getenv("MYSQL_PWD"), "MYSQL_PWD required")); + configuration.setEnvironment(new Environment("mysql-department-benchmark", new JdbcTransactionFactory(), source)); + configuration.setMapUnderscoreToCamelCase(true); + configuration.setLocalCacheScope(LocalCacheScope.STATEMENT); + var properties = new PjcloudTenantConfigProperties(); + properties.setTables(List.of("hard_order", "wallet_manage", "wallet_transaction")); + tenant = new TenantLineInnerInterceptor(new PjcloudTenantHandler(properties)); + var plugins = new MybatisPlusInterceptor(); + plugins.addInnerInterceptor(tenant); + configuration.addInterceptor(plugins); + for (String resource : List.of("mapper/order/HardOrderMapper.xml", "mapper/order/HardDepartmentStatisticsMapper.xml")) { + try (InputStream input = getClass().getClassLoader().getResourceAsStream(resource)) { + assertNotNull(input, resource); + new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse(); + } + } + Path root = Path.of("").toAbsolutePath(); + while (root != null && !Files.isRegularFile(root.resolve("pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/WalletTransactionMapper.xml"))) root = root.getParent(); + assertNotNull(root, "Repository source root required to execute actual Wallet XML"); + Path walletXml = root.resolve("pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/WalletTransactionMapper.xml"); + try (InputStream input = Files.newInputStream(walletXml)) { + new XMLMapperBuilder(input, configuration, walletXml.toString(), configuration.getSqlFragments()).parse(); + } + session = new SqlSessionFactoryBuilder().build(configuration).openSession(true); + connection = session.getConnection(); + execute("set session time_zone='+08:00'"); + execute("set session max_execution_time=30000"); + } + + private void analyze() throws SQLException { + for (String table : List.of(HARDWARE + ".hard_order", WALLET + ".wallet_manage", WALLET + ".wallet_transaction")) { + try (var statement = connection.createStatement(); var rows = statement.executeQuery("analyze table " + table)) { + while (rows.next()) assertEquals("status", rows.getString("Msg_type"), rows.getString("Msg_text")); + } + } + } + private void catalog(String name) throws SQLException { connection.setCatalog(name); session.clearCache(); } + private void execute(String sql) throws SQLException { + try (var statement = connection.createStatement()) { statement.execute(sql); } + } + private void limit(long deadline) throws SQLException { + long remaining = (deadline - System.nanoTime()) / 1_000_000; + if (remaining < 1) throw new SQLTimeoutException("Legacy statistics exceeded total 30000ms budget"); + execute("set session max_execution_time=" + Math.max(1, remaining)); + } + private void cloneTemporary(String schema, String table) throws SQLException { + String create; + try (var statement = connection.createStatement(); var result = statement.executeQuery("show create table `" + schema + "`.`" + table + "`")) { + assertTrue(result.next()); create = result.getString(2); + } + execute("create temporary table `" + schema + "`.`" + table + "` " + create.substring(create.indexOf('('))); + } + private long count(String table) throws SQLException { + try (var statement = connection.createStatement(); var result = statement.executeQuery("select count(*) from " + table)) { + assertTrue(result.next()); return result.getLong(1); + } + } + private String explain(String statementId, Map parameters) throws Exception { + return explain(statementId, parameters, true); + } + private String explain(String statementId, Map parameters, boolean analyze) throws Exception { + execute("set session max_execution_time=30000"); + var mapped = configuration.getMappedStatement(statementId); + var bound = mapped.getBoundSql(parameters); + tenant.beforeQuery(null, mapped, parameters, RowBounds.DEFAULT, null, bound); + try (var statement = connection.prepareStatement((analyze ? "explain analyze " : "explain format=tree ") + bound.getSql())) { + new DefaultParameterHandler(mapped, parameters, bound).setParameters(statement); + try (var result = statement.executeQuery()) { assertTrue(result.next()); return result.getString(1); } + } + } + @Override public void close() { + try { session.close(); } + finally { TenantContextHolder.clear(); } + } + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardDepartmentStatisticsMySqlTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardDepartmentStatisticsMySqlTest.java new file mode 100644 index 000000000..3c70092bd --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardDepartmentStatisticsMySqlTest.java @@ -0,0 +1,324 @@ +package com.bsd.pjcloud.hard.service.order; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler; +import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor; +import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum; +import com.bsd.pjcloud.hard.api.dto.order.HardOrderSumAndCountDto; +import com.bsd.pjcloud.hard.api.vo.order.HardOrderSumAndCountVo; +import com.bsd.pjcloud.hard.mapper.order.HardDepartmentStatisticsMapper; +import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper; +import com.bsd.pjcloud.hard.mapper.order.model.HardOrderDepartmentStatisticsDetail; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.LongValue; +import org.apache.ibatis.builder.xml.XMLMapperBuilder; +import org.apache.ibatis.datasource.unpooled.UnpooledDataSource; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.session.LocalCacheScope; +import org.apache.ibatis.session.SqlSession; +import org.apache.ibatis.session.SqlSessionFactoryBuilder; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.junit.jupiter.api.Test; +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.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; +import java.util.UUID; +import java.util.function.Consumer; +import java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 显式启用的真实 MySQL 回归:源 XML 不作 H2 方言替换;业务表只读。 + * 所有写操作只作用于连接临时表或本测试创建的唯一 scratch 库,最后清理。 + * 使用 HARD_DEPT_MYSQL_TEST=true 及 MYSQL_HOST/MYSQL_PORT/MYSQL_USER/MYSQL_PWD 显式启用。 + */ +@EnabledIfEnvironmentVariable(named = "HARD_DEPT_MYSQL_TEST", matches = "true") +class HardDepartmentStatisticsMySqlTest { + static final String MAPPER = HardDepartmentStatisticsMapper.class.getName() + ".paymentStatistics"; + static final String MIGRATION = "db/20261022_hard_dept_statistics_indexes.sql"; + record Totals(long sum, long count) {} + + /** 供真实数据回归及连接临时表性能测试共用,加载未经替换的生产 XML。 */ + static class Database implements AutoCloseable { + final long tenantId; + final MybatisConfiguration configuration; + final SqlSession session; + final Connection connection; + + Database(long tenantId) throws Exception { + this.tenantId = tenantId; + String url = "jdbc:mysql://" + requiredEnv("MYSQL_HOST") + ":" + requiredEnv("MYSQL_PORT") + + "/pjcloudx_hardware?useSSL=false&allowPublicKeyRetrieval=true&allowMultiQueries=true&serverTimezone=Asia/Shanghai"; + var source = new UnpooledDataSource("com.mysql.cj.jdbc.Driver", url, + requiredEnv("MYSQL_USER"), requiredEnv("MYSQL_PWD")); + configuration = new MybatisConfiguration(); + configuration.setEnvironment(new Environment("department-mysql", new JdbcTransactionFactory(), source)); + configuration.setMapUnderscoreToCamelCase(true); + configuration.setLocalCacheScope(LocalCacheScope.STATEMENT); + var interceptor = new MybatisPlusInterceptor(); + interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() { + @Override public Expression getTenantId() { return new LongValue(tenantId); } + })); + configuration.addInterceptor(interceptor); + for (String resource : List.of("mapper/order/HardOrderMapper.xml", "mapper/order/HardDepartmentStatisticsMapper.xml")) { + try (InputStream input = getClass().getClassLoader().getResourceAsStream(resource)) { + assertNotNull(input, resource); + new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse(); + } + } + session = new SqlSessionFactoryBuilder().build(configuration).openSession(true); + connection = session.getConnection(); + execute("set session time_zone='+08:00'; set session max_execution_time=30000"); + } + + List details(HardOrderSumAndCountDto query) { + return session.getMapper(HardOrderMapper.class).listPayStatisticsDetails(query); + } + + Map rows(HardOrderSumAndCountDto query) { + List values = session.getMapper(HardDepartmentStatisticsMapper.class) + .paymentStatistics(query, tenantId); + Map result = new TreeMap<>(); + for (var row : values) { + assertNotNull(row.getDeptId()); + assertNull(result.put(row.getDeptId(), new Totals( + Objects.requireNonNullElse(row.getSum(), 0L), Objects.requireNonNullElse(row.getCount(), 0L)))); + } + return result; + } + + /** 原流程:逐笔订单 -> 按业务号/钱包批量取流水 -> Java 精确 key 及最新有效快照 -> 最终部门筛选。 */ + Map baseline(HardOrderSumAndCountDto query) throws Exception { + List details = details(query); + List walletDetails = details.stream() + .filter(row -> row.getWalletId() != null && row.getBizId() != null && !row.getBizId().isEmpty()).toList(); + Map expectedWallets = new HashMap<>(); + walletDetails.forEach(row -> expectedWallets.putIfAbsent(row.getBizId(), row.getWalletId())); + Map departments = new HashMap<>(); + for (int start = 0; start < walletDetails.size(); start += 1_000) { + var batch = walletDetails.subList(start, Math.min(start + 1_000, walletDetails.size())); + List ids = batch.stream().map(HardOrderDepartmentStatisticsDetail::getBizId).distinct().toList(); + List wallets = batch.stream().map(HardOrderDepartmentStatisticsDetail::getWalletId).distinct().toList(); + String sql = "select biz_type,biz_id,wallet_id,dept_id from pjcloudx_wallet.wallet_transaction " + + "where tenant_id=? and biz_type='4' and del_flag='0' and dept_id is not null " + + "and biz_id in (" + placeholders(ids.size()) + ") and wallet_id in (" + placeholders(wallets.size()) + ") order by id desc"; + try (var statement = connection.prepareStatement(sql)) { + int parameter = 1; + statement.setLong(parameter++, tenantId); + for (String id : ids) statement.setString(parameter++, id); + for (Long wallet : wallets) statement.setLong(parameter++, wallet); + try (var snapshots = statement.executeQuery()) { + while (snapshots.next()) { + String id = snapshots.getString("biz_id"); + long wallet = snapshots.getLong("wallet_id"); + // 字符排序规则可返回尾空格/Unicode 等价值,旧 Java Map 只接受精确业务号和预期钱包。 + if ("4".equals(snapshots.getString("biz_type")) && Objects.equals(expectedWallets.get(id), wallet)) { + departments.putIfAbsent(id, snapshots.getLong("dept_id")); + } + } + } + } + } + Map result = new TreeMap<>(); + for (var row : details) { + Long department = departments.getOrDefault(row.getBizId(), row.getDeptId()); + if (department == null) continue; + if (query.getUserDeptIds() != null && !query.getUserDeptIds().isEmpty()) { + boolean contains = query.getUserDeptIds().contains(department); + if (Boolean.FALSE.equals(query.getIsInclude()) ? contains : !contains) continue; + } + var existing = result.getOrDefault(department, new Totals(0, 0)); + result.put(department, new Totals( + Math.addExact(existing.sum(), Objects.requireNonNullElse(row.getSum(), 0L)), + Math.addExact(existing.count(), Objects.requireNonNullElse(row.getCount(), 0L)))); + } + return result; + } + + void assertMatches(HardOrderSumAndCountDto query, String label) throws Exception { + assertEquals(baseline(query), rows(query), label); + } + + void execute(String sql) throws Exception { + try (var statement = connection.createStatement()) { + boolean rows = statement.execute(sql); + while (true) { + if (rows) { + try (var result = statement.getResultSet()) { while (result.next()) { /* drain multi-query result */ } } + } else if (statement.getUpdateCount() == -1) break; + rows = statement.getMoreResults(); + } + } + } + + List ids(String sql) throws Exception { + List result = new ArrayList<>(); + try (var statement = connection.prepareStatement(sql)) { + statement.setLong(1, tenantId); + try (var rows = statement.executeQuery()) { + while (rows.next()) result.add(rows.getLong(1)); + } + } + return result; + } + + @Override public void close() { session.close(); } + } + + static Database open(long tenantId) throws Exception { return new Database(tenantId); } + + @Test + void existingDataMatchesLegacyJavaAttributionForAllOptionalFilters() throws Exception { + long tenant = Long.parseLong(System.getenv().getOrDefault("HARD_DEPT_MYSQL_TENANT", "1")); + try (var database = open(tenant)) { + var all = database.baseline(query()); + long department = all.keySet().stream().findFirst().orElse(-1L); + List walletTypes = database.ids("select distinct wallet_type_id from pjcloudx_wallet.wallet_manage where tenant_id=? and wallet_type_id is not null limit 3"); + long type = walletTypes.isEmpty() ? -1L : walletTypes.get(0); + long otherType = walletTypes.size() < 2 ? -2L : walletTypes.get(1); + List merchants = database.ids("select merchant_id from hard_order where tenant_id=? and del_flag='0' and merchant_id is not null limit 3"); + List> cases = List.of( + q -> {}, q -> q.setWalletTypeId(type), q -> q.setWalletTypeIdList(List.of(type, otherType)), + q -> { q.setWalletTypeId(type); q.setWalletTypeIdList(List.of(type, otherType)); }, + q -> { q.setWalletTypeId(type); q.setWalletTypeIdList(List.of(otherType)); }, + q -> q.setWalletTypeId(-1L), q -> q.setWalletTypeIdList(List.of()), + q -> q.setMchIds(merchants.isEmpty() ? List.of(-1L) : merchants), q -> q.setMchIds(List.of()), + q -> q.setOrderStatusCodes(List.of("10")), q -> q.setOrderStatusCodes(List.of()), + q -> q.setOrderStatusCodes(null), q -> q.setIsRefundSuccess(true), q -> q.setIsRefundSuccess(false), + q -> q.setCreateTimeList(null), q -> q.setCreateTimeList(new LocalDateTime[]{LocalDateTime.of(2026, 10, 20, 0, 0)}), + q -> q.setCreateTimeList(new LocalDateTime[]{LocalDateTime.of(2026, 10, 20, 0, 0), LocalDateTime.of(2026, 10, 20, 23, 59, 59)}), + q -> { q.setUserDeptIds(List.of(department)); q.setIsInclude(true); }, + q -> { q.setUserDeptIds(List.of(department)); q.setIsInclude(false); }, + q -> { q.setUserDeptIds(List.of(department)); q.setIsInclude(null); }, + q -> q.setUserDeptIds(List.of()), + q -> { q.setUserDeptIds(List.of(-1L)); q.setIsInclude(true); }, + q -> { q.setUserDeptIds(List.of(-1L)); q.setIsInclude(false); } + ); + for (int index = 0; index < cases.size(); index++) { + var query = query(); + cases.get(index).accept(query); + database.assertMatches(query, "real MySQL existing-data filter " + index); + } + System.out.printf("MYSQL_DEPT_REGRESSION cases=%d initialDepartments=%d allFieldsEqual=true%n", cases.size(), all.size()); + } + } + + @Test + void sourceSqlPreservesExactKeysAndLatestSnapshotsInConnectionTemporaryTables() throws Exception { + try (var database = open(7L)) { + database.execute(""" + create temporary table pjcloudx_hardware.hard_order ( + id bigint primary key,tenant_id bigint,del_flag char(1),wallet_id bigint,user_dept_id bigint, + pay_price bigint,order_status varchar(10),refund_status varchar(10),create_time datetime,merchant_id bigint); + create temporary table pjcloudx_wallet.wallet_manage ( + id bigint primary key,tenant_id bigint,wallet_type_id bigint,del_flag char(1)); + create temporary table pjcloudx_wallet.wallet_transaction ( + id bigint primary key,tenant_id bigint,del_flag char(1),wallet_id bigint, + biz_type tinytext,biz_id varchar(64) character set utf8mb4 collate utf8mb4_general_ci,dept_id bigint,price bigint); + insert into pjcloudx_hardware.hard_order values + (1,7,'0',901,10,100,'5',null,'2026-10-20 12:00:00',100), + (2,7,'0',901,11,200,'20',null,'2026-10-20 12:00:00',100), + (3,7,'0',null,30,300,'10',null,'2026-10-20 12:00:00',100), + (9007199254740993,7,'0',901,31,400,'5',null,'2026-10-20 12:00:00',100); + insert into pjcloudx_wallet.wallet_manage values (901,7,111,'0'),(902,7,222,'0'); + insert into pjcloudx_wallet.wallet_transaction values + (101,7,'0',901,'4','1 ',99,-100), + (200,7,'0',901,'4','2',20,-999), + (201,7,'1',901,'4','2',88,-200), + (202,7,'0',901,'4','2',null,-200), + (203,7,'0',902,'4','2',87,-200), + (204,8,'0',901,'4','2',86,-200), + (205,7,'0',901,'5','2',85,-200), + (206,7,'0',901,'4','02',84,-200), + (207,7,'0',901,'4','2.0',83,-200), + (208,7,'0',901,'4','9007199254740993',40,-400), + (209,7,'0',901,'4 ','1',98,-100), + (210,7,'0',901,'4 ','2',82,-200); + """); + database.assertMatches(query(), "source MySQL CHAR/BINARY exact business key"); + assertEquals(Map.of(10L, new Totals(100, 1), 20L, new Totals(200, 1), + 30L, new Totals(300, 1), 40L, new Totals(400, 1)), database.rows(query())); + System.out.println("MYSQL_DEPT_SOURCE_SQL exactKeysAndLatestSnapshot=true temporaryTablesOnly=true"); + } + } + + @Test + void migrationCanRepeatAndRecognizesEquivalentVisibleCompleteIndexes() throws Exception { + String schema = "codex_dept_idx_" + UUID.randomUUID().toString().replace("-", ""); + try (var database = open(7L)) { + try { + database.execute("create database `" + schema + "`"); + database.execute("create table `" + schema + "`.hard_order (id bigint primary key,tenant_id bigint,del_flag char(1),create_time datetime,order_status varchar(10),wallet_id bigint,user_dept_id bigint,pay_price bigint); " + + "create table `" + schema + "`.wallet_transaction (id bigint primary key,tenant_id bigint,biz_id varchar(64),biz_type tinytext)"); + String migration = Files.readString(repositoryFile(MIGRATION)) + .replace("pjcloudx_hardware", schema).replace("pjcloudx_wallet", schema); + database.execute(migration); + int initial = indexCount(database, schema); + assertEquals(4, initial, "两个主键和两个新索引"); + database.execute(migration); + assertEquals(initial, indexCount(database, schema), "重复执行不能加重复索引"); + database.execute("alter table `" + schema + "`.hard_order rename index idx_hard_order_dept_ct_cover to renamed_order_cover; " + + "alter table `" + schema + "`.wallet_transaction rename index idx_wt_tenant_bizid_type to renamed_wallet_lookup"); + database.execute(migration); + assertEquals(initial, indexCount(database, schema), "同定义不同名称应复用"); + database.execute("alter table `" + schema + "`.hard_order drop index renamed_order_cover, add index narrow_order (tenant_id,del_flag,create_time); " + + "alter table `" + schema + "`.wallet_transaction drop index renamed_wallet_lookup, add index truncated_biz (tenant_id,biz_id(8),biz_type(8))"); + database.execute(migration); + assertEquals(initial + 2, indexCount(database, schema), "窄订单索引、截断业务号索引不能冒充候选"); + database.execute("alter table `" + schema + "`.hard_order drop index idx_hard_order_dept_ct_cover,drop index narrow_order,add index reordered_cover (tenant_id,del_flag,create_time,pay_price,user_dept_id,wallet_id,order_status,id); " + + "alter table `" + schema + "`.wallet_transaction drop index idx_wt_tenant_bizid_type,drop index truncated_biz,add index two_column_lookup (tenant_id,biz_id)"); + database.execute(migration); + assertEquals(initial, indexCount(database, schema), "覆盖后缀顺序不同、业务号两列索引都应复用"); + database.execute("alter table `" + schema + "`.hard_order alter index reordered_cover invisible; " + + "alter table `" + schema + "`.wallet_transaction alter index two_column_lookup invisible"); + database.execute(migration); + assertEquals(initial + 2, indexCount(database, schema), "不可见索引不能冒充可用候选"); + System.out.println("MYSQL_DEPT_MIGRATION repeatRenameReorderNarrowPrefixInvisibleGuards=true"); + } finally { + database.execute("drop database if exists `" + schema + "`"); + } + } + } + + static HardOrderSumAndCountDto query() { + var query = new HardOrderSumAndCountDto(); + query.setOrderStatusCodes(HardOrderStatusEnum.getPayPrice()); + query.setCreateTimeList(new LocalDateTime[]{LocalDateTime.of(2025, 1, 1, 0, 0), LocalDateTime.of(2026, 10, 22, 23, 59, 59)}); + return query; + } + + static Path repositoryFile(String path) { + Path root = Path.of("").toAbsolutePath(); + while (root != null && !Files.isRegularFile(root.resolve(path))) root = root.getParent(); + assertNotNull(root, path); + return root.resolve(path); + } + + private static String requiredEnv(String name) { + String value = System.getenv(name); + assertNotNull(value, "显式启用 MySQL 回归时必须提供 " + name); + return value; + } + + private static String placeholders(int count) { return String.join(",", java.util.Collections.nCopies(count, "?")); } + + private static int indexCount(Database database, String schema) throws Exception { + try (var statement = database.connection.prepareStatement( + "select count(distinct table_name,index_name) from information_schema.statistics where table_schema=?")) { + statement.setString(1, schema); + try (var result = statement.executeQuery()) { assertTrue(result.next()); return result.getInt(1); } + } + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardDepartmentStatisticsServiceTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardDepartmentStatisticsServiceTest.java new file mode 100644 index 000000000..17408c3cd --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardDepartmentStatisticsServiceTest.java @@ -0,0 +1,349 @@ +package com.bsd.pjcloud.hard.service.order; + +import com.bsd.pjcloud.admin.api.dto.DeptAllListDto; +import com.bsd.pjcloud.admin.api.entity.SysDept; +import com.bsd.pjcloud.admin.api.feign.RemoteDeptService; +import com.bsd.pjcloud.admin.api.feign.RemoteUserService; +import com.bsd.pjcloud.common.asynctask.service.AsyncTaskProgressReporter; +import com.bsd.pjcloud.common.core.jackson.PjcloudJavaTimeModule; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.data.tenant.TenantContextHolder; +import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum; +import com.bsd.pjcloud.hard.api.dto.order.HardDeptTotalDto; +import com.bsd.pjcloud.hard.api.dto.order.HardOrderSumAndCountDto; +import com.bsd.pjcloud.hard.api.vo.order.HardDeptTotalVo; +import com.bsd.pjcloud.hard.api.vo.order.HardOrderSumAndCountVo; +import com.bsd.pjcloud.hard.controller.order.HardOrderTotalController; +import com.bsd.pjcloud.hard.mapper.manager.HardManagerMapper; +import com.bsd.pjcloud.hard.mapper.order.HardDepartmentStatisticsMapper; +import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper; +import com.bsd.pjcloud.hard.service.HardManagerService; +import com.bsd.pjcloud.hard.service.HardOrderService; +import com.bsd.pjcloud.hard.service.impl.order.HardBatchExportService; +import com.bsd.pjcloud.hard.service.impl.order.HardOrderTotalServiceImpl; +import com.bsd.pjcloud.wallet.api.dto.WalletTransactionSumAndCountDto; +import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; +import com.bsd.pjcloud.wallet.api.feign.RemoteWalletTransactionService; +import com.bsd.pjcloud.wallet.api.vo.WalletDepartmentStatisticsVo; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.http.MediaType; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** 验证真实报表服务只使用部门级聚合,同时保持页面、导出的字段和远程失败处理。 */ +class HardDepartmentStatisticsServiceTest { + private final HardOrderMapper orders = mock(HardOrderMapper.class); + private final HardManagerMapper hardManagers = mock(HardManagerMapper.class); + private final HardManagerService managers = mock(HardManagerService.class); + private final RemoteMchService merchants = mock(RemoteMchService.class); + private final RemoteWalletTransactionService wallets = mock(RemoteWalletTransactionService.class); + private final RemoteDeptService departments = mock(RemoteDeptService.class); + private final HardDepartmentStatisticsMapper aggregates = mock(HardDepartmentStatisticsMapper.class); + private final HardOrderTotalServiceImpl service = new HardOrderTotalServiceImpl( + orders, hardManagers, managers, merchants, wallets, departments, aggregates); + private final ObjectMapper json = new ObjectMapper().registerModule(new PjcloudJavaTimeModule()); + + @BeforeEach + void setup() { + TenantContextHolder.setTenantId(7L); + when(departments.getInnerAllDept(any())).thenReturn(R.ok(List.of(dept(7L, "部门七")))); + when(wallets.departmentStatistics(any())).thenReturn(R.ok(List.of())); + when(aggregates.paymentStatistics(any(), eq(7L))).thenReturn(List.of()); + } + + @AfterEach + void clear() { + TenantContextHolder.clear(); + } + + @Test + void allSixteenFieldsKeepSignedRefundAbsoluteExpenseAndExactNetAmounts() { + when(wallets.departmentStatistics(any())).thenReturn(R.ok(List.of(fullWallet()))); + when(aggregates.paymentStatistics(any(), eq(7L))).thenReturn(List.of(payment(7L, 5005L, 8L))); + HardDeptTotalVo row = service.pageListDept(query()).get(0); + assertEquals(7L, row.getDeptId()); + assertEquals("部门七", row.getDeptName()); + assertEquals(new BigDecimal("1001"), row.getWalletPrice()); + assertEquals(2L, row.getWalletCount()); + assertEquals(101L, row.getWalletReducePrice()); + assertEquals(3L, row.getWalletReduceCount()); + assertEquals(new BigDecimal("2002"), row.getWalletOncePrice()); + assertEquals(4L, row.getWalletOnceCount()); + assertEquals(new BigDecimal("202"), row.getWalletOnceReducePrice()); + assertEquals(5L, row.getWalletOnceReduceCount()); + assertEquals(new BigDecimal("303"), row.getWalletReturnCanteenPrice()); + assertEquals(6L, row.getWalletReturnCanteenCount()); + assertEquals(new BigDecimal("5005"), row.getOrderPayPrice()); + assertEquals(8L, row.getOrderPayCount()); + assertEquals(new BigDecimal("-404"), row.getOrderRefundPrice()); + assertEquals(7L, row.getOrderRefundCont()); + assertEquals(new BigDecimal("5409"), row.getOrderProfitPrice()); + assertEquals(1L, row.getOrderProfitCont()); + verify(wallets, times(1)).departmentStatistics(any()); + verify(aggregates, times(1)).paymentStatistics(any(), eq(7L)); + verifyNoMoreInteractions(wallets, aggregates); + verifyZeroInteractions(orders, hardManagers, managers, merchants); + } + + @Test + void departmentOrderDeletedAndZeroRowsStayVisibleAndRefundOnlyRowsHaveNegativeNet() { + when(departments.getInnerAllDept(any())).thenReturn(R.ok(List.of( + dept(42L, "历史部门(已被删除)"), dept(7L, "部门七"), dept(99L, "仅退款部门"), dept(88L, "零交易部门")))); + WalletDepartmentStatisticsVo refundOnly = new WalletDepartmentStatisticsVo(); + refundOnly.setDeptId(99L); refundOnly.setRefundSum(77L); refundOnly.setRefundCount(2L); + when(wallets.departmentStatistics(any())).thenReturn(R.ok(List.of(refundOnly, fullWallet()))); + when(aggregates.paymentStatistics(any(), eq(7L))).thenReturn(List.of( + payment(999L, 123456L, 999L), payment(7L, 5005L, 8L))); + List rows = service.pageListDept(query()); + assertEquals(List.of(42L, 7L, 99L, 88L), rows.stream().map(HardDeptTotalVo::getDeptId).toList()); + assertEquals("历史部门(已被删除)", rows.get(0).getDeptName()); + assertZeroAmounts(rows.get(0)); + assertZeroAmounts(rows.get(3)); + HardDeptTotalVo refund = rows.get(2); + assertEquals(BigDecimal.ZERO, refund.getOrderPayPrice()); + assertEquals(0L, refund.getOrderPayCount()); + assertEquals(new BigDecimal("77"), refund.getOrderRefundPrice()); + assertEquals(2L, refund.getOrderRefundCont()); + assertEquals(new BigDecimal("-77"), refund.getOrderProfitPrice()); + assertEquals(-2L, refund.getOrderProfitCont()); + } + + @Test + void emptyDepartmentsAvoidBothWalletAndOrderQueries() { + when(departments.getInnerAllDept(any())).thenReturn(R.ok(List.of())); + assertTrue(service.pageListDept(query()).isEmpty()); + verifyZeroInteractions(wallets, aggregates, orders); + } + + @Test + void successfulNullDepartmentDataIsAnEmptyScope() { + when(departments.getInnerAllDept(any())).thenReturn(R.ok(null)); + assertTrue(service.pageListDept(query()).isEmpty()); + verifyZeroInteractions(wallets, aggregates, orders); + } + + @Test + void nullOrFailedDepartmentRpcTerminatesBeforeStatistics() { + when(departments.getInnerAllDept(any())).thenReturn(null, R.failed("部门服务失败")); + for (int attempt = 0; attempt < 2; attempt++) { + IllegalStateException failure = assertThrows(IllegalStateException.class, () -> service.pageListDept(query())); + assertTrue(failure.getMessage().contains("部门信息失败")); + } + verifyZeroInteractions(wallets, aggregates, orders); + } + + @Test + void nullOrFailedWalletRpcTerminatesBeforePaymentAggregation() { + when(wallets.departmentStatistics(any())).thenReturn(null, R.failed("钱包服务失败")); + for (int attempt = 0; attempt < 2; attempt++) { + IllegalStateException failure = assertThrows(IllegalStateException.class, () -> service.pageListDept(query())); + assertTrue(failure.getMessage().contains("钱包部门交易统计失败")); + } + verify(wallets, times(2)).departmentStatistics(any()); + verifyNoMoreInteractions(wallets); + verifyZeroInteractions(aggregates, orders); + } + + @Test + void successfulNullAggregatesAndNullFieldsUseZeroAmounts() { + when(wallets.departmentStatistics(any())).thenReturn(R.ok(null)); + when(aggregates.paymentStatistics(any(), eq(7L))).thenReturn(null); + assertZeroAmounts(service.pageListDept(query()).get(0)); + WalletDepartmentStatisticsVo wallet = new WalletDepartmentStatisticsVo(); + wallet.setDeptId(7L); + wallet.setBatchIncomeSum(null); wallet.setBatchIncomeCount(null); + wallet.setBatchExpenseSum(null); wallet.setBatchExpenseCount(null); + wallet.setOnceIncomeSum(null); wallet.setOnceIncomeCount(null); + wallet.setOnceExpenseSum(null); wallet.setOnceExpenseCount(null); + wallet.setReturnCanteenSum(null); wallet.setReturnCanteenCount(null); + wallet.setRefundSum(null); wallet.setRefundCount(null); + when(wallets.departmentStatistics(any())).thenReturn(R.ok(List.of(wallet))); + when(aggregates.paymentStatistics(any(), eq(7L))).thenReturn(List.of(payment(7L, null, null))); + assertZeroAmounts(service.pageListDept(query()).get(0)); + } + + @Test + void databaseFailurePropagatesWithoutReportingCompleteMerge() { + var reporter = mock(AsyncTaskProgressReporter.class); + IllegalStateException databaseFailure = new IllegalStateException("database unavailable"); + when(aggregates.paymentStatistics(any(), eq(7L))).thenThrow(databaseFailure); + assertSame(databaseFailure, assertThrows(IllegalStateException.class, () -> service.listDeptForExport(query(), reporter))); + verify(reporter).advance("部门范围已确认"); + verify(reporter).advance("钱包六类流水汇总完成"); + verify(reporter, never()).advance("订单历史部门归集完成"); + verify(reporter, never()).advance("部门统计合并完成"); + } + + @Test + void filtersAreForwardedWithoutMutatingTheRequestOrInventingAdditionalScope() { + HardDeptTotalDto query = query(); + query.setDeptId(123L); + query.setDeptIds(new ArrayList<>(List.of(7L, 99L))); + query.setIsInclude(null); + query.setIsDeepDept(true); + query.setWalletTypeId(11L); + query.setWalletTypeIdList(new ArrayList<>(List.of(11L, 22L))); + var before = json.valueToTree(query); + service.pageListDept(query); + var deptFilter = ArgumentCaptor.forClass(DeptAllListDto.class); + var walletFilter = ArgumentCaptor.forClass(WalletTransactionSumAndCountDto.class); + var paymentFilter = ArgumentCaptor.forClass(HardOrderSumAndCountDto.class); + verify(departments).getInnerAllDept(deptFilter.capture()); + verify(wallets).departmentStatistics(walletFilter.capture()); + verify(aggregates).paymentStatistics(paymentFilter.capture(), eq(7L)); + assertNull(deptFilter.getValue().getDeptId(), "多部门条件优先于单部门条件"); + assertEquals(query.getDeptIds(), deptFilter.getValue().getDeptIds()); + assertNull(deptFilter.getValue().getIsInclude()); + assertEquals(query.getDeptIds(), walletFilter.getValue().getDeptIds()); + assertNull(walletFilter.getValue().getIsInclude()); + assertEquals(query.getWalletTypeId(), walletFilter.getValue().getWalletTypeId()); + assertEquals(query.getWalletTypeIdList(), walletFilter.getValue().getWalletTypeIdList()); + assertArrayEquals(query.getCreateTimeList(), walletFilter.getValue().getCreateTimeList()); + assertNull(walletFilter.getValue().getBizTypes(), "六类统计不能被单一业务类型截断"); + assertNull(walletFilter.getValue().getType()); + assertEquals(query.getDeptIds(), paymentFilter.getValue().getUserDeptIds()); + assertNull(paymentFilter.getValue().getIsInclude()); + assertEquals(query.getWalletTypeId(), paymentFilter.getValue().getWalletTypeId()); + assertEquals(query.getWalletTypeIdList(), paymentFilter.getValue().getWalletTypeIdList()); + assertArrayEquals(query.getCreateTimeList(), paymentFilter.getValue().getCreateTimeList()); + assertEquals(HardOrderStatusEnum.getPayPrice(), paymentFilter.getValue().getOrderStatusCodes()); + assertEquals(before, json.valueToTree(query)); + } + + @Test + void singleDepartmentAndFalseIncludeAreForwardedWithoutClearingInput() { + HardDeptTotalDto query = query(); + query.setDeptId(42L); + query.setIsInclude(false); + service.pageListDept(query); + var capture = ArgumentCaptor.forClass(DeptAllListDto.class); + verify(departments).getInnerAllDept(capture.capture()); + assertEquals(42L, capture.getValue().getDeptId()); + assertEquals(false, capture.getValue().getIsInclude()); + assertEquals(42L, query.getDeptId()); + assertEquals(false, query.getIsInclude()); + } + + @Test + void missingTenantContextFailsBeforeAnyQuery() { + TenantContextHolder.clear(); + IllegalStateException failure = assertThrows(IllegalStateException.class, () -> service.pageListDept(query())); + assertTrue(failure.getMessage().contains("租户上下文")); + verifyZeroInteractions(departments, wallets, aggregates, orders); + } + + @Test + void pageAndExportUseTheSameAggregatesAndReportFourOfSixExportStages() { + when(wallets.departmentStatistics(any())).thenReturn(R.ok(List.of(fullWallet()))); + when(aggregates.paymentStatistics(any(), eq(7L))).thenReturn(List.of(payment(7L, 5005L, 8L))); + HardDeptTotalDto dto = query(); + List page = service.pageListDept(dto); + AsyncTaskProgressReporter reporter = mock(AsyncTaskProgressReporter.class); + List exported = service.listDeptForExport(dto, reporter); + assertEquals(page, exported); + var progress = inOrder(reporter); + progress.verify(reporter).updateTotal(6, "正在汇总部门交易"); + progress.verify(reporter).advance("部门范围已确认"); + progress.verify(reporter).advance("钱包六类流水汇总完成"); + progress.verify(reporter).advance("订单历史部门归集完成"); + progress.verify(reporter).advance("部门统计合并完成"); + verifyNoMoreInteractions(reporter); + verify(wallets, times(2)).departmentStatistics(any()); + verify(aggregates, times(2)).paymentStatistics(any(), eq(7L)); + verifyNoMoreInteractions(wallets, aggregates); + verifyZeroInteractions(orders); + } + + @Test + void controllerJsonRequestUsesRealServiceAndOneWalletAggregation() throws Exception { + when(wallets.departmentStatistics(any())).thenReturn(R.ok(List.of(fullWallet()))); + when(aggregates.paymentStatistics(any(), eq(7L))).thenReturn(List.of(payment(7L, 5005L, 8L))); + var controller = new HardOrderTotalController(mock(HardBatchExportService.class), mock(HardOrderService.class), + service, managers, orders, merchants, mock(RemoteUserService.class)); + var mvc = MockMvcBuilders.standaloneSetup(controller) + .setMessageConverters(new MappingJackson2HttpMessageConverter(json)).build(); + var response = mvc.perform(post("/hard-total/page-list-dept").contentType(MediaType.APPLICATION_JSON) + .content(""" + {"deptIds":[7],"isInclude":true,"walletTypeId":11, + "walletTypeIdList":[11,22], + "createTimeList":["2026-10-20 00:00:00","2026-10-21 23:59:59"]} + """)) + .andExpect(status().isOk()).andReturn().getResponse(); + var result = json.readTree(response.getContentAsByteArray()); + assertEquals(0, result.get("code").asInt()); + var rows = result.get("data"); + assertEquals(1, rows.size()); + var row = rows.get(0); + assertEquals(7L, row.get("deptId").asLong()); + assertEquals("部门七", row.get("deptName").asText()); + assertEquals(101L, row.get("walletReducePrice").asLong()); + assertEquals(303L, row.get("walletReturnCanteenPrice").asLong()); + assertEquals(-404L, row.get("orderRefundPrice").asLong()); + assertEquals(5409L, row.get("orderProfitPrice").asLong()); + var walletFilter = ArgumentCaptor.forClass(WalletTransactionSumAndCountDto.class); + verify(wallets).departmentStatistics(walletFilter.capture()); + assertEquals(List.of(7L), walletFilter.getValue().getDeptIds()); + assertEquals(true, walletFilter.getValue().getIsInclude()); + assertEquals(11L, walletFilter.getValue().getWalletTypeId()); + assertEquals(List.of(11L, 22L), walletFilter.getValue().getWalletTypeIdList()); + assertArrayEquals(query().getCreateTimeList(), walletFilter.getValue().getCreateTimeList()); + verifyNoMoreInteractions(wallets); + verifyZeroInteractions(orders); + } + + private HardDeptTotalDto query() { + HardDeptTotalDto dto = new HardDeptTotalDto(); + dto.setCreateTimeList(new LocalDateTime[]{LocalDateTime.of(2026, 10, 20, 0, 0), + LocalDateTime.of(2026, 10, 21, 23, 59, 59)}); + return dto; + } + + private static SysDept dept(long id, String name) { + SysDept row = new SysDept(); row.setDeptId(id); row.setName(name); + return row; + } + + private static HardOrderSumAndCountVo payment(Long id, Long sum, Long count) { + HardOrderSumAndCountVo row = new HardOrderSumAndCountVo(); + row.setDeptId(id); row.setSum(sum); row.setCount(count); + return row; + } + + private static WalletDepartmentStatisticsVo fullWallet() { + WalletDepartmentStatisticsVo row = new WalletDepartmentStatisticsVo(); + row.setDeptId(7L); + row.setBatchIncomeSum(1001L); row.setBatchIncomeCount(2L); + row.setBatchExpenseSum(-101L); row.setBatchExpenseCount(3L); + row.setOnceIncomeSum(2002L); row.setOnceIncomeCount(4L); + row.setOnceExpenseSum(-202L); row.setOnceExpenseCount(5L); + row.setReturnCanteenSum(-303L); row.setReturnCanteenCount(6L); + row.setRefundSum(-404L); row.setRefundCount(7L); + return row; + } + + private static void assertZeroAmounts(HardDeptTotalVo row) { + assertEquals(BigDecimal.ZERO, row.getWalletPrice()); assertEquals(0L, row.getWalletCount()); + assertEquals(0L, row.getWalletReducePrice()); assertEquals(0L, row.getWalletReduceCount()); + assertEquals(BigDecimal.ZERO, row.getWalletOncePrice()); assertEquals(0L, row.getWalletOnceCount()); + assertEquals(BigDecimal.ZERO, row.getWalletOnceReducePrice()); assertEquals(0L, row.getWalletOnceReduceCount()); + assertEquals(BigDecimal.ZERO, row.getWalletReturnCanteenPrice()); assertEquals(0L, row.getWalletReturnCanteenCount()); + assertEquals(BigDecimal.ZERO, row.getOrderPayPrice()); assertEquals(0L, row.getOrderPayCount()); + assertEquals(BigDecimal.ZERO, row.getOrderRefundPrice()); assertEquals(0L, row.getOrderRefundCont()); + assertEquals(BigDecimal.ZERO, row.getOrderProfitPrice()); assertEquals(0L, row.getOrderProfitCont()); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardDepartmentStatisticsSqlTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardDepartmentStatisticsSqlTest.java new file mode 100644 index 000000000..67e2bb9a0 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardDepartmentStatisticsSqlTest.java @@ -0,0 +1,397 @@ +package com.bsd.pjcloud.hard.service.order; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler; +import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor; +import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum; +import com.bsd.pjcloud.hard.api.dto.order.HardOrderSumAndCountDto; +import com.bsd.pjcloud.hard.api.vo.order.HardOrderSumAndCountVo; +import com.bsd.pjcloud.hard.mapper.order.HardDepartmentStatisticsMapper; +import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.LongValue; +import org.apache.ibatis.builder.xml.XMLMapperBuilder; +import org.apache.ibatis.datasource.unpooled.UnpooledDataSource; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.session.LocalCacheScope; +import org.apache.ibatis.session.SqlSession; +import org.apache.ibatis.session.SqlSessionFactoryBuilder; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.InputStream; +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; +import java.util.UUID; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 执行真实部门聚合 XML 与 TenantLine 插件,以原逐笔订单查询、历史快照选择和 Java 归集为独立基线。 + * H2 只验证统计等价性与 SQL/插件兼容性,不将内存数据库耗时作为 MySQL/PolarDB 性能依据。 + */ +class HardDepartmentStatisticsSqlTest { + private static final LocalDateTime FROM = LocalDateTime.of(2026, 10, 20, 0, 0); + private static final LocalDateTime TO = LocalDateTime.of(2026, 10, 21, 23, 59, 59); + private static final long LARGE_ID = 9_007_199_254_740_993L; + private SqlSession session; + private Connection connection; + private long tenantId = 7L; + + private record Totals(long sum, long count) {} + + @BeforeEach + void setup() throws Exception { + var source = new UnpooledDataSource("org.h2.Driver", + "jdbc:h2:mem:department_" + UUID.randomUUID() + ";MODE=MySQL;DATABASE_TO_LOWER=TRUE", "sa", ""); + var configuration = new MybatisConfiguration(); + configuration.setEnvironment(new Environment("test", new JdbcTransactionFactory(), source)); + configuration.setMapUnderscoreToCamelCase(true); + configuration.setLocalCacheScope(LocalCacheScope.STATEMENT); + var interceptor = new MybatisPlusInterceptor(); + interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() { + @Override public Expression getTenantId() { return new LongValue(tenantId); } + })); + configuration.addInterceptor(interceptor); + for (String resource : List.of("mapper/order/HardOrderMapper.xml", "mapper/order/HardDepartmentStatisticsMapper.xml")) { + try (InputStream input = getClass().getClassLoader().getResourceAsStream(resource)) { + assertNotNull(input, resource); + // H2 MySQL 模式的 AS CHAR/AS BINARY 默认长度均为 1,MySQL 则保留完整值;仅适配这两项方言差异。 + String xml = new String(input.readAllBytes(), StandardCharsets.UTF_8) + .replaceAll("(?i)cast\\(\\s*a\\.id\\s+as\\s+char(?:\\s+character\\s+set\\s+ascii)?\\s*\\)", "CAST(a.id AS VARCHAR)") + .replaceAll("(?i)as\\s+binary\\s*\\)", "AS VARBINARY)"); + new XMLMapperBuilder(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8)), + configuration, resource, configuration.getSqlFragments()).parse(); + } + } + session = new SqlSessionFactoryBuilder().build(configuration).openSession(true); + connection = session.getConnection(); + execute(""" + create schema pjcloudx_wallet; + create table hard_order ( + id bigint primary key, tenant_id bigint, del_flag varchar(1), user_dept_id bigint, + wallet_id bigint, merchant_id bigint, order_status varchar(10), refund_status varchar(10), + pay_price bigint, create_time timestamp, pay_time timestamp); + create table pjcloudx_wallet.wallet_manage ( + id bigint primary key, tenant_id bigint, wallet_type_id bigint, del_flag varchar(1)); + create table pjcloudx_wallet.wallet_transaction ( + id bigint primary key, tenant_id bigint, del_flag varchar(1), wallet_id bigint, + biz_type varchar(10), biz_id varchar(64), dept_id bigint, price bigint, create_time timestamp); + insert into pjcloudx_wallet.wallet_manage values + (901,7,111,'0'),(902,7,222,'0'),(903,7,111,'1'),(904,8,111,'0'); + """); + seed(); + } + + @AfterEach + void close() { + if (session != null) session.close(); + } + + @Test + void completeAggregationMatchesOriginalDetailsAndLatestSnapshots() throws Exception { + assertMatchesBaseline(query(), "all departments"); + var actual = rows(query()); + assertEquals(new Totals(101, 1), actual.get(20L), "订单金额保持 pay_price,不取流水金额"); + assertEquals(new Totals(202, 1), actual.get(22L), "重复流水不放大金额或次数"); + assertFalse(actual.containsKey(10L), "订单部门已重归历史部门"); + assertFalse(actual.containsKey(21L), "旧快照部门不重复计入"); + } + + @Test + void latestValidSnapshotIgnoresDeletedNullDepartmentWrongWalletTypeAndTenant() throws Exception { + var query = only(2L); + assertMatchesBaseline(query, "latest eligible snapshot"); + assertEquals(Map.of(22L, new Totals(202, 1)), rows(query)); + } + + @Test + void snapshotTimeAndPriceDoNotRestrictHistoricalDepartment() throws Exception { + var query = only(5L); + assertMatchesBaseline(query, "snapshot outside order date interval"); + assertEquals(Map.of(23L, new Totals(505, 1)), rows(query), "区间外且正金额 PAYMENT 快照仍决定部门"); + } + + @Test + void cashMissingAndDeletedWalletsRetainOriginalFallback() throws Exception { + var query = query(); + query.setMchIds(List.of(3L, 4L, 6L, 23L, 30L)); + assertMatchesBaseline(query, "cash and historical wallet availability"); + assertEquals(Map.of(30L, new Totals(303, 1), 31L, new Totals(404, 1), + 33L, new Totals(606, 1), 43L, new Totals(2_323, 1), + 50L, new Totals(3_030, 1)), rows(query)); + query.setWalletTypeId(111L); + assertMatchesBaseline(query, "deleted wallet is still a type match"); + assertEquals(Map.of(33L, new Totals(606, 1)), rows(query)); + } + + @Test + void missingOriginalDepartmentUsesSnapshotAndMissingBothIsExcluded() throws Exception { + var query = query(); + query.setMchIds(List.of(7L, 8L)); + assertMatchesBaseline(query, "nullable original department"); + assertEquals(Map.of(24L, new Totals(707, 1)), rows(query)); + } + + @Test + void finalHistoricalDepartmentControlsIncludeExcludeAndNullSemantics() throws Exception { + for (long department : List.of(10L, 20L, 21L, 22L, 30L, 43L, 51L, -1L)) { + for (Boolean include : new Boolean[]{true, false, null}) { + var query = query(); + query.setUserDeptIds(List.of(department)); + query.setIsInclude(include); + assertMatchesBaseline(query, "final department " + department + " include " + include); + } + } + var selected = only(2L); + selected.setUserDeptIds(List.of(22L)); + selected.setIsInclude(true); + assertEquals(Map.of(22L, new Totals(202, 1)), rows(selected)); + selected.setUserDeptIds(List.of(10L, 21L)); + assertTrue(rows(selected).isEmpty(), "按旧订单部门或旧流水部门包含都不能匹配"); + selected.setIsInclude(false); + assertEquals(Map.of(22L, new Totals(202, 1)), rows(selected)); + } + + @Test + void allOptionalFiltersAndWalletTypeIntersectionMatchOriginalDetails() throws Exception { + List> filters = List.of( + q -> {}, q -> q.setWalletTypeId(111L), q -> q.setWalletTypeId(222L), + q -> q.setWalletTypeIdList(List.of(111L, 222L)), q -> q.setWalletTypeIdList(List.of()), + q -> { q.setWalletTypeId(111L); q.setWalletTypeIdList(List.of(111L, 222L)); }, + q -> { q.setWalletTypeId(111L); q.setWalletTypeIdList(List.of(222L)); }, + q -> q.setMchIds(List.of(1L, 3L, 5L, 23L)), q -> q.setMchIds(List.of()), + q -> q.setOrderStatusCodes(List.of("10")), q -> q.setOrderStatusCodes(List.of()), + q -> q.setOrderStatusCodes(null), q -> q.setIsRefundSuccess(true), + q -> q.setIsRefundSuccess(false), q -> q.setCreateTimeList(null), + q -> q.setCreateTimeList(new LocalDateTime[]{FROM}), + q -> q.setCreateTimeList(new LocalDateTime[]{FROM, FROM}), + q -> q.setCreateTimeList(new LocalDateTime[]{TO, TO}), + q -> q.setUserDeptIds(List.of()), + q -> { q.setUserDeptIds(List.of(20L, 22L)); q.setIsInclude(true); q.setWalletTypeId(111L); }, + q -> { q.setUserDeptIds(List.of(20L, 22L)); q.setIsInclude(false); q.setWalletTypeIdList(List.of(111L, 222L)); } + ); + for (int index = 0; index < filters.size(); index++) { + var query = query(); + filters.get(index).accept(query); + assertMatchesBaseline(query, "optional filter " + index); + } + var emptyIntersection = query(); + emptyIntersection.setWalletTypeId(111L); + emptyIntersection.setWalletTypeIdList(List.of(222L)); + assertTrue(rows(emptyIntersection).isEmpty()); + } + + @Test + void dateBoundariesUseOrderCreationAndStatusContract() throws Exception { + var query = query(); + query.setMchIds(List.of(11L, 12L, 13L, 14L, 15L, 16L, 17L, 18L, 29L)); + assertMatchesBaseline(query, "creation time and payment statuses"); + assertEquals(Map.of(15L, new Totals(15, 1), 16L, new Totals(16, 1), + 49L, new Totals(29, 1)), rows(query), "首尾包含,不能改成支付时间,离线与商城状态不在 getPayPrice 范围"); + } + + @Test + void stringBusinessIdsAndLargeOrderIdsMatchExactly() throws Exception { + var query = query(); + query.setMchIds(List.of(20L, 21L, 22L, 32L, LARGE_ID)); + assertMatchesBaseline(query, "business ids are exact strings"); + assertEquals(Map.of(40L, new Totals(2_020, 1), 41L, new Totals(2_121, 1), + 42L, new Totals(2_222, 1), 51L, new Totals(3_131, 1), + 52L, new Totals(3_232, 1)), rows(query), "前导零、小数形式或尾空格业务号不能替代订单业务号"); + } + + @Test + void nullNegativeZeroAndAmountsAboveIntegerRangeKeepAmountAndCount() throws Exception { + var query = query(); + query.setMchIds(List.of(19L, 24L, 25L)); + assertMatchesBaseline(query, "null negative and zero"); + assertEquals(Map.of(19L, new Totals(0, 1), 44L, new Totals(-24, 1), + 45L, new Totals(0, 1)), rows(query)); + execute("update hard_order set pay_price=2147483647 where id in (1,2)"); + execute("update pjcloudx_wallet.wallet_transaction set dept_id=20 where id=103"); + query.setMchIds(List.of(1L, 2L)); + assertMatchesBaseline(query, "sum exceeds signed int"); + assertEquals(Map.of(20L, new Totals(4_294_967_294L, 2)), rows(query)); + } + + @Test + void changingTenantIsolatesOrdersWalletTypesAndLatestSnapshots() throws Exception { + assertMatchesBaseline(query(), "tenant seven"); + tenantId = 8L; + assertMatchesBaseline(query(), "tenant eight"); + assertEquals(Map.of(80L, new Totals(1_010, 1)), rows(query())); + var query = query(); + query.setWalletTypeId(111L); + assertEquals(Map.of(80L, new Totals(1_010, 1)), rows(query)); + tenantId = 7L; + assertMatchesBaseline(query(), "tenant restored"); + assertFalse(rows(query()).containsKey(80L)); + } + + private void assertMatchesBaseline(HardOrderSumAndCountDto query, String label) throws Exception { + assertEquals(baseline(query), rows(query), label); + } + + private Map rows(HardOrderSumAndCountDto query) { + List rows = session.getMapper(HardDepartmentStatisticsMapper.class) + .paymentStatistics(query, tenantId); + Map result = new TreeMap<>(); + for (var row : rows) { + assertNotNull(row.getDeptId()); + assertNull(result.put(row.getDeptId(), new Totals( + row.getSum() == null ? 0L : row.getSum(), row.getCount() == null ? 0L : row.getCount())), + "一个部门必须只返回一条聚合结果"); + } + return result; + } + + /** 旧 SQL 先取逐笔明细;旧 Wallet 接口先过滤有效流水,再按主键倒序取相同业务号及钱包的首条。 */ + private Map baseline(HardOrderSumAndCountDto query) throws Exception { + var details = session.getMapper(HardOrderMapper.class).listPayStatisticsDetails(query); + Map result = new TreeMap<>(); + for (var detail : details) { + Long department = detail.getDeptId(); + if (detail.getWalletId() != null && detail.getBizId() != null && !detail.getBizId().isEmpty()) { + try (var statement = connection.prepareStatement(""" + select biz_type, biz_id, dept_id from pjcloudx_wallet.wallet_transaction + where tenant_id=? and wallet_id=? and biz_type='4' and biz_id=? + and del_flag='0' and dept_id is not null + order by id desc + """)) { + statement.setLong(1, tenantId); + statement.setLong(2, detail.getWalletId()); + statement.setString(3, detail.getBizId()); + try (var snapshot = statement.executeQuery()) { + while (snapshot.next()) { + // 旧接口 SQL 的宽松字符排序规则可能返回 PAD SPACE 等价值,Java Map 仍只接受完全相等的 key。 + if ("4".equals(snapshot.getString("biz_type")) && detail.getBizId().equals(snapshot.getString("biz_id"))) { + department = snapshot.getLong("dept_id"); + break; + } + } + } + } + } + if (department == null) continue; + if (query.getUserDeptIds() != null && !query.getUserDeptIds().isEmpty()) { + boolean contains = query.getUserDeptIds().contains(department); + if (Boolean.FALSE.equals(query.getIsInclude()) ? contains : !contains) continue; + } + long amount = Objects.requireNonNullElse(detail.getSum(), 0L); + long count = Objects.requireNonNullElse(detail.getCount(), 0L); + var existing = result.getOrDefault(department, new Totals(0, 0)); + result.put(department, new Totals(Math.addExact(existing.sum(), amount), Math.addExact(existing.count(), count))); + } + return result; + } + + private static HardOrderSumAndCountDto query() { + var query = new HardOrderSumAndCountDto(); + query.setOrderStatusCodes(HardOrderStatusEnum.getPayPrice()); + query.setCreateTimeList(new LocalDateTime[]{FROM, TO}); + return query; + } + + private static HardOrderSumAndCountDto only(long merchantId) { + var query = query(); + query.setMchIds(List.of(merchantId)); + return query; + } + + private void seed() throws Exception { + order(1, Map.of("user_dept_id", 10, "pay_price", 101)); + order(2, Map.of("user_dept_id", 10, "pay_price", 202, "order_status", "20")); + order(3, Map.of("user_dept_id", 30, "pay_price", 303)); + execute("update hard_order set wallet_id=null where id=3"); + order(4, Map.of("user_dept_id", 31, "wallet_id", 999, "pay_price", 404)); + order(5, Map.of("user_dept_id", 32, "wallet_id", 902, "pay_price", 505)); + order(6, Map.of("user_dept_id", 33, "wallet_id", 903, "pay_price", 606)); + order(7, Map.of("pay_price", 707)); + order(8, Map.of("pay_price", 808)); + execute("update hard_order set user_dept_id=null where id in (7,8)"); + order(10, Map.of("tenant_id", 8, "wallet_id", 904, "user_dept_id", 35, "pay_price", 1_010)); + order(11, Map.of("del_flag", "1")); + order(12, Map.of("order_status", "6")); + order(13, Map.of("order_status", "200")); + order(14, Map.of("order_status", "1")); + order(15, Map.of("user_dept_id", 15, "pay_price", 15, "create_time", FROM, "pay_time", TO.plusDays(1))); + order(16, Map.of("user_dept_id", 16, "pay_price", 16, "create_time", TO, "pay_time", FROM.minusDays(1))); + order(17, Map.of("create_time", FROM.minusSeconds(1))); + order(18, Map.of("create_time", TO.plusSeconds(1))); + order(19, Map.of("user_dept_id", 19)); + execute("update hard_order set pay_price=null where id=19"); + order(20, Map.of("user_dept_id", 40, "pay_price", 2_020)); + order(21, Map.of("user_dept_id", 41, "pay_price", 2_121)); + order(22, Map.of("user_dept_id", 42, "pay_price", 2_222)); + order(23, Map.of("user_dept_id", 43, "wallet_id", 904, "pay_price", 2_323)); + order(24, Map.of("user_dept_id", 44, "pay_price", -24)); + order(25, Map.of("user_dept_id", 45, "pay_price", 0)); + order(26, Map.of("user_dept_id", 46, "pay_price", 26, "refund_status", "20")); + order(27, Map.of("user_dept_id", 47, "pay_price", 27)); + order(28, Map.of("user_dept_id", 48, "pay_price", 28, "refund_status", "10")); + order(29, Map.of("user_dept_id", 49, "pay_price", 29, "order_status", "10")); + order(30, Map.of("user_dept_id", 34, "wallet_id", 999, "pay_price", 3_030)); + order(32, Map.of("user_dept_id", 52, "pay_price", 3_232)); + order(LARGE_ID, Map.of("user_dept_id", 36, "pay_price", 3_131)); + execute(""" + insert into pjcloudx_wallet.wallet_transaction values + (101,7,'0',901,'4','1',20,-9999,'2026-10-20 12:00:00'), + (102,7,'0',901,'4','2',21,-202,'2026-10-20 12:00:00'), + (103,7,'0',901,'4','2',22,-202,'2026-10-20 12:00:00'), + (104,7,'1',901,'4','2',88,-202,'2026-10-20 12:00:00'), + (105,7,'0',901,'4','2',null,-202,'2026-10-20 12:00:00'), + (106,8,'0',901,'4','2',99,-202,'2026-10-20 12:00:00'), + (107,7,'0',902,'4','2',98,-202,'2026-10-20 12:00:00'), + (108,7,'0',901,'3','2',97,-202,'2026-10-20 12:00:00'), + (109,7,'0',902,'4','5',23,1,'2026-09-01 12:00:00'), + (110,7,'0',901,'4','7',24,0,'2026-11-01 12:00:00'), + (111,7,'0',901,'4','020',92,-2020,'2026-10-20 12:00:00'), + (112,7,'0',901,'4','21.0',93,-2121,'2026-10-20 12:00:00'), + (113,7,'0',902,'4','22',94,-2222,'2026-10-20 12:00:00'), + (114,7,'0',999,'4','30',50,-3030,'2026-10-20 12:00:00'), + (115,7,'0',901,'4','9007199254740993',51,-3131,'2026-10-20 12:00:00'), + (116,7,'0',901,'4','32 ',95,-3232,'2026-10-20 12:00:00'), + (117,7,'0',901,'4 ','1',96,-101,'2026-10-20 12:00:00'), + (190,8,'0',904,'4','10',80,-1010,'2026-10-20 12:00:00'); + """); + } + + private void execute(String sql) throws Exception { + try (var statement = connection.createStatement()) { statement.execute(sql); } + } + + private void order(long id, Map changes) throws Exception { + Map values = new LinkedHashMap<>(); + values.put("id", id); + values.put("tenant_id", 7L); + values.put("del_flag", "0"); + values.put("user_dept_id", 60L); + values.put("wallet_id", 901L); + values.put("merchant_id", id); + values.put("order_status", "5"); + values.put("pay_price", 1_000L); + values.put("create_time", FROM.plusHours(12)); + values.put("pay_time", FROM.plusHours(12)); + values.putAll(changes); + String sql = "insert into hard_order (" + String.join(",", values.keySet()) + ") values (" + + values.keySet().stream().map(key -> "?").collect(Collectors.joining(",")) + ")"; + try (var statement = connection.prepareStatement(sql)) { + int index = 1; + for (Object value : values.values()) statement.setObject(index++, value); + statement.executeUpdate(); + } + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardOrderCsMySqlTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardOrderCsMySqlTest.java new file mode 100644 index 000000000..34a427bd9 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardOrderCsMySqlTest.java @@ -0,0 +1,218 @@ +package com.bsd.pjcloud.hard.service.order; + +import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto; +import com.bsd.pjcloud.hard.api.vo.order.HardOrderCsVo; +import org.apache.ibatis.datasource.unpooled.UnpooledDataSource; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 显式启用的真实 MySQL 回归/压测。业务表只读;造数及加索引仅在当前连接的临时表执行,连接关闭自动清理。 + * HARD_CS_MYSQL_TEST=true MYSQL_HOST=... MYSQL_PORT=... MYSQL_USER=... MYSQL_PWD=... mvn ... -Dtest=HardOrderCsMySqlTest test + */ +@EnabledIfEnvironmentVariable(named = "HARD_CS_MYSQL_TEST", matches = "true") +class HardOrderCsMySqlTest { + private HardOrderCsSqlTest open(long tenant) throws Exception { + var database = new HardOrderCsSqlTest(); + database.tenantId = tenant; + String url = "jdbc:mysql://" + System.getenv("MYSQL_HOST") + ":" + System.getenv("MYSQL_PORT") + + "/pjcloudx_hardware?useSSL=false&allowPublicKeyRetrieval=true&allowMultiQueries=true&serverTimezone=Asia/Shanghai"; + database.open(new UnpooledDataSource("com.mysql.cj.jdbc.Driver", url, System.getenv("MYSQL_USER"), System.getenv("MYSQL_PWD"))); + database.execute("set session time_zone='+08:00'; set session max_execution_time=30000"); + return database; + } + + @Test + void existingTestDataMatchesOriginalSqlWithoutChangingBusinessTables() throws Exception { + var database = open(1); + try { + List> cases = List.of( + q -> {}, q -> q.setPayTimeYearStr("2026"), q -> q.setMchTypeCodes(List.of("1", "2", "3")), + q -> q.setMerchantType("1"), q -> q.setWalletTypeId(-1L), + q -> q.setWalletTypeIdList(List.of(-1L, -2L)), q -> q.setHourType("1"), + q -> { q.setMerchantDeptIds(List.of(-1L)); q.setIsIncludeMerchantDept(false); }, + q -> { q.setUserDeptIds(List.of(-1L)); q.setIsInclude(false); } + ); + for (int index = 0; index < cases.size(); index++) { + var query = HardOrderCsSqlTest.query(); + query.setPayTimeList(new LocalDateTime[]{LocalDateTime.of(2025,1,1,0,0), LocalDateTime.of(2026,10,22,23,59,59)}); + cases.get(index).accept(query); + database.assertSameAsBaseline(query, HardOrderCsSqlTest.scope(), "real data " + index); + } + System.out.println("MYSQL_CS_REGRESSION existing_business_data cases=9 payment_and_refund_equal=true"); + } finally { database.close(); } + } + + @Test + void indexMigrationCanBeRepeatedAndRecognizesEquivalentIndexes() throws Exception { + var database = open(7); + String schema = "codex_cs_index_" + java.util.UUID.randomUUID().toString().replace("-", ""); + try { + database.execute("create database `"+schema+"`"); + database.execute("create table `"+schema+"`.hard_order like pjcloudx_hardware.hard_order; " + + "create table `"+schema+"`.hard_after_sale like pjcloudx_hardware.hard_after_sale"); + java.nio.file.Path root = java.nio.file.Path.of("").toAbsolutePath(); + while (root != null && !java.nio.file.Files.isRegularFile(root.resolve("db/20261022_hard_cs_statistics_indexes.sql"))) root = root.getParent(); + assertNotNull(root); + String migration = java.nio.file.Files.readString(root.resolve("db/20261022_hard_cs_statistics_indexes.sql")).replace("pjcloudx_hardware", schema); + database.execute(migration); + int first = indexCount(database, schema); + database.execute(migration); + assertEquals(first, indexCount(database, schema), "重复执行不新增索引"); + database.execute("alter table `"+schema+"`.hard_order rename index idx_hard_order_cs_pay_cover to equivalent_pay_cover; " + + "alter table `"+schema+"`.hard_after_sale rename index idx_hard_after_sale_cs_audit to equivalent_refund_audit"); + database.execute(migration); + assertEquals(first, indexCount(database, schema), "不同名称的等效索引也应跳过"); + System.out.println("MYSQL_CS_MIGRATION repeat_and_equivalent_indexes_passed=true"); + } finally { + try { database.execute("drop database if exists `"+schema+"`"); } + finally { database.close(); } + } + } + + private int indexCount(HardOrderCsSqlTest database, String schema) throws Exception { + try (var statement = database.connection.prepareStatement("select count(distinct table_name,index_name) from information_schema.statistics where table_schema=?")) { + statement.setString(1,schema); + try (var result = statement.executeQuery()) { assertTrue(result.next()); return result.getInt(1); } + } + } + + @Test + @EnabledIfEnvironmentVariable(named = "HARD_CS_MYSQL_BENCHMARK", matches = "true") + void millionOrderAndMillionRefundBenchmarkUsesOnlyConnectionLocalTemporaryTables() throws Exception { + var database = open(7); + try { + cloneTemporary(database, "pjcloudx_hardware", "hard_order"); + cloneTemporary(database, "pjcloudx_hardware", "hard_after_sale"); + cloneTemporary(database, "pjcloudx_wallet", "wallet_manage"); + // 复制实际表的结构及原索引,避免宽行成本和索引差异使对比失真。 + String numbers = """ + with digits as (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), + numbers as (select 1+a.n+10*b.n+100*c.n+1000*d.n+10000*e.n+100000*f.n n + from digits a cross join digits b cross join digits c cross join digits d cross join digits e cross join digits f) + """; + System.out.println("MYSQL_CS_BENCHMARK preparing temporary tables: orders=1000000 refunds=1000000 distinctRefundOrders=1000000"); + database.execute(""" + insert into hard_order (id,user_id,user_dept_id,merchant_id,merchant_type,merchant_dept_id,hard_id,dept_id,wallet_id, + order_type,order_status,pay_type,hour_type,total_price,pay_price,pay_time,create_time,del_flag,tenant_id) + """ + numbers + """ + select n, 1+mod(n,10000), 20+mod(n,50), 1000+mod(n,997), cast(1+mod(n,4) as char), cast(10+mod(n,25) as char), + 501,99,100000+mod(n,10000),'1',case when mod(n,23)=0 then '1' when mod(n,13)=0 then '10' when mod(n,7)=0 then '20' else '5' end, + '1',cast(1+mod(n,3) as char),100+mod(n,10000),100+mod(n,10000), + timestamp('2026-10-21 12:00:00')-interval mod(n,730) day, + timestamp('2026-10-21 12:00:00')-interval mod(n,730) day, + if(mod(n,97)=0,'1','0'),if(mod(n,10)=0,8,7) from numbers + """); + database.execute(""" + insert into hard_after_sale (id,no,status,way,user_id,apply_reason,order_id,audit_time,refund_price,del_flag,tenant_id) + """ + numbers + """ + select n,concat('benchmark-',n),if(mod(n,11)=0,'10','50'),'1',1,'benchmark',n, + timestamp('2026-10-21 12:00:00')-interval mod(n,730) day,1+mod(n,100),if(mod(n,97)=0,'1','0'), + if(mod(n,10)=0,8,7) from numbers + """); + database.execute(""" + insert into pjcloudx_wallet.wallet_manage (id,user_id,wallet_type_id,dept_id,tenant_id) + """ + numbers + """ + select 100000+n,1+n,111+mod(n,3),10,7 from numbers where n<10000 + """); + var query = HardOrderCsSqlTest.query(); + query.setPayTimeList(new LocalDateTime[]{LocalDateTime.of(2026,7,22,0,0),LocalDateTime.of(2026,10,22,23,23,23)}); + if (!Boolean.getBoolean("hard.cs.benchmark.finalOnly")) benchmark(database, query, "original_indexes"); + database.execute(""" + create index idx_hard_after_sale_cs_audit on hard_after_sale (tenant_id,del_flag,status,audit_time,order_id,refund_price); + """); + if (!Boolean.getBoolean("hard.cs.benchmark.finalOnly")) benchmark(database, query, "refund_time_index"); + database.execute(""" + create index idx_hard_order_cs_pay_cover on hard_order + (tenant_id,del_flag,pay_time,order_status,merchant_type,merchant_id,merchant_dept_id,pay_price,wallet_id,user_dept_id,hour_type); + """); + benchmark(database, query, "pay_and_refund_indexes"); + for (String scenario : List.of("single_day", "merchant", "departments", "user_departments", "hour", "wallet")) { + var filtered = cn.hutool.core.bean.BeanUtil.toBean(query, HardOrderDto.class); + if (scenario.equals("single_day")) filtered.setPayTimeList(HardOrderCsSqlTest.query().getPayTimeList()); + if (scenario.equals("merchant")) filtered.setMerchantId(1001L); + if (scenario.equals("departments")) { filtered.setMerchantDeptIds(List.of(10L,11L)); filtered.setIsIncludeMerchantDept(true); } + if (scenario.equals("user_departments")) { filtered.setUserDeptIds(List.of(20L,21L)); filtered.setIsInclude(true); } + if (scenario.equals("hour")) filtered.setHourType("1"); + if (scenario.equals("wallet")) filtered.setWalletTypeIdList(List.of(111L)); + benchmark(database, filtered, scenario); + } + } finally { database.close(); } + } + + private void cloneTemporary(HardOrderCsSqlTest database, String schema, String table) throws Exception { + String create; + try (var statement = database.connection.createStatement(); var result = statement.executeQuery("show create table `"+schema+"`.`"+table+"`")) { + assertTrue(result.next()); + create = result.getString(2); + } + database.execute("create temporary table `"+schema+"`.`"+table+"` " + create.substring(create.indexOf('('))); + } + + private void benchmark(HardOrderCsSqlTest database, HardOrderDto query, String scenario) throws Exception { + for (String suffix : List.of("listCs", "listRefundCs")) { + var dto = suffix.equals("listCs") ? query : HardOrderCsSqlTest.refund(query); + var parameters = Map.of("dto",dto,"scope",HardOrderCsSqlTest.scope()); + List baseline = database.rows("hardCsBaseline."+suffix,dto,HardOrderCsSqlTest.scope()); + List optimized = database.rows(HardOrderCsSqlTest.MAPPER+suffix,dto,HardOrderCsSqlTest.scope()); + assertEquals(HardOrderCsSqlTest.byMerchant(baseline),HardOrderCsSqlTest.byMerchant(optimized),scenario+" "+suffix); + for (String mapper : Boolean.getBoolean("hard.cs.benchmark.finalOnly") ? List.of(HardOrderCsSqlTest.MAPPER) : List.of("hardCsBaseline.", HardOrderCsSqlTest.MAPPER)) { + var samples = new ArrayList(); + for (int run = 0; run < 3; run++) { + long started = System.nanoTime(); + database.rows(mapper+suffix,dto,HardOrderCsSqlTest.scope()); + samples.add((System.nanoTime()-started)/1_000_000D); + } + samples.sort(Comparator.naturalOrder()); + System.out.printf("MYSQL_CS_BENCHMARK scenario=%s query=%s variant=%s rows=%d medianMillis=%.2f samples=%s%n", + scenario,suffix,mapper.equals("hardCsBaseline.")?"baseline":"optimized",optimized.size(),samples.get(1),samples); + } + if (!Boolean.getBoolean("hard.cs.benchmark.finalOnly") && suffix.equals("listRefundCs") && !scenario.equals("merchant")) { + String direct = "hardCsBaseline.listRefundCsDirect"; + String grouped = "hardCsBaseline.listRefundCsGrouped"; + assertEquals(HardOrderCsSqlTest.byMerchant(baseline), HardOrderCsSqlTest.byMerchant(database.rows(direct,dto,HardOrderCsSqlTest.scope()))); + var directSamples = new ArrayList(); + var groupedSamples = new ArrayList(); + for (int run = 0; run < 5; run++) { + for (String candidate : run % 2 == 0 ? List.of(direct,grouped) : List.of(grouped,direct)) { + long started = System.nanoTime(); + assertEquals(HardOrderCsSqlTest.byMerchant(baseline), HardOrderCsSqlTest.byMerchant(database.rows(candidate,dto,HardOrderCsSqlTest.scope()))); + (candidate.equals(direct)?directSamples:groupedSamples).add((System.nanoTime()-started)/1_000_000D); + } + } + directSamples.sort(Comparator.naturalOrder()); + groupedSamples.sort(Comparator.naturalOrder()); + System.out.printf("MYSQL_CS_REFUND_CANDIDATES scenario=%s directMedianMillis=%.2f groupedMedianMillis=%.2f%n",scenario,directSamples.get(2),groupedSamples.get(2)); + } + var mapped = database.configuration.getMappedStatement(HardOrderCsSqlTest.MAPPER+suffix); + var bound = mapped.getBoundSql(parameters); + // EXPLAIN 使用实际插件生成的 SQL(含租户),避免无租户条件的计划误导。 + var tenantPlugin = new com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor(new com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler() { + @Override public net.sf.jsqlparser.expression.Expression getTenantId() { return new net.sf.jsqlparser.expression.LongValue(7); } + }); + tenantPlugin.beforeQuery(null,mapped,parameters,org.apache.ibatis.session.RowBounds.DEFAULT,null,bound); + try (var statement = database.connection.prepareStatement("explain analyze "+bound.getSql())) { + new org.apache.ibatis.scripting.defaults.DefaultParameterHandler(mapped,parameters,bound).setParameters(statement); + try (var plan = statement.executeQuery()) { + assertTrue(plan.next()); + String result = plan.getString(1); + System.out.printf("MYSQL_CS_PLAN scenario=%s query=%s%n%s%n",scenario,suffix,result); + if ((scenario.equals("pay_and_refund_indexes") || scenario.equals("single_day")) && suffix.equals("listRefundCs")) + assertTrue(result.contains("idx_hard_after_sale_cs_audit"),result); + if (scenario.equals("pay_and_refund_indexes") && suffix.equals("listCs")) + assertTrue(result.contains("idx_hard_order_cs_pay_cover"),result); + } + } + } + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardOrderCsServiceTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardOrderCsServiceTest.java new file mode 100644 index 000000000..bb2c94780 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardOrderCsServiceTest.java @@ -0,0 +1,153 @@ +package com.bsd.pjcloud.hard.service.order; + +import com.bsd.pjcloud.admin.api.entity.SysDept; +import com.bsd.pjcloud.admin.api.feign.RemoteDeptService; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.security.service.PjcloudUser; +import com.bsd.pjcloud.hard.api.dto.hard.CurrentUserHardTypeInfoDto; +import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto; +import com.bsd.pjcloud.hard.api.vo.order.HardOrderCsVo; +import com.bsd.pjcloud.hard.mapper.manager.HardManagerMapper; +import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper; +import com.bsd.pjcloud.hard.service.HardManagerService; +import com.bsd.pjcloud.hard.service.impl.order.HardOrderTotalServiceImpl; +import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; +import com.bsd.pjcloud.wallet.api.feign.RemoteWalletTransactionService; +import com.bsd.pjcloud.wallet.api.vo.MchManageVo; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.LongStream; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class HardOrderCsServiceTest { + private final HardOrderMapper mapper = mock(HardOrderMapper.class); + private final HardManagerService managers = mock(HardManagerService.class); + private final RemoteMchService merchants = mock(RemoteMchService.class); + private final RemoteDeptService departments = mock(RemoteDeptService.class); + private final HardOrderTotalServiceImpl service = new HardOrderTotalServiceImpl(mapper, mock(HardManagerMapper.class), + managers, merchants, mock(RemoteWalletTransactionService.class), departments, + mock(com.bsd.pjcloud.hard.mapper.order.HardDepartmentStatisticsMapper.class)); + + @BeforeEach + void setup() { + var user = new PjcloudUser(1L, "alice", 10L, null, null, null, null, null, 7L, "test", + true, true, "0", true, true, List.of()); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user, "", user.getAuthorities())); + var permission = new CurrentUserHardTypeInfoDto(); + permission.setCanteenView(true); + when(managers.getCurrentUserHardTypeInfo(any())).thenReturn(permission); + when(mapper.listCs(any(), any())).thenReturn(List.of()); + when(mapper.listRefundCs(any(), any())).thenReturn(List.of()); + when(merchants.getByIds(anyList())).thenReturn(R.ok(List.of())); + when(departments.getDeptByDeptIds(anyList())).thenReturn(R.ok(Map.of())); + } + + @AfterEach + void clear() { SecurityContextHolder.clearContext(); } + + @Test + void mixedRowsPreserveOrderExactNetAmountsAndCommission() { + when(mapper.listCs(any(), any())).thenReturn(List.of(row(2, 1001, 2), row(1, 201, 1))); + when(mapper.listRefundCs(any(), any())).thenReturn(List.of(row(1, 101, 2), row(3, 99, 1))); + var merchant = new MchManageVo(); + merchant.setId(1L); + merchant.setName("商户一"); + merchant.setPlatformRanke(12.345); + when(merchants.getByIds(anyList())).thenReturn(R.ok(List.of(merchant))); + var department = new SysDept(); + department.setDeptId(10L); + department.setName("交易时商户部门"); + when(departments.getDeptByDeptIds(List.of(10L))).thenReturn(R.ok(Map.of(10L, department))); + var result = service.listCs(HardOrderCsSqlTest.query()); + assertEquals(List.of(2L, 1L, 3L), result.stream().map(HardOrderCsVo::getMerchantId).toList()); + var target = result.get(1); + assertEquals(new BigDecimal("100"), target.getTotalProfitPrice()); + assertEquals(new BigDecimal("-1"), target.getTotalProfitCount()); + assertEquals(new BigDecimal("12.35"), target.getMchPlatformRankePrice()); + assertEquals("商户一", target.getMerchantName()); + assertEquals("交易时商户部门", target.getMerchantDeptName()); + assertNull(result.get(0).getTotalRefundPrice()); + assertEquals(new BigDecimal("1001"), result.get(0).getTotalProfitPrice()); + assertEquals(new BigDecimal("-99"), result.get(2).getTotalProfitPrice()); + } + + @Test + void intervalContainingOnlyRefundsStillReturnsNegativeNetTotals() { + when(mapper.listRefundCs(any(), any())).thenReturn(List.of(row(1, 250, 2))); + var result = service.listCs(HardOrderCsSqlTest.query()); + assertEquals(1, result.size()); + assertEquals(BigDecimal.ZERO, result.get(0).getTotalPayPrice()); + assertEquals(BigDecimal.ZERO, result.get(0).getTotalPayCount()); + assertEquals(new BigDecimal("250"), result.get(0).getTotalRefundPrice()); + assertEquals(new BigDecimal("-250"), result.get(0).getTotalProfitPrice()); + assertEquals(new BigDecimal("-2"), result.get(0).getTotalProfitCount()); + } + + @Test + void emptyResultsAvoidMetadataRemoteCalls() { + assertTrue(service.listCs(HardOrderCsSqlTest.query()).isEmpty()); + verifyNoMoreInteractions(merchants, departments); + } + + @Test + void paymentAndRefundKeepSeparateDatesStatusesAndMerchantPermission() { + var query = HardOrderCsSqlTest.query(); + var dates = query.getPayTimeList().clone(); + service.listCs(query); + var payments = ArgumentCaptor.forClass(HardOrderDto.class); + var refunds = ArgumentCaptor.forClass(HardOrderDto.class); + verify(mapper).listCs(payments.capture(), any()); + verify(mapper).listRefundCs(refunds.capture(), any()); + assertArrayEquals(dates, payments.getValue().getPayTimeList()); + assertNull(refunds.getValue().getPayTimeList()); + assertArrayEquals(dates, refunds.getValue().getRefundTimeList()); + assertEquals(List.of("50"), refunds.getValue().getRefundStatusCodes()); + assertEquals(List.of("5", "20", "10"), payments.getValue().getOrderStatusCodes()); + assertEquals(payments.getValue().getOrderStatusCodes(), refunds.getValue().getOrderStatusCodes()); + assertEquals(List.of("1"), refunds.getValue().getMchTypeCodes()); + assertArrayEquals(dates, query.getPayTimeList()); + assertNull(query.getRefundTimeList()); + } + + @Test + void manyMerchantsUseBoundedBatchesAndOneDepartmentLookup() { + List rows = LongStream.rangeClosed(1, 401).mapToObj(id -> row(id, 100, 1)).toList(); + when(mapper.listCs(any(), any())).thenReturn(rows); + when(mapper.listRefundCs(any(), any())).thenReturn(LongStream.rangeClosed(1, 401).mapToObj(id -> row(id, 30, 1)).toList()); + List> batches = new ArrayList<>(); + when(merchants.getByIds(anyList())).thenAnswer(call -> { + List ids = call.getArgument(0); + batches.add(ids); + return R.ok(ids.stream().map(id -> { var merchant = new MchManageVo(); merchant.setId(id); merchant.setPlatformRanke(10D); return merchant; }).toList()); + }); + var result = service.listCs(HardOrderCsSqlTest.query()); + assertEquals(401, result.size()); + assertEquals(List.of(200, 200, 1), batches.stream().map(List::size).toList()); + verify(departments, times(1)).getDeptByDeptIds(List.of(10L)); + for (var row : result) { + assertEquals(new BigDecimal("70"), row.getTotalProfitPrice()); + assertEquals(new BigDecimal("7.00"), row.getMchPlatformRankePrice()); + } + } + + private static HardOrderCsVo row(long id, int amount, int count) { + var row = new HardOrderCsVo(); + row.setMerchantId(id); + row.setMerchantDeptId(10L); + row.setTotalPayPrice(BigDecimal.valueOf(amount)); + row.setTotalPayCount(BigDecimal.valueOf(count)); + return row; + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardOrderCsSqlTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardOrderCsSqlTest.java new file mode 100644 index 000000000..e27297cef --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/order/HardOrderCsSqlTest.java @@ -0,0 +1,376 @@ +package com.bsd.pjcloud.hard.service.order; + +import cn.hutool.core.bean.BeanUtil; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler; +import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor; +import com.bsd.pjcloud.admin.api.feign.RemoteDeptService; +import com.bsd.pjcloud.admin.api.feign.RemoteUserService; +import com.bsd.pjcloud.common.core.jackson.PjcloudJavaTimeModule; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.data.datascope.DataScope; +import com.bsd.pjcloud.common.data.datascope.DataScopeInnerInterceptor; +import com.bsd.pjcloud.common.security.service.PjcloudUser; +import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum; +import com.bsd.pjcloud.hard.api.dto.hard.CurrentUserHardTypeInfoDto; +import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto; +import com.bsd.pjcloud.hard.api.vo.order.HardOrderCsVo; +import com.bsd.pjcloud.hard.controller.order.HardOrderTotalController; +import com.bsd.pjcloud.hard.mapper.manager.HardManagerMapper; +import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper; +import com.bsd.pjcloud.hard.service.HardManagerService; +import com.bsd.pjcloud.hard.service.HardOrderService; +import com.bsd.pjcloud.hard.service.impl.order.HardBatchExportService; +import com.bsd.pjcloud.hard.service.impl.order.HardOrderTotalServiceImpl; +import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; +import com.bsd.pjcloud.wallet.api.feign.RemoteWalletTransactionService; +import com.bsd.pjcloud.wallet.api.vo.MchManageVo; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.InputStream; +import java.math.BigDecimal; +import java.sql.Connection; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.LongValue; +import org.apache.ibatis.builder.xml.XMLMapperBuilder; +import org.apache.ibatis.datasource.unpooled.UnpooledDataSource; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.session.LocalCacheScope; +import org.apache.ibatis.session.SqlSession; +import org.apache.ibatis.session.SqlSessionFactoryBuilder; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** 执行真实 XML 和租户、部门插件,与保存的优化前 SQL 对比金额、次数及筛选。 */ +class HardOrderCsSqlTest { + static final String MAPPER = HardOrderMapper.class.getName() + "."; + SqlSession session; + Connection connection; + MybatisConfiguration configuration; + boolean allAccess = true; + long tenantId = 7L; + + @BeforeEach + void setup() throws Exception { + open(new UnpooledDataSource("org.h2.Driver", + "jdbc:h2:mem:merchant_" + UUID.randomUUID() + ";MODE=MySQL;DATABASE_TO_LOWER=TRUE", "sa", "")); + execute(""" + create schema pjcloudx_wallet; + create table hard_order ( + id bigint primary key, tenant_id bigint, del_flag varchar(1), order_no varchar(50), + user_id bigint, user_dept_id bigint, merchant_id bigint, merchant_type varchar(10), merchant_dept_id varchar(64), + hard_id bigint, dept_id bigint, wallet_id bigint, order_type varchar(10), order_status varchar(10), + pay_type varchar(10), hour_type varchar(10), pay_price int, refund_price int, refund_status varchar(10), + is_offline_order varchar(1), product_desc varchar(100), create_time timestamp, pay_time timestamp); + create table hard_after_sale ( + id bigint primary key, order_id bigint, tenant_id bigint, del_flag varchar(1), + status varchar(10), audit_time timestamp, refund_price int); + create table hard_manager (id bigint primary key, tenant_id bigint, type varchar(10)); + create table pjcloudx_wallet.wallet_manage ( + id bigint primary key, wallet_type_id bigint, tenant_id bigint, del_flag varchar(1)); + insert into hard_manager values (501,7,'1'),(502,7,'2'); + insert into pjcloudx_wallet.wallet_manage values (901,111,7,'0'),(902,222,7,'0'),(903,111,7,'1'),(904,111,8,'0'); + """); + seedOrder(1, Map.of()); + seedOrder(2, Map.of("pay_price", 500, "order_status", "10")); + seedOrder(3, Map.of("merchant_id", 200, "merchant_type", "2", "merchant_dept_id", "11", "wallet_id", 902, + "hard_id", 502, "pay_time", "2026-09-20 12:00:00", "pay_price", 300)); + seedOrder(4, Map.of("merchant_id", 300, "merchant_type", "3", "merchant_dept_id", "12", "wallet_id", 999, + "hard_id", 502, "pay_price", 400, "user_dept_id", 30, "hour_type", "2")); + seedOrder(5, Map.of("merchant_id", 400, "merchant_dept_id", "12", "wallet_id", 903, "pay_price", 600)); + seedOrder(6, Map.of("merchant_id", 500, "wallet_id", 904, "pay_price", 700)); + seedOrder(7, Map.of("tenant_id", 8, "merchant_id", 600)); + seedOrder(8, Map.of("del_flag", "1")); + seedOrder(9, Map.of("order_status", "30")); + seedOrder(10, Map.of("merchant_id", -1)); + execute("update hard_order set merchant_id=null where id=10"); + seedOrder(11, Map.of("pay_time", "2026-10-20 00:00:00", "pay_price", 10)); + seedOrder(12, Map.of("pay_time", "2026-10-21 23:59:59", "pay_price", 20)); + seedOrder(13, Map.of()); + execute("update hard_order set pay_price=null where id=13"); + seedOrder(14, Map.of("pay_time", "2026-10-22 00:00:00")); + execute(""" + insert into hard_after_sale values + (1,2,7,'0','50','2026-10-20 00:00:00',100), + (2,2,7,'0','50','2026-10-21 23:59:59',150), + (3,3,7,'0','50','2026-10-20 12:00:00',90), + (4,2,7,'0','10','2026-10-20 12:00:00',5000), + (5,2,7,'1','50','2026-10-20 12:00:00',5000), + (6,7,8,'0','50','2026-10-20 12:00:00',5000), + (7,8,7,'0','50','2026-10-20 12:00:00',5000), + (8,9,7,'0','50','2026-10-20 12:00:00',5000), + (9,2,7,'0','50','2026-10-22 00:00:00',5000), + (10,2,8,'0','50','2026-10-20 12:00:00',5000); + """); + } + + void open(UnpooledDataSource source) throws Exception { + configuration = new MybatisConfiguration(); + configuration.setEnvironment(new Environment("test", new JdbcTransactionFactory(), source)); + configuration.setMapUnderscoreToCamelCase(true); + configuration.setLocalCacheScope(LocalCacheScope.STATEMENT); + var interceptor = new MybatisPlusInterceptor(); + interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() { + @Override public Expression getTenantId() { return new LongValue(tenantId); } + })); + var dataScope = new DataScopeInnerInterceptor(); + dataScope.setDataScopeHandle(scope -> allAccess); + interceptor.addInnerInterceptor(dataScope); + configuration.addInterceptor(interceptor); + for (String resource : List.of("mapper/order/HardOrderMapper.xml", "mapper/order/HardOrderCsBaselineMapper.xml")) { + try (InputStream input = getClass().getClassLoader().getResourceAsStream(resource)) { + assertNotNull(input); + new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse(); + } + } + session = new SqlSessionFactoryBuilder().build(configuration).openSession(true); + connection = session.getConnection(); + } + + @AfterEach + void close() { + if (session != null) session.close(); + } + + @Test + void allPageFiltersMatchTheOriginalPaymentAndRefundSql() { + List> filters = List.of( + q -> {}, q -> q.setMerchantId(100L), q -> q.setMchIds(List.of(100L, 200L)), + q -> q.setMerchantType("2"), q -> q.setMchTypeCodes(List.of("1", "3")), + q -> q.setWalletTypeId(111L), q -> q.setWalletTypeIdList(List.of(111L, 222L)), + q -> { q.setWalletTypeId(111L); q.setWalletTypeIdList(List.of(222L)); }, + q -> q.setHourType("2"), q -> q.setHourTypeIsNotEmpty(true), + q -> q.setUserDeptIds(List.of(20L)), + q -> { q.setUserDeptIds(List.of(20L)); q.setIsInclude(true); }, + q -> q.setMerchantDeptIds(List.of(10L)), + q -> { q.setMerchantDeptIds(List.of(10L)); q.setIsIncludeMerchantDept(true); }, + q -> q.setUserDeptId(30L), q -> q.setDeptId(99L), q -> q.setDeptIds(List.of(99L)), + q -> { q.setDeptIds(List.of(99L)); q.setIsInclude(true); }, + q -> q.setOrderIds(List.of(1L, 2L, 3L)), q -> q.setId(2L), q -> q.setOrderNo("order-2"), + q -> q.setHardId(502L), q -> q.setUserId(1L), q -> q.setUserIds(List.of(1L)), + q -> q.setOrderType("1"), q -> q.setOrderStatus("10"), q -> q.setOrderPayTypeCodes(List.of("1")), + q -> q.setIsOfflineOrder("0"), q -> q.setProductDescIsNotEmpty(true), + q -> q.setCreateTimeList(new LocalDateTime[]{LocalDateTime.of(2026,10,20,0,0), LocalDateTime.of(2026,10,21,0,0)}), + q -> q.setPayTimeList(null), q -> q.setWalletTypeIdList(List.of()), + q -> q.setThirdRefundState("NONE"), q -> q.setThirdRefundState("PARTIAL"), + q -> q.setThirdRefundState("FULL"), q -> q.setThirdRefundState("PROCESSING") + ); + for (int index = 0; index < filters.size(); index++) { + var query = query(); + filters.get(index).accept(query); + assertSameAsBaseline(query, scope(), "filter " + index); + } + } + + @Test + void absentWalletsAndDeletedWalletsKeepTheExistingStatisticsContract() { + var payments = byMerchant(rows(MAPPER + "listCs", query(), scope())); + assertTrue(payments.containsKey(300L), "钱包不存在仍统计历史支付"); + assertTrue(payments.containsKey(500L), "没有钱包筛选时,钱包不改变订单租户隔离"); + var query = query(); + query.setWalletTypeId(111L); + payments = byMerchant(rows(MAPPER + "listCs", query, scope())); + assertTrue(payments.containsKey(400L), "保持原接口对已删除钱包的筛选口径"); + assertFalse(payments.containsKey(300L)); + assertFalse(payments.containsKey(500L)); + } + + @Test + void eachCompletedRefundCountsOnceAndUsesItsOwnTimeRange() { + var payments = byMerchant(rows(MAPPER + "listCs", query(), scope())); + assertEquals(0, new BigDecimal("1530").compareTo(payments.get(100L).getTotalPayPrice())); + assertEquals(0, new BigDecimal("5").compareTo(payments.get(100L).getTotalPayCount())); + assertFalse(payments.containsKey(200L)); + assertFalse(payments.containsKey(600L)); + var refunds = byMerchant(rows(MAPPER + "listRefundCs", refund(query()), scope())); + assertEquals(0, new BigDecimal("250").compareTo(refunds.get(100L).getTotalPayPrice())); + assertEquals(0, new BigDecimal("2").compareTo(refunds.get(100L).getTotalPayCount())); + assertEquals(0, new BigDecimal("90").compareTo(refunds.get(200L).getTotalPayPrice())); + assertEquals(2, refunds.size()); + } + + @Test + void noWalletFilterProducesNoWalletLookupsAndOptionalDeviceFilterWorksOnBothQueries() { + for (String name : List.of("listCs", "listRefundCs")) { + var statement = configuration.getMappedStatement(MAPPER + name); + assertFalse(statement.getBoundSql(Map.of("dto", query())).getSql().contains("wallet_manage")); + } + var query = query(); + query.setHardTypeCodes(List.of("2")); + assertEquals(List.of(300L), rows(MAPPER + "listCs", query, scope()).stream().map(HardOrderCsVo::getMerchantId).toList()); + assertEquals(List.of(200L), rows(MAPPER + "listRefundCs", refund(query), scope()).stream().map(HardOrderCsVo::getMerchantId).toList()); + } + + @Test + void broadRefundQueryMatchesTheMeasuredDirectJoinCandidate() { + List> cases = List.of(q -> {}, q -> q.setWalletTypeIdList(List.of(111L)), + q -> q.setUserDeptIds(List.of(20L)), q -> q.setMerchantDeptIds(List.of(10L)), q -> q.setHourType("1")); + for (var filter : cases) { + var query = query(); + filter.accept(query); + var parameters = Map.of("dto", refund(query)); + var actual = configuration.getMappedStatement(MAPPER+"listRefundCs").getBoundSql(parameters); + var measured = configuration.getMappedStatement("hardCsBaseline.listRefundCsDirect").getBoundSql(parameters); + assertEquals(measured.getSql().replaceAll("\\s+", "").toLowerCase(java.util.Locale.ROOT), + actual.getSql().replaceAll("\\s+", "").toLowerCase(java.util.Locale.ROOT)); + assertEquals(measured.getParameterMappings().stream().map(org.apache.ibatis.mapping.ParameterMapping::getProperty).toList(), + actual.getParameterMappings().stream().map(org.apache.ibatis.mapping.ParameterMapping::getProperty).toList()); + } + } + + @Test + void departmentPermissionsAndEmptyPermissionsMatchTheExistingInterceptor() { + allAccess = false; + for (List departments : List.of(List.of(10L), List.of(11L, 12L), List.of())) { + var scope = scope(); + scope.setDeptList(new ArrayList<>(departments)); + assertSameAsBaseline(query(), scope, "departments " + departments); + for (var row : rows(MAPPER + "listCs", query(), scope)) { + assertTrue(departments.contains(row.getMerchantDeptId())); + } + } + } + + @Test + void largeAmountsKeepBigDecimalPrecision() throws Exception { + execute("update hard_order set pay_price=2147483647 where merchant_id=100"); + assertSameAsBaseline(query(), scope(), "large amount"); + assertEquals(0, new BigDecimal("10737418235").compareTo(byMerchant(rows(MAPPER + "listCs", query(), scope())).get(100L).getTotalPayPrice())); + } + + @Test + void httpRequestRunsControllerServiceAndRealSqlIncludingChildMerchantsAndRefundOnlyRows() throws Exception { + var managers = mock(HardManagerService.class); + var merchants = mock(RemoteMchService.class); + var departments = mock(RemoteDeptService.class); + var allTypes = new CurrentUserHardTypeInfoDto(); + allTypes.setCanteenView(true); + allTypes.setSupermarket(true); + when(managers.getCurrentUserHardTypeInfo(any())).thenReturn(allTypes); + var parent = new MchManageVo(); + parent.setId(100L); + parent.setName("父商户"); + parent.setPlatformRanke(10D); + var child = new MchManageVo(); + child.setId(200L); + child.setName("子商户"); + when(merchants.getByParentId(anyLong())).thenReturn(R.ok(List.of())); + when(merchants.getByParentId(100L)).thenReturn(R.ok(List.of(child))); + when(merchants.getByIds(anyList())).thenReturn(R.ok(List.of(parent, child))); + when(departments.getDeptByDeptIds(anyList())).thenReturn(R.ok(Map.of())); + var mapper = session.getMapper(HardOrderMapper.class); + var totals = new HardOrderTotalServiceImpl(mapper, + mock(HardManagerMapper.class), managers, merchants, + mock(RemoteWalletTransactionService.class), departments, + mock(com.bsd.pjcloud.hard.mapper.order.HardDepartmentStatisticsMapper.class)); + var controller = new HardOrderTotalController( + mock(HardBatchExportService.class), + mock(HardOrderService.class), totals, managers, mapper, merchants, + mock(RemoteUserService.class)); + var json = new ObjectMapper().registerModule(new PjcloudJavaTimeModule()); + var mvc = MockMvcBuilders.standaloneSetup(controller) + .setMessageConverters(new MappingJackson2HttpMessageConverter(json)).build(); + var user = new PjcloudUser(1L, "alice", 10L, null, null, null, null, null, 7L, "test", + true, true, "0", true, true, List.of()); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(user, "", user.getAuthorities())); + try { + var response = mvc.perform(post("/hard-total/page-list-cs") + .contentType(MediaType.APPLICATION_JSON).content(""" + {"merchantId":100,"payTimeList":["2026-10-20 00:00:00","2026-10-21 23:59:59"]} + """)) + .andExpect(status().isOk()) + .andReturn().getResponse(); + var data = json.readTree(response.getContentAsString()).get("data"); + assertEquals(2, data.size()); + assertEquals(100L, data.get(0).get("merchantId").asLong()); + assertEquals(1530, data.get(0).get("totalPayPrice").asInt()); + assertEquals(250, data.get(0).get("totalRefundPrice").asInt()); + assertEquals(1280, data.get(0).get("totalProfitPrice").asInt()); + assertEquals(128, data.get(0).get("mchPlatformRankePrice").asInt()); + assertEquals(200L, data.get(1).get("merchantId").asLong()); + assertEquals(-90, data.get(1).get("totalProfitPrice").asInt()); + assertEquals(-1, data.get(1).get("totalProfitCount").asInt()); + } finally { SecurityContextHolder.clearContext(); } + } + + void assertSameAsBaseline(HardOrderDto query, DataScope scope, String label) { + assertEquals(byMerchant(rows("hardCsBaseline.listCs", query, scope)), byMerchant(rows(MAPPER + "listCs", query, scope)), label + " payments"); + var refunds = refund(query); + assertEquals(byMerchant(rows("hardCsBaseline.listRefundCs", refunds, scope)), byMerchant(rows(MAPPER + "listRefundCs", refunds, scope)), label + " refunds"); + } + + List rows(String name, HardOrderDto query, DataScope scope) { + return session.selectList(name, Map.of("dto", query, "scope", scope)); + } + + static Map byMerchant(List rows) { + return rows.stream().collect(Collectors.toMap(HardOrderCsVo::getMerchantId, row -> row)); + } + + static HardOrderDto query() { + var query = new HardOrderDto(); + query.setOrderStatusCodes(HardOrderStatusEnum.getPayPrice()); + query.setPayTimeList(new LocalDateTime[]{LocalDateTime.of(2026,10,20,0,0), LocalDateTime.of(2026,10,21,23,59,59)}); + return query; + } + + static HardOrderDto refund(HardOrderDto query) { + var refund = BeanUtil.toBean(query, HardOrderDto.class); + refund.setRefundTimeList(query.getPayTimeList()); + refund.setPayTimeList(null); + refund.setRefundStatusCodes(List.of("50")); + return refund; + } + + static DataScope scope() { + var scope = DataScope.of(); + scope.setScopeDeptName("merchant_dept_id"); + return scope; + } + + void execute(String sql) throws Exception { + try (var statement = connection.createStatement()) { statement.execute(sql); } + } + + private void seedOrder(long id, Map changes) throws Exception { + Map values = new LinkedHashMap<>(Map.of( + "tenant_id",7, "del_flag","0", "user_id",1, "user_dept_id",20, "merchant_id",100, + "merchant_type","1", "merchant_dept_id","10", "hard_id",501, "dept_id",99, "wallet_id",901)); + values.putAll(Map.of("id",id, "order_no","order-"+id, "order_type","1", "order_status","5", "pay_type","1", + "hour_type","1", "pay_price",1000, "is_offline_order","0", "product_desc","商品")); + values.put("create_time", "2026-10-20 12:00:00"); + values.put("pay_time", "2026-10-20 12:00:00"); + values.putAll(changes); + String sql = "insert into hard_order (" + String.join(",", values.keySet()) + ") values (" + values.keySet().stream().map(key -> "?").collect(Collectors.joining(",")) + ")"; + try (var statement = connection.prepareStatement(sql)) { + int index = 1; + for (Object value : values.values()) statement.setObject(index++, value); + statement.executeUpdate(); + } + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/resources/mapper/order/HardOrderCsBaselineMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/test/resources/mapper/order/HardOrderCsBaselineMapper.xml new file mode 100644 index 000000000..798455130 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/resources/mapper/order/HardOrderCsBaselineMapper.xml @@ -0,0 +1,162 @@ + + + + + + + + + + + + + + diff --git a/pjcloud-wallet/README_RULE_TRANSACTION_PERFORMANCE.md b/pjcloud-wallet/README_RULE_TRANSACTION_PERFORMANCE.md new file mode 100644 index 000000000..8be082a02 --- /dev/null +++ b/pjcloud-wallet/README_RULE_TRANSACTION_PERFORMANCE.md @@ -0,0 +1,78 @@ +# 转账记录分页优化与验证 + +日期:2026-10-22。接口:`POST /wallet/wallet-rule-transaction/page-join`。 + +## 查询链路与瓶颈 + +管理端 `wallet-rule-transaction/index.vue` 通过 `useTable` 发出一次分页 POST,请求参数中的 `current/size` 绑定 Page,JSON 中的用户、部门、转账类型、退款状态及日期绑定 DTO。网关路由及 `wallet_walletRuleTransaction_view` 权限未变。 + +原 SQL 将转账表关联两条收支流水,再关联源/目标钱包及类型,最后 `GROUP BY a.id`、排序、分页。精确总数也需要对这组关联结果计数。测试库原始转账表仅有主键,流水没有按业务编号定位的索引。`t.biz_id` 为 VARCHAR,`a.id` 为 BIGINT,隐式比较既阻碍索引定位,也存在大整数浮点精度问题。 + +原始样本中 43 笔转账各关联两条转账流水;原执行计划出现全表扫描、临时表和 filesort。原查询还依赖非严格 GROUP BY 行为。 + +## 实现 + +1. 复用 `PjcloudDefaultDataScopeHandle` 解析权限,形成内部不可变快照。用户/部门筛选和数据权限在 COUNT、LIMIT 前执行;角色权限不会因拆分详情查询而重复获取。 +2. 分页查询只取转账 ID,用 EXISTS 判断流水条件,每笔转账天然只有一行。COUNT 保持准确,默认统计不关联流水或钱包。 +3. 只为当前页 ID 查询完整转账、钱包类型及一条符合条件的流水,再按分页 ID 顺序组装结果。元数据补齐不随 OFFSET 增长。 +4. 同时保留 `t.biz_id = CAST(a.id AS CHAR)` 与 `a.id = CAST(t.biz_id AS SIGNED)`:前者保证精确匹配及流水索引定位,后者允许用户/部门筛出的少量流水反查转账主键。 +5. 显式添加 `t.tenant_id = a.tenant_id`。真实租户插件未给 JOIN ON 内的相关子查询注入租户条件,不能只依赖自动改写。 +6. 用户资料仍通过原 `getUserDeptByIds` 一次批量获取,ID 去重,空页不调用。筛选使用流水历史部门,展示仍沿用用户服务返回的当前部门。未引入逐行远程调用。 + +日期参数仍沿用原 LocalDate BETWEEN 边界;结束日零点的行为已回归。原有软删除流水的可见性、缺失流水的 LEFT JOIN 行为及金额字段保持一致。混合部门流水稳定取符合筛选/权限条件的最小流水 ID,不依赖 GROUP BY 任意挑选一行。 + +并发删除发生在 ID 分页与详情查询之间时,详情已不存在的记录不会以残缺对象返回。COUNT 与记录读取仍遵循原来多语句分页的实时并发语义,未引入跨 Feign 调用的长事务。 + +## 实测结果 + +MySQL 8.0.46。复制提供的测试库四张相关表到唯一命名的 `codex_wrt_*` 隔离库,再构造 200,000 笔转账和 1,000,000 条流水;包含真实样本及边界用例后共 **200,055 笔转账、1,001,150 条流水**。源库相关表未写入、未建索引。测试结束删除隔离库。 + +以下为预热后 3 次中位数,包含 MyBatis 分页 COUNT、ID 查询、详情查询及服务组装。Feign 使用桩,因此不包含真实远程调用、HTTP 网关和生产网络耗时。旧 SQL 使用源库原索引,新 SQL 使用配套新索引;通过隔离库索引可见性切换,避免新增索引干扰旧 SQL 的执行计划。旧 SQL 单条查询超过 5 秒则终止,标为超时,不将其伪装成实测中位数。 + +| 场景 | 旧查询 | 新查询 | +| --- | ---: | ---: | +| 默认首页,准确总数 189,810 | ≥5,000ms,超时 | 50.9ms | +| 第 5,000 页,每页 10 条 | ≥5,000ms,超时 | 260.3ms | +| 转账类型 | ≥5,000ms,超时 | 7.4ms | +| 退款状态 | ≥5,000ms,超时 | 7.2ms | +| 退回日期 | ≥5,000ms,超时 | 29.8ms | +| 单个用户 | 3.1ms | 3.6ms | +| 部门包含 | 2,285.3ms | 6.5ms | +| 部门排除,匹配 189,610 条 | 3,536.1ms | 737.8ms | +| 部门数据权限 | 未对旧外层权限包装计时 | 8.2ms | + +默认详情的 EXPLAIN ANALYZE 显示主表按当前页 10 个主键读取,流水通过 `idx_wt_tenant_bizid_type` 每次定位约两行。用户筛选通过流水用户索引和转账主键查询。退款计数使用对应覆盖索引。深分页只扫描 ID,但测试优化器仍可能选择 skip scan + 排序;表中保留了该实际耗时,没有强制固定索引计划。 + +准确 COUNT 及大范围部门排除仍随匹配数据量增长;以上为测试环境结果,不代表生产 SLA。需要发布后使用真实租户、日期、筛选和并发验证生产执行计划及端到端耗时。 + +## 回归验证 + +- 真实 43 笔样本进行了 **405 组**全部字段、总数及分页的新旧结果对照。 +- 20 组筛选/权限用例覆盖用户、部门包含/排除、空/空值参数、混合部门流水、本人权限、本人或部门、无权限、退款日期/状态、钱包 ID 和无结果。 +- 验证 ONLY_FULL_GROUP_BY、相邻大整数 ID、带前导零/非法业务编号、重复/缺失/软删除流水、主表软删除、跨租户流水/钱包、跨租户更小流水 ID、日期零点边界、稳定排序、越界页、关闭计数及 1,000 条上限。 +- 40 次并发请求验证租户隔离。控制器 MockMvc 验证 POST 参数、JSON 日期/Long ID、分页响应契约;该独立 MVC 测试未启动生产认证或真实 Feign。 +- 百万流水压力回归:28 项通过,无失败、无跳过。大数据下还依据生成规则独立计算总数、排序和详情字段,旧查询超时也不会跳过新版正确性断言。 +- 最终小数据及钱包导出回归:50 项,49 通过、1 项压力测试在该轮跳过;该压力测试已在上一轮通过。新增并发删除保护用例通过。 + +## 运行测试 + +通过调用环境提供 `MYSQL_USER`、`MYSQL_PWD`,不要将凭据写入文件。测试账号需具有创建/删除隔离库的权限。 + +```sh +export WALLET_RULE_TEST_JDBC_URL='jdbc:mysql://127.0.0.1:33905/mysql?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia%2FShanghai' +MAVEN_OPTS='-Dfile.encoding=UTF-8' mvn -o -pl pjcloud-wallet/pjcloud-wallet-biz -am \ + -Dtest=WalletRuleTransactionServiceTest,WalletRuleTransactionControllerTest,WalletRuleTransactionMySqlTest \ + -Dwallet.rule.stress.rows=200000 \ + -Dsurefire.failIfNoSpecifiedTests=false -DfailIfNoTests=false \ + '-DargLine=-Xmx512m -Dfile.encoding=UTF-8' test +``` + +不传 `WALLET_RULE_TEST_JDBC_URL` 时自动跳过真实数据库测试;不传 `wallet.rule.stress.rows` 时跳过大数据生成及计时。 + +## 发布 + +配套脚本:[`20261022_wallet_rule_transaction_page_index.sql`](../db/20261022_wallet_rule_transaction_page_index.sql),共六个索引:转账表四个,流水表两个。脚本在隔离测试库执行成功;尚未应用到共享测试源表或生产。 + +钱包服务代码及索引需要配套发布。脚本已说明同名索引核对、逐条建索引及元数据锁注意事项。新代码没有索引时仍能执行,但性能目标依赖这些索引;单独增加索引也不能消除旧查询的隐式数字比较与 GROUP BY。上线后应核对完整列顺序及真实 SQL 执行计划。 + +本次代码范围为 `WalletRuleTransactionServiceImpl`、对应 Mapper/内部权限快照、SQL、测试及本记录。工作区同期的硬件订单、未激活钱包及前端修改不属于本次变更。 diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletTopupActionDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletTopupActionDto.java index b63a56124..a04e9790b 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletTopupActionDto.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletTopupActionDto.java @@ -1,8 +1,16 @@ package com.bsd.pjcloud.wallet.api.dto; import com.bsd.pjcloud.admin.api.vo.base.BaseIsIncludeTreeInterface; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; import lombok.Data; +import java.io.IOException; import java.util.List; @Data @@ -14,8 +22,11 @@ public class WalletTopupActionDto implements BaseIsIncludeTreeInterface { private Long userId; /** - * 不活跃天数 + * 不活跃月数,省略时为3;兼容旧页面发送的整数字符串。 */ + @Min(value = 1, message = "不活跃月数须为1至120的整数") + @Max(value = 120, message = "不活跃月数须为1至120的整数") + @JsonDeserialize(using = MonthsDeserializer.class) private Long unactiveMonthsNumber; /** @@ -28,4 +39,15 @@ public class WalletTopupActionDto implements BaseIsIncludeTreeInterface { */ private Boolean isInclude; + /** 禁止 Jackson 将 1.5 静默截断为 1。 */ + public static class MonthsDeserializer extends JsonDeserializer { + @Override + public Long deserialize(JsonParser parser, DeserializationContext context) throws IOException { + if (parser.currentToken() == JsonToken.VALUE_NUMBER_INT) return parser.getLongValue(); + if (parser.currentToken() == JsonToken.VALUE_STRING && parser.getText().matches("[0-9]{1,3}")) { + return Long.valueOf(parser.getText()); + } + return (Long) context.handleUnexpectedToken(Long.class, parser); + } + } } diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteWalletTransactionService.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteWalletTransactionService.java index 353fb54e3..031e80db8 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteWalletTransactionService.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteWalletTransactionService.java @@ -7,6 +7,7 @@ import com.bsd.pjcloud.wallet.api.dto.WalletBalanceTransactionDto; import com.bsd.pjcloud.wallet.api.dto.WalletTransactionDepartmentSnapshotBatchDto; import com.bsd.pjcloud.wallet.api.dto.WalletTransactionSumAndCountDto; import com.bsd.pjcloud.wallet.api.entity.WalletTransactionEntity; +import com.bsd.pjcloud.wallet.api.vo.WalletDepartmentStatisticsVo; import com.bsd.pjcloud.wallet.api.vo.WalletTransactionDepartmentSnapshotVo; import com.bsd.pjcloud.wallet.api.vo.WalletTransactionSumAndCountVo; import org.springframework.cloud.openfeign.FeignClient; @@ -30,6 +31,11 @@ public interface RemoteWalletTransactionService { @PostMapping("/wallet-transaction/sum-and-count") R> sumAndCount(@RequestBody WalletTransactionSumAndCountDto dto); + /** 一次扫描归集部门报表的批量、单次、退还食堂和退款流水。 */ + @NoToken + @PostMapping("/wallet-transaction/department-statistics") + R> departmentStatistics(@RequestBody WalletTransactionSumAndCountDto dto); + /** * 按业务类型和数据库参数容量拆分后的查询组,批量还原流水创建时固化的历史部门。 */ diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/WalletDepartmentStatisticsVo.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/WalletDepartmentStatisticsVo.java new file mode 100644 index 000000000..34d8b10b9 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/WalletDepartmentStatisticsVo.java @@ -0,0 +1,23 @@ +package com.bsd.pjcloud.wallet.api.vo; + +import lombok.Data; + +/** 部门交易报表的六类钱包流水统计,金额保留流水原始正负号,单位分。 */ +@Data +public class WalletDepartmentStatisticsVo { + + private Long deptId; + + private Long batchIncomeSum = 0L; + private Long batchIncomeCount = 0L; + private Long batchExpenseSum = 0L; + private Long batchExpenseCount = 0L; + private Long onceIncomeSum = 0L; + private Long onceIncomeCount = 0L; + private Long onceExpenseSum = 0L; + private Long onceExpenseCount = 0L; + private Long returnCanteenSum = 0L; + private Long returnCanteenCount = 0L; + private Long refundSum = 0L; + private Long refundCount = 0L; +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/inactive/WalletInactivePageVo.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/inactive/WalletInactivePageVo.java new file mode 100644 index 000000000..79bf22f4d --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/inactive/WalletInactivePageVo.java @@ -0,0 +1,19 @@ +package com.bsd.pjcloud.wallet.api.vo.inactive; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.Map; + +/** 保留 records/total/current/size 分页结构,并返回本次查询的统一时间基准。 */ +@Getter +@Setter +public class WalletInactivePageVo extends Page { + private LocalDateTime asOfTime; + private LocalDateTime cutoffTime; + private int unactiveMonthsNumber; + private Map walletNames = new LinkedHashMap<>(); +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/inactive/WalletInactiveUserVo.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/inactive/WalletInactiveUserVo.java new file mode 100644 index 000000000..edc3bdae5 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/inactive/WalletInactiveUserVo.java @@ -0,0 +1,42 @@ +package com.bsd.pjcloud.wallet.api.vo.inactive; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** 每个用户一行;金额均为分,受限钱包不返回余额。 */ +@Data +public class WalletInactiveUserVo { + private String userId; + private String username; + private String userMobile; + private String userAvatar; + private List deptNames = new ArrayList<>(); + @JsonIgnore + private List deptIds = new ArrayList<>(); + private List wallets = new ArrayList<>(); + private Long totalBalance = 0L; + private boolean partialBalance; + private LocalDateTime firstWalletCreatedTime; + private LocalDateTime lastTransactionTime; + private LocalDateTime inactiveSinceTime; + private int inactiveMonths; + private boolean neverTransacted; + + public enum Status { AVAILABLE, UNOPENED, RESTRICTED } + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class Wallet { + private String code; + private String name; + private Status status; + private Long balance; + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/docs/wallet-inactive.md b/pjcloud-wallet/pjcloud-wallet-biz/docs/wallet-inactive.md new file mode 100644 index 000000000..703dde37c --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/docs/wallet-inactive.md @@ -0,0 +1,45 @@ +# 不活跃钱包统计 + +`POST /wallet-manage/page-join-active` 现在按用户返回统计行。三个钱包任一有效流水都会重新计时,余额净变化为零也算活跃;逻辑删除流水、备注及资料修改不参与判断。 + +默认向前推 3 个日历月,可选 1~120 个月,时间采用 Asia/Shanghai。最后流水时间等于阈值时计入;从未使用的用户按最早有效钱包开户时间判断。未开通全部三个钱包不影响统计,零余额、负余额同样纳入。 + +## 接口与权限 + +- 原请求字段 `userId/unactiveMonthsNumber/deptIds/isInclude` 保留;分页查询参数默认 `current=1&size=20`,服务端上限 100。 +- 保留 `records/total/current/size` 分页结构,新增 `asOfTime/cutoffTime/unactiveMonthsNumber/walletNames`。 +- 每条记录包含字符串 `userId`、用户资料、可见部门名称、三个 `wallets`、可见余额合计、最后流水时间、起算时间和完整不活跃月数。 +- 钱包状态为 `AVAILABLE/UNOPENED/RESTRICTED`;只有 AVAILABLE 返回余额,RESTRICTED 不返回金额和部门。金额单位为分;合计使用 Long,遵循项目统一的 Long JSON 字符串序列化。 +- 部门条件筛选候选用户;角色权限同时约束候选用户和余额展示。活跃检查始终覆盖同租户该用户的有效流水,不跟随历史流水部门过滤。 +- 旧 `/list-list-active` 明细接口保留,统计新页面不再调用。 + +## 查询与索引 + +权限和租户在服务端解析一次,数据库内共用不可变权限条件。钱包类型先批量读取为常量 IN 参数;计数和分页共用 SQL 片段,通过 NOT EXISTS 排除近期有流水的用户。分页之后批量取钱包,最后流水按用户时间索引倒序取首行。非空页固定 4 条 SQL,空页 2 条 SQL;用户和部门资料分别批量补齐。 + +数据库读取使用短只读 REPEATABLE READ 事务,权限和资料远程调用在事务外。不会修改支付写入链路,不缓存实时统计结果。 + +发布时先核对并执行 `db/20261022_wallet_inactive_index.sql`。脚本只增加索引,发现等价可见 BTREE 前缀时跳过;同名异构索引会报错,需人工核对。大表应低峰逐条执行并观察 IO。 + +前后端需配套发布,发布后刷新已打开的统计页面。前端对缺少新统计字段的响应显示失败,避免把缺失余额当成零。 + +## 本地验证记录(2026-10-22) + +真实 MySQL 8.0.46 独立测试库:10 万生成用户、300041 个钱包、1100017 条流水,其中单用户另含 10 万条历史流水;测试 JVM 为 `-Xmx256m`。每场景预热后采样 5 次: + +| 场景 | 数据库读取中位耗时 | 样本最大耗时 | SQL 数 | +| --- | ---: | ---: | ---: | +| 默认统计,20 人/页 | 761 ms | 798 ms | 4 | +| 部门筛选 | 31 ms | 35 ms | 4 | +| 单用户大量流水 | 45 ms | 81 ms | 4 | + +此耗时包含统计读取及当前页组装,不包含远程资料补齐,也不代表生产性能。EXPLAIN ANALYZE 确认分页先取用户、最后流水倒序索引取一条,流水未做整表历史聚合。测试独立库自动清理,业务库未执行本次索引脚本。 + +- 6 项服务测试:月末/闰年、输入校验、缺少钱包、余额权限、Long 合计、用户 ID 精度及批量资料补齐。 +- 6 项真实 MySQL 测试:独立口径对照、所有钱包活动、逻辑删除、历史部门、租户/角色权限、稳定分页、并发插入时的读取快照及大数据查询计划。 +- 3 项 MVC 测试:原 URL、默认分页、JSON 时间、非法输入、旧整数串和旧明细路由。 +- 原有钱包导出 21 项回归测试通过。 +- 前端 8 项专项测试及 4 项相关导出回归通过;另通过 ESLint 和生产构建。 +- 浏览器使用真实页面、部门树、搜索组件和 Element Plus,接口为模拟数据;检查了 1440/1024/375px、暗色主题、键盘焦点、横向滚动、失败重试和减少动态效果。所检查的状态标签、负余额与主按钮文本对比度均至少 4.5:1。 + +大数据测试通过环境变量 `WALLET_INACTIVE_TEST_JDBC_URL`、`MYSQL_USER`、`MYSQL_PWD` 显式启用,只接受 localhost/127.0.0.1,并只创建和删除随机命名的 `codex_wi_*` 测试库。运行时增加 `-Dwallet.inactive.stress=true`。报告位于本模块 `target/wallet-inactive-benchmark.txt` 和 `target/wallet-inactive-explain.txt`。 diff --git a/pjcloud-wallet/pjcloud-wallet-biz/pom.xml b/pjcloud-wallet/pjcloud-wallet-biz/pom.xml index b3a7d98f4..875fab896 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/pom.xml +++ b/pjcloud-wallet/pjcloud-wallet-biz/pom.xml @@ -13,6 +13,11 @@ pjcloud-wallet-biz + + com.h2database + h2 + test + com.bsd diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletManageController.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletManageController.java index af2d79180..230862b53 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletManageController.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletManageController.java @@ -21,11 +21,13 @@ import com.bsd.pjcloud.wallet.api.dto.WalletTopupActionDto; import com.bsd.pjcloud.wallet.api.entity.WalletManageEntity; import com.bsd.pjcloud.wallet.api.vo.WalletManageVo; import com.bsd.pjcloud.wallet.api.vo.WalletQbTotalVo; +import com.bsd.pjcloud.wallet.api.vo.inactive.WalletInactivePageVo; import com.bsd.pjcloud.wallet.mapper.WalletManageMapper; import com.bsd.pjcloud.wallet.service.WalletCodeUseService; import com.bsd.pjcloud.wallet.service.WalletManageService; import com.bsd.pjcloud.wallet.service.export.WalletManageBatchExportService; import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; import org.springframework.cache.annotation.Cacheable; import org.springframework.security.access.prepost.PreAuthorize; import io.swagger.v3.oas.annotations.security.SecurityRequirement; @@ -274,9 +276,11 @@ public class WalletManageController { @Operation(summary = "不活跃用户统计", description = "不活跃用户统计") @PostMapping("/page-join-active") - public R> pageJoinActive(@ParameterObject Page page, @RequestBody WalletTopupActionDto dto) { + public R pageJoinActive( + @RequestParam(defaultValue = "1") long current, @RequestParam(defaultValue = "20") long size, + @Valid @RequestBody WalletTopupActionDto dto) { DataScope dataScope = new DataScope(); - return R.ok(walletManageService.pageJoinActive(page, dto, dataScope)); + return R.ok(walletManageService.pageJoinActive(new Page<>(current, size), dto, dataScope)); } @Operation(summary = "不活跃用户统计list", description = "不活跃用户统计list") diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletTransactionController.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletTransactionController.java index 3dbb11c9b..08db875d2 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletTransactionController.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletTransactionController.java @@ -19,6 +19,7 @@ import com.bsd.pjcloud.wallet.api.dto.WalletTransactionSumAndCountDto; import com.bsd.pjcloud.wallet.api.dto.WalletTransactionUpdateInfoDto; import com.bsd.pjcloud.wallet.api.entity.WalletTransactionEntity; import com.bsd.pjcloud.wallet.api.vo.WalletCzTotalVo; +import com.bsd.pjcloud.wallet.api.vo.WalletDepartmentStatisticsVo; import com.bsd.pjcloud.wallet.api.vo.WalletTransactionDepartmentSnapshotVo; import com.bsd.pjcloud.wallet.api.vo.WalletTransactionSumAndCountVo; import com.bsd.pjcloud.wallet.api.vo.WalletTypeTotalVo; @@ -79,6 +80,13 @@ public class WalletTransactionController { return R.ok(walletTransactionMapper.sumAndCount(dto)); } + /** 部门报表的钱包六类金额、次数一次聚合。 */ + @Inner + @PostMapping("/department-statistics") + public R> departmentStatistics(@RequestBody WalletTransactionSumAndCountDto dto) { + return R.ok(walletTransactionMapper.departmentStatistics(dto)); + } + /** * 按业务类型和数据库参数容量拆分后的查询组,批量还原流水创建时固化的历史部门。 */ diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletTransactionMapper.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletTransactionMapper.java index b4a264e82..f827545fa 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletTransactionMapper.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletTransactionMapper.java @@ -11,6 +11,7 @@ import com.bsd.pjcloud.wallet.api.dto.WalletTransactionDto; import com.bsd.pjcloud.wallet.api.dto.WalletTransactionSumAndCountDto; import com.bsd.pjcloud.wallet.api.entity.WalletTransactionEntity; import com.bsd.pjcloud.wallet.api.vo.WalletExportCzTotalPerson; +import com.bsd.pjcloud.wallet.api.vo.WalletDepartmentStatisticsVo; import com.bsd.pjcloud.wallet.api.vo.WalletTransactionDepartmentSnapshotVo; import com.bsd.pjcloud.wallet.api.vo.WalletTransactionSumAndCountVo; import com.bsd.pjcloud.wallet.api.vo.WalletTypeCountVo; @@ -85,6 +86,9 @@ public interface WalletTransactionMapper extends PjcloudBaseMapper sumAndCount(@Param("dto") WalletTransactionSumAndCountDto dto); + /** 保留各类流水正负号与次数口径,按部门一次聚合,避免六次重复扫描。 */ + List departmentStatistics(@Param("dto") WalletTransactionSumAndCountDto dto); + /** * 查询指定业务流水创建时固化的历史部门,结果按流水主键倒序以便调用方兼容异常重复数据。 * @@ -95,4 +99,4 @@ public interface WalletTransactionMapper extends PjcloudBaseMapper exportCzTotalPerson(); -} \ No newline at end of file +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/inactive/WalletInactiveMapper.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/inactive/WalletInactiveMapper.java new file mode 100644 index 000000000..d00c06c8b --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/inactive/WalletInactiveMapper.java @@ -0,0 +1,27 @@ +package com.bsd.pjcloud.wallet.mapper.inactive; + +import com.baomidou.mybatisplus.annotation.InterceptorIgnore; +import com.bsd.pjcloud.wallet.api.dto.WalletTopupActionDto; +import com.bsd.pjcloud.wallet.api.entity.WalletTypeEntity; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.time.LocalDateTime; +import java.util.List; + +/** 每条 SQL 都显式绑定服务端租户,避免复杂子查询被租户插件重复改写。 */ +@Mapper +@InterceptorIgnore(tenantLine = "true") +public interface WalletInactiveMapper { + long countUsers(@Param("dto") WalletTopupActionDto dto, @Param("scope") WalletInactiveQueryScope scope, + @Param("asOfTime") LocalDateTime asOfTime, @Param("cutoffTime") LocalDateTime cutoffTime); + + List pageUsers(@Param("dto") WalletTopupActionDto dto, + @Param("scope") WalletInactiveQueryScope scope, @Param("asOfTime") LocalDateTime asOfTime, + @Param("cutoffTime") LocalDateTime cutoffTime, @Param("offset") long offset, @Param("size") long size); + + List listWallets(@Param("userIds") List userIds, + @Param("scope") WalletInactiveQueryScope scope); + + List listTypes(@Param("scope") WalletInactiveQueryScope scope); +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/inactive/WalletInactiveQueryScope.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/inactive/WalletInactiveQueryScope.java new file mode 100644 index 000000000..a55e026c4 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/inactive/WalletInactiveQueryScope.java @@ -0,0 +1,34 @@ +package com.bsd.pjcloud.wallet.mapper.inactive; + +import com.bsd.pjcloud.common.data.datascope.DataScope; +import lombok.Value; +import lombok.AllArgsConstructor; + +import java.util.List; +import java.util.Objects; + +/** 仅由服务端构造;不把 DataScope 交给通用拦截器再次包装 COUNT/LIMIT。 */ +@Value +@AllArgsConstructor +public class WalletInactiveQueryScope { + Long tenantId; + boolean unrestricted; + String username; + List deptIds; + List walletTypeIds; + + public WalletInactiveQueryScope(Long tenantId, boolean unrestricted, String username, List deptIds) { + this(Objects.requireNonNull(tenantId), unrestricted, username, List.copyOf(deptIds), List.of()); + } + + public WalletInactiveQueryScope withWalletTypeIds(List ids) { + return new WalletInactiveQueryScope(tenantId, unrestricted, username, deptIds, List.copyOf(ids)); + } + + public static WalletInactiveQueryScope of(Long tenantId, boolean unrestricted, DataScope scope) { + Objects.requireNonNull(tenantId, "查询钱包统计必须指定租户"); + String username = scope.getUsername(); + if (username != null && username.isBlank()) username = null; + return new WalletInactiveQueryScope(tenantId, unrestricted, username, List.copyOf(scope.getDeptList())); + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/inactive/WalletInactiveUserRecord.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/inactive/WalletInactiveUserRecord.java new file mode 100644 index 000000000..39be0192b --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/inactive/WalletInactiveUserRecord.java @@ -0,0 +1,11 @@ +package com.bsd.pjcloud.wallet.mapper.inactive; + +import lombok.Data; +import java.time.LocalDateTime; + +@Data +public class WalletInactiveUserRecord { + private Long userId; + private LocalDateTime firstWalletCreatedTime; + private LocalDateTime lastTransactionTime; +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/inactive/WalletInactiveWalletRecord.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/inactive/WalletInactiveWalletRecord.java new file mode 100644 index 000000000..b639f864a --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/inactive/WalletInactiveWalletRecord.java @@ -0,0 +1,12 @@ +package com.bsd.pjcloud.wallet.mapper.inactive; + +import lombok.Data; + +@Data +public class WalletInactiveWalletRecord { + private Long userId; + private Long deptId; + private String code; + private Long balance; + private boolean visible; +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/rule/WalletRuleTransactionMapper.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/rule/WalletRuleTransactionMapper.java index 342c8f6ae..b23ed23fd 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/rule/WalletRuleTransactionMapper.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/rule/WalletRuleTransactionMapper.java @@ -1,7 +1,6 @@ package com.bsd.pjcloud.wallet.mapper.rule; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; -import com.bsd.pjcloud.common.data.datascope.DataScope; import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; import com.bsd.pjcloud.wallet.api.dto.rule.WalletRuleTransactionDto; import com.bsd.pjcloud.wallet.api.entity.rule.WalletRuleTransactionEntity; @@ -9,9 +8,16 @@ import com.bsd.pjcloud.wallet.api.vo.rule.WalletRuleTransactionVo; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; +import java.util.List; + @Mapper public interface WalletRuleTransactionMapper extends PjcloudBaseMapper { - Page scopePageList(Page page, @Param("dto") WalletRuleTransactionDto dto, DataScope dataScope); + Page scopePageList(Page page, @Param("dto") WalletRuleTransactionDto dto, + @Param("scope") WalletRuleTransactionQueryScope scope); -} \ No newline at end of file + List listPageDetails(@Param("ids") List ids, + @Param("dto") WalletRuleTransactionDto dto, + @Param("scope") WalletRuleTransactionQueryScope scope); + +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/rule/WalletRuleTransactionQueryScope.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/rule/WalletRuleTransactionQueryScope.java new file mode 100644 index 000000000..cbe782750 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/rule/WalletRuleTransactionQueryScope.java @@ -0,0 +1,27 @@ +package com.bsd.pjcloud.wallet.mapper.rule; + +import com.bsd.pjcloud.common.data.datascope.DataScope; +import lombok.Value; + +import java.util.List; + +/** + * 服务端解析后的权限快照。分页、计数和当前页详情使用同一份权限条件, + * 避免通用拦截器在分页 SQL 外层包装,并避免详情查询再次获取角色权限。 + */ +@Value +public class WalletRuleTransactionQueryScope { + + boolean unrestricted; + String username; + List deptIds; + + public static WalletRuleTransactionQueryScope of(boolean unrestricted, DataScope scope) { + String username = scope == null ? null : scope.getUsername(); + if (username != null && username.isBlank()) { + username = null; + } + List deptIds = scope == null || scope.getDeptList() == null ? List.of() : List.copyOf(scope.getDeptList()); + return new WalletRuleTransactionQueryScope(unrestricted, username, deptIds); + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletManageService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletManageService.java index 45443eb77..d24e518a9 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletManageService.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletManageService.java @@ -7,6 +7,7 @@ import com.bsd.pjcloud.wallet.api.constant.WalletTypeEnum; import com.bsd.pjcloud.wallet.api.dto.WalletManageBalanceDto; import com.bsd.pjcloud.wallet.api.dto.WalletManageDto; import com.bsd.pjcloud.wallet.api.dto.WalletTopupActionDto; +import com.bsd.pjcloud.wallet.api.vo.inactive.WalletInactivePageVo; import com.bsd.pjcloud.wallet.api.entity.WalletManageEntity; import com.bsd.pjcloud.wallet.api.vo.WalletManageVo; import com.bsd.pjcloud.wallet.api.vo.WalletQbTotalVo; @@ -88,7 +89,7 @@ public interface WalletManageService extends IService { /** * 不活跃用户钱包 */ - Page pageJoinActive(Page page, WalletTopupActionDto dto, DataScope dataScope); + WalletInactivePageVo pageJoinActive(Page page, WalletTopupActionDto dto, DataScope dataScope); /** * 不活跃用户钱包 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 0a7168cf0..67673584b 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 @@ -37,11 +37,13 @@ import com.bsd.pjcloud.wallet.api.vo.MchManageVo; import com.bsd.pjcloud.wallet.api.vo.WalletManageBalanceVo; import com.bsd.pjcloud.wallet.api.vo.WalletManageVo; import com.bsd.pjcloud.wallet.api.vo.WalletQbTotalVo; +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.service.*; import com.bsd.pjcloud.wallet.service.export.WalletManageBatchExportService; +import com.bsd.pjcloud.wallet.service.inactive.WalletInactiveService; import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.constraints.NotNull; import lombok.RequiredArgsConstructor; @@ -92,6 +94,7 @@ public class WalletManageServiceImpl extends ServiceImpl pageJoinActive(Page page, WalletTopupActionDto dto, DataScope dataScope) { - if (ObjectUtil.isEmpty(dto.getUnactiveMonthsNumber())) { - dto.setUnactiveMonthsNumber(3L); - } - String unactiveDayNumberStr = LocalDateTimeUtil.format(LocalDateTimeUtil.now().minusMonths(dto.getUnactiveMonthsNumber()), DatePattern.NORM_DATE_PATTERN); - Page pageList = baseMapper.activeBalancePage(page, dto, unactiveDayNumberStr); - disposeList(pageList.getRecords()); - return pageList; + public WalletInactivePageVo pageJoinActive(Page page, WalletTopupActionDto dto, DataScope dataScope) { + return inactiveService.page(page, dto, dataScope); } /** diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletTransactionServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletTransactionServiceImpl.java index f79ffda84..a885ec019 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletTransactionServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletTransactionServiceImpl.java @@ -139,7 +139,10 @@ public class WalletTransactionServiceImpl extends ServiceImpl() + .eq(WalletManageEntity::getId, walletId).last("FOR UPDATE")); if (ObjectUtil.isEmpty(wallet)) { log.error("|{}|,用户钱包id({})不存在.", TAG, walletId); throw new RuntimeException("扣款失败,用户钱包不存在"); diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/rule/WalletRuleTransactionServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/rule/WalletRuleTransactionServiceImpl.java index 98722d373..edec62f70 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/rule/WalletRuleTransactionServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/rule/WalletRuleTransactionServiceImpl.java @@ -4,11 +4,13 @@ import cn.hutool.core.util.ObjectUtil; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.bsd.pjcloud.admin.api.feign.RemoteUserService; +import com.bsd.pjcloud.admin.api.feign.RemoteDataScopeService; import com.bsd.pjcloud.admin.api.utils.DataUtil; import com.bsd.pjcloud.admin.api.vo.UserVO; import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; import com.bsd.pjcloud.common.core.util.CollectionUtils; import com.bsd.pjcloud.common.data.datascope.DataScope; +import com.bsd.pjcloud.common.data.datascope.PjcloudDefaultDataScopeHandle; import com.bsd.pjcloud.wallet.api.constant.WalletBizEnum; import com.bsd.pjcloud.wallet.api.constant.WalletTypeEnum; import com.bsd.pjcloud.wallet.api.constant.rule.WalletRuleTransationTypeEnum; @@ -20,6 +22,7 @@ import com.bsd.pjcloud.wallet.api.vo.WalletManageVo; import com.bsd.pjcloud.wallet.api.vo.rule.WalletRuleJoinUserConfigVo; import com.bsd.pjcloud.wallet.api.vo.rule.WalletRuleTransactionVo; import com.bsd.pjcloud.wallet.mapper.rule.WalletRuleTransactionMapper; +import com.bsd.pjcloud.wallet.mapper.rule.WalletRuleTransactionQueryScope; import com.bsd.pjcloud.wallet.service.WalletManageService; import com.bsd.pjcloud.wallet.service.WalletRuleJoinService; import com.bsd.pjcloud.wallet.service.WalletRuleTransactionService; @@ -33,6 +36,8 @@ import org.springframework.validation.annotation.Validated; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; +import java.util.Map; +import java.util.Objects; /** * 钱包转账记录 @@ -50,14 +55,31 @@ public class WalletRuleTransactionServiceImpl extends ServiceImpl scopePageList(Page page, WalletRuleTransactionDto dto, DataScope dataScope) { - Page pageList = baseMapper.scopePageList(page, dto, dataScope); - disposeList(pageList.getRecords(), dto); + // 复用通用权限规则,但只解析一次,并把条件下推到分页和 COUNT 之前。 + boolean unrestricted = dataScope == null + || new PjcloudDefaultDataScopeHandle(dataScopeService).calcScope(dataScope); + WalletRuleTransactionQueryScope scope = WalletRuleTransactionQueryScope.of(unrestricted, dataScope); + Page pageList = baseMapper.scopePageList(page, dto, scope); + List records = pageList.getRecords(); + if (records.isEmpty()) { + return pageList; + } + + // 大表只参与当前页的详情补齐,不参与分页排序或整表分组去重。 + List ids = records.stream().map(WalletRuleTransactionVo::getId).toList(); + Map details = CollectionUtils.convertMap( + baseMapper.listPageDetails(ids, dto, scope), WalletRuleTransactionVo::getId); + // 按分页 id 的顺序组装;并发删除的记录不返回只有 id 的不完整对象。 + List detailedRecords = ids.stream().map(details::get).filter(Objects::nonNull).toList(); + pageList.setRecords(detailedRecords); + disposeList(detailedRecords); return pageList; } @@ -187,7 +209,7 @@ public class WalletRuleTransactionServiceImpl extends ServiceImpl list, WalletRuleTransactionDto dto) { + private void disposeList(List list) { // 用户 List userIds = list.stream().map(WalletRuleTransactionVo::getUserId).filter(ObjectUtil::isNotEmpty).distinct().toList(); if (ObjectUtil.isNotEmpty(userIds)) { @@ -197,4 +219,4 @@ public class WalletRuleTransactionServiceImpl extends ServiceImpl requested, WalletTopupActionDto dto, + WalletInactiveQueryScope scope, LocalDateTime asOfTime) { + long months = dto.getUnactiveMonthsNumber() == null ? 3L : dto.getUnactiveMonthsNumber(); + if (months < 1 || months > 120) throw new IllegalArgumentException("不活跃月数须为1至120的整数"); + if (requested.getSize() < 1) throw new IllegalArgumentException("每页人数须大于0"); + long size = Math.min(requested.getSize(), 100); + LocalDateTime cutoffTime = asOfTime.minusMonths(months); + WalletInactivePageVo result = new WalletInactivePageVo(); + result.setSize(size); + result.setAsOfTime(asOfTime); + result.setCutoffTime(cutoffTime); + result.setUnactiveMonthsNumber((int) months); + for (WalletTypeEnum type : WalletTypeEnum.values()) result.getWalletNames().put(type.getCode(), type.getDesc()); + List types = mapper.listTypes(scope); + for (WalletTypeEntity type : types) result.getWalletNames().put(type.getUniqueCode(), type.getName()); + // 类型配置只读取一次,以常量 IN 参数筛选,避免优化器从类型表驱动并重复扫描钱包。 + scope = scope.withWalletTypeIds(types.stream().map(WalletTypeEntity::getId).toList()); + + long total = mapper.countUsers(dto, scope, asOfTime, cutoffTime); + result.setTotal(total); + long lastPage = total == 0 ? 1 : (total - 1) / size + 1; + long current = Math.min(Math.max(requested.getCurrent(), 1), lastPage); + result.setCurrent(current); + if (total == 0) return result; + + List users = mapper.pageUsers(dto, scope, asOfTime, cutoffTime, (current - 1) * size, size); + if (users.isEmpty()) return result; + List userIds = users.stream().map(WalletInactiveUserRecord::getUserId).toList(); + Map> wallets = mapper.listWallets(userIds, scope).stream() + .collect(Collectors.groupingBy(WalletInactiveWalletRecord::getUserId)); + List records = new ArrayList<>(users.size()); + for (WalletInactiveUserRecord user : users) { + WalletInactiveUserVo row = new WalletInactiveUserVo(); + row.setUserId(user.getUserId().toString()); + row.setFirstWalletCreatedTime(user.getFirstWalletCreatedTime()); + row.setLastTransactionTime(user.getLastTransactionTime()); + row.setNeverTransacted(user.getLastTransactionTime() == null); + row.setInactiveSinceTime(row.isNeverTransacted() ? user.getFirstWalletCreatedTime() : user.getLastTransactionTime()); + row.setInactiveMonths(inactiveMonths(row.getInactiveSinceTime(), asOfTime)); + List userWallets = wallets.getOrDefault(user.getUserId(), List.of()); + row.setDeptIds(userWallets.stream().filter(WalletInactiveWalletRecord::isVisible) + .map(WalletInactiveWalletRecord::getDeptId).filter(Objects::nonNull).distinct().toList()); + for (WalletTypeEnum type : WalletTypeEnum.values()) { + List matches = userWallets.stream() + .filter(w -> type.getCode().equals(w.getCode())).toList(); + Status status = matches.isEmpty() ? Status.UNOPENED + : matches.stream().anyMatch(w -> !w.isVisible()) ? Status.RESTRICTED : Status.AVAILABLE; + Long balance = status == Status.AVAILABLE + ? matches.stream().mapToLong(w -> w.getBalance() == null ? 0 : w.getBalance()).sum() : null; + row.getWallets().add(new WalletInactiveUserVo.Wallet(type.getCode(), result.getWalletNames().get(type.getCode()), status, balance)); + if (balance != null) row.setTotalBalance(row.getTotalBalance() + balance); + if (status == Status.RESTRICTED) row.setPartialBalance(true); + } + records.add(row); + } + result.setRecords(records); + return result; + } + + /** 与 asOf.minusMonths(N) 阈值保持一致,避免闰年/月末使用固定天数换算。 */ + public static int inactiveMonths(LocalDateTime since, LocalDateTime asOf) { + if (since == null || since.isAfter(asOf)) return 0; + int months = Math.toIntExact(ChronoUnit.MONTHS.between(YearMonth.from(since), YearMonth.from(asOf))); + if (since.isAfter(asOf.minusMonths(months))) months--; + return Math.max(months, 0); + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/inactive/WalletInactiveService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/inactive/WalletInactiveService.java new file mode 100644 index 000000000..3c1cf4426 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/inactive/WalletInactiveService.java @@ -0,0 +1,79 @@ +package com.bsd.pjcloud.wallet.service.inactive; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.admin.api.entity.SysDept; +import com.bsd.pjcloud.admin.api.entity.SysUser; +import com.bsd.pjcloud.admin.api.feign.RemoteDataScopeService; +import com.bsd.pjcloud.admin.api.feign.RemoteDeptService; +import com.bsd.pjcloud.admin.api.feign.RemoteUserService; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.data.datascope.DataScope; +import com.bsd.pjcloud.common.data.datascope.PjcloudDefaultDataScopeHandle; +import com.bsd.pjcloud.common.data.tenant.TenantContextHolder; +import com.bsd.pjcloud.wallet.api.dto.WalletTopupActionDto; +import com.bsd.pjcloud.wallet.api.vo.inactive.WalletInactivePageVo; +import com.bsd.pjcloud.wallet.api.vo.inactive.WalletInactiveUserVo; +import com.bsd.pjcloud.wallet.mapper.inactive.WalletInactiveQueryScope; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** 权限只解析一次;角色、用户和部门远程调用均在数据库事务之外。 */ +@Service +@RequiredArgsConstructor +public class WalletInactiveService { + private static final ZoneId BEIJING = ZoneId.of("Asia/Shanghai"); + private final WalletInactiveQueryService queryService; + private final RemoteDataScopeService dataScopeService; + private final RemoteUserService userService; + private final RemoteDeptService deptService; + + public WalletInactivePageVo page(Page page, WalletTopupActionDto input, DataScope dataScope) { + WalletTopupActionDto dto = new WalletTopupActionDto(); + dto.setUserId(input.getUserId()); + dto.setUnactiveMonthsNumber(input.getUnactiveMonthsNumber() == null ? 3L : input.getUnactiveMonthsNumber()); + if (dto.getUnactiveMonthsNumber() < 1 || dto.getUnactiveMonthsNumber() > 120) { + throw new IllegalArgumentException("不活跃月数须为1至120的整数"); + } + dto.setDeptIds(input.getDeptIds() == null ? List.of() : input.getDeptIds().stream().filter(Objects::nonNull).distinct().toList()); + dto.setIsInclude(input.getIsInclude()); + DataScope resolved = dataScope == null ? DataScope.of() : dataScope; + Long tenantId = Objects.requireNonNull(TenantContextHolder.getTenantId(), "查询钱包统计必须指定租户"); + boolean unrestricted = new PjcloudDefaultDataScopeHandle(dataScopeService).calcScope(resolved); + WalletInactiveQueryScope scope = WalletInactiveQueryScope.of(tenantId, unrestricted, resolved); + WalletInactivePageVo result = queryService.readPage(page, dto, scope, LocalDateTime.now(BEIJING).withNano(0)); + if (result.getRecords().isEmpty()) return result; + + List userIds = result.getRecords().stream().map(row -> Long.valueOf(row.getUserId())).toList(); + R> users = userService.getInnerUserById(userIds); + if (users == null || !users.isOk() || users.getData() == null) throw new IllegalStateException("读取钱包用户信息失败,请重试"); + Map userMap = users.getData().stream().collect(Collectors.toMap(SysUser::getUserId, Function.identity(), (a, b) -> a)); + List deptIds = result.getRecords().stream().flatMap(row -> row.getDeptIds().stream()).distinct().toList(); + Map deptMap = Map.of(); + if (!deptIds.isEmpty()) { + R> depts = deptService.getDeptByDeptIds(deptIds); + if (depts == null || !depts.isOk() || depts.getData() == null) throw new IllegalStateException("读取钱包部门信息失败,请重试"); + deptMap = depts.getData(); + } + for (WalletInactiveUserVo row : result.getRecords()) { + SysUser user = userMap.get(Long.valueOf(row.getUserId())); + if (user != null) { + row.setUsername(user.getName()); + row.setUserMobile(user.getPhone()); + row.setUserAvatar(user.getAvatar()); + } + List names = new ArrayList<>(); + for (Long id : row.getDeptIds()) { + SysDept dept = deptMap.get(id); + names.add(dept == null ? "未知部门" : dept.getName()); + } + row.setDeptNames(names.stream().distinct().toList()); + } + return result; + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/WalletTransactionMapper.xml b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/WalletTransactionMapper.xml index 7bc37f605..0916fc285 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/WalletTransactionMapper.xml +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/WalletTransactionMapper.xml @@ -214,6 +214,75 @@ group by a.dept_id + + + - \ No newline at end of file + diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/inactive/WalletInactiveMapper.xml b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/inactive/WalletInactiveMapper.xml new file mode 100644 index 000000000..1e4fcc5ed --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/inactive/WalletInactiveMapper.xml @@ -0,0 +1,109 @@ + + + + + + 1 = 1 + + ( + w.create_by = #{scope.username} or + + + w.dept_id in + #{id} + + 1 = 0 + + ) + + + + + + + + #{id} + + (null) + + + + + + select w.user_id + from wallet_manage w + where w.tenant_id = #{scope.tenantId} and w.del_flag = '0' + and w.wallet_type_id in + and + and w.user_id = #{dto.userId} + + + and w.dept_id not in + and w.dept_id in + + #{id} + + group by w.user_id + having not exists ( + select 1 from wallet_transaction recent + where recent.tenant_id = #{scope.tenantId} and recent.user_id = w.user_id and recent.del_flag = '0' + and recent.create_time > #{cutoffTime} and recent.create_time <= #{asOfTime} + ) + and ( + exists ( + select 1 from wallet_manage opened + where opened.tenant_id = #{scope.tenantId} and opened.user_id = w.user_id + and opened.del_flag = '0' and opened.create_time <= #{cutoffTime} + and opened.wallet_type_id in + ) + or exists ( + select 1 from wallet_transaction history + where history.tenant_id = #{scope.tenantId} and history.user_id = w.user_id + and history.del_flag = '0' and history.create_time <= #{cutoffTime} + ) + ) + + + + + + + + + + + + diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/rule/WalletRuleTransactionMapper.xml b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/rule/WalletRuleTransactionMapper.xml index 0936e2be4..fa8fe221c 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/rule/WalletRuleTransactionMapper.xml +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/rule/WalletRuleTransactionMapper.xml @@ -38,11 +38,11 @@ a.update_by, a.update_time, a.del_flag, - a.tenant_id, + a.tenant_id - a.del_flag = 0 + a.del_flag = '0' and a.biz_type = #{dto.bizType} @@ -63,43 +63,95 @@ + + + t.biz_id = cast(a.id as char) and t.biz_type = '6' + + and t.tenant_id = a.tenant_id + and a.id = cast(t.biz_id as signed) + + and t.user_id = #{dto.userId} + + + + + and t.dept_id NOT IN + + + and t.dept_id IN + + + + #{v} + + + + and ( + + a.create_by = #{scope.username} or + + + + t.dept_id in + + #{id} + + + 1 = 0 + + ) + + + + + - \ No newline at end of file + diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/inactive/WalletInactiveControllerTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/inactive/WalletInactiveControllerTest.java new file mode 100644 index 000000000..98e787473 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/inactive/WalletInactiveControllerTest.java @@ -0,0 +1,89 @@ +package com.bsd.pjcloud.wallet.inactive; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.common.core.jackson.PjcloudJavaTimeModule; +import com.bsd.pjcloud.wallet.api.dto.WalletTopupActionDto; +import com.bsd.pjcloud.wallet.api.vo.inactive.WalletInactivePageVo; +import com.bsd.pjcloud.wallet.api.vo.inactive.WalletInactiveUserVo; +import com.bsd.pjcloud.wallet.controller.WalletManageController; +import com.bsd.pjcloud.wallet.mapper.WalletManageMapper; +import com.bsd.pjcloud.wallet.service.WalletCodeUseService; +import com.bsd.pjcloud.wallet.service.WalletManageService; +import com.bsd.pjcloud.wallet.service.export.WalletManageBatchExportService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.http.MediaType; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +/** 执行真实 MVC 路由、请求反序列化、校验以及项目统一的时间序列化。 */ +class WalletInactiveControllerTest { + private final WalletManageService service = mock(WalletManageService.class); + private MockMvc mvc; + + @BeforeEach + void setup() { + WalletManageController controller = new WalletManageController(service, mock(WalletCodeUseService.class), + mock(WalletManageMapper.class), mock(WalletManageBatchExportService.class)); + mvc = MockMvcBuilders.standaloneSetup(controller) + .setMessageConverters(new MappingJackson2HttpMessageConverter(new ObjectMapper().registerModule(new PjcloudJavaTimeModule()))) + .build(); + } + + @Test + void existingRouteDefaultsToTwentyAndReturnsStringIdsAndFormattedTimes() throws Exception { + WalletInactivePageVo page = new WalletInactivePageVo(); + page.setSize(20); page.setCurrent(1); page.setAsOfTime(LocalDateTime.of(2026,10,22,12,0)); + page.setCutoffTime(page.getAsOfTime().minusMonths(3)); + WalletInactiveUserVo row = new WalletInactiveUserVo(); row.setUserId("9007199254740993"); + page.setRecords(List.of(row)); + when(service.pageJoinActive(any(),any(),any())).thenReturn(page); + mvc.perform(post("/wallet-manage/page-join-active").contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.records[0].userId").value("9007199254740993")) + .andExpect(jsonPath("$.data.asOfTime").value("2026-10-22 12:00:00")) + .andExpect(jsonPath("$.data.cutoffTime").value("2026-07-22 12:00:00")); + ArgumentCaptor captured = ArgumentCaptor.forClass(Page.class); + verify(service).pageJoinActive(captured.capture(),any(),any()); + assertEquals(20,captured.getValue().getSize()); + assertEquals(1,captured.getValue().getCurrent()); + } + + @Test + void invalidMonthsFailBeforeReachingTheService() throws Exception { + for (String value : List.of("0","-1","121","1.5","\"1.5\"","true","{}")) { + mvc.perform(post("/wallet-manage/page-join-active").contentType(MediaType.APPLICATION_JSON) + .content("{\"unactiveMonthsNumber\":"+value+"}")) + .andExpect(status().isBadRequest()); + } + verifyZeroInteractions(service); + } + + @Test + void legacyIntegerStringsPaginationAndDetailRouteRemainCompatible() throws Exception { + when(service.pageJoinActive(any(),any(),any())).thenReturn(new WalletInactivePageVo()); + mvc.perform(post("/wallet-manage/page-join-active").param("current","2").param("size","50") + .contentType(MediaType.APPLICATION_JSON).content("{\"userId\":\"9007199254740993\",\"unactiveMonthsNumber\":\"6\"}")) + .andExpect(status().isOk()); + ArgumentCaptor captured = ArgumentCaptor.forClass(Page.class); + ArgumentCaptor dto = ArgumentCaptor.forClass(WalletTopupActionDto.class); + verify(service).pageJoinActive(captured.capture(),dto.capture(),any()); + assertEquals(2,captured.getValue().getCurrent()); assertEquals(50,captured.getValue().getSize()); + assertEquals(6L,dto.getValue().getUnactiveMonthsNumber()); assertEquals(9007199254740993L,dto.getValue().getUserId()); + when(service.pageListActive(any(),any())).thenReturn(List.of()); + mvc.perform(post("/wallet-manage/list-list-active").contentType(MediaType.APPLICATION_JSON).content("{\"userId\":\"77\"}")) + .andExpect(status().isOk()).andExpect(jsonPath("$.data").isArray()); + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/inactive/WalletInactiveMySqlTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/inactive/WalletInactiveMySqlTest.java new file mode 100644 index 000000000..0700fe0e7 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/inactive/WalletInactiveMySqlTest.java @@ -0,0 +1,348 @@ +package com.bsd.pjcloud.wallet.inactive; + +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.PaginationInnerInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.common.data.datascope.DataScope; +import com.bsd.pjcloud.common.data.datascope.DataScopeInnerInterceptor; +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.WalletTopupActionDto; +import com.bsd.pjcloud.wallet.api.vo.inactive.WalletInactivePageVo; +import com.bsd.pjcloud.wallet.api.vo.inactive.WalletInactiveUserVo; +import com.bsd.pjcloud.wallet.mapper.inactive.WalletInactiveMapper; +import com.bsd.pjcloud.wallet.mapper.inactive.WalletInactiveQueryScope; +import com.bsd.pjcloud.wallet.service.inactive.WalletInactiveQueryService; +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.mapping.MappedStatement; +import org.apache.ibatis.plugin.*; +import org.apache.ibatis.reflection.SystemMetaObject; +import org.apache.ibatis.scripting.defaults.DefaultParameterHandler; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.mybatis.spring.SqlSessionTemplate; +import org.mybatis.spring.transaction.SpringManagedTransactionFactory; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.*; +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.*; + +/** 仅允许本机 MySQL。创建并清理独立 codex_wi_* 库,从不修改业务库或复制个人数据。 */ +@EnabledIfEnvironmentVariable(named = "WALLET_INACTIVE_TEST_JDBC_URL", matches = ".+") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class WalletInactiveMySqlTest { + static final LocalDateTime NOW = LocalDateTime.of(2026, 10, 22, 12, 0); + static final LocalDateTime OLD = NOW.minusMonths(8); + static final WalletInactiveQueryScope ALL = WalletInactiveQueryScope.of(7L, true, DataScope.of()); + private String schema; + private Connection admin; + private PooledDataSource source; + private WalletInactiveMapper mapper; + private WalletInactiveQueryService service; + private final Recorder recorder = new Recorder(); + private final List wallets = new ArrayList<>(); + private final List transactions = new ArrayList<>(); + private long transactionId = 0; + + @BeforeAll + void setup() throws Exception { + String url = System.getenv("WALLET_INACTIVE_TEST_JDBC_URL"); + assertTrue(url.matches("jdbc:mysql://(127\\.0\\.0\\.1|localhost):[0-9]+/.*"), "只允许本机独立测试库"); + admin = DriverManager.getConnection(url, System.getenv("MYSQL_USER"), System.getenv("MYSQL_PWD")); + schema = "codex_wi_" + UUID.randomUUID().toString().replace("-", ""); + execute("create database " + schema); + execute("use " + schema); + execute("create table wallet_type(id bigint primary key,tenant_id bigint,del_flag char(1),unique_code varchar(16),name varchar(64))"); + execute("create table wallet_manage(id bigint primary key,user_id bigint not null,tenant_id bigint,dept_id bigint,wallet_type_id bigint,balance int,create_by varchar(64),create_time datetime,update_time datetime,del_flag char(1),key idx_user(user_id))"); + execute("create table wallet_transaction(id bigint primary key,user_id bigint not null,wallet_id bigint,tenant_id bigint,dept_id bigint,biz_type varchar(8),price int,create_time datetime,update_time datetime,del_flag char(1),key idx_user_time(user_id,create_time),key idx_tenant_time(tenant_id,del_flag,create_time,user_id))"); + execute("insert into wallet_type values(1,7,'0','1','饭卡钱包'),(2,7,'0','2','福利钱包'),(3,7,'0','3','充值钱包'),(4,7,'1','1','已删除类型'),(8,8,'0','1','其他租户钱包')"); + seedEdges(); + applyIndexes(); + applyIndexes(); // 等价索引重复执行必须无害。 + int slash = url.indexOf('/', "jdbc:mysql://".length()); + int question = url.indexOf('?', slash); + String testUrl = url.substring(0, slash) + "/" + schema + (question < 0 ? "" : url.substring(question)); + source = new PooledDataSource("com.mysql.cj.jdbc.Driver", testUrl, System.getenv("MYSQL_USER"), System.getenv("MYSQL_PWD")); + MybatisConfiguration config = new MybatisConfiguration(); + config.setMapUnderscoreToCamelCase(true); + config.setEnvironment(new Environment("wallet-inactive", new SpringManagedTransactionFactory(), source)); + config.addInterceptor(recorder); + MybatisPlusInterceptor plugins = new MybatisPlusInterceptor(); + PjcloudTenantConfigProperties tenant = new PjcloudTenantConfigProperties(); + tenant.setTables(List.of("wallet_manage", "wallet_type", "wallet_transaction")); + plugins.addInnerInterceptor(new TenantLineInnerInterceptor(new PjcloudTenantHandler(tenant))); + DataScopeInnerInterceptor scope = new DataScopeInnerInterceptor(); + scope.setDataScopeHandle(s -> { throw new AssertionError("不得重复计算权限或在分页外层追加过滤"); }); + plugins.addInnerInterceptor(scope); + plugins.addInnerInterceptor(new PaginationInnerInterceptor()); + config.addInterceptor(plugins); + String resource = "mapper/inactive/WalletInactiveMapper.xml"; + try (InputStream input = getClass().getClassLoader().getResourceAsStream(resource)) { + assertNotNull(input); + new XMLMapperBuilder(input, config, resource, config.getSqlFragments()).parse(); + } + mapper = new SqlSessionTemplate(new MybatisSqlSessionFactoryBuilder().build(config)).getMapper(WalletInactiveMapper.class); + ProxyFactory proxy = new ProxyFactory(new WalletInactiveQueryService(mapper)); + proxy.setProxyTargetClass(true); + proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(source), new AnnotationTransactionAttributeSource())); + service = (WalletInactiveQueryService) proxy.getProxy(); + TenantContextHolder.setTenantId(7L); + } + + @AfterAll + void cleanup() throws Exception { + TenantContextHolder.clear(); + if (source != null) source.forceCloseAll(); + if (admin != null) { + try { if (schema != null && schema.matches("codex_wi_[a-f0-9]{32}")) execute("drop database " + schema); } + finally { admin.close(); } + } + } + + @Test @Order(1) + void allWalletActivityDeletedRowsBoundaryTimesAndNeverUsedAgingMatchIndependentOracle() { + for (long months : List.of(1L, 3L, 6L, 12L)) { + WalletTopupActionDto dto = dto(); dto.setUnactiveMonthsNumber(months); + assertMatches(dto, ALL); + } + List actual = ids(service.readPage(new Page<>(1, 100), dto(), ALL, NOW)); + assertTrue(actual.containsAll(List.of(1L, 5L, 7L, 8L, 10L, 11L, 13L, 16L, 23L, 24L, 26L, 27L))); + for (long active : List.of(2L, 3L, 4L, 6L, 9L, 12L, 14L, 15L, 22L, 25L)) assertFalse(actual.contains(active), "不应计入用户 " + active); + } + + @Test @Order(2) + void departmentsIncludeExcludeSelfAndRoleScopesApplyBeforeCountAndLimit() { + for (WalletInactiveQueryScope scope : List.of(ALL, + new WalletInactiveQueryScope(7L, false, null, List.of(10L)), + new WalletInactiveQueryScope(7L, false, "operator", List.of()), + new WalletInactiveQueryScope(7L, false, "operator", List.of(20L)), + new WalletInactiveQueryScope(7L, false, null, List.of()))) { + assertMatches(dto(), scope); + for (boolean include : List.of(true, false)) { + WalletTopupActionDto dto = dto(); dto.setDeptIds(List.of(10L)); dto.setIsInclude(include); + assertMatches(dto, scope); + } + } + WalletTopupActionDto specific = dto(); specific.setUserId(16L); + WalletInactivePageVo result = service.readPage(new Page<>(1, 20), specific, new WalletInactiveQueryScope(7L, false, null, List.of(10L)), NOW); + WalletInactiveUserVo row = result.getRecords().get(0); + assertEquals(100L, row.getTotalBalance()); + assertEquals(WalletInactiveUserVo.Status.RESTRICTED, row.getWallets().get(1).getStatus()); + assertNull(row.getWallets().get(1).getBalance()); + assertEquals(List.of(10L), row.getDeptIds()); + } + + @Test @Order(3) + void explicitTenantCannotLeakViaWalletTypesHistoryOrSameUserId() { + TenantContextHolder.setTenantId(8L); // 即使通用插件上下文不同,专用查询也只能使用服务端权限快照。 + try { + assertMatches(dto(), ALL); + assertMatches(dto(), WalletInactiveQueryScope.of(8L, true, DataScope.of())); + assertTrue(ids(service.readPage(new Page<>(1, 100), dto(), ALL, NOW)).contains(20L)); + assertFalse(ids(service.readPage(new Page<>(1, 100), dto(), WalletInactiveQueryScope.of(8L, true, DataScope.of()), NOW)).contains(20L)); + } finally { TenantContextHolder.setTenantId(7L); } + } + + @Test @Order(4) + void stablePagesAndWalletDetailsUseAConstantFourQueries() { + List expected = oracle(dto(), ALL); + Set seen = new HashSet<>(); + for (int current = 1; current <= (expected.size() + 2) / 3; current++) { + recorder.calls.clear(); + WalletInactivePageVo page = service.readPage(new Page<>(current, 3), dto(), ALL, NOW); + assertEquals(4, recorder.calls.size()); + assertTrue(recorder.calls.stream().allMatch(Capture::transactional)); + for (long id : ids(page)) assertTrue(seen.add(id), "分页不能重复用户"); + } + assertEquals(new HashSet<>(expected), seen); + WalletInactivePageVo last = service.readPage(new Page<>(999, 3), dto(), ALL, NOW); + assertEquals((expected.size() + 2) / 3, last.getCurrent()); + } + + @Test @Order(5) + void snapshotKeepsCountPageAndBalancesConsistentDuringConcurrentLedgerInsert() throws Exception { + addWallet(99000, 7, 1, 10, OLD, false, 100, "seed"); + WalletTopupActionDto dto = dto(); dto.setUserId(99000L); + recorder.beforeWallets = () -> { + try { + execute("update wallet_manage set balance=999 where user_id=99000"); + addTransaction(99000, 7, 1, NOW.minusSeconds(1), false, "1"); + } catch (Exception ex) { throw new RuntimeException(ex); } + }; + try { + WalletInactivePageVo page = service.readPage(new Page<>(1, 20), dto, ALL, NOW); + assertEquals(1, page.getTotal()); + assertEquals(100L, page.getRecords().get(0).getTotalBalance()); + assertTrue(page.getRecords().get(0).isNeverTransacted()); + assertEquals(0, service.readPage(new Page<>(1, 20), dto, ALL, NOW).getTotal()); + assertFalse(TransactionSynchronizationManager.isActualTransactionActive()); + } finally { + recorder.beforeWallets = null; + execute("delete from wallet_manage where user_id=99000"); + execute("delete from wallet_transaction where user_id=99000"); + } + } + + @Test @Order(6) + void hundredThousandUsersMillionLedgersAndWhaleStayIndexedAndBounded() throws Exception { + Assumptions.assumeTrue(Boolean.getBoolean("wallet.inactive.stress"), "使用 -Dwallet.inactive.stress=true 运行百万流水验证"); + 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 generated_users(n int primary key)"); + execute("insert into generated_users select 1+a.n+10*b.n+100*c.n+1000*d.n+10000*e.n from " + digits + " a cross join " + digits + " b cross join " + digits + " c cross join " + digits + " d cross join " + digits + " e"); + execute("insert into wallet_manage select 10000000+n*10+t.id,1000000+n,7,10+mod(n,100),t.id,mod(n,100)*100,'seed','2025-01-01','2025-01-01','0' from generated_users cross join wallet_type t where t.tenant_id=7 and t.del_flag='0'"); + execute("insert into wallet_transaction select 10000000+u.n*10+d.n,1000000+u.n,10000000+u.n*10+1+mod(d.n,3),7,999,'1',100,case when mod(u.n,10)=0 and d.n=9 then '2026-10-01' else timestampadd(day,d.n,'2026-05-01') end,'2026-10-01','0' from generated_users u cross join " + digits + " d"); + execute("insert into wallet_transaction select 30000000+n,1000001,10000011,7,999,'8',-100,timestampadd(second,n,'2026-06-01'),'2026-10-01','0' from generated_users"); + execute("analyze table wallet_manage,wallet_transaction,wallet_type"); + long walletCount = scalar("select count(*) from wallet_manage"); + long transactionCount = scalar("select count(*) from wallet_transaction"); + assertTrue(walletCount >= 300000); assertTrue(transactionCount >= 1100000); + Map scenarios = new LinkedHashMap<>(); + scenarios.put("all", dto()); + WalletTopupActionDto dept = dto(); dept.setDeptIds(List.of(11L)); dept.setIsInclude(true); scenarios.put("department", dept); + WalletTopupActionDto whale = dto(); whale.setUserId(1000001L); scenarios.put("whale", whale); + List reports = new ArrayList<>(); + StringBuilder plans = new StringBuilder(); + reports.add("MySQL=" + admin.getMetaData().getDatabaseProductVersion() + ", users=100000, wallets=" + walletCount + ", ledgers=" + transactionCount); + for (Map.Entry scenario : scenarios.entrySet()) { + service.readPage(new Page<>(1, 20), scenario.getValue(), ALL, NOW); + List times = new ArrayList<>(); + for (int run = 0; run < 5; run++) { + recorder.calls.clear(); + long start = System.nanoTime(); + WalletInactivePageVo page = service.readPage(new Page<>(1, 20), scenario.getValue(), ALL, NOW); + times.add((System.nanoTime() - start) / 1_000_000.0); + assertTrue(page.getRecords().size() <= 20); + assertEquals(4, recorder.calls.size()); + if (scenario.getKey().equals("all")) assertTrue(page.getTotal() >= 90000); + if (scenario.getKey().equals("whale")) assertEquals(1, page.getTotal()); + } + List sorted = times.stream().sorted().toList(); + String report = scenario.getKey() + " ms=" + times + ", median=" + sorted.get(2) + ", p95=" + sorted.get(4) + ", SQL=4"; + reports.add(report); System.out.println("WALLET_INACTIVE_BENCH " + report); + for (Capture call : new ArrayList<>(recorder.calls)) { + String plan = explain(call); + plans.append(scenario.getKey()).append(' ').append(call.statement().getId()).append('\n').append(plan).append('\n'); + assertFalse(plan.toLowerCase(Locale.ROOT).matches("(?s).*table scan on (recent|history|t)\\b.*"), plan); + assertFalse(plan.toLowerCase(Locale.ROOT).contains("sort: t."), plan); + } + } + Files.write(Path.of("target/wallet-inactive-benchmark.txt"), reports); + Files.writeString(Path.of("target/wallet-inactive-explain.txt"), plans); + } + + private void assertMatches(WalletTopupActionDto dto, WalletInactiveQueryScope scope) { + WalletInactivePageVo actual = service.readPage(new Page<>(1, 100), dto, scope, NOW); + List expected = oracle(dto, scope); + assertEquals(expected, ids(actual)); + assertEquals(expected.size(), actual.getTotal()); + for (WalletInactiveUserVo row : actual.getRecords()) { + assertTrue(row.getInactiveMonths() >= (dto.getUnactiveMonthsNumber() == null ? 3 : dto.getUnactiveMonthsNumber())); + assertEquals(3, row.getWallets().size()); + } + } + + private List oracle(WalletTopupActionDto dto, WalletInactiveQueryScope scope) { + LocalDateTime cutoff = NOW.minusMonths(dto.getUnactiveMonthsNumber() == null ? 3 : dto.getUnactiveMonthsNumber()); + return wallets.stream().filter(w -> !w.deleted && w.tenant == scope.getTenantId() && w.type != 4) + .filter(w -> dto.getUserId() == null || w.user == dto.getUserId()) + .filter(w -> scope.isUnrestricted() || Objects.equals(scope.getUsername(), w.creator) || scope.getDeptIds().contains(w.dept)) + .filter(w -> dto.getDeptIds() == null || dto.getDeptIds().isEmpty() || (dto.getIsInclude() == null || dto.getIsInclude()) == dto.getDeptIds().contains(w.dept)) + .map(W::user).distinct().filter(user -> { + LocalDateTime last = transactions.stream().filter(t -> t.user == user && t.tenant == scope.getTenantId() && !t.deleted && !t.time.isAfter(NOW)).map(T::time).max(Comparator.naturalOrder()).orElse(null); + LocalDateTime opened = wallets.stream().filter(w -> w.user == user && w.tenant == scope.getTenantId() && !w.deleted && w.type != 4 && w.created != null).map(W::created).min(Comparator.naturalOrder()).orElse(null); + LocalDateTime since = last != null ? last : opened; + return since != null && !since.isAfter(cutoff); + }).sorted(Comparator.reverseOrder()).collect(Collectors.toList()); + } + + private void seedEdges() throws Exception { + for (int user = 1; user <= 28; user++) { + if (user == 15) addWallet(user,7,1,10,OLD,true,0,"seed"); + else if (user == 22) addWallet(user,7,4,10,OLD,false,0,"seed"); + else addWallet(user,7,1,user == 17 ? 20 : 10, + Set.of(6,23,26).contains(user) ? NOW.minusDays(2) : user == 7 ? NOW.minusMonths(3) : Set.of(24,25).contains(user) ? null : OLD, + false,user == 1 ? -50 : 100,user == 18 ? "operator" : "seed"); + if (user <= 5 || user == 16) addWallet(user,7,2,user == 16 ? 20 : 10,OLD,false,900,"seed"); + if (user >= 2 && user <= 5) addWallet(user,7,3,10,OLD,false,0,"seed"); + } + addWallet(26,7,2,20,OLD,false,500,"seed"); + for (int user : List.of(1,8,10,11,23,24)) addTransaction(user,7,1,user == 8 ? NOW.minusMonths(3) : NOW.minusMonths(4),false,"1"); + addTransaction(2,7,1,NOW.minusDays(1),false,"1"); + addTransaction(3,7,2,NOW.minusDays(1),false,"2"); + addTransaction(4,7,3,NOW.minusDays(1),false,"8"); + addTransaction(9,7,1,NOW.minusMonths(3).plusSeconds(1),false,"6"); + addTransaction(10,7,1,NOW.minusDays(1),true,"1"); + addTransaction(12,7,1,NOW.minusDays(1),false,"1"); // 历史部门999不等于钱包当前部门10。 + addTransaction(13,8,8,NOW.minusDays(1),false,"1"); + addTransaction(14,7,1,NOW.minusDays(1),false,"1"); addTransaction(14,7,1,NOW.minusDays(1),false,"2"); + addWallet(20,8,8,20,OLD,false,0,"seed"); addTransaction(20,8,8,NOW.minusDays(1),false,"1"); + addWallet(21,8,8,20,OLD,false,0,"seed"); + addTransaction(27,7,1,NOW.plusSeconds(1),false,"1"); + } + + private void addWallet(long user,long tenant,long type,long dept,LocalDateTime created,boolean deleted,int balance,String creator) throws SQLException { + long id = tenant * 1000000 + user * 10 + type; + try (PreparedStatement s = admin.prepareStatement("insert into wallet_manage values(?,?,?,?,?,?,?,?,?,?)")) { + s.setLong(1,id); s.setLong(2,user); s.setLong(3,tenant); s.setLong(4,dept); s.setLong(5,type); s.setInt(6,balance); s.setString(7,creator); s.setObject(8,created); s.setObject(9,NOW); s.setString(10,deleted ? "1" : "0"); s.executeUpdate(); + } + wallets.add(new W(user,tenant,type,dept,created,deleted,creator)); + } + private void addTransaction(long user,long tenant,long type,LocalDateTime time,boolean deleted,String biz) throws SQLException { + try (PreparedStatement s = admin.prepareStatement("insert into wallet_transaction values(?,?,?,?,?,?,?,?,?,?)")) { + s.setLong(1,++transactionId); s.setLong(2,user); s.setLong(3,tenant*1000000+user*10+type); s.setLong(4,tenant); s.setLong(5,999); s.setString(6,biz); s.setInt(7,biz.equals("2") ? -100 : 100); s.setObject(8,time); s.setObject(9,NOW); s.setString(10,deleted ? "1" : "0"); s.executeUpdate(); + } + transactions.add(new T(user,tenant,time,deleted)); + } + private void applyIndexes() throws Exception { + String sql = Files.readString(Path.of("../../db/20261022_wallet_inactive_index.sql")) + .replace("pjcloudx_wallet", schema).replaceAll("(?m)^--.*$", ""); + for (String statement : sql.split(";")) if (!statement.isBlank()) execute(statement); + } + private void execute(String sql) throws SQLException { try (Statement s = admin.createStatement()) { s.execute(sql); } } + private long scalar(String sql) throws SQLException { try (Statement s = admin.createStatement(); ResultSet rs = s.executeQuery(sql)) { rs.next(); return rs.getLong(1); } } + private String explain(Capture capture) throws SQLException { + try (PreparedStatement s = admin.prepareStatement("explain analyze " + capture.sql().getSql())) { + new DefaultParameterHandler(capture.statement(), capture.sql().getParameterObject(), capture.sql()).setParameters(s); + try (ResultSet rs = s.executeQuery()) { StringBuilder text = new StringBuilder(); while (rs.next()) text.append(rs.getString(1)).append('\n'); return text.toString(); } + } + } + private static WalletTopupActionDto dto() { return new WalletTopupActionDto(); } + private static List ids(WalletInactivePageVo page) { return page.getRecords().stream().map(r -> Long.valueOf(r.getUserId())).toList(); } + record W(long user,long tenant,long type,long dept,LocalDateTime created,boolean deleted,String creator) { } + record T(long user,long tenant,LocalDateTime time,boolean deleted) { } + record Capture(MappedStatement statement,BoundSql sql,boolean transactional) { } + + @Intercepts(@Signature(type=StatementHandler.class,method="prepare",args={Connection.class,Integer.class})) + static class Recorder implements Interceptor { + final List calls = new ArrayList<>(); + Runnable beforeWallets; + @Override public Object intercept(Invocation invocation) throws Throwable { + StatementHandler target = (StatementHandler) invocation.getTarget(); + BoundSql sql = target.getBoundSql(); + calls.add(new Capture((MappedStatement) SystemMetaObject.forObject(target).getValue("delegate.mappedStatement"), sql, TransactionSynchronizationManager.isActualTransactionActive())); + if (beforeWallets != null && sql.getSql().contains("case when")) { Runnable hook=beforeWallets; beforeWallets=null; hook.run(); } + return invocation.proceed(); + } + @Override public Object plugin(Object target) { return Plugin.wrap(target,this); } + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/inactive/WalletInactiveServiceTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/inactive/WalletInactiveServiceTest.java new file mode 100644 index 000000000..70c0dfcb4 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/inactive/WalletInactiveServiceTest.java @@ -0,0 +1,181 @@ +package com.bsd.pjcloud.wallet.inactive; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.admin.api.entity.SysDept; +import com.bsd.pjcloud.admin.api.entity.SysRole; +import com.bsd.pjcloud.admin.api.entity.SysUser; +import com.bsd.pjcloud.admin.api.feign.RemoteDataScopeService; +import com.bsd.pjcloud.admin.api.feign.RemoteDeptService; +import com.bsd.pjcloud.admin.api.feign.RemoteUserService; +import com.bsd.pjcloud.common.core.constant.SecurityConstants; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.data.datascope.DataScope; +import com.bsd.pjcloud.common.data.tenant.TenantContextHolder; +import com.bsd.pjcloud.common.security.service.PjcloudUser; +import com.bsd.pjcloud.wallet.api.dto.WalletTopupActionDto; +import com.bsd.pjcloud.wallet.api.entity.WalletTypeEntity; +import com.bsd.pjcloud.wallet.api.vo.inactive.WalletInactivePageVo; +import com.bsd.pjcloud.wallet.api.vo.inactive.WalletInactiveUserVo; +import com.bsd.pjcloud.wallet.api.vo.inactive.WalletInactiveUserVo.Status; +import com.bsd.pjcloud.wallet.mapper.inactive.*; +import com.bsd.pjcloud.wallet.service.inactive.WalletInactiveQueryService; +import com.bsd.pjcloud.wallet.service.inactive.WalletInactiveService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class WalletInactiveServiceTest { + private static final LocalDateTime NOW = LocalDateTime.of(2026, 10, 22, 12, 0); + private static final WalletInactiveQueryScope ALL = WalletInactiveQueryScope.of(7L, true, DataScope.of()); + private final WalletInactiveMapper mapper = mock(WalletInactiveMapper.class); + private final WalletInactiveQueryService query = new WalletInactiveQueryService(mapper); + + @BeforeEach + void types() { + when(mapper.listTypes(any())).thenReturn(List.of(type(1), type(2), type(3))); + } + + @AfterEach + void cleanup() { TenantContextHolder.clear(); SecurityContextHolder.clearContext(); } + + @Test + void defaultsClampPagesAndSkipEmptyEnrichmentWithoutMutatingRequest() { + Page input = new Page<>(99, 500); + WalletInactivePageVo result = query.readPage(input, new WalletTopupActionDto(), ALL, NOW); + assertEquals(100, result.getSize()); + assertEquals(1, result.getCurrent()); + assertEquals(3, result.getUnactiveMonthsNumber()); + assertEquals(NOW.minusMonths(3), result.getCutoffTime()); + assertEquals(99, input.getCurrent()); + assertEquals(500, input.getSize()); + assertTrue(result.getRecords().isEmpty()); + verify(mapper, never()).pageUsers(any(), any(), any(), any(), anyLong(), anyLong()); + verify(mapper, never()).listWallets(anyList(), any()); + } + + @Test + void missingZeroNegativeAndRestrictedWalletsHaveDifferentStatesAndNeverLeakMoney() throws Exception { + long id = 9_007_199_254_740_993L; + WalletInactiveUserRecord user = user(id, NOW.minusMonths(8), null); + when(mapper.countUsers(any(), any(), any(), any())).thenReturn(1L); + when(mapper.pageUsers(any(), any(), any(), any(), eq(0L), eq(20L))).thenReturn(List.of(user)); + when(mapper.listWallets(eq(List.of(id)), any())).thenReturn(List.of(wallet(id, "1", 0L, true), wallet(id, "2", 999999L, false))); + WalletInactiveUserVo row = query.readPage(new Page<>(1, 20), new WalletTopupActionDto(), ALL, NOW).getRecords().get(0); + assertEquals(Long.toString(id), row.getUserId()); + assertTrue(row.isNeverTransacted()); + assertEquals(8, row.getInactiveMonths()); + assertEquals(List.of(Status.AVAILABLE, Status.RESTRICTED, Status.UNOPENED), row.getWallets().stream().map(WalletInactiveUserVo.Wallet::getStatus).toList()); + assertEquals(0L, row.getWallets().get(0).getBalance()); + assertNull(row.getWallets().get(1).getBalance()); + assertNull(row.getWallets().get(2).getBalance()); + assertEquals(0L, row.getTotalBalance()); + assertTrue(row.isPartialBalance()); + assertEquals(List.of(10L), row.getDeptIds()); + String json = new ObjectMapper().findAndRegisterModules().writeValueAsString(row); + assertTrue(json.contains("\"userId\":\"9007199254740993\"")); + assertFalse(json.contains("999999")); + assertFalse(json.contains("deptIds")); + } + + @Test + void sumsUseLongAndTimeUsesTheLastLedgerInsteadOfWalletUpdateTime() { + when(mapper.countUsers(any(), any(), any(), any())).thenReturn(21L); + when(mapper.pageUsers(any(), any(), any(), any(), eq(20L), eq(20L))) + .thenReturn(List.of(user(1, NOW.minusYears(2), NOW.minusMonths(3)))); + when(mapper.listWallets(eq(List.of(1L)), any())).thenReturn(List.of(wallet(1, "1", 2147483647L, true), wallet(1, "2", 2147483647L, true), wallet(1, "3", -50L, true))); + WalletInactivePageVo result = query.readPage(new Page<>(999, 20), new WalletTopupActionDto(), ALL, NOW); + assertEquals(2, result.getCurrent()); + assertEquals(21, result.getTotal()); + assertEquals(4294967244L, result.getRecords().get(0).getTotalBalance()); + assertEquals(3, result.getRecords().get(0).getInactiveMonths()); + assertFalse(result.getRecords().get(0).isNeverTransacted()); + assertEquals(NOW.minusMonths(3), result.getRecords().get(0).getInactiveSinceTime()); + } + + @Test + void monthEndAndLeapYearAgreeWithMinusMonthsThresholds() { + for (LocalDateTime now : List.of(LocalDateTime.of(2024, 8, 31, 12, 0), LocalDateTime.of(2025, 3, 31, 12, 0), NOW)) { + for (int months : List.of(1, 3, 6, 12, 120)) { + LocalDateTime edge = now.minusMonths(months); + assertEquals(months, WalletInactiveQueryService.inactiveMonths(edge, now)); + assertEquals(months - 1, WalletInactiveQueryService.inactiveMonths(edge.plusSeconds(1), now)); + } + } + } + + @Test + void invalidMonthsAreRejectedIncludingFractionalJsonAndLegacyIntegerStringsWork() throws Exception { + ObjectMapper json = new ObjectMapper(); + for (String value : List.of("1.5", "\"1.5\"", "true", "{}")) { + assertThrows(Exception.class, () -> json.readValue("{\"unactiveMonthsNumber\":" + value + "}", WalletTopupActionDto.class)); + } + assertEquals(3L, json.readValue("{\"unactiveMonthsNumber\":\"3\"}", WalletTopupActionDto.class).getUnactiveMonthsNumber()); + for (long months : List.of(-1L, 0L, 121L, Long.MAX_VALUE)) { + WalletTopupActionDto dto = new WalletTopupActionDto(); + dto.setUnactiveMonthsNumber(months); + assertThrows(IllegalArgumentException.class, () -> query.readPage(new Page<>(1, 20), dto, ALL, NOW)); + } + verifyZeroInteractions(mapper); + assertThrows(NullPointerException.class, () -> WalletInactiveQueryScope.of(null, true, DataScope.of())); + } + + @Test + void resolvesScopeOnceThenBatchesCurrentPageUsersAndDepartments() { + WalletInactiveQueryService reader = mock(WalletInactiveQueryService.class); + RemoteUserService users = mock(RemoteUserService.class); + RemoteDeptService depts = mock(RemoteDeptService.class); + RemoteDataScopeService roles = mock(RemoteDataScopeService.class); + WalletInactiveService service = new WalletInactiveService(reader, roles, users, depts); + List authorities = List.of(new SimpleGrantedAuthority(SecurityConstants.ROLE + "1")); + PjcloudUser operator = new PjcloudUser(1L, "operator", 10L, null, null, null, null, null, 7L, "password", true, true, "0", true, true, authorities); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(operator, "", authorities)); + TenantContextHolder.setTenantId(7L); + SysRole role = new SysRole(); role.setDsType(1); role.setDsScope("10,20"); + when(roles.getRoleList(List.of(1L))).thenReturn(R.ok(List.of(role))); + WalletInactivePageVo result = new WalletInactivePageVo(); + WalletInactiveUserVo row = new WalletInactiveUserVo(); row.setUserId("77"); row.setDeptIds(List.of(10L)); + result.setRecords(List.of(row)); + when(reader.readPage(any(), any(), any(), any())).thenReturn(result); + SysUser user = new SysUser(); user.setUserId(77L); user.setName("测试用户"); user.setPhone("13800000000"); + SysDept dept = new SysDept(); dept.setDeptId(10L); dept.setName("测试部门"); + when(users.getInnerUserById(List.of(77L))).thenReturn(R.ok(List.of(user))); + when(depts.getDeptByDeptIds(List.of(10L))).thenReturn(R.ok(Map.of(10L, dept))); + DataScope scope = DataScope.of(); + assertSame(result, service.page(new Page<>(1, 20), new WalletTopupActionDto(), scope)); + ArgumentCaptor captured = ArgumentCaptor.forClass(WalletInactiveQueryScope.class); + verify(reader).readPage(any(), any(), captured.capture(), any()); + assertEquals(7L, captured.getValue().getTenantId()); + assertFalse(captured.getValue().isUnrestricted()); + assertEquals(List.of(10L, 20L), captured.getValue().getDeptIds()); + scope.getDeptList().clear(); + assertEquals(List.of(10L, 20L), captured.getValue().getDeptIds()); + assertEquals("测试用户", row.getUsername()); + assertEquals(List.of("测试部门"), row.getDeptNames()); + verify(roles, times(1)).getRoleList(anyList()); + verify(users, times(1)).getInnerUserById(anyList()); + verify(depts, times(1)).getDeptByDeptIds(anyList()); + } + + private static WalletInactiveUserRecord user(long id, LocalDateTime opened, LocalDateTime last) { + WalletInactiveUserRecord row = new WalletInactiveUserRecord(); row.setUserId(id); row.setFirstWalletCreatedTime(opened); row.setLastTransactionTime(last); return row; + } + private static WalletTypeEntity type(long id) { + WalletTypeEntity type = new WalletTypeEntity(); type.setId(id); type.setUniqueCode(Long.toString(id)); type.setName("钱包" + id); return type; + } + private static WalletInactiveWalletRecord wallet(long user, String code, Long balance, boolean visible) { + WalletInactiveWalletRecord row = new WalletInactiveWalletRecord(); row.setUserId(user); row.setCode(code); row.setBalance(balance); row.setVisible(visible); row.setDeptId(10L); return row; + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/rule/WalletRuleTransactionControllerTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/rule/WalletRuleTransactionControllerTest.java new file mode 100644 index 000000000..eb66cf925 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/rule/WalletRuleTransactionControllerTest.java @@ -0,0 +1,56 @@ +package com.bsd.pjcloud.wallet.rule; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.common.data.datascope.DataScope; +import com.bsd.pjcloud.common.data.datascope.DataScopeFuncEnum; +import com.bsd.pjcloud.wallet.api.dto.rule.WalletRuleTransactionDto; +import com.bsd.pjcloud.wallet.api.vo.rule.WalletRuleTransactionVo; +import com.bsd.pjcloud.wallet.controller.rule.WalletRuleTransactionController; +import com.bsd.pjcloud.wallet.service.WalletRuleTransactionService; +import com.bsd.pjcloud.wallet.service.WalletTypeService; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.time.LocalDate; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +class WalletRuleTransactionControllerTest { + + @Test + void preservesAdminPostPaginationBodyDatesAndResponseContract() throws Exception { + WalletRuleTransactionService service = mock(WalletRuleTransactionService.class); + when(service.scopePageList(any(), any(), any())).thenAnswer(call -> { + Page page = call.getArgument(0); + WalletRuleTransactionDto dto = call.getArgument(1); + DataScope scope = call.getArgument(2); + assertEquals(2, page.getCurrent()); + assertEquals(20, page.getSize()); + assertEquals(8_000_000_000_000_000_001L, dto.getUserId()); + assertEquals(List.of(10L, 20L), dto.getDeptIds()); + assertEquals(Boolean.TRUE, dto.getIsInclude()); + assertArrayEquals(new LocalDate[]{LocalDate.of(2026, 7, 22), LocalDate.of(2026, 10, 22)}, dto.getCreateTimeList()); + assertEquals(DataScopeFuncEnum.ALL, scope.getFunc()); + return new Page(2, 20).setTotal(43); + }); + // Verifies MVC binding and the public contract; live authentication/Feign are covered separately by deployment checks. + MockMvc mvc = MockMvcBuilders.standaloneSetup(new WalletRuleTransactionController(service, mock(WalletTypeService.class))).build(); + mvc.perform(post("/wallet-rule-transaction/page-join").param("current", "2").param("size", "20") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"userId\":\"8000000000000000001\",\"deptIds\":[\"10\",\"20\"],\"isInclude\":true," + + "\"createTimeList\":[\"2026-07-22\",\"2026-10-22\"]}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.current").value(2)) + .andExpect(jsonPath("$.data.size").value(20)) + .andExpect(jsonPath("$.data.total").value(43)) + .andExpect(jsonPath("$.data.records").isArray()); + verify(service, times(1)).scopePageList(any(), any(), any()); + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/rule/WalletRuleTransactionMySqlTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/rule/WalletRuleTransactionMySqlTest.java new file mode 100644 index 000000000..96967a939 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/rule/WalletRuleTransactionMySqlTest.java @@ -0,0 +1,632 @@ +package com.bsd.pjcloud.wallet.rule; + +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.PaginationInnerInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.admin.api.feign.RemoteDataScopeService; +import com.bsd.pjcloud.admin.api.feign.RemoteUserService; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.data.datascope.DataScope; +import com.bsd.pjcloud.common.data.datascope.DataScopeInnerInterceptor; +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.common.security.service.PjcloudUser; +import com.bsd.pjcloud.wallet.api.dto.rule.WalletRuleTransactionDto; +import com.bsd.pjcloud.wallet.api.vo.rule.WalletRuleTransactionVo; +import com.bsd.pjcloud.wallet.mapper.rule.WalletRuleTransactionMapper; +import com.bsd.pjcloud.wallet.mapper.rule.WalletRuleTransactionQueryScope; +import com.bsd.pjcloud.wallet.service.WalletManageService; +import com.bsd.pjcloud.wallet.service.WalletRuleJoinService; +import com.bsd.pjcloud.wallet.service.WalletTransactionService; +import com.bsd.pjcloud.wallet.service.impl.rule.WalletRuleTransactionServiceImpl; +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.Environment; +import org.apache.ibatis.mapping.BoundSql; +import org.apache.ibatis.reflection.SystemMetaObject; +import org.apache.ibatis.plugin.*; +import org.apache.ibatis.session.SqlSession; +import org.apache.ibatis.session.SqlSessionFactory; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.*; +import java.time.LocalDate; +import java.util.*; +import java.util.concurrent.*; +import java.util.function.Consumer; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.*; + +/** + * Opt-in real MySQL tests. Creates and drops only a uniquely named codex_wrt_* database; + * the four source tables in pjcloudx_wallet are read only. Never put credentials in source. + * WALLET_RULE_TEST_JDBC_URL, MYSQL_USER, MYSQL_PWD must be supplied by the caller. + * -Dwallet.rule.stress.rows=200000 additionally generates 200k rules / 1m ledger rows. + */ +@EnabledIfEnvironmentVariable(named = "WALLET_RULE_TEST_JDBC_URL", matches = ".+") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class WalletRuleTransactionMySqlTest { + + private static final long EDGE = 8_000_000_000_000_000_000L; + private static final long PERF = 7_000_000_000_000_000_000L; + private static final List TABLES = List.of("wallet_rule_transaction", "wallet_transaction", "wallet_manage", "wallet_type"); + private static final WalletRuleTransactionQueryScope ALL = new WalletRuleTransactionQueryScope(true, null, List.of()); + private String schema; + private Connection admin; + private PooledDataSource dataSource; + private SqlSessionFactory factory; + private final SqlRecorder recorder = new SqlRecorder(); + private final RemoteUserService users = mock(RemoteUserService.class); + private final RemoteDataScopeService roles = mock(RemoteDataScopeService.class); + + public interface LegacyMapper { + Page scopePageList(Page page, + @org.apache.ibatis.annotations.Param("dto") WalletRuleTransactionDto dto); + } + + @BeforeAll + void createIsolatedDatabase() throws Exception { + String url = System.getenv("WALLET_RULE_TEST_JDBC_URL"); + String username = System.getenv("MYSQL_USER"); + String password = System.getenv("MYSQL_PWD"); + admin = DriverManager.getConnection(url, username, password); + schema = "codex_wrt_" + System.currentTimeMillis(); + execute(admin, "CREATE DATABASE " + schema + " CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci"); + execute(admin, "USE " + schema); + for (String table : TABLES) { + execute(admin, "CREATE TABLE " + table + " LIKE pjcloudx_wallet." + table); + execute(admin, "INSERT INTO " + table + " SELECT * FROM pjcloudx_wallet." + table); + } + insertEdgeCases(); + String ddl = Files.readString(Path.of("../../db/20261022_wallet_rule_transaction_page_index.sql")); + ddl = ddl.replace("pjcloudx_wallet.", schema + ".").replaceAll("(?m)^--.*$", ""); + for (String statement : ddl.split(";")) { + if (!statement.isBlank()) execute(admin, statement); + } + for (String table : TABLES) execute(admin, "ANALYZE TABLE " + table); + + int slash = url.indexOf('/', "jdbc:mysql://".length()); + int query = url.indexOf('?', slash); + String testUrl = url.substring(0, slash) + "/" + schema + (query < 0 ? "" : url.substring(query)); + dataSource = new PooledDataSource("com.mysql.cj.jdbc.Driver", testUrl, username, password); + MybatisConfiguration configuration = new MybatisConfiguration(); + configuration.setMapUnderscoreToCamelCase(true); + configuration.setEnvironment(new Environment("wallet-rule-test", new JdbcTransactionFactory(), dataSource)); + configuration.addInterceptor(recorder); + MybatisPlusInterceptor interceptors = new MybatisPlusInterceptor(); + PjcloudTenantConfigProperties tenantProperties = new PjcloudTenantConfigProperties(); + tenantProperties.setTables(TABLES); + interceptors.addInnerInterceptor(new TenantLineInnerInterceptor(new PjcloudTenantHandler(tenantProperties))); + DataScopeInnerInterceptor dataScope = new DataScopeInnerInterceptor(); + dataScope.setDataScopeHandle(scope -> { throw new AssertionError("Permissions must already be resolved once by the service"); }); + interceptors.addInnerInterceptor(dataScope); + PaginationInnerInterceptor pagination = new PaginationInnerInterceptor(); + pagination.setMaxLimit(1000L); + interceptors.addInnerInterceptor(pagination); + configuration.addInterceptor(interceptors); + for (String resource : List.of("mapper/rule/WalletRuleTransactionMapper.xml", "mapper/rule/WalletRuleTransactionLegacyMapper.xml")) { + try (InputStream input = getClass().getClassLoader().getResourceAsStream(resource)) { + assertNotNull(input, resource); + new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse(); + } + } + factory = new MybatisSqlSessionFactoryBuilder().build(configuration); + when(users.getUserDeptByIds(anyList())).thenReturn(R.ok(List.of())); + System.out.printf("WRT_FIXTURE schema=%s rules=%d ledger=%d%n", schema, + scalar(admin, "SELECT COUNT(*) FROM wallet_rule_transaction"), + scalar(admin, "SELECT COUNT(*) FROM wallet_transaction")); + } + + @AfterAll + void dropOnlyIsolatedDatabase() throws Exception { + TenantContextHolder.clear(); + SecurityContextHolder.clearContext(); + if (dataSource != null) dataSource.forceCloseAll(); + if (admin != null) { + try { + if (schema != null && schema.matches("codex_wrt_[0-9]+")) execute(admin, "DROP DATABASE " + schema); + } finally { + admin.close(); + } + } + } + + @Test + @Order(1) + void realDataAllFieldsTotalsFiltersAndPagesMatchLegacyQuery() throws Exception { + WalletRuleTransactionDto original = new WalletRuleTransactionDto(); + original.setCreateTimeList(new LocalDate[]{LocalDate.of(2025, 1, 1), LocalDate.of(2026, 1, 1)}); + List filters = new ArrayList<>(); + filters.add(original); + try (SqlSession session = factory.openSession(true)) { + TenantContextHolder.setTenantId(1L); + Page rows = legacy(session, new Page<>(1, 1000), original); + assertFalse(rows.getRecords().isEmpty()); + for (WalletRuleTransactionVo row : rows.getRecords()) { + WalletRuleTransactionDto byUser = copyDates(original); + byUser.setUserId(row.getUserId()); + filters.add(byUser); + for (boolean include : List.of(true, false)) { + WalletRuleTransactionDto byDept = copyDates(original); + byDept.setDeptIds(List.of(row.getUserDeptId())); + byDept.setIsInclude(include); + filters.add(byDept); + } + } + for (String type : List.of("1", "2", "3")) { + WalletRuleTransactionDto byType = copyDates(original); + byType.setBizType(type); + filters.add(byType); + } + for (String refund : List.of("0", "1")) { + WalletRuleTransactionDto byRefund = copyDates(original); + byRefund.setIsRefund(refund); + filters.add(byRefund); + } + int comparisons = 0; + for (WalletRuleTransactionDto filter : filters) { + for (int current : List.of(1, 2, 10)) { + Page before = legacy(session, new Page<>(current, 10), filter); + Page after = optimized(session, new Page<>(current, 10), filter, ALL); + assertEquals(before.getTotal(), after.getTotal()); + assertThat(after.getRecords()).usingFieldByFieldElementComparator().containsExactlyElementsOf(before.getRecords()); + comparisons++; + } + } + System.out.printf("WRT_REAL_DATA comparisons=%d fields=all%n", comparisons); + } finally { + TenantContextHolder.clear(); + } + } + + @TestFactory + @Order(2) + Stream filtersAndPermissionsApplyBeforeCountAndPagination() { + List cases = List.of( + scenario("all / duplicates / orphan / date end midnight", dto -> {}, ALL, 11, 10, 9, 8, 7, 5, 3, 2, 1), + scenario("user", dto -> dto.setUserId(201L), ALL, 2, 1), + scenario("department include", dto -> { dto.setDeptIds(List.of(10L)); dto.setIsInclude(true); }, ALL, 11, 2, 1), + scenario("department exclude / mixed ledger departments", dto -> dto.setDeptIds(List.of(10L)), ALL, 10, 9, 8, 5, 2), + scenario("null include flag retains include semantics", dto -> { dto.setDeptIds(List.of(10L)); dto.setIsInclude(null); }, ALL, 11, 2, 1), + scenario("empty department filter", dto -> dto.setDeptIds(List.of()), ALL, 11, 10, 9, 8, 7, 5, 3, 2, 1), + scenario("user and department match same ledger", dto -> { dto.setUserId(201L); dto.setDeptIds(List.of(20L)); dto.setIsInclude(true); }, ALL, 2), + scenario("scope department", dto -> {}, scope(null, 10L), 11, 2, 1), + scenario("scope alternate ledger", dto -> {}, scope(null, 20L), 2), + scenario("scope and filter cannot match different ledger rows", dto -> dto.setDeptIds(List.of(10L)), scope(null, 10L)), + scenario("self including missing ledger", dto -> {}, scope("creator-a"), 3, 1), + scenario("self OR department", dto -> {}, scope("creator-a", 30L), 5, 3, 1), + scenario("no data permissions", dto -> {}, scope(null)), + scenario("refund status", dto -> dto.setIsRefund("1"), ALL, 5), + scenario("refund dates", dto -> dto.setRefundTimeList(new LocalDate[]{LocalDate.of(2026, 10, 2), LocalDate.of(2026, 10, 3)}), ALL, 5), + scenario("business type", dto -> dto.setBizType("2"), ALL, 5, 2), + scenario("source wallet", dto -> dto.setSourceWalletId(91003L), ALL, 9), + scenario("missing target wallet", dto -> dto.setTargetWalletId(-1L), ALL), + scenario("missing user", dto -> dto.setUserId(-1L), ALL), + scenario("malformed or padded business ids are not transfers", dto -> dto.setUserId(999L), ALL) + ); + return cases.stream().map(test -> DynamicTest.dynamicTest(test.name, () -> { + TenantContextHolder.setTenantId(1L); + try (SqlSession session = factory.openSession(true)) { + WalletRuleTransactionDto dto = edges(); + test.filter.accept(dto); + Page page = optimized(session, new Page<>(1, 100), dto, test.scope); + assertEquals(test.expected.size(), page.getTotal()); + assertEquals(test.expected, ids(page)); + assertEquals(new HashSet<>(ids(page)).size(), page.getRecords().size()); + if (!test.expected.isEmpty()) { + Page next = optimized(session, new Page<>(2, 2), dto, test.scope); + assertEquals(page.getTotal(), next.getTotal()); + assertEquals(test.expected.stream().skip(2).limit(2).toList(), ids(next)); + } + } finally { + TenantContextHolder.clear(); + SecurityContextHolder.clearContext(); + } + })); + } + + @Test + @Order(3) + void strictSqlTenantIsolationMetadataAndExactLongIds() throws Exception { + TenantContextHolder.setTenantId(1L); + try (SqlSession session = factory.openSession(true)) { + execute(session.getConnection(), "SET SESSION sql_mode=CONCAT_WS(',',@@sql_mode,'ONLY_FULL_GROUP_BY')"); + assertTrue(stringScalar(session.getConnection(), "SELECT @@sql_mode").contains("ONLY_FULL_GROUP_BY")); + recorder.sql.clear(); + Page page = optimized(session, new Page<>(1, 100), edges(), ALL); + assertEquals(3, recorder.sql.size()); + assertFalse(recorder.sql.get(0).toLowerCase().replaceAll("\\s+", " ").matches(".*(join|group by|order by).*")); + assertTrue(recorder.sql.get(0).contains("tenant_id")); + Map rows = new HashMap<>(); + page.getRecords().forEach(row -> rows.put(row.getId() - EDGE, row)); + assertEquals(201L, rows.get(1L).getUserId()); + assertEquals(10L, rows.get(2L).getUserDeptId()); + assertNull(rows.get(3L).getUserId()); + assertNull(rows.get(7L).getUserId(), "Cross-tenant ledger must not populate the row"); + assertEquals(203L, rows.get(8L).getUserId(), "Retain existing visibility of soft-deleted ledger rows"); + assertNull(rows.get(9L).getSourceWalletTypeName(), "Cross-tenant wallet must not populate metadata"); + assertEquals("target-type", rows.get(1L).getTargetWalletTypeName()); + WalletRuleTransactionDto dto = edges(); + dto.setUserId(201L); + dto.setDeptIds(List.of(20L)); + dto.setIsInclude(true); + assertEquals(20L, optimized(session, new Page<>(1, 10), dto, ALL).getRecords().get(0).getUserDeptId()); + assertEquals(3, optimized(session, new Page<>(1, 10), edges(), scope(null, 10L)).getTotal()); + + recorder.sql.clear(); + dto.setUserId(-1L); + assertEquals(0, optimized(session, new Page<>(1, 10), dto, ALL).getTotal()); + assertEquals(1, recorder.sql.size(), "Empty results need only COUNT"); + Page noCount = new Page<>(1, 2, false); + assertEquals(List.of(EDGE + 11, EDGE + 10), ids(optimized(session, noCount, edges(), ALL))); + assertEquals(0, noCount.getTotal()); + assertTrue(optimized(session, new Page<>(99, 10), edges(), ALL).getRecords().isEmpty()); + + TenantContextHolder.setTenantId(2L); + assertEquals(List.of(EDGE + 6), ids(optimized(session, new Page<>(1, 100), edges(), ALL))); + } finally { + TenantContextHolder.clear(); + SecurityContextHolder.clearContext(); + } + } + + @Test + @Order(4) + void parallelRequestsKeepTenantAndPaginationIsolated() throws Exception { + ExecutorService pool = Executors.newFixedThreadPool(4); + try { + List> futures = new ArrayList<>(); + for (int n = 0; n < 40; n++) { + long tenant = n % 2 + 1; + futures.add(pool.submit(() -> { + TenantContextHolder.setTenantId(tenant); + try (SqlSession session = factory.openSession(true)) { + Page page = optimized(session, new Page<>(1, 2), edges(), ALL); + assertEquals(tenant == 1 ? 9 : 1, page.getTotal()); + assertTrue(page.getRecords().stream().allMatch(row -> row.getTenantId().equals(tenant))); + } finally { + TenantContextHolder.clear(); + } + })); + } + for (Future future : futures) future.get(30, TimeUnit.SECONDS); + } finally { + pool.shutdownNow(); + } + } + + @Test + @Order(5) + void largeDataPlansAndEndToEndDatabaseTimings() throws Exception { + int stressRows = Integer.getInteger("wallet.rule.stress.rows", 0); + Assumptions.assumeTrue(stressRows > 0); + assertTrue(stressRows <= 200_000, "Fixture generator supports up to 200k rules / 1m ledger rows"); + // Run the 405 full-field comparisons on the original sample first; only this test needs the volume fixture. + generateLargeData(stressRows); + for (String table : TABLES) execute(admin, "ANALYZE TABLE " + table); + System.out.printf("WRT_LARGE_FIXTURE rules=%d ledger=%d%n", scalar(admin, "SELECT COUNT(*) FROM wallet_rule_transaction"), + scalar(admin, "SELECT COUNT(*) FROM wallet_transaction")); + TenantContextHolder.setTenantId(1L); + try (SqlSession session = factory.openSession(true)) { + WalletRuleTransactionDto dto = new WalletRuleTransactionDto(); + dto.setCreateTimeList(new LocalDate[]{LocalDate.of(2026, 7, 1), LocalDate.of(2026, 10, 1)}); + Page capped = optimized(session, new Page<>(1, 5000), dto, ALL); + assertEquals(1000, capped.getSize()); + assertEquals(1000, capped.getRecords().size()); + List benchmarks = List.of( + new Benchmark("default-first-page", copyDates(dto), ALL, 1), + new Benchmark("deep-page", copyDates(dto), ALL, 5000), + new Benchmark("business-type", with(dto, x -> x.setBizType("2")), ALL, 1), + new Benchmark("refund-status", with(dto, x -> x.setIsRefund("1")), ALL, 1), + new Benchmark("refund-dates", with(dto, x -> x.setRefundTimeList(new LocalDate[]{LocalDate.of(2026, 9, 1), LocalDate.of(2026, 9, 2)})), ALL, 1), + new Benchmark("user", with(dto, x -> x.setUserId(1234L)), ALL, 1), + new Benchmark("department-include", with(dto, x -> { x.setDeptIds(List.of(123L)); x.setIsInclude(true); }), ALL, 1), + new Benchmark("department-exclude", with(dto, x -> x.setDeptIds(List.of(123L))), ALL, 1), + new Benchmark("department-scope", copyDates(dto), scope(null, 123L), 1) + ); + for (Benchmark benchmark : benchmarks) { + // Compare legacy SQL with the source indexes, then optimized SQL with the new indexes. + setPerformanceIndexesVisible(false); + LegacyTiming old = benchmark.scope.isUnrestricted() ? measureLegacy(session, benchmark) : new LegacyTiming(null, -1, false); + Page before = old.page; + double[] newMs = new double[3]; + setPerformanceIndexesVisible(true); + session.clearCache(); + Page after = optimized(session, new Page<>(benchmark.current, 10), benchmark.dto, benchmark.scope); + if (before != null) { + assertEquals(before.getTotal(), after.getTotal(), benchmark.name); + assertThat(after.getRecords()).usingFieldByFieldElementComparator().containsExactlyElementsOf(before.getRecords()); + } + verifyGeneratedResults(benchmark, after, stressRows); + for (int n = 0; n < 3; n++) { + session.clearCache(); + long start = System.nanoTime(); + optimized(session, new Page<>(benchmark.current, 10), benchmark.dto, benchmark.scope); + newMs[n] = (System.nanoTime() - start) / 1e6; + } + Arrays.sort(newMs); + System.out.printf(Locale.ROOT, "WRT_BENCH %s total=%d old_median_ms=%.3f old_timeout=%s new_median_ms=%.3f new_max_ms=%.3f%n", + benchmark.name, after.getTotal(), old.milliseconds, old.timeout, newMs[1], newMs[2]); + int last = recorder.captures.size(); + printCapturedPlan(benchmark.name + "-count", recorder.captures.get(last - 3)); + printCapturedPlan(benchmark.name + "-page", recorder.captures.get(last - 2)); + if (benchmark.name.equals("default-first-page")) printCapturedPlan("page-details", recorder.captures.get(last - 1)); + } + printPlan("page", "SELECT a.id FROM wallet_rule_transaction a WHERE a.tenant_id=1 AND a.del_flag='0' " + + "AND a.create_time BETWEEN '2026-07-01' AND '2026-10-01' ORDER BY a.create_time DESC,a.id DESC LIMIT 10"); + printPlan("count", "SELECT COUNT(*) FROM wallet_rule_transaction a WHERE a.tenant_id=1 AND a.del_flag='0' " + + "AND a.create_time BETWEEN '2026-07-01' AND '2026-10-01'"); + printPlan("ledger", "SELECT MIN(t.id) FROM wallet_transaction t WHERE t.tenant_id=1 AND t.biz_type='6' " + + "AND t.biz_id='" + (PERF + 10000) + "'"); + } finally { + TenantContextHolder.clear(); + } + } + + private Page optimized(SqlSession session, Page page, WalletRuleTransactionDto dto, + WalletRuleTransactionQueryScope scope) { + WalletRuleTransactionServiceImpl service = new WalletRuleTransactionServiceImpl(mock(WalletRuleJoinService.class), + mock(WalletManageService.class), mock(WalletTransactionService.class), users, roles); + ReflectionTestUtils.setField(service, "baseMapper", session.getMapper(WalletRuleTransactionMapper.class)); + DataScope dataScope = null; + if (!scope.isUnrestricted()) { + dataScope = DataScope.of().deptIds(new ArrayList<>(scope.getDeptIds())); + dataScope.setUsername(scope.getUsername()); + PjcloudUser user = new PjcloudUser(1L, "operator", 10L, null, null, null, null, null, + 1L, "password", true, true, "0", true, true, List.of()); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user, "", List.of())); + } + try { + return service.scopePageList(page, dto, dataScope); + } finally { + SecurityContextHolder.clearContext(); + } + } + + private Page legacy(SqlSession session, Page page, WalletRuleTransactionDto dto) throws Exception { + // Only the legacy session temporarily relaxes GROUP BY; production SQL used nonaggregated t columns. + String mode = stringScalar(session.getConnection(), "SELECT @@sql_mode"); + execute(session.getConnection(), "SET SESSION sql_mode=REPLACE(@@sql_mode,'ONLY_FULL_GROUP_BY','')"); + try { + return session.getMapper(LegacyMapper.class).scopePageList(page, dto); + } finally { + try (PreparedStatement restore = session.getConnection().prepareStatement("SET SESSION sql_mode=?")) { + restore.setString(1, mode); + restore.execute(); + } + } + } + + private LegacyTiming measureLegacy(SqlSession session, Benchmark benchmark) throws Exception { + // Keep a pathological legacy plan from monopolizing the test database. A timeout is reported as >= 5s, never as a measured median. + execute(session.getConnection(), "SET SESSION max_execution_time=5000"); + try { + Page page = legacy(session, new Page<>(benchmark.current, 10), benchmark.dto); + double[] times = new double[3]; + for (int n = 0; n < times.length; n++) { + session.clearCache(); + long start = System.nanoTime(); + legacy(session, new Page<>(benchmark.current, 10), benchmark.dto); + times[n] = (System.nanoTime() - start) / 1e6; + } + Arrays.sort(times); + return new LegacyTiming(page, times[1], false); + } catch (Exception error) { + for (Throwable cause = error; cause != null; cause = cause.getCause()) { + if (cause instanceof SQLException sql && sql.getErrorCode() == 3024) return new LegacyTiming(null, 5000, true); + } + throw error; + } finally { + execute(session.getConnection(), "SET SESSION max_execution_time=0"); + } + } + + private static void verifyGeneratedResults(Benchmark benchmark, Page page, int rows) { + List matches = new ArrayList<>(); + for (int n = 1; n <= rows; n++) { + if (n % 20 == 0 || n % 997 == 0) continue; + WalletRuleTransactionDto dto = benchmark.dto; + long userId = 200 + n % 20000; + long deptId = 100 + n % 1000; + if (dto.getUserId() != null && dto.getUserId() != userId) continue; + if (dto.getBizType() != null && !dto.getBizType().equals(n % 99 == 0 ? "2" : "1")) continue; + if (dto.getIsRefund() != null && !dto.getIsRefund().equals(n % 10 == 0 ? "1" : "0")) continue; + if (dto.getRefundTimeList() != null && n % 10 != 0) continue; + if (dto.getDeptIds() != null && !dto.getDeptIds().isEmpty()) { + boolean included = dto.getDeptIds().contains(deptId); + if (Boolean.FALSE.equals(dto.getIsInclude()) == included) continue; + } + if (!benchmark.scope.isUnrestricted() && !benchmark.scope.getDeptIds().contains(deptId)) continue; + matches.add(n); + } + matches.sort(Comparator.comparingLong(n -> (n % 80) * 86400L + n % 86400).reversed().thenComparing(Comparator.reverseOrder())); + assertEquals(matches.size(), page.getTotal(), benchmark.name); + assertEquals(matches.stream().skip((benchmark.current - 1L) * 10).limit(10).map(n -> PERF + n * 10000L).toList(), ids(page), benchmark.name); + for (WalletRuleTransactionVo row : page.getRecords()) { + long n = (row.getId() - PERF) / 10000; + assertEquals(200 + n % 20000, row.getUserId()); + assertEquals(100 + n % 1000, row.getUserDeptId()); + assertEquals("source-type", row.getSourceWalletTypeName()); + assertEquals("target-type", row.getTargetWalletTypeName()); + assertEquals(111, row.getBalance()); + assertEquals(2111, row.getAfterBalance()); + } + } + + private void insertEdgeCases() throws Exception { + execute(admin, "INSERT INTO wallet_type(id,name,icon,unique_code,tenant_id) VALUES " + + "(9101,'source-type','','source',1),(9102,'target-type','','target',1),(9103,'other-tenant','','other',2)"); + execute(admin, "INSERT INTO wallet_manage(id,user_id,wallet_type_id,dept_id,tenant_id) VALUES " + + "(91001,201,9101,10,1),(91002,201,9102,10,1),(91003,204,9103,50,2)"); + for (int n = 1; n <= 12; n++) { + String time = n == 11 ? "2026-10-03 00:00:00" : n == 12 ? "2026-10-03 12:00:00" : "2026-10-01 12:00:00"; + execute(admin, "INSERT INTO wallet_rule_transaction(id,biz_type,source_wallet_id,target_wallet_id," + + "source_wallet_balance,target_wallet_balance,balance,after_balance,balance_multiple,is_refund," + + "refund_time,create_by,create_time,del_flag,tenant_id) VALUES (" + (EDGE + n) + ",'" + + (n == 2 || n == 5 ? "2" : "1") + "'," + (n == 9 ? 91003 : 91001) + + ",91002,1000,2000,111,2111,100,'" + (n == 5 ? "1" : "0") + "'," + + (n == 5 ? "'2026-10-02 00:00:00'" : "NULL") + ",'" + + (n == 1 || n == 3 ? "creator-a" : "creator-b") + "','" + time + "','" + + (n == 4 ? "1" : "0") + "'," + (n == 6 ? 2 : 1) + ")"); + } + ledger(1, 1, 201, 10, 1, "0"); + ledger(1, 2, 201, 10, 1, "0"); + ledger(1, 0, 999, 99, 2, "0"); // Lower id in another tenant must not hide the correct tenant's ledger. + ledger(2, 1, 201, 10, 1, "0"); + ledger(2, 2, 201, 20, 1, "0"); + ledger(4, 1, 201, 10, 1, "0"); + ledger(5, 1, 202, 30, 1, "0"); + ledger(6, 1, 201, 10, 2, "0"); + ledger(7, 1, 999, 10, 2, "0"); + ledger(8, 1, 203, 40, 1, "1"); + ledger(9, 1, 204, 50, 1, "0"); + ledger(10, 1, 205, 60, 1, "0"); + ledger(11, 1, 206, 10, 1, "0"); + ledger(12, 1, 206, 10, 1, "0"); + execute(admin, "INSERT INTO wallet_transaction(id,wallet_id,user_id,dept_id,biz_type,biz_id,title,price,balance,tenant_id) VALUES " + + "(" + (EDGE + 20001) + ",91001,999,10,'6','not-a-number','fixture',1,100,1)," + + "(" + (EDGE + 20002) + ",91001,999,10,'6','0" + (EDGE + 1) + "','fixture',1,100,1)"); + } + + private void ledger(int rule, int ordinal, long user, long dept, long tenant, String deleted) throws Exception { + execute(admin, "INSERT INTO wallet_transaction(id,wallet_id,user_id,dept_id,biz_type,biz_id,title,price,balance,del_flag,tenant_id) VALUES (" + + (EDGE + 10000 + rule * 10L + ordinal) + ",91001," + user + "," + dept + ",'6','" + (EDGE + rule) + + "','fixture',1,100,'" + deleted + "'," + tenant + ")"); + } + + private void generateLargeData(int rows) throws Exception { + long started = System.nanoTime(); + execute(admin, "CREATE TABLE perf_sequence(n INT PRIMARY KEY)"); + 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(admin, "INSERT INTO perf_sequence SELECT 1+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 1+a.n+10*b.n+100*c.n+1000*d.n+10000*e.n+100000*f.n <= " + rows * 5); + execute(admin, "INSERT INTO wallet_rule_transaction(id,biz_type,source_wallet_id,target_wallet_id,source_wallet_balance," + + "target_wallet_balance,balance,after_balance,balance_multiple,is_refund,refund_time,create_by,create_time,del_flag,tenant_id) " + + "SELECT " + PERF + "+n*10000,IF(MOD(n,99)=0,'2','1'),91001,91002,1000,2000,111,2111,100,IF(MOD(n,10)=0,'1','0')," + + "IF(MOD(n,10)=0,'2026-09-01',NULL),'perf',DATE_ADD(DATE_ADD('2026-07-01',INTERVAL MOD(n,80) DAY),INTERVAL MOD(n,86400) SECOND)," + + "IF(MOD(n,997)=0,'1','0'),IF(MOD(n,20)=0,2,1) FROM perf_sequence WHERE n <= " + rows); + execute(admin, "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 6000000000000000000+n*10000,91001,200+MOD(CEIL(n/2),20000),100+MOD(CEIL(n/2),1000)," + + "IF(n<=" + rows * 2 + ",'6','1'),CAST(IF(n<=" + rows * 2 + "," + PERF + "+CEIL(n/2)*10000,8500000000000000000+n*10000) AS CHAR)," + + "'perf',IF(MOD(n,2)=0,111,-111),1000,'2026-09-01','0',IF(MOD(CEIL(n/2),20)=0,2,1) FROM perf_sequence"); + execute(admin, "DROP TABLE perf_sequence"); + System.out.printf(Locale.ROOT, "WRT_GENERATE rules=%d ledger=%d seconds=%.2f%n", rows, rows * 5, (System.nanoTime() - started) / 1e9); + } + + private void printPlan(String label, String sql) throws Exception { + try (Statement statement = admin.createStatement(); ResultSet result = statement.executeQuery("EXPLAIN ANALYZE " + sql)) { + while (result.next()) System.out.printf("WRT_PLAN %s %s%n", label, result.getString(1).replace('\n', ' ')); + } + } + + private void setPerformanceIndexesVisible(boolean visible) throws Exception { + String visibility = visible ? " VISIBLE" : " INVISIBLE"; + execute(admin, "ALTER TABLE wallet_rule_transaction ALTER INDEX idx_wrt_tenant_del_ct_id" + visibility + + ", ALTER INDEX idx_wrt_tenant_del_biz_ct_id" + visibility + + ", ALTER INDEX idx_wrt_tenant_del_refund_ct_id" + visibility + + ", ALTER INDEX idx_wrt_tenant_del_rt_ct_id" + visibility); + execute(admin, "ALTER TABLE wallet_transaction ALTER INDEX idx_wt_tenant_bizid_type" + visibility + + ", ALTER INDEX idx_wt_tenant_type_dept_bizid" + visibility); + } + + private void printCapturedPlan(String label, SqlCapture query) throws Exception { + try (PreparedStatement statement = admin.prepareStatement("EXPLAIN ANALYZE " + query.sql)) { + for (int index = 0; index < query.params.size(); index++) statement.setObject(index + 1, query.params.get(index)); + try (ResultSet result = statement.executeQuery()) { + while (result.next()) System.out.printf("WRT_PLAN %s %s%n", label, result.getString(1).replace('\n', ' ')); + } + } + } + + private static void execute(Connection connection, String sql) throws Exception { + try (Statement statement = connection.createStatement()) { statement.execute(sql); } + } + + private static long scalar(Connection connection, String sql) throws Exception { + try (Statement statement = connection.createStatement(); ResultSet result = statement.executeQuery(sql)) { + assertTrue(result.next()); + return result.getLong(1); + } + } + + private static String stringScalar(Connection connection, String sql) throws Exception { + try (Statement statement = connection.createStatement(); ResultSet result = statement.executeQuery(sql)) { + assertTrue(result.next()); + return result.getString(1); + } + } + + private static WalletRuleTransactionDto edges() { + WalletRuleTransactionDto dto = new WalletRuleTransactionDto(); + dto.setCreateTimeList(new LocalDate[]{LocalDate.of(2026, 10, 1), LocalDate.of(2026, 10, 3)}); + return dto; + } + + private static WalletRuleTransactionDto copyDates(WalletRuleTransactionDto source) { + WalletRuleTransactionDto copy = new WalletRuleTransactionDto(); + copy.setCreateTimeList(source.getCreateTimeList().clone()); + return copy; + } + + private static WalletRuleTransactionDto with(WalletRuleTransactionDto source, Consumer change) { + WalletRuleTransactionDto copy = copyDates(source); + change.accept(copy); + return copy; + } + + private static List ids(Page page) { + return page.getRecords().stream().map(WalletRuleTransactionVo::getId).toList(); + } + + private static WalletRuleTransactionQueryScope scope(String username, Long... depts) { + return new WalletRuleTransactionQueryScope(false, username, List.of(depts)); + } + + private static Scenario scenario(String name, Consumer filter, + WalletRuleTransactionQueryScope scope, int... expected) { + return new Scenario(name, filter, scope, Arrays.stream(expected).mapToObj(id -> EDGE + id).toList()); + } + + private record Scenario(String name, Consumer filter, + WalletRuleTransactionQueryScope scope, List expected) {} + private record Benchmark(String name, WalletRuleTransactionDto dto, WalletRuleTransactionQueryScope scope, int current) {} + private record LegacyTiming(Page page, double milliseconds, boolean timeout) {} + private record SqlCapture(String sql, List params) {} + + @Intercepts(@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})) + public static class SqlRecorder implements Interceptor { + final List sql = new CopyOnWriteArrayList<>(); + final List captures = new CopyOnWriteArrayList<>(); + public Object intercept(Invocation invocation) throws Throwable { + BoundSql bound = ((StatementHandler) invocation.getTarget()).getBoundSql(); + sql.add(bound.getSql()); + List params = 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(), params)); + return invocation.proceed(); + } + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/rule/WalletRuleTransactionServiceTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/rule/WalletRuleTransactionServiceTest.java new file mode 100644 index 000000000..6a1f7a359 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/rule/WalletRuleTransactionServiceTest.java @@ -0,0 +1,150 @@ +package com.bsd.pjcloud.wallet.rule; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.admin.api.entity.SysRole; +import com.bsd.pjcloud.admin.api.feign.RemoteDataScopeService; +import com.bsd.pjcloud.admin.api.feign.RemoteUserService; +import com.bsd.pjcloud.admin.api.vo.UserVO; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.core.constant.SecurityConstants; +import com.bsd.pjcloud.common.data.datascope.DataScope; +import com.bsd.pjcloud.common.security.service.PjcloudUser; +import com.bsd.pjcloud.wallet.api.dto.rule.WalletRuleTransactionDto; +import com.bsd.pjcloud.wallet.api.vo.rule.WalletRuleTransactionVo; +import com.bsd.pjcloud.wallet.mapper.rule.WalletRuleTransactionMapper; +import com.bsd.pjcloud.wallet.mapper.rule.WalletRuleTransactionQueryScope; +import com.bsd.pjcloud.wallet.service.WalletManageService; +import com.bsd.pjcloud.wallet.service.WalletRuleJoinService; +import com.bsd.pjcloud.wallet.service.WalletTransactionService; +import com.bsd.pjcloud.wallet.service.impl.rule.WalletRuleTransactionServiceImpl; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class WalletRuleTransactionServiceTest { + + private final WalletRuleTransactionMapper mapper = mock(WalletRuleTransactionMapper.class); + private final RemoteUserService users = mock(RemoteUserService.class); + private final RemoteDataScopeService scopes = mock(RemoteDataScopeService.class); + private final WalletRuleTransactionServiceImpl service = new WalletRuleTransactionServiceImpl( + mock(WalletRuleJoinService.class), mock(WalletManageService.class), + mock(WalletTransactionService.class), users, scopes); + + WalletRuleTransactionServiceTest() { + ReflectionTestUtils.setField(service, "baseMapper", mapper); + } + + @Test + void emptyPagePreservesPaginationAndSkipsEnrichment() { + Page page = new Page(9, 10).setTotal(80); + when(mapper.scopePageList(any(), any(), any())).thenReturn(page); + assertSame(page, service.scopePageList(page, new WalletRuleTransactionDto(), null)); + assertEquals(80, page.getTotal()); + verify(mapper, never()).listPageDetails(any(), any(), any()); + verifyZeroInteractions(users, scopes); + } + + @Test + void enrichesOnlyCurrentPageInOriginalOrderAndBatchesDistinctUsers() { + WalletRuleTransactionVo first = row(30L, null); + first.setBalance(123); + WalletRuleTransactionVo second = row(20L, null); + WalletRuleTransactionVo orphan = row(10L, null); + Page page = new Page(2, 3) + .setTotal(100).setRecords(List.of(first, second, orphan)); + WalletRuleTransactionVo detail = row(30L, 77L); + detail.setBalance(123); + detail.setSourceWalletTypeName("饭卡"); + detail.setSourceWalletTypeCode("eat"); + detail.setTargetWalletTypeName("消费"); + detail.setTargetWalletTypeCode("pay"); + when(mapper.scopePageList(any(), any(), any())).thenReturn(page); + when(mapper.listPageDetails(eq(List.of(30L, 20L, 10L)), any(), any())) + .thenReturn(List.of(row(20L, 77L), detail, row(10L, null))); + UserVO user = new UserVO(); + user.setUserId(77L); + user.setName("测试用户"); + user.setPhone("13800000000"); + user.setDeptId(8L); + user.setDeptName("当前部门"); + when(users.getUserDeptByIds(List.of(77L))).thenReturn(R.ok(List.of(user))); + + assertSame(page, service.scopePageList(page, new WalletRuleTransactionDto(), null)); + first = page.getRecords().get(0); + second = page.getRecords().get(1); + assertEquals(List.of(30L, 20L, 10L), page.getRecords().stream().map(WalletRuleTransactionVo::getId).toList()); + assertEquals(100, page.getTotal()); + assertEquals(123, first.getBalance()); + assertEquals("饭卡", first.getSourceWalletTypeName()); + assertEquals("pay", first.getTargetWalletTypeCode()); + assertEquals("测试用户", second.getUsername()); + assertEquals(8L, first.getUserDeptId()); + assertNull(orphan.getUserId()); + verify(users, times(1)).getUserDeptByIds(List.of(77L)); + } + + @Test + void concurrentDeletionDoesNotReturnAnIncompleteIdOnlyRecord() { + Page page = new Page(1, 2) + .setTotal(2).setRecords(List.of(row(2L, null), row(1L, null))); + WalletRuleTransactionVo detail = row(1L, null); + detail.setBalance(456); + when(mapper.scopePageList(any(), any(), any())).thenReturn(page); + when(mapper.listPageDetails(any(), any(), any())).thenReturn(List.of(detail)); + service.scopePageList(page, new WalletRuleTransactionDto(), null); + assertEquals(List.of(detail), page.getRecords()); + assertEquals(456, page.getRecords().get(0).getBalance()); + assertEquals(2, page.getTotal()); + verifyZeroInteractions(users); + } + + @Test + void resolvesRolesOnceAndSharesImmutableScopeWithBothQueries() { + List authorities = List.of(new SimpleGrantedAuthority(SecurityConstants.ROLE + "1")); + PjcloudUser user = new PjcloudUser(1L, "operator", 10L, null, null, null, null, null, + 1L, "password", true, true, "0", true, true, authorities); + SysRole role = new SysRole(); + role.setDsType(1); + role.setDsScope("10,20"); + when(scopes.getRoleList(List.of(1L))).thenReturn(R.ok(List.of(role))); + Page page = new Page() + .setRecords(List.of(row(1L, null))); + when(mapper.scopePageList(any(), any(), any())).thenReturn(page); + when(mapper.listPageDetails(any(), any(), any())).thenReturn(List.of()); + DataScope dataScope = DataScope.of(); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user, "", authorities)); + try { + service.scopePageList(page, new WalletRuleTransactionDto(), dataScope); + } finally { + SecurityContextHolder.clearContext(); + } + ArgumentCaptor captured = ArgumentCaptor.forClass(WalletRuleTransactionQueryScope.class); + verify(mapper).scopePageList(any(), any(), captured.capture()); + WalletRuleTransactionQueryScope resolved = captured.getValue(); + assertFalse(resolved.isUnrestricted()); + assertEquals(List.of(10L, 20L), resolved.getDeptIds()); + verify(mapper).listPageDetails(any(), any(), same(resolved)); + verify(scopes, times(1)).getRoleList(List.of(1L)); + dataScope.setDeptList(new ArrayList<>()); + assertEquals(List.of(10L, 20L), resolved.getDeptIds()); + assertThrows(UnsupportedOperationException.class, () -> resolved.getDeptIds().add(30L)); + verifyZeroInteractions(users); + } + + private static WalletRuleTransactionVo row(Long id, Long userId) { + WalletRuleTransactionVo row = new WalletRuleTransactionVo(); + row.setId(id); + row.setUserId(userId); + return row; + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/transaction/WalletDepartmentStatisticsSqlTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/transaction/WalletDepartmentStatisticsSqlTest.java new file mode 100644 index 000000000..52274e8e3 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/transaction/WalletDepartmentStatisticsSqlTest.java @@ -0,0 +1,316 @@ +package com.bsd.pjcloud.wallet.transaction; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +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.WalletTransactionSumAndCountDto; +import com.bsd.pjcloud.wallet.api.vo.WalletDepartmentStatisticsVo; +import com.bsd.pjcloud.wallet.api.vo.WalletTransactionSumAndCountVo; +import com.bsd.pjcloud.wallet.mapper.WalletTransactionMapper; +import org.apache.ibatis.builder.xml.XMLMapperBuilder; +import org.apache.ibatis.datasource.unpooled.UnpooledDataSource; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.session.LocalCacheScope; +import org.apache.ibatis.session.SqlSession; +import org.apache.ibatis.session.SqlSessionFactoryBuilder; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.InputStream; +import java.sql.Connection; +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.*; + +/** 使用真实 XML、生产租户处理器及独立 H2 库,对照优化前六次 sumAndCount 逐字段校验。 */ +class WalletDepartmentStatisticsSqlTest { + private static final LocalDateTime FROM = LocalDateTime.of(2026, 10, 20, 0, 0); + private static final LocalDateTime TO = LocalDateTime.of(2026, 10, 21, 23, 59, 59); + private MybatisConfiguration configuration; + private SqlSession session; + private Connection connection; + private WalletTransactionMapper mapper; + + @BeforeEach + void setup() throws Exception { + configuration = new MybatisConfiguration(); + configuration.setMapUnderscoreToCamelCase(true); + configuration.setLocalCacheScope(LocalCacheScope.STATEMENT); + configuration.setEnvironment(new Environment("wallet-department", new JdbcTransactionFactory(), + new UnpooledDataSource("org.h2.Driver", "jdbc:h2:mem:wallet_department_" + UUID.randomUUID() + + ";MODE=MySQL;DATABASE_TO_LOWER=TRUE", "sa", ""))); + PjcloudTenantConfigProperties tenant = new PjcloudTenantConfigProperties(); + tenant.setTables(List.of("wallet_transaction", "wallet_manage")); + MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); + interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new PjcloudTenantHandler(tenant))); + configuration.addInterceptor(interceptor); + String resource = "mapper/WalletTransactionMapper.xml"; + try (InputStream input = getClass().getClassLoader().getResourceAsStream(resource)) { + assertNotNull(input); + new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse(); + } + session = new SqlSessionFactoryBuilder().build(configuration).openSession(true); + connection = session.getConnection(); + mapper = session.getMapper(WalletTransactionMapper.class); + TenantContextHolder.setTenantId(7L); + execute(""" + create table wallet_manage ( + id bigint primary key, tenant_id bigint, wallet_type_id bigint, del_flag varchar(1)); + create table wallet_transaction ( + id bigint primary key, tenant_id bigint, wallet_id bigint, dept_id bigint, + biz_type varchar(12), biz_id varchar(64), price int, create_time timestamp, del_flag varchar(1)); + insert into wallet_manage values + (101,7,11,'0'),(102,7,22,'0'),(103,7,11,'1'),(104,8,11,'0'); + insert into wallet_transaction values + (1,7,101,10,'2','duplicate',100,'2026-10-20 12:00:00','0'), + (2,7,101,10,'2','duplicate',200,'2026-10-20 12:00:00','0'), + (3,7,101,10,'2','3',-40,'2026-10-20 12:00:00','0'), + (4,7,101,10,'2','4',0,'2026-10-20 12:00:00','0'), + (5,7,101,10,'1','5',30,'2026-10-20 12:00:00','0'), + (6,7,101,10,'3','6',50,'2026-10-20 12:00:00','0'), + (7,7,101,10,'1','7',-10,'2026-10-20 12:00:00','0'), + (8,7,101,10,'3','8',-20,'2026-10-20 12:00:00','0'), + (9,7,101,10,'1','9',0,'2026-10-20 12:00:00','0'), + (10,7,101,10,'3','10',0,'2026-10-20 12:00:00','0'), + (11,7,101,10,'8','11',60,'2026-10-20 12:00:00','0'), + (12,7,101,10,'8','12',-25,'2026-10-20 12:00:00','0'), + (13,7,101,10,'8','13',0,'2026-10-20 12:00:00','0'), + (14,7,101,10,'8','14',null,'2026-10-20 12:00:00','0'), + (15,7,101,10,'5','15',100,'2026-10-20 12:00:00','0'), + (16,7,101,10,'5','16',-30,'2026-10-20 12:00:00','0'), + (17,7,101,10,'5','17',0,'2026-10-20 12:00:00','0'), + (18,7,101,10,'5','18',null,'2026-10-20 12:00:00','0'), + (19,7,102,20,'2','19',700,'2026-10-20 12:00:00','0'), + (20,7,102,20,'5','20',10,'2026-10-20 12:00:00','0'), + (21,7,103,30,'2','21',400,'2026-10-20 12:00:00','0'), + (22,7,999,40,'5','22',55,'2026-10-20 12:00:00','0'), + (23,7,104,50,'2','23',600,'2026-10-20 12:00:00','0'), + (24,7,101,60,'2','24',9999,'2026-10-20 12:00:00','1'), + (25,7,101,null,'2','25',9999,'2026-10-20 12:00:00','0'), + (26,7,101,70,'2','26',0,'2026-10-20 12:00:00','0'), + (27,7,101,80,'4','27',9999,'2026-10-20 12:00:00','0'), + (28,7,101,80,'6','28',9999,'2026-10-20 12:00:00','0'), + (29,7,101,80,'7','29',9999,'2026-10-20 12:00:00','0'), + (30,7,101,90,'5','30',1,'2026-10-20 00:00:00','0'), + (31,7,101,90,'5','31',2,'2026-10-21 23:59:59','0'), + (32,7,101,90,'5','32',1000,'2026-10-19 23:59:59','0'), + (33,7,101,90,'5','33',1000,'2026-10-22 00:00:00','0'), + (34,7,101,90,'5','34',1000,null,'0'), + (35,8,104,100,'2','duplicate',8888,'2026-10-20 12:00:00','0'), + (36,7,101,110,'5','36',null,'2026-10-20 12:00:00','0'); + """); + } + + @AfterEach + void close() { + TenantContextHolder.clear(); + if (session != null) session.close(); + } + + @Test + void signedAmountsZeroNullAndDuplicateRowsMatchAllSixOriginalQueries() { + assertMatchesLegacy(query()); + Map rows = rows(query()); + WalletDepartmentStatisticsVo value = rows.get(10L); + assertEquals(300L, value.getBatchIncomeSum()); + assertEquals(2L, value.getBatchIncomeCount()); + assertEquals(-40L, value.getBatchExpenseSum()); + assertEquals(1L, value.getBatchExpenseCount()); + assertEquals(80L, value.getOnceIncomeSum()); + assertEquals(2L, value.getOnceIncomeCount()); + assertEquals(-30L, value.getOnceExpenseSum()); + assertEquals(2L, value.getOnceExpenseCount()); + assertEquals(35L, value.getReturnCanteenSum()); + assertEquals(4L, value.getReturnCanteenCount()); + assertEquals(70L, value.getRefundSum()); + assertEquals(4L, value.getRefundCount()); + assertEquals(0L, rows.get(110L).getRefundSum()); + assertEquals(1L, rows.get(110L).getRefundCount()); + assertFalse(rows.containsKey(60L)); + assertFalse(rows.containsKey(70L)); + assertFalse(rows.containsKey(80L)); + assertFalse(rows.containsKey(100L)); + } + + @Test + void departmentIncludeExcludeNullAndEmptyFiltersMatchOriginalQueries() { + List> filters = List.of( + q -> q.setDeptIds(List.of()), + q -> { q.setDeptIds(List.of(10L, 30L)); q.setIsInclude(true); }, + q -> { q.setDeptIds(List.of(10L, 30L)); q.setIsInclude(false); }, + q -> { q.setDeptIds(List.of(10L, 30L)); q.setIsInclude(null); }, + q -> { q.setDeptIds(List.of(-1L)); q.setIsInclude(true); }, + q -> { q.setDeptIds(List.of(-1L)); q.setIsInclude(false); }); + for (Consumer filter : filters) { + WalletTransactionSumAndCountDto dto = query(); + filter.accept(dto); + assertMatchesLegacy(dto); + } + } + + @Test + void walletTypeScalarListIntersectionAndEmptyListsMatchOriginalQueries() { + List> filters = List.of( + q -> q.setWalletTypeId(11L), q -> q.setWalletTypeId(22L), + q -> q.setWalletTypeIdList(List.of(11L, 22L)), + q -> q.setWalletTypeIdList(List.of()), + q -> { q.setWalletTypeId(11L); q.setWalletTypeIdList(List.of(11L, 22L)); }, + q -> { q.setWalletTypeId(11L); q.setWalletTypeIdList(List.of(22L)); }, + q -> { q.setWalletTypeId(11L); q.setDeptIds(List.of(10L)); q.setIsInclude(false); }); + for (Consumer filter : filters) { + WalletTransactionSumAndCountDto dto = query(); + filter.accept(dto); + assertMatchesLegacy(dto); + } + } + + @Test + void deletedWalletsOrphansAndOtherTenantWalletsKeepExistingJoinSemantics() { + assertEquals(400L, rows(query()).get(30L).getBatchIncomeSum()); + assertEquals(55L, rows(query()).get(40L).getRefundSum()); + assertEquals(600L, rows(query()).get(50L).getBatchIncomeSum()); + WalletTransactionSumAndCountDto dto = query(); + dto.setWalletTypeId(11L); + Map rows = rows(dto); + assertEquals(400L, rows.get(30L).getBatchIncomeSum(), "不能额外剔除已删除钱包的历史流水"); + assertFalse(rows.containsKey(40L)); + assertFalse(rows.containsKey(50L)); + assertMatchesLegacy(dto); + } + + @Test + void timeBoundariesNoTimeFilterAndCorrectedLedgerDepartmentAndTimeMatchOriginalQueries() throws Exception { + assertEquals(3L, rows(query()).get(90L).getRefundSum()); + WalletTransactionSumAndCountDto unbounded = query(); + unbounded.setCreateTimeList(null); + assertMatchesLegacy(unbounded); + assertEquals(3003L, rows(unbounded).get(90L).getRefundSum()); + unbounded.setCreateTimeList(new LocalDateTime[]{FROM}); + assertMatchesLegacy(unbounded); + execute("update wallet_transaction set dept_id=20,create_time='2026-10-22 00:00:00' where id=15"); + assertMatchesLegacy(query()); + assertEquals(-30L, rows(query()).get(10L).getRefundSum()); + assertEquals(10L, rows(query()).get(20L).getRefundSum()); + } + + @Test + void productionTenantInterceptorScopesFilteredAndUnfilteredQueries() { + TenantContextHolder.setTenantId(8L); + assertMatchesLegacy(query()); + assertEquals(List.of(100L), rows(query()).keySet().stream().toList()); + assertEquals(8888L, rows(query()).get(100L).getBatchIncomeSum()); + WalletTransactionSumAndCountDto dto = query(); + dto.setWalletTypeId(11L); + assertMatchesLegacy(dto); + assertEquals(8888L, rows(dto).get(100L).getBatchIncomeSum()); + } + + @Test + void noWalletJoinWithoutTypeFilterAndWalletIdIsBound() { + WalletTransactionSumAndCountDto dto = query(); + String statement = WalletTransactionMapper.class.getName() + ".departmentStatistics"; + String noJoin = configuration.getMappedStatement(statement).getBoundSql(Map.of("dto", dto)).getSql(); + assertFalse(noJoin.contains("wallet_manage")); + dto.setWalletTypeIdList(List.of()); + assertFalse(configuration.getMappedStatement(statement).getBoundSql(Map.of("dto", dto)).getSql().contains("wallet_manage")); + dto.setWalletTypeId(11L); + dto.setWalletId(101L); + String filtered = configuration.getMappedStatement(statement).getBoundSql(Map.of("dto", dto)).getSql(); + assertTrue(filtered.contains("inner join wallet_manage")); + assertTrue(filtered.contains("a.wallet_id = ?")); + assertTrue(filtered.contains("m.wallet_type_id = ?")); + assertFalse(filtered.contains("101")); + assertEquals(List.of(10L, 90L, 110L), rows(dto).keySet().stream().sorted().toList()); + } + + @Test + void optionalBusinessAndDirectionFiltersRestrictAllCategories() { + WalletTransactionSumAndCountDto dto = query(); + dto.setBizTypes(List.of("5", "8", "4")); + dto.setType(WalletTransactionSumAndCountDto.TYPE_EXPENSE); + WalletDepartmentStatisticsVo row = rows(dto).get(10L); + assertEquals(-30L, row.getRefundSum()); + assertEquals(1L, row.getRefundCount()); + assertEquals(-25L, row.getReturnCanteenSum()); + assertEquals(1L, row.getReturnCanteenCount()); + assertEquals(0L, row.getBatchIncomeSum()); + assertEquals(0L, row.getBatchExpenseCount()); + } + + private WalletTransactionSumAndCountDto query() { + WalletTransactionSumAndCountDto dto = new WalletTransactionSumAndCountDto(); + dto.setCreateTimeList(new LocalDateTime[]{FROM, TO}); + return dto; + } + + private Map rows(WalletTransactionSumAndCountDto dto) { + return mapper.departmentStatistics(dto).stream().collect(Collectors.toMap( + WalletDepartmentStatisticsVo::getDeptId, value -> value, (first, ignored) -> first, LinkedHashMap::new)); + } + + private void assertMatchesLegacy(WalletTransactionSumAndCountDto base) { + Map expected = new LinkedHashMap<>(); + legacyCategory(base, List.of("2"), 1, expected, (target, value) -> { + target.setBatchIncomeSum(sum(value)); target.setBatchIncomeCount(value.getCount()); + }); + legacyCategory(base, List.of("2"), 2, expected, (target, value) -> { + target.setBatchExpenseSum(sum(value)); target.setBatchExpenseCount(value.getCount()); + }); + legacyCategory(base, List.of("1", "3"), 1, expected, (target, value) -> { + target.setOnceIncomeSum(sum(value)); target.setOnceIncomeCount(value.getCount()); + }); + legacyCategory(base, List.of("1", "3"), 2, expected, (target, value) -> { + target.setOnceExpenseSum(sum(value)); target.setOnceExpenseCount(value.getCount()); + }); + legacyCategory(base, List.of("8"), null, expected, (target, value) -> { + target.setReturnCanteenSum(sum(value)); target.setReturnCanteenCount(value.getCount()); + }); + legacyCategory(base, List.of("5"), null, expected, (target, value) -> { + target.setRefundSum(sum(value)); target.setRefundCount(value.getCount()); + }); + assertEquals(expected, rows(base), base.toString()); + } + + private void legacyCategory(WalletTransactionSumAndCountDto base, List types, Integer direction, + Map result, + BiConsumer setter) { + WalletTransactionSumAndCountDto dto = new WalletTransactionSumAndCountDto(); + dto.setWalletTypeId(base.getWalletTypeId()); + dto.setWalletTypeIdList(base.getWalletTypeIdList()); + dto.setDeptIds(base.getDeptIds()); + dto.setIsInclude(base.getIsInclude()); + dto.setCreateTimeList(base.getCreateTimeList()); + dto.setBizTypes(types); + dto.setType(direction); + for (WalletTransactionSumAndCountVo value : mapper.sumAndCount(dto)) { + WalletDepartmentStatisticsVo target = result.computeIfAbsent(value.getDeptId(), key -> { + WalletDepartmentStatisticsVo row = new WalletDepartmentStatisticsVo(); + row.setDeptId(key); + return row; + }); + setter.accept(target, value); + } + } + + private long sum(WalletTransactionSumAndCountVo value) { + return value.getSum() == null ? 0L : value.getSum(); + } + + private void execute(String sql) throws Exception { + try (var statement = connection.createStatement()) { + statement.execute(sql); + } + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/transaction/WalletTerminalConcurrentDebitTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/transaction/WalletTerminalConcurrentDebitTest.java new file mode 100644 index 000000000..625b3f75d --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/transaction/WalletTerminalConcurrentDebitTest.java @@ -0,0 +1,130 @@ +package com.bsd.pjcloud.wallet.transaction; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder; +import com.bsd.pjcloud.wallet.api.constant.WalletBizEnum; +import com.bsd.pjcloud.wallet.api.entity.WalletTransactionEntity; +import com.bsd.pjcloud.wallet.mapper.WalletManageMapper; +import com.bsd.pjcloud.wallet.mapper.WalletTransactionMapper; +import com.bsd.pjcloud.wallet.service.impl.WalletTransactionServiceImpl; +import org.apache.ibatis.mapping.Environment; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.mybatis.spring.transaction.SpringManagedTransactionFactory; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; + +import java.util.List; +import java.util.UUID; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** 使用实际 MyBatis-Plus 钱包读写 SQL 和 Spring 事务验证并发扣款,H2 仅作为隔离测试数据库。 */ +class WalletTerminalConcurrentDebitTest { + private WalletTransactionServiceImpl service; + private WalletTransactionMapper transactions; + private JdbcTemplate database; + + @BeforeEach + void setup() { + var source = new DriverManagerDataSource("jdbc:h2:mem:debit_" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000", "sa", ""); + database = new JdbcTemplate(source); + database.execute(""" + create table wallet_manage(id bigint primary key,user_id bigint,wallet_type_id bigint,dept_id bigint, + card_number varchar(64),balance int,total_expense int,total_recharge int,freeze_price int, + is_freeze varchar(8),active_bind_time timestamp,status varchar(8),version int, + create_by varchar(64),create_time timestamp,update_by varchar(64),update_time timestamp, + del_flag varchar(1),tenant_id bigint) + """); + database.execute("create table debit_ledger(id bigint generated by default as identity primary key,price int,balance int)"); + database.update("insert into wallet_manage(id,user_id,balance,total_expense,version,del_flag) values(1,7,500,0,0,'0')"); + MybatisConfiguration config = new MybatisConfiguration(); + config.setMapUnderscoreToCamelCase(true); + config.setEnvironment(new Environment("terminal-debit", new SpringManagedTransactionFactory(), source)); + config.addMapper(WalletManageMapper.class); + SqlSessionTemplate sessions = new SqlSessionTemplate(new MybatisSqlSessionFactoryBuilder().build(config)); + WalletManageMapper wallets = sessions.getMapper(WalletManageMapper.class); + WalletTransactionServiceImpl target = mock(WalletTransactionServiceImpl.class, CALLS_REAL_METHODS); + transactions = mock(WalletTransactionMapper.class); + ReflectionTestUtils.setField(target, "walletManageMapper", wallets); + ReflectionTestUtils.setField(target, "baseMapper", transactions); + when(transactions.insert(any(WalletTransactionEntity.class))).thenAnswer(call -> { + WalletTransactionEntity transaction = call.getArgument(0); + return database.update("insert into debit_ledger(price,balance) values(?,?)", transaction.getPrice(), transaction.getBalance()); + }); + ProxyFactory proxy = new ProxyFactory(target); + proxy.setProxyTargetClass(true); + proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(source), new AnnotationTransactionAttributeSource())); + service = (WalletTransactionServiceImpl) proxy.getProxy(); + } + + @Test + void twoTerminalsCannotOverdrawTheSameWallet() throws Exception { + List results = concurrentDebits(350, false); + assertEquals(1, results.stream().filter(Boolean::booleanValue).count()); + assertEquals(150, balance()); + assertEquals(List.of(150), database.queryForList("select balance from debit_ledger", Integer.class)); + } + + @Test + void concurrentSuccessfulDebitsRecordSequentialBalances() throws Exception { + database.update("update wallet_manage set balance=1000 where id=1"); + assertEquals(List.of(true, true), concurrentDebits(300, false)); + assertEquals(400, balance()); + assertEquals(List.of(700, 400), database.queryForList("select balance from debit_ledger order by id", Integer.class)); + } + + @Test + void ledgerFailureRollsBackTheBalanceUpdate() { + doThrow(new RuntimeException("流水写入失败")).when(transactions).insert(any(WalletTransactionEntity.class)); + assertThrows(RuntimeException.class, () -> service.reduceWalletBalance(1L, 350, "order-1", WalletBizEnum.PAYMENT, false)); + assertEquals(500, balance()); + assertEquals(0, database.queryForObject("select count(*) from debit_ledger", Integer.class)); + } + + @Test + void legacyForcedOfflineSettlementStillAllowsDebt() { + service.reduceWalletBalance(1L, 700, "offline-1", WalletBizEnum.PAYMENT, true); + assertEquals(-200, balance()); + } + + private int balance() { + return database.queryForObject("select balance from wallet_manage where id=1", Integer.class); + } + + /** 同时启动两个终端扣款请求,不依赖固定休眠安排线程顺序。 */ + private List concurrentDebits(int price, boolean force) throws Exception { + CountDownLatch start = new CountDownLatch(1); + var executor = Executors.newFixedThreadPool(2); + Callable debit = () -> { + assertTrue(start.await(5, TimeUnit.SECONDS)); + try { + service.reduceWalletBalance(1L, price, UUID.randomUUID().toString(), WalletBizEnum.PAYMENT, force); + return true; + } catch (RuntimeException failure) { + assertEquals("钱包余额不足", failure.getMessage()); + return false; + } + }; + try { + var first = executor.submit(debit); + var second = executor.submit(debit); + start.countDown(); + return List.of(first.get(10, TimeUnit.SECONDS), second.get(10, TimeUnit.SECONDS)); + } finally { + executor.shutdownNow(); + } + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/resources/mapper/rule/WalletRuleTransactionLegacyMapper.xml b/pjcloud-wallet/pjcloud-wallet-biz/src/test/resources/mapper/rule/WalletRuleTransactionLegacyMapper.xml new file mode 100644 index 000000000..296a1da7a --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/resources/mapper/rule/WalletRuleTransactionLegacyMapper.xml @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + select a.id, + a.biz_type, + a.source_wallet_id, + a.target_wallet_id, + a.source_wallet_balance, + a.target_wallet_balance, + a.balance, + a.after_balance, + a.balance_multiple, + a.is_refund, + a.refund_time, + a.create_by, + a.create_time, + a.update_by, + a.update_time, + a.del_flag, + a.tenant_id, + + + + a.del_flag = 0 + + and a.biz_type = #{dto.bizType} + + + and a.source_wallet_id = #{dto.sourceWalletId} + + + and a.target_wallet_id = #{dto.targetWalletId} + + + and a.is_refund = #{dto.isRefund} + + + and a.create_time between #{dto.createTimeList[0]} and #{dto.createTimeList[1]} + + + and a.refund_time between #{dto.refundTimeList[0]} and #{dto.refundTimeList[1]} + + + + + \ No newline at end of file