From af755b390e1265d8ea7f395688e6e9f9ba005824 Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Fri, 6 Nov 2026 14:40:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E4=BD=99=E9=A2=9D=E5=8C=BA=E9=97=B4?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/dto/WalletManageBalanceDto.java | 31 ++ .../wallet/api/vo/WalletQbUserTotalVo.java | 20 ++ .../pjcloud/wallet/api/vo/WalletQbUserVo.java | 41 +++ .../controller/WalletManageController.java | 28 ++ .../wallet/mapper/WalletQbUserMapper.java | 34 ++ .../wallet/mapper/WalletQbUserQueryScope.java | 25 ++ .../wallet/service/WalletQbUserService.java | 188 ++++++++++ .../export/WalletQbUserExportService.java | 262 ++++++++++++++ .../resources/mapper/WalletQbUserMapper.xml | 125 +++++++ .../WalletInactiveControllerTest.java | 4 +- .../export/WalletQbUserExportServiceTest.java | 206 +++++++++++ .../total/WalletQbUserBalanceSqlTest.java | 331 ++++++++++++++++++ .../total/WalletQbUserControllerTest.java | 91 +++++ .../wallet/total/WalletQbUserServiceTest.java | 184 ++++++++++ 14 files changed, 1569 insertions(+), 1 deletion(-) create mode 100644 pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/WalletQbUserTotalVo.java create mode 100644 pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/WalletQbUserVo.java create mode 100644 pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletQbUserMapper.java create mode 100644 pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletQbUserQueryScope.java create mode 100644 pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletQbUserService.java create mode 100644 pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/export/WalletQbUserExportService.java create mode 100644 pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/WalletQbUserMapper.xml create mode 100644 pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/export/WalletQbUserExportServiceTest.java create mode 100644 pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/total/WalletQbUserBalanceSqlTest.java create mode 100644 pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/total/WalletQbUserControllerTest.java create mode 100644 pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/total/WalletQbUserServiceTest.java diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletManageBalanceDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletManageBalanceDto.java index 4238c049c..e21fb21bc 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletManageBalanceDto.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletManageBalanceDto.java @@ -2,6 +2,12 @@ package com.bsd.pjcloud.wallet.api.dto; import com.bsd.pjcloud.admin.api.vo.base.BaseIsIncludeTreeInterface; import lombok.Data; +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 java.io.IOException; import java.util.List; @@ -22,4 +28,29 @@ public class WalletManageBalanceDto implements BaseIsIncludeTreeInterface { private List deptIds; + /** 用户汇总余额下限,单位分;包含边界,空值表示不限,允许负数。 */ + @JsonDeserialize(using = CentsDeserializer.class) + private Long minBalance; + + /** 用户汇总余额上限,单位分;包含边界,空值表示不限,允许负数。 */ + @JsonDeserialize(using = CentsDeserializer.class) + private Long maxBalance; + + /** 金额区间按整数分传输,禁止将小数分静默截断;兼容精确的整数字符串。 */ + public static class CentsDeserializer 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]+")) { + try { + return Long.valueOf(parser.getText()); + } catch (NumberFormatException exception) { + return (Long) context.handleWeirdStringValue(Long.class, 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/vo/WalletQbUserTotalVo.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/WalletQbUserTotalVo.java new file mode 100644 index 000000000..387672ac3 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/WalletQbUserTotalVo.java @@ -0,0 +1,20 @@ +package com.bsd.pjcloud.wallet.api.vo; + +import lombok.Data; + +/** 当前月份、钱包筛选、权限及用户合计余额区间内的全部用户汇总。 */ +@Data +public class WalletQbUserTotalVo { + /** 三类钱包总余额,单位分。 */ + private Long total = 0L; + /** 饭卡钱包总余额,单位分。 */ + private Long eatTotal = 0L; + /** 消费钱包总余额,单位分。 */ + private Long payTotal = 0L; + /** 充值钱包总余额,单位分。 */ + private Long topupTotal = 0L; + /** 匹配区间的用户数。 */ + private Long userCount = 0L; + /** 匹配用户在当前筛选与权限范围内的实体钱包记录数。 */ + private Long walletCount = 0L; +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/WalletQbUserVo.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/WalletQbUserVo.java new file mode 100644 index 000000000..d39a2b80c --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/WalletQbUserVo.java @@ -0,0 +1,41 @@ +package com.bsd.pjcloud.wallet.api.vo; + +import com.bsd.pjcloud.admin.api.vo.base.BaseUserInterface; +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** 用户月末钱包汇总;金额只计当前筛选和权限范围内的类型,同用户同类型不重复记账。 */ +@Data +public class WalletQbUserVo implements BaseUserInterface { + /** 钱包所属用户标识。 */ + private Long userId; + /** 当前页远程补充的用户姓名。 */ + private String username; + /** 当前页远程补充的用户手机号。 */ + private String userMobile; + /** 当前页远程补充的头像。 */ + private String userAvatar; + /** 当前筛选钱包所在部门;仅为当前页查询并去重。 */ + private List deptIds = new ArrayList<>(); + /** 当前筛选钱包部门名称,多个部门以顿号连接。 */ + private String deptName; + /** 三类钱包余额合计,单位分,允许负数。 */ + private Long balance = 0L; + /** 饭卡钱包月末余额,单位分。 */ + private Long eatBalance = 0L; + /** 消费钱包月末余额,单位分。 */ + private Long payBalance = 0L; + /** 充值钱包月末余额,单位分。 */ + private Long topupBalance = 0L; + /** 当前筛选匹配的实体钱包记录数,历史重复卡也计数,但不重复加余额。 */ + private Long walletCount = 0L; + /** 本次参与余额计算的最新有效流水时间;无有效金额时为空。 */ + private LocalDateTime lastTransactionTime; + /** 区间查询由窗口计数返回的匹配用户数,仅供分页服务使用。 */ + @JsonIgnore + private Long matchedUserCount; +} 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 230862b53..704e6762c 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,10 +21,14 @@ 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.WalletQbUserVo; +import com.bsd.pjcloud.wallet.api.vo.WalletQbUserTotalVo; 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.WalletQbUserService; +import com.bsd.pjcloud.wallet.service.export.WalletQbUserExportService; import com.bsd.pjcloud.wallet.service.export.WalletManageBatchExportService; import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; @@ -55,6 +59,10 @@ public class WalletManageController { private final WalletManageMapper walletManageMapper; private final WalletManageBatchExportService batchExportService; + /** 用户级月末余额分页和全量统计。 */ + private final WalletQbUserService qbUserService; + /** 与用户汇总页相同筛选口径的异步导出。 */ + private final WalletQbUserExportService qbUserExportService; /* ************************************* start android start ************************************* */ @@ -274,6 +282,26 @@ public class WalletManageController { return R.ok(walletManageService.scopeQbPage(page, dto, dataScope)); } + /** 每用户一行;余额区间针对当前筛选类型的合计余额,金额单位分。 */ + @Operation(summary = "用户钱包余额分页") + @PostMapping("/page-qb-users") + public R> pageQbUsers(@ParameterObject Page page, @RequestBody WalletManageBalanceDto dto) { + return R.ok(qbUserService.page(page, dto, DataScope.of())); + } + + /** 统计当前筛选和用户余额区间内的全部用户,不受当前分页影响。 */ + @Operation(summary = "用户钱包余额统计") + @PostMapping("/qb-users-total") + public R qbUsersTotal(@RequestBody WalletManageBalanceDto dto) { + return R.ok(qbUserService.total(dto, DataScope.of())); + } + + /** 与原钱包记录导出相同权限及任务进度体系,生成用户汇总表。 */ + @PostMapping("/export-qb-users-async") + public R exportQbUsersAsync(@RequestBody WalletManageBalanceDto dto) { + return R.ok(qbUserExportService.submit(dto), "导出任务已提交"); + } + @Operation(summary = "不活跃用户统计", description = "不活跃用户统计") @PostMapping("/page-join-active") public R pageJoinActive( diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletQbUserMapper.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletQbUserMapper.java new file mode 100644 index 000000000..4e31f2e0a --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletQbUserMapper.java @@ -0,0 +1,34 @@ +package com.bsd.pjcloud.wallet.mapper; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.wallet.api.dto.WalletManageBalanceDto; +import com.bsd.pjcloud.wallet.api.vo.WalletManageVo; +import com.bsd.pjcloud.wallet.api.vo.WalletQbUserVo; +import com.bsd.pjcloud.wallet.api.vo.WalletQbUserTotalVo; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.time.LocalDateTime; +import java.util.List; + +/** 用户余额报表查询;所有条件在数据库内应用,不把全量用户或钱包搬到服务内存。 */ +@Mapper +public interface WalletQbUserMapper { + /** 默认列表先分页有权限的钱包用户,不读取历史流水。 */ + Page selectUserIdPage(Page page, @Param("dto") WalletManageBalanceDto dto, @Param("scope") WalletQbUserQueryScope scope); + + /** 对已缩小到当前页用户的筛选计算月末余额,返回每用户一行。 */ + List selectUsers(@Param("dto") WalletManageBalanceDto dto, @Param("monthEndExclusive") LocalDateTime monthEndExclusive, @Param("scope") WalletQbUserQueryScope scope); + + /** 全量用户余额排序或区间分页;countMatches 为真时在同次查询返回匹配用户总数。 */ + Page selectUserPage(Page page, @Param("dto") WalletManageBalanceDto dto, @Param("monthEndExclusive") LocalDateTime monthEndExclusive, @Param("scope") WalletQbUserQueryScope scope, @Param("countMatches") boolean countMatches); + + /** 无金额区间只数权限钱包的不同用户,有区间时按用户合计余额计数。 */ + Long countUsers(@Param("dto") WalletManageBalanceDto dto, @Param("monthEndExclusive") LocalDateTime monthEndExclusive, @Param("scope") WalletQbUserQueryScope scope); + + /** 一次汇总同筛选和余额区间下的三类金额、用户数和实体钱包数。 */ + WalletQbUserTotalVo selectUserTotal(@Param("dto") WalletManageBalanceDto dto, @Param("monthEndExclusive") LocalDateTime monthEndExclusive, @Param("scope") WalletQbUserQueryScope scope); + + /** 仅查询当前页用户符合筛选的钱包部门,不读取流水或其他用户资料。 */ + List selectUserDepartments(@Param("dto") WalletManageBalanceDto dto, @Param("scope") WalletQbUserQueryScope scope); +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletQbUserQueryScope.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletQbUserQueryScope.java new file mode 100644 index 000000000..8bfc52f62 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletQbUserQueryScope.java @@ -0,0 +1,25 @@ +package com.bsd.pjcloud.wallet.mapper; + +import com.bsd.pjcloud.common.data.datascope.DataScope; +import lombok.Value; + +import java.util.List; + +/** 服务端一次解析的权限快照,在用户聚合之前限制钱包;租户仍由统一租户拦截器处理。 */ +@Value +public class WalletQbUserQueryScope { + /** 是否具有全部钱包数据权限。 */ + boolean unrestricted; + /** 本人权限的创建人账号,空值表示无本人范围。 */ + String username; + /** 当前操作者允许访问的部门标识。 */ + List deptIds; + + /** 固定本次查询权限,避免分页、计数和统计重复解析角色或修改调用方的数据。 */ + public static WalletQbUserQueryScope of(boolean unrestricted, DataScope scope) { + String username = scope == null ? null : scope.getUsername(); + if (username != null && username.isBlank()) username = null; + List ids = scope == null || scope.getDeptList() == null ? List.of() : List.copyOf(scope.getDeptList()); + return new WalletQbUserQueryScope(unrestricted, username, ids); + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletQbUserService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletQbUserService.java new file mode 100644 index 000000000..16679f2b2 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletQbUserService.java @@ -0,0 +1,188 @@ +package com.bsd.pjcloud.wallet.service; + +import com.baomidou.mybatisplus.core.metadata.OrderItem; +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.BeanUtils; +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.wallet.api.dto.WalletManageBalanceDto; +import com.bsd.pjcloud.wallet.api.vo.WalletManageVo; +import com.bsd.pjcloud.wallet.api.vo.WalletQbUserVo; +import com.bsd.pjcloud.wallet.api.vo.WalletQbUserTotalVo; +import com.bsd.pjcloud.wallet.mapper.WalletQbUserMapper; +import com.bsd.pjcloud.wallet.mapper.WalletQbUserQueryScope; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.DateTimeException; +import java.time.LocalDateTime; +import java.time.YearMonth; +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** 用户级月末余额报表;旧逐钱包接口保持兼容,新查询只补全当前页资料。 */ +@Service +@RequiredArgsConstructor +public class WalletQbUserService { + /** 与全局分页上限一致,防止负页大小或超大请求变为全量读取。 */ + private static final long MAX_PAGE_SIZE = 1000L; + /** 仅允许服务端已定义的金额列参与全量排序。 */ + private static final Set BALANCE_COLUMNS = Set.of("balance", "eat_balance", "pay_balance", "topup_balance"); + /** 用户聚合及范围统计的数据库访问入口。 */ + private final WalletQbUserMapper mapper; + /** 复用统一角色数据权限解析。 */ + private final RemoteDataScopeService dataScopeService; + /** 仅批量读取当前页用户资料。 */ + private final RemoteUserService userService; + /** 仅批量读取当前页钱包部门名称。 */ + private final RemoteDeptService deptService; + + /** + * 用户分页:默认先分页用户再算当页余额;金额排序或区间查询才计算全量候选。 + * 有区间时由同次查询的窗口计数返回总数,避免正常分页重复计算全部流水。 + */ + public Page page(Page page, WalletManageBalanceDto input, DataScope dataScope) { + WalletManageBalanceDto dto = snapshot(input); + LocalDateTime monthEnd = monthEnd(dto.getWalletRecordMonth()); + if (page.getCurrent() < 1 || page.getSize() <= 0) throw new IllegalArgumentException("请使用有效页码和正数分页大小"); + page.setSize(Math.min(page.getSize(), MAX_PAGE_SIZE)); + WalletQbUserQueryScope scope = resolveScope(dataScope); + Optional requested = page.orders().stream().filter(order -> order != null && order.getColumn() != null && BALANCE_COLUMNS.contains(order.getColumn())).findFirst(); + boolean range = dto.getMinBalance() != null || dto.getMaxBalance() != null; + if (!range && requested.isEmpty()) { + Page ids = new Page<>(page.getCurrent(), page.getSize(), page.searchCount()); + ids.setMaxLimit(MAX_PAGE_SIZE); + ids = mapper.selectUserIdPage(ids, dto, scope); + page.setTotal(ids.getTotal()); + if (ids.getRecords().isEmpty()) return page.setRecords(Collections.emptyList()); + WalletManageBalanceDto currentUsers = forUsers(dto, ids.getRecords()); + Map rows = mapper.selectUsers(currentUsers, monthEnd, scope).stream() + .collect(Collectors.toMap(WalletQbUserVo::getUserId, Function.identity())); + page.setRecords(ids.getRecords().stream().map(rows::get).filter(Objects::nonNull).toList()); + } else { + OrderItem first = requested.orElseGet(() -> OrderItem.desc("user_id")); + List orders = new ArrayList<>(); + orders.add(first.isAsc() ? OrderItem.asc(first.getColumn()) : OrderItem.desc(first.getColumn())); + if (!"user_id".equals(first.getColumn())) orders.add(OrderItem.desc("user_id")); + page.setOrders(orders); + boolean searchCount = page.searchCount(); + if (searchCount && !range) { + page.setTotal(mapper.countUsers(dto, monthEnd, scope)); + if (page.getTotal() == 0) return page.setRecords(Collections.emptyList()); + } + try { + page.setSearchCount(false); + page = mapper.selectUserPage(page, dto, monthEnd, scope, searchCount && range); + if (searchCount && range) { + if (!page.getRecords().isEmpty()) { + page.setTotal(page.getRecords().get(0).getMatchedUserCount()); + } else { + // 超过末页时窗口没有行可带回总数,只在此边界补一次计数。 + page.setTotal(page.getCurrent() == 1 ? 0 : mapper.countUsers(dto, monthEnd, scope)); + } + } + } finally { + page.setSearchCount(searchCount); + } + } + fillPageProfiles(page.getRecords(), dto, scope); + return page; + } + + /** 显式统计当前筛选和用户余额区间内的全部用户,三类金额与数量一次聚合。 */ + public WalletQbUserTotalVo total(WalletManageBalanceDto input, DataScope dataScope) { + WalletManageBalanceDto dto = snapshot(input); + LocalDateTime monthEnd = monthEnd(dto.getWalletRecordMonth()); + WalletQbUserTotalVo result = mapper.selectUserTotal(dto, monthEnd, resolveScope(dataScope)); + return result == null ? new WalletQbUserTotalVo() : result; + } + + /** 冻结筛选数组并校验金额上下限,避免后续分页或异步任务修改调用方条件。 */ + private WalletManageBalanceDto snapshot(WalletManageBalanceDto input) { + if (input == null) throw new IllegalArgumentException("请提供钱包查询条件"); + if (input.getMinBalance() != null && input.getMaxBalance() != null && input.getMinBalance() > input.getMaxBalance()) { + throw new IllegalArgumentException("最低余额不能大于最高余额"); + } + WalletManageBalanceDto dto = BeanUtils.toBean(input, WalletManageBalanceDto.class); + if (input.getUserIds() != null) dto.setUserIds(new ArrayList<>(input.getUserIds())); + if (input.getWalletTypeIdList() != null) dto.setWalletTypeIdList(new ArrayList<>(input.getWalletTypeIdList())); + if (input.getDeptIds() != null) dto.setDeptIds(new ArrayList<>(input.getDeptIds())); + return dto; + } + + /** 权限按项目既有复杂查询模式解析一次,再在 SQL 用户聚合之前应用。 */ + private WalletQbUserQueryScope resolveScope(DataScope input) { + DataScope scope = new DataScope(); + if (input != null) { + scope.setUsername(input.getUsername()); + scope.setIsOnly(input.getIsOnly()); + scope.setScopeDeptName(input.getScopeDeptName()); + scope.setScopeUserName(input.getScopeUserName()); + if (input.getDeptList() != null) scope.setDeptList(new ArrayList<>(input.getDeptList())); + } + boolean unrestricted = new PjcloudDefaultDataScopeHandle(dataScopeService).calcScope(scope); + return WalletQbUserQueryScope.of(unrestricted, scope); + } + + /** 严格月份转下月零点,以时间列范围条件复用现有历史流水索引。 */ + private LocalDateTime monthEnd(String month) { + if (month == null || !month.matches("[0-9]{4}-[0-9]{2}")) throw new IllegalArgumentException("请选择有效月份(YYYY-MM)"); + try { + YearMonth value = YearMonth.parse(month); + YearMonth next = value.plusMonths(1); + if (value.getYear() < 1000 || next.getYear() > 9999) throw new IllegalArgumentException("统计月份超出支持范围"); + return next.atDay(1).atStartOfDay(); + } catch (DateTimeException exception) { + throw new IllegalArgumentException("请选择有效月份(YYYY-MM)", exception); + } + } + + /** 当前页用户已通过全部筛选,收窄历史与部门查询范围且保留其他条件。 */ + private WalletManageBalanceDto forUsers(WalletManageBalanceDto dto, List userIds) { + WalletManageBalanceDto result = snapshot(dto); + result.setUserIds(new ArrayList<>(userIds)); + return result; + } + + /** 用户及部门远程服务只接收当前页标识,多部门名称按钱包筛选结果合并。 */ + private void fillPageProfiles(List rows, WalletManageBalanceDto dto, WalletQbUserQueryScope scope) { + if (rows.isEmpty()) return; + List ids = rows.stream().map(WalletQbUserVo::getUserId).toList(); + R> users = userService.getInnerUserById(ids); + 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)); + Map> userDepartments = new HashMap<>(); + for (WalletManageVo wallet : mapper.selectUserDepartments(forUsers(dto, ids), scope)) { + if (wallet.getDeptId() != null) userDepartments.computeIfAbsent(wallet.getUserId(), ignored -> new LinkedHashSet<>()).add(wallet.getDeptId()); + } + List deptIds = userDepartments.values().stream().flatMap(Collection::stream).distinct().toList(); + Map departmentMap = Map.of(); + if (!deptIds.isEmpty()) { + R> response = deptService.getDeptByDeptIds(deptIds); + if (response == null || !response.isOk() || response.getData() == null) throw new IllegalStateException("读取钱包部门信息失败,请重试"); + departmentMap = response.getData(); + } + for (WalletQbUserVo row : rows) { + SysUser user = userMap.get(row.getUserId()); + if (user != null) { + row.setUsername(user.getName()); + row.setUserMobile(user.getPhone()); + row.setUserAvatar(user.getAvatar()); + } + row.setDeptIds(new ArrayList<>(userDepartments.getOrDefault(row.getUserId(), new LinkedHashSet<>()))); + List names = new ArrayList<>(); + for (Long id : row.getDeptIds()) { + SysDept dept = departmentMap.get(id); + names.add(dept == null ? "未知部门" : dept.getName()); + } + row.setDeptName(names.stream().filter(Objects::nonNull).distinct().collect(Collectors.joining("、"))); + } + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/export/WalletQbUserExportService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/export/WalletQbUserExportService.java new file mode 100644 index 000000000..4fa1a96b1 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/export/WalletQbUserExportService.java @@ -0,0 +1,262 @@ +package com.bsd.pjcloud.wallet.service.export; + +import cn.hutool.core.bean.BeanUtil; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.admin.api.feign.RemoteFileService; +import com.bsd.pjcloud.common.asynctask.service.AsyncTaskException; +import com.bsd.pjcloud.common.asynctask.service.AsyncTaskExecutorService; +import com.bsd.pjcloud.common.asynctask.service.AsyncTaskProgressReporter; +import com.bsd.pjcloud.common.asynctask.service.AsyncTaskResult; +import com.bsd.pjcloud.common.asynctask.service.BatchExportSupport; +import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.data.datascope.DataScope; +import com.bsd.pjcloud.common.excel.stream.BatchExcelExportTemplate; +import com.bsd.pjcloud.common.excel.stream.ExportMultipartFile; +import com.bsd.pjcloud.wallet.api.dto.WalletManageBalanceDto; +import com.bsd.pjcloud.wallet.api.vo.WalletQbUserVo; +import com.bsd.pjcloud.wallet.service.WalletQbUserService; +import lombok.RequiredArgsConstructor; +import org.apache.poi.ss.usermodel.BorderStyle; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.CellStyle; +import org.apache.poi.ss.usermodel.FillPatternType; +import org.apache.poi.ss.usermodel.Font; +import org.apache.poi.ss.usermodel.HorizontalAlignment; +import org.apache.poi.ss.usermodel.IndexedColors; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.util.CellRangeAddress; +import org.apache.poi.xssf.streaming.SXSSFSheet; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.stereotype.Service; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.time.DateTimeException; +import java.time.YearMonth; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Map; +import java.util.Objects; + +/** 按用户汇总导出月末余额,复用页面筛选和区间口径,分批查询且只上传完整文件。 */ +@Service +@RequiredArgsConstructor +public class WalletQbUserExportService { + /** 与管理端进度组件共用的业务命名空间,租户前缀由基础设施处理。 */ + private static final String REDIS_KEY = "wallet:export:qb-users"; + /** 新汇总导出的附件名,不覆盖旧钱包明细导出。 */ + private static final String FILE_NAME = "用户钱包余额.xlsx"; + /** 新表每列含义,金额单位在表头中明确为元。 */ + private static final String[] HEADERS = {"序号", "用户", "手机号", "部门", "饭卡钱包(元)", "消费钱包(元)", "充值钱包(元)", "合计余额(元)", "钱包数", "最近流水"}; + /** 标题、筛选说明和表头共占三行。 */ + private static final int DATA_START_ROW = 3; + /** 导出流水时间使用页面同等精度,不附加服务器时区。 */ + private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + /** 提交及执行异步导出,统一管理当前用户、租户上下文和进度。 */ + private final AsyncTaskExecutorService tasks; + /** 页面与导出共用的用户级余额查询。 */ + private final WalletQbUserService users; + /** 完整 Excel 交由现有文件服务保存。 */ + private final RemoteFileService files; + /** 长事务逐批释放 MyBatis 一级缓存,避免保留全部历史分页对象。 */ + private final SqlSessionTemplate sqlSession; + + /** 冻结本次筛选后立即提交异步任务,金额区间按分校验,首批才计算用户总数。 */ + public AsyncTaskProgressVO submit(WalletManageBalanceDto dto) { + WalletManageBalanceDto query = snapshot(dto); + return tasks.submitWithResult(REDIS_KEY, "用户钱包余额导出", 1, reporter -> generate(query, reporter)); + } + + /** 使用同一份筛选快照分批生成新表,数据变化导致数量不符时不上传半成品。 */ + private AsyncTaskResult generate(WalletManageBalanceDto query, AsyncTaskProgressReporter reporter) throws Exception { + Page first = fetchPage(1L, query); + long total = first.getTotal(); + if (total == 0) throw new AsyncTaskException("当前筛选条件没有可导出的数据"); + reporter.updateTotal(BatchExcelExportTemplate.calculateProgressTotal(total, BatchExportSupport.BATCH_SIZE, 2), + "共 " + total + " 位用户,正在分批生成 Excel"); + try { + return BatchExcelExportTemplate.builder(total) + .batchSize(BatchExportSupport.BATCH_SIZE) + .dataStartRowIndex(DATA_START_ROW) + .reservedTailRowCount(1) + .temporaryFilePrefix("wallet-qb-users-") + .templateSupplier(this::emptyWorkbook) + .templateInitializer(workbook -> initialize(workbook, query)) + .pageFetcher((current, size) -> current == 1 ? first.getRecords() : fetchPage(current, query).getRecords()) + .rowWriter(this::writeRow) + .footerWriter(this::writeFooter) + .progressListener((completed, expected) -> reporter.advance("已写入 " + completed + " / " + expected + " 位用户")) + .fileConsumer(file -> { + reporter.advance("文件已生成,正在保存导出结果"); + return upload(file); + }) + .execute(); + } catch (IllegalArgumentException exception) { + if (exception.getMessage() != null && exception.getMessage().startsWith("导出明细数超过")) { + throw new AsyncTaskException(exception.getMessage() + ",请缩小筛选范围"); + } + throw exception; + } catch (IllegalStateException exception) { + if (exception.getMessage() != null && exception.getMessage().startsWith("导出数据在执行期间发生变化")) { + throw new AsyncTaskException("导出期间数据发生变化,请重试"); + } + throw exception; + } + } + + /** 默认用户 ID 倒序由查询服务维护;后续批次关闭 COUNT,并在查询前清理会话缓存。 */ + private Page fetchPage(long current, WalletManageBalanceDto query) { + sqlSession.clearCache(); + return users.page(BatchExportSupport.page(current), query, DataScope.of()); + } + + /** 仅创建空白表作为流式模板输入,不读取或改写旧钱包明细模板。 */ + private InputStream emptyWorkbook() throws Exception { + try (XSSFWorkbook workbook = new XSSFWorkbook(); ByteArrayOutputStream output = new ByteArrayOutputStream()) { + workbook.createSheet("用户钱包余额"); + workbook.write(output); + return new ByteArrayInputStream(output.toByteArray()); + } + } + + /** 建立标题、范围说明及可复用样式,样式数与导出行数无关。 */ + private ExportContext initialize(XSSFWorkbook workbook, WalletManageBalanceDto query) { + Sheet sheet = workbook.getSheetAt(0); + sheet.createFreezePane(4, DATA_START_ROW); + int[] widths = {8, 18, 18, 28, 20, 20, 20, 20, 12, 24}; + for (int column = 0; column < widths.length; column++) sheet.setColumnWidth(column, widths[column] * 256); + Font bold = workbook.createFont(); + bold.setBold(true); + CellStyle titleStyle = workbook.createCellStyle(); + titleStyle.setFont(bold); + titleStyle.setAlignment(HorizontalAlignment.CENTER); + CellStyle headerStyle = workbook.createCellStyle(); + headerStyle.cloneStyleFrom(titleStyle); + headerStyle.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex()); + headerStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND); + headerStyle.setBorderBottom(BorderStyle.THIN); + CellStyle amountStyle = workbook.createCellStyle(); + amountStyle.setAlignment(HorizontalAlignment.RIGHT); + CellStyle totalStyle = workbook.createCellStyle(); + totalStyle.cloneStyleFrom(amountStyle); + totalStyle.setFont(bold); + totalStyle.setBorderTop(BorderStyle.THIN); + Row title = sheet.createRow(0); + title.setHeightInPoints(26); + Cell titleCell = title.createCell(0); + titleCell.setCellValue(query.getWalletRecordMonth() + " 用户钱包月末余额汇总"); + titleCell.setCellStyle(titleStyle); + sheet.addMergedRegion(new CellRangeAddress(0, 0, 0, HEADERS.length - 1)); + Row filter = sheet.createRow(1); + filter.createCell(0).setCellValue("用户合计余额范围(元,含边界):" + + (query.getMinBalance() == null ? "不限" : money(BigDecimal.valueOf(query.getMinBalance()))) + + " 至 " + (query.getMaxBalance() == null ? "不限" : money(BigDecimal.valueOf(query.getMaxBalance()))) + + ";同用户同类型余额只计一次,钱包数按筛选后的实际钱包记录统计。"); + sheet.addMergedRegion(new CellRangeAddress(1, 1, 0, HEADERS.length - 1)); + Row header = sheet.createRow(2); + for (int column = 0; column < HEADERS.length; column++) { + Cell cell = header.createCell(column); + cell.setCellValue(HEADERS[column]); + cell.setCellStyle(headerStyle); + } + return new ExportContext(amountStyle, totalStyle); + } + + /** 用户每行仅写一次;四个金额以精确两位小数字符串保存,避免 Long 金额转换为浮点数。 */ + private void writeRow(SXSSFSheet sheet, int rowIndex, long dataIndex, WalletQbUserVo item, ExportContext context) { + Row row = sheet.createRow(rowIndex); + row.createCell(0).setCellValue(dataIndex + 1); + row.createCell(1).setCellValue(Objects.toString(item.getUsername(), "")); + row.createCell(2).setCellValue(Objects.toString(item.getUserMobile(), "")); + row.createCell(3).setCellValue(Objects.toString(item.getDeptName(), "")); + Long[] amounts = {item.getEatBalance(), item.getPayBalance(), item.getTopupBalance(), item.getBalance()}; + for (int index = 0; index < amounts.length; index++) { + BigDecimal cents = BigDecimal.valueOf(amounts[index] == null ? 0L : amounts[index]); + Cell cell = row.createCell(index + 4); + cell.setCellValue(money(cents)); + cell.setCellStyle(context.amountStyle); + context.totals[index] = context.totals[index].add(cents); + } + long walletCount = item.getWalletCount() == null ? 0L : item.getWalletCount(); + row.createCell(8).setCellValue(walletCount); + context.walletCount = Math.addExact(context.walletCount, walletCount); + row.createCell(9).setCellValue(item.getLastTransactionTime() == null ? "" : TIME_FORMAT.format(item.getLastTransactionTime())); + } + + /** 尾部合计只累计本次区间和筛选实际导出的用户,不另行请求不同口径的统计接口。 */ + private void writeFooter(SXSSFSheet sheet, int rowIndex, long processed, ExportContext context) { + Row row = sheet.createRow(rowIndex); + row.createCell(0).setCellValue("合计"); + row.createCell(1).setCellValue("共 " + processed + " 位用户"); + for (int index = 0; index < context.totals.length; index++) { + Cell cell = row.createCell(index + 4); + cell.setCellValue(money(context.totals[index])); + cell.setCellStyle(context.totalStyle); + } + Cell walletCount = row.createCell(8); + walletCount.setCellValue(context.walletCount); + walletCount.setCellStyle(context.totalStyle); + } + + /** 已关闭的完整文件上传成功后返回结果,模板组件负责成功与失败路径的临时文件清理。 */ + private AsyncTaskResult upload(Path file) { + String encoded = new String(FILE_NAME.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1); + R result = files.innerUpload(new ExportMultipartFile(file, encoded), "export", null, "excel"); + if (result == null || !result.isOk() || !(result.getData() instanceof Map data) + || data.get("url") == null || data.get("url").toString().isBlank()) { + throw new AsyncTaskException("导出文件保存失败,请重试"); + } + return AsyncTaskResult.file(data.get("url").toString(), FILE_NAME); + } + + /** 分转换为元,所有输入均为整数分,无四舍五入损失。 */ + private static String money(BigDecimal cents) { + return cents.movePointLeft(2).setScale(2).toPlainString(); + } + + /** 复制全部筛选及数组,提交后页面修改月份、金额区间或选中部门不会改变排队任务。 */ + private static WalletManageBalanceDto snapshot(WalletManageBalanceDto dto) { + if (dto == null) throw new AsyncTaskException("请提供钱包查询条件"); + String month = dto.getWalletRecordMonth(); + try { + if (month == null || !month.matches("[0-9]{4}-[0-9]{2}")) throw new DateTimeException("invalid month"); + YearMonth value = YearMonth.parse(month); + if (value.getYear() < 1000 || value.plusMonths(1).getYear() > 9999) throw new DateTimeException("invalid year"); + } catch (DateTimeException exception) { + throw new AsyncTaskException("请选择有效月份(YYYY-MM)"); + } + if (dto.getMinBalance() != null && dto.getMaxBalance() != null && dto.getMinBalance() > dto.getMaxBalance()) { + throw new AsyncTaskException("余额下限不能大于余额上限"); + } + WalletManageBalanceDto query = BeanUtil.toBean(dto, WalletManageBalanceDto.class); + if (dto.getUserIds() != null) query.setUserIds(new ArrayList<>(dto.getUserIds())); + if (dto.getWalletTypeIdList() != null) query.setWalletTypeIdList(new ArrayList<>(dto.getWalletTypeIdList())); + if (dto.getDeptIds() != null) query.setDeptIds(new ArrayList<>(dto.getDeptIds())); + return query; + } + + /** 单次流式写入共享状态,仅保留四个精确金额合计和固定样式,不保留历史行。 */ + private static final class ExportContext { + /** 明细金额样式,整个工作簿复用。 */ + private final CellStyle amountStyle; + /** 合计样式,四个金额和钱包数共用。 */ + private final CellStyle totalStyle; + /** 饭卡、消费、充值、合计余额的分单位精确累计值。 */ + private final BigDecimal[] totals = {BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO}; + /** 当前筛选实际钱包记录数。 */ + private long walletCount; + + /** 绑定本次工作簿的样式;实例不跨任务共享。 */ + private ExportContext(CellStyle amountStyle, CellStyle totalStyle) { + this.amountStyle = amountStyle; + this.totalStyle = totalStyle; + } + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/WalletQbUserMapper.xml b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/WalletQbUserMapper.xml new file mode 100644 index 000000000..bdd61c73e --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/WalletQbUserMapper.xml @@ -0,0 +1,125 @@ + + + + + + SELECT wallet.id, wallet.user_id, wallet.wallet_type_id, wallet.dept_id, + kind.unique_code AS wallet_type_unique_code + FROM wallet_manage wallet + LEFT JOIN wallet_type kind ON kind.id = wallet.wallet_type_id AND kind.del_flag = '0' + WHERE wallet.del_flag = '0' AND wallet.user_id IS NOT NULL + + AND wallet.user_id = #{dto.userId} + + + AND wallet.user_id IN + #{id} + + + AND wallet.wallet_type_id = #{dto.walletTypeId} + + + AND wallet.wallet_type_id IN + #{id} + + + + AND wallet.dept_id NOT IN + AND wallet.dept_id IN + + #{id} + + + AND ( + wallet.create_by = #{scope.username} OR + + + wallet.dept_id IN + #{id} + + 1 = 0 + + ) + + + + + + SELECT pair.user_id, + SUM(CASE WHEN pair.wallet_type_unique_code IN ('1', '2', '3') THEN COALESCE(history.balance, 0) ELSE 0 END) AS balance, + SUM(CASE WHEN pair.wallet_type_unique_code = '1' THEN COALESCE(history.balance, 0) ELSE 0 END) AS eat_balance, + SUM(CASE WHEN pair.wallet_type_unique_code = '2' THEN COALESCE(history.balance, 0) ELSE 0 END) AS pay_balance, + SUM(CASE WHEN pair.wallet_type_unique_code = '3' THEN COALESCE(history.balance, 0) ELSE 0 END) AS topup_balance, + SUM(pair.wallet_count) AS wallet_count, + MAX(CASE WHEN pair.wallet_type_unique_code IN ('1', '2', '3') AND history.balance IS NOT NULL THEN history.create_time END) AS last_transaction_time + FROM ( + SELECT visible.user_id, visible.wallet_type_id, visible.wallet_type_unique_code, COUNT(*) AS wallet_count + FROM () visible + GROUP BY visible.user_id, visible.wallet_type_id, visible.wallet_type_unique_code + ) pair + LEFT JOIN ( + + ) history ON history.user_id = pair.user_id AND history.wallet_type_id = pair.wallet_type_id AND history.row_num = 1 + GROUP BY pair.user_id + + + + + + users.balance >= #{dto.minBalance} + AND users.balance <= #{dto.maxBalance} + + + + + + + + + + + + + + + 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 index 98e787473..4def66bab 100644 --- 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 @@ -9,6 +9,8 @@ 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.WalletQbUserService; +import com.bsd.pjcloud.wallet.service.export.WalletQbUserExportService; import com.bsd.pjcloud.wallet.service.export.WalletManageBatchExportService; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; @@ -36,7 +38,7 @@ class WalletInactiveControllerTest { @BeforeEach void setup() { WalletManageController controller = new WalletManageController(service, mock(WalletCodeUseService.class), - mock(WalletManageMapper.class), mock(WalletManageBatchExportService.class)); + mock(WalletManageMapper.class), mock(WalletManageBatchExportService.class), mock(WalletQbUserService.class), mock(WalletQbUserExportService.class)); mvc = MockMvcBuilders.standaloneSetup(controller) .setMessageConverters(new MappingJackson2HttpMessageConverter(new ObjectMapper().registerModule(new PjcloudJavaTimeModule()))) .build(); diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/export/WalletQbUserExportServiceTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/export/WalletQbUserExportServiceTest.java new file mode 100644 index 000000000..3a026a12a --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/export/WalletQbUserExportServiceTest.java @@ -0,0 +1,206 @@ +package com.bsd.pjcloud.wallet.service.export; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.admin.api.feign.RemoteFileService; +import com.bsd.pjcloud.common.asynctask.service.AsyncTaskException; +import com.bsd.pjcloud.common.asynctask.service.AsyncTaskExecutorService; +import com.bsd.pjcloud.common.asynctask.service.AsyncTaskProgressReporter; +import com.bsd.pjcloud.common.asynctask.service.AsyncTaskResult; +import com.bsd.pjcloud.common.asynctask.service.AsyncTaskResultAction; +import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.wallet.api.dto.WalletManageBalanceDto; +import com.bsd.pjcloud.wallet.api.vo.WalletQbUserVo; +import com.bsd.pjcloud.wallet.service.WalletQbUserService; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.web.multipart.MultipartFile; + +import java.io.ByteArrayInputStream; +import java.nio.file.NoSuchFileException; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Arrays; +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 WalletQbUserExportServiceTest { + private final AsyncTaskExecutorService tasks = mock(AsyncTaskExecutorService.class); + private final WalletQbUserService users = mock(WalletQbUserService.class); + private final RemoteFileService files = mock(RemoteFileService.class); + private final SqlSessionTemplate sqlSession = mock(SqlSessionTemplate.class); + private final WalletQbUserExportService service = new WalletQbUserExportService(tasks, users, files, sqlSession); + + @Test + void freezesAllFiltersAndWritesOneUserPerRowWithExactCrossBatchTotals() throws Exception { + pages(201); + WalletManageBalanceDto query = query(); + query.setUserId(1L); + query.setUserIds(new ArrayList<>(List.of(1L, 2L))); + query.setWalletTypeId(11L); + query.setWalletTypeIdList(new ArrayList<>(List.of(11L, 22L))); + query.setDeptIds(new ArrayList<>(List.of(7L, 8L))); + query.setIsInclude(false); + query.setMinBalance(-200L); + query.setMaxBalance(2_000_000_000L); + AsyncTaskProgressVO progress = new AsyncTaskProgressVO(); + when(tasks.submitWithResult(anyString(), anyString(), anyInt(), any())).thenReturn(progress); + assertSame(progress, service.submit(query)); + query.setWalletRecordMonth("2020-01"); + query.setMinBalance(0L); + query.setMaxBalance(1L); + query.getUserIds().clear(); + query.getWalletTypeIdList().clear(); + query.getDeptIds().clear(); + MultipartFile[] uploaded = {null}; + when(files.innerUpload(any(), eq("export"), isNull(), eq("excel"))).thenAnswer(call -> { + uploaded[0] = call.getArgument(0); + try (Workbook workbook = open(uploaded[0].getBytes())) { + Sheet sheet = workbook.getSheetAt(0); + assertEquals(204, sheet.getLastRowNum()); + assertEquals("2026-10 用户钱包月末余额汇总", sheet.getRow(0).getCell(0).getStringCellValue()); + assertTrue(sheet.getRow(1).getCell(0).getStringCellValue().contains("-2.00 至 20000000.00")); + assertEquals("合计余额(元)", sheet.getRow(2).getCell(7).getStringCellValue()); + assertEquals(1, sheet.getRow(3).getCell(0).getNumericCellValue()); + assertEquals(201, sheet.getRow(203).getCell(0).getNumericCellValue()); + assertEquals("20000000.00", sheet.getRow(3).getCell(4).getStringCellValue()); + assertEquals("-1.00", sheet.getRow(3).getCell(5).getStringCellValue()); + assertEquals("0.00", sheet.getRow(3).getCell(6).getStringCellValue()); + assertEquals("19999999.00", sheet.getRow(3).getCell(7).getStringCellValue()); + assertEquals("2026-10-31 12:00:00", sheet.getRow(3).getCell(9).getStringCellValue()); + assertEquals("共 201 位用户", sheet.getRow(204).getCell(1).getStringCellValue()); + assertEquals("4020000000.00", sheet.getRow(204).getCell(4).getStringCellValue()); + assertEquals("-201.00", sheet.getRow(204).getCell(5).getStringCellValue()); + assertEquals("4019999799.00", sheet.getRow(204).getCell(7).getStringCellValue()); + assertEquals(603, sheet.getRow(204).getCell(8).getNumericCellValue()); + assertTrue(workbook.getNumCellStyles() < 15); + } + return R.ok(Map.of("url", "https://files.example.com/qb-users.xlsx")); + }); + AsyncTaskProgressReporter reporter = mock(AsyncTaskProgressReporter.class); + AsyncTaskResult result = action().execute(reporter); + assertEquals("用户钱包余额.xlsx", result.fileName()); + assertEquals("https://files.example.com/qb-users.xlsx", result.fileUrl()); + verify(reporter).updateTotal(eq(4), anyString()); + verify(reporter, times(3)).advance(anyString()); + ArgumentCaptor captured = ArgumentCaptor.forClass(WalletManageBalanceDto.class); + verify(users, times(3)).page(any(), captured.capture(), any()); + WalletManageBalanceDto frozen = captured.getValue(); + assertEquals("2026-10", frozen.getWalletRecordMonth()); + assertEquals(-200L, frozen.getMinBalance()); + assertEquals(2_000_000_000L, frozen.getMaxBalance()); + assertEquals(1L, frozen.getUserId()); + assertEquals(11L, frozen.getWalletTypeId()); + assertEquals(List.of(1L, 2L), frozen.getUserIds()); + assertEquals(List.of(11L, 22L), frozen.getWalletTypeIdList()); + assertEquals(List.of(7L, 8L), frozen.getDeptIds()); + assertEquals(false, frozen.getIsInclude()); + verify(sqlSession, times(3)).clearCache(); + assertThrows(NoSuchFileException.class, () -> uploaded[0].getBytes()); + } + + @Test + void emptyUserRangeDoesNotUploadAnEmptyWorkbook() throws Exception { + pages(0); + service.submit(query()); + assertThrows(AsyncTaskException.class, () -> action().execute(mock(AsyncTaskProgressReporter.class))); + verifyZeroInteractions(files); + } + + @Test + void invalidMonthAndReversedRangeFailBeforeQueueing() { + for (String month : Arrays.asList(null, "", "2026-1", "2026-13", "0999-01", "9999-12")) { + WalletManageBalanceDto dto = query(); dto.setWalletRecordMonth(month); + assertThrows(AsyncTaskException.class, () -> service.submit(dto)); + } + WalletManageBalanceDto dto = query(); dto.setMinBalance(10L); dto.setMaxBalance(-1L); + assertThrows(AsyncTaskException.class, () -> service.submit(dto)); + verifyZeroInteractions(tasks, users, files); + } + + @Test + void uploadFailureDeletesCompletedTemporaryFile() throws Exception { + pages(1); + service.submit(query()); + MultipartFile[] uploaded = {null}; + when(files.innerUpload(any(), anyString(), isNull(), anyString())).thenAnswer(call -> { + uploaded[0] = call.getArgument(0); + try (Workbook workbook = open(uploaded[0].getBytes())) { + assertEquals(4, workbook.getSheetAt(0).getLastRowNum()); + } + return R.failed("存储不可用"); + }); + assertThrows(AsyncTaskException.class, () -> action().execute(mock(AsyncTaskProgressReporter.class))); + assertThrows(NoSuchFileException.class, () -> uploaded[0].getBytes()); + } + + @Test + void laterPageFailurePreventsPartialFileUpload() throws Exception { + pages(201); + doThrow(new IllegalStateException("查询失败")).when(users) + .page(argThat(page -> page.getCurrent() == 2), any(), any()); + service.submit(query()); + assertThrows(IllegalStateException.class, () -> action().execute(mock(AsyncTaskProgressReporter.class))); + verifyZeroInteractions(files); + } + + /** 模拟同一筛选下稳定的用户分页,核对只有第一页请求总数及所有页均使用固定批大小。 */ + private void pages(int total) { + when(users.page(any(), any(), any())).thenAnswer(call -> { + Page page = call.getArgument(0); + assertEquals(200, page.getSize()); + assertEquals(200L, page.maxLimit()); + assertEquals(page.getCurrent() == 1, page.searchCount()); + assertTrue(page.orders().isEmpty(), "导出使用服务默认用户 ID 顺序,不继承页面的金额排序"); + long from = (page.getCurrent() - 1) * page.getSize(); + return new Page(page.getCurrent(), page.getSize()) + .setTotal(page.getCurrent() == 1 ? total : 0) + .setRecords(LongStream.range(from, Math.min(from + page.getSize(), total)).mapToObj(this::row).toList()); + }); + } + + /** 从提交动作取出异步业务回调,以同步方式测试文件闭合和失败清理。 */ + private AsyncTaskResultAction action() { + ArgumentCaptor captured = ArgumentCaptor.forClass(AsyncTaskResultAction.class); + verify(tasks).submitWithResult(eq("wallet:export:qb-users"), eq("用户钱包余额导出"), eq(1), captured.capture()); + return captured.getValue(); + } + + /** 创建超过 Integer 合计上限的用户样本,同时保留负数及零余额。 */ + private WalletQbUserVo row(long index) { + WalletQbUserVo row = new WalletQbUserVo(); + row.setUserId(10000L - index); + row.setUsername("用户" + index); + row.setUserMobile("13000000000"); + row.setDeptName("部门甲 / 部门乙"); + row.setEatBalance(2_000_000_000L); + row.setPayBalance(-100L); + row.setTopupBalance(0L); + row.setBalance(1_999_999_900L); + row.setWalletCount(3L); + row.setLastTransactionTime(LocalDateTime.of(2026, 10, 31, 12, 0)); + return row; + } + + /** 所有测试使用有效月份;无金额区间时应导出全部筛选用户。 */ + private WalletManageBalanceDto query() { + WalletManageBalanceDto dto = new WalletManageBalanceDto(); + dto.setWalletRecordMonth("2026-10"); + return dto; + } + + /** 读取上传回调中的完整 XLSX,确认不是仍在写入的半成品。 */ + private static Workbook open(byte[] bytes) throws Exception { + return new XSSFWorkbook(new ByteArrayInputStream(bytes)); + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/total/WalletQbUserBalanceSqlTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/total/WalletQbUserBalanceSqlTest.java new file mode 100644 index 000000000..83c16c684 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/total/WalletQbUserBalanceSqlTest.java @@ -0,0 +1,331 @@ +package com.bsd.pjcloud.wallet.total; + +import com.baomidou.mybatisplus.annotation.DbType; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder; +import com.baomidou.mybatisplus.core.metadata.OrderItem; +import com.baomidou.mybatisplus.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.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.WalletManageBalanceDto; +import com.bsd.pjcloud.wallet.api.vo.WalletQbUserVo; +import com.bsd.pjcloud.wallet.mapper.WalletQbUserMapper; +import com.bsd.pjcloud.wallet.mapper.WalletQbUserQueryScope; +import org.apache.ibatis.builder.xml.XMLMapperBuilder; +import org.apache.ibatis.datasource.unpooled.UnpooledDataSource; +import org.apache.ibatis.executor.statement.StatementHandler; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.plugin.Interceptor; +import org.apache.ibatis.plugin.Intercepts; +import org.apache.ibatis.plugin.Invocation; +import org.apache.ibatis.plugin.Signature; +import org.apache.ibatis.session.LocalCacheScope; +import org.apache.ibatis.session.SqlSession; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.InputStream; +import java.sql.Connection; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.function.Consumer; + +import static org.junit.jupiter.api.Assertions.*; + +/** 使用生产 SQL、租户和分页拦截器验证用户级月末余额;权限快照在钱包聚合前生效。 */ +class WalletQbUserBalanceSqlTest { + /** 十月查询排除十一月零点及之后流水。 */ + private static final LocalDateTime MONTH_END = LocalDateTime.of(2026, 11, 1, 0, 0); + /** 已由服务端解析的全部权限,客户端不能提交此对象。 */ + private static final WalletQbUserQueryScope ALL = new WalletQbUserQueryScope(true, null, List.of()); + /** 每个测试专有的内存数据库会话。 */ + private SqlSession session; + /** 使用实际 Mapper XML,不模拟金额查询。 */ + private WalletQbUserMapper mapper; + /** 记录最终执行 SQL,以验证默认分页避开流水、区间分页不触发重复自动计数。 */ + private final SqlRecorder recorder = new SqlRecorder(); + + /** 安装生产相同拦截顺序,构造跨部门多钱包、重复卡及月末流水边界。 */ + @BeforeEach + void setup() throws Exception { + MybatisConfiguration configuration = new MybatisConfiguration(); + configuration.setMapUnderscoreToCamelCase(true); + configuration.setLocalCacheScope(LocalCacheScope.STATEMENT); + configuration.setEnvironment(new Environment("wallet-user-balance", new JdbcTransactionFactory(), + new UnpooledDataSource("org.h2.Driver", "jdbc:h2:mem:wallet_user_balance_" + UUID.randomUUID() + + ";MODE=MySQL;DATABASE_TO_LOWER=TRUE", "sa", ""))); + configuration.addInterceptor(recorder); + PjcloudTenantConfigProperties tenants = new PjcloudTenantConfigProperties(); + tenants.setTables(List.of("wallet_manage", "wallet_transaction", "wallet_type")); + MybatisPlusInterceptor plugins = new MybatisPlusInterceptor(); + plugins.addInnerInterceptor(new TenantLineInnerInterceptor(new PjcloudTenantHandler(tenants))); + DataScopeInnerInterceptor permissions = new DataScopeInnerInterceptor(); + permissions.setDataScopeHandle(scope -> { throw new AssertionError("用户聚合不应在外层再次解析 DataScope"); }); + plugins.addInnerInterceptor(permissions); + plugins.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); + configuration.addInterceptor(plugins); + for (String resource : List.of("mapper/WalletManageMapper.xml", "mapper/WalletQbUserMapper.xml")) { + try (InputStream input = getClass().getClassLoader().getResourceAsStream(resource)) { + assertNotNull(input); + new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse(); + } + } + session = new MybatisSqlSessionFactoryBuilder().build(configuration).openSession(true); + mapper = session.getMapper(WalletQbUserMapper.class); + TenantContextHolder.setTenantId(7L); + try (var statement = session.getConnection().createStatement()) { + statement.execute(""" + create table wallet_manage(id bigint primary key,user_id bigint,wallet_type_id bigint,dept_id bigint, + balance int,create_by varchar(64),create_time timestamp,del_flag varchar(1),tenant_id bigint); + create table wallet_type(id bigint primary key,unique_code varchar(8),del_flag varchar(1),tenant_id bigint); + create table wallet_transaction(id bigint primary key,wallet_id bigint,user_id bigint, + balance int,create_time timestamp,del_flag varchar(1),tenant_id bigint); + insert into wallet_type values (11,'1','0',7),(22,'2','0',7),(33,'3','0',7),(44,'1','1',7),(81,'1','0',8); + insert into wallet_manage(id,user_id,wallet_type_id,dept_id,balance,create_by,del_flag,tenant_id) values + (101,1,11,10,999,'admin','0',7),(105,1,11,20,999,'admin','0',7), + (109,1,11,30,999,'admin','1',7),(111,1,22,20,999,'admin','0',7),(112,1,33,10,999,'admin','0',7), + (201,2,11,10,999,'admin','0',7),(301,3,11,20,999,'admin','0',7), + (401,4,11,10,999,'admin','0',7),(501,5,33,30,999,'admin','0',7), + (601,6,11,10,999,'admin','0',7),(602,6,22,10,999,'admin','0',7),(603,6,33,10,999,'admin','0',7), + (701,7,44,10,999,'admin','0',7),(702,7,55,10,999,'admin','0',7), + (801,8,11,30,999,'self','0',7),(802,8,22,20,999,'admin','0',7), + (901,null,11,10,999,'admin','0',7),(1801,1,81,10,999,'admin','0',8); + insert into wallet_transaction values + (1,101,1,120,'2026-10-20 12:00:00','0',7),(2,101,1,140,'2026-10-20 12:00:00','0',7), + (3,101,1,9999,'2026-10-31 23:59:59','1',7),(4,101,1,9999,'2026-11-01 00:00:00','0',7), + (5,109,1,300,'2026-10-25 12:00:00','0',7),(6,111,1,-50,'2026-10-31 23:59:59','0',7), + (7,112,1,100,'2026-10-15 12:00:00','0',7),(8,201,2,100,'2026-10-15 12:00:00','0',7), + (9,301,3,-50,'2026-10-15 12:00:00','0',7),(10,401,4,null,'2026-10-15 12:00:00','0',7), + (11,501,5,350,'2026-10-15 12:00:00','0',7), + (12,601,6,2000000000,'2026-10-15 12:00:00','0',7), + (13,602,6,2000000000,'2026-10-15 12:00:00','0',7), + (14,603,6,2000000000,'2026-10-15 12:00:00','0',7), + (15,701,7,7000,'2026-10-15 12:00:00','0',7),(16,702,7,8000,'2026-10-15 12:00:00','0',7), + (17,801,8,25,'2026-10-15 12:00:00','0',7),(18,802,8,75,'2026-10-15 12:00:00','0',7), + (19,1801,1,8000,'2026-10-15 12:00:00','0',8), + (20,101,1,9999,'2026-10-31 23:59:59','0',8),(21,101,1,9999,null,'0',7); + """); + } + recorder.statements.clear(); + } + + /** 关闭会话销毁内存库,并清除线程租户上下文。 */ + @AfterEach + void close() { + TenantContextHolder.clear(); + if (session != null) session.close(); + } + + /** 同用户同类型重复卡只计一次,先按所有用户合计排序,再取分页。 */ + @Test + void deduplicatesWalletTypesAndSortsAllUsersBeforePagingWithoutIntOverflow() { + var first = page(query(), ALL, false, 1, 3); + assertEquals(List.of(6L, 5L, 1L), ids(first)); + assertEquals(6_000_000_000L, first.getRecords().get(0).getBalance()); + WalletQbUserVo firstUser = first.getRecords().get(2); + assertEquals(300L, firstUser.getEatBalance()); + assertEquals(-50L, firstUser.getPayBalance()); + assertEquals(100L, firstUser.getTopupBalance()); + assertEquals(350L, firstUser.getBalance()); + assertEquals(4L, firstUser.getWalletCount()); + assertEquals(List.of(8L, 2L, 7L), ids(page(query(), ALL, false, 2, 3))); + assertEquals(List.of(4L, 3L), ids(page(query(), ALL, false, 3, 3))); + assertEquals(List.of(3L, 7L, 4L), ids(page(query(), ALL, true, 1, 3))); + assertEquals(8L, mapper.countUsers(query(), MONTH_END, ALL)); + assertSummaryMatchesRows(query(), ALL); + } + + /** 金额区间包含边界,作用于三类合计;窗口计数随同一条分页 SQL 返回。 */ + @Test + void inclusiveOneSidedZeroAndNegativeRangesFilterUserTotals() { + WalletManageBalanceDto bounded = query(); + bounded.setMinBalance(100L); + bounded.setMaxBalance(350L); + recorder.statements.clear(); + var first = page(bounded, ALL, false, 1, 2); + assertEquals(List.of(5L, 1L), ids(first)); + assertEquals(4L, first.getRecords().get(0).getMatchedUserCount()); + assertEquals(1, recorder.statements.size(), "区间页不能再次执行自动 COUNT 扫描历史余额"); + assertEquals(List.of(8L, 2L), ids(page(bounded, ALL, false, 2, 2))); + var summary = mapper.selectUserTotal(bounded, MONTH_END, ALL); + assertEquals(4L, summary.getUserCount()); + assertEquals(900L, summary.getTotal()); + assertEquals(425L, summary.getEatTotal()); + assertEquals(25L, summary.getPayTotal()); + assertEquals(450L, summary.getTopupTotal()); + bounded.setMinBalance(null); + bounded.setMaxBalance(0L); + assertEquals(List.of(7L, 4L, 3L), ids(page(bounded, ALL, false, 1, 50))); + bounded.setMinBalance(0L); + assertEquals(List.of(7L, 4L), ids(page(bounded, ALL, false, 1, 50))); + bounded.setMinBalance(-50L); + bounded.setMaxBalance(-50L); + assertEquals(List.of(3L), ids(page(bounded, ALL, false, 1, 50))); + bounded.setMinBalance(350L); + bounded.setMaxBalance(null); + assertEquals(List.of(6L, 5L, 1L), ids(page(bounded, ALL, false, 1, 50))); + assertSummaryMatchesRows(bounded, ALL); + } + + /** 用户的不可见钱包不能因另一钱包可见而泄漏金额;权限在金额区间和分页之前生效。 */ + @Test + void permissionsLimitWalletsBeforeAggregationRangeAndPagination() { + var department = new WalletQbUserQueryScope(false, null, List.of(10L)); + WalletManageBalanceDto range = query(); + range.setMinBalance(350L); + range.setMaxBalance(450L); + var only = page(range, department, false, 1, 1); + assertEquals(List.of(1L), ids(only)); + assertEquals(400L, only.getRecords().get(0).getBalance()); + assertEquals(0L, only.getRecords().get(0).getPayBalance()); + assertEquals(1L, only.getRecords().get(0).getMatchedUserCount()); + assertEquals(400L, mapper.selectUserTotal(range, MONTH_END, department).getTotal()); + assertSummaryMatchesRows(query(), department); + var self = new WalletQbUserQueryScope(false, "self", List.of()); + var own = page(query(), self, false, 1, 50); + assertEquals(List.of(8L), ids(own)); + assertEquals(25L, own.getRecords().get(0).getBalance()); + assertEquals(0L, own.getRecords().get(0).getPayBalance()); + assertSummaryMatchesRows(query(), new WalletQbUserQueryScope(false, "self", List.of(10L))); + assertSummaryMatchesRows(query(), new WalletQbUserQueryScope(false, null, List.of())); + } + + /** 当前钱包部门和类型决定入选金额,不能提前删除跨历史钱包的月末余额来源。 */ + @Test + void departmentAndTypeFiltersApplyBeforeUserTotals() { + WalletManageBalanceDto filter = query(); + filter.setDeptIds(List.of(20L)); + filter.setIsInclude(true); + var rows = page(filter, ALL, false, 1, 50); + assertEquals(List.of(1L, 8L, 3L), ids(rows)); + assertEquals(250L, rows.getRecords().get(0).getBalance()); + assertEquals(300L, rows.getRecords().get(0).getEatBalance()); + assertEquals(275L, mapper.selectUserTotal(filter, MONTH_END, ALL).getTotal()); + filter.setIsInclude(false); + assertSummaryMatchesRows(filter, ALL); + filter = query(); + filter.setWalletTypeIdList(List.of(11L)); + assertEquals(2_000_000_375L, mapper.selectUserTotal(filter, MONTH_END, ALL).getTotal()); + assertEquals(6L, mapper.countUsers(filter, MONTH_END, ALL)); + filter.setWalletTypeId(22L); + assertTrue(page(filter, ALL, false, 1, 50).getRecords().isEmpty()); + assertSummaryMatchesRows(filter, ALL); + } + + /** 月界、同秒、删除流水和租户过滤均作用于历史余额,异常类型只产生零金额。 */ + @Test + void tenantAndHistoricalBoundariesRemainConsistent() { + WalletManageBalanceDto user = query(); + user.setUserId(1L); + var beforeLaterHistory = mapper.selectUsers(user, LocalDateTime.of(2026, 10, 21, 0, 0), ALL); + assertEquals(1, beforeLaterHistory.size()); + assertEquals(140L, beforeLaterHistory.get(0).getEatBalance()); + var atMonthEnd = mapper.selectUsers(user, MONTH_END, ALL); + assertEquals(300L, atMonthEnd.get(0).getEatBalance()); + assertEquals(350L, atMonthEnd.get(0).getBalance()); + user.setUserId(7L); + var unknownTypes = mapper.selectUsers(user, MONTH_END, ALL); + assertEquals(1, unknownTypes.size()); + assertEquals(0L, unknownTypes.get(0).getBalance()); + assertEquals(2L, unknownTypes.get(0).getWalletCount()); + TenantContextHolder.setTenantId(8L); + assertEquals(List.of(1L), ids(page(query(), ALL, false, 1, 50))); + assertEquals(8000L, mapper.selectUserTotal(query(), MONTH_END, ALL).getTotal()); + assertSummaryMatchesRows(query(), ALL); + } + + /** 默认未排序页只在钱包表取用户页,历史金额查询限定到当页用户。 */ + @Test + void defaultUserPagingDoesNotScanHistory() { + Page requested = new Page<>(1, 2, false); + requested.addOrder(OrderItem.desc("user_id")); + recorder.statements.clear(); + var users = mapper.selectUserIdPage(requested, query(), ALL); + assertEquals(List.of(8L, 7L), users.getRecords()); + assertTrue(recorder.statements.stream().noneMatch(sql -> sql.toLowerCase().contains("wallet_transaction"))); + WalletManageBalanceDto detail = query(); + detail.setUserIds(users.getRecords()); + var rows = mapper.selectUsers(detail, MONTH_END, ALL); + assertEquals(List.of(7L, 8L), rows.stream().map(WalletQbUserVo::getUserId).sorted().toList()); + assertEquals(100L, rows.stream().mapToLong(WalletQbUserVo::getBalance).sum()); + } + + /** 多种筛选下,统计全部匹配用户,并保证三类汇总之和严格等于合计。 */ + @Test + void summariesMatchAllFilteredUsersIncludingEmptyResults() { + var total = mapper.selectUserTotal(query(), MONTH_END, ALL); + assertEquals(8L, total.getUserCount()); + assertEquals(15L, total.getWalletCount()); + assertEquals(6_000_000_850L, total.getTotal()); + assertEquals(2_000_000_375L, total.getEatTotal()); + assertEquals(2_000_000_025L, total.getPayTotal()); + assertEquals(2_000_000_450L, total.getTopupTotal()); + List> filters = List.of( + dto -> dto.setUserIds(List.of(1L, 2L, 8L)), + dto -> dto.setUserId(999L), + dto -> { dto.setMinBalance(100L); dto.setMaxBalance(350L); }, + dto -> { dto.setWalletTypeIdList(List.of(11L, 22L)); dto.setMinBalance(100L); }, + dto -> { dto.setDeptIds(List.of(10L)); dto.setIsInclude(true); dto.setMaxBalance(0L); }); + for (Consumer filter : filters) { + WalletManageBalanceDto dto = query(); + filter.accept(dto); + assertSummaryMatchesRows(dto, ALL); + } + } + + /** 测试分页关闭自动计数,与服务端使用窗口返回区间用户数的路径相同。 */ + private Page page(WalletManageBalanceDto dto, WalletQbUserQueryScope scope, boolean ascending, long current, long size) { + Page requested = new Page<>(current, size, false); + requested.addOrder(ascending ? OrderItem.asc("balance") : OrderItem.desc("balance"), OrderItem.desc("user_id")); + return mapper.selectUserPage(requested, dto, MONTH_END, scope, true); + } + + /** 比较全部匹配用户的逐行金额与统计 API 的三类汇总、用户数和实体钱包数。 */ + private void assertSummaryMatchesRows(WalletManageBalanceDto dto, WalletQbUserQueryScope scope) { + var rows = page(dto, scope, false, 1, 50).getRecords(); + var total = mapper.selectUserTotal(dto, MONTH_END, scope); + assertEquals((long) rows.size(), total.getUserCount()); + assertEquals(rows.stream().mapToLong(WalletQbUserVo::getBalance).sum(), total.getTotal()); + assertEquals(rows.stream().mapToLong(WalletQbUserVo::getEatBalance).sum(), total.getEatTotal()); + assertEquals(rows.stream().mapToLong(WalletQbUserVo::getPayBalance).sum(), total.getPayTotal()); + assertEquals(rows.stream().mapToLong(WalletQbUserVo::getTopupBalance).sum(), total.getTopupTotal()); + assertEquals(rows.stream().mapToLong(WalletQbUserVo::getWalletCount).sum(), total.getWalletCount()); + assertEquals(total.getEatTotal() + total.getPayTotal() + total.getTopupTotal(), total.getTotal()); + assertEquals((long) rows.size(), mapper.countUsers(dto, MONTH_END, scope)); + } + + /** 提取分页顺序用于跨页断言。 */ + private List ids(Page page) { + return page.getRecords().stream().map(WalletQbUserVo::getUserId).toList(); + } + + /** 生成固定月份查询,测试不依赖机器当前日期。 */ + private WalletManageBalanceDto query() { + WalletManageBalanceDto dto = new WalletManageBalanceDto(); + dto.setWalletRecordMonth("2026-10"); + return dto; + } + + /** 在语句准备阶段记录租户和分页插件处理后的 SQL,不输出连接或凭据。 */ + @Intercepts(@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})) + private static class SqlRecorder implements Interceptor { + /** 当前测试实际送往 H2 的 SQL。 */ + private final List statements = new ArrayList<>(); + + /** 记录语句后继续真实执行。 */ + @Override + public Object intercept(Invocation invocation) throws Throwable { + statements.add(((StatementHandler) invocation.getTarget()).getBoundSql().getSql()); + return invocation.proceed(); + } + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/total/WalletQbUserControllerTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/total/WalletQbUserControllerTest.java new file mode 100644 index 000000000..a3d82d053 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/total/WalletQbUserControllerTest.java @@ -0,0 +1,91 @@ +package com.bsd.pjcloud.wallet.total; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.common.core.jackson.PjcloudJavaTimeModule; +import com.bsd.pjcloud.common.data.resolver.SqlFilterArgumentResolver; +import com.bsd.pjcloud.wallet.api.dto.WalletManageBalanceDto; +import com.bsd.pjcloud.wallet.api.vo.WalletQbUserTotalVo; +import com.bsd.pjcloud.wallet.api.vo.WalletQbUserVo; +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.WalletQbUserService; +import com.bsd.pjcloud.wallet.service.export.WalletManageBatchExportService; +import com.bsd.pjcloud.wallet.service.export.WalletQbUserExportService; +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.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 WalletQbUserControllerTest { + private final WalletQbUserService service = mock(WalletQbUserService.class); + private final WalletQbUserExportService exports = mock(WalletQbUserExportService.class); + private MockMvc mvc; + + @BeforeEach + void setup() { + WalletManageController controller = new WalletManageController(mock(WalletManageService.class), mock(WalletCodeUseService.class), + mock(WalletManageMapper.class), mock(WalletManageBatchExportService.class), service, exports); + mvc = MockMvcBuilders.standaloneSetup(controller) + .setCustomArgumentResolvers(new SqlFilterArgumentResolver()) + .setMessageConverters(new MappingJackson2HttpMessageConverter(new ObjectMapper().registerModule(new PjcloudJavaTimeModule()))) + .build(); + } + + @Test + void pageBindsGlobalSortAndExactSignedCentsWithoutExposingWindowCount() throws Exception { + WalletQbUserVo row = new WalletQbUserVo(); row.setUserId(3L); row.setBalance(6_000_000_000L); row.setMatchedUserCount(1L); + when(service.page(any(), any(), any())).thenReturn(new Page().setTotal(1).setRecords(List.of(row))); + mvc.perform(post("/wallet-manage/page-qb-users").param("current", "2").param("size", "50").param("ascs", "eat_balance") + .contentType(MediaType.APPLICATION_JSON).content("{\"walletRecordMonth\":\"2026-10\",\"minBalance\":-100,\"maxBalance\":6000000000}")) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.records[0].balance").value(6_000_000_000L)) + .andExpect(jsonPath("$.data.records[0].matchedUserCount").doesNotExist()); + ArgumentCaptor page = ArgumentCaptor.forClass(Page.class); + ArgumentCaptor dto = ArgumentCaptor.forClass(WalletManageBalanceDto.class); + verify(service).page(page.capture(), dto.capture(), any()); + assertEquals(2, page.getValue().getCurrent()); assertEquals(50, page.getValue().getSize()); + Page capturedPage = page.getValue(); + assertEquals("eat_balance", capturedPage.orders().get(0).getColumn()); + assertTrue(capturedPage.orders().get(0).isAsc()); + assertEquals(-100L, dto.getValue().getMinBalance()); assertEquals(6_000_000_000L, dto.getValue().getMaxBalance()); + } + + @Test + void summaryAndExportReceiveTheSameOptionalIntegerRange() throws Exception { + when(service.total(any(), any())).thenReturn(new WalletQbUserTotalVo()); + String body = "{\"walletRecordMonth\":\"2026-10\",\"walletTypeIdList\":[11,22],\"minBalance\":\"0\",\"maxBalance\":null}"; + mvc.perform(post("/wallet-manage/qb-users-total").contentType(MediaType.APPLICATION_JSON).content(body)).andExpect(status().isOk()); + mvc.perform(post("/wallet-manage/export-qb-users-async").contentType(MediaType.APPLICATION_JSON).content(body)).andExpect(status().isOk()); + ArgumentCaptor summary = ArgumentCaptor.forClass(WalletManageBalanceDto.class); + ArgumentCaptor export = ArgumentCaptor.forClass(WalletManageBalanceDto.class); + verify(service).total(summary.capture(), any()); verify(exports).submit(export.capture()); + assertEquals(0L, summary.getValue().getMinBalance()); assertNull(summary.getValue().getMaxBalance()); + assertEquals(summary.getValue(), export.getValue()); + } + + @Test + void fractionalOrInvalidCentsAreRejectedBeforeAnyEndpointQueries() throws Exception { + for (String endpoint : List.of("page-qb-users", "qb-users-total", "export-qb-users-async")) { + for (String invalid : List.of("1.5", "\"1.5\"", "true", "{}", "9223372036854775808", "\"9223372036854775808\"")) { + mvc.perform(post("/wallet-manage/" + endpoint).contentType(MediaType.APPLICATION_JSON) + .content("{\"walletRecordMonth\":\"2026-10\",\"minBalance\":" + invalid + "}")) + .andExpect(status().isBadRequest()); + } + } + verifyZeroInteractions(service, exports); + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/total/WalletQbUserServiceTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/total/WalletQbUserServiceTest.java new file mode 100644 index 000000000..bda759531 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/total/WalletQbUserServiceTest.java @@ -0,0 +1,184 @@ +package com.bsd.pjcloud.wallet.total; + +import com.baomidou.mybatisplus.core.metadata.OrderItem; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.admin.api.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.security.service.PjcloudUser; +import com.bsd.pjcloud.wallet.api.dto.WalletManageBalanceDto; +import com.bsd.pjcloud.wallet.api.vo.WalletManageVo; +import com.bsd.pjcloud.wallet.api.vo.WalletQbUserTotalVo; +import com.bsd.pjcloud.wallet.api.vo.WalletQbUserVo; +import com.bsd.pjcloud.wallet.mapper.WalletQbUserMapper; +import com.bsd.pjcloud.wallet.mapper.WalletQbUserQueryScope; +import com.bsd.pjcloud.wallet.service.WalletQbUserService; +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.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** 用户余额报表服务验证:分页策略、范围计数、权限快照和当前页资料边界。 */ +class WalletQbUserServiceTest { + private final WalletQbUserMapper mapper = mock(WalletQbUserMapper.class); + private final RemoteDataScopeService scopes = mock(RemoteDataScopeService.class); + private final RemoteUserService users = mock(RemoteUserService.class); + private final RemoteDeptService departments = mock(RemoteDeptService.class); + private final WalletQbUserService service = new WalletQbUserService(mapper, scopes, users, departments); + + @BeforeEach + void setup() { + PjcloudUser operator = new PjcloudUser(1L, "operator", 10L, null, null, null, null, null, + 7L, "password", true, true, "0", true, true, List.of()); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(operator, "", List.of())); + when(users.getInnerUserById(any())).thenReturn(R.ok(List.of())); + when(departments.getDeptByDeptIds(any())).thenReturn(R.ok(Map.of())); + when(mapper.selectUserDepartments(any(), any())).thenReturn(List.of()); + } + + @AfterEach + void cleanup() { + SecurityContextHolder.clearContext(); + } + + @Test + void defaultPageReadsOnlyItsUserBalancesAndProfilesAndPreservesFilters() { + WalletManageBalanceDto query = query(); + query.setUserIds(new ArrayList<>(List.of(1L, 2L, 3L))); + query.setWalletTypeIdList(List.of(11L, 22L)); + query.setDeptIds(List.of(10L)); query.setIsInclude(false); + when(mapper.selectUserIdPage(any(), any(), any())).thenReturn(new Page(1, 2).setTotal(3).setRecords(List.of(3L, 2L))); + when(mapper.selectUsers(any(), any(), any())).thenReturn(List.of(row(2, 100), row(3, 200))); + WalletManageVo dept10 = new WalletManageVo(); dept10.setUserId(3L); dept10.setDeptId(10L); + WalletManageVo dept20 = new WalletManageVo(); dept20.setUserId(3L); dept20.setDeptId(20L); + when(mapper.selectUserDepartments(any(), any())).thenReturn(List.of(dept10, dept20)); + SysDept a = new SysDept(); a.setDeptId(10L); a.setName("一部"); + SysDept b = new SysDept(); b.setDeptId(20L); b.setName("二部"); + when(departments.getDeptByDeptIds(any())).thenReturn(R.ok(Map.of(10L, a, 20L, b))); + SysUser user = new SysUser(); user.setUserId(3L); user.setName("张三"); + when(users.getInnerUserById(any())).thenReturn(R.ok(List.of(user))); + Page result = service.page(new Page<>(1, 2), query, DataScope.of().deptIds(List.of(10L, 20L))); + assertEquals(3, result.getTotal()); + assertEquals(List.of(3L, 2L), result.getRecords().stream().map(WalletQbUserVo::getUserId).toList()); + assertEquals("张三", result.getRecords().get(0).getUsername()); + assertEquals("一部、二部", result.getRecords().get(0).getDeptName()); + ArgumentCaptor narrowed = ArgumentCaptor.forClass(WalletManageBalanceDto.class); + verify(mapper).selectUsers(narrowed.capture(), eq(LocalDateTime.of(2026, 11, 1, 0, 0)), any()); + assertEquals(List.of(3L, 2L), narrowed.getValue().getUserIds()); + assertEquals(List.of(11L, 22L), narrowed.getValue().getWalletTypeIdList()); + assertEquals(List.of(10L), narrowed.getValue().getDeptIds()); + assertEquals(false, narrowed.getValue().getIsInclude()); + assertEquals(List.of(1L, 2L, 3L), query.getUserIds()); + verify(users).getInnerUserById(List.of(3L, 2L)); + verify(mapper, never()).selectUserPage(any(), any(), any(), any(), anyBoolean()); + verify(mapper, never()).countUsers(any(), any(), any()); + } + + @Test + void rangePageUsesWindowCountWithoutRunningAnotherAggregateAndFallsBackOnlyForEmptyLaterPages() { + WalletManageBalanceDto query = query(); query.setMinBalance(-100L); query.setMaxBalance(350L); + when(mapper.selectUserPage(any(), any(), any(), any(), eq(true))).thenAnswer(call -> { + Page page = call.getArgument(0); + assertFalse(page.searchCount()); + assertEquals(List.of("user_id"), page.orders().stream().map(OrderItem::getColumn).toList()); + WalletQbUserVo row = row(3, 100); row.setMatchedUserCount(17L); + return page.setRecords(List.of(row)); + }); + Page first = service.page(new Page<>(1, 2), query, DataScope.of().deptIds(List.of(10L))); + assertEquals(17, first.getTotal()); assertTrue(first.searchCount()); + verify(mapper, never()).countUsers(any(), any(), any()); + when(mapper.selectUserPage(any(), any(), any(), any(), eq(true))).thenAnswer(call -> call.>getArgument(0).setRecords(List.of())); + when(mapper.countUsers(any(), any(), any())).thenReturn(17L); + assertEquals(17, service.page(new Page<>(20, 2), query, DataScope.of()).getTotal()); + verify(mapper).countUsers(any(), any(), any()); + } + + @Test + void amountSortingUsesOnlyAllowedColumnsAndStableUserTieOrderWithCheapCount() { + WalletManageBalanceDto query = query(); + when(mapper.countUsers(any(), any(), any())).thenReturn(2L); + when(mapper.selectUserPage(any(), any(), any(), any(), eq(false))).thenAnswer(call -> { + Page page = call.getArgument(0); + assertEquals(List.of("eat_balance", "user_id"), page.orders().stream().map(OrderItem::getColumn).toList()); + assertTrue(page.orders().get(0).isAsc()); assertFalse(page.orders().get(1).isAsc()); + assertEquals(1000, page.getSize()); assertFalse(page.searchCount()); + return page.setRecords(List.of()); + }); + Page page = new Page(1, 2000).addOrder(OrderItem.desc("unknown"), OrderItem.asc("eat_balance")); + service.page(page, query, DataScope.of()); + assertTrue(page.searchCount()); assertEquals(2, page.getTotal()); + verify(mapper).countUsers(any(), any(), any()); + verify(mapper, never()).selectUserIdPage(any(), any(), any()); + } + + @Test + void laterExportBatchesDisableCountAndRestoreFlagOnQueryFailure() { + WalletManageBalanceDto query = query(); query.setMinBalance(0L); + Page page = new Page<>(2, 200, false); + when(mapper.selectUserPage(any(), any(), any(), any(), eq(false))).thenAnswer(call -> call.getArgument(0)); + service.page(page, query, DataScope.of()); + verify(mapper, never()).countUsers(any(), any(), any()); + assertFalse(page.searchCount()); + page.setSearchCount(true); + when(mapper.selectUserPage(any(), any(), any(), any(), eq(true))).thenThrow(new IllegalStateException("unavailable")); + assertThrows(IllegalStateException.class, () -> service.page(page, query, DataScope.of())); + assertTrue(page.searchCount()); + } + + @Test + void summarySharesRangeAndImmutablePermissionWithoutLoadingUserProfiles() { + WalletManageBalanceDto query = query(); query.setMinBalance(0L); query.setMaxBalance(100L); + DataScope scope = DataScope.of().deptIds(new ArrayList<>(List.of(10L))); scope.setUsername("self"); + when(mapper.selectUserTotal(any(), any(), any())).thenAnswer(call -> { + WalletManageBalanceDto captured = call.getArgument(0); + assertEquals(0L, captured.getMinBalance()); assertEquals(100L, captured.getMaxBalance()); + WalletQbUserQueryScope permission = call.getArgument(2); + assertFalse(permission.isUnrestricted()); assertEquals("self", permission.getUsername()); + assertEquals(List.of(10L), permission.getDeptIds()); + assertThrows(UnsupportedOperationException.class, () -> permission.getDeptIds().add(20L)); + return new WalletQbUserTotalVo(); + }); + assertEquals(0, service.total(query, scope).getUserCount()); + assertEquals(List.of(10L), scope.getDeptList()); + verifyZeroInteractions(users, departments); + } + + @Test + void invalidBoundsMonthsAndUnboundedPagesFailBeforeAnyQuery() { + WalletManageBalanceDto query = query(); query.setMinBalance(1L); query.setMaxBalance(0L); + assertThrows(IllegalArgumentException.class, () -> service.page(new Page<>(1, 20), query, DataScope.of())); + assertThrows(IllegalArgumentException.class, () -> service.total(query, DataScope.of())); + query.setMinBalance(null); query.setMaxBalance(null); + for (String month : Arrays.asList(null, "", "2026-1", "2026-13", "0999-01", "9999-12")) { + query.setWalletRecordMonth(month); + assertThrows(IllegalArgumentException.class, () -> service.total(query, DataScope.of())); + } + query.setWalletRecordMonth("2026-10"); + assertThrows(IllegalArgumentException.class, () -> service.page(new Page<>(1, -1), query, DataScope.of())); + verifyZeroInteractions(mapper); + } + + private static WalletManageBalanceDto query() { + WalletManageBalanceDto dto = new WalletManageBalanceDto(); dto.setWalletRecordMonth("2026-10"); return dto; + } + + private static WalletQbUserVo row(long userId, long amount) { + WalletQbUserVo row = new WalletQbUserVo(); row.setUserId(userId); row.setBalance(amount); return row; + } +}