feat:余额区间查询

This commit is contained in:
dzc committed 2026-11-06 14:40:23 +08:00
1 parent 886d23e3db
commit af755b390e
14 files changed
+1569 -1

No files matched your search

@@ -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<Long> deptIds;
/** 用户汇总余额下限,单位分;包含边界,空值表示不限,允许负数。 */
@JsonDeserialize(using = CentsDeserializer.class)
private Long minBalance;
/** 用户汇总余额上限,单位分;包含边界,空值表示不限,允许负数。 */
@JsonDeserialize(using = CentsDeserializer.class)
private Long maxBalance;
/** 金额区间按整数分传输,禁止将小数分静默截断;兼容精确的整数字符串。 */
public static class CentsDeserializer extends JsonDeserializer<Long> {
/** 拒绝非整数、溢出、布尔及复合值,避免查询范围在反序列化时悄悄变化。 */
@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);
}
}
}
@@ -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;
}
@@ -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<Long> 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;
}
@@ -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<Page<WalletQbUserVo>> pageQbUsers(@ParameterObject Page<WalletQbUserVo> page, @RequestBody WalletManageBalanceDto dto) {
return R.ok(qbUserService.page(page, dto, DataScope.of()));
}
/** 统计当前筛选和用户余额区间内的全部用户,不受当前分页影响。 */
@Operation(summary = "用户钱包余额统计")
@PostMapping("/qb-users-total")
public R<WalletQbUserTotalVo> qbUsersTotal(@RequestBody WalletManageBalanceDto dto) {
return R.ok(qbUserService.total(dto, DataScope.of()));
}
/** 与原钱包记录导出相同权限及任务进度体系,生成用户汇总表。 */
@PostMapping("/export-qb-users-async")
public R<AsyncTaskProgressVO> exportQbUsersAsync(@RequestBody WalletManageBalanceDto dto) {
return R.ok(qbUserExportService.submit(dto), "导出任务已提交");
}
@Operation(summary = "不活跃用户统计", description = "不活跃用户统计")
@PostMapping("/page-join-active")
public R<WalletInactivePageVo> pageJoinActive(
@@ -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<Long> selectUserIdPage(Page<Long> page, @Param("dto") WalletManageBalanceDto dto, @Param("scope") WalletQbUserQueryScope scope);
/** 对已缩小到当前页用户的筛选计算月末余额,返回每用户一行。 */
List<WalletQbUserVo> selectUsers(@Param("dto") WalletManageBalanceDto dto, @Param("monthEndExclusive") LocalDateTime monthEndExclusive, @Param("scope") WalletQbUserQueryScope scope);
/** 全量用户余额排序或区间分页;countMatches 为真时在同次查询返回匹配用户总数。 */
Page<WalletQbUserVo> selectUserPage(Page<WalletQbUserVo> 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<WalletManageVo> selectUserDepartments(@Param("dto") WalletManageBalanceDto dto, @Param("scope") WalletQbUserQueryScope scope);
}
@@ -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<Long> deptIds;
/** 固定本次查询权限,避免分页、计数和统计重复解析角色或修改调用方的数据。 */
public static WalletQbUserQueryScope of(boolean unrestricted, DataScope scope) {
String username = scope == null ? null : scope.getUsername();
if (username != null && username.isBlank()) username = null;
List<Long> ids = scope == null || scope.getDeptList() == null ? List.of() : List.copyOf(scope.getDeptList());
return new WalletQbUserQueryScope(unrestricted, username, ids);
}
}
@@ -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<String> 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<WalletQbUserVo> page(Page<WalletQbUserVo> 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<OrderItem> 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<Long> 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<Long, WalletQbUserVo> 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<OrderItem> 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<Long> userIds) {
WalletManageBalanceDto result = snapshot(dto);
result.setUserIds(new ArrayList<>(userIds));
return result;
}
/** 用户及部门远程服务只接收当前页标识,多部门名称按钱包筛选结果合并。 */
private void fillPageProfiles(List<WalletQbUserVo> rows, WalletManageBalanceDto dto, WalletQbUserQueryScope scope) {
if (rows.isEmpty()) return;
List<Long> ids = rows.stream().map(WalletQbUserVo::getUserId).toList();
R<List<SysUser>> users = userService.getInnerUserById(ids);
if (users == null || !users.isOk() || users.getData() == null) throw new IllegalStateException("读取钱包用户信息失败,请重试");
Map<Long, SysUser> userMap = users.getData().stream().collect(Collectors.toMap(SysUser::getUserId, Function.identity(), (a, b) -> a));
Map<Long, LinkedHashSet<Long>> 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<Long> deptIds = userDepartments.values().stream().flatMap(Collection::stream).distinct().toList();
Map<Long, SysDept> departmentMap = Map.of();
if (!deptIds.isEmpty()) {
R<Map<Long, SysDept>> 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<String> 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("、")));
}
}
}
@@ -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<WalletQbUserVo> 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.<WalletQbUserVo, ExportContext, AsyncTaskResult>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<WalletQbUserVo> 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;
}
}
}
@@ -0,0 +1,125 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.bsd.pjcloud.wallet.mapper.WalletQbUserMapper">
<!-- 钱包筛选和数据权限在按用户/类型聚合之前生效,不将无权或未选中的类型计入用户合计。 -->
<sql id="visibleWallets">
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
<if test="dto.userId != null">
AND wallet.user_id = #{dto.userId}
</if>
<if test="dto.userIds != null and dto.userIds.size() != 0">
AND wallet.user_id IN
<foreach collection="dto.userIds" item="id" open="(" close=")" separator=",">#{id}</foreach>
</if>
<if test="dto.walletTypeId != null">
AND wallet.wallet_type_id = #{dto.walletTypeId}
</if>
<if test="dto.walletTypeIdList != null and dto.walletTypeIdList.size() != 0">
AND wallet.wallet_type_id IN
<foreach collection="dto.walletTypeIdList" item="id" open="(" close=")" separator=",">#{id}</foreach>
</if>
<if test="dto.deptIds != null and dto.deptIds.size() != 0">
<choose>
<when test="dto.isInclude != null and dto.isInclude == false">AND wallet.dept_id NOT IN</when>
<otherwise>AND wallet.dept_id IN</otherwise>
</choose>
<foreach collection="dto.deptIds" item="id" open="(" close=")" separator=",">#{id}</foreach>
</if>
<if test="!scope.unrestricted">
AND (
<if test="scope.username != null">wallet.create_by = #{scope.username} OR</if>
<choose>
<when test="scope.deptIds.size() != 0">
wallet.dept_id IN
<foreach collection="scope.deptIds" item="id" open="(" close=")" separator=",">#{id}</foreach>
</when>
<otherwise>1 = 0</otherwise>
</choose>
)
</if>
</sql>
<!--
先对当前可见钱包按用户/类型去重,再连接既有月末最新有效流水候选。
历史重复卡只贡献一次该类型余额,实体卡数量仍累计;已删除/缺失类型保留零余额用户。
复用候选收缩算法,仅对每钱包/历史用户的末次流水做窗口排序,而非排序全部流水。
-->
<sql id="groupedUsers">
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 (<include refid="visibleWallets"/>) visible
GROUP BY visible.user_id, visible.wallet_type_id, visible.wallet_type_unique_code
) pair
LEFT JOIN (
<include refid="com.bsd.pjcloud.wallet.mapper.WalletManageMapper.qbLatestBalanceRows"/>
) 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
</sql>
<!-- 区间限定的是筛选后用户的合计余额,分为整数绑定参数,包含上下边界。 -->
<sql id="balanceRange">
<where>
<if test="dto.minBalance != null">users.balance &gt;= #{dto.minBalance}</if>
<if test="dto.maxBalance != null">AND users.balance &lt;= #{dto.maxBalance}</if>
</where>
</sql>
<select id="selectUserIdPage" resultType="java.lang.Long">
SELECT DISTINCT visible.user_id
FROM (<include refid="visibleWallets"/>) visible
ORDER BY visible.user_id DESC
</select>
<select id="selectUsers" resultType="com.bsd.pjcloud.wallet.api.vo.WalletQbUserVo">
SELECT users.* FROM (<include refid="groupedUsers"/>) users
<include refid="balanceRange"/>
ORDER BY users.user_id DESC
</select>
<select id="selectUserPage" resultType="com.bsd.pjcloud.wallet.api.vo.WalletQbUserVo">
SELECT users.*
<if test="countMatches">, COUNT(*) OVER() AS matched_user_count</if>
FROM (<include refid="groupedUsers"/>) users
<include refid="balanceRange"/>
</select>
<select id="countUsers" resultType="java.lang.Long">
<choose>
<when test="dto.minBalance != null or dto.maxBalance != null">
SELECT COUNT(*) FROM (<include refid="groupedUsers"/>) users
<include refid="balanceRange"/>
</when>
<otherwise>
SELECT COUNT(DISTINCT visible.user_id) FROM (<include refid="visibleWallets"/>) visible
</otherwise>
</choose>
</select>
<select id="selectUserTotal" resultType="com.bsd.pjcloud.wallet.api.vo.WalletQbUserTotalVo">
SELECT COALESCE(SUM(users.balance), 0) AS total,
COALESCE(SUM(users.eat_balance), 0) AS eat_total,
COALESCE(SUM(users.pay_balance), 0) AS pay_total,
COALESCE(SUM(users.topup_balance), 0) AS topup_total,
COUNT(*) AS user_count,
COALESCE(SUM(users.wallet_count), 0) AS wallet_count
FROM (<include refid="groupedUsers"/>) users
<include refid="balanceRange"/>
</select>
<select id="selectUserDepartments" resultType="com.bsd.pjcloud.wallet.api.vo.WalletManageVo">
SELECT DISTINCT visible.user_id, visible.dept_id
FROM (<include refid="visibleWallets"/>) visible
ORDER BY visible.user_id, visible.dept_id
</select>
</mapper>
@@ -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();
@@ -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<WalletManageBalanceDto> 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<WalletQbUserVo> 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<WalletQbUserVo>(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<AsyncTaskResultAction> 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));
}
}
@@ -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<Long> 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<Consumer<WalletManageBalanceDto>> 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<WalletManageBalanceDto> filter : filters) {
WalletManageBalanceDto dto = query();
filter.accept(dto);
assertSummaryMatchesRows(dto, ALL);
}
}
/** 测试分页关闭自动计数,与服务端使用窗口返回区间用户数的路径相同。 */
private Page<WalletQbUserVo> page(WalletManageBalanceDto dto, WalletQbUserQueryScope scope, boolean ascending, long current, long size) {
Page<WalletQbUserVo> 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<Long> ids(Page<WalletQbUserVo> 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<String> statements = new ArrayList<>();
/** 记录语句后继续真实执行。 */
@Override
public Object intercept(Invocation invocation) throws Throwable {
statements.add(((StatementHandler) invocation.getTarget()).getBoundSql().getSql());
return invocation.proceed();
}
}
}
@@ -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<WalletQbUserVo>().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> page = ArgumentCaptor.forClass(Page.class);
ArgumentCaptor<WalletManageBalanceDto> 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<WalletManageBalanceDto> summary = ArgumentCaptor.forClass(WalletManageBalanceDto.class);
ArgumentCaptor<WalletManageBalanceDto> 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);
}
}
@@ -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<Long>(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<WalletQbUserVo> 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<WalletManageBalanceDto> 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<WalletQbUserVo> 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<WalletQbUserVo> 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.<Page<WalletQbUserVo>>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<WalletQbUserVo> 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<WalletQbUserVo> page = new Page<WalletQbUserVo>(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<WalletQbUserVo> 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;
}
}