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.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 SUPPORTED_ADVANCE_MINUTES = Arrays.asList(15, 30, 60, 120, 180); private static final long AVATAR_MAX_SIZE = 5 * 1024 * 1024L; private static final Set ALLOWED_IMAGE_TYPES = new HashSet( 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 AppUserNotificationSettingMapper appUserNotificationSettingMapper; private final ObsUploadService obsUploadService; /** * 注入 APP 设置页所需依赖。 */ public AppSettingsService(AppAuthSessionService appAuthSessionService, UserMapper userMapper, AppSmsCodeService appSmsCodeService, BCryptPasswordEncoder passwordEncoder, AppWechatAuthClient appWechatAuthClient, AppUserNotificationSettingMapper appUserNotificationSettingMapper, ObsUploadService obsUploadService) { this.appAuthSessionService = appAuthSessionService; this.userMapper = userMapper; this.appSmsCodeService = appSmsCodeService; this.passwordEncoder = passwordEncoder; this.appWechatAuthClient = appWechatAuthClient; 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); userMapper.updateById(user); return toProfileVO(user); } /** * 修改当前登录用户密码,并在成功后使当前登录态失效。 */ @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() .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() .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() .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 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; } }