| | |
| | | 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.auth.exception.InviteCodeRequiredException; |
| | | 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 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; |
| | |
| | | } |
| | | |
| | | /** |
| | | * 为已注册 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; |
| | | } |
| | | |
| | | /** |
| | |
| | | AppWechatUserProfile profile = appWechatAuthClient.resolveUserProfile(request); |
| | | AppUser user = findUserByWechatOpenid(profile.getOpenid()); |
| | | if (user == null) { |
| | | requireInviteCodeForThirdPartyRegister(request.getInviteCode()); |
| | | user = buildWechatUser(profile); |
| | | bindInviteRelationIfAbsent(user, request.getInviteCode()); |
| | | userMapper.insert(user); |
| | |
| | | AppAppleUserProfile profile = appAppleAuthClient.resolveUserProfile(request); |
| | | AppUser user = findUserByAppleSubject(profile.getSubject()); |
| | | if (user == null) { |
| | | requireInviteCodeForThirdPartyRegister(request.getInviteCode()); |
| | | user = buildAppleUser(profile); |
| | | bindInviteRelationIfAbsent(user, request.getInviteCode()); |
| | | userMapper.insert(user); |
| | |
| | | } |
| | | |
| | | /** |
| | | * 校验忘记密码验证码并生成一次性重置凭证。 |
| | | * 当前版本已下线密码能力,旧客户端调用时返回统一提示。 |
| | | */ |
| | | public AppPasswordResetVerifyResponse verifyPasswordResetCode(AppPasswordResetVerifyRequest request) { |
| | | String phone = normalizePhone(request.getPhone()); |
| | | requireEnabledUserByPhone(phone); |
| | | appSmsCodeService.validatePasswordResetCode(phone, normalizeCode(request.getCode())); |
| | | |
| | | String resetTicket = createResetTicket(); |
| | | cacheResetTicket(resetTicket, phone); |
| | | return new AppPasswordResetVerifyResponse(resetTicket); |
| | | throwPasswordAuthDisabled(); |
| | | return null; |
| | | } |
| | | |
| | | /** |
| | | * 使用一次性重置凭证更新 APP 用户密码。 |
| | | * 当前版本已下线密码能力,旧客户端调用时返回统一提示。 |
| | | */ |
| | | 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", |
| | |
| | | */ |
| | | private AppUser requireEnabledUserByPhone(String phone) { |
| | | AppUser user = requireExistingUserByPhone(phone); |
| | | requireEnabledUser(user); |
| | | return user; |
| | | } |
| | | |
| | | /** |
| | | * 按用户主键查询并校验 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; |
| | | } |
| | |
| | | } |
| | | |
| | | /** |
| | | * 第三方授权首次创建账号时必须携带医生邀请码。 |
| | | */ |
| | | private void requireInviteCodeForThirdPartyRegister(String inviteCode) { |
| | | if (normalizeInviteCode(inviteCode) == null) { |
| | | throw new InviteCodeRequiredException(); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 用户尚未绑定诊所关系时,根据医生邀请码补齐上下级关系。 |
| | | */ |
| | | private void bindInviteRelationIfAbsent(AppUser user, String inviteCode) { |
| | |
| | | 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("�"); |
| | | } |
| | | |
| | | /** |
| | |
| | | } |
| | | |
| | | /** |
| | | * 归一化密码登录场景下的密码输入,仅要求非空。 |
| | | * 抛出 APP 密码能力下线后的统一业务提示。 |
| | | */ |
| | | private String normalizeLoginPassword(String password) { |
| | | String normalized = trimToNull(password); |
| | | if (normalized == null) { |
| | | throw new IllegalArgumentException("请输入密码"); |
| | | } |
| | | return normalized; |
| | | private void throwPasswordAuthDisabled() { |
| | | throw new IllegalArgumentException(PASSWORD_AUTH_DISABLED_MESSAGE); |
| | | } |
| | | |
| | | /** |
| | |
| | | } |
| | | String upperInviteCode = normalized.toUpperCase(); |
| | | return upperInviteCode.matches("^[A-Z0-9]{6}$") ? upperInviteCode : null; |
| | | } |
| | | |
| | | /** |
| | | * 归一化并校验忘记密码场景下的新密码格式。 |
| | | */ |
| | | 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; |
| | | } |
| | | |
| | | /** |