package com.xuekairui.user.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.xuekairui.common.BusinessException; import com.xuekairui.common.ErrorCode; import com.xuekairui.common.ValidateUtil; import com.xuekairui.common.event.ActivityEventRedisService; import com.xuekairui.user.dto.ChangePhoneRequest; import com.xuekairui.user.dto.DashboardResponse; import com.xuekairui.user.dto.ExternalTokenRequest; import com.xuekairui.user.dto.LoginResponse; import com.xuekairui.user.dto.MiniProgramLoginRequest; import com.xuekairui.user.dto.MiniProgramPhoneRequest; import com.xuekairui.user.dto.PasswordLoginRequest; import com.xuekairui.user.dto.RegisterRequest; import com.xuekairui.user.dto.SendCodeRequest; import com.xuekairui.user.dto.SetPasswordRequest; import com.xuekairui.user.dto.SmsLoginRequest; import com.xuekairui.user.dto.UserGateStatus; import com.xuekairui.user.dto.UserInfoResponse; import com.xuekairui.user.dto.UserLevelResponse; import com.xuekairui.user.dto.WechatBindRequest; import com.xuekairui.user.dto.WechatLoginRequest; import com.xuekairui.user.entity.User; import com.xuekairui.user.enums.LicenseStatus; import com.xuekairui.user.enums.MembershipLevel; import com.xuekairui.user.enums.OperationType; import com.xuekairui.user.enums.OperatorRole; import com.xuekairui.user.event.UserRegisteredEvent; import com.xuekairui.user.mapper.UserMapper; import com.xuekairui.user.util.JwtUtil; import com.xuekairui.user.util.PasswordUtil; import io.jsonwebtoken.Claims; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.slf4j.MDC; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronizationManager; import java.time.LocalDateTime; /** * 认证服务 */ @Slf4j @Service @RequiredArgsConstructor public class AuthService { private final UserMapper userMapper; private final VerificationCodeService verificationCodeService; private final WechatService wechatService; private final JwtUtil jwtUtil; private final ApplicationEventPublisher eventPublisher; private final PasswordUtil passwordUtil; private final OperationAuditLogService auditLogService; private final MembershipService membershipService; private final AdminAuthService adminAuthService; private final com.xuekairui.user.security.TokenVersionCache tokenVersionCache; private final WechatMiniProgramService wechatMiniProgramService; private final ExternalUserSyncService externalUserSyncService; private final CertificateSyncService certificateSyncService; private final BusinessLicenseService businessLicenseService; private final com.xuekairui.user.mapper.PlatformAccountMapper platformAccountMapper; private final com.xuekairui.user.mapper.CrawlerUsageLogMapper crawlerUsageLogMapper; private final com.xuekairui.user.mapper.SearchRecordMapper searchRecordMapper; private final com.xuekairui.user.mapper.PurchaseIntentMapper purchaseIntentMapper; private final ActivityEventRedisService activityEventRedisService; /** * 发送验证码 */ public void sendVerificationCode(SendCodeRequest request, String ip) { verificationCodeService.sendCode(request.getPhone(), request.getScene(), ip); } /** * 验证码登录/注册 * 如果手机号已注册则登录,否则自动注册 */ @Transactional public LoginResponse smsLogin(SmsLoginRequest request, String ip) { // 1. 验证验证码(兼容 scene=login 和 scene=register,统一用 login key) verificationCodeService.verifyCode(request.getPhone(), request.getCode()); // 2. 查找或创建用户 User user = userMapper.selectOne( new LambdaQueryWrapper().eq(User::getPhone, request.getPhone())); boolean isNewUser = false; if (user == null) { isNewUser = true; // 自动注册新用户 user = User.builder() .phone(request.getPhone()) .nickname("用户" + request.getPhone().substring(7)) .membershipLevel(MembershipLevel.PLUS) .loginSource("SMS") .status(1) .build(); userMapper.insert(user); // 注册即写入 PLUS 会员记录 membershipService.initPlusMembership(user.getId()); // 发布用户注册事件(邀请模块监听处理邀请码,赠送会员奖励) eventPublisher.publishEvent(new UserRegisteredEvent(this, user.getId(), request.getInviteCode())); // 普通会员(PLUS)免费开放,注册即拥有,无需额外发放 } // 3. 更新登录信息 user.setLastLoginTime(LocalDateTime.now()); user.setLastLoginIp(ip); user.setLoginSource("PC"); userMapper.updateById(user); // 4. 生成Token LoginResponse response = buildLoginResponse(user); response.setUserId(user.getId()); response.setNewUser(isNewUser); // 记录审计日志 if (isNewUser) { auditLogService.logSuccess(user.getId(), user.getNickname(), OperatorRole.USER.getCode(), OperationType.USER_REGISTER, "USER:" + user.getId(), user.getId()); } auditLogService.logSuccess(user.getId(), user.getNickname(), OperatorRole.USER.getCode(), OperationType.USER_LOGIN, "USER:" + user.getId(), user.getId()); return response; } /** * 微信扫码登录 */ @Transactional public LoginResponse wechatLogin(WechatLoginRequest request, String ip) { // 1. 获取微信用户信息 WechatService.WechatUserInfo wechatInfo = wechatService.getUserInfo(request.getCode()); // 2. 通过UnionID查找用户 User user = null; if (wechatInfo.getUnionId() != null) { user = userMapper.selectOne( new LambdaQueryWrapper().eq(User::getWechatUnionId, wechatInfo.getUnionId())); } // 3. 如果用户存在且已绑定手机号,直接登录 if (user != null && user.getPhone() != null) { user.setLastLoginTime(LocalDateTime.now()); user.setLastLoginIp(ip); user.setLoginSource("PC"); userMapper.updateById(user); return buildLoginResponse(user); } // 4. 如果用户存在但未绑定手机号,或新用户,返回需要绑定手机号 if (user == null) { // 创建新用户(未绑定手机号) user = User.builder() .wechatOpenId(wechatInfo.getOpenId()) .wechatUnionId(wechatInfo.getUnionId()) .nickname(wechatInfo.getNickname()) .avatar(wechatInfo.getHeadImgUrl()) .membershipLevel(MembershipLevel.PLUS) .loginSource("WECHAT") .status(1) .build(); userMapper.insert(user); membershipService.initPlusMembership(user.getId()); } else { // 更新微信信息 user.setWechatOpenId(wechatInfo.getOpenId()); user.setWechatUnionId(wechatInfo.getUnionId()); if (user.getNickname() == null) { user.setNickname(wechatInfo.getNickname()); } if (user.getAvatar() == null) { user.setAvatar(wechatInfo.getHeadImgUrl()); } user.setLastLoginTime(LocalDateTime.now()); user.setLastLoginIp(ip); userMapper.updateById(user); } // 5. 生成临时Token,要求绑定手机号 String tempToken = jwtUtil.generateTempToken(wechatInfo.getOpenId(), wechatInfo.getUnionId()); // 确保 MDC 中有 uid(微信登录临时Token场景不走 buildLoginResponse) MDC.put("uid", String.valueOf(user.getId())); return LoginResponse.builder() .needBindPhone(true) .tempToken(tempToken) .userInfo(convertToUserInfoResponse(user)) .build(); } /** * 微信绑定手机号 */ @Transactional public LoginResponse wechatBindPhone(WechatBindRequest request, String ip) { // 1. 验证临时Token Claims claims = jwtUtil.parseToken(request.getTempToken()); String tokenType = claims.get("type", String.class); if (!"temp".equals(tokenType)) { throw new BusinessException(ErrorCode.TOKEN_INVALID); } String openId = claims.get("openId", String.class); String unionId = claims.get("unionId", String.class); // 2. 验证验证码 verificationCodeService.verifyCode(request.getPhone(), request.getCode()); // 3. 检查手机号是否已被其他用户绑定 User existUser = userMapper.selectOne( new LambdaQueryWrapper().eq(User::getPhone, request.getPhone())); User wechatUser = userMapper.selectOne( new LambdaQueryWrapper().eq(User::getWechatOpenId, openId)); if (wechatUser == null) { throw new BusinessException(ErrorCode.WECHAT_LOGIN_FAILED); } if (existUser != null && !existUser.getId().equals(wechatUser.getId())) { // 手机号已被其他账号绑定,合并账号 existUser.setWechatOpenId(openId); existUser.setWechatUnionId(unionId); existUser.setLastLoginTime(LocalDateTime.now()); existUser.setLastLoginIp(ip); existUser.setLoginSource("PC"); userMapper.updateById(existUser); // 禁用旧的微信用户(不物理删除,仅改状态) wechatUser.setStatus(0); userMapper.updateById(wechatUser); wechatUser = existUser; } else if (existUser == null) { // 绑定手机号到微信用户(首次完成注册) wechatUser.setPhone(request.getPhone()); wechatUser.setLoginSource("PC"); wechatUser.setLastLoginTime(LocalDateTime.now()); wechatUser.setLastLoginIp(ip); userMapper.updateById(wechatUser); // 发布用户注册事件,邀请模块可据此建立邀请关系并发放奖励 eventPublisher.publishEvent(new UserRegisteredEvent(this, wechatUser.getId(), request.getInviteCode())); // 普通会员(PLUS)免费开放,注册即拥有,无需额外发放 } // 4. 生成正式Token return buildLoginResponse(wechatUser); } /** * 外部系统换取小程序 Token(供小程序后台按手机号签发) *

查找或自动注册用户,不递增 tokenVersion,不踢 PC。 */ @Transactional public LoginResponse externalMiniAppToken(ExternalTokenRequest request, String ip) { User user = userMapper.selectOne( new LambdaQueryWrapper().eq(User::getPhone, request.getPhone())); boolean isNewUser = false; if (user == null) { isNewUser = true; // 优先级:nickname > username > 手机号生成 String nickname = (request.getNickname() != null && !request.getNickname().isBlank()) ? request.getNickname() : (request.getUsername() != null && !request.getUsername().isBlank()) ? request.getUsername() : "用户" + request.getPhone().substring(request.getPhone().length() - 4); user = User.builder() .phone(request.getPhone()) .nickname(nickname) .userName(request.getUsername()) .wechatMiniOpenId(request.getOpenId()) .wechatUnionId(request.getUnionId()) .pharmacyName(request.getPharmacyName()) .province(request.getProvince()) .city(request.getCity()) .district(request.getDistrict()) .pharmacyAddress(request.getPharmacyAddress()) .contactPerson(request.getContactPerson()) .contactPhone(request.getContactPhone()) .businessLicenseNo(request.getBusinessLicenseNo()) .membershipLevel(MembershipLevel.PLUS) .loginSource("MINIAPP") .status(1) .build(); userMapper.insert(user); membershipService.initPlusMembership(user.getId()); if (request.getInviteCode() != null) { eventPublisher.publishEvent( new UserRegisteredEvent(this, user.getId(), request.getInviteCode())); } } else { // 已存在用户:更新第三方传入的信息(非空字段才覆盖,避免清空已有数据) // userName 独立存储原始用户名;nickname 优先取 nickname,其次 username if (request.getUsername() != null && !request.getUsername().isBlank()) { user.setUserName(request.getUsername()); } if (request.getNickname() != null && !request.getNickname().isBlank()) { user.setNickname(request.getNickname()); } else if (request.getUsername() != null && !request.getUsername().isBlank()) { user.setNickname(request.getUsername()); } if (request.getPharmacyName() != null && !request.getPharmacyName().isBlank()) { user.setPharmacyName(request.getPharmacyName()); } if (request.getProvince() != null && !request.getProvince().isBlank()) { user.setProvince(request.getProvince()); } if (request.getCity() != null && !request.getCity().isBlank()) { user.setCity(request.getCity()); } if (request.getDistrict() != null && !request.getDistrict().isBlank()) { user.setDistrict(request.getDistrict()); } if (request.getPharmacyAddress() != null && !request.getPharmacyAddress().isBlank()) { user.setPharmacyAddress(request.getPharmacyAddress()); } if (request.getContactPerson() != null && !request.getContactPerson().isBlank()) { user.setContactPerson(request.getContactPerson()); } if (request.getContactPhone() != null && !request.getContactPhone().isBlank()) { user.setContactPhone(request.getContactPhone()); } if (request.getBusinessLicenseNo() != null && !request.getBusinessLicenseNo().isBlank()) { user.setBusinessLicenseNo(request.getBusinessLicenseNo()); } if (request.getOpenId() != null && !request.getOpenId().isBlank()) { user.setWechatMiniOpenId(request.getOpenId()); } if (request.getUnionId() != null && !request.getUnionId().isBlank()) { user.setWechatUnionId(request.getUnionId()); } } user.setLoginSource("MINIAPP"); user.setLastLoginTime(LocalDateTime.now()); user.setLastLoginIp(ip); userMapper.updateById(user); // 同步入驻信息到 t_business_license(仅当传入入驻相关字段时) syncBusinessLicense(request, user.getId()); LoginResponse response = buildMiniAppLoginResponse(user); response.setNewUser(isNewUser); auditLogService.logSuccess(user.getId(), user.getNickname(), OperatorRole.USER.getCode(), isNewUser ? OperationType.USER_REGISTER : OperationType.USER_LOGIN, "USER:" + user.getId(), user.getId()); return response; } public LoginResponse heartbeat(Long userId, String role, String loginSource) { String roleCode = role != null ? role : "USER"; Long currentVersion = tokenVersionCache.getVersion(userId, false); if (currentVersion == null) { User user = userMapper.selectById(userId); currentVersion = user != null && user.getTokenVersion() != null ? user.getTokenVersion() : 0L; if (user != null) { tokenVersionCache.putVersion(userId, false, currentVersion); } } String newAccessToken = jwtUtil.generateAccessToken( userId, roleCode, loginSource, currentVersion); String newRefreshToken = jwtUtil.generateRefreshToken(userId, loginSource); User updateUser = new User(); updateUser.setId(userId); updateUser.setRefreshTokenHash(JwtUtil.hashToken(newRefreshToken)); updateUser.setRefreshTokenStatus("ACTIVE"); userMapper.updateById(updateUser); return LoginResponse.builder() .userId(userId) .accessToken(newAccessToken) .refreshToken(newRefreshToken) .expiresIn(jwtUtil.getExpirationInSeconds(roleCode, loginSource)) .needBindPhone(false) .role(roleCode) .build(); } /** * 获取用户信息 */ public UserInfoResponse getUserInfo(Long userId) { User user = userMapper.selectById(userId); if (user == null) { throw new BusinessException(ErrorCode.USER_NOT_FOUND); } return convertToUserInfoResponse(user); } /** * 获取聚合用户信息(小程序"我的"页面)— 新增,不影响 /api/auth/user/info *

返回 user + gate(门禁)+ quota(配额)+ platformBindings(平台绑定)。 */ public java.util.Map getUserMe(Long userId) { UserInfoResponse userInfo = getUserInfo(userId); UserGateStatus gate = buildGateStatus(userId); java.util.Map quota = buildQuotaSummary(userId); java.util.List platforms = getPlatformBindings(userId); java.util.Map result = new java.util.LinkedHashMap<>(toMap(userInfo)); result.put("gate", gate); result.put("quota", quota); result.put("platformBindings", platforms); return result; } /** * 首页概览统计 — 新增 */ public DashboardResponse getDashboard(Long userId) { // 今日查询次数 int todayQueryCount = crawlerUsageLogMapper.sumTodayUsage(userId); // 累计查询次数 int totalQueryCount = crawlerUsageLogMapper.sumTotalUsage(userId); // 最近查询摘要(最多 3 条) java.util.List recent = searchRecordMapper.selectRecentByUserId(userId, 3) .stream().map(r -> DashboardResponse.RecentQueryItem.builder() .drugName(r.getDrugName()) .searchedAt(r.getSearchedAt() != null ? r.getSearchedAt().toString() : null) .lowestPrice(r.getQuoteCount() > 0 ? "有报价" : null) .build()) .toList(); return DashboardResponse.builder() .todayQueryCount(todayQueryCount) .todayPurchaseIntentCount( purchaseIntentMapper.countTodayByUserId(userId, java.time.LocalDate.now())) .totalQueryCount(totalQueryCount) .recentQueries(recent) .build(); } /** * 登出 — 新增,清除 refresh token */ @Transactional public void logout(Long userId) { User user = new User(); user.setId(userId); user.setRefreshTokenHash(null); user.setRefreshTokenStatus(null); userMapper.updateById(user); log.info("用户 {} 已登出", userId); } /** * 独立注册(小程序填写药店信息后调用) — 新增 */ @Transactional public LoginResponse register(RegisterRequest request, String ip) { // 1. 验证临时 Token io.jsonwebtoken.Claims claims = jwtUtil.parseToken(request.getTempToken()); if (!"temp".equals(claims.get("type", String.class))) { throw new BusinessException(ErrorCode.TOKEN_INVALID); } String openId = claims.get("openId", String.class); String unionId = claims.get("unionId", String.class); // 2. 查找小程序用户(由 miniappUnifiedLogin 创建的未完成注册的用户) User user = userMapper.selectOne( new LambdaQueryWrapper().eq(User::getWechatMiniOpenId, openId)); if (user == null) { // fallback: try unionId if (unionId != null) { user = userMapper.selectOne( new LambdaQueryWrapper().eq(User::getWechatUnionId, unionId)); } } if (user == null) { throw new BusinessException(ErrorCode.USER_NOT_FOUND, "请先通过小程序授权登录"); } // 3. 补全药店信息 user.setPharmacyName(request.getPharmacyName()); user.setProvince(request.getProvince()); user.setCity(request.getCity()); user.setDistrict(request.getDistrict()); user.setLastLoginTime(LocalDateTime.now()); user.setLastLoginIp(ip); user.setLoginSource("MINIAPP"); userMapper.updateById(user); // 4. 发布注册事件(处理邀请码) if (request.getInviteCode() != null) { eventPublisher.publishEvent( new UserRegisteredEvent(this, user.getId(), request.getInviteCode())); } // 5. 签发正式 Token LoginResponse response = buildMiniAppLoginResponse(user); auditLogService.logSuccess(user.getId(), user.getNickname(), OperatorRole.USER.getCode(), OperationType.USER_REGISTER, "USER:" + user.getId(), user.getId()); return response; } // ==================================== // 聚合查询辅助方法 // ==================================== /** * 同步第三方传入的入驻信息到 t_business_license * 仅当请求中至少有一个入驻相关字段非空时才执行 */ private void syncBusinessLicense(ExternalTokenRequest request, Long userId) { String storeName = (request.getStoreName() != null && !request.getStoreName().isBlank()) ? request.getStoreName() : request.getPharmacyName(); boolean hasLicenseField = isNotBlank(storeName) || isNotBlank(request.getTerminalType()) || isNotBlank(request.getBusinessLicenseUrl()) || isNotBlank(request.getDrugLicenseUrl()) || isNotBlank(request.getMedicalDeviceClass2Url()) || isNotBlank(request.getMedicalDeviceClass3Url()); if (!hasLicenseField) { return; } com.xuekairui.user.entity.BusinessLicense existing = businessLicenseService.getByUserId(userId); // 审核状态:0-审核中→PENDING / 1-审核成功→APPROVED / 2-审核失败→REJECTED(不传默认0) Integer status = request.getStatus(); String reviewStatus; if (status == null || status == 0) { reviewStatus = LicenseStatus.PENDING.name(); } else if (status == 1) { reviewStatus = LicenseStatus.APPROVED.name(); } else { reviewStatus = LicenseStatus.REJECTED.name(); } if (existing == null) { com.xuekairui.user.entity.BusinessLicense license = com.xuekairui.user.entity.BusinessLicense.builder() .userId(userId) .storeName(storeName) .terminalType(request.getTerminalType()) .province(request.getProvince()) .city(request.getCity()) .district(request.getDistrict()) .storeAddress(request.getPharmacyAddress()) .contactPerson(request.getContactPerson()) .contactPhone(request.getContactPhone()) .licenseImageUrl(request.getBusinessLicenseUrl()) .drugLicenseUrl(request.getDrugLicenseUrl()) .creditCode(request.getBusinessLicenseNo()) .medicalDeviceClass2Url(request.getMedicalDeviceClass2Url()) .medicalDeviceClass3Url(request.getMedicalDeviceClass3Url()) .reviewStatus(reviewStatus) .showVerifiedBadge(false) .syncSource("MINIAPP_SYNC") .build(); businessLicenseService.insert(license); log.info("第三方用户{}同步入驻信息,创建新记录: licenseId={}, status={}", userId, license.getId(), reviewStatus); // 第三方已审核通过:赠送30天高级会员 + 推送Redis事件 if (LicenseStatus.APPROVED.name().equals(reviewStatus)) { grantLicenseApprovedReward(userId, license.getId()); } } else { if (storeName != null && !storeName.isBlank()) { existing.setStoreName(storeName); } if (isNotBlank(request.getTerminalType())) { existing.setTerminalType(request.getTerminalType()); } if (isNotBlank(request.getProvince())) { existing.setProvince(request.getProvince()); } if (isNotBlank(request.getCity())) { existing.setCity(request.getCity()); } if (isNotBlank(request.getDistrict())) { existing.setDistrict(request.getDistrict()); } if (isNotBlank(request.getPharmacyAddress())) { existing.setStoreAddress(request.getPharmacyAddress()); } if (isNotBlank(request.getContactPerson())) { existing.setContactPerson(request.getContactPerson()); } if (isNotBlank(request.getContactPhone())) { existing.setContactPhone(request.getContactPhone()); } if (isNotBlank(request.getBusinessLicenseUrl())) { existing.setLicenseImageUrl(request.getBusinessLicenseUrl()); } if (isNotBlank(request.getDrugLicenseUrl())) { existing.setDrugLicenseUrl(request.getDrugLicenseUrl()); } if (isNotBlank(request.getBusinessLicenseNo())) { existing.setCreditCode(request.getBusinessLicenseNo()); } if (isNotBlank(request.getMedicalDeviceClass2Url())) { existing.setMedicalDeviceClass2Url(request.getMedicalDeviceClass2Url()); } if (isNotBlank(request.getMedicalDeviceClass3Url())) { existing.setMedicalDeviceClass3Url(request.getMedicalDeviceClass3Url()); } // 已 APPROVED 的不降级(不覆盖);非 APPROVED 状态可随第三方 status 更新(含 PENDING/REJECTED 互转、升级到 APPROVED) boolean upgradedToApproved = false; if (!LicenseStatus.APPROVED.name().equals(existing.getReviewStatus())) { existing.setReviewStatus(reviewStatus); upgradedToApproved = LicenseStatus.APPROVED.name().equals(reviewStatus); } existing.setSyncSource("MINIAPP_SYNC"); businessLicenseService.updateById(existing); // 从非APPROVED升级到APPROVED:赠送30天高级会员 + 推送Redis事件 if (upgradedToApproved) { grantLicenseApprovedReward(userId, existing.getId()); } } } private boolean isNotBlank(String s) { return s != null && !s.isBlank(); } /** * 首次入驻审核通过:赠送30天高级会员 + 推送Redis事件供活动模块消费 *

与 BusinessLicenseService.approve() 的奖励逻辑保持一致。 */ private void grantLicenseApprovedReward(Long userId, Long licenseId) { membershipService.grantOrExtendMembership( userId, MembershipLevel.PRO, 30, "LICENSE", "入驻信息审核通过赠送30天高级会员"); log.info("入驻信息审核通过(第三方同步),赠送用户{} 30天高级会员", userId); // 事务提交后推送 Redis 事件,供活动模块消费(邀请有礼等) TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { @Override public void afterCommit() { try { activityEventRedisService.pushLicenseApproved(userId, licenseId, "BUSINESS_LICENSE"); } catch (Exception e) { log.warn("存入入驻审核通过事件失败,不影响主流程: userId={}", userId, e); } } }); } private UserGateStatus buildGateStatus(Long userId) { // 执照审核状态 com.xuekairui.user.entity.BusinessLicense license = businessLicenseService.getByUserId(userId); String licenseStatus = license != null ? license.getReviewStatus() : LicenseStatus.NOT_SUBMITTED.name(); // 已绑定平台数 Long boundCount = platformAccountMapper.selectCount( new LambdaQueryWrapper() .eq(com.xuekairui.user.entity.PlatformAccount::getUserId, userId) .eq(com.xuekairui.user.entity.PlatformAccount::getEnabled, true)); boolean canQuery = LicenseStatus.APPROVED.name().equals(licenseStatus) && boundCount >= 1; java.util.List messages = new java.util.ArrayList<>(); if (!LicenseStatus.APPROVED.name().equals(licenseStatus)) { messages.add("请先提交入驻信息并通过审核"); } if (boundCount == 0) { messages.add("请至少绑定1个B2B平台账号"); } return UserGateStatus.builder() .canQueryOnDesktop(canQuery) .licenseStatus(licenseStatus) .platformBoundCount(boundCount.intValue()) .gateMessages(messages) .build(); } private java.util.Map buildQuotaSummary(Long userId) { // 今日已用 + 每日上限 int dailyUsed = crawlerUsageLogMapper.sumTodayUsage(userId); java.util.Map quota = new java.util.LinkedHashMap<>(); quota.put("dailyQueryUsed", dailyUsed); quota.put("dailyQueryLimit", 20); // TODO: 从会员等级动态获取 return quota; } private java.util.List> getPlatformBindings(Long userId) { java.util.List accounts = platformAccountMapper.selectList( new LambdaQueryWrapper() .eq(com.xuekairui.user.entity.PlatformAccount::getUserId, userId)); return accounts.stream().map(a -> { java.util.Map m = new java.util.LinkedHashMap<>(); m.put("platformCode", a.getPlatformCode()); m.put("platformName", a.getPlatformName()); m.put("status", Boolean.TRUE.equals(a.getEnabled()) ? "bound" : "disabled"); m.put("lastVerifyTime", a.getLastVerifyTime()); return m; }).toList(); } private java.util.Map toMap(UserInfoResponse info) { // 简单反射转 Map,避免引入额外依赖 java.util.Map map = new java.util.LinkedHashMap<>(); map.put("id", info.getId()); map.put("phone", info.getPhone()); map.put("nickname", info.getNickname()); map.put("avatar", info.getAvatar()); map.put("wechatBound", info.getWechatBound()); map.put("createTime", info.getCreateTime()); map.put("loginSource", info.getLoginSource()); map.put("pharmacyName", info.getPharmacyName()); map.put("province", info.getProvince()); map.put("city", info.getCity()); map.put("district", info.getDistrict()); map.put("role", info.getRole()); map.put("membershipLevel", info.getMembershipLevel()); map.put("membershipLevelName", info.getMembershipLevelName()); map.put("inviterId", info.getInviterId()); map.put("inviterNickname", info.getInviterNickname()); map.put("hasBoundInviteCode", info.getHasBoundInviteCode()); map.put("licenseStatus", info.getLicenseStatus()); map.put("levelCode", info.getLevelCode()); map.put("version", info.getVersion()); return map; } /** * Refresh Token 旋转 + 重用检测 *

* 每次刷新:旧 Refresh Token 标记为 USED,签发新的一对 Token。 * 如果检测到已 USED 的 Refresh Token 被再次使用 → 说明被盗 → 踢出所有设备。 */ @Transactional public LoginResponse refreshToken(String refreshToken) { // 1. 验证JWT格式 String tokenType = jwtUtil.getTokenType(refreshToken); if (!"refresh".equals(tokenType)) { throw new BusinessException(ErrorCode.REFRESH_TOKEN_INVALID); } Long userId = jwtUtil.getUserId(refreshToken); // 只查 Refresh Token 校验所需的字段,减少DB开销 User user = userMapper.selectOne( new LambdaQueryWrapper() .select(User::getId, User::getPhone, User::getTokenVersion, User::getRefreshTokenHash, User::getRefreshTokenStatus, User::getStatus) .eq(User::getId, userId)); if (user == null) { throw new BusinessException(ErrorCode.USER_NOT_FOUND); } // 2. 计算哈希,比对DB中的记录 String tokenHash = JwtUtil.hashToken(refreshToken); String storedHash = user.getRefreshTokenHash(); String storedStatus = user.getRefreshTokenStatus(); // 3. 重用检测:Refresh Token 已被使用过 → 被盗! if (tokenHash.equals(storedHash) && "USED".equals(storedStatus)) { log.warn("用户 {} 的Refresh Token被重用(疑似被盗),踢出所有设备", userId); // 递增 tokenVersion,使所有旧 access token 立即失效 long newVersion = (user.getTokenVersion() != null ? user.getTokenVersion() : 0L) + 1; user.setTokenVersion(newVersion); user.setRefreshTokenHash(null); user.setRefreshTokenStatus(null); userMapper.updateById(user); tokenVersionCache.putVersion(userId, false, newVersion); throw new BusinessException(ErrorCode.TOKEN_KICKED); } // 4. 校验:哈希不匹配 → 无效或过期的 Refresh Token if (!tokenHash.equals(storedHash) || !"ACTIVE".equals(storedStatus)) { throw new BusinessException(ErrorCode.REFRESH_TOKEN_INVALID); } // 5. 旋转:标记旧 Refresh Token 为 USED,签发新的一对 user.setRefreshTokenStatus("USED"); String roleCode = "USER"; long currentVersion = user.getTokenVersion() != null ? user.getTokenVersion() : 0L; String loginSource = jwtUtil.getLoginSource(refreshToken); String newAccessToken = jwtUtil.generateAccessToken( user.getId(), roleCode, loginSource, currentVersion); String newRefreshToken = jwtUtil.generateRefreshToken(user.getId(), loginSource); // 存储新 Refresh Token 哈希 user.setRefreshTokenHash(JwtUtil.hashToken(newRefreshToken)); user.setRefreshTokenStatus("ACTIVE"); userMapper.updateById(user); log.debug("用户 {} Refresh Token 旋转成功", userId); return LoginResponse.builder() .userId(user.getId()) .accessToken(newAccessToken) .refreshToken(newRefreshToken) .expiresIn(jwtUtil.getExpirationInSeconds(roleCode, loginSource)) .userInfo(convertToUserInfoResponse(user)) .needBindPhone(false) .newUser(false) .role(roleCode) .build(); } /** PC 端构建登录响应: * 递增 tokenVersion,踢掉旧 PC */ private LoginResponse buildLoginResponse(User user) { return buildLoginResponseInternal(user, true, "PC"); } /** 小程序构建登录响应:不递增 tokenVersion,不踢 PC */ private LoginResponse buildMiniAppLoginResponse(User user) { return buildLoginResponseInternal(user, false, "MINIAPP"); } private LoginResponse buildLoginResponseInternal(User user, boolean incrementVersion, String loginSource) { MDC.put("uid", String.valueOf(user.getId())); String roleCode = "USER"; long version; if (incrementVersion) { long oldVersion = user.getTokenVersion() != null ? user.getTokenVersion() : 0L; version = oldVersion + 1; user.setTokenVersion(version); tokenVersionCache.putVersion(user.getId(), false, version); } else { version = user.getTokenVersion() != null ? user.getTokenVersion() : 0L; } String accessToken = jwtUtil.generateAccessToken( user.getId(), "USER", loginSource, version); String refreshToken = jwtUtil.generateRefreshToken(user.getId(), loginSource); user.setRefreshTokenHash(JwtUtil.hashToken(refreshToken)); user.setRefreshTokenStatus("ACTIVE"); userMapper.updateById(user); return LoginResponse.builder() .userId(user.getId()) .accessToken(accessToken) .refreshToken(refreshToken) .expiresIn(jwtUtil.getExpirationInSeconds(roleCode, loginSource)) .userInfo(convertToUserInfoResponse(user)) .needBindPhone(false) .newUser(false) .role(roleCode) .build(); } /** * 转换用户信息响应 */ private UserInfoResponse convertToUserInfoResponse(User user) { UserLevelResponse levelInfo = null; if (user != null && user.getId() != null) { try { levelInfo = membershipService.getUserLevel(user.getId()); } catch (Exception e) { log.warn("获取用户等级信息失败: userId={}, error={}", user.getId(), e.getMessage()); } } // 营业执照审核状态 String licenseStatus = LicenseStatus.NOT_SUBMITTED.name(); try { assert user != null; com.xuekairui.user.entity.BusinessLicense license = businessLicenseService.getByUserId(user.getId()); if (license != null && license.getReviewStatus() != null) { licenseStatus = license.getReviewStatus(); } } catch (Exception e) { log.warn("查询营业执照状态失败: userId={}, error={}", user.getId(), e.getMessage()); } // 邀请人信息 Long inviterId = user.getInviterId(); String inviterNickname = null; if (inviterId != null) { User inviter = userMapper.selectById(inviterId); if (inviter != null) { inviterNickname = inviter.getWechatNickname() != null ? inviter.getWechatNickname() : inviter.getNickname() != null ? inviter.getNickname() : "一位用户"; } } return UserInfoResponse.builder() .id(user.getId()) .phone(ValidateUtil.maskPhone(user.getPhone())) .nickname(user.getNickname()) .userName(user.getUserName()) .avatar(user.getAvatar()) .wechatBound(user.getWechatOpenId() != null) .levelInfo(levelInfo) .createTime(user.getCreateTime()) .loginSource(user.getLoginSource() != null ? user.getLoginSource() : "UNKNOWN") .pharmacyName(user.getPharmacyName()) .province(user.getProvince()) .city(user.getCity()) .district(user.getDistrict()) .role("USER") .membershipLevel(levelInfo != null ? levelInfo.getLevelCode() : "PLUS") .membershipLevelName(levelInfo != null ? levelInfo.getLevelName() : "普通会员") .inviterId(inviterId) .inviterNickname(inviterNickname) .hasBoundInviteCode(inviterId != null) .licenseStatus(licenseStatus) .build(); } /** * 密码登录 * 支持手机号或用户名登录:phone 和 username 至少填一个 */ @Transactional public LoginResponse passwordLogin(PasswordLoginRequest request, String ip) { // 1. 校验:phone 和 username 至少填一个 boolean hasPhone = request.getPhone() != null && !request.getPhone().isBlank(); boolean hasUsername = request.getUsername() != null && !request.getUsername().isBlank(); if (!hasPhone && !hasUsername) { throw new BusinessException(ErrorCode.PARAM_ERROR, "手机号和用户名至少填写一个"); } // 2. 查找用户(优先手机号,其次用户名/昵称) User user; if (hasPhone) { user = userMapper.selectOne( new LambdaQueryWrapper().eq(User::getPhone, request.getPhone())); } else { user = userMapper.selectOne( new LambdaQueryWrapper().eq(User::getNickname, request.getUsername())); } if (user == null) { return adminAuthService.passwordLogin( hasPhone ? request.getPhone() : request.getUsername(), request.getPassword(), ip); } // 2. 检查是否设置了密码 if (user.getPassword() == null) { throw new BusinessException(ErrorCode.PASSWORD_NOT_SET); } // 3. 验证密码 if (!passwordUtil.matches(request.getPassword(), user.getPassword())) { throw new BusinessException(ErrorCode.PASSWORD_INCORRECT); } // 4. 检查用户状态 if (user.getStatus() != null && user.getStatus() == 0) { throw new BusinessException(ErrorCode.USER_DISABLED); } // 5. 更新登录信息 user.setLastLoginTime(LocalDateTime.now()); user.setLastLoginIp(ip); user.setLoginSource("PC"); userMapper.updateById(user); // 6. 生成Token LoginResponse response = buildLoginResponse(user); auditLogService.logSuccess(user.getId(), user.getNickname(), OperatorRole.USER.getCode(), OperationType.USER_LOGIN, "USER:" + user.getId(), user.getId()); return response; } /** * 设置/修改密码 */ @Transactional public void setPassword(Long userId, SetPasswordRequest request) { User user = userMapper.selectById(userId); if (user == null) { throw new BusinessException(ErrorCode.USER_NOT_FOUND); } // 如果已有密码,需要验证旧密码 if (user.getPassword() != null && request.getOldPassword() != null) { if (!passwordUtil.matches(request.getOldPassword(), user.getPassword())) { throw new BusinessException(ErrorCode.OLD_PASSWORD_INCORRECT); } } else if (user.getPassword() != null) { throw new BusinessException(ErrorCode.OLD_PASSWORD_REQUIRED); } // 加密并更新密码 user.setPassword(passwordUtil.encode(request.getNewPassword())); userMapper.updateById(user); log.info("用户{}设置/修改密码成功", userId); auditLogService.logSuccess(userId, user.getNickname(), OperatorRole.USER.getCode(), OperationType.USER_UPDATE, "PHONE:" + userId, userId); } /** * 修改手机号 */ @Transactional public void changePhone(Long userId, ChangePhoneRequest request) { User user = userMapper.selectById(userId); if (user == null) { throw new BusinessException(ErrorCode.USER_NOT_FOUND); } // 1. 验证验证码(使用新手机号和change场景) verificationCodeService.verifyCode(request.getNewPhone(), request.getCode()); // 2. 检查新手机号是否已被其他用户占用 User existUser = userMapper.selectOne( new LambdaQueryWrapper().eq(User::getPhone, request.getNewPhone())); if (existUser != null && !existUser.getId().equals(userId)) { throw new BusinessException(ErrorCode.PHONE_ALREADY_EXISTS); } // 3. 更新手机号 String oldPhone = user.getPhone(); user.setPhone(request.getNewPhone()); userMapper.updateById(user); log.info("用户{}修改手机号成功,旧手机号:{},新手机号:{}", userId, oldPhone, request.getNewPhone()); auditLogService.logSuccess(userId, user.getNickname(), OperatorRole.USER.getCode(), OperationType.USER_UPDATE, "PHONE:" + userId, userId); } // ==================================== // 小程序统一登录(处理四种用户身份场景) // ==================================== /** * 小程序统一登录 *

* 自动处理"小程序老/新用户 × 当前系统老/新用户"四种场景: *

    *
  1. 外部老 + 本地老 → 直接登录
  2. *
  3. 外部老 + 本地新 → 本地注册 + 异步同步证书
  4. *
  5. 外部新 + 本地老 → 异步注册外部 + 本地直接登录
  6. *
  7. 外部新 + 本地新 → 两端都注册
  8. *
*

* 关键原则:外部系统操作异步执行,不阻塞登录响应。 */ @Transactional public LoginResponse miniappUnifiedLogin(MiniProgramLoginRequest request, String ip) { // 1. code2Session WechatMiniProgramService.MiniProgramSessionInfo session = wechatMiniProgramService.code2Session(request.getJsCode()); // 2. 获取手机号(如果提供了 phoneCode) String phone = null; if (request.getPhoneCode() != null && !request.getPhoneCode().isBlank()) { try { phone = wechatMiniProgramService.getPhoneNumber(request.getPhoneCode()); } catch (Exception e) { log.warn("获取小程序手机号失败(不阻断登录): {}", e.getMessage()); } } // 3. 并行检查双系统 User user = findUserByWechatIdentity(session.getUnionId(), session.getOpenId()); boolean existsExternal = checkExternalUserSafe(session.getUnionId()); boolean isNewLocal = (user == null); log.info("小程序统一登录: unionId={}, isNewLocal={}, existsExternal={}", session.getUnionId(), isNewLocal, existsExternal); // 4. 场景路由 if (!isNewLocal && existsExternal) { // 场景1: 两端都有 → 更新微信信息,直接登录 updateWechatMiniInfo(user, session.getOpenId(), session.getUnionId(), phone, ip); } else if (isNewLocal && existsExternal) { // 场景2: 外部老 + 本地新 → 本地注册 + 异步同步证书 user = createUserFromMiniProgram(session, phone, request.getInviteCode(), ip); final Long newUserId = user.getId(); final String syncUnionId = session.getUnionId(); try { certificateSyncService.syncCertificatesFromExternal(newUserId, syncUnionId); } catch (Exception e) { log.warn("触发证书同步失败(不阻断登录): userId={}", newUserId, e); } } else if (!isNewLocal) { // 场景3: 外部新 + 本地老 → 更新微信信息,异步注册外部 updateWechatMiniInfo(user, session.getOpenId(), session.getUnionId(), phone, ip); asyncRegisterInExternal(user, session.getOpenId()); } else { // 场景4: 两端都新 → 本地注册 + 异步注册外部 user = createUserFromMiniProgram(session, phone, request.getInviteCode(), ip); asyncRegisterInExternal(user, session.getOpenId()); } // 5. 没有手机号 → 返回需要绑定 if (user.getPhone() == null) { String tempToken = jwtUtil.generateTempToken(session.getOpenId(), session.getUnionId()); MDC.put("uid", String.valueOf(user.getId())); return LoginResponse.builder() .needBindPhone(true) .tempToken(tempToken) .userInfo(convertToUserInfoResponse(user)) .build(); } // 6. 更新登录信息并签发 Token user.setLastLoginTime(LocalDateTime.now()); user.setLastLoginIp(ip); user.setLoginSource("MINIAPP"); userMapper.updateById(user); LoginResponse response = buildMiniAppLoginResponse(user); auditLogService.logSuccess(user.getId(), user.getNickname(), OperatorRole.USER.getCode(), isNewLocal ? OperationType.USER_REGISTER : OperationType.USER_LOGIN, "USER:" + user.getId(), user.getId()); return response; } /** * 小程序绑定手机号(needBindPhone=true 时调用) */ @Transactional public LoginResponse miniappBindPhone(MiniProgramPhoneRequest request, String ip) { // 1. 验证临时 Token Claims claims = jwtUtil.parseToken(request.getTempToken()); if (!"temp".equals(claims.get("type", String.class))) { throw new BusinessException(ErrorCode.TOKEN_INVALID); } String openId = claims.get("openId", String.class); String unionId = claims.get("unionId", String.class); // 2. 获取手机号 String phone = wechatMiniProgramService.getPhoneNumber(request.getPhoneCode()); // 3. 查找小程序用户 User wechatUser = userMapper.selectOne( new LambdaQueryWrapper().eq(User::getWechatMiniOpenId, openId)); if (wechatUser == null) { throw new BusinessException(ErrorCode.WECHAT_LOGIN_FAILED); } // 4. 检查手机号是否已被其他用户绑定 User existUser = userMapper.selectOne( new LambdaQueryWrapper().eq(User::getPhone, phone)); if (existUser != null && !existUser.getId().equals(wechatUser.getId())) { // 手机号已被其他账号绑定 → 合并账号 existUser.setWechatMiniOpenId(openId); if (unionId != null) { existUser.setWechatUnionId(unionId); } existUser.setLastLoginTime(LocalDateTime.now()); existUser.setLastLoginIp(ip); userMapper.updateById(existUser); // 禁用旧的小程序用户(不物理删除,仅改状态) wechatUser.setStatus(0); userMapper.updateById(wechatUser); wechatUser = existUser; } else if (existUser == null) { // 绑定手机号(完成注册) wechatUser.setPhone(phone); wechatUser.setLoginSource("MINIAPP"); wechatUser.setLastLoginTime(LocalDateTime.now()); wechatUser.setLastLoginIp(ip); userMapper.updateById(wechatUser); eventPublisher.publishEvent(new UserRegisteredEvent( this, wechatUser.getId(), request.getInviteCode())); } return buildMiniAppLoginResponse(wechatUser); } // ==================================== // 小程序登录辅助方法 // ==================================== /** 通过微信身份标识查找用户(unionId 优先,fallback miniOpenId) */ private User findUserByWechatIdentity(String unionId, String miniOpenId) { if (unionId != null) { User user = userMapper.selectOne( new LambdaQueryWrapper().eq(User::getWechatUnionId, unionId)); if (user != null) { return user; } } if (miniOpenId != null) { return userMapper.selectOne( new LambdaQueryWrapper().eq(User::getWechatMiniOpenId, miniOpenId)); } return null; } /** 安全检查外部用户(不可用时降级为"外部新用户",不阻塞流程) */ private boolean checkExternalUserSafe(String unionId) { try { return externalUserSyncService.userExists(unionId); } catch (Exception e) { log.warn("检查外部用户失败(降级为外部新用户): unionId={}", unionId, e); return false; } } /** 从小程序注册新用户 */ private User createUserFromMiniProgram( WechatMiniProgramService.MiniProgramSessionInfo session, String phone, String inviteCode, String ip) { User user = User.builder() .wechatMiniOpenId(session.getOpenId()) .wechatUnionId(session.getUnionId()) .phone(phone) .nickname("药店用户") .membershipLevel(MembershipLevel.PLUS) .loginSource("MINIAPP") .lastLoginTime(LocalDateTime.now()) .lastLoginIp(ip) .status(1) .build(); userMapper.insert(user); membershipService.initPlusMembership(user.getId()); if (inviteCode != null) { eventPublisher.publishEvent(new UserRegisteredEvent(this, user.getId(), inviteCode)); } log.info("小程序新用户注册: userId={}, unionId={}", user.getId(), session.getUnionId()); return user; } /** 更新已有用户的微信小程序信息 */ private void updateWechatMiniInfo(User user, String miniOpenId, String unionId, String phone, String ip) { boolean changed = false; if (miniOpenId != null && !miniOpenId.equals(user.getWechatMiniOpenId())) { user.setWechatMiniOpenId(miniOpenId); changed = true; } if (unionId != null && !unionId.equals(user.getWechatUnionId())) { user.setWechatUnionId(unionId); changed = true; } if (phone != null && user.getPhone() == null) { user.setPhone(phone); changed = true; } user.setLastLoginTime(LocalDateTime.now()); user.setLastLoginIp(ip); user.setLoginSource("MINIAPP"); if (changed) { userMapper.updateById(user); } } /** 异步在外部系统注册用户(不阻塞主流程) */ private void asyncRegisterInExternal(User user, String miniProgramOpenId) { try { ExternalUserSyncService.ExternalUserInfo extInfo = new ExternalUserSyncService.ExternalUserInfo(); extInfo.setMiniProgramOpenId(miniProgramOpenId); extInfo.setUnionId(user.getWechatUnionId()); extInfo.setPhone(user.getPhone()); extInfo.setNickname(user.getNickname()); extInfo.setAvatar(user.getAvatar()); String extUserId = externalUserSyncService.registerUser(extInfo); if (extUserId != null) { user.setExternalUserId(extUserId); user.setExternalSyncTime(LocalDateTime.now()); userMapper.updateById(user); log.info("外部系统注册成功: userId={}, extUserId={}", user.getId(), extUserId); } } catch (Exception e) { log.warn("外部系统注册失败(不阻断主流程): userId={}", user.getId(), e); } } }