package com.tcm.training.admin.member.service; import cn.idev.excel.FastExcel; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.tcm.training.admin.common.DateTimes; import com.tcm.training.admin.common.PageResponse; import com.tcm.training.admin.common.PhoneMasker; import com.tcm.training.admin.live.entity.Clinic; import com.tcm.training.admin.live.mapper.ClinicMapper; import com.tcm.training.admin.member.dto.UserBatchClinicUpdateRequest; import com.tcm.training.admin.member.dto.UserResetPasswordRequest; import com.tcm.training.admin.member.dto.UserStatusRequest; import com.tcm.training.admin.member.dto.UserUpdateRequest; import com.tcm.training.admin.member.entity.AppUser; import com.tcm.training.admin.member.excel.MemberUserExcelRow; import com.tcm.training.admin.member.excel.UserExcelRow; import com.tcm.training.admin.member.mapper.UserMapper; import com.tcm.training.admin.member.row.UserDetailRow; import com.tcm.training.admin.member.row.UserListRow; import com.tcm.training.admin.member.vo.*; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.net.URLEncoder; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; import java.util.regex.Pattern; @Service public class UserAppService { private static final Pattern PASSWORD_PATTERN = Pattern.compile( "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[^A-Za-z\\d]).{12,64}$" ); private static final String MEMBER_STATUS_ALL = "ALL"; private static final String MEMBER_STATUS_ACTIVE = "ACTIVE"; private static final String MEMBER_STATUS_EXPIRED = "EXPIRED"; private final UserMapper userMapper; private final ClinicMapper clinicMapper; private final BCryptPasswordEncoder passwordEncoder; public UserAppService(UserMapper userMapper, ClinicMapper clinicMapper, BCryptPasswordEncoder passwordEncoder) { this.userMapper = userMapper; this.clinicMapper = clinicMapper; this.passwordEncoder = passwordEncoder; } public PageResponse page(int page, int pageSize, String nickname, String phone, Long clinicId, Integer status, Integer isMember, String memberStatus, LocalDateTime memberExpireFrom, LocalDateTime memberExpireTo, LocalDateTime registeredFrom, LocalDateTime registeredTo) { String normalizedMemberStatus = normalizeMemberStatus(memberStatus); IPage result = userMapper.selectUserPage( new Page(page, pageSize), trimToNull(nickname), trimToNull(phone), clinicId, status, isMember, normalizedMemberStatus, memberExpireFrom, memberExpireTo, registeredFrom, registeredTo ); List list = new ArrayList(); for (UserListRow row : result.getRecords()) { UserListItemVO item = new UserListItemVO(); item.setId(row.getId()); item.setNickname(row.getNickname()); item.setPhone(row.getPhone()); item.setAvatar(row.getAvatar()); item.setType(row.getType()); item.setClinicName(row.getClinicName()); item.setAge(row.getAge()); item.setGender(row.getGender()); item.setIsMember(row.getIsMember() != null && row.getIsMember() == 1); item.setMemberExpireAt(row.getMemberExpireAt()); item.setStatus(row.getStatus()); item.setCreatedAt(row.getCreatedAt()); list.add(item); } return PageResponse.of(list, result.getTotal(), result.getCurrent(), result.getSize()); } public UserDetailVO detail(Long id) { UserDetailRow row = userMapper.selectUserDetail(id); if (row == null) { throw new IllegalArgumentException("用户不存在"); } UserDetailVO vo = new UserDetailVO(); vo.setId(row.getId()); vo.setNickname(row.getNickname()); vo.setPhone(row.getPhone()); vo.setAvatar(row.getAvatar()); vo.setType(row.getType()); vo.setGender(row.getGender()); vo.setAge(row.getAge()); vo.setIntro(row.getIntro()); vo.setStatus(row.getStatus()); vo.setIsMember(row.getIsMember() != null && row.getIsMember() == 1); vo.setMemberExpireAt(row.getMemberExpireAt()); vo.setCreatedAt(row.getCreatedAt()); vo.setUpdatedAt(row.getUpdatedAt()); if (row.getClinicId() != null && row.getClinicName() != null) { vo.setClinic(new ClinicInUserVO( row.getClinicId(), row.getClinicName(), row.getClinicContactName(), row.getClinicContactPhone(), row.getClinicAddress() )); } if ((row.getWechatNickname() != null && !row.getWechatNickname().trim().isEmpty()) || (row.getWechatAvatar() != null && !row.getWechatAvatar().trim().isEmpty()) || row.getWechatBoundAt() != null) { WechatBindingVO binding = new WechatBindingVO(); binding.setNickname(row.getWechatNickname()); binding.setAvatar(row.getWechatAvatar()); binding.setBoundAt(row.getWechatBoundAt()); vo.setWechatBinding(binding); } return vo; } @Transactional public UserDetailVO update(Long id, UserUpdateRequest req) { AppUser user = requireUser(id); if (req.getClinicId() != null) { Clinic clinic = clinicMapper.selectById(req.getClinicId()); if (clinic == null || (clinic.getDeleted() != null && clinic.getDeleted() == 1)) { throw new IllegalArgumentException("绑定诊所不存在"); } } AppUser phoneConflict = userMapper.selectOne(new LambdaQueryWrapper() .eq(AppUser::getPhone, req.getPhone().trim()) .ne(AppUser::getId, id) .eq(AppUser::getDeleted, 0) .last("limit 1")); if (phoneConflict != null) { throw new IllegalArgumentException("登录账号已存在"); } user.setNickname(req.getNickname().trim()); user.setPhone(req.getPhone().trim()); user.setAvatar(trimToNull(req.getAvatar())); user.setStatus(req.getStatus()); user.setClinicId(req.getClinicId()); user.setMemberExpireAt(req.getMemberExpireAt()); userMapper.updateById(user); return detail(id); } @Transactional public UserStatusVO patchStatus(Long id, UserStatusRequest req) { AppUser user = requireUser(id); user.setStatus(req.getStatus()); userMapper.updateById(user); return new UserStatusVO(id, req.getStatus()); } @Transactional public void resetPassword(Long id, UserResetPasswordRequest req) { AppUser user = requireUser(id); if (!req.getNewPassword().equals(req.getConfirmPassword())) { throw new IllegalArgumentException("两次密码输入不一致"); } validatePassword(req.getNewPassword()); user.setPassword(passwordEncoder.encode(req.getNewPassword())); userMapper.updateById(user); } @Transactional public void delete(Long id) { requireUser(id); userMapper.deleteById(id); } @Transactional public UserDetailVO unbindWechat(Long id) { requireUser(id); userMapper.update(null, new LambdaUpdateWrapper() .eq(AppUser::getId, id) .set(AppUser::getWechatOpenid, null) .set(AppUser::getWechatNickname, null) .set(AppUser::getWechatAvatar, null) .set(AppUser::getWechatBoundAt, null)); return detail(id); } @Transactional public void batchUpdateClinic(UserBatchClinicUpdateRequest req) { Clinic clinic = clinicMapper.selectById(req.getClinicId()); if (clinic == null || Integer.valueOf(1).equals(clinic.getDeleted())) { throw new IllegalArgumentException("诊所不存在"); } Long userCount = userMapper.selectCount(new LambdaQueryWrapper() .in(AppUser::getId, req.getUserIds()) .eq(AppUser::getDeleted, 0)); if (userCount != req.getUserIds().size()) { throw new IllegalArgumentException("部分用户不存在"); } userMapper.update(null, new LambdaUpdateWrapper() .in(AppUser::getId, req.getUserIds()) .eq(AppUser::getDeleted, 0) .set(AppUser::getClinicId, req.getClinicId())); } public List listClinicOptions() { List clinics = clinicMapper.selectList(new LambdaQueryWrapper() .eq(Clinic::getDeleted, 0) .eq(Clinic::getStatus, 1) .orderByAsc(Clinic::getId)); List list = new ArrayList(); for (Clinic clinic : clinics) { list.add(new ClinicOptionVO(clinic.getId(), clinic.getName())); } return list; } public void export(String nickname, String phone, Long clinicId, Integer status, Integer isMember, String memberStatus, LocalDateTime memberExpireFrom, LocalDateTime memberExpireTo, LocalDateTime registeredFrom, LocalDateTime registeredTo, HttpServletResponse response) throws IOException { String normalizedMemberStatus = normalizeMemberStatus(memberStatus); boolean memberMode = normalizedMemberStatus != null; response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setCharacterEncoding("utf-8"); String filename = URLEncoder.encode(memberMode ? "会员用户列表" : "用户列表", "UTF-8").replace("+", "%20"); response.setHeader("Content-Disposition", "attachment;filename*=utf-8''" + filename + ".xlsx"); List rows = userMapper.selectUserList( trimToNull(nickname), trimToNull(phone), clinicId, status, isMember, normalizedMemberStatus, memberExpireFrom, memberExpireTo, registeredFrom, registeredTo ); if (memberMode) { List memberExcelRows = new ArrayList(); for (UserListRow row : rows) { MemberUserExcelRow excel = new MemberUserExcelRow(); excel.setId(row.getId()); excel.setNickname(row.getNickname()); excel.setPhone(PhoneMasker.mask(row.getPhone())); excel.setMemberStatus(row.getIsMember() != null && row.getIsMember() == 1 ? "生效中" : "已失效"); excel.setMemberExpireAt(DateTimes.format(row.getMemberExpireAt())); memberExcelRows.add(excel); } FastExcel.write(response.getOutputStream(), MemberUserExcelRow.class) .sheet("会员用户列表") .doWrite(memberExcelRows); return; } List excelRows = new ArrayList(); for (UserListRow row : rows) { UserExcelRow excel = new UserExcelRow(); excel.setId(row.getId()); excel.setNickname(row.getNickname()); excel.setType(typeText(row.getType())); excel.setPhone(PhoneMasker.mask(row.getPhone())); excel.setClinicName(row.getClinicName() == null ? "-" : row.getClinicName()); excel.setAge(row.getAge()); excel.setGender(genderText(row.getGender())); excel.setIsMember(row.getIsMember() != null && row.getIsMember() == 1 ? "是" : "否"); excel.setStatus(statusText(row.getStatus())); excel.setCreatedAt(DateTimes.format(row.getCreatedAt())); excelRows.add(excel); } FastExcel.write(response.getOutputStream(), UserExcelRow.class) .sheet("用户列表") .doWrite(excelRows); } private AppUser requireUser(Long id) { AppUser user = userMapper.selectOne(new LambdaQueryWrapper() .eq(AppUser::getId, id) .eq(AppUser::getDeleted, 0) .last("limit 1")); if (user == null) { throw new IllegalArgumentException("用户不存在"); } return user; } private void validatePassword(String password) { if (!PASSWORD_PATTERN.matcher(password).matches()) { throw new IllegalArgumentException("密码需至少12位,且包含大小写字母、数字和特殊字符"); } } private String trimToNull(String value) { if (value == null) { return null; } String trimmed = value.trim(); return trimmed.isEmpty() ? null : trimmed; } private String normalizeMemberStatus(String memberStatus) { String normalized = trimToNull(memberStatus); if (normalized == null) { return null; } String upper = normalized.toUpperCase(); if (MEMBER_STATUS_ALL.equals(upper) || MEMBER_STATUS_ACTIVE.equals(upper) || MEMBER_STATUS_EXPIRED.equals(upper)) { return upper; } throw new IllegalArgumentException("会员状态筛选不合法"); } private String typeText(Integer type) { return type != null && type == 1 ? "诊所" : "个人"; } private String genderText(Integer gender) { if (gender == null) { return "-"; } return gender == 1 ? "男" : "女"; } private String statusText(Integer status) { return status != null && status == 1 ? "启用" : "禁用"; } }