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.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 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.UUID; import java.util.concurrent.TimeUnit; import java.util.regex.Pattern; /** * 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 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); } /** * 为已注册 APP 用户生成忘记密码验证码。 */ public AppSmsCodeSendResponse sendPasswordResetCode(AppSmsCodeSendRequest request) { String phone = normalizePhone(request.getPhone()); requireEnabledUserByPhone(phone); return appSmsCodeService.createPasswordResetCode(phone); } /** * 使用短信验证码完成 APP 用户登录。 */ public AppLoginResponse smsLogin(AppSmsLoginRequest request) { String phone = normalizePhone(request.getPhone()); AppUser user = requireEnabledUserByPhone(phone); appSmsCodeService.validateLoginCode(phone, normalizeCode(request.getCode())); return createLoginResponse(user); } /** * 使用手机号密码完成 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); } /** * 使用微信授权结果完成 APP 登录,首次登录自动创建普通用户。 */ public AppLoginResponse wechatLogin(AppWechatLoginRequest request) { 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); 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) { requireInviteCodeForThirdPartyRegister(request.getInviteCode()); 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) { String phone = normalizePhone(request.getPhone()); requireEnabledUserByPhone(phone); appSmsCodeService.validatePasswordResetCode(phone, normalizeCode(request.getCode())); String resetTicket = createResetTicket(); cacheResetTicket(resetTicket, phone); return new AppPasswordResetVerifyResponse(resetTicket); } /** * 使用一次性重置凭证更新 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)); } /** * 注销当前 APP 登录态。 */ public void logout() { appAuthSessionService.logout(); } /** * 为成功登录的 APP 用户建立登录态并拼装统一响应。 */ private AppLoginResponse createLoginResponse(AppUser user) { appAuthSessionService.login(user.getId()); SaTokenInfo tokenInfo = appAuthSessionService.getTokenInfo(); 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 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 requireInviteCodeForThirdPartyRegister(String inviteCode) { if (normalizeInviteCode(inviteCode) == null) { throw new InviteCodeRequiredException(); } } /** * 用户尚未绑定诊所关系时,根据医生邀请码补齐上下级关系。 */ 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()); user.setWechatOpenid(profile.getOpenid()); user.setWechatNickname(nickname); user.setWechatAvatar(avatar); user.setWechatBoundAt(LocalDateTime.now()); if (syncLocalProfile || trimToNull(user.getNickname()) == null) { 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())); } } /** * 将苹果资料回填到本地用户,并在首次自动建号时补齐基础展示昵称。 */ 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; } /** * 归一化密码登录场景下的密码输入,仅要求非空。 */ private String normalizeLoginPassword(String password) { String normalized = trimToNull(password); if (normalized == null) { throw new IllegalArgumentException("请输入密码"); } 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 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; } /** * 优先使用昵称,缺失时回退到手机号作为展示名称。 */ 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; } }