package com.tcm.training.admin.app.auth.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; 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.AppLoginResponse; 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.data.redis.core.StringRedisTemplate; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Service; import java.time.LocalDateTime; import java.util.regex.Pattern; /** * APP 登录认证服务,负责短信登录、第三方登录与登录态刷新流程。 */ @Service public class AppAuthService { private static final Pattern PHONE_PATTERN = Pattern.compile("^1\\d{10}$"); 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; /** * 注入 APP 用户查询能力与短信验证码服务。 */ public AppAuthService(UserMapper userMapper, AppSmsCodeService appSmsCodeService, 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 sendLoginCode(AppSmsCodeSendRequest request) { String phone = normalizePhone(request.getPhone()); requireEnabledUserByPhone(phone); return appSmsCodeService.createLoginCode(phone); } /** * 当前版本已下线密码能力,旧客户端调用时返回统一提示。 */ public AppSmsCodeSendResponse sendPasswordResetCode(AppSmsCodeSendRequest request) { throwPasswordAuthDisabled(); return null; } /** * 使用短信验证码完成 APP 用户登录。 */ public AppLoginResponse smsLogin(AppSmsLoginRequest request) { String phone = normalizePhone(request.getPhone()); AppUser user = requireEnabledUserByPhone(phone); appSmsCodeService.validateLoginCode(phone, normalizeCode(request.getCode())); return createLoginResponse(user); } /** * 当前版本已下线密码登录,旧客户端调用时返回统一提示。 */ public AppLoginResponse passwordLogin(AppPasswordLoginRequest request) { throwPasswordAuthDisabled(); return null; } /** * 使用微信授权结果完成 APP 登录,首次登录自动创建普通用户。 */ public AppLoginResponse wechatLogin(AppWechatLoginRequest request) { AppWechatUserProfile profile = appWechatAuthClient.resolveUserProfile(request); AppUser user = findUserByWechatOpenid(profile.getOpenid()); if (user == null) { user = buildWechatUser(profile); bindInviteRelationIfAbsent(user, request.getInviteCode()); userMapper.insert(user); return createLoginResponse(user); } requireEnabledUser(user); // 登录时同步微信专用资料,并仅在本地昵称头像为空时补齐展示字段。 applyWechatProfile(user, profile, false); userMapper.updateById(user); return createLoginResponse(user); } /** * 使用苹果授权结果完成 APP 登录,首次登录自动创建普通用户。 */ 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); } requireEnabledUser(user); // 登录时同步苹果绑定标识,避免历史账号缺少最新 subject 或绑定时间。 applyAppleProfile(user, profile, false); userMapper.updateById(user); return createLoginResponse(user); } /** * 当前版本已下线密码能力,旧客户端调用时返回统一提示。 */ public AppPasswordResetVerifyResponse verifyPasswordResetCode(AppPasswordResetVerifyRequest request) { throwPasswordAuthDisabled(); return null; } /** * 当前版本已下线密码能力,旧客户端调用时返回统一提示。 */ public void resetPassword(AppPasswordResetRequest request) { throwPasswordAuthDisabled(); } /** * 注销当前 APP 登录态。 */ public void logout() { appAuthSessionService.logout(); } /** * 刷新当前 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()); return buildLoginResponse(user, appAuthSessionService.getTokenInfo()); } /** * 统一构造 APP 登录响应,保证登录与续期接口返回结构一致。 */ private AppLoginResponse buildLoginResponse(AppUser user, SaTokenInfo tokenInfo) { return new AppLoginResponse( tokenInfo.getTokenValue(), "Bearer", tokenInfo.getTokenTimeout(), buildLoginUser(user) ); } /** * 将 APP 用户实体转换为登录响应中的用户摘要。 */ private AppLoginUserVO buildLoginUser(AppUser user) { AppLoginUserVO loginUser = new AppLoginUserVO(); loginUser.setId(user.getId()); loginUser.setNickname(resolveNickname(user)); loginUser.setPhone(user.getPhone()); loginUser.setAvatar(resolveAvatar(user)); loginUser.setType(user.getType()); loginUser.setStatus(user.getStatus()); loginUser.setClinicId(user.getClinicId()); loginUser.setMemberExpireAt(user.getMemberExpireAt()); return loginUser; } /** * 按手机号查询 APP 用户,不存在时抛出统一业务异常。 */ private AppUser requireExistingUserByPhone(String phone) { AppUser user = userMapper.selectOne(new LambdaQueryWrapper() .eq(AppUser::getPhone, phone) .eq(AppUser::getDeleted, 0) .last("limit 1")); if (user == null) { throw new IllegalArgumentException("账号不存在,请先注册"); } return user; } /** * 按手机号查询并校验 APP 用户状态。 */ 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; } /** * 校验给定 APP 用户是否处于启用状态。 */ private void requireEnabledUser(AppUser user) { if (user.getStatus() == null || user.getStatus() != 1) { throw new IllegalArgumentException("账号已被禁用"); } } /** * 按微信 openid 查询本地 APP 用户,重复脏数据场景优先返回启用且已绑定手机号的账号。 */ private AppUser findUserByWechatOpenid(String openid) { return userMapper.selectOne(new LambdaQueryWrapper() .eq(AppUser::getWechatOpenid, openid) .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")); } /** * 按苹果 subject 查询本地 APP 用户,重复脏数据场景优先返回启用且已绑定手机号的账号。 */ private AppUser findUserByAppleSubject(String subject) { return userMapper.selectOne(new LambdaQueryWrapper() .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 AppUser buildWechatUser(AppWechatUserProfile profile) { AppUser user = new AppUser(); user.setType(0); user.setStatus(1); applyWechatProfile(user, profile, true); return 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()); } /** * 基于苹果资料构造首次登录时的本地普通用户。 */ 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 (shouldRefreshLocalNickname(currentNickname, profile.getOpenid(), nickname, syncLocalProfile)) { user.setNickname(nickname == null ? profile.getOpenid() : nickname); } if (syncLocalProfile || trimToNull(user.getAvatar()) == null) { user.setAvatar(avatar); } 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("苹果用户"); } } /** * 将微信性别值转换为本地用户性别枚举。 */ private Integer resolveWechatGender(Integer sex) { if (sex == null) { return null; } if (sex == 1) { return 1; } if (sex == 2) { return 0; } return null; } /** * 归一化并校验手机号格式。 */ private String normalizePhone(String phone) { String normalized = trimToNull(phone); if (normalized == null || !PHONE_PATTERN.matcher(normalized).matches()) { throw new IllegalArgumentException("请输入正确的手机号"); } return normalized; } /** * 归一化并校验验证码。 */ private String normalizeCode(String code) { String normalized = trimToNull(code); if (normalized == null) { throw new IllegalArgumentException("请输入验证码"); } return normalized; } /** * 抛出 APP 密码能力下线后的统一业务提示。 */ private void throwPasswordAuthDisabled() { throw new IllegalArgumentException(PASSWORD_AUTH_DISABLED_MESSAGE); } /** * 归一化医生邀请码,非法时忽略以保持第三方登录主流程可用。 */ 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 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 user.getPhone(); } /** * 优先使用本地头像,缺失时回退到微信头像。 */ private String resolveAvatar(AppUser user) { String avatar = trimToNull(user.getAvatar()); if (avatar != null) { return avatar; } return trimToNull(user.getWechatAvatar()); } /** * 将空白字符串归一化为 null。 */ private String trimToNull(String value) { if (value == null) { return null; } String trimmed = value.trim(); return trimmed.isEmpty() ? null : trimmed; } }