diff --git a/AGENTS.md b/AGENTS.md index aca325182..893e0b0d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,6 +93,7 @@ ## 开发规则 +- **新增源码文件不写版权/许可注释头**(`Copyright (c) pjcloud` 开头的大段 `/* */` 块)。既有文件里的此类注释头保持原样,不主动清理、不扩散到新文件。 - 修改前先确认所属业务模块及其 `api` / `biz` 边界,沿用现有 Controller、Service、Mapper、DTO、VO、Feign Client、异常处理、鉴权和统一响应结构。 - 跨服务调用优先遵循项目现有 OpenFeign 封装、服务名、请求模型、降级与异常处理方式,不得绕过已有边界直接耦合其他服务内部实现。 - Redis 使用必须复用已有缓存工具、序列化方式、键命名规则和过期策略。新增或修改缓存逻辑时,必须考虑缓存穿透、数据一致性、失效时机和空值处理。 diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/StudentSchoolNoDTO.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/StudentSchoolNoDTO.java new file mode 100644 index 000000000..880bee2d8 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/StudentSchoolNoDTO.java @@ -0,0 +1,46 @@ +package com.bsd.pjcloud.admin.api.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.util.Objects; + +/** + * 学校和学号组合键,供跨服务批量定位学生身份使用。 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "学校和学号组合键") +public class StudentSchoolNoDTO implements Serializable { + + @Schema(description = "学校名称") + private String schoolName; + + @Schema(description = "学号") + private String studentNo; + + /** + * 组合键去重时同时比较学校名称和学号,避免仅按单个字段误伤。 + * @param o 待比较对象 + * @return 学校名称和学号均一致时返回真 + */ + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof StudentSchoolNoDTO that)) { + return false; + } + return Objects.equals(schoolName, that.schoolName) && Objects.equals(studentNo, that.studentNo); + } + + @Override + public int hashCode() { + return Objects.hash(schoolName, studentNo); + } +} diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/StudentUserMatchDTO.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/StudentUserMatchDTO.java new file mode 100644 index 000000000..84366e53d --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/StudentUserMatchDTO.java @@ -0,0 +1,29 @@ +package com.bsd.pjcloud.admin.api.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serializable; + +/** + * 学校和学号批量定位命中的学生用户资料,携带定位键便于调用方回填到对应 Excel 行。 + */ +@Data +@Schema(description = "学校和学号定位的学生用户资料") +public class StudentUserMatchDTO implements Serializable { + + @Schema(description = "学校名称,与请求组合键一致") + private String schoolName; + + @Schema(description = "学号,与请求组合键一致") + private String studentNo; + + @Schema(description = "用户ID") + private Long userId; + + @Schema(description = "姓名") + private String name; + + @Schema(description = "部门ID") + private Long deptId; +} diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserService.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserService.java index 64f1da0cd..b17eb33b5 100644 --- a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserService.java +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserService.java @@ -20,6 +20,8 @@ package com.bsd.pjcloud.admin.api.feign; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.admin.api.dto.StudentSchoolNoDTO; +import com.bsd.pjcloud.admin.api.dto.StudentUserMatchDTO; import com.bsd.pjcloud.admin.api.dto.UserDTO; import com.bsd.pjcloud.admin.api.dto.UserDeptSimpleInfo; import com.bsd.pjcloud.admin.api.dto.UserInfo; @@ -160,6 +162,24 @@ public interface RemoteUserService { R getStudentBySchoolNameAndStudentNo(@RequestParam("schoolName") String schoolName, @RequestParam("studentNo") String studentNo); + /** + * 按手机号批量查询用户,供大文件 Excel 导入同步校验阶段一次定位多行用户。 + * + * @param mobiles 去重后的手机号列表,调用方应控制单次数量 + * @return 命中的用户列表;未命中的手机号不返回记录 + */ + @PostMapping("/user/get-users-by-mobiles") + R> getUsersByMobiles(@RequestBody List mobiles); + + /** + * 按学校和学号组合键批量查询学生用户,供大文件 Excel 导入同步校验阶段一次定位多行学生。 + * + * @param keys 学校和学号组合键列表,调用方应控制单次数量 + * @return 命中的学生用户资料,携带定位键便于回填;未命中的组合键不返回记录 + */ + @PostMapping("/user/get-students-by-school-and-no") + R> getStudentsBySchoolNameAndStudentNoBatch(@RequestBody List keys); + /** * 根据用户ID获取用户 */ diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java index 5e6e00f3e..b15b9f152 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java @@ -21,6 +21,7 @@ package com.bsd.pjcloud.admin.controller; import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.collection.ListUtil; import cn.hutool.core.util.ObjectUtil; import cn.hutool.core.util.StrUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; @@ -28,6 +29,8 @@ import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.bsd.pjcloud.admin.api.dto.ForgotPasswordDTO; +import com.bsd.pjcloud.admin.api.dto.StudentSchoolNoDTO; +import com.bsd.pjcloud.admin.api.dto.StudentUserMatchDTO; import com.bsd.pjcloud.admin.api.dto.SysUserDeleteDto; import com.bsd.pjcloud.admin.api.dto.UserDTO; import com.bsd.pjcloud.admin.api.dto.UserDeptSimpleInfo; @@ -81,6 +84,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.function.Function; import java.util.stream.Collectors; import java.util.Objects; @@ -181,6 +185,102 @@ public class SysUserController { return user == null ? R.failed("学生用户不存在") : R.ok(userService.findUserInfo(user)); } + /** + * 按手机号批量查询用户,供钱包等业务的大文件 Excel 导入在同步校验阶段一次定位多行用户,替代逐行远程调用。 + * + * @param mobiles 手机号列表,服务端按批次查询 + * @return 命中的用户列表;未命中的手机号不返回记录 + */ + @PostMapping("/get-users-by-mobiles") + public R> getUsersByMobiles(@RequestBody List mobiles) { + if (CollectionUtils.isEmpty(mobiles)) { + return R.ok(List.of()); + } + List distinctMobiles = mobiles.stream() + .filter(StrUtil::isNotBlank) + .map(String::trim) + .distinct() + .toList(); + List users = new ArrayList<>(); + // 按 1000 一批执行 IN 查询,避免超出 SQL 长度或全表扫描 + for (List batch : ListUtil.partition(distinctMobiles, 1000)) { + users.addAll(userService.list(Wrappers.query().lambda().in(SysUser::getPhone, batch))); + } + return R.ok(users); + } + + /** + * 按学校和学号组合键批量查询学生用户,供大文件 Excel 导入在同步校验阶段一次定位多行学生。 + * + * @param keys 学校和学号组合键列表,服务端按学号分批查询后精确匹配学校名称 + * @return 命中的学生定位资料,携带定位键便于调用方回填;未命中的组合键不返回记录 + */ + @PostMapping("/get-students-by-school-and-no") + public R> getStudentsBySchoolNameAndStudentNoBatch(@RequestBody List keys) { + if (CollectionUtils.isEmpty(keys)) { + return R.ok(List.of()); + } + Set distinctKeys = keys.stream() + .filter(key -> key != null && StrUtil.isNotBlank(key.getSchoolName()) && StrUtil.isNotBlank(key.getStudentNo())) + .map(key -> new StudentSchoolNoDTO(StrUtil.trim(key.getSchoolName()), StrUtil.trim(key.getStudentNo()))) + .collect(Collectors.toSet()); + if (distinctKeys.isEmpty()) { + return R.ok(List.of()); + } + // 先按学号分批缩小范围,再在内存中按学校和学号精确匹配,避免生成超长 OR 条件 + List students = new ArrayList<>(); + for (List batch : ListUtil.partition(distinctKeys.stream().map(StudentSchoolNoDTO::getStudentNo).distinct().toList(), 1000)) { + students.addAll(sysUserStudentService.list(Wrappers.query().lambda() + .in(SysUserStudentEntity::getStudentNo, batch))); + } + Map studentByKey = students.stream() + .filter(student -> StrUtil.isNotBlank(student.getSchoolName())) + .collect(Collectors.toMap( + student -> buildSchoolStudentKey(StrUtil.trim(student.getSchoolName()), StrUtil.trim(student.getStudentNo())), + Function.identity(), (first, ignored) -> first)); + List userIds = distinctKeys.stream() + .map(key -> studentByKey.get(buildSchoolStudentKey(key.getSchoolName(), key.getStudentNo()))) + .filter(Objects::nonNull) + .map(SysUserStudentEntity::getUserId) + .distinct() + .toList(); + Map userById = userIds.isEmpty() ? Map.of() + : userService.listByIds(userIds).stream() + .collect(Collectors.toMap(SysUser::getUserId, Function.identity(), (first, ignored) -> first)); + List matches = distinctKeys.stream() + .map(key -> { + SysUserStudentEntity student = studentByKey.get(buildSchoolStudentKey(key.getSchoolName(), key.getStudentNo())); + if (student == null) { + return null; + } + SysUser user = userById.get(student.getUserId()); + if (user == null) { + return null; + } + StudentUserMatchDTO match = new StudentUserMatchDTO(); + match.setSchoolName(key.getSchoolName()); + match.setStudentNo(key.getStudentNo()); + match.setUserId(user.getUserId()); + match.setName(user.getName()); + match.setDeptId(user.getDeptId()); + return match; + }) + .filter(Objects::nonNull) + .toList(); + return R.ok(matches); + } + + /** + * 生成学校和学号组合键,用于批量学生查询的内存匹配,不与业务协议交互。 + * + * @param schoolName 学校名称 + * @param studentNo 学号 + * @return 保留空值语义的组合键 + */ + private String buildSchoolStudentKey(String schoolName, String studentNo) { + return StrUtil.nullToEmpty(schoolName) + "\u0000" + StrUtil.nullToEmpty(studentNo); + } + @Inner @GetMapping("/get-name-or-mobile") public R> getUserByNameOrMobile(@RequestParam("keyword") String keyword) { diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletActiveImportServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletActiveImportServiceImpl.java index dc1ca5246..1333999ea 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletActiveImportServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletActiveImportServiceImpl.java @@ -1,10 +1,12 @@ package com.bsd.pjcloud.wallet.service.impl; +import cn.hutool.core.collection.ListUtil; import cn.hutool.core.util.ObjectUtil; import cn.hutool.core.util.StrUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; -import com.bsd.pjcloud.admin.api.dto.UserInfo; +import com.bsd.pjcloud.admin.api.dto.StudentSchoolNoDTO; +import com.bsd.pjcloud.admin.api.dto.StudentUserMatchDTO; import com.bsd.pjcloud.admin.api.entity.SysUser; import com.bsd.pjcloud.admin.api.feign.RemoteUserService; import com.bsd.pjcloud.common.asynctask.service.AsyncTaskExecutorService; @@ -25,6 +27,7 @@ import org.springframework.stereotype.Service; import org.springframework.validation.BindingResult; import java.util.ArrayList; +import java.util.HashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; @@ -49,6 +52,9 @@ public class WalletActiveImportServiceImpl implements WalletActiveImportService /** 允许导入的饭卡卡号长度,与既有开卡导入规则保持一致。 */ private static final Set CARD_NUMBER_LENGTHS = Set.of(8, 10, 12); + /** 批量用户定位单次远程请求携带的最大键数量,防止 SQL 过长。 */ + private static final int LOOKUP_BATCH_SIZE = 1000; + /** 用户远程服务,用于按默认模板或学生身份条件精确定位系统用户。 */ private final RemoteUserService remoteUserService; @@ -88,6 +94,9 @@ public class WalletActiveImportServiceImpl implements WalletActiveImportService }); Map phoneCounts = countValues(effectiveRows.stream().map(WalletImportActiveDto::getUserMobile).toList()); Map cardCounts = countValues(effectiveRows.stream().map(WalletImportActiveDto::getCardNumber).toList()); + // 一次性批量定位全部用户,避免逐行 Feign 导致大文件同步校验超时 + Map> usersByMobile = loadUsersByMobile( + effectiveRows.stream().map(WalletImportActiveDto::getUserMobile).toList()); List validatedRows = new ArrayList<>(); for (WalletImportActiveDto row : effectiveRows) { Set rowErrors = new LinkedHashSet<>(); @@ -99,8 +108,12 @@ public class WalletActiveImportServiceImpl implements WalletActiveImportService } SysUser user = null; if (StrUtil.isNotBlank(row.getUsername()) && StrUtil.isNotBlank(row.getUserMobile())) { - user = extractUser(remoteUserService.getUserByNameAndMobile(row.getUsername(), row.getUserMobile())); - if (user == null || !ObjectUtil.equals(user.getName(), row.getUsername())) { + List candidates = usersByMobile.get(row.getUserMobile()); + user = candidates == null ? null : candidates.stream() + .filter(candidate -> ObjectUtil.equals(candidate.getName(), row.getUsername())) + .findFirst() + .orElse(null); + if (user == null) { rowErrors.add("用户不存在"); } } @@ -139,6 +152,8 @@ public class WalletActiveImportServiceImpl implements WalletActiveImportService Map studentCounts = countValues(effectiveRows.stream() .map(row -> buildStudentKey(row.getSchoolName(), row.getStudentNo())).toList()); Map cardCounts = countValues(effectiveRows.stream().map(WalletStudentImportActiveDto::getCardNumber).toList()); + // 一次性批量定位全部学生,避免逐行 Feign 导致大文件同步校验超时 + Map studentByKey = loadStudentsByKey(effectiveRows); List validatedRows = new ArrayList<>(); for (WalletStudentImportActiveDto row : effectiveRows) { Set rowErrors = new LinkedHashSet<>(); @@ -150,17 +165,17 @@ public class WalletActiveImportServiceImpl implements WalletActiveImportService if (StrUtil.isNotBlank(row.getSchoolName()) && StrUtil.isNotBlank(row.getStudentNo()) && studentCounts.getOrDefault(studentKey, 0L) > 1) { rowErrors.add("学生学校和学号 <" + row.getSchoolName() + " / " + row.getStudentNo() + "> 在Excel中重复"); } - SysUser user = null; + StudentUserMatchDTO student = null; if (StrUtil.isNotBlank(row.getSchoolName()) && StrUtil.isNotBlank(row.getStudentNo())) { - user = extractUser(remoteUserService.getStudentBySchoolNameAndStudentNo(row.getSchoolName(), row.getStudentNo())); - if (user == null) { + student = studentByKey.get(studentKey); + if (student == null) { rowErrors.add("学生不存在"); - } else if (!ObjectUtil.equals(user.getName(), row.getStudentName())) { + } else if (!ObjectUtil.equals(student.getName(), row.getStudentName())) { rowErrors.add("学生姓名与学校、学号不匹配"); } } - if (rowErrors.isEmpty() && user != null) { - validatedRows.add(new ValidatedImportRow(row.getLineNum(), user.getUserId(), row.getStudentName(), row.getCardNumber())); + if (rowErrors.isEmpty() && student != null) { + validatedRows.add(new ValidatedImportRow(row.getLineNum(), student.getUserId(), row.getStudentName(), row.getCardNumber())); } else { appendErrors(errors, row.getLineNum(), rowErrors); } @@ -344,13 +359,51 @@ public class WalletActiveImportServiceImpl implements WalletActiveImportService } /** - * 从 Feign 统一响应中安全提取用户,失败响应或空数据均按未找到处理。 + * 按手机号去重分批调用远程批量查询,构建手机号到候选用户的映射。 * - * @param response UPMS 用户查询响应 - * @return 用户实体;响应失败或数据为空时返回 {@code null} + * @param mobiles 当前 Excel 全部行的手机号(允许重复与空值) + * @return 手机号到候选用户列表的映射;查询失败的批次按未命中处理,由行级校验统一提示 */ - private SysUser extractUser(R response) { - return response == null || !response.isOk() || response.getData() == null ? null : response.getData().getSysUser(); + private Map> loadUsersByMobile(List mobiles) { + List distinctMobiles = mobiles.stream() + .filter(StrUtil::isNotBlank) + .distinct() + .toList(); + Map> usersByMobile = new HashMap<>(); + for (List batch : ListUtil.partition(distinctMobiles, LOOKUP_BATCH_SIZE)) { + R> response = remoteUserService.getUsersByMobiles(batch); + if (response == null || !response.isOk() || response.getData() == null) { + continue; + } + response.getData().stream() + .filter(user -> ObjectUtil.isNotEmpty(user.getPhone())) + .forEach(user -> usersByMobile.computeIfAbsent(user.getPhone(), key -> new ArrayList<>()).add(user)); + } + return usersByMobile; + } + + /** + * 按学校和学号组合键去重分批调用远程批量查询,构建组合键到学生资料的映射。 + * + * @param rows 学生 Excel 数据行 + * @return 组合键到学生资料的映射;查询失败的批次按未命中处理 + */ + private Map loadStudentsByKey(List rows) { + List keys = rows.stream() + .filter(row -> ObjectUtil.isAllNotEmpty(row.getSchoolName(), row.getStudentNo())) + .map(row -> new StudentSchoolNoDTO(row.getSchoolName(), row.getStudentNo())) + .distinct() + .toList(); + Map studentByKey = new HashMap<>(); + for (List batch : ListUtil.partition(keys, LOOKUP_BATCH_SIZE)) { + R> response = remoteUserService.getStudentsBySchoolNameAndStudentNoBatch(batch); + if (response == null || !response.isOk() || response.getData() == null) { + continue; + } + response.getData().forEach(match -> studentByKey.put( + buildStudentKey(match.getSchoolName(), match.getStudentNo()), match)); + } + return studentByKey; } /** diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyImportServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyImportServiceImpl.java index 860dffe5d..c87c54685 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyImportServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyImportServiceImpl.java @@ -1,12 +1,18 @@ package com.bsd.pjcloud.wallet.service.impl.recharge; +import cn.hutool.core.collection.ListUtil; import cn.hutool.core.lang.Validator; import cn.hutool.core.util.ObjectUtil; -import com.bsd.pjcloud.admin.api.dto.UserInfo; +import cn.hutool.core.util.StrUtil; +import com.bsd.pjcloud.admin.api.dto.StudentSchoolNoDTO; +import com.bsd.pjcloud.admin.api.dto.StudentUserMatchDTO; import com.bsd.pjcloud.admin.api.entity.SysDictItem; import com.bsd.pjcloud.admin.api.entity.SysUser; import com.bsd.pjcloud.admin.api.feign.RemoteDictService; import com.bsd.pjcloud.admin.api.feign.RemoteUserService; +import com.bsd.pjcloud.common.asynctask.service.AsyncTaskExecutorService; +import com.bsd.pjcloud.common.asynctask.service.AsyncTaskProgressReporter; +import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.excel.vo.ErrorMessage; import com.bsd.pjcloud.common.security.service.PjcloudUser; @@ -24,22 +30,24 @@ import com.bsd.pjcloud.wallet.service.WalletRechargeInvoiceService; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.tuple.ImmutablePair; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; import org.springframework.validation.BindingResult; import java.math.BigDecimal; +import java.util.ArrayList; import java.util.Arrays; +import java.util.HashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.Optional; import java.util.Set; +import java.util.function.Function; import java.util.stream.Collectors; /** * 充值/扣款申请 Excel 导入服务实现。 - *

