|
|
@@ -40,6 +40,14 @@ public class AuthService {
|
|
|
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 com.xuekairui.user.mapper.BusinessLicenseMapper businessLicenseMapper;
|
|
|
+ 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;
|
|
|
|
|
|
/**
|
|
|
* 发送验证码
|
|
|
@@ -232,32 +240,67 @@ public class AuthService {
|
|
|
* 不递增 tokenVersion(同一设备续期),使用 JWT 中已有信息签发新 Token。
|
|
|
* 过滤层已校验 tokenVersion 和用户存在性,这里直接签发。
|
|
|
*/
|
|
|
- public LoginResponse heartbeat(Long userId, String role) {
|
|
|
+ /**
|
|
|
+ * 外部系统换取小程序 Token(供小程序后台按手机号签发)
|
|
|
+ * <p>查找或自动注册用户,不递增 tokenVersion,不踢 PC。
|
|
|
+ */
|
|
|
+ @Transactional
|
|
|
+ public LoginResponse externalMiniAppToken(ExternalTokenRequest request, String ip) {
|
|
|
+ User user = userMapper.selectOne(
|
|
|
+ new LambdaQueryWrapper<User>().eq(User::getPhone, request.getPhone()));
|
|
|
+
|
|
|
+ boolean isNewUser = false;
|
|
|
+ if (user == null) {
|
|
|
+ isNewUser = true;
|
|
|
+ user = User.builder()
|
|
|
+ .phone(request.getPhone())
|
|
|
+ .nickname("用户" + request.getPhone().substring(request.getPhone().length() - 4))
|
|
|
+ .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()));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ user.setLoginSource("MINIAPP");
|
|
|
+ user.setLastLoginTime(LocalDateTime.now());
|
|
|
+ user.setLastLoginIp(ip);
|
|
|
+ userMapper.updateById(user);
|
|
|
+
|
|
|
+ LoginResponse response = buildMiniAppLoginResponse(user);
|
|
|
+ response.setNewUser(isNewUser);
|
|
|
+
|
|
|
+ auditLogService.logSuccess(user.getId(), user.getNickname(), "USER",
|
|
|
+ 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";
|
|
|
- // 从缓存获取 tokenVersion(过滤器大概率已缓存),缓存 miss 时回退 DB
|
|
|
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);
|
|
|
- }
|
|
|
+ if (user != null) tokenVersionCache.putVersion(userId, false, currentVersion);
|
|
|
}
|
|
|
|
|
|
- // 签发新 Token(无需查DB,JWT中已有所有必要信息)
|
|
|
String newAccessToken = jwtUtil.generateAccessToken(
|
|
|
- userId, roleCode, currentVersion);
|
|
|
+ userId, roleCode, loginSource, currentVersion);
|
|
|
String newRefreshToken = jwtUtil.generateRefreshToken(userId);
|
|
|
|
|
|
- // 仅一次 blind UPDATE 更新 Refresh Token 哈希
|
|
|
User updateUser = new User();
|
|
|
updateUser.setId(userId);
|
|
|
updateUser.setRefreshTokenHash(JwtUtil.hashToken(newRefreshToken));
|
|
|
updateUser.setRefreshTokenStatus("ACTIVE");
|
|
|
userMapper.updateById(updateUser);
|
|
|
|
|
|
- log.debug("用户 {} 心跳续期成功(零查询)", userId);
|
|
|
return LoginResponse.builder()
|
|
|
.userId(userId)
|
|
|
.accessToken(newAccessToken)
|
|
|
@@ -279,6 +322,201 @@ public class AuthService {
|
|
|
return convertToUserInfoResponse(user);
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * 获取聚合用户信息(小程序"我的"页面)— 新增,不影响 /api/auth/user/info
|
|
|
+ * <p>返回 user + gate(门禁)+ quota(配额)+ platformBindings(平台绑定)。
|
|
|
+ */
|
|
|
+ public java.util.Map<String, Object> getUserMe(Long userId) {
|
|
|
+ UserInfoResponse userInfo = getUserInfo(userId);
|
|
|
+ UserGateStatus gate = buildGateStatus(userId);
|
|
|
+ java.util.Map<String, Object> quota = buildQuotaSummary(userId);
|
|
|
+ java.util.List<?> platforms = getPlatformBindings(userId);
|
|
|
+
|
|
|
+ java.util.Map<String, Object> result = new java.util.LinkedHashMap<>();
|
|
|
+ result.putAll(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<DashboardResponse.RecentQueryItem> 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<User>().eq(User::getWechatMiniOpenId, openId));
|
|
|
+ if (user == null) {
|
|
|
+ // fallback: try unionId
|
|
|
+ if (unionId != null) {
|
|
|
+ user = userMapper.selectOne(
|
|
|
+ new LambdaQueryWrapper<User>().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 = buildLoginResponse(user);
|
|
|
+ auditLogService.logSuccess(user.getId(), user.getNickname(), "USER",
|
|
|
+ OperationType.USER_REGISTER, "USER:" + user.getId(), user.getId());
|
|
|
+ return response;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ====================================
|
|
|
+ // 聚合查询辅助方法
|
|
|
+ // ====================================
|
|
|
+
|
|
|
+ private UserGateStatus buildGateStatus(Long userId) {
|
|
|
+ // 执照审核状态
|
|
|
+ com.xuekairui.user.entity.BusinessLicense license =
|
|
|
+ businessLicenseMapper.selectByUserId(userId);
|
|
|
+ String licenseStatus = license != null ? license.getReviewStatus() : "NONE";
|
|
|
+
|
|
|
+ // 已绑定平台数
|
|
|
+ Long boundCount = platformAccountMapper.selectCount(
|
|
|
+ new LambdaQueryWrapper<com.xuekairui.user.entity.PlatformAccount>()
|
|
|
+ .eq(com.xuekairui.user.entity.PlatformAccount::getUserId, userId)
|
|
|
+ .eq(com.xuekairui.user.entity.PlatformAccount::getEnabled, true));
|
|
|
+
|
|
|
+ boolean canQuery = "APPROVED".equals(licenseStatus) && boundCount >= 1;
|
|
|
+
|
|
|
+ java.util.List<String> messages = new java.util.ArrayList<>();
|
|
|
+ if (!"APPROVED".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<String, Object> buildQuotaSummary(Long userId) {
|
|
|
+ // 今日已用 + 每日上限
|
|
|
+ int dailyUsed = crawlerUsageLogMapper.sumTodayUsage(userId);
|
|
|
+ java.util.Map<String, Object> quota = new java.util.LinkedHashMap<>();
|
|
|
+ quota.put("dailyQueryUsed", dailyUsed);
|
|
|
+ quota.put("dailyQueryLimit", 20); // TODO: 从会员等级动态获取
|
|
|
+ return quota;
|
|
|
+ }
|
|
|
+
|
|
|
+ private java.util.List<java.util.Map<String, Object>> getPlatformBindings(Long userId) {
|
|
|
+ java.util.List<com.xuekairui.user.entity.PlatformAccount> accounts =
|
|
|
+ platformAccountMapper.selectList(
|
|
|
+ new LambdaQueryWrapper<com.xuekairui.user.entity.PlatformAccount>()
|
|
|
+ .eq(com.xuekairui.user.entity.PlatformAccount::getUserId, userId));
|
|
|
+
|
|
|
+ return accounts.stream().map(a -> {
|
|
|
+ java.util.Map<String, Object> 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();
|
|
|
+ }
|
|
|
+
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
+ private java.util.Map<String, Object> toMap(UserInfoResponse info) {
|
|
|
+ // 简单反射转 Map,避免引入额外依赖
|
|
|
+ java.util.Map<String, Object> 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("levelCode", info.getLevelCode());
|
|
|
+ map.put("version", info.getVersion());
|
|
|
+ return map;
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* Refresh Token 旋转 + 重用检测
|
|
|
* <p>
|
|
|
@@ -355,33 +593,39 @@ public class AuthService {
|
|
|
.build();
|
|
|
}
|
|
|
|
|
|
- /**
|
|
|
- * 构建登录响应(递增 tokenVersion + 存储 Refresh Token 哈希用于旋转检测)
|
|
|
- */
|
|
|
+ /** PC 端构建登录响应:
|
|
|
+ * 递增 tokenVersion,踢掉旧 PC */
|
|
|
private LoginResponse buildLoginResponse(User user) {
|
|
|
- // 登录/注册时注入 MDC uid,不依赖 token 解析
|
|
|
- MDC.put("uid", String.valueOf(user.getId()));
|
|
|
-
|
|
|
- String roleCode = "USER";
|
|
|
+ return buildLoginResponseInternal(user, true);
|
|
|
+ }
|
|
|
|
|
|
- // 检测是否存在旧会话
|
|
|
- long oldVersion = user.getTokenVersion() != null ? user.getTokenVersion() : 0L;
|
|
|
- boolean hadPreviousSession = oldVersion > 0;
|
|
|
+ /** 小程序构建登录响应:不递增 tokenVersion,不踢 PC */
|
|
|
+ private LoginResponse buildMiniAppLoginResponse(User user) {
|
|
|
+ return buildLoginResponseInternal(user, false);
|
|
|
+ }
|
|
|
|
|
|
- // 递增 tokenVersion,使所有旧 Token 立即失效
|
|
|
- long newVersion = oldVersion + 1;
|
|
|
- user.setTokenVersion(newVersion);
|
|
|
+ private LoginResponse buildLoginResponseInternal(User user, boolean incrementVersion) {
|
|
|
+ MDC.put("uid", String.valueOf(user.getId()));
|
|
|
+ String roleCode = "USER";
|
|
|
+ String loginSource = user.getLoginSource() != null ? user.getLoginSource() : "UNKNOWN";
|
|
|
+
|
|
|
+ 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;
|
|
|
+ }
|
|
|
|
|
|
- // 生成 Token
|
|
|
String accessToken = jwtUtil.generateAccessToken(
|
|
|
- user.getId(), "USER", newVersion);
|
|
|
+ user.getId(), "USER", loginSource, version);
|
|
|
String refreshToken = jwtUtil.generateRefreshToken(user.getId());
|
|
|
|
|
|
- // 存储 Refresh Token 哈希(用于旋转+重用检测)
|
|
|
user.setRefreshTokenHash(JwtUtil.hashToken(refreshToken));
|
|
|
user.setRefreshTokenStatus("ACTIVE");
|
|
|
userMapper.updateById(user);
|
|
|
- tokenVersionCache.putVersion(user.getId(), false, newVersion);
|
|
|
|
|
|
return LoginResponse.builder()
|
|
|
.userId(user.getId())
|
|
|
@@ -392,8 +636,6 @@ public class AuthService {
|
|
|
.needBindPhone(false)
|
|
|
.newUser(false)
|
|
|
.role(roleCode)
|
|
|
- .kickedPreviousSession(hadPreviousSession)
|
|
|
- .kickedDeviceInfo(hadPreviousSession ? user.getLastLoginDevice() : null)
|
|
|
.build();
|
|
|
}
|
|
|
|
|
|
@@ -554,4 +796,246 @@ public class AuthService {
|
|
|
auditLogService.logSuccess(userId, user.getNickname(), "USER",
|
|
|
OperationType.USER_UPDATE, "PHONE:" + userId, userId);
|
|
|
}
|
|
|
+
|
|
|
+ // ====================================
|
|
|
+ // 小程序统一登录(处理四种用户身份场景)
|
|
|
+ // ====================================
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 小程序统一登录
|
|
|
+ * <p>
|
|
|
+ * 自动处理"小程序老/新用户 × 当前系统老/新用户"四种场景:
|
|
|
+ * <ol>
|
|
|
+ * <li>外部老 + 本地老 → 直接登录</li>
|
|
|
+ * <li>外部老 + 本地新 → 本地注册 + 异步同步证书</li>
|
|
|
+ * <li>外部新 + 本地老 → 异步注册外部 + 本地直接登录</li>
|
|
|
+ * <li>外部新 + 本地新 → 两端都注册</li>
|
|
|
+ * </ol>
|
|
|
+ * <p>
|
|
|
+ * 关键原则:外部系统操作异步执行,不阻塞登录响应。
|
|
|
+ */
|
|
|
+ @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(), "USER",
|
|
|
+ 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<User>().eq(User::getWechatMiniOpenId, openId));
|
|
|
+ if (wechatUser == null) {
|
|
|
+ throw new BusinessException(ErrorCode.WECHAT_LOGIN_FAILED);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 4. 检查手机号是否已被其他用户绑定
|
|
|
+ User existUser = userMapper.selectOne(
|
|
|
+ new LambdaQueryWrapper<User>().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);
|
|
|
+ userMapper.deleteById(wechatUser.getId());
|
|
|
+ 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<User>().eq(User::getWechatUnionId, unionId));
|
|
|
+ if (user != null) {
|
|
|
+ return user;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (miniOpenId != null) {
|
|
|
+ return userMapper.selectOne(
|
|
|
+ new LambdaQueryWrapper<User>().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);
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|