| | |
| | | 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; |
| | |
| | | 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; |
| | |
| | | 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); |
| | |
| | | } |
| | | |
| | | /** |
| | | * 刷新当前 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 用户。 |
| | | * 按苹果 subject 查询本地 APP 用户,重复脏数据场景优先返回启用且已绑定手机号的账号。 |
| | | */ |
| | | private AppUser findUserByAppleSubject(String subject) { |
| | | return userMapper.selectOne(new LambdaQueryWrapper<AppUser>() |
| | | .eq(AppUser::getAppleIdentitySubject, subject) |
| | | .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")); |
| | | } |
| | | |
| | | /** |
| | |
| | | user.setStatus(1); |
| | | applyWechatProfile(user, profile, true); |
| | | return user; |
| | | } |
| | | |
| | | /** |
| | | * 第三方授权首次创建账号时必须携带医生邀请码。 |
| | | */ |
| | | private void requireInviteCodeForThirdPartyRegister(String inviteCode) { |
| | | if (normalizeInviteCode(inviteCode) == null) { |
| | | throw new InviteCodeRequiredException(); |
| | | } |
| | | } |
| | | |
| | | /** |
| | |
| | | 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) { |
| | |
| | | } |
| | | |
| | | /** |
| | | * 仅在首次建号、昵称为空、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) { |