负责默认用户模板校验、申请或发票创建及导入明细落库,不承载申请 CRUD 与审批逻辑。

+ *

同步阶段完成整表解析、批量用户定位和业务校验并立即返回逐行错误;校验全部通过后提交公共异步事务任务, + * 后台创建申请与导入明细并逐批报告进度,避免大文件导入触发网关超时。

*/ @Service @RequiredArgsConstructor @@ -48,6 +56,18 @@ public class WalletRechargeApplyImportServiceImpl implements WalletRechargeApply /** 充值流水财务标签使用的字典类型,Excel 标签需转换为稳定字典值后落库。 */ private static final String TRANSACTION_TAG_DICT_TYPE = "wallet_transaction_tag"; + /** 默认用户充值导入任务的 Redis 业务命名空间,充值、重置和发票充值导入共用,任务名区分场景。 */ + private static final String DEFAULT_IMPORT_REDIS_KEY = "wallet:recharge:import:default"; + + /** 学生充值导入任务的 Redis 业务命名空间。 */ + private static final String STUDENT_IMPORT_REDIS_KEY = "wallet:recharge:import:student"; + + /** 批量用户定位单次远程请求携带的最大键数量,防止 SQL 过长。 */ + private static final int LOOKUP_BATCH_SIZE = 1000; + + /** 异步落库阶段单批保存的明细行数,配合进度报告器逐批推进。 */ + private static final int SAVE_BATCH_SIZE = 500; + /** 充值/扣款申请 Mapper,用于创建不绑定发票的申请主记录。 */ private final WalletRechargeApplyMapper walletRechargeApplyMapper; @@ -57,133 +77,217 @@ public class WalletRechargeApplyImportServiceImpl implements WalletRechargeApply /** 充值发票服务,用于绑定发票导入时创建基础发票记录。 */ private final WalletRechargeInvoiceService walletRechargeInvoiceService; - /** 用户远程服务,用于按姓名和手机号精确定位默认用户。 */ + /** 用户远程服务,用于批量按姓名和手机号定位默认用户。 */ private final RemoteUserService remoteUserService; /** 字典远程服务,用于将 Excel 财务标签名称转换为持久化值。 */ private final RemoteDictService remoteDictService; + /** 公共异步任务执行器,用于后台事务落库并向前端报告逐批进度。 */ + private final AsyncTaskExecutorService asyncTaskExecutorService; + /** - * 校验默认用户充值、扣款或退款模板,并创建申请及明细记录。 + * 校验默认用户充值、扣款或退款模板,并提交异步导入任务。 * * @param rows 默认用户 Excel 数据行 * @param bindingResult Excel 解析阶段的逐行校验结果 * @param bizType 充值业务类型协议值 - * @return 校验失败时返回逐行错误,通过时返回导入成功结果 + * @return 校验失败时返回逐行错误,通过时返回异步任务初始进度 */ @Override - @Transactional(rollbackFor = Exception.class) public R importDefault(List rows, BindingResult bindingResult, String bizType) { validateBizType(bizType, false); ImmutablePair, List> pair = validateExcelData(rows, bindingResult, true); if (ObjectUtil.isNotEmpty(pair.getLeft())) { return R.failed(pair.getLeft()); } - return createApplyAndDetails(pair.getRight(), bizType, false); + return submitApplyImport(pair.getRight(), bizType, false, "默认用户充值导入"); } /** - * 校验默认用户余额重置模板,并以零金额创建申请明细。 + * 校验默认用户余额重置模板,并以零金额提交异步导入任务。 * * @param rows 默认用户余额重置 Excel 数据行 * @param bindingResult Excel 解析阶段的逐行校验结果 * @param bizType 余额重置业务类型协议值 - * @return 校验失败时返回逐行错误,通过时返回导入成功结果 + * @return 校验失败时返回逐行错误,通过时返回异步任务初始进度 */ @Override - @Transactional(rollbackFor = Exception.class) public R importDefaultReset(List rows, BindingResult bindingResult, String bizType) { validateBizType(bizType, true); ImmutablePair, List> pair = validateExcelData(rows, bindingResult, false); if (ObjectUtil.isNotEmpty(pair.getLeft())) { return R.failed(pair.getLeft()); } - return createApplyAndDetails(pair.getRight(), bizType, true); + return submitApplyImport(pair.getRight(), bizType, true, "默认用户余额重置导入"); } /** - * 校验学生充值、扣款或退款模板,并创建申请及明细记录。 + * 校验学生充值、扣款或退款模板,并提交异步导入任务。 * * @param rows 学生 Excel 数据行 * @param bindingResult Excel 解析阶段的逐行校验结果 * @param bizType 充值业务类型协议值 - * @return 校验失败时返回逐行错误,通过时返回导入成功结果 + * @return 校验失败时返回逐行错误,通过时返回异步任务初始进度 */ @Override - @Transactional(rollbackFor = Exception.class) public R importStudent(List rows, BindingResult bindingResult, String bizType) { validateBizType(bizType, false); ImmutablePair, List> pair = validateStudentExcelData(rows, bindingResult, true); if (ObjectUtil.isNotEmpty(pair.getLeft())) { return R.failed(pair.getLeft()); } - return createStudentApplyAndDetails(pair.getRight(), bizType, false); + return submitStudentApplyImport(pair.getRight(), bizType, false, "学生充值导入"); } /** - * 校验学生余额重置模板,并以零金额创建申请明细。 + * 校验学生余额重置模板,并以零金额提交异步导入任务。 * * @param rows 学生余额重置 Excel 数据行 * @param bindingResult Excel 解析阶段的逐行校验结果 * @param bizType 余额重置业务类型协议值 - * @return 校验失败时返回逐行错误,通过时返回导入成功结果 + * @return 校验失败时返回逐行错误,通过时返回异步任务初始进度 */ @Override - @Transactional(rollbackFor = Exception.class) public R importStudentReset(List rows, BindingResult bindingResult, String bizType) { validateBizType(bizType, true); ImmutablePair, List> pair = validateStudentExcelData(rows, bindingResult, false); if (ObjectUtil.isNotEmpty(pair.getLeft())) { return R.failed(pair.getLeft()); } - return createStudentApplyAndDetails(pair.getRight(), bizType, true); + return submitStudentApplyImport(pair.getRight(), bizType, true, "学生余额重置导入"); } /** - * 校验绑定发票的默认用户充值模板,创建基础发票及未绑定的导入明细。 + * 校验绑定发票的默认用户充值模板,并提交创建基础发票及导入明细的异步任务。 * * @param rows 默认用户充值 Excel 数据行 * @param bindingResult Excel 解析阶段的逐行校验结果 - * @return 校验失败时返回逐行错误,通过时返回导入成功结果 + * @return 校验失败时返回逐行错误,通过时返回异步任务初始进度 */ @Override - @Transactional(rollbackFor = Exception.class) public R importTopup(List rows, BindingResult bindingResult) { ImmutablePair, List> pair = validateExcelData(rows, bindingResult, true); if (ObjectUtil.isNotEmpty(pair.getLeft())) { return R.failed(pair.getLeft()); } List validatedRows = pair.getRight(); - PjcloudUser loginUser = SecurityUtils.getUser(); Integer totalPrice = validatedRows.stream().map(WalletRechargeExcelDto::getPriceNumber).reduce(Integer::sum).orElse(0); + AsyncTaskProgressVO progress = asyncTaskExecutorService.submit(DEFAULT_IMPORT_REDIS_KEY, "发票充值导入", validatedRows.size(), + reporter -> saveTopupDetails(validatedRows, totalPrice, reporter)); + return R.ok(progress, "Excel校验通过,已开始后台导入"); + } + + /** + * 提交默认用户充值导入异步任务,任务内创建申请主记录并批量保存明细。 + * + * @param rows 已完成用户定位与字段校验的数据行 + * @param bizType 充值业务类型协议值 + * @param resetBalance 是否为余额重置;为真时明细金额固定为零 + * @param taskName 异步任务展示名称 + * @return 异步任务初始进度响应 + */ + private R submitApplyImport(List rows, String bizType, boolean resetBalance, String taskName) { + AsyncTaskProgressVO progress = asyncTaskExecutorService.submit(DEFAULT_IMPORT_REDIS_KEY, taskName, rows.size(), + reporter -> saveApplyAndDetails(rows, bizType, resetBalance, reporter)); + return R.ok(progress, "Excel校验通过,已开始后台导入"); + } + + /** + * 提交学生充值导入异步任务,任务内创建申请主记录并批量保存明细。 + * + * @param rows 已完成学生定位与字段校验的数据行 + * @param bizType 充值业务类型协议值 + * @param resetBalance 是否为余额重置;为真时明细金额固定为零 + * @param taskName 异步任务展示名称 + * @return 异步任务初始进度响应 + */ + private R submitStudentApplyImport(List rows, String bizType, boolean resetBalance, String taskName) { + AsyncTaskProgressVO progress = asyncTaskExecutorService.submit(STUDENT_IMPORT_REDIS_KEY, taskName, rows.size(), + reporter -> saveStudentApplyAndDetails(rows, bizType, resetBalance, reporter)); + return R.ok(progress, "Excel校验通过,已开始后台导入"); + } + + /** + * 在异步事务内创建当前登录人的申请主记录,并批量保存导入明细。 + * + * @param rows 已完成用户定位与字段校验的数据行 + * @param bizType 充值业务类型协议值 + * @param resetBalance 是否为余额重置;为真时明细金额固定为零 + * @param reporter 异步任务进度报告器 + */ + private void saveApplyAndDetails(List rows, String bizType, boolean resetBalance, + AsyncTaskProgressReporter reporter) { + Long applyId = insertApply(bizType); + saveDetailsInBatches(rows.stream() + .map(row -> WalletRechargeImportEntity.builder() + .applyId(applyId) + .price(resetBalance ? 0 : row.getPriceNumber()) + .userId(row.getUserId()) + .userDeptId(row.getUserDeptId()) + .tags(row.getTags()) + // Excel"备注"列随明细落库,审批通过后写入钱包流水 remark + .remark(row.getRemark()) + .build()) + .toList(), reporter); + } + + /** + * 在异步事务内创建学生充值申请主记录,并批量保存导入明细。 + * + * @param rows 已完成学生定位与字段校验的数据行 + * @param bizType 充值业务类型协议值 + * @param resetBalance 是否为余额重置;为真时明细金额固定为零 + * @param reporter 异步任务进度报告器 + */ + private void saveStudentApplyAndDetails(List rows, String bizType, boolean resetBalance, + AsyncTaskProgressReporter reporter) { + Long applyId = insertApply(bizType); + saveDetailsInBatches(rows.stream() + .map(row -> WalletRechargeImportEntity.builder() + .applyId(applyId) + .price(resetBalance ? 0 : row.getPriceNumber()) + .userId(row.getUserId()) + .userDeptId(row.getUserDeptId()) + .tags(row.getTags()) + // Excel"备注"列随明细落库,审批通过后写入钱包流水 remark + .remark(row.getRemark()) + .build()) + .toList(), reporter); + } + + /** + * 在异步事务内创建基础发票并保存未绑定申请的导入明细。 + * + * @param rows 已完成用户定位与字段校验的数据行 + * @param totalPrice 同步阶段汇总的发票总金额(分) + * @param reporter 异步任务进度报告器 + */ + private void saveTopupDetails(List rows, Integer totalPrice, AsyncTaskProgressReporter reporter) { + PjcloudUser loginUser = SecurityUtils.getUser(); WalletRechargeInvoiceEntity invoice = walletRechargeInvoiceService.createBaseInvoice(loginUser, totalPrice); if (invoice == null) { throw new IllegalStateException("导入失败"); } - List details = validatedRows.stream() + saveDetailsInBatches(rows.stream() .map(row -> WalletRechargeImportEntity.builder() .price(row.getPriceNumber()) .userId(row.getUserId()) .userDeptId(row.getUserDeptId()) .invoiceId(invoice.getId()) .tags(row.getTags()) - // Excel"备注"列随明细落库,审批通过后写入钱包流水 remark .remark(row.getRemark()) .build()) - .toList(); - walletRechargeImportService.saveBatch(details); - return R.ok(null, "导入成功"); + .toList(), reporter); } /** - * 创建当前登录人的申请主记录,并批量保存已校验导入明细。 + * 以当前登录人身份创建充值申请主记录,供异步任务内明细挂靠。 * - * @param rows 已完成用户定位与字段校验的数据行 * @param bizType 充值业务类型协议值 - * @param resetBalance 是否为余额重置;为真时明细金额固定为零 - * @return 导入成功结果 + * @return 新建申请主键 */ - private R createApplyAndDetails(List rows, String bizType, boolean resetBalance) { + private Long insertApply(String bizType) { PjcloudUser loginUser = SecurityUtils.getUser(); WalletRechargeApplyEntity apply = new WalletRechargeApplyEntity(); apply.setDeptId(loginUser.getDeptId()); @@ -192,51 +296,22 @@ public class WalletRechargeApplyImportServiceImpl implements WalletRechargeApply if (walletRechargeApplyMapper.insert(apply) <= 0) { throw new IllegalStateException("导入失败"); } - List details = rows.stream() - .map(row -> WalletRechargeImportEntity.builder() - .applyId(apply.getId()) - .price(resetBalance ? 0 : row.getPriceNumber()) - .userId(row.getUserId()) - .userDeptId(row.getUserDeptId()) - .tags(row.getTags()) - // Excel"备注"列随明细落库,审批通过后写入钱包流水 remark - .remark(row.getRemark()) - .build()) - .toList(); - walletRechargeImportService.saveBatch(details); - return R.ok(null, "导入成功"); + return apply.getId(); } /** - * 创建学生充值/扣款申请主记录,并批量保存已解析学生用户的明细。 + * 分批保存导入明细并逐批报告进度;整体处于异步任务的同一事务内,任一批失败全部回滚。 * - * @param rows 已完成学生定位与字段校验的数据行 - * @param bizType 充值业务类型协议值 - * @param resetBalance 是否为余额重置;为真时明细金额固定为零 - * @return 导入成功结果 + * @param details 已组装完毕的导入明细 + * @param reporter 异步任务进度报告器 */ - private R createStudentApplyAndDetails(List rows, String bizType, boolean resetBalance) { - PjcloudUser loginUser = SecurityUtils.getUser(); - WalletRechargeApplyEntity apply = new WalletRechargeApplyEntity(); - apply.setDeptId(loginUser.getDeptId()); - apply.setUserId(loginUser.getId()); - apply.setBizType(bizType); - if (walletRechargeApplyMapper.insert(apply) <= 0) { - throw new IllegalStateException("导入失败"); + private void saveDetailsInBatches(List details, AsyncTaskProgressReporter reporter) { + int processed = 0; + for (List batch : ListUtil.partition(details, SAVE_BATCH_SIZE)) { + walletRechargeImportService.saveBatch(batch, SAVE_BATCH_SIZE); + processed += batch.size(); + reporter.advance("已导入 " + processed + "/" + details.size() + " 行"); } - List details = rows.stream() - .map(row -> WalletRechargeImportEntity.builder() - .applyId(apply.getId()) - .price(resetBalance ? 0 : row.getPriceNumber()) - .userId(row.getUserId()) - .userDeptId(row.getUserDeptId()) - .tags(row.getTags()) - // Excel"备注"列随明细落库,审批通过后写入钱包流水 remark - .remark(row.getRemark()) - .build()) - .toList(); - walletRechargeImportService.saveBatch(details); - return R.ok(null, "导入成功"); } /** @@ -261,7 +336,7 @@ public class WalletRechargeApplyImportServiceImpl implements WalletRechargeApply } /** - * 校验默认用户模板并补齐用户、部门、分值金额及财务标签字典值。 + * 校验默认用户模板并批量补齐用户、部门、分值金额及财务标签字典值。 * * @param rows Excel 原始数据行 * @param bindingResult 公共 Excel 监听器生成的逐行错误 @@ -271,24 +346,25 @@ public class WalletRechargeApplyImportServiceImpl implements WalletRechargeApply @SuppressWarnings("unchecked") private ImmutablePair, List> validateExcelData( List rows, BindingResult bindingResult, boolean validatePrice) { - if (ObjectUtil.isEmpty(rows)) { - throw new IllegalArgumentException("读取excel数据错误"); - } - List effectiveRows = rows.stream() + List errors = getErrorMessageList(bindingResult); + List effectiveRows = rows == null ? List.of() : rows.stream() .filter(row -> !ObjectUtil.isAllEmpty(row.getUsername(), row.getUserMobile(), row.getPrice())) .toList(); if (effectiveRows.isEmpty()) { - throw new IllegalArgumentException("读取excel数据错误"); + // 无有效行时优先回显解析期逐行错误(如字典未配置),确实为空表时给出明确提示 + return ImmutablePair.of(errors.isEmpty() ? List.of(new ErrorMessage(1L, Set.of("Excel内容为空"))) : errors, null); } - List errors = getErrorMessageList(bindingResult); resolveTransactionTagValues(effectiveRows, errors); - List userPhones = effectiveRows.stream().map(WalletRechargeExcelDto::getUserMobile).toList(); + // 预统计手机号出现次数,替代逐行全表扫描的重复校验 + Map phoneCounts = countValues(effectiveRows.stream().map(WalletRechargeExcelDto::getUserMobile).toList()); + // 一次性批量定位全部用户,避免逐行 Feign 导致大文件同步校验超时 + Map> usersByMobile = loadUsersByMobile( + effectiveRows.stream().map(WalletRechargeExcelDto::getUserMobile).toList()); for (WalletRechargeExcelDto row : effectiveRows) { Set rowErrors = new LinkedHashSet<>(); validatePrice(row, validatePrice, rowErrors); - resolveDefaultUser(row, rowErrors); - long phoneCount = userPhones.stream().filter(phone -> ObjectUtil.equals(phone, row.getUserMobile())).count(); - if (ObjectUtil.isNotEmpty(row.getUserMobile()) && phoneCount > 1) { + resolveDefaultUser(row, usersByMobile, rowErrors); + if (ObjectUtil.isNotEmpty(row.getUserMobile()) && phoneCounts.getOrDefault(row.getUserMobile(), 0L) > 1) { rowErrors.add("用户手机号 <" + row.getUserMobile() + "> 重复"); } if (!rowErrors.isEmpty()) { @@ -299,7 +375,7 @@ public class WalletRechargeApplyImportServiceImpl implements WalletRechargeApply } /** - * 校验学生模板并按学校、学号补齐用户及部门信息。 + * 校验学生模板并按学校、学号批量补齐学生用户及部门信息。 * * @param rows 学生 Excel 原始数据行 * @param bindingResult 公共 Excel 监听器生成的逐行错误 @@ -309,27 +385,24 @@ public class WalletRechargeApplyImportServiceImpl implements WalletRechargeApply @SuppressWarnings("unchecked") private ImmutablePair, List> validateStudentExcelData( List rows, BindingResult bindingResult, boolean validatePrice) { - if (ObjectUtil.isEmpty(rows)) { - throw new IllegalArgumentException("读取excel数据错误"); - } - List effectiveRows = rows.stream() + List errors = getErrorMessageList(bindingResult); + List effectiveRows = rows == null ? List.of() : rows.stream() .filter(row -> !ObjectUtil.isAllEmpty(row.getStudentName(), row.getSchoolName(), row.getStudentNo(), row.getPrice())) .toList(); if (effectiveRows.isEmpty()) { - throw new IllegalArgumentException("读取excel数据错误"); + return ImmutablePair.of(errors.isEmpty() ? List.of(new ErrorMessage(1L, Set.of("Excel内容为空"))) : errors, null); } - List errors = getErrorMessageList(bindingResult); resolveStudentTransactionTagValues(effectiveRows, errors); - List studentKeys = effectiveRows.stream() - .map(row -> buildStudentKey(row.getSchoolName(), row.getStudentNo())) - .toList(); + Map studentCounts = countValues(effectiveRows.stream() + .map(row -> buildStudentKey(row.getSchoolName(), row.getStudentNo())).toList()); + Map studentByKey = loadStudentsByKey(effectiveRows); for (WalletStudentRechargeExcelDto row : effectiveRows) { Set rowErrors = new LinkedHashSet<>(); validateStudentPrice(row, validatePrice, rowErrors); - resolveStudentUser(row, rowErrors); + resolveStudentUser(row, studentByKey, rowErrors); String studentKey = buildStudentKey(row.getSchoolName(), row.getStudentNo()); - long studentCount = studentKeys.stream().filter(key -> ObjectUtil.equals(key, studentKey)).count(); - if (ObjectUtil.isAllNotEmpty(row.getSchoolName(), row.getStudentNo()) && studentCount > 1) { + if (ObjectUtil.isAllNotEmpty(row.getSchoolName(), row.getStudentNo()) + && studentCounts.getOrDefault(studentKey, 0L) > 1) { rowErrors.add("学校和学号 <" + row.getSchoolName() + " / " + row.getStudentNo() + "> 在Excel中重复"); } if (!rowErrors.isEmpty()) { @@ -339,6 +412,54 @@ public class WalletRechargeApplyImportServiceImpl implements WalletRechargeApply return errors.isEmpty() ? ImmutablePair.of(null, effectiveRows) : ImmutablePair.of(errors, null); } + /** + * 按手机号去重分批调用远程批量查询,构建手机号到候选用户的映射。 + * + * @param mobiles 当前 Excel 全部行的手机号(允许重复与空值) + * @return 手机号到候选用户列表的映射;查询失败的批次按未命中处理,由行级校验统一提示 + */ + private Map> loadUsersByMobile(List mobiles) { + List distinctMobiles = mobiles.stream() + .filter(Validator::isMobile) + .distinct() + .toList(); + Map> usersByMobile = new HashMap<>(); + for (List batch : ListUtil.partition(distinctMobiles, LOOKUP_BATCH_SIZE)) { + R> response = remoteUserService.getUsersByMobiles(batch); + if (response == null || !response.isOk() || response.getData() == null) { + continue; + } + response.getData().stream() + .filter(user -> ObjectUtil.isNotEmpty(user.getPhone())) + .forEach(user -> usersByMobile.computeIfAbsent(user.getPhone(), key -> new ArrayList<>()).add(user)); + } + return usersByMobile; + } + + /** + * 按学校和学号组合键去重分批调用远程批量查询,构建组合键到学生资料的映射。 + * + * @param rows 学生 Excel 数据行 + * @return 去除首尾空白后的组合键到学生资料的映射;查询失败的批次按未命中处理 + */ + private Map loadStudentsByKey(List rows) { + List keys = rows.stream() + .filter(row -> ObjectUtil.isAllNotEmpty(row.getSchoolName(), row.getStudentNo())) + .map(row -> new StudentSchoolNoDTO(StrUtil.trim(row.getSchoolName()), StrUtil.trim(row.getStudentNo()))) + .distinct() + .toList(); + Map studentByKey = new HashMap<>(); + for (List batch : ListUtil.partition(keys, LOOKUP_BATCH_SIZE)) { + R> response = remoteUserService.getStudentsBySchoolNameAndStudentNoBatch(batch); + if (response == null || !response.isOk() || response.getData() == null) { + continue; + } + response.getData().forEach(match -> studentByKey.put( + buildStudentKey(match.getSchoolName(), match.getStudentNo()), match)); + } + return studentByKey; + } + /** * 校验并将元单位操作金额转换为钱包统一使用的整数分。 * @@ -384,12 +505,13 @@ public class WalletRechargeApplyImportServiceImpl implements WalletRechargeApply } /** - * 按姓名和手机号精确定位默认用户,并把用户及部门 ID 写回导入行。 + * 按姓名和手机号从批量查询结果中精确定位默认用户,并把用户及部门 ID 写回导入行。 * * @param row 当前 Excel 数据行 + * @param usersByMobile 批量查询得到的手机号到候选用户映射 * @param errors 当前行错误集合 */ - private void resolveDefaultUser(WalletRechargeExcelDto row, Set errors) { + private void resolveDefaultUser(WalletRechargeExcelDto row, Map> usersByMobile, Set errors) { if (ObjectUtil.isEmpty(row.getUsername())) { errors.add("请输入用户名"); return; @@ -402,23 +524,28 @@ public class WalletRechargeApplyImportServiceImpl implements WalletRechargeApply errors.add("用户手机号格式不正确"); return; } - R response = remoteUserService.getUserByNameAndMobile(row.getUsername(), row.getUserMobile()); - Optional user = Optional.ofNullable(response).map(R::getData).map(UserInfo::getSysUser); - if (user.isEmpty() || !ObjectUtil.equals(user.get().getName(), row.getUsername())) { + List candidates = usersByMobile.get(StrUtil.trim(row.getUserMobile())); + SysUser matched = candidates == null ? null : candidates.stream() + .filter(user -> ObjectUtil.equals(user.getName(), row.getUsername())) + .findFirst() + .orElse(null); + if (matched == null) { errors.add("用户不存在"); return; } - row.setUserId(user.get().getUserId()); - row.setUserDeptId(user.get().getDeptId()); + row.setUserId(matched.getUserId()); + row.setUserDeptId(matched.getDeptId()); } /** - * 按学校和学号精确定位学生,并校验模板姓名与系统用户一致。 + * 按学校和学号从批量查询结果中精确定位学生,并校验模板姓名与系统用户一致。 * * @param row 当前学生 Excel 数据行 + * @param studentByKey 批量查询得到的组合键到学生资料映射 * @param errors 当前行错误集合 */ - private void resolveStudentUser(WalletStudentRechargeExcelDto row, Set errors) { + private void resolveStudentUser(WalletStudentRechargeExcelDto row, Map studentByKey, + Set errors) { if (ObjectUtil.isEmpty(row.getStudentName())) { errors.add("请输入学生姓名"); } @@ -431,22 +558,22 @@ public class WalletRechargeApplyImportServiceImpl implements WalletRechargeApply if (ObjectUtil.hasEmpty(row.getSchoolName(), row.getStudentNo())) { return; } - R response = remoteUserService.getStudentBySchoolNameAndStudentNo(row.getSchoolName(), row.getStudentNo()); - Optional user = Optional.ofNullable(response).map(R::getData).map(UserInfo::getSysUser); - if (user.isEmpty()) { + StudentUserMatchDTO student = studentByKey.get( + buildStudentKey(StrUtil.trim(row.getSchoolName()), StrUtil.trim(row.getStudentNo()))); + if (student == null) { errors.add("学生不存在"); return; } - if (!ObjectUtil.equals(user.get().getName(), row.getStudentName())) { + if (!ObjectUtil.equals(student.getName(), row.getStudentName())) { errors.add("学生姓名与学校、学号不匹配"); return; } - row.setUserId(user.get().getUserId()); - row.setUserDeptId(user.get().getDeptId()); + row.setUserId(student.getUserId()); + row.setUserDeptId(student.getDeptId()); } /** - * 生成学校和学号组合键,仅用于当前学生 Excel 内重复校验。 + * 生成学校和学号组合键,仅用于当前学生 Excel 内重复校验与批量查询映射。 * * @param schoolName 学校名称 * @param studentNo 学号 @@ -456,6 +583,18 @@ public class WalletRechargeApplyImportServiceImpl implements WalletRechargeApply return Objects.toString(schoolName, "") + "\u0000" + Objects.toString(studentNo, ""); } + /** + * 统计非空文本在 Excel 中的出现次数,用于重复项校验。 + * + * @param values 待统计文本列表 + * @return 文本到出现次数的映射 + */ + private Map countValues(List values) { + return values.stream() + .filter(StrUtil::isNotBlank) + .collect(Collectors.groupingBy(Function.identity(), Collectors.counting())); + } + /** * 获取公共 Excel 解析器写入 BindingResult 的逐行错误集合。 * @@ -465,7 +604,7 @@ public class WalletRechargeApplyImportServiceImpl implements WalletRechargeApply @SuppressWarnings("unchecked") private List getErrorMessageList(BindingResult bindingResult) { Object target = bindingResult == null ? null : bindingResult.getTarget(); - return target instanceof List ? (List) target : new java.util.ArrayList<>(); + return target instanceof List ? (List) target : new ArrayList<>(); } /** diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyImportServiceImplTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyImportServiceImplTest.java new file mode 100644 index 000000000..cde4e9bb6 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyImportServiceImplTest.java @@ -0,0 +1,379 @@ +package com.bsd.pjcloud.wallet.service.impl.recharge; + +import com.bsd.pjcloud.admin.api.dto.StudentSchoolNoDTO; +import com.bsd.pjcloud.admin.api.dto.StudentUserMatchDTO; +import com.bsd.pjcloud.admin.api.entity.SysUser; +import com.bsd.pjcloud.admin.api.feign.RemoteDictService; +import com.bsd.pjcloud.admin.api.feign.RemoteUserService; +import com.bsd.pjcloud.common.asynctask.service.AsyncTaskAction; +import com.bsd.pjcloud.common.asynctask.service.AsyncTaskExecutorService; +import com.bsd.pjcloud.common.asynctask.service.AsyncTaskProgressReporter; +import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.excel.vo.ErrorMessage; +import com.bsd.pjcloud.common.security.service.PjcloudUser; +import com.bsd.pjcloud.wallet.api.dto.recharge.excel.WalletRechargeExcelDto; +import com.bsd.pjcloud.wallet.api.dto.recharge.excel.WalletStudentRechargeExcelDto; +import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeApplyEntity; +import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeImportEntity; +import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeInvoiceEntity; +import com.bsd.pjcloud.wallet.mapper.recharge.WalletRechargeApplyMapper; +import com.bsd.pjcloud.wallet.service.WalletRechargeImportService; +import com.bsd.pjcloud.wallet.service.WalletRechargeInvoiceService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.validation.BindingResult; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 充值导入服务同步校验与异步任务提交测试。 + *

覆盖批量用户定位、逐行错误回显、空表显式提示和异步动作事务内落库行为。

+ */ +@ExtendWith(MockitoExtension.class) +class WalletRechargeApplyImportServiceImplTest { + + /** 充值申请 Mapper Mock。 */ + @Mock + private WalletRechargeApplyMapper walletRechargeApplyMapper; + + /** 充值导入明细服务 Mock。 */ + @Mock + private WalletRechargeImportService walletRechargeImportService; + + /** 充值发票服务 Mock。 */ + @Mock + private WalletRechargeInvoiceService walletRechargeInvoiceService; + + /** 用户远程服务 Mock。 */ + @Mock + private RemoteUserService remoteUserService; + + /** 字典远程服务 Mock。 */ + @Mock + private RemoteDictService remoteDictService; + + /** 公共异步任务执行器 Mock。 */ + @Mock + private AsyncTaskExecutorService asyncTaskExecutorService; + + /** 异步动作保存明细的参数捕获器。 */ + @Captor + private ArgumentCaptor> detailsCaptor; + + /** 被测充值导入服务。 */ + @InjectMocks + private WalletRechargeApplyImportServiceImpl service; + + /** + * 清理用例写入的安全上下文,避免登录人状态串扰后续用例。 + */ + @AfterEach + void clearSecurityContext() { + SecurityContextHolder.clearContext(); + } + + /** 桩执行器捕获到的异步业务动作,由用例在校验通过后手动驱动。 */ + private AsyncTaskAction submittedAction; + + /** + * 构造一个姓名、手机号齐全的默认用户导入行。 + * + * @param lineNum Excel 行号 + * @param name 用户姓名 + * @param mobile 用户手机号 + * @return 未做用户定位的导入行 + */ + private WalletRechargeExcelDto buildDefaultRow(long lineNum, String name, String mobile) { + WalletRechargeExcelDto row = new WalletRechargeExcelDto(); + row.setLineNum(lineNum); + row.setUsername(name); + row.setUserMobile(mobile); + row.setPrice("100"); + return row; + } + + /** + * 构造一个已定位的系统用户。 + * + * @param userId 用户 ID + * @param deptId 部门 ID + * @param name 姓名 + * @param phone 手机号 + * @return 系统用户实体 + */ + private SysUser buildSysUser(Long userId, Long deptId, String name, String phone) { + SysUser user = new SysUser(); + user.setUserId(userId); + user.setDeptId(deptId); + user.setName(name); + user.setPhone(phone); + return user; + } + + /** + * 构造一个不携带解析错误的 BindingResult。 + * + * @return 目标为空错误列表的 BindingResult + */ + private BindingResult emptyBindingResult() { + BindingResult bindingResult = mock(BindingResult.class); + when(bindingResult.getTarget()).thenReturn(new ArrayList<>()); + return bindingResult; + } + + /** + * 构造固定登录人并写入安全上下文,供异步动作内创建申请或发票使用。 + */ + private void loginAsImporter() { + // User 父类要求用户名和密码非空,占位密码仅满足构造校验 + PjcloudUser loginUser = new PjcloudUser(9L, "admin", 8L, null, null, null, null, null, null, "N/A", + true, true, null, true, true, List.of()); + SecurityContextHolder.getContext() + .setAuthentication(new UsernamePasswordAuthenticationToken(loginUser, "N/A")); + } + + /** + * 桩化异步执行器:记录提交的业务动作但不执行,供用例手动驱动并断言。 + * + * @param redisKey 期望的业务命名空间 + * @param taskName 期望的任务名 + * @param total 期望的总数 + */ + private void stubSubmitCapturingAction(String redisKey, String taskName, int total) { + when(asyncTaskExecutorService.submit(eq(redisKey), eq(taskName), eq(total), any())) + .thenAnswer(invocation -> { + submittedAction = invocation.getArgument(3, AsyncTaskAction.class); + return new AsyncTaskProgressVO(); + }); + } + + /** + * 有效行校验通过后应立即返回异步任务进度,且同步阶段不落库。 + */ + @Test + void importDefaultValidRowsSubmitsAsyncTask() { + WalletRechargeExcelDto row = buildDefaultRow(2L, "张三", "13800000000"); + when(remoteUserService.getUsersByMobiles(List.of("13800000000"))) + .thenReturn(R.ok(List.of(buildSysUser(1L, 2L, "张三", "13800000000")))); + AsyncTaskProgressVO progress = new AsyncTaskProgressVO(); + when(asyncTaskExecutorService.submit(eq("wallet:recharge:import:default"), eq("默认用户充值导入"), eq(1), any())) + .thenReturn(progress); + + R response = service.importDefault(List.of(row), emptyBindingResult(), "1"); + + assertTrue(response.isOk()); + assertEquals(progress, response.getData()); + verify(walletRechargeApplyMapper, never()).insert(any(WalletRechargeApplyEntity.class)); + verify(asyncTaskExecutorService).submit(eq("wallet:recharge:import:default"), eq("默认用户充值导入"), eq(1), any()); + } + + /** + * 异步动作应在同一事务内创建申请并按批保存明细,逐批报告进度。 + */ + @Test + void importDefaultAsyncActionSavesApplyAndDetails() { + WalletRechargeExcelDto row = buildDefaultRow(2L, "张三", "13800000000"); + when(remoteUserService.getUsersByMobiles(List.of("13800000000"))) + .thenReturn(R.ok(List.of(buildSysUser(1L, 2L, "张三", "13800000000")))); + stubSubmitCapturingAction("wallet:recharge:import:default", "默认用户充值导入", 1); + service.importDefault(List.of(row), emptyBindingResult(), "1"); + assertNotNull(submittedAction); + + loginAsImporter(); + when(walletRechargeApplyMapper.insert(any(WalletRechargeApplyEntity.class))).thenReturn(1); + AsyncTaskProgressReporter reporter = mock(AsyncTaskProgressReporter.class); + submittedAction.execute(reporter); + + verify(walletRechargeImportService).saveBatch(detailsCaptor.capture(), eq(500)); + List details = detailsCaptor.getValue(); + assertEquals(1, details.size()); + assertEquals(10000, details.get(0).getPrice()); + assertEquals(1L, details.get(0).getUserId()); + assertEquals(2L, details.get(0).getUserDeptId()); + verify(reporter).advance("已导入 1/1 行"); + } + + /** + * 批量定位未命中时应同步返回行级错误且不创建异步任务。 + */ + @Test + void importDefaultUnknownUserReturnsRowErrors() { + WalletRechargeExcelDto row = buildDefaultRow(2L, "张三", "13800000000"); + when(remoteUserService.getUsersByMobiles(List.of("13800000000"))).thenReturn(R.ok(List.of())); + + R response = service.importDefault(List.of(row), emptyBindingResult(), "1"); + + assertFalse(response.isOk()); + List errors = assertInstanceOf(List.class, response.getData()); + assertTrue(errors.stream().anyMatch(error -> error.getErrors().contains("用户不存在"))); + verify(asyncTaskExecutorService, never()).submit(any(), any(), anyInt(), any()); + } + + /** + * 批量查询远程失败时应按未命中处理,同样返回行级错误。 + */ + @Test + void importDefaultLookupFailureReturnsRowErrors() { + WalletRechargeExcelDto row = buildDefaultRow(2L, "张三", "13800000000"); + when(remoteUserService.getUsersByMobiles(List.of("13800000000"))).thenReturn(R.failed("服务不可用")); + + R response = service.importDefault(List.of(row), emptyBindingResult(), "1"); + + assertFalse(response.isOk()); + List errors = assertInstanceOf(List.class, response.getData()); + assertTrue(errors.stream().anyMatch(error -> error.getErrors().contains("用户不存在"))); + verify(asyncTaskExecutorService, never()).submit(any(), any(), anyInt(), any()); + } + + /** + * Excel 内同一手机号重复出现时应在两行同时提示重复。 + */ + @Test + void importDefaultDuplicateMobileReturnsRowErrors() { + when(remoteUserService.getUsersByMobiles(List.of("13800000000"))) + .thenReturn(R.ok(List.of(buildSysUser(1L, 2L, "张三", "13800000000")))); + + R response = service.importDefault( + List.of(buildDefaultRow(2L, "张三", "13800000000"), buildDefaultRow(3L, "张三", "13800000000")), + emptyBindingResult(), "1"); + + assertFalse(response.isOk()); + List errors = assertInstanceOf(List.class, response.getData()); + assertEquals(2, errors.size()); + assertTrue(errors.stream().allMatch(error -> error.getErrors().contains("用户手机号 <13800000000> 重复"))); + } + + /** + * 空文件应返回明确的“Excel内容为空”提示,而不是抛出通用读取异常。 + */ + @Test + void importDefaultEmptyRowsReturnsExplicitError() { + R response = service.importDefault(List.of(), emptyBindingResult(), "1"); + + assertFalse(response.isOk()); + List errors = assertInstanceOf(List.class, response.getData()); + assertTrue(errors.get(0).getErrors().contains("Excel内容为空")); + } + + /** + * 全部行被解析监听器拒绝时应回显监听器错误,而不是覆盖为通用提示。 + */ + @Test + void importDefaultListenerErrorsAreReturnedVerbatim() { + BindingResult bindingResult = mock(BindingResult.class); + List listenerErrors = new ArrayList<>(); + listenerErrors.add(new ErrorMessage(2L, Set.of("操作标签字典查询失败"))); + when(bindingResult.getTarget()).thenReturn(listenerErrors); + + R response = service.importDefault(List.of(), bindingResult, "1"); + + assertFalse(response.isOk()); + assertEquals(listenerErrors, response.getData()); + } + + /** + * 学生导入应按学校学号批量定位并使用学生任务命名空间。 + */ + @Test + void importStudentValidRowsSubmitsAsyncTask() { + WalletStudentRechargeExcelDto row = new WalletStudentRechargeExcelDto(); + row.setLineNum(2L); + row.setStudentName("李四"); + row.setSchoolName("第一中学"); + row.setStudentNo("S001"); + row.setPrice("50"); + StudentUserMatchDTO match = new StudentUserMatchDTO(); + match.setSchoolName("第一中学"); + match.setStudentNo("S001"); + match.setUserId(11L); + match.setDeptId(12L); + match.setName("李四"); + when(remoteUserService.getStudentsBySchoolNameAndStudentNoBatch(List.of(new StudentSchoolNoDTO("第一中学", "S001")))) + .thenReturn(R.ok(List.of(match))); + when(asyncTaskExecutorService.submit(eq("wallet:recharge:import:student"), eq("学生充值导入"), eq(1), any())) + .thenReturn(new AsyncTaskProgressVO()); + + R response = service.importStudent(List.of(row), emptyBindingResult(), "1"); + + assertTrue(response.isOk()); + verify(asyncTaskExecutorService).submit(eq("wallet:recharge:import:student"), eq("学生充值导入"), eq(1), any()); + } + + /** + * 学生姓名与学校学号不匹配时应返回行级错误且不创建异步任务。 + */ + @Test + void importStudentNameMismatchReturnsRowErrors() { + WalletStudentRechargeExcelDto row = new WalletStudentRechargeExcelDto(); + row.setLineNum(2L); + row.setStudentName("王五"); + row.setSchoolName("第一中学"); + row.setStudentNo("S001"); + row.setPrice("50"); + StudentUserMatchDTO match = new StudentUserMatchDTO(); + match.setSchoolName("第一中学"); + match.setStudentNo("S001"); + match.setUserId(11L); + match.setName("李四"); + when(remoteUserService.getStudentsBySchoolNameAndStudentNoBatch(List.of(new StudentSchoolNoDTO("第一中学", "S001")))) + .thenReturn(R.ok(List.of(match))); + + R response = service.importStudent(List.of(row), emptyBindingResult(), "1"); + + assertFalse(response.isOk()); + List errors = assertInstanceOf(List.class, response.getData()); + assertTrue(errors.stream().anyMatch(error -> error.getErrors().contains("学生姓名与学校、学号不匹配"))); + verify(asyncTaskExecutorService, never()).submit(any(), any(), anyInt(), any()); + } + + /** + * 发票充值导入的异步动作应在事务内创建发票并保存未绑定申请的明细。 + */ + @Test + void importTopupAsyncActionCreatesInvoiceAndDetails() { + WalletRechargeExcelDto row = buildDefaultRow(2L, "张三", "13800000000"); + when(remoteUserService.getUsersByMobiles(List.of("13800000000"))) + .thenReturn(R.ok(List.of(buildSysUser(1L, 2L, "张三", "13800000000")))); + stubSubmitCapturingAction("wallet:recharge:import:default", "发票充值导入", 1); + service.importTopup(List.of(row), emptyBindingResult()); + assertNotNull(submittedAction); + + loginAsImporter(); + WalletRechargeInvoiceEntity invoice = new WalletRechargeInvoiceEntity(); + invoice.setId(7L); + when(walletRechargeInvoiceService.createBaseInvoice(any(), eq(10000))).thenReturn(invoice); + + submittedAction.execute(mock(AsyncTaskProgressReporter.class)); + + verify(walletRechargeImportService).saveBatch(detailsCaptor.capture(), eq(500)); + List details = detailsCaptor.getValue(); + assertEquals(1, details.size()); + assertEquals(7L, details.get(0).getInvoiceId()); + assertEquals(10000, details.get(0).getPrice()); + assertEquals(1L, details.get(0).getUserId()); + } +}