diff --git a/db/20260817_add_sys_user_student_guardian_phones.sql b/db/20260817_add_sys_user_student_guardian_phones.sql new file mode 100644 index 00000000..fbe2d521 --- /dev/null +++ b/db/20260817_add_sys_user_student_guardian_phones.sql @@ -0,0 +1,49 @@ +-- 为学生扩展表补充监护人手机号集合,并兼容已经存在学生、用户关系的历史环境。 +-- guardian_phones 保存当前未逻辑删除的 GUARDIAN_WARD 关系中全部监护人手机号,按关系创建顺序去重并使用英文逗号分隔。 + +SET @guardian_phones_column_exists = ( + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'sys_user_student' + AND column_name = 'guardian_phones' +); + +SET @guardian_phones_ddl = IF( + @guardian_phones_column_exists = 0, + 'ALTER TABLE `sys_user_student` ADD COLUMN `guardian_phones` text DEFAULT NULL COMMENT ''监护人手机号集合,按监护关系创建顺序去重并使用英文逗号分隔'' AFTER `class_name`', + 'SELECT ''sys_user_student.guardian_phones already exists''' +); + +PREPARE guardian_phones_statement FROM @guardian_phones_ddl; +EXECUTE guardian_phones_statement; +DEALLOCATE PREPARE guardian_phones_statement; + +SET @previous_group_concat_max_len = @@SESSION.group_concat_max_len; +SET SESSION group_concat_max_len = 65535; + +UPDATE `sys_user_student` AS student +LEFT JOIN ( + SELECT relation.tenant_id, + relation.object_user_id AS student_user_id, + GROUP_CONCAT(DISTINCT TRIM(guardian.phone) ORDER BY relation.relation_id SEPARATOR ',') AS guardian_phones + FROM `sys_user_relation` AS relation + INNER JOIN `sys_user_relation_type` AS relation_type + ON relation_type.relation_type_id = relation.relation_type_id + AND relation_type.tenant_id = relation.tenant_id + AND COALESCE(relation_type.del_flag, '0') = '0' + AND relation_type.relation_code = 'GUARDIAN_WARD' + INNER JOIN `sys_user` AS guardian + ON guardian.user_id = relation.subject_user_id + AND guardian.tenant_id = relation.tenant_id + AND COALESCE(guardian.del_flag, '0') = '0' + AND guardian.phone IS NOT NULL + AND TRIM(guardian.phone) <> '' + WHERE COALESCE(relation.del_flag, '0') = '0' + GROUP BY relation.tenant_id, relation.object_user_id +) AS guardian_relation + ON guardian_relation.student_user_id = student.user_id + AND guardian_relation.tenant_id = student.tenant_id +SET student.guardian_phones = guardian_relation.guardian_phones; + +SET SESSION group_concat_max_len = @previous_group_concat_max_len; diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/user/SysUserStudentDto.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/user/SysUserStudentDto.java index b73a45c4..e664c56b 100644 --- a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/user/SysUserStudentDto.java +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/user/SysUserStudentDto.java @@ -19,6 +19,12 @@ public class SysUserStudentDto { @Schema(description = "班级名称") private String className; + /** + * 监护人手机号集合,多个手机号使用英文逗号分隔;表单提交后会去重并规范化。 + */ + @Schema(description = "监护人手机号集合,多个手机号使用英文逗号分隔") + private String guardianPhones; + @Schema(description = "其他非核心扩展信息") private String extJson; } diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/entity/user/SysUserStudentEntity.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/entity/user/SysUserStudentEntity.java index 43c14c77..9c10a165 100755 --- a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/entity/user/SysUserStudentEntity.java +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/entity/user/SysUserStudentEntity.java @@ -44,6 +44,12 @@ public class SysUserStudentEntity extends Model { @Schema(description = "班级名称") private String className; + /** + * 监护人手机号集合,表单可直接维护;监护关系变更时按关系创建顺序重算,多个手机号使用英文逗号分隔。 + */ + @Schema(description = "监护人手机号集合,多个手机号使用英文逗号分隔") + private String guardianPhones; + /** * 其他非核心扩展信息 */ @@ -69,4 +75,4 @@ public class SysUserStudentEntity extends Model { */ @Schema(description = "所属租户ID") private Long tenantId; -} \ No newline at end of file +} 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 e3069f1b..268ac94f 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 @@ -80,6 +80,17 @@ public interface RemoteUserService { @GetMapping("/user/get-name-and-mobile") R getUserByNameAndMobile(@RequestParam("name") String name, @RequestParam("mobile") String mobile); + /** + * 根据学生学校和学号精确查询学生用户。 + * + * @param schoolName 学生身份扩展信息中的学校名称 + * @param studentNo 学生身份扩展信息中的学号 + * @return 唯一学生的用户信息;不存在或数据不唯一时返回失败响应 + */ + @GetMapping("/user/get-student-by-school-and-no") + R getStudentBySchoolNameAndStudentNo(@RequestParam("schoolName") String schoolName, + @RequestParam("studentNo") String studentNo); + /** * 根据用户ID获取用户 */ diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysFileController.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysFileController.java index 79075bee..73a788cf 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysFileController.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysFileController.java @@ -31,7 +31,9 @@ import com.bsd.pjcloud.common.excel.annotation.ExcelTemplate; import com.bsd.pjcloud.common.log.annotation.SysLog; import com.bsd.pjcloud.common.security.annotation.HasPermission; import com.bsd.pjcloud.common.security.annotation.Inner; +import com.bsd.pjcloud.wallet.api.dto.WalletStudentImportActiveDto; import com.bsd.pjcloud.wallet.api.dto.recharge.excel.WalletRechargeExcelDto; +import com.bsd.pjcloud.wallet.api.dto.recharge.excel.WalletStudentRechargeExcelDto; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; @@ -153,7 +155,7 @@ public class SysFileController { * @param response 本地文件 */ @SneakyThrows - @ExcelTemplate(WalletRechargeExcelDto.class) + @ExcelTemplate({WalletRechargeExcelDto.class, WalletStudentImportActiveDto.class, WalletStudentRechargeExcelDto.class}) @GetMapping("/local/file/{fileName}") public void localFile(@PathVariable String fileName, HttpServletResponse response) { ClassPathResource resource = new ClassPathResource("file/" + fileName); 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 0db02629..e634a765 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 @@ -35,6 +35,7 @@ import com.bsd.pjcloud.admin.api.entity.SysDept; import com.bsd.pjcloud.admin.api.entity.SysRole; import com.bsd.pjcloud.admin.api.entity.SysUser; import com.bsd.pjcloud.admin.api.entity.SysUserRole; +import com.bsd.pjcloud.admin.api.entity.user.SysUserStudentEntity; import com.bsd.pjcloud.admin.api.entity.user.SysUserTagEntity; import com.bsd.pjcloud.admin.api.vo.*; import com.bsd.pjcloud.admin.service.*; @@ -108,6 +109,8 @@ public class SysUserController { private final SysRoleService sysRoleService; private final SysUserRoleService sysUserRoleService; + /** 学生身份服务,用于跨服务开卡导入按学校和学号精确定位学生。 */ + private final SysUserStudentService sysUserStudentService; private final SysMessageService sysMessageService; /* ************************************* start 回调 start ************************************* */ @@ -154,6 +157,23 @@ public class SysUserController { return R.ok(userService.findUserInfo(user)); } + /** + * 根据学校和学号精确查询学生用户,供钱包学生开卡导入校验使用。 + * + * @param schoolName 学生身份扩展信息中的学校名称 + * @param studentNo 学生身份扩展信息中的学号 + * @return 唯一学生用户;不存在时返回失败响应 + */ + @GetMapping("/get-student-by-school-and-no") + public R getStudentBySchoolNameAndStudentNo(@RequestParam String schoolName, @RequestParam String studentNo) { + SysUserStudentEntity student = sysUserStudentService.findBySchoolNameAndStudentNo(schoolName, studentNo); + if (student == null) { + return R.failed("学生不存在"); + } + SysUser user = userService.getById(student.getUserId()); + return user == null ? R.failed("学生用户不存在") : R.ok(userService.findUserInfo(user)); + } + @Inner @GetMapping("/get-name-or-mobile") public R> getUserByNameOrMobile(@RequestParam("keyword") String keyword) { diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/user/SysUserRelationController.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/user/SysUserRelationController.java index 4464d577..64739e58 100755 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/user/SysUserRelationController.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/user/SysUserRelationController.java @@ -144,4 +144,18 @@ public class SysUserRelationController { return R.ok(sysUserRelationService.updateRelation(dto)); } + /** + * 删除指定用户关系,仅解除两个用户之间的关系,不删除关系两端的用户。 + * + * @param relationId 待删除的关系ID + * @return 已删除的关系ID + */ + @Operation(summary = "删除用户关系", description = "按关系ID解除两个用户之间的关系,不删除用户") + @SysLog("删除用户关系") + @DeleteMapping("/{relationId}") + // @PreAuthorize("@pms.hasPermission('dzc_sysUserRelation_del')") + public R deleteRelation(@PathVariable Long relationId) { + return R.ok(sysUserRelationService.deleteRelation(relationId)); + } + } diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserRelationService.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserRelationService.java index 56c7009d..2c6f884f 100755 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserRelationService.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserRelationService.java @@ -71,6 +71,20 @@ public interface SysUserRelationService extends IService */ List listActiveGuardianWardOptions(Long guardianUserId); + /** + * 根据当前未删除的监护关系重新生成指定学生的全部监护人手机号。 + * + * @param studentUserIds 待同步的学生用户 ID 列表;非学生用户会被忽略 + */ + void syncStudentGuardianPhones(List studentUserIds); + + /** + * 监护人手机号变化后,重新生成其全部被监护学生的手机号集合。 + * + * @param guardianUserId 手机号发生变化的监护人用户 ID + */ + void syncGuardianRelatedStudentPhones(Long guardianUserId); + /** * 删除指定用户作为关系任一端点的全部用户关系 * @@ -101,4 +115,12 @@ public interface SysUserRelationService extends IService * @return 关系ID */ Long updateRelation(SysUserRelationUpdateDto dto); + + /** + * 按关系ID删除用户关系,仅解除关系,不删除关系两端的用户。 + * + * @param relationId 待删除的关系ID + * @return 已删除的关系ID + */ + Long deleteRelation(Long relationId); } diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserService.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserService.java index 84380c10..ca3b0fd8 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserService.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserService.java @@ -69,12 +69,18 @@ public interface SysUserService extends IService { Boolean deleteUserByIds(Long[] ids); /** - * 更新当前用户基本信息 + * 更新当前用户基本信息;手机号变化时同步刷新其被监护学生的监护人手机号集合。 + * + * @param userDto 当前用户提交的基础资料 + * @return 更新结果 */ R updateUserInfo(UserDTO userDto); /** - * 更新指定用户信息 + * 更新指定用户信息,并同步维护学生身份及监护关系派生的手机号集合。 + * + * @param userDto 待更新用户的完整资料 + * @return 是否更新成功 */ Boolean updateUser(UserDTO userDto); diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserStudentService.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserStudentService.java index 13f2467d..9d8210d6 100755 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserStudentService.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserStudentService.java @@ -3,6 +3,7 @@ package com.bsd.pjcloud.admin.service; import com.baomidou.mybatisplus.extension.service.IService; import com.bsd.pjcloud.admin.api.entity.user.SysUserStudentEntity; +/** 学生用户扩展信息服务,负责学生身份资料的唯一性校验与精确查询。 */ public interface SysUserStudentService extends IService { /** @@ -14,4 +15,23 @@ public interface SysUserStudentService extends IService { * @return 是否存在其他相同学校、相同学号的学生 */ boolean existsStudentNo(Long userId, String schoolName, String studentNo); + + /** + * 更新指定学生由监护关系派生的手机号集合,允许传空以清除已经解除的监护关系数据。 + * + * @param userId 学生用户 ID + * @param guardianPhones 按关系顺序去重后的监护人手机号,英文逗号分隔;无有效值时为空 + * @return 学生扩展记录是否执行了更新;用户不是学生时返回 {@code false} + */ + boolean updateGuardianPhones(Long userId, String guardianPhones); + + /** + * 按学校和学号查询唯一学生身份。 + * + * @param schoolName 学校名称,调用前后均会去除首尾空白 + * @param studentNo 学号,调用前后均会去除首尾空白 + * @return 唯一学生身份;条件为空或不存在时返回 {@code null} + * @throws IllegalStateException 历史数据中存在多个相同学校、相同学号的学生时抛出 + */ + SysUserStudentEntity findBySchoolNameAndStudentNo(String schoolName, String studentNo); } diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserImportExportServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserImportExportServiceImpl.java index 8ca362e6..f3ff40a5 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserImportExportServiceImpl.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserImportExportServiceImpl.java @@ -752,7 +752,9 @@ public class SysUserImportExportServiceImpl implements SysUserImportExportServic studentInfo.setStudentNo(excel.getStudentNo()); studentInfo.setSchoolName(excel.getSchoolName()); studentInfo.setClassName(excel.getClassName()); + studentInfo.setGuardianPhones(excel.getGuardianPhone()); relationUser.setStudentInfo(studentInfo); + // 先满足学生身份的必填校验,关系落库后再由关系服务按实际关系统一重算 guardian_phones。 sysUserRelationService.createUserAndRelation(relationUser); } diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java index d9ab1f76..01d2d46b 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java @@ -480,7 +480,14 @@ public class SysUserServiceImpl extends ServiceImpl impl return Boolean.TRUE; } + /** + * 更新当前登录用户的基础资料,手机号成功变更后同步刷新其被监护学生的手机号集合。 + * + * @param userDto 当前用户提交的基础资料 + * @return 更新结果;姓名或昵称校验失败时返回失败响应 + */ @Override + @Transactional(rollbackFor = Exception.class) @CacheEvict(value = CacheConstants.USER_DETAILS, key = "#userDto.username") public R updateUserInfo(UserDTO userDto) { if (StrUtil.isBlank(userDto.getUsername())) { @@ -505,7 +512,11 @@ public class SysUserServiceImpl extends ServiceImpl impl if (StrUtil.isNotBlank(userDto.getPhone()) && !userDto.getPhone().contains("*")) { sysUser.setPhone(userDto.getPhone()); } - return R.ok(this.updateById(sysUser)); + boolean updated = this.updateById(sysUser); + if (updated && sysUser.getPhone() != null) { + sysUserRelationService.syncGuardianRelatedStudentPhones(userId); + } + return R.ok(updated); } /** @@ -571,6 +582,13 @@ public class SysUserServiceImpl extends ServiceImpl impl .set(SysUser::getPhone, null)); } sysUserIdentityService.saveUserIdentity(userDto.getUserId(), userDto); + String persistedPhone = userDto.getIdentityTypes() != null + && !userDto.getIdentityTypes().contains(UserIdentityTypeEnum.DEFAULT.getType()) + ? null : (sysUser.getPhone() == null ? dbUser.getPhone() : sysUser.getPhone()); + if (!Objects.equals(StrUtil.trim(dbUser.getPhone()), StrUtil.trim(persistedPhone))) { + // 已建立监护关系的用户手机号变化时,必须同步其全部被监护学生。 + sysUserRelationService.syncGuardianRelatedStudentPhones(userId); + } // 更新用户角色表 if (userDto.getRole() != null) { diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/user/SysUserIdentityServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/user/SysUserIdentityServiceImpl.java index 434e176e..185a4ead 100755 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/user/SysUserIdentityServiceImpl.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/user/SysUserIdentityServiceImpl.java @@ -22,6 +22,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.beans.BeanUtils; import org.springframework.stereotype.Service; +import java.util.Arrays; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; @@ -45,9 +46,15 @@ public class SysUserIdentityServiceImpl extends ServiceImpl guardianPhones = Arrays.stream(StrUtil.blankToDefault(submittedGuardianPhones, "") + .split(GUARDIAN_PHONE_SPLIT_PATTERN)) + .map(StrUtil::trim) + .filter(StrUtil::isNotBlank) + .distinct() + .toList(); + if (guardianPhones.isEmpty()) { + throw new IllegalArgumentException("监护人手机号不能为空"); + } + String invalidGuardianPhone = guardianPhones.stream() + .filter(phone -> !phone.matches(PHONE_PATTERN)) + .findFirst() + .orElse(null); + if (invalidGuardianPhone != null) { + throw new IllegalArgumentException("监护人手机号" + invalidGuardianPhone + "格式不正确"); + } String studentNo = studentInfo.getStudentNo().trim(); String schoolName = studentInfo.getSchoolName().trim(); if (studentNo.length() > STUDENT_NO_MAX_LENGTH) { @@ -254,6 +283,7 @@ public class SysUserIdentityServiceImpl extends ServiceImpl关系有效期用于消息等时点授权,手机号集合表达后台已经建立的逻辑绑定,因此这里不随时间自动增删; + * 关系改向、改类型或删除时会立即整体重算,避免保留旧手机号。

+ * + * @param studentUserIds 待同步的学生用户 ID 列表;非学生用户会被学生扩展表更新自然忽略 + */ + @Override + public void syncStudentGuardianPhones(List studentUserIds) { + if (CollUtil.isEmpty(studentUserIds)) { + return; + } + List distinctStudentUserIds = studentUserIds.stream() + .filter(Objects::nonNull) + .distinct() + .toList(); + if (distinctStudentUserIds.isEmpty()) { + return; + } + + List guardianWardRelationTypeIds = listGuardianWardRelationTypeIds(); + List guardianRelations = guardianWardRelationTypeIds.isEmpty() ? List.of() + : list(Wrappers.lambdaQuery() + .in(SysUserRelationEntity::getObjectUserId, distinctStudentUserIds) + .in(SysUserRelationEntity::getRelationTypeId, guardianWardRelationTypeIds) + .orderByAsc(SysUserRelationEntity::getRelationId)); + List guardianUserIds = guardianRelations.stream() + .map(SysUserRelationEntity::getSubjectUserId) + .filter(Objects::nonNull) + .distinct() + .toList(); + Map guardianUserMap = guardianUserIds.isEmpty() ? Map.of() + : sysUserService.listByIds(guardianUserIds).stream() + .collect(Collectors.toMap(SysUser::getUserId, Function.identity(), (first, second) -> first)); + Map> guardianPhonesByStudent = new HashMap<>(); + for (SysUserRelationEntity guardianRelation : guardianRelations) { + SysUser guardianUser = guardianUserMap.get(guardianRelation.getSubjectUserId()); + String guardianPhone = guardianUser == null ? null : StrUtil.trim(guardianUser.getPhone()); + if (StrUtil.isBlank(guardianPhone)) { + continue; + } + guardianPhonesByStudent.computeIfAbsent( + guardianRelation.getObjectUserId(), ignored -> new LinkedHashSet<>()).add(guardianPhone); + } + for (Long studentUserId : distinctStudentUserIds) { + Set guardianPhones = guardianPhonesByStudent.getOrDefault(studentUserId, new LinkedHashSet<>()); + sysUserStudentService.updateGuardianPhones( + studentUserId, guardianPhones.isEmpty() ? null : String.join(GUARDIAN_PHONE_SEPARATOR, guardianPhones)); + } + } + + /** + * 监护人的手机号发生变化后,重新生成其全部被监护学生的手机号集合。 + * + * @param guardianUserId 手机号发生变化的监护人用户 ID + */ + @Override + public void syncGuardianRelatedStudentPhones(Long guardianUserId) { + if (guardianUserId == null) { + return; + } + List guardianWardRelationTypeIds = listGuardianWardRelationTypeIds(); + if (guardianWardRelationTypeIds.isEmpty()) { + return; + } + List studentUserIds = list(Wrappers.lambdaQuery() + .eq(SysUserRelationEntity::getSubjectUserId, guardianUserId) + .in(SysUserRelationEntity::getRelationTypeId, guardianWardRelationTypeIds) + .orderByAsc(SysUserRelationEntity::getRelationId)) + .stream() + .map(SysUserRelationEntity::getObjectUserId) + .filter(Objects::nonNull) + .distinct() + .toList(); + syncStudentGuardianPhones(studentUserIds); + } + + /** + * 删除用户两端的全部关系,并在关系删除后清除或重算受影响学生的监护人手机号。 + * + * @param userIds 待删除的用户 ID 列表 + */ @Override public void deleteUserRelationByUserIds(List userIds) { if (CollUtil.isEmpty(userIds)) { return; } + List deletingRelations = list(Wrappers.lambdaQuery() + .and(wrapper -> wrapper.in(SysUserRelationEntity::getSubjectUserId, userIds) + .or() + .in(SysUserRelationEntity::getObjectUserId, userIds))); + Set guardianWardRelationTypeIds = new HashSet<>(listGuardianWardRelationTypeIds()); + List affectedStudentUserIds = deletingRelations.stream() + .filter(relation -> guardianWardRelationTypeIds.contains(relation.getRelationTypeId())) + .map(SysUserRelationEntity::getObjectUserId) + .filter(Objects::nonNull) + .distinct() + .toList(); // 用户可能位于有向或无向关系的任一端,删除时必须同时清理两种引用,避免关系图残留失效节点。 remove(Wrappers.lambdaQuery() .and(wrapper -> wrapper.in(SysUserRelationEntity::getSubjectUserId, userIds) .or() .in(SysUserRelationEntity::getObjectUserId, userIds))); + syncStudentGuardianPhones(affectedStudentUserIds); } /** @@ -329,6 +430,33 @@ public class SysUserRelationServiceImpl extends ServiceImpl listGuardianWardRelationTypeIds() { + return sysUserRelationTypeService.list(Wrappers.lambdaQuery() + .eq(SysUserRelationTypeEntity::getRelationCode, + UserRelationTypeEnum.GUARDIAN_WARD.getRelationCode())) + .stream() + .map(SysUserRelationTypeEntity::getRelationTypeId) + .filter(Objects::nonNull) + .distinct() + .toList(); + } + + /** + * 判断关系类型是否为监护关系。 + * + * @param relationType 待判断的关系类型 + * @return 是否需要同步学生监护人手机号 + */ + private boolean isGuardianWardRelationType(SysUserRelationTypeEntity relationType) { + return relationType != null && Objects.equals( + relationType.getRelationCode(), UserRelationTypeEnum.GUARDIAN_WARD.getRelationCode()); + } + @Override @Transactional(rollbackFor = Exception.class) public Long createUserAndRelation(SysUserRelationCreateUserDto dto) { @@ -348,6 +476,9 @@ public class SysUserRelationServiceImpl extends ServiceImpl affectedStudentUserIds = new LinkedHashSet<>(); + if (isGuardianWardRelationType(previousRelationType) && relation.getObjectUserId() != null) { + affectedStudentUserIds.add(relation.getObjectUserId()); + } SysUserRelationTypeEntity relationType = validateRelation( dto.getSubjectUserId(), dto.getObjectUserId(), dto.getRelationTypeId()); validateRelationNotExists( @@ -384,9 +523,36 @@ public class SysUserRelationServiceImpl extends ServiceImpl implements SysUserStudentService { + /** + * 判断同一学校内的学号是否已被其他用户占用。 + * + * @param userId 编辑时排除的当前用户 ID + * @param schoolName 学校名称 + * @param studentNo 学号 + * @return 是否存在其他学生占用相同学校和学号 + */ @Override public boolean existsStudentNo(Long userId, String schoolName, String studentNo) { if (StrUtil.isBlank(schoolName) || StrUtil.isBlank(studentNo)) { @@ -24,4 +30,43 @@ public class SysUserStudentServiceImpl extends ServiceImpllambdaUpdate() + .eq(SysUserStudentEntity::getUserId, userId) + .set(SysUserStudentEntity::getGuardianPhones, + StrUtil.isBlank(guardianPhones) ? null : guardianPhones)); + } + + /** + * 按学校和学号查询唯一学生身份,异常重复数据不得静默选择其中一条。 + * + * @param schoolName 学校名称 + * @param studentNo 学号 + * @return 唯一学生身份;条件为空或不存在时返回 {@code null} + */ + @Override + public SysUserStudentEntity findBySchoolNameAndStudentNo(String schoolName, String studentNo) { + if (StrUtil.isBlank(schoolName) || StrUtil.isBlank(studentNo)) { + return null; + } + java.util.List students = list(Wrappers.lambdaQuery() + .eq(SysUserStudentEntity::getSchoolName, schoolName.trim()) + .eq(SysUserStudentEntity::getStudentNo, studentNo.trim())); + if (students.size() > 1) { + throw new IllegalStateException("同一学校和学号存在多个学生,请联系管理员处理"); + } + return students.isEmpty() ? null : students.get(0); + } } diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/file/wallet_student_active.xlsx b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/file/wallet_student_active.xlsx new file mode 100644 index 00000000..5e6edd92 Binary files /dev/null and b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/file/wallet_student_active.xlsx differ diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/file/wallet_student_recharge.xlsx b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/file/wallet_student_recharge.xlsx new file mode 100644 index 00000000..802d02f8 Binary files /dev/null and b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/file/wallet_student_recharge.xlsx differ diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/file/wallet_student_reset_recharge.xlsx b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/file/wallet_student_reset_recharge.xlsx new file mode 100644 index 00000000..6b054e9d Binary files /dev/null and b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/file/wallet_student_reset_recharge.xlsx differ diff --git a/pjcloud-upms/pjcloud-upms-biz/src/test/java/com/bsd/pjcloud/admin/service/impl/user/SysUserRelationServiceImplTest.java b/pjcloud-upms/pjcloud-upms-biz/src/test/java/com/bsd/pjcloud/admin/service/impl/user/SysUserRelationServiceImplTest.java index 0670d235..3f02e5e1 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/test/java/com/bsd/pjcloud/admin/service/impl/user/SysUserRelationServiceImplTest.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/test/java/com/bsd/pjcloud/admin/service/impl/user/SysUserRelationServiceImplTest.java @@ -7,6 +7,7 @@ import com.bsd.pjcloud.admin.api.entity.user.SysUserRelationTypeEntity; import com.bsd.pjcloud.admin.api.vo.user.SysUserRelationGraphVo; import com.bsd.pjcloud.admin.service.SysUserRelationTypeService; import com.bsd.pjcloud.admin.service.SysUserService; +import com.bsd.pjcloud.admin.service.SysUserStudentService; import org.junit.jupiter.api.Test; import java.util.List; @@ -20,6 +21,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; class SysUserRelationServiceImplTest { @@ -45,7 +47,7 @@ class SysUserRelationServiceImplTest { SysUserService sysUserService = mock(SysUserService.class); SysUserRelationTypeService relationTypeService = mock(SysUserRelationTypeService.class); SysUserRelationServiceImpl service = spy( - new SysUserRelationServiceImpl(sysUserService, relationTypeService)); + new SysUserRelationServiceImpl(sysUserService, relationTypeService, mock(SysUserStudentService.class))); when(sysUserService.getById(SECOND_WARD_USER_ID)).thenReturn(createUser(SECOND_WARD_USER_ID, "被监护人二")); when(sysUserService.listByIds(any())).thenReturn(List.of( @@ -74,7 +76,8 @@ class SysUserRelationServiceImplTest { void shouldListDistinctActiveGuardianWardUserIds() { SysUserRelationTypeService relationTypeService = mock(SysUserRelationTypeService.class); SysUserRelationServiceImpl service = spy( - new SysUserRelationServiceImpl(mock(SysUserService.class), relationTypeService)); + new SysUserRelationServiceImpl( + mock(SysUserService.class), relationTypeService, mock(SysUserStudentService.class))); when(relationTypeService.getOne(any())).thenReturn(createGuardianRelationType()); doReturn(List.of( createRelation(2079104982669385730L, FIRST_WARD_USER_ID), @@ -95,7 +98,8 @@ class SysUserRelationServiceImplTest { @Test void shouldListDistinctUsersFromCompleteRelationGraph() { SysUserRelationServiceImpl service = spy( - new SysUserRelationServiceImpl(mock(SysUserService.class), mock(SysUserRelationTypeService.class))); + new SysUserRelationServiceImpl(mock(SysUserService.class), mock(SysUserRelationTypeService.class), + mock(SysUserStudentService.class))); SysUserRelationEntity outgoingRelation = createRelation(2079104982669385730L, FIRST_WARD_USER_ID); SysUserRelationEntity incomingRelation = createRelation(2079129636280758273L, FIRST_WARD_USER_ID); incomingRelation.setSubjectUserId(SECOND_WARD_USER_ID); @@ -107,6 +111,59 @@ class SysUserRelationServiceImplTest { assertEquals(List.of(FIRST_WARD_USER_ID, SECOND_WARD_USER_ID), relatedUserIds); } + /** + * 验证同一学生存在多个监护人时,手机号按关系顺序去重后一次性写入学生扩展表。 + */ + @Test + void shouldSyncDistinctGuardianPhonesInRelationOrder() { + Long secondGuardianUserId = 2034824566572941315L; + Long thirdGuardianUserId = 2034824566572941316L; + SysUserService sysUserService = mock(SysUserService.class); + SysUserRelationTypeService relationTypeService = mock(SysUserRelationTypeService.class); + SysUserStudentService studentService = mock(SysUserStudentService.class); + SysUserRelationServiceImpl service = spy( + new SysUserRelationServiceImpl(sysUserService, relationTypeService, studentService)); + + SysUserRelationEntity firstRelation = createRelation(1L, FIRST_WARD_USER_ID); + SysUserRelationEntity secondRelation = createRelation(2L, FIRST_WARD_USER_ID); + secondRelation.setSubjectUserId(secondGuardianUserId); + SysUserRelationEntity duplicatePhoneRelation = createRelation(3L, FIRST_WARD_USER_ID); + duplicatePhoneRelation.setSubjectUserId(thirdGuardianUserId); + SysUser firstGuardian = createUser(GUARDIAN_USER_ID, "监护人一"); + firstGuardian.setPhone("13800000001"); + SysUser secondGuardian = createUser(secondGuardianUserId, "监护人二"); + secondGuardian.setPhone("13800000002"); + SysUser thirdGuardian = createUser(thirdGuardianUserId, "监护人三"); + thirdGuardian.setPhone("13800000001"); + + when(relationTypeService.list(any())).thenReturn(List.of(createGuardianRelationType())); + doReturn(List.of(firstRelation, secondRelation, duplicatePhoneRelation)) + .when(service).list(org.mockito.ArgumentMatchers.>any()); + when(sysUserService.listByIds(any())).thenReturn(List.of(firstGuardian, secondGuardian, thirdGuardian)); + + service.syncStudentGuardianPhones(List.of(FIRST_WARD_USER_ID)); + + verify(studentService).updateGuardianPhones(FIRST_WARD_USER_ID, "13800000001,13800000002"); + } + + /** + * 验证学生最后一条监护关系解除后,派生手机号集合会显式清空。 + */ + @Test + void shouldClearGuardianPhonesWhenNoGuardianRelationRemains() { + SysUserRelationTypeService relationTypeService = mock(SysUserRelationTypeService.class); + SysUserStudentService studentService = mock(SysUserStudentService.class); + SysUserRelationServiceImpl service = spy(new SysUserRelationServiceImpl( + mock(SysUserService.class), relationTypeService, studentService)); + when(relationTypeService.list(any())).thenReturn(List.of(createGuardianRelationType())); + doReturn(List.of()).when(service) + .list(org.mockito.ArgumentMatchers.>any()); + + service.syncStudentGuardianPhones(List.of(FIRST_WARD_USER_ID)); + + verify(studentService).updateGuardianPhones(FIRST_WARD_USER_ID, null); + } + private SysUserRelationEntity createRelation(Long relationId, Long wardUserId) { SysUserRelationEntity relation = new SysUserRelationEntity(); relation.setRelationId(relationId); diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletImportActiveDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletImportActiveDto.java index 969426fd..34468e63 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletImportActiveDto.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletImportActiveDto.java @@ -6,6 +6,7 @@ import com.alibaba.excel.annotation.write.style.ColumnWidth; import com.bsd.pjcloud.common.excel.annotation.ExcelLine; import lombok.Data; +/** 默认开卡导入行,使用用户姓名和手机号精确定位已有用户。 */ @Data public class WalletImportActiveDto { @@ -16,28 +17,35 @@ public class WalletImportActiveDto { @ExcelIgnore private Long lineNum; + /** 用户姓名,必须与系统用户姓名完全一致。 */ @ColumnWidth(100) @ExcelProperty("用户姓名") private String username; + /** 用户手机号,与用户姓名共同作为导入定位条件。 */ @ColumnWidth(100) @ExcelProperty("用户手机号") private String userMobile; + /** 待写入开卡记录和饭卡钱包的卡号。 */ @ColumnWidth(100) @ExcelProperty("卡号") private String cardNumber; - // 开卡的id + /** 校验阶段解析出的开卡记录 ID,仅供服务端导入流程使用。 */ + @ExcelIgnore private Long id; - // 钱包id + /** 校验阶段解析出的饭卡钱包 ID,仅供服务端导入流程使用。 */ + @ExcelIgnore private Long walletId; - // 用户id + /** 校验阶段解析出的用户 ID,仅供服务端导入流程使用。 */ + @ExcelIgnore private Long userId; - // 用户部门id + /** 校验阶段解析出的用户部门 ID,仅供服务端导入流程兼容使用。 */ + @ExcelIgnore private Long userDeptId; } diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletStudentImportActiveDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletStudentImportActiveDto.java new file mode 100644 index 00000000..68012e02 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletStudentImportActiveDto.java @@ -0,0 +1,60 @@ +package com.bsd.pjcloud.wallet.api.dto; + +import com.alibaba.excel.annotation.ExcelIgnore; +import com.alibaba.excel.annotation.ExcelIgnoreUnannotated; +import com.alibaba.excel.annotation.ExcelProperty; +import com.alibaba.excel.annotation.write.style.ColumnWidth; +import com.alibaba.excel.annotation.write.style.ContentRowHeight; +import com.alibaba.excel.annotation.write.style.HeadRowHeight; +import com.bsd.pjcloud.common.excel.annotation.ExcelDict; +import com.bsd.pjcloud.common.excel.annotation.ExcelLine; +import lombok.Data; + +/** + * 学生开卡导入行。 + *

使用学生学校和学号精确定位学生身份,再校验学生姓名,避免同名学生绑定错误。

+ */ +@Data +@HeadRowHeight(20) +@ContentRowHeight(10) +@ExcelIgnoreUnannotated +public class WalletStudentImportActiveDto { + + /** Excel 原始行号,用于向前端返回可定位的逐行校验错误。 */ + @ExcelLine + @ExcelIgnore + private Long lineNum; + + /** 学生姓名,必须与学校和学号定位到的系统用户姓名完全一致。 */ + @ColumnWidth(100) + @ExcelProperty("学生姓名") + private String studentName; + + /** 学生学校,与学号共同作为学生身份的精确定位条件。 */ + @ColumnWidth(100) + @ExcelProperty("学生学校") + @ExcelDict("user_student_school") + private String schoolName; + + /** 学生学号,在同一学校内唯一。 */ + @ColumnWidth(100) + @ExcelProperty("学号") + private String studentNo; + + /** 待写入开卡记录和饭卡钱包的卡号。 */ + @ColumnWidth(100) + @ExcelProperty("卡号") + private String cardNumber; + + /** 校验阶段解析出的开卡记录 ID,仅供服务端导入流程使用。 */ + @ExcelIgnore + private Long id; + + /** 校验阶段解析出的饭卡钱包 ID,仅供服务端导入流程使用。 */ + @ExcelIgnore + private Long walletId; + + /** 校验阶段解析出的学生用户 ID,仅供服务端导入流程使用。 */ + @ExcelIgnore + private Long userId; +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/excel/WalletStudentRechargeExcelDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/excel/WalletStudentRechargeExcelDto.java new file mode 100644 index 00000000..cc32db6f --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/excel/WalletStudentRechargeExcelDto.java @@ -0,0 +1,70 @@ +package com.bsd.pjcloud.wallet.api.dto.recharge.excel; + +import com.alibaba.excel.annotation.ExcelIgnore; +import com.alibaba.excel.annotation.ExcelIgnoreUnannotated; +import com.alibaba.excel.annotation.ExcelProperty; +import com.alibaba.excel.annotation.write.style.ColumnWidth; +import com.alibaba.excel.annotation.write.style.ContentRowHeight; +import com.alibaba.excel.annotation.write.style.HeadRowHeight; +import com.bsd.pjcloud.common.excel.annotation.ExcelDict; +import com.bsd.pjcloud.common.excel.annotation.ExcelLine; +import lombok.Data; + +/** + * 学生充值/扣款申请 Excel 导入行。 + *

使用学校和学号精确定位学生,再校验学生姓名,避免同名学生被错误充值或扣款。

+ */ +@Data +@HeadRowHeight(20) +@ContentRowHeight(10) +@ExcelIgnoreUnannotated +public class WalletStudentRechargeExcelDto { + + /** Excel 原始行号,用于向前端返回可定位的逐行校验错误。 */ + @ExcelLine + @ExcelIgnore + private Long lineNum; + + /** 学生姓名,必须与学校和学号定位到的系统用户姓名完全一致。 */ + @ColumnWidth(100) + @ExcelProperty("学生姓名") + private String studentName; + + /** 学校名称,与学号共同作为学生身份定位条件,并受学生学校字典约束。 */ + @ColumnWidth(100) + @ExcelProperty("学校") + @ExcelDict("user_student_school") + private String schoolName; + + /** 学生学号,在同一学校内用于精确定位学生身份。 */ + @ColumnWidth(100) + @ExcelProperty("学号") + private String studentNo; + + /** Excel 中填写的操作金额,单位元;余额重置模板中允许为空。 */ + @ColumnWidth(100) + @ExcelProperty("操作金额") + private String price; + + /** Excel 中展示的财务标签名称,支持英文逗号分隔多个字典标签。 */ + @ColumnWidth(100) + @ExcelProperty("操作标签") + @ExcelDict(value = "wallet_transaction_tag", multiple = true) + private String tag; + + /** 校验阶段解析出的财务标签字典值,多个值使用英文逗号分隔。 */ + @ExcelIgnore + private String tags; + + /** 校验阶段解析出的操作金额,单位分;余额重置时固定为零。 */ + @ExcelIgnore + private Integer priceNumber; + + /** 校验阶段按学生身份解析出的系统用户 ID。 */ + @ExcelIgnore + private Long userId; + + /** 校验阶段解析出的学生所属部门 ID。 */ + @ExcelIgnore + private Long userDeptId; +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/pom.xml b/pjcloud-wallet/pjcloud-wallet-biz/pom.xml index e0f64d9b..9aa1284f 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/pom.xml +++ b/pjcloud-wallet/pjcloud-wallet-biz/pom.xml @@ -112,6 +112,10 @@ com.bsd pjcloud-common-idempotent + + com.bsd + pjcloud-common-async-task + com.bsd pjcloud-flow-task-api diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletActiveController.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletActiveController.java index 97f95194..da403219 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletActiveController.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletActiveController.java @@ -26,6 +26,7 @@ import com.bsd.pjcloud.wallet.api.vo.MchManageVo; import com.bsd.pjcloud.wallet.api.vo.WalletActiveVo; import com.bsd.pjcloud.wallet.service.MchManageService; import com.bsd.pjcloud.wallet.service.WalletActiveService; +import com.bsd.pjcloud.wallet.service.WalletActiveImportService; import jakarta.validation.Valid; import org.springframework.security.access.prepost.PreAuthorize; import com.bsd.pjcloud.common.excel.annotation.ResponseExcel; @@ -56,6 +57,8 @@ import java.util.List; public class WalletActiveController { private final WalletActiveService walletActiveService; + /** 钱包开卡 Excel 导入服务,隔离默认和学生模板的校验及异步编排。 */ + private final WalletActiveImportService walletActiveImportService; private final MchManageService mchManageService; private final RemoteDeptService remoteDeptService; @@ -226,12 +229,31 @@ public class WalletActiveController { } /** - * 导入开卡信息,会直接覆盖 + * 导入默认开卡模板,按用户姓名和手机号定位用户并异步覆盖饭卡卡号。 + * + * @param excelVOList 默认开卡 Excel 行 + * @param bindingResult Excel 解析阶段的逐行错误 + * @return 校验失败时返回逐行错误;校验通过时返回异步任务初始进度 */ @PostMapping("/import") @PreAuthorize("@pms.hasPermission('wallet_walletActive_import')") - public R importWalletActive(@RequestExcel List excelVOList, BindingResult bindingResult) { - return walletActiveService.importWalletActive(excelVOList, bindingResult); + @Idempotent(expireTime = 5, key = "'/wallet-active/import:' + #excelVOList.hashCode() + ':' + T(com.bsd.pjcloud.common.security.util.SecurityUtils).getUser().getId()", info = "正在导入中,请勿重复操作") + public R importWalletActive(@RequestExcel List excelVOList, BindingResult bindingResult) { + return walletActiveImportService.importDefault(excelVOList, bindingResult); + } + + /** + * 导入学生开卡模板,按学校和学号定位学生并异步覆盖饭卡卡号。 + * + * @param excelVOList 学生开卡 Excel 行 + * @param bindingResult Excel 解析阶段的逐行错误 + * @return 校验失败时返回逐行错误;校验通过时返回异步任务初始进度 + */ + @PostMapping("/import-student") + @PreAuthorize("@pms.hasPermission('wallet_student_walletActive_import')") + @Idempotent(expireTime = 5, key = "'/wallet-active/import-student:' + #excelVOList.hashCode() + ':' + T(com.bsd.pjcloud.common.security.util.SecurityUtils).getUser().getId()", info = "正在导入中,请勿重复操作") + public R importStudentWalletActive(@RequestExcel List excelVOList, BindingResult bindingResult) { + return walletActiveImportService.importStudent(excelVOList, bindingResult); } } diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/recharge/WalletRechargeApplyController.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/recharge/WalletRechargeApplyController.java index 489c22ae..ba549057 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/recharge/WalletRechargeApplyController.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/recharge/WalletRechargeApplyController.java @@ -16,11 +16,13 @@ import com.bsd.pjcloud.wallet.api.dto.recharge.WalletRechargeApplyDto; import com.bsd.pjcloud.wallet.api.dto.recharge.WalletRechargeImportDto; import com.bsd.pjcloud.wallet.api.dto.recharge.WalletRechargeSaveTopupDto; 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.vo.recharge.WalletRechargeApplyCompareAceVo; import com.bsd.pjcloud.wallet.api.vo.recharge.WalletRechargeImportVo; import com.bsd.pjcloud.wallet.api.vo.recharge.excel.WalletRechargeByApplyExcelVo; import com.bsd.pjcloud.wallet.service.WalletRechargeApplyService; +import com.bsd.pjcloud.wallet.service.WalletRechargeApplyImportService; import com.bsd.pjcloud.wallet.service.WalletRechargeApplyComposeService; import org.springframework.security.access.prepost.PreAuthorize; import com.bsd.pjcloud.common.excel.annotation.ResponseExcel; @@ -43,6 +45,8 @@ import java.util.List; public class WalletRechargeApplyController { private final WalletRechargeApplyService walletRechargeApplyService; + /** 充值/扣款 Excel 导入服务,独立承载默认用户和学生用户模板校验及落库。 */ + private final WalletRechargeApplyImportService walletRechargeApplyImportService; private final WalletRechargeApplyComposeService walletTransactionComposeService; /* ************************************* start callback start ************************************* */ @@ -160,25 +164,80 @@ public class WalletRechargeApplyController { return walletRechargeApplyService.list(Wrappers.lambdaQuery(walletRechargeApply).in(ArrayUtil.isNotEmpty(ids), WalletRechargeApplyEntity::getId, ids)); } + /** + * 读取默认用户充值、扣款或退款 Excel,按姓名和手机号定位用户。 + * + * @param excelVOList 默认用户充值 Excel 数据行 + * @param bindingResult Excel 解析阶段的逐行错误 + * @param bizType 充值业务类型协议值 + * @return 校验失败时返回逐行错误,通过时返回导入成功结果 + */ @PostMapping("/read-excel") - public R readExcel(@RequestExcel List excelVOList, BindingResult bindingResult, @RequestParam(required = false) String bizType) { - return walletRechargeApplyService.readExcel(excelVOList, bindingResult, bizType); + @PreAuthorize("@pms.hasPermission('wallet_walletRechargeApply_unbind_def')") + @Idempotent(expireTime = 3, info = "正在上传中,请勿重复操作") + public R readExcel(@RequestExcel List excelVOList, BindingResult bindingResult, @RequestParam(required = false) String bizType) { + return walletRechargeApplyImportService.importDefault(excelVOList, bindingResult, bizType); } /** - * 读取重置钱包的excel + * 读取默认用户余额重置 Excel,按姓名和手机号定位用户且不校验操作金额。 + * + * @param excelVOList 默认用户余额重置 Excel 数据行 + * @param bindingResult Excel 解析阶段的逐行错误 + * @param bizType 余额重置业务类型协议值 + * @return 校验失败时返回逐行错误,通过时返回导入成功结果 */ @PostMapping("/read-reset-excel") + @PreAuthorize("@pms.hasPermission('wallet_walletRechargeApply_unbind_def')") @Idempotent(expireTime = 3, info = "正在上传中,请勿重复操作") - public R readResetExcel(@RequestExcel List excelVOList, BindingResult bindingResult, @RequestParam(required = false) String bizType) { - return walletRechargeApplyService.readResetExcel(excelVOList, bindingResult, bizType); + public R readResetExcel(@RequestExcel List excelVOList, BindingResult bindingResult, @RequestParam(required = false) String bizType) { + return walletRechargeApplyImportService.importDefaultReset(excelVOList, bindingResult, bizType); } + /** + * 读取学生充值、扣款或退款 Excel,按学校和学号定位学生并校验姓名。 + * + * @param excelVOList 学生充值 Excel 数据行 + * @param bindingResult Excel 解析阶段的逐行错误 + * @param bizType 充值业务类型协议值 + * @return 校验失败时返回逐行错误,通过时返回导入成功结果 + */ + @PostMapping("/read-student-excel") + @PreAuthorize("@pms.hasPermission('wallet_walletRechargeApply_unbind_studen')") + @Idempotent(expireTime = 3, info = "正在上传中,请勿重复操作") + public R readStudentExcel(@RequestExcel List excelVOList, BindingResult bindingResult, + @RequestParam(required = false) String bizType) { + return walletRechargeApplyImportService.importStudent(excelVOList, bindingResult, bizType); + } + + /** + * 读取学生余额重置 Excel,按学校和学号定位学生且不校验操作金额。 + * + * @param excelVOList 学生余额重置 Excel 数据行 + * @param bindingResult Excel 解析阶段的逐行错误 + * @param bizType 余额重置业务类型协议值 + * @return 校验失败时返回逐行错误,通过时返回导入成功结果 + */ + @PostMapping("/read-student-reset-excel") + @PreAuthorize("@pms.hasPermission('wallet_walletRechargeApply_unbind_studen')") + @Idempotent(expireTime = 3, info = "正在上传中,请勿重复操作") + public R readStudentResetExcel(@RequestExcel List excelVOList, BindingResult bindingResult, + @RequestParam(required = false) String bizType) { + return walletRechargeApplyImportService.importStudentReset(excelVOList, bindingResult, bizType); + } + + /** + * 读取绑定发票前的默认用户充值明细,并创建基础发票记录。 + * + * @param excelVOList 默认用户充值 Excel 数据行 + * @param bindingResult Excel 解析阶段的逐行错误 + * @return 校验失败时返回逐行错误,通过时返回导入成功结果 + */ @PostMapping("/read-topup-excel") @Idempotent(expireTime = 3, info = "正在上传中,请勿重复操作") - public R readTopupExcel(@RequestExcel List excelVOList, BindingResult bindingResult) { - return walletRechargeApplyService.readTopupExcel(excelVOList, bindingResult); + public R readTopupExcel(@RequestExcel List excelVOList, BindingResult bindingResult) { + return walletRechargeApplyImportService.importTopup(excelVOList, bindingResult); } /* ************************************* start excel start ************************************* */ -} \ No newline at end of file +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletActiveImportService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletActiveImportService.java new file mode 100644 index 00000000..62270e14 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletActiveImportService.java @@ -0,0 +1,30 @@ +package com.bsd.pjcloud.wallet.service; + +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.wallet.api.dto.WalletImportActiveDto; +import com.bsd.pjcloud.wallet.api.dto.WalletStudentImportActiveDto; +import org.springframework.validation.BindingResult; + +import java.util.List; + +/** 钱包开卡 Excel 导入服务,与开卡 CRUD 和设备激活业务隔离。 */ +public interface WalletActiveImportService { + + /** + * 导入默认开卡模板,按用户姓名和手机号定位用户。 + * + * @param rows 默认开卡 Excel 行 + * @param bindingResult Excel 解析阶段的逐行校验结果 + * @return 校验失败时返回逐行错误;通过时返回异步任务初始进度 + */ + R importDefault(List rows, BindingResult bindingResult); + + /** + * 导入学生开卡模板,按学校和学号定位学生并校验姓名。 + * + * @param rows 学生开卡 Excel 行 + * @param bindingResult Excel 解析阶段的逐行校验结果 + * @return 校验失败时返回逐行错误;通过时返回异步任务初始进度 + */ + R importStudent(List rows, BindingResult bindingResult); +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletActiveService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletActiveService.java index 56656b15..31f6781c 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletActiveService.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletActiveService.java @@ -2,20 +2,13 @@ package com.bsd.pjcloud.wallet.service; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.IService; -import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.wallet.api.dto.WalletActiveBindCardDto; import com.bsd.pjcloud.wallet.api.dto.WalletActiveDto; -import com.bsd.pjcloud.wallet.api.dto.WalletImportActiveDto; import com.bsd.pjcloud.wallet.api.dto.WalletRemoteOpenCardCountDto; -import com.bsd.pjcloud.wallet.api.dto.recharge.excel.WalletRechargeExcelDto; import com.bsd.pjcloud.wallet.api.entity.WalletActiveEntity; import com.bsd.pjcloud.wallet.api.entity.WalletManageEntity; import com.bsd.pjcloud.wallet.api.vo.WalletActiveVo; import jakarta.validation.constraints.NotEmpty; -import org.springframework.validation.BindingResult; - -import java.util.List; - public interface WalletActiveService extends IService { /** * 一键开卡 @@ -80,9 +73,4 @@ public interface WalletActiveService extends IService { */ void delCardByCardNumber(String cardNumber); - /** - * 导入开卡信息,会直接覆盖 - */ - R importWalletActive(List excelVOList, BindingResult bindingResult); - -} \ No newline at end of file +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletRechargeApplyImportService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletRechargeApplyImportService.java new file mode 100644 index 00000000..0d043101 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletRechargeApplyImportService.java @@ -0,0 +1,63 @@ +package com.bsd.pjcloud.wallet.service; + +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.wallet.api.dto.recharge.excel.WalletRechargeExcelDto; +import com.bsd.pjcloud.wallet.api.dto.recharge.excel.WalletStudentRechargeExcelDto; +import org.springframework.validation.BindingResult; + +import java.util.List; + +/** + * 充值/扣款申请 Excel 导入服务,与申请 CRUD、审批和余额处理业务隔离。 + */ +public interface WalletRechargeApplyImportService { + + /** + * 导入不绑定发票的默认用户充值、扣款或退款数据。 + * + * @param rows 默认用户 Excel 数据行 + * @param bindingResult Excel 解析阶段的逐行校验结果 + * @param bizType 充值业务类型协议值 + * @return 校验失败时返回逐行错误,通过时返回导入成功结果 + */ + R importDefault(List rows, BindingResult bindingResult, String bizType); + + /** + * 导入不绑定发票的默认用户余额重置数据,操作金额固定为零。 + * + * @param rows 默认用户余额重置 Excel 数据行 + * @param bindingResult Excel 解析阶段的逐行校验结果 + * @param bizType 余额重置业务类型协议值 + * @return 校验失败时返回逐行错误,通过时返回导入成功结果 + */ + R importDefaultReset(List rows, BindingResult bindingResult, String bizType); + + /** + * 导入不绑定发票的学生充值、扣款或退款数据。 + * + * @param rows 学生 Excel 数据行 + * @param bindingResult Excel 解析阶段的逐行校验结果 + * @param bizType 充值业务类型协议值 + * @return 校验失败时返回逐行错误,通过时返回导入成功结果 + */ + R importStudent(List rows, BindingResult bindingResult, String bizType); + + /** + * 导入不绑定发票的学生余额重置数据,操作金额固定为零。 + * + * @param rows 学生余额重置 Excel 数据行 + * @param bindingResult Excel 解析阶段的逐行校验结果 + * @param bizType 余额重置业务类型协议值 + * @return 校验失败时返回逐行错误,通过时返回导入成功结果 + */ + R importStudentReset(List rows, BindingResult bindingResult, String bizType); + + /** + * 导入绑定发票前的充值明细并创建基础发票记录。 + * + * @param rows 默认用户充值 Excel 数据行 + * @param bindingResult Excel 解析阶段的逐行校验结果 + * @return 校验失败时返回逐行错误,通过时返回导入成功结果 + */ + R importTopup(List rows, BindingResult bindingResult); +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletRechargeApplyService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletRechargeApplyService.java index c060c655..bbd0d2f2 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletRechargeApplyService.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletRechargeApplyService.java @@ -2,17 +2,14 @@ package com.bsd.pjcloud.wallet.service; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.IService; -import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.data.datascope.DataScope; import com.bsd.pjcloud.flow.task.dto.ProcessNodeRecordAssignUserParamDto; import com.bsd.pjcloud.wallet.api.dto.recharge.WalletRechargeApplyDto; import com.bsd.pjcloud.wallet.api.dto.recharge.WalletRechargeImportDto; import com.bsd.pjcloud.wallet.api.dto.recharge.WalletRechargeSaveTopupDto; -import com.bsd.pjcloud.wallet.api.dto.recharge.excel.WalletRechargeExcelDto; import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeApplyEntity; import com.bsd.pjcloud.wallet.api.vo.recharge.WalletRechargeApplyVo; import com.bsd.pjcloud.wallet.api.vo.recharge.excel.WalletRechargeByApplyExcelVo; -import org.springframework.validation.BindingResult; import java.util.ArrayList; import java.util.List; @@ -47,21 +44,6 @@ public interface WalletRechargeApplyService extends IService excelVOList, BindingResult bindingResult, String bizType); - - /** - * 读取重置钱包的excel - */ - R readResetExcel(List excelVOList, BindingResult bindingResult, String bizType); - - /** - * 读取充值excel内容 - */ - R readTopupExcel(List excelVOList, BindingResult bindingResult); - /** * 审批回调 */ @@ -75,4 +57,4 @@ public interface WalletRechargeApplyService extends IService exportExcel(WalletRechargeImportDto dto); -} \ No newline at end of file +} 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 new file mode 100644 index 00000000..dc1ca524 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletActiveImportServiceImpl.java @@ -0,0 +1,464 @@ +package com.bsd.pjcloud.wallet.service.impl; + +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.entity.SysUser; +import com.bsd.pjcloud.admin.api.feign.RemoteUserService; +import com.bsd.pjcloud.common.asynctask.service.AsyncTaskExecutorService; +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.wallet.api.constant.WalletTypeEnum; +import com.bsd.pjcloud.wallet.api.dto.WalletImportActiveDto; +import com.bsd.pjcloud.wallet.api.dto.WalletStudentImportActiveDto; +import com.bsd.pjcloud.wallet.api.entity.WalletActiveEntity; +import com.bsd.pjcloud.wallet.api.entity.WalletManageEntity; +import com.bsd.pjcloud.wallet.mapper.WalletActiveMapper; +import com.bsd.pjcloud.wallet.service.WalletActiveImportService; +import com.bsd.pjcloud.wallet.service.WalletActiveService; +import com.bsd.pjcloud.wallet.service.WalletManageService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.validation.BindingResult; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * 钱包开卡 Excel 导入服务实现。 + *

同步阶段完成整表解析、用户定位和业务校验,校验全部通过后才提交公共异步事务任务,避免错误行造成部分卡号已覆盖。

+ */ +@Service +@RequiredArgsConstructor +public class WalletActiveImportServiceImpl implements WalletActiveImportService { + + /** 默认开卡导入任务的 Redis 业务命名空间。 */ + private static final String DEFAULT_IMPORT_REDIS_KEY = "wallet:active:import:default"; + + /** 学生开卡导入任务的 Redis 业务命名空间。 */ + private static final String STUDENT_IMPORT_REDIS_KEY = "wallet:active:import:student"; + + /** 允许导入的饭卡卡号长度,与既有开卡导入规则保持一致。 */ + private static final Set CARD_NUMBER_LENGTHS = Set.of(8, 10, 12); + + /** 用户远程服务,用于按默认模板或学生身份条件精确定位系统用户。 */ + private final RemoteUserService remoteUserService; + + /** 钱包管理服务,用于查询饭卡钱包并同步覆盖钱包卡号。 */ + private final WalletManageService walletManageService; + + /** 开卡核心服务,用于为尚未绑定饭卡的用户复用现有自动分配和绑定逻辑。 */ + private final WalletActiveService walletActiveService; + + /** 开卡 Mapper,用于批量查询用户开卡记录并更新导入卡号。 */ + private final WalletActiveMapper walletActiveMapper; + + /** 公共异步任务执行器,用于后台事务写入和向前端报告逐行进度。 */ + private final AsyncTaskExecutorService asyncTaskExecutorService; + + /** + * 校验默认模板并提交异步开卡导入任务。 + * + * @param rows 默认开卡 Excel 行 + * @param bindingResult Excel 解析阶段的逐行错误 + * @return 校验失败时返回逐行错误;通过时返回异步任务初始进度 + */ + @Override + public R importDefault(List rows, BindingResult bindingResult) { + List errors = getErrorMessageList(bindingResult); + List effectiveRows = rows == null ? List.of() : rows.stream() + .filter(row -> !ObjectUtil.isAllEmpty(row.getUsername(), row.getUserMobile(), row.getCardNumber())) + .toList(); + if (effectiveRows.isEmpty()) { + return errors.isEmpty() ? R.failed("Excel内容为空") : R.failed(errors); + } + + effectiveRows.forEach(row -> { + row.setUsername(StrUtil.trim(row.getUsername())); + row.setUserMobile(StrUtil.trim(row.getUserMobile())); + row.setCardNumber(StrUtil.trim(row.getCardNumber())); + }); + Map phoneCounts = countValues(effectiveRows.stream().map(WalletImportActiveDto::getUserMobile).toList()); + Map cardCounts = countValues(effectiveRows.stream().map(WalletImportActiveDto::getCardNumber).toList()); + List validatedRows = new ArrayList<>(); + for (WalletImportActiveDto row : effectiveRows) { + Set rowErrors = new LinkedHashSet<>(); + requireText(row.getUsername(), "请输入用户姓名", rowErrors); + requireText(row.getUserMobile(), "请输入用户手机号", rowErrors); + validateCardNumber(row.getCardNumber(), cardCounts, rowErrors); + if (StrUtil.isNotBlank(row.getUserMobile()) && phoneCounts.getOrDefault(row.getUserMobile(), 0L) > 1) { + rowErrors.add("用户手机号 <" + row.getUserMobile() + "> 在Excel中重复"); + } + 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())) { + rowErrors.add("用户不存在"); + } + } + if (rowErrors.isEmpty() && user != null) { + validatedRows.add(new ValidatedImportRow(row.getLineNum(), user.getUserId(), row.getUsername(), row.getCardNumber())); + } else { + appendErrors(errors, row.getLineNum(), rowErrors); + } + } + return validateBindingsAndSubmit(validatedRows, errors, DEFAULT_IMPORT_REDIS_KEY, "默认开卡导入"); + } + + /** + * 校验学生模板并提交异步开卡导入任务。 + * + * @param rows 学生开卡 Excel 行 + * @param bindingResult Excel 解析阶段的逐行错误 + * @return 校验失败时返回逐行错误;通过时返回异步任务初始进度 + */ + @Override + public R importStudent(List rows, BindingResult bindingResult) { + List errors = getErrorMessageList(bindingResult); + List effectiveRows = rows == null ? List.of() : rows.stream() + .filter(row -> !ObjectUtil.isAllEmpty(row.getStudentName(), row.getSchoolName(), row.getStudentNo(), row.getCardNumber())) + .toList(); + if (effectiveRows.isEmpty()) { + return errors.isEmpty() ? R.failed("Excel内容为空") : R.failed(errors); + } + + effectiveRows.forEach(row -> { + row.setStudentName(StrUtil.trim(row.getStudentName())); + row.setSchoolName(StrUtil.trim(row.getSchoolName())); + row.setStudentNo(StrUtil.trim(row.getStudentNo())); + row.setCardNumber(StrUtil.trim(row.getCardNumber())); + }); + Map studentCounts = countValues(effectiveRows.stream() + .map(row -> buildStudentKey(row.getSchoolName(), row.getStudentNo())).toList()); + Map cardCounts = countValues(effectiveRows.stream().map(WalletStudentImportActiveDto::getCardNumber).toList()); + List validatedRows = new ArrayList<>(); + for (WalletStudentImportActiveDto row : effectiveRows) { + Set rowErrors = new LinkedHashSet<>(); + requireText(row.getStudentName(), "请输入学生姓名", rowErrors); + requireText(row.getSchoolName(), "请输入学生学校", rowErrors); + requireText(row.getStudentNo(), "请输入学号", rowErrors); + validateCardNumber(row.getCardNumber(), cardCounts, rowErrors); + String studentKey = buildStudentKey(row.getSchoolName(), row.getStudentNo()); + if (StrUtil.isNotBlank(row.getSchoolName()) && StrUtil.isNotBlank(row.getStudentNo()) && studentCounts.getOrDefault(studentKey, 0L) > 1) { + rowErrors.add("学生学校和学号 <" + row.getSchoolName() + " / " + row.getStudentNo() + "> 在Excel中重复"); + } + SysUser user = null; + if (StrUtil.isNotBlank(row.getSchoolName()) && StrUtil.isNotBlank(row.getStudentNo())) { + user = extractUser(remoteUserService.getStudentBySchoolNameAndStudentNo(row.getSchoolName(), row.getStudentNo())); + if (user == null) { + rowErrors.add("学生不存在"); + } else if (!ObjectUtil.equals(user.getName(), row.getStudentName())) { + rowErrors.add("学生姓名与学校、学号不匹配"); + } + } + if (rowErrors.isEmpty() && user != null) { + validatedRows.add(new ValidatedImportRow(row.getLineNum(), user.getUserId(), row.getStudentName(), row.getCardNumber())); + } else { + appendErrors(errors, row.getLineNum(), rowErrors); + } + } + return validateBindingsAndSubmit(validatedRows, errors, STUDENT_IMPORT_REDIS_KEY, "学生开卡导入"); + } + + /** + * 批量解析用户现有开卡记录或饭卡钱包,校验卡号占用后提交异步任务。 + * + * @param rows 已完成用户定位和模板字段校验的行 + * @param errors 可继续追加的逐行错误集合 + * @param redisKey 当前导入类型的任务 Redis 命名空间 + * @param taskName 当前导入类型的任务展示名称 + * @return 校验失败响应或异步任务初始进度响应 + */ + private R validateBindingsAndSubmit(List rows, List errors, + String redisKey, String taskName) { + List userIds = rows.stream().map(ValidatedImportRow::userId).distinct().toList(); + List wallets = userIds.isEmpty() ? List.of() : ObjectUtil.defaultIfNull( + walletManageService.getWalletsByUserIds(userIds, WalletTypeEnum.EAT), List.of()); + Map walletByUserId = wallets.stream() + .collect(Collectors.toMap(WalletManageEntity::getUserId, Function.identity(), (first, second) -> first)); + List walletIds = wallets.stream().map(WalletManageEntity::getId).filter(ObjectUtil::isNotEmpty).distinct().toList(); + List activeRecords = new ArrayList<>(); + if (!userIds.isEmpty()) { + activeRecords.addAll(walletActiveMapper.selectList( + new LambdaQueryWrapper().in(WalletActiveEntity::getUserId, userIds))); + } + if (!walletIds.isEmpty()) { + // 兼容历史开卡记录只写 walletId、未写 userId 的数据,避免误判为未绑定后重复分配饭卡。 + activeRecords.addAll(walletActiveMapper.selectList( + new LambdaQueryWrapper().in(WalletActiveEntity::getWalletId, walletIds))); + } + Map activeByUserId = activeRecords.stream() + .filter(active -> active.getUserId() != null) + .collect(Collectors.toMap(WalletActiveEntity::getUserId, Function.identity(), (first, second) -> first)); + Map activeByWalletId = activeRecords.stream() + .filter(active -> active.getWalletId() != null) + .collect(Collectors.toMap(WalletActiveEntity::getWalletId, Function.identity(), (first, second) -> first)); + + rows.forEach(row -> { + WalletManageEntity wallet = walletByUserId.get(row.userId()); + if (wallet == null) { + appendErrors(errors, row.lineNum(), Set.of("用户 <" + row.displayName() + "> 饭卡钱包不存在")); + return; + } + WalletActiveEntity active = activeByUserId.get(row.userId()); + if (active == null) { + active = activeByWalletId.get(wallet.getId()); + } + if (active != null) { + row.bind(active.getId(), wallet.getId()); + return; + } + row.bind(null, wallet.getId()); + }); + validateCardOccupancy(rows, errors); + if (!errors.isEmpty()) { + return R.failed(errors); + } + AsyncTaskProgressVO progress = asyncTaskExecutorService.submit(redisKey, taskName, rows.size(), reporter -> { + for (ValidatedImportRow row : rows) { + applyCardNumber(row); + reporter.advance("正在导入开卡信息:" + row.displayName()); + } + }); + return R.ok(progress, "Excel校验通过,已开始后台导入"); + } + + /** + * 校验目标卡号未被本次导入用户之外的其他开卡记录占用。 + * + * @param rows 已解析用户开卡关系的导入行 + * @param errors 可继续追加的逐行错误集合 + */ + private void validateCardOccupancy(List rows, List errors) { + List cardNumbers = rows.stream().map(ValidatedImportRow::cardNumber).distinct().toList(); + if (cardNumbers.isEmpty()) { + return; + } + Map activeByCardNumber = walletActiveMapper.selectList( + new LambdaQueryWrapper().in(WalletActiveEntity::getCardNumber, cardNumbers)) + .stream().collect(Collectors.toMap(WalletActiveEntity::getCardNumber, Function.identity(), (first, second) -> first)); + rows.forEach(row -> { + WalletActiveEntity occupied = activeByCardNumber.get(row.cardNumber()); + if (occupied != null && !ObjectUtil.equals(occupied.getId(), row.activeId())) { + appendErrors(errors, row.lineNum(), Set.of("卡号 <" + row.cardNumber() + "> 已被其他用户使用")); + } + }); + } + + /** + * 在异步事务内为单行用户准备开卡记录并同步覆盖开卡表和钱包表卡号。 + * + * @param row 已通过全部校验并包含用户、钱包关联的导入行 + */ + private void applyCardNumber(ValidatedImportRow row) { + WalletManageEntity wallet = walletManageService.getWallet(row.walletId()); + if (wallet == null) { + throw new IllegalStateException("用户 <" + row.displayName() + "> 饭卡钱包不存在"); + } + if (row.activeId() == null) { + WalletActiveEntity active = findBoundActive(row.userId(), row.walletId()); + if (active == null) { + if (ObjectUtil.isNotEmpty(wallet.getCardNumber())) { + // 钱包有历史卡号但没有任何开卡记录时属于孤立状态;先在本事务内清空,才能复用现有自动绑卡校验。 + walletManageService.update(new LambdaUpdateWrapper() + .eq(WalletManageEntity::getId, wallet.getId()) + .set(WalletManageEntity::getCardNumber, null)); + wallet.setCardNumber(null); + } + walletActiveService.createAndActiveCard(wallet); + active = findBoundActive(row.userId(), row.walletId()); + } + if (active == null) { + throw new IllegalStateException("用户 <" + row.displayName() + "> 自动绑定饭卡失败,请检查所属食堂商户和开卡配置"); + } + row.bind(active.getId(), row.walletId()); + } + WalletActiveEntity activeUpdate = new WalletActiveEntity(); + activeUpdate.setId(row.activeId()); + activeUpdate.setUserId(row.userId()); + activeUpdate.setWalletId(row.walletId()); + activeUpdate.setWalletTypeId(wallet.getWalletTypeId()); + activeUpdate.setCardNumber(row.cardNumber()); + walletActiveMapper.updateById(activeUpdate); + + WalletManageEntity walletUpdate = new WalletManageEntity(); + walletUpdate.setId(row.walletId()); + walletUpdate.setCardNumber(row.cardNumber()); + walletManageService.updateById(walletUpdate); + } + + /** + * 按用户或饭卡钱包查询已经绑定的开卡记录,兼容历史数据缺少其中一个关联字段的情况。 + * + * @param userId 当前导入用户 ID + * @param walletId 当前用户饭卡钱包 ID + * @return 已绑定的开卡记录;两个关联条件均未命中时返回 {@code null} + */ + private WalletActiveEntity findBoundActive(Long userId, Long walletId) { + return walletActiveMapper.selectList(new LambdaQueryWrapper() + .and(wrapper -> wrapper.eq(WalletActiveEntity::getUserId, userId) + .or().eq(WalletActiveEntity::getWalletId, walletId)) + .last("limit 1")) + .stream().findFirst().orElse(null); + } + + /** + * 校验必填文本并追加当前行错误。 + * + * @param value 待校验文本 + * @param message 为空时的中文错误说明 + * @param errors 当前行错误集合 + */ + private void requireText(String value, String message, Set errors) { + if (StrUtil.isBlank(value)) { + errors.add(message); + } + } + + /** + * 校验卡号必填、长度和 Excel 内唯一性。 + * + * @param cardNumber 卡号 + * @param cardCounts 当前 Excel 非空卡号出现次数 + * @param errors 当前行错误集合 + */ + private void validateCardNumber(String cardNumber, Map cardCounts, Set errors) { + if (StrUtil.isBlank(cardNumber)) { + errors.add("请输入卡号"); + return; + } + if (!CARD_NUMBER_LENGTHS.contains(cardNumber.length())) { + errors.add("卡号长度错误,卡号长度必须为8、10、12"); + } + if (cardCounts.getOrDefault(cardNumber, 0L) > 1) { + errors.add("卡号 <" + cardNumber + "> 在Excel中重复"); + } + } + + /** + * 从 Feign 统一响应中安全提取用户,失败响应或空数据均按未找到处理。 + * + * @param response UPMS 用户查询响应 + * @return 用户实体;响应失败或数据为空时返回 {@code null} + */ + private SysUser extractUser(R response) { + return response == null || !response.isOk() || response.getData() == null ? null : response.getData().getSysUser(); + } + + /** + * 统计非空文本在 Excel 中的出现次数。 + * + * @param values 待统计文本列表 + * @return 文本到出现次数的映射 + */ + private Map countValues(List values) { + return values.stream().filter(StrUtil::isNotBlank) + .collect(Collectors.groupingBy(Function.identity(), Collectors.counting())); + } + + /** + * 生成学生学校和学号组合键,仅用于当前 Excel 内重复校验。 + * + * @param schoolName 学生学校 + * @param studentNo 学号 + * @return 不与业务协议交互的内存组合键 + */ + private String buildStudentKey(String schoolName, String studentNo) { + return StrUtil.nullToEmpty(schoolName) + "\u0000" + StrUtil.nullToEmpty(studentNo); + } + + /** + * 按 Excel 行号合并解析错误与业务错误,避免同一行拆成多条记录。 + * + * @param errors 整表错误集合 + * @param lineNum Excel 行号 + * @param rowErrors 当前行新增错误 + */ + private void appendErrors(List errors, Long lineNum, Set rowErrors) { + if (rowErrors.isEmpty()) { + return; + } + ErrorMessage existing = errors.stream().filter(error -> ObjectUtil.equals(error.getLineNum(), lineNum)) + .findFirst().orElse(null); + if (existing == null) { + errors.add(new ErrorMessage(lineNum, new LinkedHashSet<>(rowErrors))); + } else { + existing.getErrors().addAll(rowErrors); + } + } + + /** + * 获取 Excel 解析器写入 BindingResult 目标中的逐行错误集合。 + * + * @param bindingResult Excel 参数绑定结果 + * @return 可继续追加业务错误的列表;解析器未提供列表时返回新列表 + */ + @SuppressWarnings("unchecked") + private List getErrorMessageList(BindingResult bindingResult) { + Object target = bindingResult == null ? null : bindingResult.getTarget(); + return target instanceof List targetList ? (List) targetList : new ArrayList<>(); + } + + /** + * 已通过用户定位的开卡导入上下文,关联 ID 在整表校验阶段补齐并由异步事务使用。 + * + * @param lineNum Excel 原始行号 + * @param userId 已定位用户 ID + * @param displayName 进度和错误提示使用的用户姓名 + * @param cardNumber 待覆盖的卡号 + */ + private record ValidatedImportRow(Long lineNum, Long userId, String displayName, String cardNumber, + Binding binding) { + + /** + * 创建尚未绑定钱包和开卡记录的校验上下文。 + * + * @param lineNum Excel 原始行号 + * @param userId 已定位用户 ID + * @param displayName 用户姓名 + * @param cardNumber 待覆盖卡号 + */ + private ValidatedImportRow(Long lineNum, Long userId, String displayName, String cardNumber) { + this(lineNum, userId, displayName, cardNumber, new Binding()); + } + + /** + * 更新当前导入行关联的开卡记录和钱包 ID。 + * + * @param activeId 开卡记录 ID;尚未自动分配饭卡时为空 + * @param walletId 饭卡钱包 ID + */ + private void bind(Long activeId, Long walletId) { + binding.activeId = activeId; + binding.walletId = walletId; + } + + /** @return 当前开卡记录 ID;未自动分配饭卡时为空。 */ + private Long activeId() { + return binding.activeId; + } + + /** @return 当前饭卡钱包 ID。 */ + private Long walletId() { + return binding.walletId; + } + } + + /** 开卡导入行的可变关联标识,仅在校验完成后、异步任务提交前由当前请求线程写入。 */ + private static final class Binding { + + /** 已有或自动创建的开卡记录 ID。 */ + private Long activeId; + + /** 用户饭卡钱包 ID。 */ + private Long walletId; + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletActiveServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletActiveServiceImpl.java index 4acd7af3..f2c39a96 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletActiveServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletActiveServiceImpl.java @@ -17,7 +17,6 @@ import com.bsd.pjcloud.common.core.constant.enums.MerchantTypeEnum; import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; import com.bsd.pjcloud.common.core.util.CollectionUtils; import com.bsd.pjcloud.common.core.util.R; -import com.bsd.pjcloud.common.excel.vo.ErrorMessage; import com.bsd.pjcloud.wallet.api.constant.WalleCardStatusEnum; import com.bsd.pjcloud.wallet.api.constant.WalletTypeEnum; import com.bsd.pjcloud.wallet.api.dto.*; @@ -36,10 +35,8 @@ import com.bsd.pjcloud.wallet.service.WalletManageService; import com.bsd.pjcloud.wallet.service.WalletTypeService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.tuple.ImmutablePair; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.validation.BindingResult; import org.springframework.validation.annotation.Validated; import java.time.LocalDateTime; @@ -116,6 +113,15 @@ public class WalletActiveServiceImpl extends ServiceImpl 0; } + /** + * 将指定空闲饭卡绑定到用户的饭卡钱包。 + *

饭卡记录和钱包记录均通过带未绑定条件的更新完成抢占;任一记录已被并发请求绑定时抛出异常, + * 当前事务会回滚,避免同一卡号覆盖绑定到多个用户或同一钱包关联多张饭卡。

+ * + * @param dto 待绑定的用户、钱包和饭卡卡号 + * @return 完成绑定后的饭卡记录 + * @throws RuntimeException 饭卡或钱包不存在、饭卡已被绑定、用户已绑定饭卡时抛出 + */ @Override @Transactional(rollbackFor = Exception.class) public WalletActiveEntity bindCard(WalletActiveBindCardDto dto) { @@ -128,15 +134,29 @@ public class WalletActiveServiceImpl extends ServiceImpl() + .eq(WalletActiveEntity::getId, walletActive.getId()) + .isNull(WalletActiveEntity::getUserId) + .isNull(WalletActiveEntity::getWalletId)); + if (activeUpdateCount == 0) { + throw new RuntimeException("该卡号已经绑定用户"); + } + // 钱包也需要条件更新;失败时抛出异常,由事务回滚前面已经完成的饭卡抢占。 WalletManageEntity walletObj = WalletManageEntity.builder().id(selfWallet.getId()).cardNumber(walletActive.getCardNumber()).status(WalleCardStatusEnum.ACTIVE.getCode()).activeBindTime(LocalDateTime.now()).build(); - walletManageService.updateById(walletObj); + boolean walletUpdateSuccess = walletManageService.update(walletObj, new LambdaUpdateWrapper() + .eq(WalletManageEntity::getId, selfWallet.getId()) + .isNull(WalletManageEntity::getCardNumber)); + if (!walletUpdateSuccess) { + throw new RuntimeException("用户已绑定饭卡"); + } return baseMapper.selectById(walletActiveObj.getId()); } @@ -151,12 +171,20 @@ public class WalletActiveServiceImpl extends ServiceImpl学生学校可能挂在“其他食堂”商户下,因此自动绑卡同时匹配其他食堂和自营食堂; + * 钱包已有卡号时视为已绑定并保持原行为,不重复分配。

+ * + * @param wallet 待绑定的饭卡钱包;为空或已有卡号时不执行任何操作 + */ @Override @Transactional(rollbackFor = Exception.class) public void createAndActiveCard(WalletManageEntity wallet) { if (ObjectUtil.isEmpty(wallet) || ObjectUtil.isNotEmpty(wallet.getCardNumber())) return; - // 查询用户所属于的商户 - MchManageVo mch = mchManageService.getFirstMchByDeptId(wallet.getDeptId(), List.of(MerchantTypeEnum.CANTEEN)); + // 学校部门可能归属其他食堂,必须与用户部门商户关系同步时采用相同的食堂类型范围。 + MchManageVo mch = mchManageService.getFirstMchByDeptId(wallet.getDeptId(), + List.of(MerchantTypeEnum.OTHER_CANTEEN, MerchantTypeEnum.CANTEEN)); if (ObjectUtil.isEmpty(mch)) return; LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .isNull(WalletActiveEntity::getUserId) @@ -170,7 +198,8 @@ public class WalletActiveServiceImpl extends ServiceImpl selectOneWrapper = wrapper.clone().orderByAsc(WalletActiveEntity::getCardSort).last("limit 1"); + // 在当前事务内锁定候选卡;后续并发请求等待前一笔提交后重新判断空闲条件,避免选中同一张卡。 + LambdaQueryWrapper selectOneWrapper = wrapper.clone().orderByAsc(WalletActiveEntity::getCardSort).last("limit 1 for update"); WalletActiveEntity walletActive = baseMapper.selectOne(selectOneWrapper); if (ObjectUtil.isNotEmpty(walletActive)) { // 绑定饭卡 @@ -352,148 +381,6 @@ public class WalletActiveServiceImpl extends ServiceImpl().eq(WalletActiveEntity::getCardNumber, cardNumber)); } - /** - * 导入开卡信息并覆盖用户当前饭卡卡号;用户尚未绑定饭卡时,先在同一事务中自动分配并绑定饭卡。 - * - * @param excelVOList Excel 中读取的开卡数据 - * @param bindingResult Excel 行级校验错误容器 - * @return 导入结果;校验失败时返回逐行错误,成功时返回导入成功提示 - */ - @Override - @Transactional(rollbackFor = Exception.class) - public R importWalletActive(List excelVOList, BindingResult bindingResult) { - ImmutablePair, List> pair = validateExcelData(excelVOList, bindingResult, false); - List errorMessages = pair.getLeft(); - List list = pair.getRight(); - if (ObjectUtil.isNotEmpty(errorMessages)) return R.failed(errorMessages); - // 更改卡号 - List updateActiveList = new ArrayList<>(); - List updateWalletManageList = new ArrayList<>(); - for (WalletImportActiveDto v : list) { - if (ObjectUtil.isEmpty(v.getId())) { - WalletManageEntity wallet = walletManageService.getWallet(v.getWalletId()); - // 校验阶段只确认饭卡钱包存在,实际绑定延迟到导入事务内,避免其他行校验失败后产生半成品数据。 - getSelf().createAndActiveCard(wallet); - WalletActiveEntity targetWalletActive = baseMapper.selectList(new LambdaQueryWrapper() - .eq(WalletActiveEntity::getUserId, v.getUserId())) - .stream().findFirst().orElse(null); - if (ObjectUtil.isEmpty(targetWalletActive)) { - throw new RuntimeException("用户 <" + v.getUsername() + "> 自动绑定饭卡失败,请检查所属食堂商户和开卡配置"); - } - v.setId(targetWalletActive.getId()); - v.setWalletId(targetWalletActive.getWalletId()); - } - if (ObjectUtil.isNotEmpty(v.getId())) { - WalletActiveEntity entity = new WalletActiveEntity(); - entity.setId(v.getId()); - entity.setCardNumber(v.getCardNumber()); - updateActiveList.add(entity); - } - if (ObjectUtil.isNotEmpty(v.getWalletId())) { - WalletManageEntity entity = new WalletManageEntity(); - entity.setId(v.getWalletId()); - entity.setCardNumber(v.getCardNumber()); - updateWalletManageList.add(entity); - } - } - // 更改钱包绑定的饭卡卡号 - baseMapper.updateById(updateActiveList); - // 更改钱包管理绑定的饭卡卡号 - walletManageService.updateBatchById(updateWalletManageList); - return R.ok(null, "导入成功"); - } - - /** - * 校验开卡导入数据并补充后续导入需要的用户、钱包及开卡记录标识。 - * - * @param list Excel 中读取的原始行数据,空白行会被过滤 - * @param bindingResult Excel 解析阶段产生的行级错误容器 - * @param validatePrice 预留的金额校验开关,当前开卡导入流程不使用 - * @return 左值为校验错误,右值为校验通过且已补充关联标识的导入数据 - */ - public ImmutablePair, List> validateExcelData(List list, BindingResult bindingResult, boolean validatePrice) { - if (ObjectUtil.isEmpty(list)) { - throw new RuntimeException("读取excel数据错误"); - } - list = list.stream().filter(v -> !ObjectUtil.isAllEmpty(v.getUsername(), v.getUserMobile(), v.getCardNumber())).toList(); - if (ObjectUtil.isEmpty(list)) { - throw new RuntimeException("读取excel数据错误"); - } - List errorMessageList = (List) bindingResult.getTarget(); - List usernameList = list.stream().map(WalletImportActiveDto::getUsername).toList(); - List userPoneList = list.stream().map(WalletImportActiveDto::getUserMobile).toList(); - for (WalletImportActiveDto v : list) { - Set errors = new LinkedHashSet<>(); - if (ObjectUtil.isEmpty(v.getUsername())) { - errors.add("请输入用户姓名"); - } - if (ObjectUtil.isEmpty(v.getUserMobile())) { - errors.add("请输入用户手机号"); - } - if (ObjectUtil.isEmpty(v.getCardNumber())) { - errors.add("请输入卡号"); - } else { - if (!List.of(8, 10, 12).contains(v.getCardNumber().length())) { - errors.add("卡号长度错误,卡号长度必须为8、10、12"); - } - } - if (ObjectUtil.isNotEmpty(v.getUsername()) && ObjectUtil.isNotEmpty(v.getUserMobile())) { - R user = userService.getUserByNameAndMobile(v.getUsername(), v.getUserMobile()); - Optional sysUser = Optional.of(user).map(R::getData).map(UserInfo::getSysUser); - if (sysUser.isEmpty()) { - errors.add("用户不存在"); - } else { - if (!ObjectUtil.equals(sysUser.get().getName(), v.getUsername())) { - errors.add("用户不存在"); - } else { - v.setUserId(sysUser.get().getUserId()); - v.setUserDeptId(sysUser.get().getDeptId()); - } - } - } - long userPhone = userPoneList.stream().filter(i -> ObjectUtil.equals(i, v.getUserMobile())).count(); - if (userPhone > 1) { - errors.add("用手机号 <" + v.getUserMobile() + "> 重复"); - } - if (ObjectUtil.isNotEmpty(errors)) { - errorMessageList.add(new ErrorMessage(v.getLineNum(), errors)); - } - } - // 查询用户有没有绑定过饭卡 - List userIdList = list.stream().map(WalletImportActiveDto::getUserId).distinct().filter(ObjectUtil::isNotEmpty).toList(); - if (ObjectUtil.isNotEmpty(userIdList)) { - List walletActiveList = baseMapper.selectList(new LambdaQueryWrapper().in(WalletActiveEntity::getUserId, userIdList)); - List walletManageList = walletManageService.getWalletsByUserIds(userIdList, WalletTypeEnum.EAT); - for (WalletImportActiveDto v : list) { - WalletActiveEntity targetWalletActive = walletActiveList.stream().filter(i -> ObjectUtil.equals(i.getUserId(), v.getUserId())).findFirst().orElse(null); - if (ObjectUtil.isEmpty(targetWalletActive)) { - WalletManageEntity targetWalletManage = walletManageList.stream().filter(i -> ObjectUtil.equals(i.getUserId(), v.getUserId())).findFirst().orElse(null); - if (ObjectUtil.isEmpty(targetWalletManage)) { - Set errors = new LinkedHashSet<>(); - // 饭卡钱包是自动绑卡的必要载体,缺失时保留行号并阻止整批数据写入。 - ErrorMessage errorMessage = errorMessageList.stream().filter(i -> ObjectUtil.equals(i.getLineNum(), v.getLineNum())).findFirst().orElse(null); - if (ObjectUtil.isNotEmpty(errorMessage)) { - errors = errorMessage.getErrors(); - } - errors.add("用户 <" + v.getUsername() + "> 饭卡钱包不存在"); - if (ObjectUtil.isEmpty(errorMessage)) { - errorMessageList.add(new ErrorMessage(v.getLineNum(), errors)); - } - } else { - v.setWalletId(targetWalletManage.getId()); - } - } else { - v.setId(targetWalletActive.getId()); - v.setWalletId(targetWalletActive.getWalletId()); - } - } - } - if (ObjectUtil.isNotEmpty(errorMessageList)) { - return ImmutablePair.of(errorMessageList, null); - } - return ImmutablePair.of(null, list); - } - private boolean existWalletCarNumber(String cardNumber, Long id) { Long count = baseMapper.selectCount(new LambdaQueryWrapper() .eq(ObjectUtil.isNotEmpty(cardNumber), WalletActiveEntity::getCardNumber, cardNumber) 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 new file mode 100644 index 00000000..5572585b --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyImportServiceImpl.java @@ -0,0 +1,534 @@ +package com.bsd.pjcloud.wallet.service.impl.recharge; + +import cn.hutool.core.lang.Validator; +import cn.hutool.core.util.ObjectUtil; +import com.bsd.pjcloud.admin.api.dto.UserInfo; +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.core.util.R; +import com.bsd.pjcloud.common.excel.vo.ErrorMessage; +import com.bsd.pjcloud.common.security.service.PjcloudUser; +import com.bsd.pjcloud.common.security.util.SecurityUtils; +import com.bsd.pjcloud.wallet.api.constant.WalletBizRechargeEnum; +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.WalletRechargeApplyImportService; +import com.bsd.pjcloud.wallet.service.WalletRechargeImportService; +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.Arrays; +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.stream.Collectors; + +/** + * 充值/扣款申请 Excel 导入服务实现。 + *

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

+ */ +@Service +@RequiredArgsConstructor +public class WalletRechargeApplyImportServiceImpl implements WalletRechargeApplyImportService { + + /** 充值流水财务标签使用的字典类型,Excel 标签需转换为稳定字典值后落库。 */ + private static final String TRANSACTION_TAG_DICT_TYPE = "wallet_transaction_tag"; + + /** 充值/扣款申请 Mapper,用于创建不绑定发票的申请主记录。 */ + private final WalletRechargeApplyMapper walletRechargeApplyMapper; + + /** 充值导入明细服务,用于批量保存已校验的数据行。 */ + private final WalletRechargeImportService walletRechargeImportService; + + /** 充值发票服务,用于绑定发票导入时创建基础发票记录。 */ + private final WalletRechargeInvoiceService walletRechargeInvoiceService; + + /** 用户远程服务,用于按姓名和手机号精确定位默认用户。 */ + private final RemoteUserService remoteUserService; + + /** 字典远程服务,用于将 Excel 财务标签名称转换为持久化值。 */ + private final RemoteDictService remoteDictService; + + /** + * 校验默认用户充值、扣款或退款模板,并创建申请及明细记录。 + * + * @param rows 默认用户 Excel 数据行 + * @param bindingResult Excel 解析阶段的逐行校验结果 + * @param bizType 充值业务类型协议值 + * @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); + } + + /** + * 校验默认用户余额重置模板,并以零金额创建申请明细。 + * + * @param rows 默认用户余额重置 Excel 数据行 + * @param bindingResult Excel 解析阶段的逐行校验结果 + * @param bizType 余额重置业务类型协议值 + * @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); + } + + /** + * 校验学生充值、扣款或退款模板,并创建申请及明细记录。 + * + * @param rows 学生 Excel 数据行 + * @param bindingResult Excel 解析阶段的逐行校验结果 + * @param bizType 充值业务类型协议值 + * @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); + } + + /** + * 校验学生余额重置模板,并以零金额创建申请明细。 + * + * @param rows 学生余额重置 Excel 数据行 + * @param bindingResult Excel 解析阶段的逐行校验结果 + * @param bizType 余额重置业务类型协议值 + * @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); + } + + /** + * 校验绑定发票的默认用户充值模板,创建基础发票及未绑定的导入明细。 + * + * @param rows 默认用户充值 Excel 数据行 + * @param bindingResult Excel 解析阶段的逐行校验结果 + * @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); + WalletRechargeInvoiceEntity invoice = walletRechargeInvoiceService.createBaseInvoice(loginUser, totalPrice); + if (invoice == null) { + throw new IllegalStateException("导入失败"); + } + List details = validatedRows.stream() + .map(row -> WalletRechargeImportEntity.builder() + .price(row.getPriceNumber()) + .userId(row.getUserId()) + .userDeptId(row.getUserDeptId()) + .invoiceId(invoice.getId()) + .tags(row.getTags()) + .build()) + .toList(); + walletRechargeImportService.saveBatch(details); + return R.ok(null, "导入成功"); + } + + /** + * 创建当前登录人的申请主记录,并批量保存已校验导入明细。 + * + * @param rows 已完成用户定位与字段校验的数据行 + * @param bizType 充值业务类型协议值 + * @param resetBalance 是否为余额重置;为真时明细金额固定为零 + * @return 导入成功结果 + */ + private R createApplyAndDetails(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("导入失败"); + } + 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()) + .build()) + .toList(); + walletRechargeImportService.saveBatch(details); + return R.ok(null, "导入成功"); + } + + /** + * 创建学生充值/扣款申请主记录,并批量保存已解析学生用户的明细。 + * + * @param rows 已完成学生定位与字段校验的数据行 + * @param bizType 充值业务类型协议值 + * @param resetBalance 是否为余额重置;为真时明细金额固定为零 + * @return 导入成功结果 + */ + 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("导入失败"); + } + 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()) + .build()) + .toList(); + walletRechargeImportService.saveBatch(details); + return R.ok(null, "导入成功"); + } + + /** + * 校验接口与充值业务类型匹配,防止绕过前端把余额重置提交到金额导入入口。 + * + * @param bizType 充值业务类型协议值 + * @param resetEndpoint 当前是否为余额重置专用入口 + */ + private void validateBizType(String bizType, boolean resetEndpoint) { + if (ObjectUtil.isEmpty(bizType)) { + throw new IllegalArgumentException("请选择充值类型"); + } + boolean resetBizType = ObjectUtil.equals(WalletBizRechargeEnum.RESET.getCode(), bizType); + if (resetEndpoint != resetBizType) { + throw new IllegalArgumentException("充值类型与导入模板不匹配"); + } + boolean supported = Arrays.stream(WalletBizRechargeEnum.values()) + .anyMatch(item -> ObjectUtil.equals(item.getCode(), bizType)); + if (!supported) { + throw new IllegalArgumentException("充值类型不存在"); + } + } + + /** + * 校验默认用户模板并补齐用户、部门、分值金额及财务标签字典值。 + * + * @param rows Excel 原始数据行 + * @param bindingResult 公共 Excel 监听器生成的逐行错误 + * @param validatePrice 是否要求并解析操作金额 + * @return 左侧为错误集合,右侧为校验通过的数据行,二者不会同时有值 + */ + @SuppressWarnings("unchecked") + private ImmutablePair, List> validateExcelData( + List rows, BindingResult bindingResult, boolean validatePrice) { + if (ObjectUtil.isEmpty(rows)) { + throw new IllegalArgumentException("读取excel数据错误"); + } + List effectiveRows = rows.stream() + .filter(row -> !ObjectUtil.isAllEmpty(row.getUsername(), row.getUserMobile(), row.getPrice())) + .toList(); + if (effectiveRows.isEmpty()) { + throw new IllegalArgumentException("读取excel数据错误"); + } + List errors = getErrorMessageList(bindingResult); + resolveTransactionTagValues(effectiveRows, errors); + List userPhones = 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) { + rowErrors.add("用户手机号 <" + row.getUserMobile() + "> 重复"); + } + if (!rowErrors.isEmpty()) { + errors.add(new ErrorMessage(row.getLineNum(), rowErrors)); + } + } + return errors.isEmpty() ? ImmutablePair.of(null, effectiveRows) : ImmutablePair.of(errors, null); + } + + /** + * 校验学生模板并按学校、学号补齐用户及部门信息。 + * + * @param rows 学生 Excel 原始数据行 + * @param bindingResult 公共 Excel 监听器生成的逐行错误 + * @param validatePrice 是否要求并解析操作金额 + * @return 左侧为错误集合,右侧为校验通过的数据行,二者不会同时有值 + */ + @SuppressWarnings("unchecked") + private ImmutablePair, List> validateStudentExcelData( + List rows, BindingResult bindingResult, boolean validatePrice) { + if (ObjectUtil.isEmpty(rows)) { + throw new IllegalArgumentException("读取excel数据错误"); + } + List effectiveRows = rows.stream() + .filter(row -> !ObjectUtil.isAllEmpty(row.getStudentName(), row.getSchoolName(), row.getStudentNo(), row.getPrice())) + .toList(); + if (effectiveRows.isEmpty()) { + throw new IllegalArgumentException("读取excel数据错误"); + } + List errors = getErrorMessageList(bindingResult); + resolveStudentTransactionTagValues(effectiveRows, errors); + List studentKeys = effectiveRows.stream() + .map(row -> buildStudentKey(row.getSchoolName(), row.getStudentNo())) + .toList(); + for (WalletStudentRechargeExcelDto row : effectiveRows) { + Set rowErrors = new LinkedHashSet<>(); + validateStudentPrice(row, validatePrice, rowErrors); + resolveStudentUser(row, 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) { + rowErrors.add("学校和学号 <" + row.getSchoolName() + " / " + row.getStudentNo() + "> 在Excel中重复"); + } + if (!rowErrors.isEmpty()) { + errors.add(new ErrorMessage(row.getLineNum(), rowErrors)); + } + } + return errors.isEmpty() ? ImmutablePair.of(null, effectiveRows) : ImmutablePair.of(errors, null); + } + + /** + * 校验并将元单位操作金额转换为钱包统一使用的整数分。 + * + * @param row 当前 Excel 数据行 + * @param required 是否要求填写操作金额 + * @param errors 当前行错误集合 + */ + private void validatePrice(WalletRechargeExcelDto row, boolean required, Set errors) { + if (!required) { + return; + } + if (ObjectUtil.isEmpty(row.getPrice())) { + errors.add("请输入操作金额"); + return; + } + try { + row.setPriceNumber(new BigDecimal(row.getPrice()).multiply(BigDecimal.valueOf(100)).intValue()); + } catch (Exception exception) { + errors.add("请输入正确的操作金额"); + } + } + + /** + * 校验学生模板操作金额,并把元单位金额转换为整数分。 + * + * @param row 当前学生 Excel 数据行 + * @param required 是否要求填写操作金额 + * @param errors 当前行错误集合 + */ + private void validateStudentPrice(WalletStudentRechargeExcelDto row, boolean required, Set errors) { + if (!required) { + return; + } + if (ObjectUtil.isEmpty(row.getPrice())) { + errors.add("请输入操作金额"); + return; + } + try { + row.setPriceNumber(new BigDecimal(row.getPrice()).multiply(BigDecimal.valueOf(100)).intValue()); + } catch (Exception exception) { + errors.add("请输入正确的操作金额"); + } + } + + /** + * 按姓名和手机号精确定位默认用户,并把用户及部门 ID 写回导入行。 + * + * @param row 当前 Excel 数据行 + * @param errors 当前行错误集合 + */ + private void resolveDefaultUser(WalletRechargeExcelDto row, Set errors) { + if (ObjectUtil.isEmpty(row.getUsername())) { + errors.add("请输入用户名"); + return; + } + if (ObjectUtil.isEmpty(row.getUserMobile())) { + errors.add("请输入用户手机号"); + return; + } + if (!Validator.isMobile(row.getUserMobile())) { + 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())) { + errors.add("用户不存在"); + return; + } + row.setUserId(user.get().getUserId()); + row.setUserDeptId(user.get().getDeptId()); + } + + /** + * 按学校和学号精确定位学生,并校验模板姓名与系统用户一致。 + * + * @param row 当前学生 Excel 数据行 + * @param errors 当前行错误集合 + */ + private void resolveStudentUser(WalletStudentRechargeExcelDto row, Set errors) { + if (ObjectUtil.isEmpty(row.getStudentName())) { + errors.add("请输入学生姓名"); + } + if (ObjectUtil.isEmpty(row.getSchoolName())) { + errors.add("请输入学校"); + } + if (ObjectUtil.isEmpty(row.getStudentNo())) { + errors.add("请输入学号"); + } + 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()) { + errors.add("学生不存在"); + return; + } + if (!ObjectUtil.equals(user.get().getName(), row.getStudentName())) { + errors.add("学生姓名与学校、学号不匹配"); + return; + } + row.setUserId(user.get().getUserId()); + row.setUserDeptId(user.get().getDeptId()); + } + + /** + * 生成学校和学号组合键,仅用于当前学生 Excel 内重复校验。 + * + * @param schoolName 学校名称 + * @param studentNo 学号 + * @return 保留空值语义的组合键 + */ + private String buildStudentKey(String schoolName, String studentNo) { + return Objects.toString(schoolName, "") + "\u0000" + Objects.toString(studentNo, ""); + } + + /** + * 获取公共 Excel 解析器写入 BindingResult 的逐行错误集合。 + * + * @param bindingResult Excel 参数绑定结果 + * @return 可继续追加业务错误的列表;解析器未提供列表时返回新列表 + */ + @SuppressWarnings("unchecked") + private List getErrorMessageList(BindingResult bindingResult) { + Object target = bindingResult == null ? null : bindingResult.getTarget(); + return target instanceof List ? (List) target : new java.util.ArrayList<>(); + } + + /** + * 将 Excel 财务标签名称转换为稳定字典值,支持英文逗号分隔多个标签。 + * + * @param rows 已通过公共 Excel 字典标签校验的数据行 + * @param errors 当前导入的逐行错误集合 + */ + private void resolveTransactionTagValues(List rows, List errors) { + List taggedRows = rows.stream().filter(row -> ObjectUtil.isNotEmpty(row.getTag())).toList(); + if (taggedRows.isEmpty()) { + return; + } + R> result = remoteDictService.getDictByType(TRANSACTION_TAG_DICT_TYPE); + if (result == null || !result.isOk() || ObjectUtil.isEmpty(result.getData())) { + taggedRows.forEach(row -> errors.add(new ErrorMessage(row.getLineNum(), Set.of("操作标签字典查询失败")))); + return; + } + Map valueByLabel = result.getData().stream() + .filter(item -> ObjectUtil.isNotEmpty(item.getLabel()) && ObjectUtil.isNotEmpty(item.getItemValue())) + .collect(Collectors.toMap(SysDictItem::getLabel, SysDictItem::getItemValue, (first, ignored) -> first)); + for (WalletRechargeExcelDto row : taggedRows) { + List tagValues = Arrays.stream(row.getTag().split(",")) + .map(String::trim) + .distinct() + .map(valueByLabel::get) + .filter(Objects::nonNull) + .toList(); + long selectedTagCount = Arrays.stream(row.getTag().split(",")).map(String::trim).distinct().count(); + if (tagValues.size() != selectedTagCount) { + errors.add(new ErrorMessage(row.getLineNum(), Set.of("操作标签包含无法映射的字典项"))); + continue; + } + row.setTags(String.join(",", tagValues)); + } + } + + /** + * 将学生 Excel 财务标签名称转换为稳定字典值。 + * + * @param rows 学生 Excel 数据行 + * @param errors 当前导入的逐行错误集合 + */ + private void resolveStudentTransactionTagValues(List rows, List errors) { + List taggedRows = rows.stream().filter(row -> ObjectUtil.isNotEmpty(row.getTag())).toList(); + if (taggedRows.isEmpty()) { + return; + } + R> result = remoteDictService.getDictByType(TRANSACTION_TAG_DICT_TYPE); + if (result == null || !result.isOk() || ObjectUtil.isEmpty(result.getData())) { + taggedRows.forEach(row -> errors.add(new ErrorMessage(row.getLineNum(), Set.of("操作标签字典查询失败")))); + return; + } + Map valueByLabel = result.getData().stream() + .filter(item -> ObjectUtil.isNotEmpty(item.getLabel()) && ObjectUtil.isNotEmpty(item.getItemValue())) + .collect(Collectors.toMap(SysDictItem::getLabel, SysDictItem::getItemValue, (first, ignored) -> first)); + for (WalletStudentRechargeExcelDto row : taggedRows) { + List tagValues = Arrays.stream(row.getTag().split(",")) + .map(String::trim) + .distinct() + .map(valueByLabel::get) + .filter(Objects::nonNull) + .toList(); + long selectedTagCount = Arrays.stream(row.getTag().split(",")).map(String::trim).distinct().count(); + if (tagValues.size() != selectedTagCount) { + errors.add(new ErrorMessage(row.getLineNum(), Set.of("操作标签包含无法映射的字典项"))); + continue; + } + row.setTags(String.join(",", tagValues)); + } + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyServiceImpl.java index a755fa87..ed1ecc55 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyServiceImpl.java @@ -2,7 +2,6 @@ package com.bsd.pjcloud.wallet.service.impl.recharge; import cn.hutool.core.date.DatePattern; import cn.hutool.core.date.LocalDateTimeUtil; -import cn.hutool.core.lang.Validator; import cn.hutool.core.util.ObjectUtil; import cn.hutool.extra.spring.SpringUtil; @@ -12,10 +11,8 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.bsd.pjcloud.admin.api.dto.UserInfo; import com.bsd.pjcloud.admin.api.entity.SysDept; -import com.bsd.pjcloud.admin.api.entity.SysDictItem; import com.bsd.pjcloud.admin.api.entity.SysUser; import com.bsd.pjcloud.admin.api.feign.RemoteDeptService; -import com.bsd.pjcloud.admin.api.feign.RemoteDictService; import com.bsd.pjcloud.admin.api.feign.RemoteUserService; import com.bsd.pjcloud.admin.api.utils.DataUtil; import com.bsd.pjcloud.admin.api.vo.UserVO; @@ -25,7 +22,6 @@ import com.bsd.pjcloud.common.core.util.CollectionUtils; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.data.datascope.DataScope; import com.bsd.pjcloud.common.data.tenant.TenantContextHolder; -import com.bsd.pjcloud.common.excel.vo.ErrorMessage; import com.bsd.pjcloud.common.security.service.PjcloudUser; import com.bsd.pjcloud.common.security.util.SecurityUtils; import com.bsd.pjcloud.flow.task.api.feign.RemoteFlowEngineService; @@ -43,7 +39,6 @@ import com.bsd.pjcloud.wallet.api.dto.WalletTypeDto; import com.bsd.pjcloud.wallet.api.dto.recharge.WalletRechargeApplyDto; import com.bsd.pjcloud.wallet.api.dto.recharge.WalletRechargeImportDto; import com.bsd.pjcloud.wallet.api.dto.recharge.WalletRechargeSaveTopupDto; -import com.bsd.pjcloud.wallet.api.dto.recharge.excel.WalletRechargeExcelDto; import com.bsd.pjcloud.wallet.api.entity.WalletManageEntity; import com.bsd.pjcloud.wallet.api.entity.WalletTypeEntity; import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeApplyEntity; @@ -58,7 +53,6 @@ import com.bsd.pjcloud.wallet.mapper.WalletManageMapper; import com.bsd.pjcloud.wallet.mapper.WalletTransactionMapper; import com.bsd.pjcloud.wallet.mapper.WalletTypeMapper; import com.bsd.pjcloud.wallet.mapper.recharge.WalletRechargeApplyMapper; -import com.bsd.pjcloud.wallet.mapper.recharge.WalletRechargeInvoiceMapper; import com.bsd.pjcloud.wallet.service.*; import com.bsd.pjcloud.wallet.utils.flow.FlowUtil; import com.bsd.pjcloud.wallet.utils.flow.dto.WalletRechargeApplyFlowDto; @@ -66,11 +60,8 @@ import com.bsd.pjcloud.wallet.utils.flow.dto.WalletRechargeApplyFlowDto; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.tuple.ImmutablePair; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.validation.BindingResult; import org.springframework.validation.annotation.Validated; import java.math.BigDecimal; @@ -92,11 +83,6 @@ public class WalletRechargeApplyServiceImpl extends ServiceImpl excelVOList, BindingResult bindingResult, String bizType) { - ImmutablePair, List> pair = validateExcelData(excelVOList, bindingResult, true); - List errorMessages = pair.getLeft(); - List list = pair.getRight(); - if (ObjectUtil.isNotEmpty(errorMessages)) return R.failed(errorMessages); - PjcloudUser loginUser = SecurityUtils.getUser(); - WalletRechargeApplyEntity insertObj = new WalletRechargeApplyEntity(); - insertObj.setDeptId(loginUser.getDeptId()); - insertObj.setUserId(loginUser.getId()); - insertObj.setBizType(bizType); - int i = baseMapper.insert(insertObj); - if (i <= 0) { - throw new RuntimeException("导入失败"); - } - List batchSaveList = list.stream().map(v -> WalletRechargeImportEntity.builder().price(v.getPriceNumber()).userId(v.getUserId()).userDeptId(v.getUserDeptId()).tags(v.getTags()).build()).toList(); - batchSaveList.forEach(v -> v.setApplyId(insertObj.getId())); - rechargeImportService.saveBatch(batchSaveList); - return R.ok(null, "导入成功"); - } - - @Override - public R readResetExcel(List excelVOList, BindingResult bindingResult, String bizType) { - ImmutablePair, List> pair = validateExcelData(excelVOList, bindingResult, false); - List errorMessages = pair.getLeft(); - List list = pair.getRight(); - if (ObjectUtil.isNotEmpty(errorMessages)) return R.failed(errorMessages); - PjcloudUser loginUser = SecurityUtils.getUser(); - WalletRechargeApplyEntity insertObj = new WalletRechargeApplyEntity(); - insertObj.setDeptId(loginUser.getDeptId()); - insertObj.setUserId(loginUser.getId()); - insertObj.setBizType(bizType); - int i = baseMapper.insert(insertObj); - if (i <= 0) { - throw new RuntimeException("导入失败"); - } - List batchSaveList = list.stream().map(v -> WalletRechargeImportEntity.builder().price(0).userId(v.getUserId()).userDeptId(v.getUserDeptId()).tags(v.getTags()).build()).toList(); - batchSaveList.forEach(v -> v.setApplyId(insertObj.getId())); - rechargeImportService.saveBatch(batchSaveList); - return R.ok(null, "导入成功"); - } - - /** - * 读取充值excel内容 - */ - @Override - @Transactional(rollbackFor = Exception.class) - public R readTopupExcel(List excelVOList, BindingResult bindingResult) { - ImmutablePair, List> pair = validateExcelData(excelVOList, bindingResult, true); - List errorMessages = pair.getLeft(); - List list = pair.getRight(); - if (ObjectUtil.isNotEmpty(errorMessages)) return R.failed(errorMessages); - PjcloudUser loginUser = SecurityUtils.getUser(); - // 先创建发票 - Integer totalPrice = list.stream().map(WalletRechargeExcelDto::getPriceNumber).reduce(Integer::sum).orElse(0); - WalletRechargeInvoiceEntity invoice = walletRechargeInvoiceService.createBaseInvoice(loginUser, totalPrice); - if (ObjectUtil.isEmpty(invoice)) throw new RuntimeException("导入失败"); - // 导入数据 - List batchSaveList = list.stream().map(v -> WalletRechargeImportEntity.builder().price(v.getPriceNumber()).userId(v.getUserId()).userDeptId(v.getUserDeptId()).invoiceId(invoice.getId()).tags(v.getTags()).build()).toList(); - rechargeImportService.saveBatch(batchSaveList); - return R.ok(null, "导入成功"); - } - - /** - * 校验充值/扣款excel的数据 - */ - public ImmutablePair, List> validateExcelData(List list, BindingResult bindingResult, boolean validatePrice) { - if (ObjectUtil.isEmpty(list)) { - throw new RuntimeException("读取excel数据错误"); - } - list = list.stream().filter(v -> !ObjectUtil.isAllEmpty(v.getUsername(), v.getUserMobile(), v.getPrice())).toList(); - if (ObjectUtil.isEmpty(list)) { - throw new RuntimeException("读取excel数据错误"); - } - List errorMessageList = (List) bindingResult.getTarget(); - resolveTransactionTagValues(list, errorMessageList); - List usernameList = list.stream().map(WalletRechargeExcelDto::getUsername).toList(); - List userPoneList = list.stream().map(WalletRechargeExcelDto::getUserMobile).toList(); - for (WalletRechargeExcelDto v : list) { - Set errors = new LinkedHashSet<>(); - if (validatePrice) { - if (ObjectUtil.isEmpty(v.getPrice())) { - errors.add("请输入操作金额"); - } else { - try { - int i = new BigDecimal(v.getPrice()).multiply(new BigDecimal(100)).intValue(); - v.setPriceNumber(i); - } catch (Exception e) { - errors.add("请输入正确的操作金额"); - } - } - } - if (ObjectUtil.isEmpty(v.getUsername())) { - errors.add("请输入用户名"); - } else if (ObjectUtil.isEmpty(v.getUserMobile())) { - errors.add("请输入用户手机号"); - } else if (!Validator.isMobile(v.getUserMobile())) { - errors.add("用户手机号格式不正确"); - } else { - R user = userService.getUserByNameAndMobile(v.getUsername(), v.getUserMobile()); - Optional sysUser = Optional.of(user).map(R::getData).map(UserInfo::getSysUser); - if (sysUser.isEmpty()) { - errors.add("用户不存在"); - } else { - if (!ObjectUtil.equals(sysUser.get().getName(), v.getUsername())) { - errors.add("用户不存在"); - } else { - v.setUserId(sysUser.get().getUserId()); - v.setUserDeptId(sysUser.get().getDeptId()); - } - } - } - // long userNameCount = usernameList.stream().filter(i -> ObjectUtil.equals(i, v.getUsername())).count(); - // if (userNameCount > 1) { - // errors.add("用户名 <" + v.getUsername() + "> 重复"); - // } - long userPhone = userPoneList.stream().filter(i -> ObjectUtil.equals(i, v.getUserMobile())).count(); - if (userPhone > 1) { - errors.add("用手机号 <" + v.getUserMobile() + "> 重复"); - } - if (ObjectUtil.isNotEmpty(errors)) { - errorMessageList.add(new ErrorMessage(v.getLineNum(), errors)); - } - } - if (ObjectUtil.isNotEmpty(errorMessageList)) { - return ImmutablePair.of(errorMessageList, null); - } - return ImmutablePair.of(null, list); - } - - /** - * 将 Excel 中展示的财务标签转换为稳定的字典值,供导入记录和最终钱包流水持久化及统计查询。 - * - * @param list 已通过公共 Excel 字典标签校验的数据行 - * @param errorMessageList 当前导入任务的行级错误集合,字典服务异常或标签映射缺失时追加错误 - */ - private void resolveTransactionTagValues(List list, List errorMessageList) { - List taggedRows = list.stream().filter(item -> ObjectUtil.isNotEmpty(item.getTag())).toList(); - if (taggedRows.isEmpty()) { - return; - } - R> result = remoteDictService.getDictByType(TRANSACTION_TAG_DICT_TYPE); - if (result == null || !result.isOk() || ObjectUtil.isEmpty(result.getData())) { - taggedRows.forEach(item -> errorMessageList.add(new ErrorMessage(item.getLineNum(), Set.of("操作标签字典查询失败")))); - return; - } - Map valueByLabel = result.getData().stream() - .filter(item -> ObjectUtil.isNotEmpty(item.getLabel()) && ObjectUtil.isNotEmpty(item.getItemValue())) - .collect(java.util.stream.Collectors.toMap(SysDictItem::getLabel, SysDictItem::getItemValue, (first, ignored) -> first)); - for (WalletRechargeExcelDto item : taggedRows) { - List tagValues = Arrays.stream(item.getTag().split(",")) - .map(String::trim) - .distinct() - .map(valueByLabel::get) - .filter(Objects::nonNull) - .toList(); - long selectedTagCount = Arrays.stream(item.getTag().split(",")).map(String::trim).distinct().count(); - if (tagValues.size() != selectedTagCount) { - errorMessageList.add(new ErrorMessage(item.getLineNum(), Set.of("操作标签包含无法映射的字典项"))); - continue; - } - item.setTags(String.join(",", tagValues)); - } - } - - /* ************************************* end excel end ************************************* */ - @Override @Transactional(rollbackFor = Exception.class) public void applyCallback(ProcessNodeRecordAssignUserParamDto dto, Long tenantId) {