feat:删除绑定关系

feat:修改绑定关系权限问题
feat:学生导入饭卡
feat:学生充值导入
feat:学生用户类型增加监护人手机号
This commit is contained in:
dzc committed 2026-08-17 18:42:09 +08:00
1 parent 195756e80a
commit f7aab36210
33 files changed
+1863 -398

No files matched your search

@@ -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;
@@ -19,6 +19,12 @@ public class SysUserStudentDto {
@Schema(description = "班级名称")
private String className;
/**
* 监护人手机号集合,多个手机号使用英文逗号分隔;表单提交后会去重并规范化。
*/
@Schema(description = "监护人手机号集合,多个手机号使用英文逗号分隔")
private String guardianPhones;
@Schema(description = "其他非核心扩展信息")
private String extJson;
}
@@ -44,6 +44,12 @@ public class SysUserStudentEntity extends Model<SysUserStudentEntity> {
@Schema(description = "班级名称")
private String className;
/**
* 监护人手机号集合,表单可直接维护;监护关系变更时按关系创建顺序重算,多个手机号使用英文逗号分隔。
*/
@Schema(description = "监护人手机号集合,多个手机号使用英文逗号分隔")
private String guardianPhones;
/**
* 其他非核心扩展信息
*/
@@ -69,4 +75,4 @@ public class SysUserStudentEntity extends Model<SysUserStudentEntity> {
*/
@Schema(description = "所属租户ID")
private Long tenantId;
}
}
@@ -80,6 +80,17 @@ public interface RemoteUserService {
@GetMapping("/user/get-name-and-mobile")
R<UserInfo> getUserByNameAndMobile(@RequestParam("name") String name, @RequestParam("mobile") String mobile);
/**
* 根据学生学校和学号精确查询学生用户。
*
* @param schoolName 学生身份扩展信息中的学校名称
* @param studentNo 学生身份扩展信息中的学号
* @return 唯一学生的用户信息;不存在或数据不唯一时返回失败响应
*/
@GetMapping("/user/get-student-by-school-and-no")
R<UserInfo> getStudentBySchoolNameAndStudentNo(@RequestParam("schoolName") String schoolName,
@RequestParam("studentNo") String studentNo);
/**
* 根据用户ID获取用户
*/
@@ -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);
@@ -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<UserInfo> 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<List<UserDeptSimpleInfo>> getUserByNameOrMobile(@RequestParam("keyword") String keyword) {
@@ -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<Long> deleteRelation(@PathVariable Long relationId) {
return R.ok(sysUserRelationService.deleteRelation(relationId));
}
}
@@ -71,6 +71,20 @@ public interface SysUserRelationService extends IService<SysUserRelationEntity>
*/
List<SysUserGuardianWardOptionVo> listActiveGuardianWardOptions(Long guardianUserId);
/**
* 根据当前未删除的监护关系重新生成指定学生的全部监护人手机号。
*
* @param studentUserIds 待同步的学生用户 ID 列表;非学生用户会被忽略
*/
void syncStudentGuardianPhones(List<Long> studentUserIds);
/**
* 监护人手机号变化后,重新生成其全部被监护学生的手机号集合。
*
* @param guardianUserId 手机号发生变化的监护人用户 ID
*/
void syncGuardianRelatedStudentPhones(Long guardianUserId);
/**
* 删除指定用户作为关系任一端点的全部用户关系
*
@@ -101,4 +115,12 @@ public interface SysUserRelationService extends IService<SysUserRelationEntity>
* @return 关系ID
*/
Long updateRelation(SysUserRelationUpdateDto dto);
/**
* 按关系ID删除用户关系,仅解除关系,不删除关系两端的用户。
*
* @param relationId 待删除的关系ID
* @return 已删除的关系ID
*/
Long deleteRelation(Long relationId);
}
@@ -69,12 +69,18 @@ public interface SysUserService extends IService<SysUser> {
Boolean deleteUserByIds(Long[] ids);
/**
* 更新当前用户基本信息
* 更新当前用户基本信息;手机号变化时同步刷新其被监护学生的监护人手机号集合。
*
* @param userDto 当前用户提交的基础资料
* @return 更新结果
*/
R<Boolean> updateUserInfo(UserDTO userDto);
/**
* 更新指定用户信息
* 更新指定用户信息,并同步维护学生身份及监护关系派生的手机号集合。
*
* @param userDto 待更新用户的完整资料
* @return 是否更新成功
*/
Boolean updateUser(UserDTO userDto);
@@ -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<SysUserStudentEntity> {
/**
@@ -14,4 +15,23 @@ public interface SysUserStudentService extends IService<SysUserStudentEntity> {
* @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);
}
@@ -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);
}
@@ -480,7 +480,14 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> impl
return Boolean.TRUE;
}
/**
* 更新当前登录用户的基础资料,手机号成功变更后同步刷新其被监护学生的手机号集合。
*
* @param userDto 当前用户提交的基础资料
* @return 更新结果;姓名或昵称校验失败时返回失败响应
*/
@Override
@Transactional(rollbackFor = Exception.class)
@CacheEvict(value = CacheConstants.USER_DETAILS, key = "#userDto.username")
public R<Boolean> updateUserInfo(UserDTO userDto) {
if (StrUtil.isBlank(userDto.getUsername())) {
@@ -505,7 +512,11 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> 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<SysUserMapper, SysUser> 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) {
@@ -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<SysUserIdentityMappe
/** 老师学号最大长度,与老师扩展表字段长度保持一致。 */
private static final int TEACHER_NO_MAX_LENGTH = 60;
/** 中国大陆手机号校验规则,用于默认身份绑定手机号。 */
/** 中国大陆手机号校验规则,用于默认身份和学生监护人手机号。 */
private static final String PHONE_PATTERN = "^1[3-9]\\d{9}$";
/** 监护人手机号输入分隔规则,同时兼容中文逗号并在保存时统一为英文逗号。 */
private static final String GUARDIAN_PHONE_SPLIT_PATTERN = "[,,]";
/** 监护人手机号持久化分隔符,数据库统一使用英文逗号。 */
private static final String GUARDIAN_PHONE_SEPARATOR = ",";
/** 学生扩展信息服务,负责学生资料持久化和学号查重。 */
private final SysUserStudentService sysUserStudentService;
@@ -243,6 +250,28 @@ public class SysUserIdentityServiceImpl extends ServiceImpl<SysUserIdentityMappe
if (StrUtil.isBlank(studentInfo.getSchoolName())) {
throw new IllegalArgumentException("学校名称不能为空");
}
String submittedGuardianPhones = studentInfo.getGuardianPhones();
if (StrUtil.isBlank(submittedGuardianPhones) && userDto.getUserId() != null) {
// 兼容未提交该新增字段的旧调用方;已有值只能被显式的新值替换,不能因缺字段被清空。
SysUserStudentEntity currentStudent = sysUserStudentService.getById(userDto.getUserId());
submittedGuardianPhones = currentStudent == null ? null : currentStudent.getGuardianPhones();
}
List<String> 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<SysUserIdentityMappe
}
studentInfo.setStudentNo(studentNo);
studentInfo.setSchoolName(schoolName);
studentInfo.setGuardianPhones(String.join(GUARDIAN_PHONE_SEPARATOR, guardianPhones));
}
/**
@@ -20,6 +20,7 @@ import com.bsd.pjcloud.admin.mapper.user.SysUserRelationMapper;
import com.bsd.pjcloud.admin.service.SysUserRelationService;
import com.bsd.pjcloud.admin.service.SysUserRelationTypeService;
import com.bsd.pjcloud.admin.service.SysUserService;
import com.bsd.pjcloud.admin.service.SysUserStudentService;
import com.bsd.pjcloud.common.core.constant.CommonConstants;
import com.bsd.pjcloud.common.core.exception.ErrorCodes;
import com.bsd.pjcloud.common.core.util.MsgUtils;
@@ -55,10 +56,16 @@ public class SysUserRelationServiceImpl extends ServiceImpl<SysUserRelationMappe
private static final Long TREE_ROOT_NODE_ID = 0L;
/** 多个监护人手机号的持久化分隔符,与数据库迁移脚本的历史回填格式保持一致。 */
private static final String GUARDIAN_PHONE_SEPARATOR = ",";
private final SysUserService sysUserService;
private final SysUserRelationTypeService sysUserRelationTypeService;
/** 学生扩展信息服务,用于维护由监护关系派生的手机号集合。 */
private final SysUserStudentService sysUserStudentService;
/**
* 按关系对象反向查询消息接收人,并在事件时间和处理时间分别校验有效期。
*
@@ -289,16 +296,110 @@ public class SysUserRelationServiceImpl extends ServiceImpl<SysUserRelationMappe
.toList();
}
/**
* 根据当前未删除的监护关系重算学生的全部监护人手机号。
* <p>关系有效期用于消息等时点授权,手机号集合表达后台已经建立的逻辑绑定,因此这里不随时间自动增删;
* 关系改向、改类型或删除时会立即整体重算,避免保留旧手机号。</p>
*
* @param studentUserIds 待同步的学生用户 ID 列表;非学生用户会被学生扩展表更新自然忽略
*/
@Override
public void syncStudentGuardianPhones(List<Long> studentUserIds) {
if (CollUtil.isEmpty(studentUserIds)) {
return;
}
List<Long> distinctStudentUserIds = studentUserIds.stream()
.filter(Objects::nonNull)
.distinct()
.toList();
if (distinctStudentUserIds.isEmpty()) {
return;
}
List<Long> guardianWardRelationTypeIds = listGuardianWardRelationTypeIds();
List<SysUserRelationEntity> guardianRelations = guardianWardRelationTypeIds.isEmpty() ? List.of()
: list(Wrappers.<SysUserRelationEntity>lambdaQuery()
.in(SysUserRelationEntity::getObjectUserId, distinctStudentUserIds)
.in(SysUserRelationEntity::getRelationTypeId, guardianWardRelationTypeIds)
.orderByAsc(SysUserRelationEntity::getRelationId));
List<Long> guardianUserIds = guardianRelations.stream()
.map(SysUserRelationEntity::getSubjectUserId)
.filter(Objects::nonNull)
.distinct()
.toList();
Map<Long, SysUser> guardianUserMap = guardianUserIds.isEmpty() ? Map.of()
: sysUserService.listByIds(guardianUserIds).stream()
.collect(Collectors.toMap(SysUser::getUserId, Function.identity(), (first, second) -> first));
Map<Long, LinkedHashSet<String>> 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<String> 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<Long> guardianWardRelationTypeIds = listGuardianWardRelationTypeIds();
if (guardianWardRelationTypeIds.isEmpty()) {
return;
}
List<Long> studentUserIds = list(Wrappers.<SysUserRelationEntity>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<Long> userIds) {
if (CollUtil.isEmpty(userIds)) {
return;
}
List<SysUserRelationEntity> deletingRelations = list(Wrappers.<SysUserRelationEntity>lambdaQuery()
.and(wrapper -> wrapper.in(SysUserRelationEntity::getSubjectUserId, userIds)
.or()
.in(SysUserRelationEntity::getObjectUserId, userIds)));
Set<Long> guardianWardRelationTypeIds = new HashSet<>(listGuardianWardRelationTypeIds());
List<Long> affectedStudentUserIds = deletingRelations.stream()
.filter(relation -> guardianWardRelationTypeIds.contains(relation.getRelationTypeId()))
.map(SysUserRelationEntity::getObjectUserId)
.filter(Objects::nonNull)
.distinct()
.toList();
// 用户可能位于有向或无向关系的任一端,删除时必须同时清理两种引用,避免关系图残留失效节点。
remove(Wrappers.<SysUserRelationEntity>lambdaQuery()
.and(wrapper -> wrapper.in(SysUserRelationEntity::getSubjectUserId, userIds)
.or()
.in(SysUserRelationEntity::getObjectUserId, userIds)));
syncStudentGuardianPhones(affectedStudentUserIds);
}
/**
@@ -329,6 +430,33 @@ public class SysUserRelationServiceImpl extends ServiceImpl<SysUserRelationMappe
.last("limit 1"));
}
/**
* 查询当前租户全部监护关系类型 ID,不限制启用状态,以便已有关系类型停用后仍能正确维护历史绑定数据。
*
* @return 当前租户监护关系类型 ID 列表
*/
private List<Long> listGuardianWardRelationTypeIds() {
return sysUserRelationTypeService.list(Wrappers.<SysUserRelationTypeEntity>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<SysUserRelationMappe
relation.setExpireTime(dto.getExpireTime());
relation.setRemark(dto.getRemark());
save(relation);
if (isGuardianWardRelationType(relationType)) {
syncStudentGuardianPhones(List.of(objectUserId));
}
return newUserId;
}
@@ -361,6 +492,9 @@ public class SysUserRelationServiceImpl extends ServiceImpl<SysUserRelationMappe
SysUserRelationEntity relation = new SysUserRelationEntity();
BeanUtils.copyProperties(dto, relation);
save(relation);
if (isGuardianWardRelationType(relationType)) {
syncStudentGuardianPhones(List.of(relation.getObjectUserId()));
}
return relation.getRelationId();
}
@@ -377,6 +511,11 @@ public class SysUserRelationServiceImpl extends ServiceImpl<SysUserRelationMappe
if (relation == null) {
throw new RuntimeException("用户关系不存在");
}
SysUserRelationTypeEntity previousRelationType = sysUserRelationTypeService.getById(relation.getRelationTypeId());
Set<Long> 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<SysUserRelationMappe
BeanUtils.copyProperties(dto, relation);
updateById(relation);
if (isGuardianWardRelationType(relationType) && relation.getObjectUserId() != null) {
affectedStudentUserIds.add(relation.getObjectUserId());
}
syncStudentGuardianPhones(affectedStudentUserIds.stream().toList());
return relation.getRelationId();
}
/**
* 按关系ID逻辑删除用户关系,关系两端的用户及其业务数据保持不变。
*
* @param relationId 待删除的关系ID
* @return 已删除的关系ID
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Long deleteRelation(Long relationId) {
SysUserRelationEntity relation = getById(relationId);
if (relation == null) {
throw new RuntimeException("用户关系不存在");
}
SysUserRelationTypeEntity relationType = sysUserRelationTypeService.getById(relation.getRelationTypeId());
if (!removeById(relationId)) {
throw new RuntimeException("用户关系删除失败");
}
if (isGuardianWardRelationType(relationType) && relation.getObjectUserId() != null) {
syncStudentGuardianPhones(List.of(relation.getObjectUserId()));
}
return relationId;
}
private void validateCreateUserAndRelation(SysUserRelationCreateUserDto dto) {
Long existingUserId = dto.getSubjectUserId() == null ? dto.getObjectUserId() : dto.getSubjectUserId();
validateExistingUserAndRelationType(existingUserId, dto.getRelationTypeId());
@@ -406,10 +572,15 @@ public class SysUserRelationServiceImpl extends ServiceImpl<SysUserRelationMappe
if (Objects.equals(subjectUserId, objectUserId)) {
throw new RuntimeException("不能与自己建立用户关系");
}
if (sysUserService.getById(subjectUserId) == null) {
SysUser subjectUser = sysUserService.getById(subjectUserId);
if (subjectUser == null) {
throw new RuntimeException("关系主语用户不存在");
}
return validateObjectUserAndRelationType(objectUserId, relationTypeId);
SysUserRelationTypeEntity relationType = validateObjectUserAndRelationType(objectUserId, relationTypeId);
if (isGuardianWardRelationType(relationType) && StrUtil.isBlank(subjectUser.getPhone())) {
throw new RuntimeException("监护人手机号不能为空");
}
return relationType;
}
private SysUserRelationTypeEntity validateObjectUserAndRelationType(Long objectUserId, Long relationTypeId) {
@@ -8,12 +8,18 @@ import com.bsd.pjcloud.admin.mapper.user.SysUserStudentMapper;
import com.bsd.pjcloud.admin.service.SysUserStudentService;
import org.springframework.stereotype.Service;
/**
* 学生用户扩展信息表
*/
/** 学生用户扩展信息服务实现,使用租户 MyBatis 拦截器隔离学校和学号查询。 */
@Service
public class SysUserStudentServiceImpl extends ServiceImpl<SysUserStudentMapper, SysUserStudentEntity> 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 ServiceImpl<SysUserStudentMapper,
.eq(SysUserStudentEntity::getStudentNo, studentNo.trim())
.ne(userId != null, SysUserStudentEntity::getUserId, userId));
}
/**
* 更新学生的监护人手机号派生字段,显式写入空值以清理已经解除的最后一条监护关系。
*
* @param userId 学生用户 ID
* @param guardianPhones 按关系顺序去重后的监护人手机号,英文逗号分隔;无有效值时为空
* @return 学生扩展记录是否执行了更新
*/
@Override
public boolean updateGuardianPhones(Long userId, String guardianPhones) {
if (userId == null) {
return false;
}
return update(Wrappers.<SysUserStudentEntity>lambdaUpdate()
.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<SysUserStudentEntity> students = list(Wrappers.<SysUserStudentEntity>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);
}
}
@@ -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.<Wrapper<SysUserRelationEntity>>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.<Wrapper<SysUserRelationEntity>>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);
@@ -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;
}
@@ -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;
/**
* 学生开卡导入行。
* <p>使用学生学校和学号精确定位学生身份,再校验学生姓名,避免同名学生绑定错误。</p>
*/
@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;
}
@@ -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 导入行。
* <p>使用学校和学号精确定位学生,再校验学生姓名,避免同名学生被错误充值或扣款。</p>
*/
@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;
}
@@ -112,6 +112,10 @@
<groupId>com.bsd</groupId>
<artifactId>pjcloud-common-idempotent</artifactId>
</dependency>
<dependency>
<groupId>com.bsd</groupId>
<artifactId>pjcloud-common-async-task</artifactId>
</dependency>
<dependency>
<groupId>com.bsd</groupId>
<artifactId>pjcloud-flow-task-api</artifactId>
@@ -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<WalletImportActiveDto> 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<WalletImportActiveDto> 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<WalletStudentImportActiveDto> excelVOList, BindingResult bindingResult) {
return walletActiveImportService.importStudent(excelVOList, bindingResult);
}
}
@@ -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<WalletRechargeExcelDto> 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<WalletRechargeExcelDto> 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<WalletRechargeExcelDto> excelVOList, BindingResult bindingResult, @RequestParam(required = false) String bizType) {
return walletRechargeApplyService.readResetExcel(excelVOList, bindingResult, bizType);
public R<?> readResetExcel(@RequestExcel List<WalletRechargeExcelDto> 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<WalletStudentRechargeExcelDto> 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<WalletStudentRechargeExcelDto> 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<WalletRechargeExcelDto> excelVOList, BindingResult bindingResult) {
return walletRechargeApplyService.readTopupExcel(excelVOList, bindingResult);
public R<?> readTopupExcel(@RequestExcel List<WalletRechargeExcelDto> excelVOList, BindingResult bindingResult) {
return walletRechargeApplyImportService.importTopup(excelVOList, bindingResult);
}
/* ************************************* start excel start ************************************* */
}
}
@@ -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<WalletImportActiveDto> rows, BindingResult bindingResult);
/**
* 导入学生开卡模板,按学校和学号定位学生并校验姓名。
*
* @param rows 学生开卡 Excel 行
* @param bindingResult Excel 解析阶段的逐行校验结果
* @return 校验失败时返回逐行错误;通过时返回异步任务初始进度
*/
R<?> importStudent(List<WalletStudentImportActiveDto> rows, BindingResult bindingResult);
}
@@ -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<WalletActiveEntity> {
/**
* 一键开卡
@@ -80,9 +73,4 @@ public interface WalletActiveService extends IService<WalletActiveEntity> {
*/
void delCardByCardNumber(String cardNumber);
/**
* 导入开卡信息,会直接覆盖
*/
R importWalletActive(List<WalletImportActiveDto> excelVOList, BindingResult bindingResult);
}
}
@@ -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<WalletRechargeExcelDto> rows, BindingResult bindingResult, String bizType);
/**
* 导入不绑定发票的默认用户余额重置数据,操作金额固定为零。
*
* @param rows 默认用户余额重置 Excel 数据行
* @param bindingResult Excel 解析阶段的逐行校验结果
* @param bizType 余额重置业务类型协议值
* @return 校验失败时返回逐行错误,通过时返回导入成功结果
*/
R<?> importDefaultReset(List<WalletRechargeExcelDto> rows, BindingResult bindingResult, String bizType);
/**
* 导入不绑定发票的学生充值、扣款或退款数据。
*
* @param rows 学生 Excel 数据行
* @param bindingResult Excel 解析阶段的逐行校验结果
* @param bizType 充值业务类型协议值
* @return 校验失败时返回逐行错误,通过时返回导入成功结果
*/
R<?> importStudent(List<WalletStudentRechargeExcelDto> rows, BindingResult bindingResult, String bizType);
/**
* 导入不绑定发票的学生余额重置数据,操作金额固定为零。
*
* @param rows 学生余额重置 Excel 数据行
* @param bindingResult Excel 解析阶段的逐行校验结果
* @param bizType 余额重置业务类型协议值
* @return 校验失败时返回逐行错误,通过时返回导入成功结果
*/
R<?> importStudentReset(List<WalletStudentRechargeExcelDto> rows, BindingResult bindingResult, String bizType);
/**
* 导入绑定发票前的充值明细并创建基础发票记录。
*
* @param rows 默认用户充值 Excel 数据行
* @param bindingResult Excel 解析阶段的逐行校验结果
* @return 校验失败时返回逐行错误,通过时返回导入成功结果
*/
R<?> importTopup(List<WalletRechargeExcelDto> rows, BindingResult bindingResult);
}
@@ -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<WalletRechargeApply
*/
void cancelApply(WalletRechargeApplyDto dto);
/**
* 读取记账扣款、退回各自食堂excel内容
*/
R readExcel(List<WalletRechargeExcelDto> excelVOList, BindingResult bindingResult, String bizType);
/**
* 读取重置钱包的excel
*/
R readResetExcel(List<WalletRechargeExcelDto> excelVOList, BindingResult bindingResult, String bizType);
/**
* 读取充值excel内容
*/
R readTopupExcel(List<WalletRechargeExcelDto> excelVOList, BindingResult bindingResult);
/**
* 审批回调
*/
@@ -75,4 +57,4 @@ public interface WalletRechargeApplyService extends IService<WalletRechargeApply
void updateBeforePrice(WalletRechargeApplyEntity detail);
List<WalletRechargeByApplyExcelVo> exportExcel(WalletRechargeImportDto dto);
}
}
@@ -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 导入服务实现。
* <p>同步阶段完成整表解析、用户定位和业务校验,校验全部通过后才提交公共异步事务任务,避免错误行造成部分卡号已覆盖。</p>
*/
@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<Integer> 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<WalletImportActiveDto> rows, BindingResult bindingResult) {
List<ErrorMessage> errors = getErrorMessageList(bindingResult);
List<WalletImportActiveDto> 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<String, Long> phoneCounts = countValues(effectiveRows.stream().map(WalletImportActiveDto::getUserMobile).toList());
Map<String, Long> cardCounts = countValues(effectiveRows.stream().map(WalletImportActiveDto::getCardNumber).toList());
List<ValidatedImportRow> validatedRows = new ArrayList<>();
for (WalletImportActiveDto row : effectiveRows) {
Set<String> 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<WalletStudentImportActiveDto> rows, BindingResult bindingResult) {
List<ErrorMessage> errors = getErrorMessageList(bindingResult);
List<WalletStudentImportActiveDto> 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<String, Long> studentCounts = countValues(effectiveRows.stream()
.map(row -> buildStudentKey(row.getSchoolName(), row.getStudentNo())).toList());
Map<String, Long> cardCounts = countValues(effectiveRows.stream().map(WalletStudentImportActiveDto::getCardNumber).toList());
List<ValidatedImportRow> validatedRows = new ArrayList<>();
for (WalletStudentImportActiveDto row : effectiveRows) {
Set<String> 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<ValidatedImportRow> rows, List<ErrorMessage> errors,
String redisKey, String taskName) {
List<Long> userIds = rows.stream().map(ValidatedImportRow::userId).distinct().toList();
List<WalletManageEntity> wallets = userIds.isEmpty() ? List.of() : ObjectUtil.defaultIfNull(
walletManageService.getWalletsByUserIds(userIds, WalletTypeEnum.EAT), List.of());
Map<Long, WalletManageEntity> walletByUserId = wallets.stream()
.collect(Collectors.toMap(WalletManageEntity::getUserId, Function.identity(), (first, second) -> first));
List<Long> walletIds = wallets.stream().map(WalletManageEntity::getId).filter(ObjectUtil::isNotEmpty).distinct().toList();
List<WalletActiveEntity> activeRecords = new ArrayList<>();
if (!userIds.isEmpty()) {
activeRecords.addAll(walletActiveMapper.selectList(
new LambdaQueryWrapper<WalletActiveEntity>().in(WalletActiveEntity::getUserId, userIds)));
}
if (!walletIds.isEmpty()) {
// 兼容历史开卡记录只写 walletId、未写 userId 的数据,避免误判为未绑定后重复分配饭卡。
activeRecords.addAll(walletActiveMapper.selectList(
new LambdaQueryWrapper<WalletActiveEntity>().in(WalletActiveEntity::getWalletId, walletIds)));
}
Map<Long, WalletActiveEntity> activeByUserId = activeRecords.stream()
.filter(active -> active.getUserId() != null)
.collect(Collectors.toMap(WalletActiveEntity::getUserId, Function.identity(), (first, second) -> first));
Map<Long, WalletActiveEntity> 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<ValidatedImportRow> rows, List<ErrorMessage> errors) {
List<String> cardNumbers = rows.stream().map(ValidatedImportRow::cardNumber).distinct().toList();
if (cardNumbers.isEmpty()) {
return;
}
Map<String, WalletActiveEntity> activeByCardNumber = walletActiveMapper.selectList(
new LambdaQueryWrapper<WalletActiveEntity>().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<WalletManageEntity>()
.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<WalletActiveEntity>()
.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<String> errors) {
if (StrUtil.isBlank(value)) {
errors.add(message);
}
}
/**
* 校验卡号必填、长度和 Excel 内唯一性。
*
* @param cardNumber 卡号
* @param cardCounts 当前 Excel 非空卡号出现次数
* @param errors 当前行错误集合
*/
private void validateCardNumber(String cardNumber, Map<String, Long> cardCounts, Set<String> 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<UserInfo> response) {
return response == null || !response.isOk() || response.getData() == null ? null : response.getData().getSysUser();
}
/**
* 统计非空文本在 Excel 中的出现次数。
*
* @param values 待统计文本列表
* @return 文本到出现次数的映射
*/
private Map<String, Long> countValues(List<String> 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<ErrorMessage> errors, Long lineNum, Set<String> 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<ErrorMessage> getErrorMessageList(BindingResult bindingResult) {
Object target = bindingResult == null ? null : bindingResult.getTarget();
return target instanceof List<?> targetList ? (List<ErrorMessage>) 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;
}
}
@@ -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<WalletActiveMapper, Wal
return baseMapper.updateById(updateObj) > 0;
}
/**
* 将指定空闲饭卡绑定到用户的饭卡钱包。
* <p>饭卡记录和钱包记录均通过带未绑定条件的更新完成抢占;任一记录已被并发请求绑定时抛出异常,
* 当前事务会回滚,避免同一卡号覆盖绑定到多个用户或同一钱包关联多张饭卡。</p>
*
* @param dto 待绑定的用户、钱包和饭卡卡号
* @return 完成绑定后的饭卡记录
* @throws RuntimeException 饭卡或钱包不存在、饭卡已被绑定、用户已绑定饭卡时抛出
*/
@Override
@Transactional(rollbackFor = Exception.class)
public WalletActiveEntity bindCard(WalletActiveBindCardDto dto) {
@@ -128,15 +134,29 @@ public class WalletActiveServiceImpl extends ServiceImpl<WalletActiveMapper, Wal
throw new RuntimeException("该卡号已经绑定用户");
}
WalletManageEntity selfWallet = walletManageService.getWallet(dto.getWalletId());
if (ObjectUtil.isEmpty(selfWallet)) {
throw new RuntimeException("饭卡钱包不存在");
}
if (ObjectUtil.isNotEmpty(selfWallet) && ObjectUtil.isNotEmpty(selfWallet.getCardNumber())) {
throw new RuntimeException("用户已绑定饭卡");
}
// 更新开卡记录
// 前置查询只能用于友好提示,必须以未绑定条件原子抢占饭卡,防止并发请求相互覆盖。
WalletActiveEntity walletActiveObj = WalletActiveEntity.builder().id(walletActive.getId()).userId(dto.getUserId()).walletId(selfWallet.getId()).walletTypeId(selfWallet.getWalletTypeId()).build();
baseMapper.updateById(walletActiveObj);
// 更新用户钱包
int activeUpdateCount = baseMapper.update(walletActiveObj, new LambdaUpdateWrapper<WalletActiveEntity>()
.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<WalletManageEntity>()
.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<WalletActiveMapper, Wal
return getSelf().bindCard(dto);
}
/**
* 为尚未绑定饭卡的钱包分配空闲卡并建立开卡关联。
* <p>学生学校可能挂在“其他食堂”商户下,因此自动绑卡同时匹配其他食堂和自营食堂;
* 钱包已有卡号时视为已绑定并保持原行为,不重复分配。</p>
*
* @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<WalletActiveEntity> wrapper = new LambdaQueryWrapper<WalletActiveEntity>()
.isNull(WalletActiveEntity::getUserId)
@@ -170,7 +198,8 @@ public class WalletActiveServiceImpl extends ServiceImpl<WalletActiveMapper, Wal
createCardDto.setMerchantId(mch.getId());
getSelf().fastCreateCard(createCardDto);
}
LambdaQueryWrapper<WalletActiveEntity> selectOneWrapper = wrapper.clone().orderByAsc(WalletActiveEntity::getCardSort).last("limit 1");
// 在当前事务内锁定候选卡;后续并发请求等待前一笔提交后重新判断空闲条件,避免选中同一张卡。
LambdaQueryWrapper<WalletActiveEntity> 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<WalletActiveMapper, Wal
baseMapper.delete(new LambdaQueryWrapper<WalletActiveEntity>().eq(WalletActiveEntity::getCardNumber, cardNumber));
}
/**
* 导入开卡信息并覆盖用户当前饭卡卡号;用户尚未绑定饭卡时,先在同一事务中自动分配并绑定饭卡。
*
* @param excelVOList Excel 中读取的开卡数据
* @param bindingResult Excel 行级校验错误容器
* @return 导入结果;校验失败时返回逐行错误,成功时返回导入成功提示
*/
@Override
@Transactional(rollbackFor = Exception.class)
public R importWalletActive(List<WalletImportActiveDto> excelVOList, BindingResult bindingResult) {
ImmutablePair<List<ErrorMessage>, List<WalletImportActiveDto>> pair = validateExcelData(excelVOList, bindingResult, false);
List<ErrorMessage> errorMessages = pair.getLeft();
List<WalletImportActiveDto> list = pair.getRight();
if (ObjectUtil.isNotEmpty(errorMessages)) return R.failed(errorMessages);
// 更改卡号
List<WalletActiveEntity> updateActiveList = new ArrayList<>();
List<WalletManageEntity> 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<WalletActiveEntity>()
.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<ErrorMessage>, List<WalletImportActiveDto>> validateExcelData(List<WalletImportActiveDto> 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<ErrorMessage> errorMessageList = (List<ErrorMessage>) bindingResult.getTarget();
List<String> usernameList = list.stream().map(WalletImportActiveDto::getUsername).toList();
List<String> userPoneList = list.stream().map(WalletImportActiveDto::getUserMobile).toList();
for (WalletImportActiveDto v : list) {
Set<String> 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<UserInfo> user = userService.getUserByNameAndMobile(v.getUsername(), v.getUserMobile());
Optional<SysUser> 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<Long> userIdList = list.stream().map(WalletImportActiveDto::getUserId).distinct().filter(ObjectUtil::isNotEmpty).toList();
if (ObjectUtil.isNotEmpty(userIdList)) {
List<WalletActiveEntity> walletActiveList = baseMapper.selectList(new LambdaQueryWrapper<WalletActiveEntity>().in(WalletActiveEntity::getUserId, userIdList));
List<WalletManageEntity> 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<String> 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<WalletActiveEntity>()
.eq(ObjectUtil.isNotEmpty(cardNumber), WalletActiveEntity::getCardNumber, cardNumber)
@@ -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 导入服务实现。
* <p>负责默认用户模板校验、申请或发票创建及导入明细落库,不承载申请 CRUD 与审批逻辑。</p>
*/
@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<WalletRechargeExcelDto> rows, BindingResult bindingResult, String bizType) {
validateBizType(bizType, false);
ImmutablePair<List<ErrorMessage>, List<WalletRechargeExcelDto>> 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<WalletRechargeExcelDto> rows, BindingResult bindingResult, String bizType) {
validateBizType(bizType, true);
ImmutablePair<List<ErrorMessage>, List<WalletRechargeExcelDto>> 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<WalletStudentRechargeExcelDto> rows, BindingResult bindingResult, String bizType) {
validateBizType(bizType, false);
ImmutablePair<List<ErrorMessage>, List<WalletStudentRechargeExcelDto>> 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<WalletStudentRechargeExcelDto> rows, BindingResult bindingResult, String bizType) {
validateBizType(bizType, true);
ImmutablePair<List<ErrorMessage>, List<WalletStudentRechargeExcelDto>> 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<WalletRechargeExcelDto> rows, BindingResult bindingResult) {
ImmutablePair<List<ErrorMessage>, List<WalletRechargeExcelDto>> pair = validateExcelData(rows, bindingResult, true);
if (ObjectUtil.isNotEmpty(pair.getLeft())) {
return R.failed(pair.getLeft());
}
List<WalletRechargeExcelDto> 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<WalletRechargeImportEntity> 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<WalletRechargeExcelDto> 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<WalletRechargeImportEntity> 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<WalletStudentRechargeExcelDto> 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<WalletRechargeImportEntity> 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<ErrorMessage>, List<WalletRechargeExcelDto>> validateExcelData(
List<WalletRechargeExcelDto> rows, BindingResult bindingResult, boolean validatePrice) {
if (ObjectUtil.isEmpty(rows)) {
throw new IllegalArgumentException("读取excel数据错误");
}
List<WalletRechargeExcelDto> effectiveRows = rows.stream()
.filter(row -> !ObjectUtil.isAllEmpty(row.getUsername(), row.getUserMobile(), row.getPrice()))
.toList();
if (effectiveRows.isEmpty()) {
throw new IllegalArgumentException("读取excel数据错误");
}
List<ErrorMessage> errors = getErrorMessageList(bindingResult);
resolveTransactionTagValues(effectiveRows, errors);
List<String> userPhones = effectiveRows.stream().map(WalletRechargeExcelDto::getUserMobile).toList();
for (WalletRechargeExcelDto row : effectiveRows) {
Set<String> 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<ErrorMessage>, List<WalletStudentRechargeExcelDto>> validateStudentExcelData(
List<WalletStudentRechargeExcelDto> rows, BindingResult bindingResult, boolean validatePrice) {
if (ObjectUtil.isEmpty(rows)) {
throw new IllegalArgumentException("读取excel数据错误");
}
List<WalletStudentRechargeExcelDto> effectiveRows = rows.stream()
.filter(row -> !ObjectUtil.isAllEmpty(row.getStudentName(), row.getSchoolName(), row.getStudentNo(), row.getPrice()))
.toList();
if (effectiveRows.isEmpty()) {
throw new IllegalArgumentException("读取excel数据错误");
}
List<ErrorMessage> errors = getErrorMessageList(bindingResult);
resolveStudentTransactionTagValues(effectiveRows, errors);
List<String> studentKeys = effectiveRows.stream()
.map(row -> buildStudentKey(row.getSchoolName(), row.getStudentNo()))
.toList();
for (WalletStudentRechargeExcelDto row : effectiveRows) {
Set<String> 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<String> 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<String> 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<String> 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<UserInfo> response = remoteUserService.getUserByNameAndMobile(row.getUsername(), row.getUserMobile());
Optional<SysUser> 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<String> 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<UserInfo> response = remoteUserService.getStudentBySchoolNameAndStudentNo(row.getSchoolName(), row.getStudentNo());
Optional<SysUser> 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<ErrorMessage> getErrorMessageList(BindingResult bindingResult) {
Object target = bindingResult == null ? null : bindingResult.getTarget();
return target instanceof List<?> ? (List<ErrorMessage>) target : new java.util.ArrayList<>();
}
/**
* 将 Excel 财务标签名称转换为稳定字典值,支持英文逗号分隔多个标签。
*
* @param rows 已通过公共 Excel 字典标签校验的数据行
* @param errors 当前导入的逐行错误集合
*/
private void resolveTransactionTagValues(List<WalletRechargeExcelDto> rows, List<ErrorMessage> errors) {
List<WalletRechargeExcelDto> taggedRows = rows.stream().filter(row -> ObjectUtil.isNotEmpty(row.getTag())).toList();
if (taggedRows.isEmpty()) {
return;
}
R<List<SysDictItem>> 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<String, String> 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<String> 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<WalletStudentRechargeExcelDto> rows, List<ErrorMessage> errors) {
List<WalletStudentRechargeExcelDto> taggedRows = rows.stream().filter(row -> ObjectUtil.isNotEmpty(row.getTag())).toList();
if (taggedRows.isEmpty()) {
return;
}
R<List<SysDictItem>> 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<String, String> 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<String> 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));
}
}
}
@@ -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<WalletRechargeAp
static private final String TAG = "充值/扣款申请";
/**
* 充值流水财务标签使用的 UPMS 字典类型,Excel 展示标签而数据库保存对应字典值。
*/
private static final String TRANSACTION_TAG_DICT_TYPE = "wallet_transaction_tag";
private final WalletRechargeImportServiceImpl rechargeImportService;
private final WalletManageService walletManageService;
private final WalletTransactionService walletTransactionService;
@@ -110,7 +96,6 @@ public class WalletRechargeApplyServiceImpl extends ServiceImpl<WalletRechargeAp
private final RemoteUserService userService;
private final RemoteDeptService deptService;
private final RemoteDictService remoteDictService;
private final RemoteFlowEngineService flowEngineService;
private final FlowUtil flowUtil;
@@ -318,176 +303,6 @@ public class WalletRechargeApplyServiceImpl extends ServiceImpl<WalletRechargeAp
}
}
/* ************************************* start excel start ************************************* */
@Override
@Transactional(rollbackFor = Exception.class)
public R readExcel(List<WalletRechargeExcelDto> excelVOList, BindingResult bindingResult, String bizType) {
ImmutablePair<List<ErrorMessage>, List<WalletRechargeExcelDto>> pair = validateExcelData(excelVOList, bindingResult, true);
List<ErrorMessage> errorMessages = pair.getLeft();
List<WalletRechargeExcelDto> 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<WalletRechargeImportEntity> 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<WalletRechargeExcelDto> excelVOList, BindingResult bindingResult, String bizType) {
ImmutablePair<List<ErrorMessage>, List<WalletRechargeExcelDto>> pair = validateExcelData(excelVOList, bindingResult, false);
List<ErrorMessage> errorMessages = pair.getLeft();
List<WalletRechargeExcelDto> 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<WalletRechargeImportEntity> 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<WalletRechargeExcelDto> excelVOList, BindingResult bindingResult) {
ImmutablePair<List<ErrorMessage>, List<WalletRechargeExcelDto>> pair = validateExcelData(excelVOList, bindingResult, true);
List<ErrorMessage> errorMessages = pair.getLeft();
List<WalletRechargeExcelDto> 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<WalletRechargeImportEntity> 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<ErrorMessage>, List<WalletRechargeExcelDto>> validateExcelData(List<WalletRechargeExcelDto> 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<ErrorMessage> errorMessageList = (List<ErrorMessage>) bindingResult.getTarget();
resolveTransactionTagValues(list, errorMessageList);
List<String> usernameList = list.stream().map(WalletRechargeExcelDto::getUsername).toList();
List<String> userPoneList = list.stream().map(WalletRechargeExcelDto::getUserMobile).toList();
for (WalletRechargeExcelDto v : list) {
Set<String> 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<UserInfo> user = userService.getUserByNameAndMobile(v.getUsername(), v.getUserMobile());
Optional<SysUser> 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<WalletRechargeExcelDto> list, List<ErrorMessage> errorMessageList) {
List<WalletRechargeExcelDto> taggedRows = list.stream().filter(item -> ObjectUtil.isNotEmpty(item.getTag())).toList();
if (taggedRows.isEmpty()) {
return;
}
R<List<SysDictItem>> 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<String, String> 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<String> 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) {