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.*; import com.xuekairui.user.entity.User; import com.xuekairui.user.enums.LicenseStatus; import com.xuekairui.user.enums.MembershipLevel; import com.xuekairui.user.enums.OperationSource; 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; import java.util.Map; /** * 认证服务 */ @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 ShopResourceSyncService shopResourceSyncService; private final com.xuekairui.user.mapper.LicenseSyncDiffMapper licenseSyncDiffMapper; 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); String loginSource = request.getLoginSource() != null && !request.getLoginSource().isBlank() ? request.getLoginSource() : "PC"; user.setLoginSource(loginSource); userMapper.updateById(user); // 4. 生成Token(小程序不递增tokenVersion,不踢PC) boolean incrementVersion = !"MINIAPP".equals(loginSource); LoginResponse response = buildLoginResponseInternal(user, incrementVersion, loginSource, false); 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()); // 登录时检查入驻信息,未同步成功则补推一次到第三方 syncShopResourceOnLogin(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); syncShopResourceOnLogin(user.getId()); 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 syncShopResourceOnLogin(wechatUser.getId()); return buildLoginResponse(wechatUser); } /** * 外部系统换取 Token(登录 + 信息同步,兼容旧版一并传入用户资料和入驻信息) *

查找或自动注册用户。根据 loginSource 决定过期策略:MINIAPP=30天不踢PC / 其他=1小时。 */ @Transactional public ExternalTokenResponse externalMiniAppToken(ExternalTokenRequest request, String ip) { // 登录来源:不传默认 MINIAPP(向后兼容) String loginSource = request.getLoginSource() != null && !request.getLoginSource().isBlank() ? request.getLoginSource() : "MINIAPP"; 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(loginSource) .status(1) .build(); userMapper.insert(user); membershipService.initPlusMembership(user.getId()); if (request.getInviteCode() != null) { eventPublisher.publishEvent( new UserRegisteredEvent(this, user.getId(), request.getInviteCode())); } } else { // 已存在用户:更新第三方传入的信息(非空字段才覆盖) 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(loginSource); user.setLastLoginTime(LocalDateTime.now()); user.setLastLoginIp(ip); userMapper.updateById(user); // 同步入驻信息到 t_business_license(仅当传入入驻相关字段时) syncBusinessLicenseFromTokenRequest(request, user.getId()); boolean skipRefresh = request.getSkipRefreshToken() != null && request.getSkipRefreshToken(); boolean incrementVersion = !"MINIAPP".equals(loginSource); LoginResponse response = buildLoginResponseInternal(user, incrementVersion, loginSource, skipRefresh); 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 ExternalTokenResponse.builder() .userId(response.getUserId()) .accessToken(response.getAccessToken()) .refreshToken(response.getRefreshToken()) .expiresIn(response.getExpiresIn()) .newUser(isNewUser) .role(response.getRole()) .build(); } 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); // 心跳不覆盖已有的 ACTIVE refreshToken,避免 PC/小程序互相踢 refreshToken User user = userMapper.selectById(userId); String newRefreshToken = null; if (user != null && (user.getRefreshTokenHash() == null || !"ACTIVE".equals(user.getRefreshTokenStatus()))) { newRefreshToken = jwtUtil.generateRefreshToken(userId, loginSource); } User updateUser = new User(); updateUser.setId(userId); if (newRefreshToken != null) { 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; } // ==================================== // 聚合查询辅助方法 // ==================================== /** * 第三方信息同步(独立于登录,通过手机号定位用户,更新用户信息 + 入驻信息) */ @Transactional public void syncExternalInfo(SyncInfoRequest request) { User user = userMapper.selectOne( new LambdaQueryWrapper().eq(User::getPhone, request.getPhone())); if (user == null) { throw new BusinessException(ErrorCode.USER_NOT_FOUND); } // 更新用户信息字段(非空覆盖) 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()); } userMapper.updateById(user); // 同步入驻信息到 t_business_license syncBusinessLicense(request, user.getId()); auditLogService.logSuccess( 0L, OperatorRole.SYSTEM, com.xuekairui.user.enums.OperationType.LICENSE_SYNC_THIRD, "SYNC_INFO:" + request.getPhone(), user.getId(), null, Map.of("phone", request.getPhone(), "userId", user.getId(), "status", request.getStatus(), "storeName", request.getStoreName()), "第三方同步用户信息:phone=" + request.getPhone(), com.xuekairui.user.enums.OperationSource.SYSTEM_SYNC); log.info("第三方信息同步完成: phone={}, userId={}", request.getPhone(), user.getId()); } /** * 第三方同步用户店铺信息(无鉴权,按手机号自动创建/更新用户,仅返回同步确认) *

对应接口:POST /api/sync_shop_resource/report_user */ @Transactional public SyncShopReportResponse reportUser(SyncShopReportRequest request) { String phone = request.getPhone(); if (phone == null || phone.isBlank()) { throw new BusinessException(ErrorCode.PARAM_ERROR, "手机号不能为空"); } // 1. 查找或创建用户 User user = userMapper.selectOne( new LambdaQueryWrapper().eq(User::getPhone, phone)); boolean isNewUser = false; if (user == null) { isNewUser = true; user = User.builder() .phone(phone) .nickname("用户" + phone.substring(phone.length() - 4)) .membershipLevel(MembershipLevel.PLUS) .loginSource("MINIAPP") .status(1) .build(); userMapper.insert(user); membershipService.initPlusMembership(user.getId()); } // 2. 映射第三方参数 → 更新用户信息(非空覆盖) updateUserFromShopReport(user, request); userMapper.updateById(user); // 3. 同步入驻信息 syncBusinessLicenseFromShopReport(request, user.getId()); log.info("第三方店铺同步完成: phone={}, userId={}, newUser={}", phone, user.getId(), isNewUser); return SyncShopReportResponse.builder() .userId(String.valueOf(user.getId())) .newUser(isNewUser) .build(); } /** 第三方 shop_type → 内部枚举字符串,兼容数字 "1"/"2"/"3" 和英文 */ private static String parseShopType(String shopType) { if (shopType == null || shopType.isBlank()) { return null; } return switch (shopType) { case "1", "SINGLE" -> "SINGLE"; case "2", "CHAIN" -> "CHAIN"; case "3", "CLINIC", "COMMUNITY_HEALTH" -> "CLINIC"; default -> shopType; // 未知值原样保留 }; } /** 映射第三方 status → int */ private static int mapStatus(String status) { if (status == null || status.isBlank()) { return 0; } try { return Integer.parseInt(status); } catch (NumberFormatException e) { return 0; } } /** 更新用户信息字段 */ private void updateUserFromShopReport(User user, SyncShopReportRequest req) { if (isNotBlank(req.getContactName())) { user.setNickname(req.getContactName()); } if (isNotBlank(req.getContactShop())) { user.setPharmacyName(req.getContactShop()); } if (isNotBlank(req.getContactProvince())) { user.setProvince(req.getContactProvince()); } if (isNotBlank(req.getContactCity())) { user.setCity(req.getContactCity()); } if (isNotBlank(req.getContactArea())) { user.setDistrict(req.getContactArea()); } if (isNotBlank(req.getContactAddr())) { user.setPharmacyAddress(req.getContactAddr()); } if (isNotBlank(req.getContactPhone())) { user.setContactPhone(req.getContactPhone()); } } /** 同步入驻信息到 t_business_license */ private void syncBusinessLicenseFromShopReport(SyncShopReportRequest req, Long userId) { String storeName = isNotBlank(req.getContactShop()) ? req.getContactShop() : null; String terminalType = parseShopType(req.getShopType()); int status = mapStatus(req.getStatus()); boolean hasLicenseField = isNotBlank(storeName) || terminalType != null || isNotBlank(req.getBusinessLicenseImage()) || isNotBlank(req.getDrugBusinessLicenseImage()) || isNotBlank(req.getTwoMedicalDeviceRegistration()) || isNotBlank(req.getThreeMedicalDeviceBusinessLicense()); if (!hasLicenseField) { return; } String reviewStatus; if (status == 0) { reviewStatus = LicenseStatus.PENDING.name(); } else if (status == 1) { reviewStatus = LicenseStatus.APPROVED.name(); } else { reviewStatus = LicenseStatus.REJECTED.name(); } com.xuekairui.user.entity.BusinessLicense existing = businessLicenseService.getByUserId(userId); if (existing == null) { com.xuekairui.user.entity.BusinessLicense license = com.xuekairui.user.entity.BusinessLicense.builder() .userId(userId) .storeName(storeName) .terminalType(terminalType) .province(req.getContactProvince()) .city(req.getContactCity()) .district(req.getContactArea()) .storeAddress(req.getContactAddr()) .contactPerson(req.getContactName()) .contactPhone(req.getContactPhone()) .licenseImageUrl(req.getBusinessLicenseImage()) .drugLicenseUrl(req.getDrugBusinessLicenseImage()) .medicalDeviceClass2Url(req.getTwoMedicalDeviceRegistration()) .medicalDeviceClass3Url(req.getThreeMedicalDeviceBusinessLicense()) .reviewStatus(reviewStatus) .showVerifiedBadge(false) .syncSource("MINIAPP_SYNC") .build(); businessLicenseService.insert(license); log.info("第三方店铺同步创建入驻记录: userId={}, licenseId={}, status={}", userId, license.getId(), reviewStatus); if (LicenseStatus.APPROVED.name().equals(reviewStatus)) { grantLicenseApprovedReward(userId, license.getId()); } } else { // 已存在记录:更新入驻字段,原已审核通过的记录重置为PENDING重新审核 boolean upgradedToApproved = false; boolean wasApproved = LicenseStatus.APPROVED.name().equals(existing.getReviewStatus()); if (!wasApproved) { // 本地未审核通过:允许第三方直接覆盖状态 existing.setReviewStatus(reviewStatus); upgradedToApproved = LicenseStatus.APPROVED.name().equals(reviewStatus); } else { // 本地已审核通过,第三方再次同步时更新字段并重置为PENDING重新审核 // rewardGranted保持不变,确保不重复奖励 existing.setReviewStatus(LicenseStatus.PENDING.name()); existing.setShowVerifiedBadge(false); log.info("第三方店铺同步,原已审核通过,更新入驻字段并重置为PENDING待重新审核: licenseId={}, userId={}", existing.getId(), userId); } // 更新第三方传入的入驻字段(非空即覆盖) applyThirdPartyLicenseFields(existing, req, storeName, terminalType); businessLicenseService.updateById(existing); log.info("第三方店铺同步已存在记录: licenseId={}, userId={}, wasApproved={}, newStatus={}, upgradedToApproved={}", existing.getId(), userId, wasApproved, existing.getReviewStatus(), upgradedToApproved); if (upgradedToApproved) { grantLicenseApprovedReward(userId, existing.getId()); } } } /** * 将双方都审核通过时的字段差异保存到 t_license_sync_diff 表,等待运营确认 */ private void saveLicenseSyncDiffRecords(Long userId, Long licenseId, Map diff) { try { diff.forEach((field, values) -> { com.xuekairui.user.entity.LicenseSyncDiff record = com.xuekairui.user.entity.LicenseSyncDiff.builder() .licenseId(licenseId) .userId(userId) .fieldName(field) .localValue(values[0]) .thirdPartyValue(values[1]) .status(0) .build(); licenseSyncDiffMapper.insert(record); }); log.info("保存入驻信息差异记录成功: licenseId={}, count={}", licenseId, diff.size()); } catch (Exception e) { log.warn("保存入驻信息差异记录失败(不阻断同步): licenseId={}, error={}", licenseId, e.getMessage()); } } /** * 将第三方传入的入驻字段覆盖到本地记录(非空即覆盖) */ private void applyThirdPartyLicenseFields( com.xuekairui.user.entity.BusinessLicense existing, SyncShopReportRequest req, String storeName, String terminalType) { if (storeName != null) { existing.setStoreName(storeName); } if (terminalType != null) { existing.setTerminalType(terminalType); } if (isNotBlank(req.getContactProvince())) { existing.setProvince(req.getContactProvince()); } if (isNotBlank(req.getContactCity())) { existing.setCity(req.getContactCity()); } if (isNotBlank(req.getContactArea())) { existing.setDistrict(req.getContactArea()); } if (isNotBlank(req.getContactAddr())) { existing.setStoreAddress(req.getContactAddr()); } if (isNotBlank(req.getContactName())) { existing.setContactPerson(req.getContactName()); } if (isNotBlank(req.getContactPhone())) { existing.setContactPhone(req.getContactPhone()); } if (isNotBlank(req.getBusinessLicenseImage())) { existing.setLicenseImageUrl(req.getBusinessLicenseImage()); } if (isNotBlank(req.getDrugBusinessLicenseImage())) { existing.setDrugLicenseUrl(req.getDrugBusinessLicenseImage()); } if (isNotBlank(req.getTwoMedicalDeviceRegistration())) { existing.setMedicalDeviceClass2Url(req.getTwoMedicalDeviceRegistration()); } if (isNotBlank(req.getThreeMedicalDeviceBusinessLicense())) { existing.setMedicalDeviceClass3Url(req.getThreeMedicalDeviceBusinessLicense()); } } /** * 检测本地入驻信息与第三方传入字段的不一致项 *

返回 Map:key=字段名,value=[本地值, 第三方值] */ private Map detectLicenseDiff( com.xuekairui.user.entity.BusinessLicense existing, SyncShopReportRequest req, String storeName, String terminalType) { Map diff = new java.util.LinkedHashMap<>(); if (storeName != null && !storeName.equals(existing.getStoreName())) { diff.put("storeName", new String[]{existing.getStoreName(), storeName}); } if (terminalType != null && !terminalType.equals(existing.getTerminalType())) { diff.put("terminalType", new String[]{existing.getTerminalType(), terminalType}); } putIfDiff(diff, "province", existing.getProvince(), req.getContactProvince()); putIfDiff(diff, "city", existing.getCity(), req.getContactCity()); putIfDiff(diff, "district", existing.getDistrict(), req.getContactArea()); putIfDiff(diff, "storeAddress", existing.getStoreAddress(), req.getContactAddr()); putIfDiff(diff, "contactPerson", existing.getContactPerson(), req.getContactName()); putIfDiff(diff, "contactPhone", existing.getContactPhone(), req.getContactPhone()); putIfDiff(diff, "licenseImageUrl", existing.getLicenseImageUrl(), req.getBusinessLicenseImage()); putIfDiff(diff, "drugLicenseUrl", existing.getDrugLicenseUrl(), req.getDrugBusinessLicenseImage()); putIfDiff(diff, "medicalDeviceClass2Url", existing.getMedicalDeviceClass2Url(), req.getTwoMedicalDeviceRegistration()); putIfDiff(diff, "medicalDeviceClass3Url", existing.getMedicalDeviceClass3Url(), req.getThreeMedicalDeviceBusinessLicense()); return diff; } private static void putIfDiff(Map diff, String field, String local, String third) { if (third != null && !third.isBlank() && !third.equals(local)) { diff.put(field, new String[]{local, third}); } } /** * 记录第三方变更入驻信息的审计日志 *

* 第三方同步入驻信息时,我们不对对方谁操作做验证, * 只通过对方传过来的用户手机号判断变更了谁的信息。 * 审计日志中记录:被变更用户的手机号、用户ID、变更前后的字段值。 * * @param phone 第三方传入的用户手机号(用于识别被变更的用户) * @param userId 被变更的用户ID * @param licenseId 入驻信息ID * @param diff 变更字段差异(key=字段名,value=[本地值, 第三方值]) */ private void recordThirdPartyUpdateAudit(String phone, Long userId, Long licenseId, Map diff) { try { Map beforeData = new java.util.LinkedHashMap<>(); Map afterData = new java.util.LinkedHashMap<>(); diff.forEach((field, values) -> { beforeData.put(field, values[0]); afterData.put(field, values[1]); }); // 目标标识:用户手机号 + 用户ID,便于审计时定位"变更了谁的信息" String targetIdentifier = String.format("PHONE:%s|USER:%d|LICENSE:%d", phone != null ? phone : "UNKNOWN", userId, licenseId); auditLogService.logSuccess( 0L, OperatorRole.SYSTEM, OperationType.LICENSE_THIRD_UPDATE, targetIdentifier, licenseId, beforeData, afterData, String.format("第三方变更入驻信息(双方均已审核通过,待运营确认):用户手机号 %s(ID=%d),共 %d 个字段不一致: %s", phone != null ? phone : "UNKNOWN", userId, diff.size(), diff.keySet()), OperationSource.SYSTEM_SYNC); } catch (Exception e) { log.warn("记录第三方变更入驻信息审计日志失败: licenseId={}, error={}", licenseId, e.getMessage()); } } /** * 第三方登录接口同步入驻信息(适配 ExternalTokenRequest → SyncInfoRequest) */ private void syncBusinessLicenseFromTokenRequest(ExternalTokenRequest req, Long userId) { SyncInfoRequest syncReq = new SyncInfoRequest(); syncReq.setStoreName(req.getStoreName()); syncReq.setPharmacyName(req.getPharmacyName()); syncReq.setTerminalType(req.getTerminalType()); syncReq.setBusinessLicenseUrl(req.getBusinessLicenseUrl()); syncReq.setDrugLicenseUrl(req.getDrugLicenseUrl()); syncReq.setMedicalDeviceClass2Url(req.getMedicalDeviceClass2Url()); syncReq.setMedicalDeviceClass3Url(req.getMedicalDeviceClass3Url()); syncReq.setProvince(req.getProvince()); syncReq.setCity(req.getCity()); syncReq.setDistrict(req.getDistrict()); syncReq.setPharmacyAddress(req.getPharmacyAddress()); syncReq.setContactPerson(req.getContactPerson()); syncReq.setContactPhone(req.getContactPhone()); syncReq.setBusinessLicenseNo(req.getBusinessLicenseNo()); syncReq.setStatus(req.getStatus()); syncBusinessLicense(syncReq, userId); } private void syncBusinessLicense(SyncInfoRequest request, Long userId) { // 先更新用户基本信息(非空即覆盖),确保入驻信息同步时用户信息也一并保存 User user = userMapper.selectById(userId); if (user != null) { applySyncInfoUserFields(user, request); userMapper.updateById(user); } String storeName = (request.getStoreName() != null && !request.getStoreName().isBlank()) ? request.getStoreName() : request.getPharmacyName(); // 第三方终端类型(可能是数字字符串1/2/3),统一转换 String terminalType = parseShopType(request.getTerminalType()); boolean hasLicenseField = isNotBlank(storeName) || terminalType != null || 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(terminalType) .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 { // 已存在记录:更新入驻字段,原已审核通过的记录重置为PENDING重新审核 boolean wasApproved = LicenseStatus.APPROVED.name().equals(existing.getReviewStatus()); boolean upgradedToApproved = false; if (!wasApproved) { // 本地未审核通过:允许第三方直接覆盖状态 existing.setReviewStatus(reviewStatus); upgradedToApproved = LicenseStatus.APPROVED.name().equals(reviewStatus); } else { // 本地已审核通过,第三方再次同步时更新字段并重置为PENDING重新审核 // rewardGranted保持不变,确保不重复奖励 existing.setReviewStatus(LicenseStatus.PENDING.name()); existing.setShowVerifiedBadge(false); log.info("第三方用户{}重新同步入驻信息,原已审核通过,重置为PENDING待重新审核: licenseId={}", userId, existing.getId()); } // 更新第三方传入的入驻字段(非空即覆盖) applySyncInfoLicenseFields(existing, request, storeName); businessLicenseService.updateById(existing); log.info("第三方用户{}重新同步入驻信息: licenseId={}, wasApproved={}, newStatus={}", userId, existing.getId(), wasApproved, existing.getReviewStatus()); // 从非APPROVED升级到APPROVED:赠送30天高级会员 + 推送Redis事件 if (upgradedToApproved) { grantLicenseApprovedReward(userId, existing.getId()); } } } private boolean isNotBlank(String s) { return s != null && !s.isBlank(); } /** * 将 SyncInfoRequest 中的入驻字段覆盖到本地记录(非空即覆盖) */ private void applySyncInfoLicenseFields( com.xuekairui.user.entity.BusinessLicense existing, SyncInfoRequest req, String storeName) { if (storeName != null) { existing.setStoreName(storeName); } if (isNotBlank(req.getTerminalType())) { existing.setTerminalType(req.getTerminalType()); } if (isNotBlank(req.getProvince())) { existing.setProvince(req.getProvince()); } if (isNotBlank(req.getCity())) { existing.setCity(req.getCity()); } if (isNotBlank(req.getDistrict())) { existing.setDistrict(req.getDistrict()); } if (isNotBlank(req.getPharmacyAddress())) { existing.setStoreAddress(req.getPharmacyAddress()); } if (isNotBlank(req.getContactPerson())) { existing.setContactPerson(req.getContactPerson()); } if (isNotBlank(req.getContactPhone())) { existing.setContactPhone(req.getContactPhone()); } if (isNotBlank(req.getBusinessLicenseUrl())) { existing.setLicenseImageUrl(req.getBusinessLicenseUrl()); } if (isNotBlank(req.getDrugLicenseUrl())) { existing.setDrugLicenseUrl(req.getDrugLicenseUrl()); } if (isNotBlank(req.getMedicalDeviceClass2Url())) { existing.setMedicalDeviceClass2Url(req.getMedicalDeviceClass2Url()); } if (isNotBlank(req.getMedicalDeviceClass3Url())) { existing.setMedicalDeviceClass3Url(req.getMedicalDeviceClass3Url()); } if (isNotBlank(req.getBusinessLicenseNo())) { existing.setCreditCode(req.getBusinessLicenseNo()); } } /** * 将 SyncInfoRequest 中的用户字段覆盖到本地 User 记录(非空即覆盖) */ private void applySyncInfoUserFields(User user, SyncInfoRequest req) { if (isNotBlank(req.getUsername())) { user.setUserName(req.getUsername()); } if (isNotBlank(req.getNickname())) { user.setNickname(req.getNickname()); } else if (isNotBlank(req.getUsername())) { user.setNickname(req.getUsername()); } if (isNotBlank(req.getPharmacyName())) { user.setPharmacyName(req.getPharmacyName()); } if (isNotBlank(req.getProvince())) { user.setProvince(req.getProvince()); } if (isNotBlank(req.getCity())) { user.setCity(req.getCity()); } if (isNotBlank(req.getDistrict())) { user.setDistrict(req.getDistrict()); } if (isNotBlank(req.getPharmacyAddress())) { user.setPharmacyAddress(req.getPharmacyAddress()); } if (isNotBlank(req.getContactPerson())) { user.setContactPerson(req.getContactPerson()); } if (isNotBlank(req.getContactPhone())) { user.setContactPhone(req.getContactPhone()); } if (isNotBlank(req.getBusinessLicenseNo())) { user.setBusinessLicenseNo(req.getBusinessLicenseNo()); } if (isNotBlank(req.getOpenId())) { user.setWechatMiniOpenId(req.getOpenId()); } if (isNotBlank(req.getUnionId())) { user.setWechatUnionId(req.getUnionId()); } } /** * 首次入驻审核通过:赠送30天高级会员 + 推送Redis事件供活动模块消费 *

与 BusinessLicenseService.approve() 的奖励逻辑保持一致。 *

幂等性检查:通过 DB 原子 CAS(UPDATE WHERE reward_granted = 0)保证同一入驻记录 * 全局仅奖励一次,杜绝并发场景下的竞态条件。 */ private void grantLicenseApprovedReward(Long userId, Long licenseId) { // 原子标记奖励已发放(CAS:仅当 reward_granted=0 时更新为1), // 杜绝并发场景下 rewardGranted 检查与更新的竞态条件,确保全局仅发放一次 int claimed = businessLicenseService.markRewardGrantedAtomic(licenseId); if (claimed == 0) { log.info("用户{}的入驻记录奖励已被标记,跳过重复赠送: licenseId={}", userId, licenseId); return; } membershipService.grantOrExtendMembership( userId, MembershipLevel.PRO, 30, "LICENSE", "入驻信息审核通过赠送30天高级会员"); log.info("入驻信息审核通过(第三方同步),赠送用户{} 30天高级会员", userId); // 事务提交后推送 Redis 事件,供活动模块消费(邀请有礼等) // 活动模块通过 uk_invitee 唯一索引保证同一被邀请人仅奖励一次 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,正常刷新 token */ private LoginResponse buildLoginResponse(User user) { return buildLoginResponseInternal(user, true, "PC", false); } /** 小程序构建登录响应:不递增 tokenVersion,不踢 PC,正常刷新 token */ private LoginResponse buildMiniAppLoginResponse(User user) { return buildLoginResponseInternal(user, false, "MINIAPP", false); } /** * 构建登录响应(内部通用方法) * @param skipRefreshToken 不生成/保存新 refreshToken(仅返回 accessToken) */ private LoginResponse buildLoginResponseInternal(User user, boolean incrementVersion, String loginSource, boolean skipRefreshToken) { 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 = null; if (!skipRefreshToken) { // 仅当无 ACTIVE 的 refreshToken 时才生成新的(首次登录),已有则复用 // 避免 PC/小程序两端登录互相覆盖 refreshToken 导致另一端无法刷新 if (user.getRefreshTokenHash() == null || !"ACTIVE".equals(user.getRefreshTokenStatus())) { 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(); } /** * 登录时检查入驻信息并同步到第三方(智价云药店版) *

* 仅在我方系统自行登录(短信/密码/微信/小程序)时触发,第三方登录不走此逻辑。 * 幂等策略:仅当入驻记录存在且 status != 1(未同步成功)时才补推一次, * 避免每次登录都重复调用第三方接口。 * 同步在事务提交后异步执行,不阻塞登录响应,失败仅记录日志。 */ private void syncShopResourceOnLogin(Long userId) { if (userId == null) { return; } try { com.xuekairui.user.entity.BusinessLicense license = businessLicenseService.getByUserId(userId); if (license == null) { return; } // status=1 表示已同步成功,跳过;0=未同步,2=同步失败,需补推 if (license.getStatus() != null && license.getStatus() == 1) { return; } User user = userMapper.selectById(userId); if (user == null) { return; } String phone = user.getPhone(); String userName = user.getNickname() != null ? user.getNickname() : String.valueOf(userId); String storeName = license.getStoreName() != null ? license.getStoreName() : "未知药店"; String targetIdentifier = String.format("PHONE:%s|USER:%d|LICENSE:%d", phone != null ? phone : "UNKNOWN", userId, license.getId()); TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { @Override public void afterCommit() { try { ShopResourceSyncService.SyncResult result = shopResourceSyncService.syncShopResource(user, license); if (result != null && result.getUserId() != null) { license.setExternalId(result.getUserId()); license.setStatus(1); businessLicenseService.updateById(license); log.info("登录时同步入驻信息到第三方成功: userId={}, externalUserId={}", userId, result.getUserId()); auditLogService.logSuccess( 0L, OperatorRole.SYSTEM, OperationType.LICENSE_SYNC_THIRD, targetIdentifier, license.getId(), null, Map.of("userId", userId, "userName", userName, "storeName", storeName, "phone", phone != null ? phone : "UNKNOWN", "trigger", "LOGIN", "externalUserId", result.getUserId(), "newUser", result.getNewUser()), String.format("登录时同步客户「%s」(手机号:%s,药店:%s)的入驻信息到第三方成功,第三方用户ID:%s", userName, phone != null ? phone : "UNKNOWN", storeName, result.getUserId()), OperationSource.SYSTEM_SYNC); } else { license.setStatus(2); businessLicenseService.updateById(license); log.warn("登录时同步入驻信息到第三方未成功(未返回user_id): userId={}", userId); auditLogService.logSuccess( 0L, OperatorRole.SYSTEM, OperationType.LICENSE_SYNC_THIRD, targetIdentifier, license.getId(), null, Map.of("userId", userId, "userName", userName, "storeName", storeName, "phone", phone != null ? phone : "UNKNOWN", "trigger", "LOGIN", "result", "no_user_id"), String.format("登录时同步客户「%s」(手机号:%s,药店:%s)的入驻信息到第三方未成功:第三方未返回用户ID", userName, phone != null ? phone : "UNKNOWN", storeName), OperationSource.SYSTEM_SYNC); } } catch (Exception e) { log.warn("登录时同步入驻信息到第三方失败(不阻断登录): userId={}, error={}", userId, e.getMessage()); auditLogService.logSuccess( 0L, OperatorRole.SYSTEM, OperationType.LICENSE_SYNC_THIRD, targetIdentifier, license.getId(), null, Map.of("userId", userId, "userName", userName, "storeName", storeName, "phone", phone != null ? phone : "UNKNOWN", "trigger", "LOGIN", "error", e.getMessage()), String.format("登录时同步客户「%s」(手机号:%s,药店:%s)的入驻信息到第三方失败:%s", userName, phone != null ? phone : "UNKNOWN", storeName, e.getMessage()), OperationSource.SYSTEM_SYNC); } } }); } catch (Exception e) { log.warn("登录时检查入驻信息失败(不阻断登录): userId={}, error={}", userId, e.getMessage()); } } /** * 转换用户信息响应 */ 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()); syncShopResourceOnLogin(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()); syncShopResourceOnLogin(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); if (unionId != null && wechatUser.getWechatUnionId() == null) { wechatUser.setWechatUnionId(unionId); } wechatUser.setLoginSource("MINIAPP"); wechatUser.setLastLoginTime(LocalDateTime.now()); wechatUser.setLastLoginIp(ip); userMapper.updateById(wechatUser); eventPublisher.publishEvent(new UserRegisteredEvent( this, wechatUser.getId(), request.getInviteCode())); } syncShopResourceOnLogin(wechatUser.getId()); 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; } /** 更新已有用户的微信小程序信息(始终写入 DB 保存登录时间戳和身份信息) */ private void updateWechatMiniInfo(User user, String miniOpenId, String unionId, String phone, String ip) { if (miniOpenId != null && !miniOpenId.equals(user.getWechatMiniOpenId())) { user.setWechatMiniOpenId(miniOpenId); } if (unionId != null && !unionId.equals(user.getWechatUnionId())) { user.setWechatUnionId(unionId); } if (phone != null && user.getPhone() == null) { user.setPhone(phone); } user.setLastLoginTime(LocalDateTime.now()); user.setLastLoginIp(ip); user.setLoginSource("MINIAPP"); 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); } } }