package com.tcm.training.admin.app.settings.service;
|
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
import com.tcm.training.admin.app.auth.dto.AppWechatLoginRequest;
|
import com.tcm.training.admin.app.auth.service.AppAuthSessionService;
|
import com.tcm.training.admin.app.auth.service.AppSmsCodeService;
|
import com.tcm.training.admin.app.auth.service.AppWechatAuthClient;
|
import com.tcm.training.admin.app.auth.service.AppWechatUserProfile;
|
import com.tcm.training.admin.app.settings.dto.AppSettingsNotificationUpdateRequest;
|
import com.tcm.training.admin.app.settings.dto.AppSettingsPasswordUpdateRequest;
|
import com.tcm.training.admin.app.settings.dto.AppSettingsPhoneBindRequest;
|
import com.tcm.training.admin.app.settings.dto.AppSettingsPhoneCodeRequest;
|
import com.tcm.training.admin.app.settings.dto.AppSettingsPhoneUpdateRequest;
|
import com.tcm.training.admin.app.settings.dto.AppSettingsProfileUpdateRequest;
|
import com.tcm.training.admin.app.settings.entity.AppUserNotificationSetting;
|
import com.tcm.training.admin.app.settings.mapper.AppUserNotificationSettingMapper;
|
import com.tcm.training.admin.app.settings.vo.AppSettingsNotificationVO;
|
import com.tcm.training.admin.app.settings.vo.AppSettingsProfileVO;
|
import com.tcm.training.admin.app.settings.vo.AppSettingsWechatBindingVO;
|
import com.tcm.training.admin.app.share.service.AppShareService;
|
import com.tcm.training.admin.member.entity.AppUser;
|
import com.tcm.training.admin.member.mapper.UserMapper;
|
import com.tcm.training.admin.upload.ObsUploadService;
|
import com.tcm.training.admin.upload.UploadController;
|
import com.tcm.training.admin.upload.UploadFileResult;
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
import org.springframework.stereotype.Service;
|
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.web.multipart.MultipartFile;
|
|
import java.io.IOException;
|
import java.time.LocalDateTime;
|
import java.util.HashSet;
|
import java.util.Arrays;
|
import java.util.List;
|
import java.util.Set;
|
import java.util.regex.Pattern;
|
|
/**
|
* APP 设置页服务。
|
*/
|
@Service
|
public class AppSettingsService {
|
|
private static final Pattern PHONE_PATTERN = Pattern.compile("^1\\d{10}$");
|
private static final Pattern PASSWORD_PATTERN = Pattern.compile("^(?![0-9]+$)(?![a-zA-Z]+$)(?![^0-9a-zA-Z]+$).{8,32}$");
|
private static final List<Integer> SUPPORTED_ADVANCE_MINUTES = Arrays.asList(15, 30, 60, 120, 180);
|
private static final long AVATAR_MAX_SIZE = 5 * 1024 * 1024L;
|
private static final Set<String> ALLOWED_IMAGE_TYPES = new HashSet<String>(
|
Arrays.asList("image/jpeg", "image/png", "image/gif", "image/webp")
|
);
|
|
private final AppAuthSessionService appAuthSessionService;
|
private final UserMapper userMapper;
|
private final AppSmsCodeService appSmsCodeService;
|
private final BCryptPasswordEncoder passwordEncoder;
|
private final AppWechatAuthClient appWechatAuthClient;
|
private final AppShareService appShareService;
|
private final AppUserNotificationSettingMapper appUserNotificationSettingMapper;
|
private final ObsUploadService obsUploadService;
|
|
/**
|
* 注入 APP 设置页所需依赖。
|
*/
|
public AppSettingsService(AppAuthSessionService appAuthSessionService,
|
UserMapper userMapper,
|
AppSmsCodeService appSmsCodeService,
|
BCryptPasswordEncoder passwordEncoder,
|
AppWechatAuthClient appWechatAuthClient,
|
AppShareService appShareService,
|
AppUserNotificationSettingMapper appUserNotificationSettingMapper,
|
ObsUploadService obsUploadService) {
|
this.appAuthSessionService = appAuthSessionService;
|
this.userMapper = userMapper;
|
this.appSmsCodeService = appSmsCodeService;
|
this.passwordEncoder = passwordEncoder;
|
this.appWechatAuthClient = appWechatAuthClient;
|
this.appShareService = appShareService;
|
this.appUserNotificationSettingMapper = appUserNotificationSettingMapper;
|
this.obsUploadService = obsUploadService;
|
}
|
|
/**
|
* 获取当前登录用户设置页资料。
|
*/
|
public AppSettingsProfileVO getProfile() {
|
return toProfileVO(requireCurrentEnabledUser());
|
}
|
|
/**
|
* 上传当前登录用户头像。
|
*/
|
public UploadController.UploadResult uploadAvatar(MultipartFile file) throws IOException {
|
// APP 路由默认允许游客访问,因此头像上传必须在业务层显式校验登录用户。
|
requireCurrentEnabledUser();
|
validateAvatarImage(file);
|
UploadFileResult uploadResult = obsUploadService.upload(file, "app/avatar", null);
|
return new UploadController.UploadResult(uploadResult.getUrl(), uploadResult.getFileName());
|
}
|
|
/**
|
* 更新当前登录用户设置页资料。
|
*/
|
@Transactional
|
public AppSettingsProfileVO updateProfile(AppSettingsProfileUpdateRequest request) {
|
AppUser user = requireCurrentEnabledUser();
|
user.setNickname(normalizeNickname(request.getNickname()));
|
user.setAvatar(normalizeAvatar(request.getAvatar()));
|
user.setIntro(normalizeIntro(request.getIntro()));
|
userMapper.updateById(user);
|
return toProfileVO(user);
|
}
|
|
/**
|
* 发送修改手机号验证码。
|
*/
|
public void sendPhoneChangeCode(AppSettingsPhoneCodeRequest request) {
|
AppUser currentUser = requireCurrentEnabledUser();
|
String newPhone = normalizePhone(request.getNewPhone());
|
if (newPhone.equals(trimToEmpty(currentUser.getPhone()))) {
|
throw new IllegalArgumentException("新手机号不能与当前手机号一致");
|
}
|
requirePhoneAvailable(newPhone, currentUser.getId());
|
appSmsCodeService.createPhoneChangeCode(newPhone);
|
}
|
|
/**
|
* 修改当前登录用户手机号。
|
*/
|
@Transactional
|
public AppSettingsProfileVO updatePhone(AppSettingsPhoneUpdateRequest request) {
|
AppUser user = requireCurrentEnabledUser();
|
String oldPhone = normalizePhone(request.getOldPhone());
|
String newPhone = normalizePhone(request.getNewPhone());
|
String code = normalizeRequired(request.getCode(), "请输入验证码");
|
if (!oldPhone.equals(trimToEmpty(user.getPhone()))) {
|
// 设置页保留旧手机号输入框,因此服务端需要显式校验,避免用户误改其它账号手机号。
|
throw new IllegalArgumentException("旧手机号校验失败");
|
}
|
if (newPhone.equals(oldPhone)) {
|
throw new IllegalArgumentException("新手机号不能与当前手机号一致");
|
}
|
requirePhoneAvailable(newPhone, user.getId());
|
appSmsCodeService.validatePhoneChangeCode(newPhone, code);
|
user.setPhone(newPhone);
|
userMapper.updateById(user);
|
return toProfileVO(user);
|
}
|
|
/**
|
* 首次绑定当前登录用户手机号,供微信登录未绑定手机号场景使用。
|
*/
|
@Transactional
|
public AppSettingsProfileVO bindPhone(AppSettingsPhoneBindRequest request) {
|
AppUser user = requireCurrentEnabledUser();
|
String newPhone = normalizePhone(request.getNewPhone());
|
String code = normalizeRequired(request.getCode(), "请输入验证码");
|
if (trimToNull(user.getPhone()) != null) {
|
throw new IllegalArgumentException("当前账号已绑定手机号");
|
}
|
requirePhoneAvailable(newPhone, user.getId());
|
appSmsCodeService.validatePhoneChangeCode(newPhone, code);
|
user.setPhone(newPhone);
|
bindInviteRelationIfAbsent(user, request.getInviteCode());
|
userMapper.updateById(user);
|
return toProfileVO(user);
|
}
|
|
/**
|
* 用户尚未绑定诊所关系时,根据医生邀请码补齐上下级关系。
|
*/
|
private void bindInviteRelationIfAbsent(AppUser user, String inviteCode) {
|
if (user.getClinicId() != null || user.getSuperiorClinicUserId() != null) {
|
return;
|
}
|
String normalizedInviteCode = normalizeInviteCode(inviteCode);
|
if (normalizedInviteCode == null) {
|
return;
|
}
|
AppShareService.InviteBindingTarget bindingTarget = appShareService.resolveInviteBindingTarget(normalizedInviteCode);
|
user.setClinicId(bindingTarget.getClinicId());
|
user.setSuperiorClinicUserId(bindingTarget.getSuperiorClinicUserId());
|
}
|
|
/**
|
* 修改当前登录用户密码,并在成功后使当前登录态失效。
|
*/
|
@Transactional
|
public void updatePassword(AppSettingsPasswordUpdateRequest request) {
|
AppUser user = requireCurrentEnabledUser();
|
String oldPassword = normalizeRequired(request.getOldPassword(), "请输入原密码");
|
String newPassword = normalizePassword(request.getNewPassword(), "请输入新密码");
|
String confirmPassword = normalizePassword(request.getConfirmPassword(), "请再次输入密码");
|
if (!passwordEncoder.matches(oldPassword, trimToEmpty(user.getPassword()))) {
|
throw new IllegalArgumentException("原密码错误");
|
}
|
if (!newPassword.equals(confirmPassword)) {
|
throw new IllegalArgumentException("两次密码输入不一致");
|
}
|
user.setPassword(passwordEncoder.encode(newPassword));
|
userMapper.updateById(user);
|
appAuthSessionService.logout();
|
}
|
|
/**
|
* 绑定或换绑当前登录用户微信信息。
|
*/
|
@Transactional
|
public AppSettingsWechatBindingVO bindWechat(AppWechatLoginRequest request) {
|
AppUser currentUser = requireCurrentEnabledUser();
|
AppWechatUserProfile profile = appWechatAuthClient.resolveUserProfile(request);
|
AppUser occupiedUser = userMapper.selectOne(new LambdaQueryWrapper<AppUser>()
|
.eq(AppUser::getWechatOpenid, profile.getOpenid())
|
.eq(AppUser::getDeleted, 0)
|
.last("limit 1"));
|
// 同一个 openid 只允许归属一个有效账号,避免微信账号被多个本地账号共用。
|
if (occupiedUser != null && !occupiedUser.getId().equals(currentUser.getId())) {
|
throw new IllegalArgumentException("该微信已绑定其他账号");
|
}
|
|
String wechatNickname = trimToNull(profile.getNickname());
|
String wechatAvatar = trimToNull(profile.getAvatar());
|
currentUser.setWechatOpenid(profile.getOpenid());
|
currentUser.setWechatNickname(wechatNickname);
|
currentUser.setWechatAvatar(wechatAvatar);
|
currentUser.setWechatBoundAt(LocalDateTime.now());
|
if (trimToNull(currentUser.getNickname()) == null && wechatNickname != null) {
|
currentUser.setNickname(wechatNickname);
|
}
|
if (trimToNull(currentUser.getAvatar()) == null && wechatAvatar != null) {
|
currentUser.setAvatar(wechatAvatar);
|
}
|
if (currentUser.getGender() == null) {
|
currentUser.setGender(resolveWechatGender(profile.getSex()));
|
}
|
userMapper.updateById(currentUser);
|
return buildWechatBinding(currentUser);
|
}
|
|
/**
|
* 获取当前登录用户通知设置,没有记录时按默认值初始化。
|
*/
|
@Transactional
|
public AppSettingsNotificationVO getNotificationSettings() {
|
return toNotificationVO(requireNotificationSetting(requireCurrentEnabledUser().getId()));
|
}
|
|
/**
|
* 保存当前登录用户通知设置。
|
*/
|
@Transactional
|
public AppSettingsNotificationVO updateNotificationSettings(AppSettingsNotificationUpdateRequest request) {
|
AppUser user = requireCurrentEnabledUser();
|
AppUserNotificationSetting setting = requireNotificationSetting(user.getId());
|
Integer advanceMinutes = normalizeAdvanceMinutes(request.getAdvanceMinutes());
|
setting.setOrderStatusUpdateEnabled(booleanToInteger(request.getOrderStatusUpdateEnabled()));
|
setting.setActivityNoticeEnabled(booleanToInteger(request.getActivityNoticeEnabled()));
|
setting.setCourseUpdateEnabled(booleanToInteger(request.getCourseUpdateEnabled()));
|
setting.setLiveReservationEnabled(booleanToInteger(request.getLiveReservationEnabled()));
|
setting.setAdvanceNoticeMinutes(advanceMinutes);
|
appUserNotificationSettingMapper.updateById(setting);
|
return toNotificationVO(setting);
|
}
|
|
/**
|
* 注销当前登录账号,保留历史业务记录用于审计和数据回溯。
|
*/
|
@Transactional
|
public void cancelAccount() {
|
AppUser user = requireCurrentEnabledUser();
|
user.setStatus(0);
|
user.setDeleted(1);
|
userMapper.updateById(user);
|
appAuthSessionService.logout();
|
}
|
|
/**
|
* 校验当前登录用户存在且启用。
|
*/
|
private AppUser requireCurrentEnabledUser() {
|
Long userId = appAuthSessionService.requireLoginUserId();
|
AppUser user = userMapper.selectById(userId);
|
if (user == null || user.getDeleted() == null || user.getDeleted().intValue() != 0) {
|
throw new IllegalArgumentException("用户不存在");
|
}
|
if (user.getStatus() == null || user.getStatus().intValue() != 1) {
|
throw new IllegalArgumentException("用户已禁用");
|
}
|
return user;
|
}
|
|
/**
|
* 读取当前用户通知设置,不存在时自动创建默认值。
|
*/
|
private AppUserNotificationSetting requireNotificationSetting(Long userId) {
|
AppUserNotificationSetting setting = appUserNotificationSettingMapper.selectOne(new LambdaQueryWrapper<AppUserNotificationSetting>()
|
.eq(AppUserNotificationSetting::getAppUserId, userId)
|
.eq(AppUserNotificationSetting::getDeleted, 0)
|
.last("limit 1"));
|
if (setting != null) {
|
return setting;
|
}
|
AppUserNotificationSetting created = new AppUserNotificationSetting();
|
created.setAppUserId(userId);
|
created.setOrderStatusUpdateEnabled(1);
|
created.setActivityNoticeEnabled(0);
|
created.setCourseUpdateEnabled(0);
|
created.setLiveReservationEnabled(1);
|
created.setAdvanceNoticeMinutes(60);
|
created.setDeleted(0);
|
appUserNotificationSettingMapper.insert(created);
|
return created;
|
}
|
|
/**
|
* 将用户实体转换为设置页资料响应。
|
*/
|
private AppSettingsProfileVO toProfileVO(AppUser user) {
|
AppSettingsProfileVO result = new AppSettingsProfileVO();
|
result.setId(user.getId());
|
result.setNickname(resolveNickname(user));
|
result.setAvatar(resolveAvatar(user));
|
result.setPhone(trimToEmpty(user.getPhone()));
|
result.setIntro(trimToEmpty(user.getIntro()));
|
result.setType(user.getType());
|
result.setWechatBinding(buildWechatBinding(user));
|
return result;
|
}
|
|
/**
|
* 将通知设置实体转换为响应结构。
|
*/
|
private AppSettingsNotificationVO toNotificationVO(AppUserNotificationSetting setting) {
|
AppSettingsNotificationVO result = new AppSettingsNotificationVO();
|
result.setOrderStatusUpdateEnabled(integerToBoolean(setting.getOrderStatusUpdateEnabled()));
|
result.setActivityNoticeEnabled(integerToBoolean(setting.getActivityNoticeEnabled()));
|
result.setCourseUpdateEnabled(integerToBoolean(setting.getCourseUpdateEnabled()));
|
result.setLiveReservationEnabled(integerToBoolean(setting.getLiveReservationEnabled()));
|
result.setAdvanceMinutes(setting.getAdvanceNoticeMinutes());
|
return result;
|
}
|
|
/**
|
* 构造微信绑定展示信息。
|
*/
|
private AppSettingsWechatBindingVO buildWechatBinding(AppUser user) {
|
AppSettingsWechatBindingVO result = new AppSettingsWechatBindingVO();
|
result.setBound(trimToNull(user.getWechatOpenid()) != null);
|
result.setNickname(trimToEmpty(user.getWechatNickname()));
|
result.setAvatar(trimToEmpty(user.getWechatAvatar()));
|
result.setBoundAt(user.getWechatBoundAt());
|
return result;
|
}
|
|
/**
|
* 校验手机号未被其他有效用户占用。
|
*/
|
private void requirePhoneAvailable(String phone, Long currentUserId) {
|
AppUser occupiedUser = userMapper.selectOne(new LambdaQueryWrapper<AppUser>()
|
.eq(AppUser::getPhone, phone)
|
.eq(AppUser::getDeleted, 0)
|
.last("limit 1"));
|
if (occupiedUser != null && !occupiedUser.getId().equals(currentUserId)) {
|
throw new IllegalArgumentException("该手机号已注册");
|
}
|
}
|
|
/**
|
* 归一化昵称输入。
|
*/
|
private String normalizeNickname(String nickname) {
|
String normalized = trimToNull(nickname);
|
if (normalized == null) {
|
throw new IllegalArgumentException("请输入姓名");
|
}
|
return normalized;
|
}
|
|
/**
|
* 校验头像图片大小与格式。
|
*/
|
private void validateAvatarImage(MultipartFile file) {
|
if (file == null || file.isEmpty()) {
|
throw new IllegalArgumentException("请选择要上传的文件");
|
}
|
if (file.getSize() > AVATAR_MAX_SIZE) {
|
throw new IllegalArgumentException("文件大小超出限制");
|
}
|
String contentType = file.getContentType();
|
if (contentType == null || !ALLOWED_IMAGE_TYPES.contains(contentType)) {
|
throw new IllegalArgumentException("仅支持 JPG、PNG、GIF、WebP 格式");
|
}
|
}
|
|
/**
|
* 归一化头像地址输入。
|
*/
|
private String normalizeAvatar(String avatar) {
|
return trimToNull(avatar);
|
}
|
|
/**
|
* 归一化简介输入,允许为空。
|
*/
|
private String normalizeIntro(String intro) {
|
return trimToNull(intro);
|
}
|
|
/**
|
* 归一化并校验手机号。
|
*/
|
private String normalizePhone(String phone) {
|
String normalized = trimToNull(phone);
|
if (normalized == null || !PHONE_PATTERN.matcher(normalized).matches()) {
|
throw new IllegalArgumentException("请输入正确的手机号");
|
}
|
return normalized;
|
}
|
|
/**
|
* 归一化必填文本。
|
*/
|
private String normalizeRequired(String value, String message) {
|
String normalized = trimToNull(value);
|
if (normalized == null) {
|
throw new IllegalArgumentException(message);
|
}
|
return normalized;
|
}
|
|
/**
|
* 归一化并校验密码规则。
|
*/
|
private String normalizePassword(String password, String emptyMessage) {
|
String normalized = trimToNull(password);
|
if (normalized == null) {
|
throw new IllegalArgumentException(emptyMessage);
|
}
|
if (!PASSWORD_PATTERN.matcher(normalized).matches()) {
|
throw new IllegalArgumentException("密码需为8-32位,且包含数字、字母、符号至少2种");
|
}
|
return normalized;
|
}
|
|
/**
|
* 归一化医生邀请码,非法时忽略以保持绑定手机号主流程可用。
|
*/
|
private String normalizeInviteCode(String inviteCode) {
|
String normalized = trimToNull(inviteCode);
|
if (normalized == null) {
|
return null;
|
}
|
String upperInviteCode = normalized.toUpperCase();
|
return upperInviteCode.matches("^[A-Z0-9]{6}$") ? upperInviteCode : null;
|
}
|
|
/**
|
* 校验通知时间仅允许使用页面固定选项。
|
*/
|
private Integer normalizeAdvanceMinutes(Integer advanceMinutes) {
|
if (advanceMinutes == null || !SUPPORTED_ADVANCE_MINUTES.contains(advanceMinutes)) {
|
throw new IllegalArgumentException("通知时间不合法");
|
}
|
return advanceMinutes;
|
}
|
|
/**
|
* 将微信性别值转换为本地性别枚举。
|
*/
|
private Integer resolveWechatGender(Integer sex) {
|
if (sex == null) {
|
return null;
|
}
|
if (sex.intValue() == 1) {
|
return 1;
|
}
|
if (sex.intValue() == 2) {
|
return 0;
|
}
|
return null;
|
}
|
|
/**
|
* 优先使用本地昵称,没有时回退微信昵称和手机号。
|
*/
|
private String resolveNickname(AppUser user) {
|
String nickname = trimToNull(user.getNickname());
|
if (nickname != null) {
|
return nickname;
|
}
|
String wechatNickname = trimToNull(user.getWechatNickname());
|
if (wechatNickname != null) {
|
return wechatNickname;
|
}
|
return trimToEmpty(user.getPhone());
|
}
|
|
/**
|
* 优先使用本地头像,没有时回退微信头像。
|
*/
|
private String resolveAvatar(AppUser user) {
|
String avatar = trimToNull(user.getAvatar());
|
if (avatar != null) {
|
return avatar;
|
}
|
return trimToEmpty(user.getWechatAvatar());
|
}
|
|
/**
|
* 将布尔值转换为数据库使用的 0/1。
|
*/
|
private Integer booleanToInteger(Boolean value) {
|
return Boolean.TRUE.equals(value) ? 1 : 0;
|
}
|
|
/**
|
* 将数据库 0/1 转换为布尔值。
|
*/
|
private Boolean integerToBoolean(Integer value) {
|
return value != null && value.intValue() == 1;
|
}
|
|
/**
|
* 将空白文本归一化为 null。
|
*/
|
private String trimToNull(String value) {
|
if (value == null) {
|
return null;
|
}
|
String trimmed = value.trim();
|
return trimmed.isEmpty() ? null : trimmed;
|
}
|
|
/**
|
* 将可空文本转换为空字符串。
|
*/
|
private String trimToEmpty(String value) {
|
String normalized = trimToNull(value);
|
return normalized == null ? "" : normalized;
|
}
|
}
|