AuthService.java 56 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288
  1. package com.xuekairui.user.service;
  2. import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
  3. import com.xuekairui.common.BusinessException;
  4. import com.xuekairui.common.ErrorCode;
  5. import com.xuekairui.common.ValidateUtil;
  6. import com.xuekairui.common.event.ActivityEventRedisService;
  7. import com.xuekairui.user.dto.ChangePhoneRequest;
  8. import com.xuekairui.user.dto.DashboardResponse;
  9. import com.xuekairui.user.dto.ExternalTokenRequest;
  10. import com.xuekairui.user.dto.LoginResponse;
  11. import com.xuekairui.user.dto.MiniProgramLoginRequest;
  12. import com.xuekairui.user.dto.MiniProgramPhoneRequest;
  13. import com.xuekairui.user.dto.PasswordLoginRequest;
  14. import com.xuekairui.user.dto.RegisterRequest;
  15. import com.xuekairui.user.dto.SendCodeRequest;
  16. import com.xuekairui.user.dto.SetPasswordRequest;
  17. import com.xuekairui.user.dto.SmsLoginRequest;
  18. import com.xuekairui.user.dto.UserGateStatus;
  19. import com.xuekairui.user.dto.UserInfoResponse;
  20. import com.xuekairui.user.dto.UserLevelResponse;
  21. import com.xuekairui.user.dto.WechatBindRequest;
  22. import com.xuekairui.user.dto.WechatLoginRequest;
  23. import com.xuekairui.user.entity.User;
  24. import com.xuekairui.user.enums.LicenseStatus;
  25. import com.xuekairui.user.enums.MembershipLevel;
  26. import com.xuekairui.user.enums.OperationType;
  27. import com.xuekairui.user.enums.OperatorRole;
  28. import com.xuekairui.user.event.UserRegisteredEvent;
  29. import com.xuekairui.user.mapper.UserMapper;
  30. import com.xuekairui.user.util.JwtUtil;
  31. import com.xuekairui.user.util.PasswordUtil;
  32. import io.jsonwebtoken.Claims;
  33. import lombok.RequiredArgsConstructor;
  34. import lombok.extern.slf4j.Slf4j;
  35. import org.slf4j.MDC;
  36. import org.springframework.context.ApplicationEventPublisher;
  37. import org.springframework.stereotype.Service;
  38. import org.springframework.transaction.annotation.Transactional;
  39. import org.springframework.transaction.support.TransactionSynchronization;
  40. import org.springframework.transaction.support.TransactionSynchronizationManager;
  41. import java.time.LocalDateTime;
  42. /**
  43. * 认证服务
  44. */
  45. @Slf4j
  46. @Service
  47. @RequiredArgsConstructor
  48. public class AuthService {
  49. private final UserMapper userMapper;
  50. private final VerificationCodeService verificationCodeService;
  51. private final WechatService wechatService;
  52. private final JwtUtil jwtUtil;
  53. private final ApplicationEventPublisher eventPublisher;
  54. private final PasswordUtil passwordUtil;
  55. private final OperationAuditLogService auditLogService;
  56. private final MembershipService membershipService;
  57. private final AdminAuthService adminAuthService;
  58. private final com.xuekairui.user.security.TokenVersionCache tokenVersionCache;
  59. private final WechatMiniProgramService wechatMiniProgramService;
  60. private final ExternalUserSyncService externalUserSyncService;
  61. private final CertificateSyncService certificateSyncService;
  62. private final BusinessLicenseService businessLicenseService;
  63. private final com.xuekairui.user.mapper.PlatformAccountMapper platformAccountMapper;
  64. private final com.xuekairui.user.mapper.CrawlerUsageLogMapper crawlerUsageLogMapper;
  65. private final com.xuekairui.user.mapper.SearchRecordMapper searchRecordMapper;
  66. private final com.xuekairui.user.mapper.PurchaseIntentMapper purchaseIntentMapper;
  67. private final ActivityEventRedisService activityEventRedisService;
  68. /**
  69. * 发送验证码
  70. */
  71. public void sendVerificationCode(SendCodeRequest request, String ip) {
  72. verificationCodeService.sendCode(request.getPhone(), request.getScene(), ip);
  73. }
  74. /**
  75. * 验证码登录/注册
  76. * 如果手机号已注册则登录,否则自动注册
  77. */
  78. @Transactional
  79. public LoginResponse smsLogin(SmsLoginRequest request, String ip) {
  80. // 1. 验证验证码(兼容 scene=login 和 scene=register,统一用 login key)
  81. verificationCodeService.verifyCode(request.getPhone(), request.getCode());
  82. // 2. 查找或创建用户
  83. User user = userMapper.selectOne(
  84. new LambdaQueryWrapper<User>().eq(User::getPhone, request.getPhone()));
  85. boolean isNewUser = false;
  86. if (user == null) {
  87. isNewUser = true;
  88. // 自动注册新用户
  89. user = User.builder()
  90. .phone(request.getPhone())
  91. .nickname("用户" + request.getPhone().substring(7))
  92. .membershipLevel(MembershipLevel.PLUS)
  93. .loginSource("SMS")
  94. .status(1)
  95. .build();
  96. userMapper.insert(user);
  97. // 注册即写入 PLUS 会员记录
  98. membershipService.initPlusMembership(user.getId());
  99. // 发布用户注册事件(邀请模块监听处理邀请码,赠送会员奖励)
  100. eventPublisher.publishEvent(new UserRegisteredEvent(this, user.getId(), request.getInviteCode()));
  101. // 普通会员(PLUS)免费开放,注册即拥有,无需额外发放
  102. }
  103. // 3. 更新登录信息
  104. user.setLastLoginTime(LocalDateTime.now());
  105. user.setLastLoginIp(ip);
  106. user.setLoginSource("PC");
  107. userMapper.updateById(user);
  108. // 4. 生成Token
  109. LoginResponse response = buildLoginResponse(user);
  110. response.setUserId(user.getId());
  111. response.setNewUser(isNewUser);
  112. // 记录审计日志
  113. if (isNewUser) {
  114. auditLogService.logSuccess(user.getId(), user.getNickname(), OperatorRole.USER.getCode(),
  115. OperationType.USER_REGISTER, "USER:" + user.getId(), user.getId());
  116. }
  117. auditLogService.logSuccess(user.getId(), user.getNickname(), OperatorRole.USER.getCode(),
  118. OperationType.USER_LOGIN, "USER:" + user.getId(), user.getId());
  119. return response;
  120. }
  121. /**
  122. * 微信扫码登录
  123. */
  124. @Transactional
  125. public LoginResponse wechatLogin(WechatLoginRequest request, String ip) {
  126. // 1. 获取微信用户信息
  127. WechatService.WechatUserInfo wechatInfo = wechatService.getUserInfo(request.getCode());
  128. // 2. 通过UnionID查找用户
  129. User user = null;
  130. if (wechatInfo.getUnionId() != null) {
  131. user = userMapper.selectOne(
  132. new LambdaQueryWrapper<User>().eq(User::getWechatUnionId, wechatInfo.getUnionId()));
  133. }
  134. // 3. 如果用户存在且已绑定手机号,直接登录
  135. if (user != null && user.getPhone() != null) {
  136. user.setLastLoginTime(LocalDateTime.now());
  137. user.setLastLoginIp(ip);
  138. user.setLoginSource("PC");
  139. userMapper.updateById(user);
  140. return buildLoginResponse(user);
  141. }
  142. // 4. 如果用户存在但未绑定手机号,或新用户,返回需要绑定手机号
  143. if (user == null) {
  144. // 创建新用户(未绑定手机号)
  145. user = User.builder()
  146. .wechatOpenId(wechatInfo.getOpenId())
  147. .wechatUnionId(wechatInfo.getUnionId())
  148. .nickname(wechatInfo.getNickname())
  149. .avatar(wechatInfo.getHeadImgUrl())
  150. .membershipLevel(MembershipLevel.PLUS)
  151. .loginSource("WECHAT")
  152. .status(1)
  153. .build();
  154. userMapper.insert(user);
  155. membershipService.initPlusMembership(user.getId());
  156. } else {
  157. // 更新微信信息
  158. user.setWechatOpenId(wechatInfo.getOpenId());
  159. user.setWechatUnionId(wechatInfo.getUnionId());
  160. if (user.getNickname() == null) {
  161. user.setNickname(wechatInfo.getNickname());
  162. }
  163. if (user.getAvatar() == null) {
  164. user.setAvatar(wechatInfo.getHeadImgUrl());
  165. }
  166. user.setLastLoginTime(LocalDateTime.now());
  167. user.setLastLoginIp(ip);
  168. userMapper.updateById(user);
  169. }
  170. // 5. 生成临时Token,要求绑定手机号
  171. String tempToken = jwtUtil.generateTempToken(wechatInfo.getOpenId(), wechatInfo.getUnionId());
  172. // 确保 MDC 中有 uid(微信登录临时Token场景不走 buildLoginResponse)
  173. MDC.put("uid", String.valueOf(user.getId()));
  174. return LoginResponse.builder()
  175. .needBindPhone(true)
  176. .tempToken(tempToken)
  177. .userInfo(convertToUserInfoResponse(user))
  178. .build();
  179. }
  180. /**
  181. * 微信绑定手机号
  182. */
  183. @Transactional
  184. public LoginResponse wechatBindPhone(WechatBindRequest request, String ip) {
  185. // 1. 验证临时Token
  186. Claims claims = jwtUtil.parseToken(request.getTempToken());
  187. String tokenType = claims.get("type", String.class);
  188. if (!"temp".equals(tokenType)) {
  189. throw new BusinessException(ErrorCode.TOKEN_INVALID);
  190. }
  191. String openId = claims.get("openId", String.class);
  192. String unionId = claims.get("unionId", String.class);
  193. // 2. 验证验证码
  194. verificationCodeService.verifyCode(request.getPhone(), request.getCode());
  195. // 3. 检查手机号是否已被其他用户绑定
  196. User existUser = userMapper.selectOne(
  197. new LambdaQueryWrapper<User>().eq(User::getPhone, request.getPhone()));
  198. User wechatUser = userMapper.selectOne(
  199. new LambdaQueryWrapper<User>().eq(User::getWechatOpenId, openId));
  200. if (wechatUser == null) {
  201. throw new BusinessException(ErrorCode.WECHAT_LOGIN_FAILED);
  202. }
  203. if (existUser != null && !existUser.getId().equals(wechatUser.getId())) {
  204. // 手机号已被其他账号绑定,合并账号
  205. existUser.setWechatOpenId(openId);
  206. existUser.setWechatUnionId(unionId);
  207. existUser.setLastLoginTime(LocalDateTime.now());
  208. existUser.setLastLoginIp(ip);
  209. existUser.setLoginSource("PC");
  210. userMapper.updateById(existUser);
  211. // 禁用旧的微信用户(不物理删除,仅改状态)
  212. wechatUser.setStatus(0);
  213. userMapper.updateById(wechatUser);
  214. wechatUser = existUser;
  215. } else if (existUser == null) {
  216. // 绑定手机号到微信用户(首次完成注册)
  217. wechatUser.setPhone(request.getPhone());
  218. wechatUser.setLoginSource("PC");
  219. wechatUser.setLastLoginTime(LocalDateTime.now());
  220. wechatUser.setLastLoginIp(ip);
  221. userMapper.updateById(wechatUser);
  222. // 发布用户注册事件,邀请模块可据此建立邀请关系并发放奖励
  223. eventPublisher.publishEvent(new UserRegisteredEvent(this, wechatUser.getId(), request.getInviteCode()));
  224. // 普通会员(PLUS)免费开放,注册即拥有,无需额外发放
  225. }
  226. // 4. 生成正式Token
  227. return buildLoginResponse(wechatUser);
  228. }
  229. /**
  230. * 外部系统换取小程序 Token(供小程序后台按手机号签发)
  231. * <p>查找或自动注册用户,不递增 tokenVersion,不踢 PC。
  232. */
  233. @Transactional
  234. public LoginResponse externalMiniAppToken(ExternalTokenRequest request, String ip) {
  235. User user = userMapper.selectOne(
  236. new LambdaQueryWrapper<User>().eq(User::getPhone, request.getPhone()));
  237. boolean isNewUser = false;
  238. if (user == null) {
  239. isNewUser = true;
  240. // 优先级:nickname > username > 手机号生成
  241. String nickname = (request.getNickname() != null && !request.getNickname().isBlank())
  242. ? request.getNickname()
  243. : (request.getUsername() != null && !request.getUsername().isBlank())
  244. ? request.getUsername()
  245. : "用户" + request.getPhone().substring(request.getPhone().length() - 4);
  246. user = User.builder()
  247. .phone(request.getPhone())
  248. .nickname(nickname)
  249. .userName(request.getUsername())
  250. .wechatMiniOpenId(request.getOpenId())
  251. .wechatUnionId(request.getUnionId())
  252. .pharmacyName(request.getPharmacyName())
  253. .province(request.getProvince())
  254. .city(request.getCity())
  255. .district(request.getDistrict())
  256. .pharmacyAddress(request.getPharmacyAddress())
  257. .contactPerson(request.getContactPerson())
  258. .contactPhone(request.getContactPhone())
  259. .businessLicenseNo(request.getBusinessLicenseNo())
  260. .membershipLevel(MembershipLevel.PLUS)
  261. .loginSource("MINIAPP")
  262. .status(1)
  263. .build();
  264. userMapper.insert(user);
  265. membershipService.initPlusMembership(user.getId());
  266. if (request.getInviteCode() != null) {
  267. eventPublisher.publishEvent(
  268. new UserRegisteredEvent(this, user.getId(), request.getInviteCode()));
  269. }
  270. } else {
  271. // 已存在用户:更新第三方传入的信息(非空字段才覆盖,避免清空已有数据)
  272. // userName 独立存储原始用户名;nickname 优先取 nickname,其次 username
  273. if (request.getUsername() != null && !request.getUsername().isBlank()) {
  274. user.setUserName(request.getUsername());
  275. }
  276. if (request.getNickname() != null && !request.getNickname().isBlank()) {
  277. user.setNickname(request.getNickname());
  278. } else if (request.getUsername() != null && !request.getUsername().isBlank()) {
  279. user.setNickname(request.getUsername());
  280. }
  281. if (request.getPharmacyName() != null && !request.getPharmacyName().isBlank()) {
  282. user.setPharmacyName(request.getPharmacyName());
  283. }
  284. if (request.getProvince() != null && !request.getProvince().isBlank()) {
  285. user.setProvince(request.getProvince());
  286. }
  287. if (request.getCity() != null && !request.getCity().isBlank()) {
  288. user.setCity(request.getCity());
  289. }
  290. if (request.getDistrict() != null && !request.getDistrict().isBlank()) {
  291. user.setDistrict(request.getDistrict());
  292. }
  293. if (request.getPharmacyAddress() != null && !request.getPharmacyAddress().isBlank()) {
  294. user.setPharmacyAddress(request.getPharmacyAddress());
  295. }
  296. if (request.getContactPerson() != null && !request.getContactPerson().isBlank()) {
  297. user.setContactPerson(request.getContactPerson());
  298. }
  299. if (request.getContactPhone() != null && !request.getContactPhone().isBlank()) {
  300. user.setContactPhone(request.getContactPhone());
  301. }
  302. if (request.getBusinessLicenseNo() != null && !request.getBusinessLicenseNo().isBlank()) {
  303. user.setBusinessLicenseNo(request.getBusinessLicenseNo());
  304. }
  305. if (request.getOpenId() != null && !request.getOpenId().isBlank()) {
  306. user.setWechatMiniOpenId(request.getOpenId());
  307. }
  308. if (request.getUnionId() != null && !request.getUnionId().isBlank()) {
  309. user.setWechatUnionId(request.getUnionId());
  310. }
  311. }
  312. user.setLoginSource("MINIAPP");
  313. user.setLastLoginTime(LocalDateTime.now());
  314. user.setLastLoginIp(ip);
  315. userMapper.updateById(user);
  316. // 同步入驻信息到 t_business_license(仅当传入入驻相关字段时)
  317. syncBusinessLicense(request, user.getId());
  318. LoginResponse response = buildMiniAppLoginResponse(user);
  319. response.setNewUser(isNewUser);
  320. auditLogService.logSuccess(user.getId(), user.getNickname(), OperatorRole.USER.getCode(),
  321. isNewUser ? OperationType.USER_REGISTER : OperationType.USER_LOGIN,
  322. "USER:" + user.getId(), user.getId());
  323. return response;
  324. }
  325. public LoginResponse heartbeat(Long userId, String role, String loginSource) {
  326. String roleCode = role != null ? role : "USER";
  327. Long currentVersion = tokenVersionCache.getVersion(userId, false);
  328. if (currentVersion == null) {
  329. User user = userMapper.selectById(userId);
  330. currentVersion = user != null && user.getTokenVersion() != null
  331. ? user.getTokenVersion() : 0L;
  332. if (user != null) {
  333. tokenVersionCache.putVersion(userId, false, currentVersion);
  334. }
  335. }
  336. String newAccessToken = jwtUtil.generateAccessToken(
  337. userId, roleCode, loginSource, currentVersion);
  338. String newRefreshToken = jwtUtil.generateRefreshToken(userId, loginSource);
  339. User updateUser = new User();
  340. updateUser.setId(userId);
  341. updateUser.setRefreshTokenHash(JwtUtil.hashToken(newRefreshToken));
  342. updateUser.setRefreshTokenStatus("ACTIVE");
  343. userMapper.updateById(updateUser);
  344. return LoginResponse.builder()
  345. .userId(userId)
  346. .accessToken(newAccessToken)
  347. .refreshToken(newRefreshToken)
  348. .expiresIn(jwtUtil.getExpirationInSeconds(roleCode, loginSource))
  349. .needBindPhone(false)
  350. .role(roleCode)
  351. .build();
  352. }
  353. /**
  354. * 获取用户信息
  355. */
  356. public UserInfoResponse getUserInfo(Long userId) {
  357. User user = userMapper.selectById(userId);
  358. if (user == null) {
  359. throw new BusinessException(ErrorCode.USER_NOT_FOUND);
  360. }
  361. return convertToUserInfoResponse(user);
  362. }
  363. /**
  364. * 获取聚合用户信息(小程序"我的"页面)— 新增,不影响 /api/auth/user/info
  365. * <p>返回 user + gate(门禁)+ quota(配额)+ platformBindings(平台绑定)。
  366. */
  367. public java.util.Map<String, Object> getUserMe(Long userId) {
  368. UserInfoResponse userInfo = getUserInfo(userId);
  369. UserGateStatus gate = buildGateStatus(userId);
  370. java.util.Map<String, Object> quota = buildQuotaSummary(userId);
  371. java.util.List<?> platforms = getPlatformBindings(userId);
  372. java.util.Map<String, Object> result = new java.util.LinkedHashMap<>(toMap(userInfo));
  373. result.put("gate", gate);
  374. result.put("quota", quota);
  375. result.put("platformBindings", platforms);
  376. return result;
  377. }
  378. /**
  379. * 首页概览统计 — 新增
  380. */
  381. public DashboardResponse getDashboard(Long userId) {
  382. // 今日查询次数
  383. int todayQueryCount = crawlerUsageLogMapper.sumTodayUsage(userId);
  384. // 累计查询次数
  385. int totalQueryCount = crawlerUsageLogMapper.sumTotalUsage(userId);
  386. // 最近查询摘要(最多 3 条)
  387. java.util.List<DashboardResponse.RecentQueryItem> recent =
  388. searchRecordMapper.selectRecentByUserId(userId, 3)
  389. .stream().map(r -> DashboardResponse.RecentQueryItem.builder()
  390. .drugName(r.getDrugName())
  391. .searchedAt(r.getSearchedAt() != null ? r.getSearchedAt().toString() : null)
  392. .lowestPrice(r.getQuoteCount() > 0 ? "有报价" : null)
  393. .build())
  394. .toList();
  395. return DashboardResponse.builder()
  396. .todayQueryCount(todayQueryCount)
  397. .todayPurchaseIntentCount(
  398. purchaseIntentMapper.countTodayByUserId(userId, java.time.LocalDate.now()))
  399. .totalQueryCount(totalQueryCount)
  400. .recentQueries(recent)
  401. .build();
  402. }
  403. /**
  404. * 登出 — 新增,清除 refresh token
  405. */
  406. @Transactional
  407. public void logout(Long userId) {
  408. User user = new User();
  409. user.setId(userId);
  410. user.setRefreshTokenHash(null);
  411. user.setRefreshTokenStatus(null);
  412. userMapper.updateById(user);
  413. log.info("用户 {} 已登出", userId);
  414. }
  415. /**
  416. * 独立注册(小程序填写药店信息后调用) — 新增
  417. */
  418. @Transactional
  419. public LoginResponse register(RegisterRequest request, String ip) {
  420. // 1. 验证临时 Token
  421. io.jsonwebtoken.Claims claims = jwtUtil.parseToken(request.getTempToken());
  422. if (!"temp".equals(claims.get("type", String.class))) {
  423. throw new BusinessException(ErrorCode.TOKEN_INVALID);
  424. }
  425. String openId = claims.get("openId", String.class);
  426. String unionId = claims.get("unionId", String.class);
  427. // 2. 查找小程序用户(由 miniappUnifiedLogin 创建的未完成注册的用户)
  428. User user = userMapper.selectOne(
  429. new LambdaQueryWrapper<User>().eq(User::getWechatMiniOpenId, openId));
  430. if (user == null) {
  431. // fallback: try unionId
  432. if (unionId != null) {
  433. user = userMapper.selectOne(
  434. new LambdaQueryWrapper<User>().eq(User::getWechatUnionId, unionId));
  435. }
  436. }
  437. if (user == null) {
  438. throw new BusinessException(ErrorCode.USER_NOT_FOUND, "请先通过小程序授权登录");
  439. }
  440. // 3. 补全药店信息
  441. user.setPharmacyName(request.getPharmacyName());
  442. user.setProvince(request.getProvince());
  443. user.setCity(request.getCity());
  444. user.setDistrict(request.getDistrict());
  445. user.setLastLoginTime(LocalDateTime.now());
  446. user.setLastLoginIp(ip);
  447. user.setLoginSource("MINIAPP");
  448. userMapper.updateById(user);
  449. // 4. 发布注册事件(处理邀请码)
  450. if (request.getInviteCode() != null) {
  451. eventPublisher.publishEvent(
  452. new UserRegisteredEvent(this, user.getId(), request.getInviteCode()));
  453. }
  454. // 5. 签发正式 Token
  455. LoginResponse response = buildMiniAppLoginResponse(user);
  456. auditLogService.logSuccess(user.getId(), user.getNickname(), OperatorRole.USER.getCode(),
  457. OperationType.USER_REGISTER, "USER:" + user.getId(), user.getId());
  458. return response;
  459. }
  460. // ====================================
  461. // 聚合查询辅助方法
  462. // ====================================
  463. /**
  464. * 同步第三方传入的入驻信息到 t_business_license
  465. * 仅当请求中至少有一个入驻相关字段非空时才执行
  466. */
  467. private void syncBusinessLicense(ExternalTokenRequest request, Long userId) {
  468. String storeName = (request.getStoreName() != null && !request.getStoreName().isBlank())
  469. ? request.getStoreName() : request.getPharmacyName();
  470. boolean hasLicenseField = isNotBlank(storeName)
  471. || isNotBlank(request.getTerminalType())
  472. || isNotBlank(request.getBusinessLicenseUrl())
  473. || isNotBlank(request.getDrugLicenseUrl())
  474. || isNotBlank(request.getMedicalDeviceClass2Url())
  475. || isNotBlank(request.getMedicalDeviceClass3Url());
  476. if (!hasLicenseField) {
  477. return;
  478. }
  479. com.xuekairui.user.entity.BusinessLicense existing =
  480. businessLicenseService.getByUserId(userId);
  481. // 审核状态:0-审核中→PENDING / 1-审核成功→APPROVED / 2-审核失败→REJECTED(不传默认0)
  482. Integer status = request.getStatus();
  483. String reviewStatus;
  484. if (status == null || status == 0) {
  485. reviewStatus = LicenseStatus.PENDING.name();
  486. } else if (status == 1) {
  487. reviewStatus = LicenseStatus.APPROVED.name();
  488. } else {
  489. reviewStatus = LicenseStatus.REJECTED.name();
  490. }
  491. if (existing == null) {
  492. com.xuekairui.user.entity.BusinessLicense license =
  493. com.xuekairui.user.entity.BusinessLicense.builder()
  494. .userId(userId)
  495. .storeName(storeName)
  496. .terminalType(request.getTerminalType())
  497. .province(request.getProvince())
  498. .city(request.getCity())
  499. .district(request.getDistrict())
  500. .storeAddress(request.getPharmacyAddress())
  501. .contactPerson(request.getContactPerson())
  502. .contactPhone(request.getContactPhone())
  503. .licenseImageUrl(request.getBusinessLicenseUrl())
  504. .drugLicenseUrl(request.getDrugLicenseUrl())
  505. .creditCode(request.getBusinessLicenseNo())
  506. .medicalDeviceClass2Url(request.getMedicalDeviceClass2Url())
  507. .medicalDeviceClass3Url(request.getMedicalDeviceClass3Url())
  508. .reviewStatus(reviewStatus)
  509. .showVerifiedBadge(false)
  510. .syncSource("MINIAPP_SYNC")
  511. .build();
  512. businessLicenseService.insert(license);
  513. log.info("第三方用户{}同步入驻信息,创建新记录: licenseId={}, status={}",
  514. userId, license.getId(), reviewStatus);
  515. // 第三方已审核通过:赠送30天高级会员 + 推送Redis事件
  516. if (LicenseStatus.APPROVED.name().equals(reviewStatus)) {
  517. grantLicenseApprovedReward(userId, license.getId());
  518. }
  519. } else {
  520. if (storeName != null && !storeName.isBlank()) {
  521. existing.setStoreName(storeName);
  522. }
  523. if (isNotBlank(request.getTerminalType())) {
  524. existing.setTerminalType(request.getTerminalType());
  525. }
  526. if (isNotBlank(request.getProvince())) {
  527. existing.setProvince(request.getProvince());
  528. }
  529. if (isNotBlank(request.getCity())) {
  530. existing.setCity(request.getCity());
  531. }
  532. if (isNotBlank(request.getDistrict())) {
  533. existing.setDistrict(request.getDistrict());
  534. }
  535. if (isNotBlank(request.getPharmacyAddress())) {
  536. existing.setStoreAddress(request.getPharmacyAddress());
  537. }
  538. if (isNotBlank(request.getContactPerson())) {
  539. existing.setContactPerson(request.getContactPerson());
  540. }
  541. if (isNotBlank(request.getContactPhone())) {
  542. existing.setContactPhone(request.getContactPhone());
  543. }
  544. if (isNotBlank(request.getBusinessLicenseUrl())) {
  545. existing.setLicenseImageUrl(request.getBusinessLicenseUrl());
  546. }
  547. if (isNotBlank(request.getDrugLicenseUrl())) {
  548. existing.setDrugLicenseUrl(request.getDrugLicenseUrl());
  549. }
  550. if (isNotBlank(request.getBusinessLicenseNo())) {
  551. existing.setCreditCode(request.getBusinessLicenseNo());
  552. }
  553. if (isNotBlank(request.getMedicalDeviceClass2Url())) {
  554. existing.setMedicalDeviceClass2Url(request.getMedicalDeviceClass2Url());
  555. }
  556. if (isNotBlank(request.getMedicalDeviceClass3Url())) {
  557. existing.setMedicalDeviceClass3Url(request.getMedicalDeviceClass3Url());
  558. }
  559. // 已 APPROVED 的不降级(不覆盖);非 APPROVED 状态可随第三方 status 更新(含 PENDING/REJECTED 互转、升级到 APPROVED)
  560. boolean upgradedToApproved = false;
  561. if (!LicenseStatus.APPROVED.name().equals(existing.getReviewStatus())) {
  562. existing.setReviewStatus(reviewStatus);
  563. upgradedToApproved = LicenseStatus.APPROVED.name().equals(reviewStatus);
  564. }
  565. existing.setSyncSource("MINIAPP_SYNC");
  566. businessLicenseService.updateById(existing);
  567. // 从非APPROVED升级到APPROVED:赠送30天高级会员 + 推送Redis事件
  568. if (upgradedToApproved) {
  569. grantLicenseApprovedReward(userId, existing.getId());
  570. }
  571. }
  572. }
  573. private boolean isNotBlank(String s) {
  574. return s != null && !s.isBlank();
  575. }
  576. /**
  577. * 首次入驻审核通过:赠送30天高级会员 + 推送Redis事件供活动模块消费
  578. * <p>与 BusinessLicenseService.approve() 的奖励逻辑保持一致。
  579. */
  580. private void grantLicenseApprovedReward(Long userId, Long licenseId) {
  581. membershipService.grantOrExtendMembership(
  582. userId,
  583. MembershipLevel.PRO,
  584. 30,
  585. "LICENSE",
  586. "入驻信息审核通过赠送30天高级会员");
  587. log.info("入驻信息审核通过(第三方同步),赠送用户{} 30天高级会员", userId);
  588. // 事务提交后推送 Redis 事件,供活动模块消费(邀请有礼等)
  589. TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
  590. @Override
  591. public void afterCommit() {
  592. try {
  593. activityEventRedisService.pushLicenseApproved(userId, licenseId, "BUSINESS_LICENSE");
  594. } catch (Exception e) {
  595. log.warn("存入入驻审核通过事件失败,不影响主流程: userId={}", userId, e);
  596. }
  597. }
  598. });
  599. }
  600. private UserGateStatus buildGateStatus(Long userId) {
  601. // 执照审核状态
  602. com.xuekairui.user.entity.BusinessLicense license =
  603. businessLicenseService.getByUserId(userId);
  604. String licenseStatus = license != null ? license.getReviewStatus() : LicenseStatus.NOT_SUBMITTED.name();
  605. // 已绑定平台数
  606. Long boundCount = platformAccountMapper.selectCount(
  607. new LambdaQueryWrapper<com.xuekairui.user.entity.PlatformAccount>()
  608. .eq(com.xuekairui.user.entity.PlatformAccount::getUserId, userId)
  609. .eq(com.xuekairui.user.entity.PlatformAccount::getEnabled, true));
  610. boolean canQuery = LicenseStatus.APPROVED.name().equals(licenseStatus) && boundCount >= 1;
  611. java.util.List<String> messages = new java.util.ArrayList<>();
  612. if (!LicenseStatus.APPROVED.name().equals(licenseStatus)) {
  613. messages.add("请先提交入驻信息并通过审核");
  614. }
  615. if (boundCount == 0) {
  616. messages.add("请至少绑定1个B2B平台账号");
  617. }
  618. return UserGateStatus.builder()
  619. .canQueryOnDesktop(canQuery)
  620. .licenseStatus(licenseStatus)
  621. .platformBoundCount(boundCount.intValue())
  622. .gateMessages(messages)
  623. .build();
  624. }
  625. private java.util.Map<String, Object> buildQuotaSummary(Long userId) {
  626. // 今日已用 + 每日上限
  627. int dailyUsed = crawlerUsageLogMapper.sumTodayUsage(userId);
  628. java.util.Map<String, Object> quota = new java.util.LinkedHashMap<>();
  629. quota.put("dailyQueryUsed", dailyUsed);
  630. quota.put("dailyQueryLimit", 20); // TODO: 从会员等级动态获取
  631. return quota;
  632. }
  633. private java.util.List<java.util.Map<String, Object>> getPlatformBindings(Long userId) {
  634. java.util.List<com.xuekairui.user.entity.PlatformAccount> accounts =
  635. platformAccountMapper.selectList(
  636. new LambdaQueryWrapper<com.xuekairui.user.entity.PlatformAccount>()
  637. .eq(com.xuekairui.user.entity.PlatformAccount::getUserId, userId));
  638. return accounts.stream().map(a -> {
  639. java.util.Map<String, Object> m = new java.util.LinkedHashMap<>();
  640. m.put("platformCode", a.getPlatformCode());
  641. m.put("platformName", a.getPlatformName());
  642. m.put("status", Boolean.TRUE.equals(a.getEnabled()) ? "bound" : "disabled");
  643. m.put("lastVerifyTime", a.getLastVerifyTime());
  644. return m;
  645. }).toList();
  646. }
  647. private java.util.Map<String, Object> toMap(UserInfoResponse info) {
  648. // 简单反射转 Map,避免引入额外依赖
  649. java.util.Map<String, Object> map = new java.util.LinkedHashMap<>();
  650. map.put("id", info.getId());
  651. map.put("phone", info.getPhone());
  652. map.put("nickname", info.getNickname());
  653. map.put("avatar", info.getAvatar());
  654. map.put("wechatBound", info.getWechatBound());
  655. map.put("createTime", info.getCreateTime());
  656. map.put("loginSource", info.getLoginSource());
  657. map.put("pharmacyName", info.getPharmacyName());
  658. map.put("province", info.getProvince());
  659. map.put("city", info.getCity());
  660. map.put("district", info.getDistrict());
  661. map.put("role", info.getRole());
  662. map.put("membershipLevel", info.getMembershipLevel());
  663. map.put("membershipLevelName", info.getMembershipLevelName());
  664. map.put("inviterId", info.getInviterId());
  665. map.put("inviterNickname", info.getInviterNickname());
  666. map.put("hasBoundInviteCode", info.getHasBoundInviteCode());
  667. map.put("licenseStatus", info.getLicenseStatus());
  668. map.put("levelCode", info.getLevelCode());
  669. map.put("version", info.getVersion());
  670. return map;
  671. }
  672. /**
  673. * Refresh Token 旋转 + 重用检测
  674. * <p>
  675. * 每次刷新:旧 Refresh Token 标记为 USED,签发新的一对 Token。
  676. * 如果检测到已 USED 的 Refresh Token 被再次使用 → 说明被盗 → 踢出所有设备。
  677. */
  678. @Transactional
  679. public LoginResponse refreshToken(String refreshToken) {
  680. // 1. 验证JWT格式
  681. String tokenType = jwtUtil.getTokenType(refreshToken);
  682. if (!"refresh".equals(tokenType)) {
  683. throw new BusinessException(ErrorCode.REFRESH_TOKEN_INVALID);
  684. }
  685. Long userId = jwtUtil.getUserId(refreshToken);
  686. // 只查 Refresh Token 校验所需的字段,减少DB开销
  687. User user = userMapper.selectOne(
  688. new LambdaQueryWrapper<User>()
  689. .select(User::getId, User::getPhone,
  690. User::getTokenVersion, User::getRefreshTokenHash,
  691. User::getRefreshTokenStatus, User::getStatus)
  692. .eq(User::getId, userId));
  693. if (user == null) {
  694. throw new BusinessException(ErrorCode.USER_NOT_FOUND);
  695. }
  696. // 2. 计算哈希,比对DB中的记录
  697. String tokenHash = JwtUtil.hashToken(refreshToken);
  698. String storedHash = user.getRefreshTokenHash();
  699. String storedStatus = user.getRefreshTokenStatus();
  700. // 3. 重用检测:Refresh Token 已被使用过 → 被盗!
  701. if (tokenHash.equals(storedHash) && "USED".equals(storedStatus)) {
  702. log.warn("用户 {} 的Refresh Token被重用(疑似被盗),踢出所有设备", userId);
  703. // 递增 tokenVersion,使所有旧 access token 立即失效
  704. long newVersion = (user.getTokenVersion() != null ? user.getTokenVersion() : 0L) + 1;
  705. user.setTokenVersion(newVersion);
  706. user.setRefreshTokenHash(null);
  707. user.setRefreshTokenStatus(null);
  708. userMapper.updateById(user);
  709. tokenVersionCache.putVersion(userId, false, newVersion);
  710. throw new BusinessException(ErrorCode.TOKEN_KICKED);
  711. }
  712. // 4. 校验:哈希不匹配 → 无效或过期的 Refresh Token
  713. if (!tokenHash.equals(storedHash) || !"ACTIVE".equals(storedStatus)) {
  714. throw new BusinessException(ErrorCode.REFRESH_TOKEN_INVALID);
  715. }
  716. // 5. 旋转:标记旧 Refresh Token 为 USED,签发新的一对
  717. user.setRefreshTokenStatus("USED");
  718. String roleCode = "USER";
  719. long currentVersion = user.getTokenVersion() != null ? user.getTokenVersion() : 0L;
  720. String loginSource = jwtUtil.getLoginSource(refreshToken);
  721. String newAccessToken = jwtUtil.generateAccessToken(
  722. user.getId(), roleCode, loginSource, currentVersion);
  723. String newRefreshToken = jwtUtil.generateRefreshToken(user.getId(), loginSource);
  724. // 存储新 Refresh Token 哈希
  725. user.setRefreshTokenHash(JwtUtil.hashToken(newRefreshToken));
  726. user.setRefreshTokenStatus("ACTIVE");
  727. userMapper.updateById(user);
  728. log.debug("用户 {} Refresh Token 旋转成功", userId);
  729. return LoginResponse.builder()
  730. .userId(user.getId())
  731. .accessToken(newAccessToken)
  732. .refreshToken(newRefreshToken)
  733. .expiresIn(jwtUtil.getExpirationInSeconds(roleCode, loginSource))
  734. .userInfo(convertToUserInfoResponse(user))
  735. .needBindPhone(false)
  736. .newUser(false)
  737. .role(roleCode)
  738. .build();
  739. }
  740. /** PC 端构建登录响应:
  741. * 递增 tokenVersion,踢掉旧 PC */
  742. private LoginResponse buildLoginResponse(User user) {
  743. return buildLoginResponseInternal(user, true, "PC");
  744. }
  745. /** 小程序构建登录响应:不递增 tokenVersion,不踢 PC */
  746. private LoginResponse buildMiniAppLoginResponse(User user) {
  747. return buildLoginResponseInternal(user, false, "MINIAPP");
  748. }
  749. private LoginResponse buildLoginResponseInternal(User user, boolean incrementVersion, String loginSource) {
  750. MDC.put("uid", String.valueOf(user.getId()));
  751. String roleCode = "USER";
  752. long version;
  753. if (incrementVersion) {
  754. long oldVersion = user.getTokenVersion() != null ? user.getTokenVersion() : 0L;
  755. version = oldVersion + 1;
  756. user.setTokenVersion(version);
  757. tokenVersionCache.putVersion(user.getId(), false, version);
  758. } else {
  759. version = user.getTokenVersion() != null ? user.getTokenVersion() : 0L;
  760. }
  761. String accessToken = jwtUtil.generateAccessToken(
  762. user.getId(), "USER", loginSource, version);
  763. String refreshToken = jwtUtil.generateRefreshToken(user.getId(), loginSource);
  764. user.setRefreshTokenHash(JwtUtil.hashToken(refreshToken));
  765. user.setRefreshTokenStatus("ACTIVE");
  766. userMapper.updateById(user);
  767. return LoginResponse.builder()
  768. .userId(user.getId())
  769. .accessToken(accessToken)
  770. .refreshToken(refreshToken)
  771. .expiresIn(jwtUtil.getExpirationInSeconds(roleCode, loginSource))
  772. .userInfo(convertToUserInfoResponse(user))
  773. .needBindPhone(false)
  774. .newUser(false)
  775. .role(roleCode)
  776. .build();
  777. }
  778. /**
  779. * 转换用户信息响应
  780. */
  781. private UserInfoResponse convertToUserInfoResponse(User user) {
  782. UserLevelResponse levelInfo = null;
  783. if (user != null && user.getId() != null) {
  784. try {
  785. levelInfo = membershipService.getUserLevel(user.getId());
  786. } catch (Exception e) {
  787. log.warn("获取用户等级信息失败: userId={}, error={}", user.getId(), e.getMessage());
  788. }
  789. }
  790. // 营业执照审核状态
  791. String licenseStatus = LicenseStatus.NOT_SUBMITTED.name();
  792. try {
  793. assert user != null;
  794. com.xuekairui.user.entity.BusinessLicense license =
  795. businessLicenseService.getByUserId(user.getId());
  796. if (license != null && license.getReviewStatus() != null) {
  797. licenseStatus = license.getReviewStatus();
  798. }
  799. } catch (Exception e) {
  800. log.warn("查询营业执照状态失败: userId={}, error={}", user.getId(), e.getMessage());
  801. }
  802. // 邀请人信息
  803. Long inviterId = user.getInviterId();
  804. String inviterNickname = null;
  805. if (inviterId != null) {
  806. User inviter = userMapper.selectById(inviterId);
  807. if (inviter != null) {
  808. inviterNickname = inviter.getWechatNickname() != null ? inviter.getWechatNickname()
  809. : inviter.getNickname() != null ? inviter.getNickname()
  810. : "一位用户";
  811. }
  812. }
  813. return UserInfoResponse.builder()
  814. .id(user.getId())
  815. .phone(ValidateUtil.maskPhone(user.getPhone()))
  816. .nickname(user.getNickname())
  817. .userName(user.getUserName())
  818. .avatar(user.getAvatar())
  819. .wechatBound(user.getWechatOpenId() != null)
  820. .levelInfo(levelInfo)
  821. .createTime(user.getCreateTime())
  822. .loginSource(user.getLoginSource() != null ? user.getLoginSource() : "UNKNOWN")
  823. .pharmacyName(user.getPharmacyName())
  824. .province(user.getProvince())
  825. .city(user.getCity())
  826. .district(user.getDistrict())
  827. .role("USER")
  828. .membershipLevel(levelInfo != null ? levelInfo.getLevelCode() : "PLUS")
  829. .membershipLevelName(levelInfo != null ? levelInfo.getLevelName() : "普通会员")
  830. .inviterId(inviterId)
  831. .inviterNickname(inviterNickname)
  832. .hasBoundInviteCode(inviterId != null)
  833. .licenseStatus(licenseStatus)
  834. .build();
  835. }
  836. /**
  837. * 密码登录
  838. * 支持手机号或用户名登录:phone 和 username 至少填一个
  839. */
  840. @Transactional
  841. public LoginResponse passwordLogin(PasswordLoginRequest request, String ip) {
  842. // 1. 校验:phone 和 username 至少填一个
  843. boolean hasPhone = request.getPhone() != null && !request.getPhone().isBlank();
  844. boolean hasUsername = request.getUsername() != null && !request.getUsername().isBlank();
  845. if (!hasPhone && !hasUsername) {
  846. throw new BusinessException(ErrorCode.PARAM_ERROR, "手机号和用户名至少填写一个");
  847. }
  848. // 2. 查找用户(优先手机号,其次用户名/昵称)
  849. User user;
  850. if (hasPhone) {
  851. user = userMapper.selectOne(
  852. new LambdaQueryWrapper<User>().eq(User::getPhone, request.getPhone()));
  853. } else {
  854. user = userMapper.selectOne(
  855. new LambdaQueryWrapper<User>().eq(User::getNickname, request.getUsername()));
  856. }
  857. if (user == null) {
  858. return adminAuthService.passwordLogin(
  859. hasPhone ? request.getPhone() : request.getUsername(),
  860. request.getPassword(), ip);
  861. }
  862. // 2. 检查是否设置了密码
  863. if (user.getPassword() == null) {
  864. throw new BusinessException(ErrorCode.PASSWORD_NOT_SET);
  865. }
  866. // 3. 验证密码
  867. if (!passwordUtil.matches(request.getPassword(), user.getPassword())) {
  868. throw new BusinessException(ErrorCode.PASSWORD_INCORRECT);
  869. }
  870. // 4. 检查用户状态
  871. if (user.getStatus() != null && user.getStatus() == 0) {
  872. throw new BusinessException(ErrorCode.USER_DISABLED);
  873. }
  874. // 5. 更新登录信息
  875. user.setLastLoginTime(LocalDateTime.now());
  876. user.setLastLoginIp(ip);
  877. user.setLoginSource("PC");
  878. userMapper.updateById(user);
  879. // 6. 生成Token
  880. LoginResponse response = buildLoginResponse(user);
  881. auditLogService.logSuccess(user.getId(), user.getNickname(), OperatorRole.USER.getCode(),
  882. OperationType.USER_LOGIN, "USER:" + user.getId(), user.getId());
  883. return response;
  884. }
  885. /**
  886. * 设置/修改密码
  887. */
  888. @Transactional
  889. public void setPassword(Long userId, SetPasswordRequest request) {
  890. User user = userMapper.selectById(userId);
  891. if (user == null) {
  892. throw new BusinessException(ErrorCode.USER_NOT_FOUND);
  893. }
  894. // 如果已有密码,需要验证旧密码
  895. if (user.getPassword() != null && request.getOldPassword() != null) {
  896. if (!passwordUtil.matches(request.getOldPassword(), user.getPassword())) {
  897. throw new BusinessException(ErrorCode.OLD_PASSWORD_INCORRECT);
  898. }
  899. } else if (user.getPassword() != null) {
  900. throw new BusinessException(ErrorCode.OLD_PASSWORD_REQUIRED);
  901. }
  902. // 加密并更新密码
  903. user.setPassword(passwordUtil.encode(request.getNewPassword()));
  904. userMapper.updateById(user);
  905. log.info("用户{}设置/修改密码成功", userId);
  906. auditLogService.logSuccess(userId, user.getNickname(), OperatorRole.USER.getCode(),
  907. OperationType.USER_UPDATE, "PHONE:" + userId, userId);
  908. }
  909. /**
  910. * 修改手机号
  911. */
  912. @Transactional
  913. public void changePhone(Long userId, ChangePhoneRequest request) {
  914. User user = userMapper.selectById(userId);
  915. if (user == null) {
  916. throw new BusinessException(ErrorCode.USER_NOT_FOUND);
  917. }
  918. // 1. 验证验证码(使用新手机号和change场景)
  919. verificationCodeService.verifyCode(request.getNewPhone(), request.getCode());
  920. // 2. 检查新手机号是否已被其他用户占用
  921. User existUser = userMapper.selectOne(
  922. new LambdaQueryWrapper<User>().eq(User::getPhone, request.getNewPhone()));
  923. if (existUser != null && !existUser.getId().equals(userId)) {
  924. throw new BusinessException(ErrorCode.PHONE_ALREADY_EXISTS);
  925. }
  926. // 3. 更新手机号
  927. String oldPhone = user.getPhone();
  928. user.setPhone(request.getNewPhone());
  929. userMapper.updateById(user);
  930. log.info("用户{}修改手机号成功,旧手机号:{},新手机号:{}", userId, oldPhone, request.getNewPhone());
  931. auditLogService.logSuccess(userId, user.getNickname(), OperatorRole.USER.getCode(),
  932. OperationType.USER_UPDATE, "PHONE:" + userId, userId);
  933. }
  934. // ====================================
  935. // 小程序统一登录(处理四种用户身份场景)
  936. // ====================================
  937. /**
  938. * 小程序统一登录
  939. * <p>
  940. * 自动处理"小程序老/新用户 × 当前系统老/新用户"四种场景:
  941. * <ol>
  942. * <li>外部老 + 本地老 → 直接登录</li>
  943. * <li>外部老 + 本地新 → 本地注册 + 异步同步证书</li>
  944. * <li>外部新 + 本地老 → 异步注册外部 + 本地直接登录</li>
  945. * <li>外部新 + 本地新 → 两端都注册</li>
  946. * </ol>
  947. * <p>
  948. * 关键原则:外部系统操作异步执行,不阻塞登录响应。
  949. */
  950. @Transactional
  951. public LoginResponse miniappUnifiedLogin(MiniProgramLoginRequest request, String ip) {
  952. // 1. code2Session
  953. WechatMiniProgramService.MiniProgramSessionInfo session =
  954. wechatMiniProgramService.code2Session(request.getJsCode());
  955. // 2. 获取手机号(如果提供了 phoneCode)
  956. String phone = null;
  957. if (request.getPhoneCode() != null && !request.getPhoneCode().isBlank()) {
  958. try {
  959. phone = wechatMiniProgramService.getPhoneNumber(request.getPhoneCode());
  960. } catch (Exception e) {
  961. log.warn("获取小程序手机号失败(不阻断登录): {}", e.getMessage());
  962. }
  963. }
  964. // 3. 并行检查双系统
  965. User user = findUserByWechatIdentity(session.getUnionId(), session.getOpenId());
  966. boolean existsExternal = checkExternalUserSafe(session.getUnionId());
  967. boolean isNewLocal = (user == null);
  968. log.info("小程序统一登录: unionId={}, isNewLocal={}, existsExternal={}",
  969. session.getUnionId(), isNewLocal, existsExternal);
  970. // 4. 场景路由
  971. if (!isNewLocal && existsExternal) {
  972. // 场景1: 两端都有 → 更新微信信息,直接登录
  973. updateWechatMiniInfo(user, session.getOpenId(), session.getUnionId(), phone, ip);
  974. } else if (isNewLocal && existsExternal) {
  975. // 场景2: 外部老 + 本地新 → 本地注册 + 异步同步证书
  976. user = createUserFromMiniProgram(session, phone, request.getInviteCode(), ip);
  977. final Long newUserId = user.getId();
  978. final String syncUnionId = session.getUnionId();
  979. try {
  980. certificateSyncService.syncCertificatesFromExternal(newUserId, syncUnionId);
  981. } catch (Exception e) {
  982. log.warn("触发证书同步失败(不阻断登录): userId={}", newUserId, e);
  983. }
  984. } else if (!isNewLocal) {
  985. // 场景3: 外部新 + 本地老 → 更新微信信息,异步注册外部
  986. updateWechatMiniInfo(user, session.getOpenId(), session.getUnionId(), phone, ip);
  987. asyncRegisterInExternal(user, session.getOpenId());
  988. } else {
  989. // 场景4: 两端都新 → 本地注册 + 异步注册外部
  990. user = createUserFromMiniProgram(session, phone, request.getInviteCode(), ip);
  991. asyncRegisterInExternal(user, session.getOpenId());
  992. }
  993. // 5. 没有手机号 → 返回需要绑定
  994. if (user.getPhone() == null) {
  995. String tempToken = jwtUtil.generateTempToken(session.getOpenId(), session.getUnionId());
  996. MDC.put("uid", String.valueOf(user.getId()));
  997. return LoginResponse.builder()
  998. .needBindPhone(true)
  999. .tempToken(tempToken)
  1000. .userInfo(convertToUserInfoResponse(user))
  1001. .build();
  1002. }
  1003. // 6. 更新登录信息并签发 Token
  1004. user.setLastLoginTime(LocalDateTime.now());
  1005. user.setLastLoginIp(ip);
  1006. user.setLoginSource("MINIAPP");
  1007. userMapper.updateById(user);
  1008. LoginResponse response = buildMiniAppLoginResponse(user);
  1009. auditLogService.logSuccess(user.getId(), user.getNickname(), OperatorRole.USER.getCode(),
  1010. isNewLocal ? OperationType.USER_REGISTER : OperationType.USER_LOGIN,
  1011. "USER:" + user.getId(), user.getId());
  1012. return response;
  1013. }
  1014. /**
  1015. * 小程序绑定手机号(needBindPhone=true 时调用)
  1016. */
  1017. @Transactional
  1018. public LoginResponse miniappBindPhone(MiniProgramPhoneRequest request, String ip) {
  1019. // 1. 验证临时 Token
  1020. Claims claims = jwtUtil.parseToken(request.getTempToken());
  1021. if (!"temp".equals(claims.get("type", String.class))) {
  1022. throw new BusinessException(ErrorCode.TOKEN_INVALID);
  1023. }
  1024. String openId = claims.get("openId", String.class);
  1025. String unionId = claims.get("unionId", String.class);
  1026. // 2. 获取手机号
  1027. String phone = wechatMiniProgramService.getPhoneNumber(request.getPhoneCode());
  1028. // 3. 查找小程序用户
  1029. User wechatUser = userMapper.selectOne(
  1030. new LambdaQueryWrapper<User>().eq(User::getWechatMiniOpenId, openId));
  1031. if (wechatUser == null) {
  1032. throw new BusinessException(ErrorCode.WECHAT_LOGIN_FAILED);
  1033. }
  1034. // 4. 检查手机号是否已被其他用户绑定
  1035. User existUser = userMapper.selectOne(
  1036. new LambdaQueryWrapper<User>().eq(User::getPhone, phone));
  1037. if (existUser != null && !existUser.getId().equals(wechatUser.getId())) {
  1038. // 手机号已被其他账号绑定 → 合并账号
  1039. existUser.setWechatMiniOpenId(openId);
  1040. if (unionId != null) {
  1041. existUser.setWechatUnionId(unionId);
  1042. }
  1043. existUser.setLastLoginTime(LocalDateTime.now());
  1044. existUser.setLastLoginIp(ip);
  1045. userMapper.updateById(existUser);
  1046. // 禁用旧的小程序用户(不物理删除,仅改状态)
  1047. wechatUser.setStatus(0);
  1048. userMapper.updateById(wechatUser);
  1049. wechatUser = existUser;
  1050. } else if (existUser == null) {
  1051. // 绑定手机号(完成注册)
  1052. wechatUser.setPhone(phone);
  1053. wechatUser.setLoginSource("MINIAPP");
  1054. wechatUser.setLastLoginTime(LocalDateTime.now());
  1055. wechatUser.setLastLoginIp(ip);
  1056. userMapper.updateById(wechatUser);
  1057. eventPublisher.publishEvent(new UserRegisteredEvent(
  1058. this, wechatUser.getId(), request.getInviteCode()));
  1059. }
  1060. return buildMiniAppLoginResponse(wechatUser);
  1061. }
  1062. // ====================================
  1063. // 小程序登录辅助方法
  1064. // ====================================
  1065. /** 通过微信身份标识查找用户(unionId 优先,fallback miniOpenId) */
  1066. private User findUserByWechatIdentity(String unionId, String miniOpenId) {
  1067. if (unionId != null) {
  1068. User user = userMapper.selectOne(
  1069. new LambdaQueryWrapper<User>().eq(User::getWechatUnionId, unionId));
  1070. if (user != null) {
  1071. return user;
  1072. }
  1073. }
  1074. if (miniOpenId != null) {
  1075. return userMapper.selectOne(
  1076. new LambdaQueryWrapper<User>().eq(User::getWechatMiniOpenId, miniOpenId));
  1077. }
  1078. return null;
  1079. }
  1080. /** 安全检查外部用户(不可用时降级为"外部新用户",不阻塞流程) */
  1081. private boolean checkExternalUserSafe(String unionId) {
  1082. try {
  1083. return externalUserSyncService.userExists(unionId);
  1084. } catch (Exception e) {
  1085. log.warn("检查外部用户失败(降级为外部新用户): unionId={}", unionId, e);
  1086. return false;
  1087. }
  1088. }
  1089. /** 从小程序注册新用户 */
  1090. private User createUserFromMiniProgram(
  1091. WechatMiniProgramService.MiniProgramSessionInfo session,
  1092. String phone, String inviteCode, String ip) {
  1093. User user = User.builder()
  1094. .wechatMiniOpenId(session.getOpenId())
  1095. .wechatUnionId(session.getUnionId())
  1096. .phone(phone)
  1097. .nickname("药店用户")
  1098. .membershipLevel(MembershipLevel.PLUS)
  1099. .loginSource("MINIAPP")
  1100. .lastLoginTime(LocalDateTime.now())
  1101. .lastLoginIp(ip)
  1102. .status(1)
  1103. .build();
  1104. userMapper.insert(user);
  1105. membershipService.initPlusMembership(user.getId());
  1106. if (inviteCode != null) {
  1107. eventPublisher.publishEvent(new UserRegisteredEvent(this, user.getId(), inviteCode));
  1108. }
  1109. log.info("小程序新用户注册: userId={}, unionId={}", user.getId(), session.getUnionId());
  1110. return user;
  1111. }
  1112. /** 更新已有用户的微信小程序信息 */
  1113. private void updateWechatMiniInfo(User user, String miniOpenId, String unionId,
  1114. String phone, String ip) {
  1115. boolean changed = false;
  1116. if (miniOpenId != null && !miniOpenId.equals(user.getWechatMiniOpenId())) {
  1117. user.setWechatMiniOpenId(miniOpenId);
  1118. changed = true;
  1119. }
  1120. if (unionId != null && !unionId.equals(user.getWechatUnionId())) {
  1121. user.setWechatUnionId(unionId);
  1122. changed = true;
  1123. }
  1124. if (phone != null && user.getPhone() == null) {
  1125. user.setPhone(phone);
  1126. changed = true;
  1127. }
  1128. user.setLastLoginTime(LocalDateTime.now());
  1129. user.setLastLoginIp(ip);
  1130. user.setLoginSource("MINIAPP");
  1131. if (changed) {
  1132. userMapper.updateById(user);
  1133. }
  1134. }
  1135. /** 异步在外部系统注册用户(不阻塞主流程) */
  1136. private void asyncRegisterInExternal(User user, String miniProgramOpenId) {
  1137. try {
  1138. ExternalUserSyncService.ExternalUserInfo extInfo =
  1139. new ExternalUserSyncService.ExternalUserInfo();
  1140. extInfo.setMiniProgramOpenId(miniProgramOpenId);
  1141. extInfo.setUnionId(user.getWechatUnionId());
  1142. extInfo.setPhone(user.getPhone());
  1143. extInfo.setNickname(user.getNickname());
  1144. extInfo.setAvatar(user.getAvatar());
  1145. String extUserId = externalUserSyncService.registerUser(extInfo);
  1146. if (extUserId != null) {
  1147. user.setExternalUserId(extUserId);
  1148. user.setExternalSyncTime(LocalDateTime.now());
  1149. userMapper.updateById(user);
  1150. log.info("外部系统注册成功: userId={}, extUserId={}", user.getId(), extUserId);
  1151. }
  1152. } catch (Exception e) {
  1153. log.warn("外部系统注册失败(不阻断主流程): userId={}", user.getId(), e);
  1154. }
  1155. }
  1156. }