feat:余额区间查询
This commit is contained in:
1 parent
886d23e3db
commit
af755b390e
14 files changed
+1569
-1
No files matched your search
+31
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+20
@@ -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;
|
||||
}
|
||||
+41
@@ -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;
|
||||
}
|
||||
+28
@@ -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(
|
||||
|
||||
+34
@@ -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);
|
||||
}
|
||||
+25
@@ -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);
|
||||
}
|
||||
}
|
||||
+188
@@ -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("、")));
|
||||
}
|
||||
}
|
||||
}
|
||||
+262
@@ -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 >= #{dto.minBalance}</if>
|
||||
<if test="dto.maxBalance != null">AND users.balance <= #{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>
|
||||
+3
-1
@@ -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();
|
||||
|
||||
+206
@@ -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));
|
||||
}
|
||||
}
|
||||
+331
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+91
@@ -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);
|
||||
}
|
||||
}
|
||||
+184
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user