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<AppUser>()
|
.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<AppUser>()
|
.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<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 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;
|
}
|
}
|