| | |
| | | import com.tcm.training.admin.app.auth.dto.AppPasswordLoginRequest; |
| | | import com.tcm.training.admin.app.auth.dto.AppPasswordResetRequest; |
| | | import com.tcm.training.admin.app.auth.dto.AppPasswordResetVerifyRequest; |
| | | import com.tcm.training.admin.app.auth.dto.AppAppleLoginRequest; |
| | | import com.tcm.training.admin.app.auth.dto.AppSmsCodeSendRequest; |
| | | import com.tcm.training.admin.app.auth.dto.AppSmsLoginRequest; |
| | | import com.tcm.training.admin.app.auth.dto.AppWechatLoginRequest; |
| | |
| | | import com.tcm.training.admin.app.auth.vo.AppLoginUserVO; |
| | | import com.tcm.training.admin.app.auth.vo.AppPasswordResetVerifyResponse; |
| | | import com.tcm.training.admin.app.auth.vo.AppSmsCodeSendResponse; |
| | | 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 cn.dev33.satoken.stp.SaTokenInfo; |
| | |
| | | import org.springframework.stereotype.Service; |
| | | |
| | | import java.time.LocalDateTime; |
| | | import java.util.UUID; |
| | | import java.util.concurrent.TimeUnit; |
| | | import java.util.regex.Pattern; |
| | | |
| | | /** |
| | | * APP 登录认证服务,负责短信登录、密码登录、微信登录与忘记密码流程。 |
| | | * APP 登录认证服务,负责短信登录、第三方登录与登录态刷新流程。 |
| | | */ |
| | | @Service |
| | | public class AppAuthService { |
| | | |
| | | 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 String PASSWORD_RESET_TICKET_CACHE_PREFIX = "app:password-reset:ticket:"; |
| | | private static final long PASSWORD_RESET_TICKET_EXPIRE_MINUTES = 10L; |
| | | |
| | | private static final String PASSWORD_AUTH_DISABLED_MESSAGE = "当前版本仅支持验证码登录"; |
| | | private final UserMapper userMapper; |
| | | private final AppSmsCodeService appSmsCodeService; |
| | | private final BCryptPasswordEncoder passwordEncoder; |
| | | private final AppAuthSessionService appAuthSessionService; |
| | | private final AppWechatAuthClient appWechatAuthClient; |
| | | private final AppAppleAuthClient appAppleAuthClient; |
| | | private final AppShareService appShareService; |
| | | private final StringRedisTemplate stringRedisTemplate; |
| | | |
| | | /** |
| | |
| | | BCryptPasswordEncoder passwordEncoder, |
| | | AppAuthSessionService appAuthSessionService, |
| | | AppWechatAuthClient appWechatAuthClient, |
| | | AppAppleAuthClient appAppleAuthClient, |
| | | AppShareService appShareService, |
| | | StringRedisTemplate stringRedisTemplate) { |
| | | this.userMapper = userMapper; |
| | | this.appSmsCodeService = appSmsCodeService; |
| | | this.passwordEncoder = passwordEncoder; |
| | | this.appAuthSessionService = appAuthSessionService; |
| | | this.appWechatAuthClient = appWechatAuthClient; |
| | | this.appAppleAuthClient = appAppleAuthClient; |
| | | this.appShareService = appShareService; |
| | | this.stringRedisTemplate = stringRedisTemplate; |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | /** |
| | | * 为已注册 APP 用户生成忘记密码验证码。 |
| | | * 当前版本已下线密码能力,旧客户端调用时返回统一提示。 |
| | | */ |
| | | public AppSmsCodeSendResponse sendPasswordResetCode(AppSmsCodeSendRequest request) { |
| | | String phone = normalizePhone(request.getPhone()); |
| | | requireEnabledUserByPhone(phone); |
| | | return appSmsCodeService.createPasswordResetCode(phone); |
| | | throwPasswordAuthDisabled(); |
| | | return null; |
| | | } |
| | | |
| | | /** |
| | |
| | | } |
| | | |
| | | /** |
| | | * 使用手机号密码完成 APP 用户登录。 |
| | | * 当前版本已下线密码登录,旧客户端调用时返回统一提示。 |
| | | */ |
| | | public AppLoginResponse passwordLogin(AppPasswordLoginRequest request) { |
| | | String phone = normalizePhone(request.getPhone()); |
| | | String password = normalizeLoginPassword(request.getPassword()); |
| | | AppUser user = requireEnabledUserByPhone(phone); |
| | | if (!passwordEncoder.matches(password, user.getPassword())) { |
| | | throw new IllegalArgumentException("账号或密码错误"); |
| | | } |
| | | return createLoginResponse(user); |
| | | throwPasswordAuthDisabled(); |
| | | return null; |
| | | } |
| | | |
| | | /** |
| | |
| | | AppUser user = findUserByWechatOpenid(profile.getOpenid()); |
| | | if (user == null) { |
| | | user = buildWechatUser(profile); |
| | | bindInviteRelationIfAbsent(user, request.getInviteCode()); |
| | | userMapper.insert(user); |
| | | return createLoginResponse(user); |
| | | } |
| | |
| | | } |
| | | |
| | | /** |
| | | * 校验忘记密码验证码并生成一次性重置凭证。 |
| | | * 使用苹果授权结果完成 APP 登录,首次登录自动创建普通用户。 |
| | | */ |
| | | public AppPasswordResetVerifyResponse verifyPasswordResetCode(AppPasswordResetVerifyRequest request) { |
| | | String phone = normalizePhone(request.getPhone()); |
| | | requireEnabledUserByPhone(phone); |
| | | appSmsCodeService.validatePasswordResetCode(phone, normalizeCode(request.getCode())); |
| | | public AppLoginResponse appleLogin(AppAppleLoginRequest request) { |
| | | AppAppleUserProfile profile = appAppleAuthClient.resolveUserProfile(request); |
| | | AppUser user = findUserByAppleSubject(profile.getSubject()); |
| | | if (user == null) { |
| | | user = buildAppleUser(profile); |
| | | bindInviteRelationIfAbsent(user, request.getInviteCode()); |
| | | userMapper.insert(user); |
| | | return createLoginResponse(user); |
| | | } |
| | | |
| | | String resetTicket = createResetTicket(); |
| | | cacheResetTicket(resetTicket, phone); |
| | | return new AppPasswordResetVerifyResponse(resetTicket); |
| | | requireEnabledUser(user); |
| | | // 登录时同步苹果绑定标识,避免历史账号缺少最新 subject 或绑定时间。 |
| | | applyAppleProfile(user, profile, false); |
| | | userMapper.updateById(user); |
| | | return createLoginResponse(user); |
| | | } |
| | | |
| | | /** |
| | | * 使用一次性重置凭证更新 APP 用户密码。 |
| | | * 当前版本已下线密码能力,旧客户端调用时返回统一提示。 |
| | | */ |
| | | public AppPasswordResetVerifyResponse verifyPasswordResetCode(AppPasswordResetVerifyRequest request) { |
| | | throwPasswordAuthDisabled(); |
| | | return null; |
| | | } |
| | | |
| | | /** |
| | | * 当前版本已下线密码能力,旧客户端调用时返回统一提示。 |
| | | */ |
| | | public void resetPassword(AppPasswordResetRequest request) { |
| | | String resetTicket = normalizeResetTicket(request.getResetTicket()); |
| | | String password = normalizeNewPassword(request.getPassword()); |
| | | String confirmPassword = normalizeConfirmPassword(request.getConfirmPassword()); |
| | | validatePasswordConfirmation(password, confirmPassword); |
| | | |
| | | String phone = loadPhoneByResetTicket(resetTicket); |
| | | AppUser user = requireEnabledUserByPhone(phone); |
| | | user.setPassword(passwordEncoder.encode(password)); |
| | | userMapper.updateById(user); |
| | | stringRedisTemplate.delete(buildResetTicketKey(resetTicket)); |
| | | throwPasswordAuthDisabled(); |
| | | } |
| | | |
| | | /** |
| | |
| | | } |
| | | |
| | | /** |
| | | * 刷新当前 APP 登录态,并重新签发一枚新的 token。 |
| | | */ |
| | | public AppLoginResponse refreshToken() { |
| | | Long userId = appAuthSessionService.requireLoginUserId(); |
| | | AppUser user = requireEnabledUserById(userId); |
| | | // 当前 Sa-Token 版本不支持按 token 原地续期,这里直接为当前用户重新签发新 token。 |
| | | appAuthSessionService.login(user.getId()); |
| | | return buildLoginResponse(user, appAuthSessionService.getTokenInfo()); |
| | | } |
| | | |
| | | /** |
| | | * 为成功登录的 APP 用户建立登录态并拼装统一响应。 |
| | | */ |
| | | private AppLoginResponse createLoginResponse(AppUser user) { |
| | | appAuthSessionService.login(user.getId()); |
| | | SaTokenInfo tokenInfo = appAuthSessionService.getTokenInfo(); |
| | | return buildLoginResponse(user, appAuthSessionService.getTokenInfo()); |
| | | } |
| | | |
| | | /** |
| | | * 统一构造 APP 登录响应,保证登录与续期接口返回结构一致。 |
| | | */ |
| | | private AppLoginResponse buildLoginResponse(AppUser user, SaTokenInfo tokenInfo) { |
| | | return new AppLoginResponse( |
| | | tokenInfo.getTokenValue(), |
| | | "Bearer", |
| | |
| | | } |
| | | |
| | | /** |
| | | * 按用户主键查询并校验 APP 用户状态。 |
| | | */ |
| | | private AppUser requireEnabledUserById(Long userId) { |
| | | AppUser user = userMapper.selectById(userId); |
| | | if (user == null || user.getDeleted() == null || user.getDeleted().intValue() != 0) { |
| | | throw new IllegalArgumentException("用户不存在"); |
| | | } |
| | | requireEnabledUser(user); |
| | | return user; |
| | | } |
| | | |
| | | /** |
| | | * 校验给定 APP 用户是否处于启用状态。 |
| | | */ |
| | | private void requireEnabledUser(AppUser user) { |
| | |
| | | } |
| | | |
| | | /** |
| | | * 按微信 openid 查询本地 APP 用户。 |
| | | * 按微信 openid 查询本地 APP 用户,重复脏数据场景优先返回启用且已绑定手机号的账号。 |
| | | */ |
| | | private AppUser findUserByWechatOpenid(String openid) { |
| | | return userMapper.selectOne(new LambdaQueryWrapper<AppUser>() |
| | | .eq(AppUser::getWechatOpenid, openid) |
| | | .eq(AppUser::getDeleted, 0) |
| | | .last("limit 1")); |
| | | .last("ORDER BY CASE WHEN status = 1 THEN 0 ELSE 1 END, " |
| | | + "CASE WHEN phone IS NOT NULL AND TRIM(phone) <> '' THEN 0 ELSE 1 END, " |
| | | + "updated_at DESC, id DESC LIMIT 1")); |
| | | } |
| | | |
| | | /** |
| | | * 按苹果 subject 查询本地 APP 用户,重复脏数据场景优先返回启用且已绑定手机号的账号。 |
| | | */ |
| | | private AppUser findUserByAppleSubject(String subject) { |
| | | return userMapper.selectOne(new LambdaQueryWrapper<AppUser>() |
| | | .eq(AppUser::getAppleIdentitySubject, subject) |
| | | .eq(AppUser::getDeleted, 0) |
| | | .last("ORDER BY CASE WHEN status = 1 THEN 0 ELSE 1 END, " |
| | | + "CASE WHEN phone IS NOT NULL AND TRIM(phone) <> '' THEN 0 ELSE 1 END, " |
| | | + "updated_at DESC, id DESC LIMIT 1")); |
| | | } |
| | | |
| | | /** |
| | |
| | | } |
| | | |
| | | /** |
| | | * 用户尚未绑定诊所关系时,根据医生邀请码补齐上下级关系。 |
| | | */ |
| | | 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()); |
| | | } |
| | | |
| | | /** |
| | | * 基于苹果资料构造首次登录时的本地普通用户。 |
| | | */ |
| | | private AppUser buildAppleUser(AppAppleUserProfile profile) { |
| | | AppUser user = new AppUser(); |
| | | user.setType(0); |
| | | user.setStatus(1); |
| | | applyAppleProfile(user, profile, true); |
| | | return user; |
| | | } |
| | | |
| | | /** |
| | | * 将微信资料回填到本地用户,必要时补齐昵称头像展示字段。 |
| | | */ |
| | | private void applyWechatProfile(AppUser user, AppWechatUserProfile profile, boolean syncLocalProfile) { |
| | | String nickname = trimToNull(profile.getNickname()); |
| | | String avatar = trimToNull(profile.getAvatar()); |
| | | String currentNickname = trimToNull(user.getNickname()); |
| | | |
| | | user.setWechatOpenid(profile.getOpenid()); |
| | | user.setWechatNickname(nickname); |
| | | user.setWechatAvatar(avatar); |
| | | user.setWechatBoundAt(LocalDateTime.now()); |
| | | |
| | | if (syncLocalProfile || trimToNull(user.getNickname()) == null) { |
| | | if (shouldRefreshLocalNickname(currentNickname, profile.getOpenid(), nickname, syncLocalProfile)) { |
| | | user.setNickname(nickname == null ? profile.getOpenid() : nickname); |
| | | } |
| | | if (syncLocalProfile || trimToNull(user.getAvatar()) == null) { |
| | |
| | | } |
| | | if (syncLocalProfile || user.getGender() == null) { |
| | | user.setGender(resolveWechatGender(profile.getSex())); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 仅在首次建号、昵称为空、openid 兜底值或历史乱码场景下刷新本地昵称,避免覆盖用户手动修改结果。 |
| | | */ |
| | | private boolean shouldRefreshLocalNickname(String currentNickname, |
| | | String openid, |
| | | String latestWechatNickname, |
| | | boolean syncLocalProfile) { |
| | | if (syncLocalProfile) { |
| | | return true; |
| | | } |
| | | if (trimToNull(latestWechatNickname) == null) { |
| | | return false; |
| | | } |
| | | String normalizedCurrentNickname = trimToNull(currentNickname); |
| | | if (normalizedCurrentNickname == null) { |
| | | return true; |
| | | } |
| | | String normalizedOpenid = trimToNull(openid); |
| | | if (normalizedOpenid != null && normalizedOpenid.equals(normalizedCurrentNickname)) { |
| | | return true; |
| | | } |
| | | return containsNicknameMojibake(normalizedCurrentNickname); |
| | | } |
| | | |
| | | /** |
| | | * 识别历史微信昵称乱码的常见特征,限定为已知乱码模式,避免误伤正常昵称。 |
| | | */ |
| | | private boolean containsNicknameMojibake(String nickname) { |
| | | return nickname.contains("ð") |
| | | || nickname.contains("Ã") |
| | | || nickname.contains("â") |
| | | || nickname.contains("�"); |
| | | } |
| | | |
| | | /** |
| | | * 将苹果资料回填到本地用户,并在首次自动建号时补齐基础展示昵称。 |
| | | */ |
| | | private void applyAppleProfile(AppUser user, AppAppleUserProfile profile, boolean syncLocalProfile) { |
| | | user.setAppleUserIdentifier(trimToNull(profile.getUserIdentifier())); |
| | | user.setAppleIdentitySubject(trimToNull(profile.getSubject())); |
| | | user.setAppleBoundAt(LocalDateTime.now()); |
| | | |
| | | if (syncLocalProfile || trimToNull(user.getNickname()) == null) { |
| | | user.setNickname("苹果用户"); |
| | | } |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | /** |
| | | * 归一化密码登录场景下的密码输入,仅要求非空。 |
| | | * 抛出 APP 密码能力下线后的统一业务提示。 |
| | | */ |
| | | private String normalizeLoginPassword(String password) { |
| | | String normalized = trimToNull(password); |
| | | private void throwPasswordAuthDisabled() { |
| | | throw new IllegalArgumentException(PASSWORD_AUTH_DISABLED_MESSAGE); |
| | | } |
| | | |
| | | /** |
| | | * 归一化医生邀请码,非法时忽略以保持第三方登录主流程可用。 |
| | | */ |
| | | private String normalizeInviteCode(String inviteCode) { |
| | | String normalized = trimToNull(inviteCode); |
| | | if (normalized == null) { |
| | | throw new IllegalArgumentException("请输入密码"); |
| | | return null; |
| | | } |
| | | return normalized; |
| | | } |
| | | |
| | | /** |
| | | * 归一化并校验忘记密码场景下的新密码格式。 |
| | | */ |
| | | private String normalizeNewPassword(String password) { |
| | | String normalized = trimToNull(password); |
| | | if (normalized == null) { |
| | | throw new IllegalArgumentException("请输入新密码"); |
| | | } |
| | | if (!PASSWORD_PATTERN.matcher(normalized).matches()) { |
| | | throw new IllegalArgumentException("密码需为8-32位,且包含数字、字母、符号至少2种"); |
| | | } |
| | | return normalized; |
| | | } |
| | | |
| | | /** |
| | | * 归一化并校验确认密码输入。 |
| | | */ |
| | | private String normalizeConfirmPassword(String confirmPassword) { |
| | | String normalized = trimToNull(confirmPassword); |
| | | if (normalized == null) { |
| | | throw new IllegalArgumentException("请再次输入密码"); |
| | | } |
| | | if (!PASSWORD_PATTERN.matcher(normalized).matches()) { |
| | | throw new IllegalArgumentException("密码需为8-32位,且包含数字、字母、符号至少2种"); |
| | | } |
| | | return normalized; |
| | | } |
| | | |
| | | /** |
| | | * 归一化并校验一次性重置凭证。 |
| | | */ |
| | | private String normalizeResetTicket(String resetTicket) { |
| | | String normalized = trimToNull(resetTicket); |
| | | if (normalized == null) { |
| | | throw new IllegalArgumentException("重置凭证已失效,请重新验证"); |
| | | } |
| | | return normalized; |
| | | } |
| | | |
| | | /** |
| | | * 校验两次输入的新密码保持一致。 |
| | | */ |
| | | private void validatePasswordConfirmation(String password, String confirmPassword) { |
| | | if (!password.equals(confirmPassword)) { |
| | | throw new IllegalArgumentException("两次密码输入不一致"); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 生成忘记密码场景使用的一次性重置凭证。 |
| | | */ |
| | | private String createResetTicket() { |
| | | return UUID.randomUUID().toString().replace("-", ""); |
| | | } |
| | | |
| | | /** |
| | | * 将重置凭证与手机号绑定写入 Redis,供下一步更新密码使用。 |
| | | */ |
| | | private void cacheResetTicket(String resetTicket, String phone) { |
| | | stringRedisTemplate.opsForValue().set( |
| | | buildResetTicketKey(resetTicket), |
| | | phone, |
| | | PASSWORD_RESET_TICKET_EXPIRE_MINUTES, |
| | | TimeUnit.MINUTES |
| | | ); |
| | | } |
| | | |
| | | /** |
| | | * 从 Redis 读取并校验一次性重置凭证对应的手机号。 |
| | | */ |
| | | private String loadPhoneByResetTicket(String resetTicket) { |
| | | String phone = stringRedisTemplate.opsForValue().get(buildResetTicketKey(resetTicket)); |
| | | if (phone == null || phone.trim().isEmpty()) { |
| | | throw new IllegalArgumentException("重置凭证已失效,请重新验证"); |
| | | } |
| | | return phone; |
| | | } |
| | | |
| | | /** |
| | | * 构造忘记密码重置凭证的 Redis key。 |
| | | */ |
| | | private String buildResetTicketKey(String resetTicket) { |
| | | return PASSWORD_RESET_TICKET_CACHE_PREFIX + resetTicket; |
| | | String upperInviteCode = normalized.toUpperCase(); |
| | | return upperInviteCode.matches("^[A-Z0-9]{6}$") ? upperInviteCode : null; |
| | | } |
| | | |
| | | /** |