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.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<UserListItemVO> 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<UserListRow> result = userMapper.selectUserPage(
|
new Page<UserListRow>(page, pageSize),
|
trimToNull(nickname),
|
trimToNull(phone),
|
clinicId,
|
status,
|
isMember,
|
normalizedMemberStatus,
|
memberExpireFrom,
|
memberExpireTo,
|
registeredFrom,
|
registeredTo
|
);
|
List<UserListItemVO> list = new ArrayList<UserListItemVO>();
|
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(rollbackFor = Exception.class)
|
/** 更新用户信息。 */
|
public UserDetailVO update(Long id, UserUpdateRequest req) {
|
AppUser user = requireUser(id);
|
boolean personalUser = Integer.valueOf(0).equals(user.getType());
|
Long clinicUserId = personalUser ? resolveClinicUserId(req.getClinicId()) : null;
|
AppUser phoneConflict = userMapper.selectOne(new LambdaQueryWrapper<AppUser>()
|
.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.setMemberExpireAt(req.getMemberExpireAt());
|
userMapper.updateById(user);
|
if (personalUser) {
|
updateClinicBinding(java.util.Collections.singletonList(id), req.getClinicId(), clinicUserId);
|
}
|
return detail(id);
|
}
|
|
@Transactional(rollbackFor = Exception.class)
|
/** 修改用户状态。 */
|
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(rollbackFor = Exception.class)
|
/** 重置用户密码。 */
|
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(rollbackFor = Exception.class)
|
/** 删除用户。 */
|
public void delete(Long id) {
|
requireUser(id);
|
userMapper.deleteById(id);
|
}
|
|
@Transactional(rollbackFor = Exception.class)
|
/** 解除用户微信绑定。 */
|
public UserDetailVO unbindWechat(Long id) {
|
requireUser(id);
|
userMapper.update(null, new LambdaUpdateWrapper<AppUser>()
|
.eq(AppUser::getId, id)
|
.set(AppUser::getWechatOpenid, null)
|
.set(AppUser::getWechatNickname, null)
|
.set(AppUser::getWechatAvatar, null)
|
.set(AppUser::getWechatBoundAt, null));
|
return detail(id);
|
}
|
|
@Transactional(rollbackFor = Exception.class)
|
/** 批量更新用户诊所归属。 */
|
public void batchUpdateClinic(UserBatchClinicUpdateRequest req) {
|
Long clinicUserId = resolveClinicUserId(req.getClinicId());
|
Long userCount = userMapper.selectCount(new LambdaQueryWrapper<AppUser>()
|
.in(AppUser::getId, req.getUserIds())
|
.eq(AppUser::getType, 0)
|
.eq(AppUser::getDeleted, 0));
|
if (userCount != req.getUserIds().size()) {
|
throw new IllegalArgumentException("部分用户不存在或不是普通用户");
|
}
|
updateClinicBinding(req.getUserIds(), req.getClinicId(), clinicUserId);
|
}
|
|
/** 诊所 ID 与诊所账号用户 ID 不同,患者查询依赖后者。 */
|
private Long resolveClinicUserId(Long clinicId) {
|
if (clinicId == null) {
|
return null;
|
}
|
Clinic clinic = clinicMapper.selectById(clinicId);
|
if (clinic == null || Integer.valueOf(1).equals(clinic.getDeleted())) {
|
throw new IllegalArgumentException("绑定诊所不存在");
|
}
|
List<AppUser> clinicUsers = userMapper.selectList(new LambdaQueryWrapper<AppUser>()
|
.eq(AppUser::getClinicId, clinicId)
|
.eq(AppUser::getType, 1)
|
.eq(AppUser::getDeleted, 0)
|
.last("limit 2"));
|
if (clinicUsers.isEmpty()) {
|
throw new IllegalArgumentException("目标诊所尚未关联诊所账号,无法绑定患者");
|
}
|
if (clinicUsers.size() > 1) {
|
throw new IllegalArgumentException("目标诊所关联多个账号,请先确认患者归属");
|
}
|
return clinicUsers.get(0).getId();
|
}
|
|
/** 同步两个归属字段;显式 set 确保解除绑定时 null 也能写入。 */
|
private void updateClinicBinding(List<Long> userIds, Long clinicId, Long clinicUserId) {
|
userMapper.update(null, new LambdaUpdateWrapper<AppUser>()
|
.in(AppUser::getId, userIds)
|
.eq(AppUser::getType, 0)
|
.eq(AppUser::getDeleted, 0)
|
.set(AppUser::getClinicId, clinicId)
|
.set(AppUser::getSuperiorClinicUserId, clinicUserId));
|
}
|
|
/** 查询诊所选项。 */
|
public List<ClinicOptionVO> listClinicOptions() {
|
List<Clinic> clinics = clinicMapper.selectList(new LambdaQueryWrapper<Clinic>()
|
.eq(Clinic::getDeleted, 0)
|
.eq(Clinic::getStatus, 1)
|
.orderByAsc(Clinic::getId));
|
List<ClinicOptionVO> list = new ArrayList<ClinicOptionVO>();
|
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<UserListRow> rows = userMapper.selectUserList(
|
trimToNull(nickname),
|
trimToNull(phone),
|
clinicId,
|
status,
|
isMember,
|
normalizedMemberStatus,
|
memberExpireFrom,
|
memberExpireTo,
|
registeredFrom,
|
registeredTo
|
);
|
if (memberMode) {
|
List<MemberUserExcelRow> memberExcelRows = new ArrayList<MemberUserExcelRow>();
|
for (UserListRow row : rows) {
|
MemberUserExcelRow excel = new MemberUserExcelRow();
|
excel.setId(row.getId());
|
excel.setNickname(row.getNickname());
|
excel.setPhone(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<UserExcelRow> excelRows = new ArrayList<UserExcelRow>();
|
for (UserListRow row : rows) {
|
UserExcelRow excel = new UserExcelRow();
|
excel.setId(row.getId());
|
excel.setNickname(row.getNickname());
|
excel.setType(typeText(row.getType()));
|
excel.setPhone(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<AppUser>()
|
.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 ? "启用" : "禁用";
|
}
|
}
|