AuthService.java 82 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799
  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.SyncInfoRequest;
  19. import com.xuekairui.user.dto.SyncShopReportRequest;
  20. import com.xuekairui.user.dto.SyncShopReportResponse;
  21. import com.xuekairui.user.dto.UserGateStatus;
  22. import com.xuekairui.user.dto.UserInfoResponse;
  23. import com.xuekairui.user.dto.UserLevelResponse;
  24. import com.xuekairui.user.dto.WechatBindRequest;
  25. import com.xuekairui.user.dto.WechatLoginRequest;
  26. import com.xuekairui.user.entity.User;
  27. import com.xuekairui.user.enums.LicenseStatus;
  28. import com.xuekairui.user.enums.MembershipLevel;
  29. import com.xuekairui.user.enums.OperationSource;
  30. import com.xuekairui.user.enums.OperationType;
  31. import com.xuekairui.user.enums.OperatorRole;
  32. import com.xuekairui.user.event.UserRegisteredEvent;
  33. import com.xuekairui.user.mapper.UserMapper;
  34. import com.xuekairui.user.util.JwtUtil;
  35. import com.xuekairui.user.util.PasswordUtil;
  36. import io.jsonwebtoken.Claims;
  37. import lombok.RequiredArgsConstructor;
  38. import lombok.extern.slf4j.Slf4j;
  39. import org.slf4j.MDC;
  40. import org.springframework.context.ApplicationEventPublisher;
  41. import org.springframework.stereotype.Service;
  42. import org.springframework.transaction.annotation.Transactional;
  43. import org.springframework.transaction.support.TransactionSynchronization;
  44. import org.springframework.transaction.support.TransactionSynchronizationManager;
  45. import java.time.LocalDateTime;
  46. import java.util.Map;
  47. /**
  48. * 认证服务
  49. */
  50. @Slf4j
  51. @Service
  52. @RequiredArgsConstructor
  53. public class AuthService {
  54. private final UserMapper userMapper;
  55. private final VerificationCodeService verificationCodeService;
  56. private final WechatService wechatService;
  57. private final JwtUtil jwtUtil;
  58. private final ApplicationEventPublisher eventPublisher;
  59. private final PasswordUtil passwordUtil;
  60. private final OperationAuditLogService auditLogService;
  61. private final MembershipService membershipService;
  62. private final AdminAuthService adminAuthService;
  63. private final com.xuekairui.user.security.TokenVersionCache tokenVersionCache;
  64. private final WechatMiniProgramService wechatMiniProgramService;
  65. private final ExternalUserSyncService externalUserSyncService;
  66. private final CertificateSyncService certificateSyncService;
  67. private final BusinessLicenseService businessLicenseService;
  68. private final ShopResourceSyncService shopResourceSyncService;
  69. private final com.xuekairui.user.mapper.LicenseSyncDiffMapper licenseSyncDiffMapper;
  70. private final com.xuekairui.user.mapper.PlatformAccountMapper platformAccountMapper;
  71. private final com.xuekairui.user.mapper.CrawlerUsageLogMapper crawlerUsageLogMapper;
  72. private final com.xuekairui.user.mapper.SearchRecordMapper searchRecordMapper;
  73. private final com.xuekairui.user.mapper.PurchaseIntentMapper purchaseIntentMapper;
  74. private final ActivityEventRedisService activityEventRedisService;
  75. /**
  76. * 发送验证码
  77. */
  78. public void sendVerificationCode(SendCodeRequest request, String ip) {
  79. verificationCodeService.sendCode(request.getPhone(), request.getScene(), ip);
  80. }
  81. /**
  82. * 验证码登录/注册
  83. * 如果手机号已注册则登录,否则自动注册
  84. */
  85. @Transactional
  86. public LoginResponse smsLogin(SmsLoginRequest request, String ip) {
  87. // 1. 验证验证码(兼容 scene=login 和 scene=register,统一用 login key)
  88. verificationCodeService.verifyCode(request.getPhone(), request.getCode());
  89. // 2. 查找或创建用户
  90. User user = userMapper.selectOne(
  91. new LambdaQueryWrapper<User>().eq(User::getPhone, request.getPhone()));
  92. boolean isNewUser = false;
  93. if (user == null) {
  94. isNewUser = true;
  95. // 自动注册新用户
  96. user = User.builder()
  97. .phone(request.getPhone())
  98. .nickname("用户" + request.getPhone().substring(7))
  99. .membershipLevel(MembershipLevel.PLUS)
  100. .loginSource("SMS")
  101. .status(1)
  102. .build();
  103. userMapper.insert(user);
  104. // 注册即写入 PLUS 会员记录
  105. membershipService.initPlusMembership(user.getId());
  106. // 发布用户注册事件(邀请模块监听处理邀请码,赠送会员奖励)
  107. eventPublisher.publishEvent(new UserRegisteredEvent(this, user.getId(), request.getInviteCode()));
  108. // 普通会员(PLUS)免费开放,注册即拥有,无需额外发放
  109. }
  110. // 3. 更新登录信息
  111. user.setLastLoginTime(LocalDateTime.now());
  112. user.setLastLoginIp(ip);
  113. String loginSource = request.getLoginSource() != null && !request.getLoginSource().isBlank()
  114. ? request.getLoginSource() : "PC";
  115. user.setLoginSource(loginSource);
  116. userMapper.updateById(user);
  117. // 4. 生成Token(小程序不递增tokenVersion,不踢PC)
  118. boolean incrementVersion = !"MINIAPP".equals(loginSource);
  119. LoginResponse response = buildLoginResponseInternal(user, incrementVersion, loginSource, false);
  120. response.setUserId(user.getId());
  121. response.setNewUser(isNewUser);
  122. // 记录审计日志
  123. if (isNewUser) {
  124. auditLogService.logSuccess(user.getId(), user.getNickname(), OperatorRole.USER.getCode(),
  125. OperationType.USER_REGISTER, "USER:" + user.getId(), user.getId());
  126. }
  127. auditLogService.logSuccess(user.getId(), user.getNickname(), OperatorRole.USER.getCode(),
  128. OperationType.USER_LOGIN, "USER:" + user.getId(), user.getId());
  129. // 登录时检查入驻信息,未同步成功则补推一次到第三方
  130. syncShopResourceOnLogin(user.getId());
  131. return response;
  132. }
  133. /**
  134. * 微信扫码登录
  135. */
  136. @Transactional
  137. public LoginResponse wechatLogin(WechatLoginRequest request, String ip) {
  138. // 1. 获取微信用户信息
  139. WechatService.WechatUserInfo wechatInfo = wechatService.getUserInfo(request.getCode());
  140. // 2. 通过UnionID查找用户
  141. User user = null;
  142. if (wechatInfo.getUnionId() != null) {
  143. user = userMapper.selectOne(
  144. new LambdaQueryWrapper<User>().eq(User::getWechatUnionId, wechatInfo.getUnionId()));
  145. }
  146. // 3. 如果用户存在且已绑定手机号,直接登录
  147. if (user != null && user.getPhone() != null) {
  148. user.setLastLoginTime(LocalDateTime.now());
  149. user.setLastLoginIp(ip);
  150. user.setLoginSource("PC");
  151. userMapper.updateById(user);
  152. syncShopResourceOnLogin(user.getId());
  153. return buildLoginResponse(user);
  154. }
  155. // 4. 如果用户存在但未绑定手机号,或新用户,返回需要绑定手机号
  156. if (user == null) {
  157. // 创建新用户(未绑定手机号)
  158. user = User.builder()
  159. .wechatOpenId(wechatInfo.getOpenId())
  160. .wechatUnionId(wechatInfo.getUnionId())
  161. .nickname(wechatInfo.getNickname())
  162. .avatar(wechatInfo.getHeadImgUrl())
  163. .membershipLevel(MembershipLevel.PLUS)
  164. .loginSource("WECHAT")
  165. .status(1)
  166. .build();
  167. userMapper.insert(user);
  168. membershipService.initPlusMembership(user.getId());
  169. } else {
  170. // 更新微信信息
  171. user.setWechatOpenId(wechatInfo.getOpenId());
  172. user.setWechatUnionId(wechatInfo.getUnionId());
  173. if (user.getNickname() == null) {
  174. user.setNickname(wechatInfo.getNickname());
  175. }
  176. if (user.getAvatar() == null) {
  177. user.setAvatar(wechatInfo.getHeadImgUrl());
  178. }
  179. user.setLastLoginTime(LocalDateTime.now());
  180. user.setLastLoginIp(ip);
  181. userMapper.updateById(user);
  182. }
  183. // 5. 生成临时Token,要求绑定手机号
  184. String tempToken = jwtUtil.generateTempToken(wechatInfo.getOpenId(), wechatInfo.getUnionId());
  185. // 确保 MDC 中有 uid(微信登录临时Token场景不走 buildLoginResponse)
  186. MDC.put("uid", String.valueOf(user.getId()));
  187. return LoginResponse.builder()
  188. .needBindPhone(true)
  189. .tempToken(tempToken)
  190. .userInfo(convertToUserInfoResponse(user))
  191. .build();
  192. }
  193. /**
  194. * 微信绑定手机号
  195. */
  196. @Transactional
  197. public LoginResponse wechatBindPhone(WechatBindRequest request, String ip) {
  198. // 1. 验证临时Token
  199. Claims claims = jwtUtil.parseToken(request.getTempToken());
  200. String tokenType = claims.get("type", String.class);
  201. if (!"temp".equals(tokenType)) {
  202. throw new BusinessException(ErrorCode.TOKEN_INVALID);
  203. }
  204. String openId = claims.get("openId", String.class);
  205. String unionId = claims.get("unionId", String.class);
  206. // 2. 验证验证码
  207. verificationCodeService.verifyCode(request.getPhone(), request.getCode());
  208. // 3. 检查手机号是否已被其他用户绑定
  209. User existUser = userMapper.selectOne(
  210. new LambdaQueryWrapper<User>().eq(User::getPhone, request.getPhone()));
  211. User wechatUser = userMapper.selectOne(
  212. new LambdaQueryWrapper<User>().eq(User::getWechatOpenId, openId));
  213. if (wechatUser == null) {
  214. throw new BusinessException(ErrorCode.WECHAT_LOGIN_FAILED);
  215. }
  216. if (existUser != null && !existUser.getId().equals(wechatUser.getId())) {
  217. // 手机号已被其他账号绑定,合并账号
  218. existUser.setWechatOpenId(openId);
  219. existUser.setWechatUnionId(unionId);
  220. existUser.setLastLoginTime(LocalDateTime.now());
  221. existUser.setLastLoginIp(ip);
  222. existUser.setLoginSource("PC");
  223. userMapper.updateById(existUser);
  224. // 禁用旧的微信用户(不物理删除,仅改状态)
  225. wechatUser.setStatus(0);
  226. userMapper.updateById(wechatUser);
  227. wechatUser = existUser;
  228. } else if (existUser == null) {
  229. // 绑定手机号到微信用户(首次完成注册)
  230. wechatUser.setPhone(request.getPhone());
  231. wechatUser.setLoginSource("PC");
  232. wechatUser.setLastLoginTime(LocalDateTime.now());
  233. wechatUser.setLastLoginIp(ip);
  234. userMapper.updateById(wechatUser);
  235. // 发布用户注册事件,邀请模块可据此建立邀请关系并发放奖励
  236. eventPublisher.publishEvent(new UserRegisteredEvent(this, wechatUser.getId(), request.getInviteCode()));
  237. // 普通会员(PLUS)免费开放,注册即拥有,无需额外发放
  238. }
  239. // 4. 生成正式Token
  240. syncShopResourceOnLogin(wechatUser.getId());
  241. return buildLoginResponse(wechatUser);
  242. }
  243. /**
  244. * 外部系统换取 Token(登录 + 信息同步,兼容旧版一并传入用户资料和入驻信息)
  245. * <p>查找或自动注册用户。根据 loginSource 决定过期策略:MINIAPP=30天不踢PC / 其他=1小时。
  246. */
  247. @Transactional
  248. public LoginResponse externalMiniAppToken(ExternalTokenRequest request, String ip) {
  249. // 登录来源:不传默认 MINIAPP(向后兼容)
  250. String loginSource = request.getLoginSource() != null && !request.getLoginSource().isBlank()
  251. ? request.getLoginSource() : "MINIAPP";
  252. User user = userMapper.selectOne(
  253. new LambdaQueryWrapper<User>().eq(User::getPhone, request.getPhone()));
  254. boolean isNewUser = false;
  255. if (user == null) {
  256. isNewUser = true;
  257. // 优先级:nickname > username > 手机号生成
  258. String nickname = (request.getNickname() != null && !request.getNickname().isBlank())
  259. ? request.getNickname()
  260. : (request.getUsername() != null && !request.getUsername().isBlank())
  261. ? request.getUsername()
  262. : "用户" + request.getPhone().substring(request.getPhone().length() - 4);
  263. user = User.builder()
  264. .phone(request.getPhone())
  265. .nickname(nickname)
  266. .userName(request.getUsername())
  267. .wechatMiniOpenId(request.getOpenId())
  268. .wechatUnionId(request.getUnionId())
  269. .pharmacyName(request.getPharmacyName())
  270. .province(request.getProvince())
  271. .city(request.getCity())
  272. .district(request.getDistrict())
  273. .pharmacyAddress(request.getPharmacyAddress())
  274. .contactPerson(request.getContactPerson())
  275. .contactPhone(request.getContactPhone())
  276. .businessLicenseNo(request.getBusinessLicenseNo())
  277. .membershipLevel(MembershipLevel.PLUS)
  278. .loginSource(loginSource)
  279. .status(1)
  280. .build();
  281. userMapper.insert(user);
  282. membershipService.initPlusMembership(user.getId());
  283. if (request.getInviteCode() != null) {
  284. eventPublisher.publishEvent(
  285. new UserRegisteredEvent(this, user.getId(), request.getInviteCode()));
  286. }
  287. } else {
  288. // 已存在用户:更新第三方传入的信息(非空字段才覆盖)
  289. if (request.getUsername() != null && !request.getUsername().isBlank()) {
  290. user.setUserName(request.getUsername());
  291. }
  292. if (request.getNickname() != null && !request.getNickname().isBlank()) {
  293. user.setNickname(request.getNickname());
  294. } else if (request.getUsername() != null && !request.getUsername().isBlank()) {
  295. user.setNickname(request.getUsername());
  296. }
  297. if (request.getPharmacyName() != null && !request.getPharmacyName().isBlank()) {
  298. user.setPharmacyName(request.getPharmacyName());
  299. }
  300. if (request.getProvince() != null && !request.getProvince().isBlank()) {
  301. user.setProvince(request.getProvince());
  302. }
  303. if (request.getCity() != null && !request.getCity().isBlank()) {
  304. user.setCity(request.getCity());
  305. }
  306. if (request.getDistrict() != null && !request.getDistrict().isBlank()) {
  307. user.setDistrict(request.getDistrict());
  308. }
  309. if (request.getPharmacyAddress() != null && !request.getPharmacyAddress().isBlank()) {
  310. user.setPharmacyAddress(request.getPharmacyAddress());
  311. }
  312. if (request.getContactPerson() != null && !request.getContactPerson().isBlank()) {
  313. user.setContactPerson(request.getContactPerson());
  314. }
  315. if (request.getContactPhone() != null && !request.getContactPhone().isBlank()) {
  316. user.setContactPhone(request.getContactPhone());
  317. }
  318. if (request.getBusinessLicenseNo() != null && !request.getBusinessLicenseNo().isBlank()) {
  319. user.setBusinessLicenseNo(request.getBusinessLicenseNo());
  320. }
  321. if (request.getOpenId() != null && !request.getOpenId().isBlank()) {
  322. user.setWechatMiniOpenId(request.getOpenId());
  323. }
  324. if (request.getUnionId() != null && !request.getUnionId().isBlank()) {
  325. user.setWechatUnionId(request.getUnionId());
  326. }
  327. }
  328. user.setLoginSource(loginSource);
  329. user.setLastLoginTime(LocalDateTime.now());
  330. user.setLastLoginIp(ip);
  331. userMapper.updateById(user);
  332. // 同步入驻信息到 t_business_license(仅当传入入驻相关字段时)
  333. syncBusinessLicenseFromTokenRequest(request, user.getId());
  334. boolean skipRefresh = request.getSkipRefreshToken() != null && request.getSkipRefreshToken();
  335. boolean incrementVersion = !"MINIAPP".equals(loginSource);
  336. LoginResponse response = buildLoginResponseInternal(user, incrementVersion, loginSource, skipRefresh);
  337. response.setNewUser(isNewUser);
  338. auditLogService.logSuccess(user.getId(), user.getNickname(), OperatorRole.USER.getCode(),
  339. isNewUser ? OperationType.USER_REGISTER : OperationType.USER_LOGIN,
  340. "USER:" + user.getId(), user.getId());
  341. return response;
  342. }
  343. public LoginResponse heartbeat(Long userId, String role, String loginSource) {
  344. String roleCode = role != null ? role : "USER";
  345. Long currentVersion = tokenVersionCache.getVersion(userId, false);
  346. if (currentVersion == null) {
  347. User user = userMapper.selectById(userId);
  348. currentVersion = user != null && user.getTokenVersion() != null
  349. ? user.getTokenVersion() : 0L;
  350. if (user != null) {
  351. tokenVersionCache.putVersion(userId, false, currentVersion);
  352. }
  353. }
  354. String newAccessToken = jwtUtil.generateAccessToken(
  355. userId, roleCode, loginSource, currentVersion);
  356. // 心跳不覆盖已有的 ACTIVE refreshToken,避免 PC/小程序互相踢 refreshToken
  357. User user = userMapper.selectById(userId);
  358. String newRefreshToken = null;
  359. if (user != null && (user.getRefreshTokenHash() == null || !"ACTIVE".equals(user.getRefreshTokenStatus()))) {
  360. newRefreshToken = jwtUtil.generateRefreshToken(userId, loginSource);
  361. }
  362. User updateUser = new User();
  363. updateUser.setId(userId);
  364. if (newRefreshToken != null) {
  365. updateUser.setRefreshTokenHash(JwtUtil.hashToken(newRefreshToken));
  366. updateUser.setRefreshTokenStatus("ACTIVE");
  367. }
  368. userMapper.updateById(updateUser);
  369. return LoginResponse.builder()
  370. .userId(userId)
  371. .accessToken(newAccessToken)
  372. .refreshToken(newRefreshToken)
  373. .expiresIn(jwtUtil.getExpirationInSeconds(roleCode, loginSource))
  374. .needBindPhone(false)
  375. .role(roleCode)
  376. .build();
  377. }
  378. /**
  379. * 获取用户信息
  380. */
  381. public UserInfoResponse getUserInfo(Long userId) {
  382. User user = userMapper.selectById(userId);
  383. if (user == null) {
  384. throw new BusinessException(ErrorCode.USER_NOT_FOUND);
  385. }
  386. return convertToUserInfoResponse(user);
  387. }
  388. /**
  389. * 获取聚合用户信息(小程序"我的"页面)— 新增,不影响 /api/auth/user/info
  390. * <p>返回 user + gate(门禁)+ quota(配额)+ platformBindings(平台绑定)。
  391. */
  392. public java.util.Map<String, Object> getUserMe(Long userId) {
  393. UserInfoResponse userInfo = getUserInfo(userId);
  394. UserGateStatus gate = buildGateStatus(userId);
  395. java.util.Map<String, Object> quota = buildQuotaSummary(userId);
  396. java.util.List<?> platforms = getPlatformBindings(userId);
  397. java.util.Map<String, Object> result = new java.util.LinkedHashMap<>(toMap(userInfo));
  398. result.put("gate", gate);
  399. result.put("quota", quota);
  400. result.put("platformBindings", platforms);
  401. return result;
  402. }
  403. /**
  404. * 首页概览统计 — 新增
  405. */
  406. public DashboardResponse getDashboard(Long userId) {
  407. // 今日查询次数
  408. int todayQueryCount = crawlerUsageLogMapper.sumTodayUsage(userId);
  409. // 累计查询次数
  410. int totalQueryCount = crawlerUsageLogMapper.sumTotalUsage(userId);
  411. // 最近查询摘要(最多 3 条)
  412. java.util.List<DashboardResponse.RecentQueryItem> recent =
  413. searchRecordMapper.selectRecentByUserId(userId, 3)
  414. .stream().map(r -> DashboardResponse.RecentQueryItem.builder()
  415. .drugName(r.getDrugName())
  416. .searchedAt(r.getSearchedAt() != null ? r.getSearchedAt().toString() : null)
  417. .lowestPrice(r.getQuoteCount() > 0 ? "有报价" : null)
  418. .build())
  419. .toList();
  420. return DashboardResponse.builder()
  421. .todayQueryCount(todayQueryCount)
  422. .todayPurchaseIntentCount(
  423. purchaseIntentMapper.countTodayByUserId(userId, java.time.LocalDate.now()))
  424. .totalQueryCount(totalQueryCount)
  425. .recentQueries(recent)
  426. .build();
  427. }
  428. /**
  429. * 登出 — 新增,清除 refresh token
  430. */
  431. @Transactional
  432. public void logout(Long userId) {
  433. User user = new User();
  434. user.setId(userId);
  435. user.setRefreshTokenHash(null);
  436. user.setRefreshTokenStatus(null);
  437. userMapper.updateById(user);
  438. log.info("用户 {} 已登出", userId);
  439. }
  440. /**
  441. * 独立注册(小程序填写药店信息后调用) — 新增
  442. */
  443. @Transactional
  444. public LoginResponse register(RegisterRequest request, String ip) {
  445. // 1. 验证临时 Token
  446. io.jsonwebtoken.Claims claims = jwtUtil.parseToken(request.getTempToken());
  447. if (!"temp".equals(claims.get("type", String.class))) {
  448. throw new BusinessException(ErrorCode.TOKEN_INVALID);
  449. }
  450. String openId = claims.get("openId", String.class);
  451. String unionId = claims.get("unionId", String.class);
  452. // 2. 查找小程序用户(由 miniappUnifiedLogin 创建的未完成注册的用户)
  453. User user = userMapper.selectOne(
  454. new LambdaQueryWrapper<User>().eq(User::getWechatMiniOpenId, openId));
  455. if (user == null) {
  456. // fallback: try unionId
  457. if (unionId != null) {
  458. user = userMapper.selectOne(
  459. new LambdaQueryWrapper<User>().eq(User::getWechatUnionId, unionId));
  460. }
  461. }
  462. if (user == null) {
  463. throw new BusinessException(ErrorCode.USER_NOT_FOUND, "请先通过小程序授权登录");
  464. }
  465. // 3. 补全药店信息
  466. user.setPharmacyName(request.getPharmacyName());
  467. user.setProvince(request.getProvince());
  468. user.setCity(request.getCity());
  469. user.setDistrict(request.getDistrict());
  470. user.setLastLoginTime(LocalDateTime.now());
  471. user.setLastLoginIp(ip);
  472. user.setLoginSource("MINIAPP");
  473. userMapper.updateById(user);
  474. // 4. 发布注册事件(处理邀请码)
  475. if (request.getInviteCode() != null) {
  476. eventPublisher.publishEvent(
  477. new UserRegisteredEvent(this, user.getId(), request.getInviteCode()));
  478. }
  479. // 5. 签发正式 Token
  480. LoginResponse response = buildMiniAppLoginResponse(user);
  481. auditLogService.logSuccess(user.getId(), user.getNickname(), OperatorRole.USER.getCode(),
  482. OperationType.USER_REGISTER, "USER:" + user.getId(), user.getId());
  483. return response;
  484. }
  485. // ====================================
  486. // 聚合查询辅助方法
  487. // ====================================
  488. /**
  489. * 第三方信息同步(独立于登录,通过手机号定位用户,更新用户信息 + 入驻信息)
  490. */
  491. @Transactional
  492. public void syncExternalInfo(SyncInfoRequest request) {
  493. User user = userMapper.selectOne(
  494. new LambdaQueryWrapper<User>().eq(User::getPhone, request.getPhone()));
  495. if (user == null) {
  496. throw new BusinessException(ErrorCode.USER_NOT_FOUND);
  497. }
  498. // 更新用户信息字段(非空覆盖)
  499. if (request.getUsername() != null && !request.getUsername().isBlank()) {
  500. user.setUserName(request.getUsername());
  501. }
  502. if (request.getNickname() != null && !request.getNickname().isBlank()) {
  503. user.setNickname(request.getNickname());
  504. } else if (request.getUsername() != null && !request.getUsername().isBlank()) {
  505. user.setNickname(request.getUsername());
  506. }
  507. if (request.getPharmacyName() != null && !request.getPharmacyName().isBlank()) {
  508. user.setPharmacyName(request.getPharmacyName());
  509. }
  510. if (request.getProvince() != null && !request.getProvince().isBlank()) {
  511. user.setProvince(request.getProvince());
  512. }
  513. if (request.getCity() != null && !request.getCity().isBlank()) {
  514. user.setCity(request.getCity());
  515. }
  516. if (request.getDistrict() != null && !request.getDistrict().isBlank()) {
  517. user.setDistrict(request.getDistrict());
  518. }
  519. if (request.getPharmacyAddress() != null && !request.getPharmacyAddress().isBlank()) {
  520. user.setPharmacyAddress(request.getPharmacyAddress());
  521. }
  522. if (request.getContactPerson() != null && !request.getContactPerson().isBlank()) {
  523. user.setContactPerson(request.getContactPerson());
  524. }
  525. if (request.getContactPhone() != null && !request.getContactPhone().isBlank()) {
  526. user.setContactPhone(request.getContactPhone());
  527. }
  528. if (request.getBusinessLicenseNo() != null && !request.getBusinessLicenseNo().isBlank()) {
  529. user.setBusinessLicenseNo(request.getBusinessLicenseNo());
  530. }
  531. if (request.getOpenId() != null && !request.getOpenId().isBlank()) {
  532. user.setWechatMiniOpenId(request.getOpenId());
  533. }
  534. if (request.getUnionId() != null && !request.getUnionId().isBlank()) {
  535. user.setWechatUnionId(request.getUnionId());
  536. }
  537. userMapper.updateById(user);
  538. // 同步入驻信息到 t_business_license
  539. syncBusinessLicense(request, user.getId());
  540. log.info("第三方信息同步完成: phone={}, userId={}", request.getPhone(), user.getId());
  541. }
  542. /**
  543. * 第三方同步用户店铺信息(无鉴权,按手机号自动创建/更新用户,仅返回同步确认)
  544. * <p>对应接口:POST /api/sync_shop_resource/report_user
  545. */
  546. @Transactional
  547. public SyncShopReportResponse reportUser(SyncShopReportRequest request) {
  548. String phone = request.getPhone();
  549. if (phone == null || phone.isBlank()) {
  550. throw new BusinessException(ErrorCode.PARAM_ERROR, "手机号不能为空");
  551. }
  552. // 1. 查找或创建用户
  553. User user = userMapper.selectOne(
  554. new LambdaQueryWrapper<User>().eq(User::getPhone, phone));
  555. boolean isNewUser = false;
  556. if (user == null) {
  557. isNewUser = true;
  558. user = User.builder()
  559. .phone(phone)
  560. .nickname("用户" + phone.substring(phone.length() - 4))
  561. .membershipLevel(MembershipLevel.PLUS)
  562. .loginSource("MINIAPP")
  563. .status(1)
  564. .build();
  565. userMapper.insert(user);
  566. membershipService.initPlusMembership(user.getId());
  567. }
  568. // 2. 映射第三方参数 → 更新用户信息(非空覆盖)
  569. updateUserFromShopReport(user, request);
  570. userMapper.updateById(user);
  571. // 3. 同步入驻信息
  572. syncBusinessLicenseFromShopReport(request, user.getId());
  573. log.info("第三方店铺同步完成: phone={}, userId={}, newUser={}", phone, user.getId(), isNewUser);
  574. return SyncShopReportResponse.builder()
  575. .userId(String.valueOf(user.getId()))
  576. .newUser(isNewUser)
  577. .build();
  578. }
  579. /** 映射第三方 shop_type → 内部 terminalType */
  580. private static String mapShopType(String shopType) {
  581. if (shopType == null) {
  582. return null;
  583. }
  584. return switch (shopType) {
  585. case "1" -> "SINGLE";
  586. case "2" -> "CHAIN";
  587. case "3" -> "CLINIC";
  588. default -> shopType;
  589. };
  590. }
  591. /** 映射第三方 status → int */
  592. private static int mapStatus(String status) {
  593. if (status == null || status.isBlank()) {
  594. return 0;
  595. }
  596. try {
  597. return Integer.parseInt(status);
  598. } catch (NumberFormatException e) {
  599. return 0;
  600. }
  601. }
  602. /** 更新用户信息字段 */
  603. private void updateUserFromShopReport(User user, SyncShopReportRequest req) {
  604. if (isNotBlank(req.getContactName())) {
  605. user.setNickname(req.getContactName());
  606. }
  607. if (isNotBlank(req.getContactShop())) {
  608. user.setPharmacyName(req.getContactShop());
  609. }
  610. if (isNotBlank(req.getContactProvince())) {
  611. user.setProvince(req.getContactProvince());
  612. }
  613. if (isNotBlank(req.getContactCity())) {
  614. user.setCity(req.getContactCity());
  615. }
  616. if (isNotBlank(req.getContactArea())) {
  617. user.setDistrict(req.getContactArea());
  618. }
  619. if (isNotBlank(req.getContactAddr())) {
  620. user.setPharmacyAddress(req.getContactAddr());
  621. }
  622. if (isNotBlank(req.getContactPhone())) {
  623. user.setContactPhone(req.getContactPhone());
  624. }
  625. }
  626. /** 同步入驻信息到 t_business_license */
  627. private void syncBusinessLicenseFromShopReport(SyncShopReportRequest req, Long userId) {
  628. String storeName = isNotBlank(req.getContactShop()) ? req.getContactShop() : null;
  629. String terminalType = mapShopType(req.getShopType());
  630. int status = mapStatus(req.getStatus());
  631. boolean hasLicenseField = isNotBlank(storeName)
  632. || isNotBlank(terminalType)
  633. || isNotBlank(req.getBusinessLicenseImage())
  634. || isNotBlank(req.getDrugBusinessLicenseImage())
  635. || isNotBlank(req.getTwoMedicalDeviceRegistration())
  636. || isNotBlank(req.getThreeMedicalDeviceBusinessLicense());
  637. if (!hasLicenseField) {
  638. return;
  639. }
  640. String reviewStatus;
  641. if (status == 0) {
  642. reviewStatus = LicenseStatus.PENDING.name();
  643. } else if (status == 1) {
  644. reviewStatus = LicenseStatus.APPROVED.name();
  645. } else {
  646. reviewStatus = LicenseStatus.REJECTED.name();
  647. }
  648. com.xuekairui.user.entity.BusinessLicense existing =
  649. businessLicenseService.getByUserId(userId);
  650. if (existing == null) {
  651. com.xuekairui.user.entity.BusinessLicense license =
  652. com.xuekairui.user.entity.BusinessLicense.builder()
  653. .userId(userId)
  654. .storeName(storeName)
  655. .terminalType(terminalType)
  656. .province(req.getContactProvince())
  657. .city(req.getContactCity())
  658. .district(req.getContactArea())
  659. .storeAddress(req.getContactAddr())
  660. .contactPerson(req.getContactName())
  661. .contactPhone(req.getContactPhone())
  662. .licenseImageUrl(req.getBusinessLicenseImage())
  663. .drugLicenseUrl(req.getDrugBusinessLicenseImage())
  664. .medicalDeviceClass2Url(req.getTwoMedicalDeviceRegistration())
  665. .medicalDeviceClass3Url(req.getThreeMedicalDeviceBusinessLicense())
  666. .reviewStatus(reviewStatus)
  667. .showVerifiedBadge(false)
  668. .syncSource("MINIAPP_SYNC")
  669. .build();
  670. businessLicenseService.insert(license);
  671. log.info("第三方店铺同步创建入驻记录: userId={}, licenseId={}, status={}",
  672. userId, license.getId(), reviewStatus);
  673. if (LicenseStatus.APPROVED.name().equals(reviewStatus)) {
  674. grantLicenseApprovedReward(userId, license.getId());
  675. }
  676. } else {
  677. // 已存在记录:保存第三方变更的入驻信息到数据库
  678. // 尤其是双方都审核通过(APPROVED)的情况,需要记录第三方变更的字段
  679. boolean upgradedToApproved = false;
  680. boolean wasApproved = LicenseStatus.APPROVED.name().equals(existing.getReviewStatus());
  681. // 更新审核状态
  682. if (!wasApproved) {
  683. // 我方未审核通过:允许第三方直接覆盖本地
  684. existing.setReviewStatus(reviewStatus);
  685. upgradedToApproved = LicenseStatus.APPROVED.name().equals(reviewStatus);
  686. applyThirdPartyLicenseFields(existing, req, storeName, terminalType);
  687. } else if (LicenseStatus.APPROVED.name().equals(reviewStatus)) {
  688. // 双方都审核通过:检测字段是否不一致
  689. // 不直接覆盖本地,而是保存差异记录,等待运营人工确认
  690. Map<String, String[]> diff = detectLicenseDiff(existing, req, storeName, terminalType);
  691. if (!diff.isEmpty()) {
  692. log.info("第三方与本地入驻信息存在差异(双方均已审核通过),保存差异记录待运营确认: userId={}, licenseId={}, diffFields={}",
  693. userId, existing.getId(), diff.keySet());
  694. saveLicenseSyncDiffRecords(userId, existing.getId(), diff);
  695. recordThirdPartyUpdateAudit(req.getPhone(), userId, existing.getId(), diff);
  696. }
  697. } else {
  698. // 本地已审核通过,第三方变为非通过状态:仅更新状态,保留本地字段
  699. existing.setReviewStatus(reviewStatus);
  700. }
  701. businessLicenseService.updateById(existing);
  702. log.info("第三方店铺同步已存在记录: licenseId={}, userId={}, wasApproved={}, thirdStatus={}, upgradedToApproved={}",
  703. existing.getId(), userId, wasApproved, reviewStatus, upgradedToApproved);
  704. if (upgradedToApproved) {
  705. grantLicenseApprovedReward(userId, existing.getId());
  706. }
  707. }
  708. }
  709. /**
  710. * 将双方都审核通过时的字段差异保存到 t_license_sync_diff 表,等待运营确认
  711. */
  712. private void saveLicenseSyncDiffRecords(Long userId, Long licenseId, Map<String, String[]> diff) {
  713. try {
  714. diff.forEach((field, values) -> {
  715. com.xuekairui.user.entity.LicenseSyncDiff record =
  716. com.xuekairui.user.entity.LicenseSyncDiff.builder()
  717. .licenseId(licenseId)
  718. .userId(userId)
  719. .fieldName(field)
  720. .localValue(values[0])
  721. .thirdPartyValue(values[1])
  722. .status(0)
  723. .build();
  724. licenseSyncDiffMapper.insert(record);
  725. });
  726. log.info("保存入驻信息差异记录成功: licenseId={}, count={}", licenseId, diff.size());
  727. } catch (Exception e) {
  728. log.warn("保存入驻信息差异记录失败(不阻断同步): licenseId={}, error={}", licenseId, e.getMessage());
  729. }
  730. }
  731. /**
  732. * 将第三方传入的入驻字段覆盖到本地记录(非空即覆盖)
  733. */
  734. private void applyThirdPartyLicenseFields(
  735. com.xuekairui.user.entity.BusinessLicense existing,
  736. SyncShopReportRequest req, String storeName, String terminalType) {
  737. if (storeName != null) {
  738. existing.setStoreName(storeName);
  739. }
  740. if (terminalType != null) {
  741. existing.setTerminalType(terminalType);
  742. }
  743. if (isNotBlank(req.getContactProvince())) {
  744. existing.setProvince(req.getContactProvince());
  745. }
  746. if (isNotBlank(req.getContactCity())) {
  747. existing.setCity(req.getContactCity());
  748. }
  749. if (isNotBlank(req.getContactArea())) {
  750. existing.setDistrict(req.getContactArea());
  751. }
  752. if (isNotBlank(req.getContactAddr())) {
  753. existing.setStoreAddress(req.getContactAddr());
  754. }
  755. if (isNotBlank(req.getContactName())) {
  756. existing.setContactPerson(req.getContactName());
  757. }
  758. if (isNotBlank(req.getContactPhone())) {
  759. existing.setContactPhone(req.getContactPhone());
  760. }
  761. if (isNotBlank(req.getBusinessLicenseImage())) {
  762. existing.setLicenseImageUrl(req.getBusinessLicenseImage());
  763. }
  764. if (isNotBlank(req.getDrugBusinessLicenseImage())) {
  765. existing.setDrugLicenseUrl(req.getDrugBusinessLicenseImage());
  766. }
  767. if (isNotBlank(req.getTwoMedicalDeviceRegistration())) {
  768. existing.setMedicalDeviceClass2Url(req.getTwoMedicalDeviceRegistration());
  769. }
  770. if (isNotBlank(req.getThreeMedicalDeviceBusinessLicense())) {
  771. existing.setMedicalDeviceClass3Url(req.getThreeMedicalDeviceBusinessLicense());
  772. }
  773. }
  774. /**
  775. * 检测本地入驻信息与第三方传入字段的不一致项
  776. * <p>返回 Map:key=字段名,value=[本地值, 第三方值]
  777. */
  778. private Map<String, String[]> detectLicenseDiff(
  779. com.xuekairui.user.entity.BusinessLicense existing,
  780. SyncShopReportRequest req, String storeName, String terminalType) {
  781. Map<String, String[]> diff = new java.util.LinkedHashMap<>();
  782. if (storeName != null && !storeName.equals(existing.getStoreName())) {
  783. diff.put("storeName", new String[]{existing.getStoreName(), storeName});
  784. }
  785. if (terminalType != null && !terminalType.equals(existing.getTerminalType())) {
  786. diff.put("terminalType", new String[]{existing.getTerminalType(), terminalType});
  787. }
  788. putIfDiff(diff, "province", existing.getProvince(), req.getContactProvince());
  789. putIfDiff(diff, "city", existing.getCity(), req.getContactCity());
  790. putIfDiff(diff, "district", existing.getDistrict(), req.getContactArea());
  791. putIfDiff(diff, "storeAddress", existing.getStoreAddress(), req.getContactAddr());
  792. putIfDiff(diff, "contactPerson", existing.getContactPerson(), req.getContactName());
  793. putIfDiff(diff, "contactPhone", existing.getContactPhone(), req.getContactPhone());
  794. putIfDiff(diff, "licenseImageUrl", existing.getLicenseImageUrl(), req.getBusinessLicenseImage());
  795. putIfDiff(diff, "drugLicenseUrl", existing.getDrugLicenseUrl(), req.getDrugBusinessLicenseImage());
  796. putIfDiff(diff, "medicalDeviceClass2Url",
  797. existing.getMedicalDeviceClass2Url(), req.getTwoMedicalDeviceRegistration());
  798. putIfDiff(diff, "medicalDeviceClass3Url",
  799. existing.getMedicalDeviceClass3Url(), req.getThreeMedicalDeviceBusinessLicense());
  800. return diff;
  801. }
  802. private static void putIfDiff(Map<String, String[]> diff, String field, String local, String third) {
  803. if (third != null && !third.isBlank() && !third.equals(local)) {
  804. diff.put(field, new String[]{local, third});
  805. }
  806. }
  807. /**
  808. * 记录第三方变更入驻信息的审计日志
  809. * <p>
  810. * 第三方同步入驻信息时,我们不对对方谁操作做验证,
  811. * 只通过对方传过来的用户手机号判断变更了谁的信息。
  812. * 审计日志中记录:被变更用户的手机号、用户ID、变更前后的字段值。
  813. *
  814. * @param phone 第三方传入的用户手机号(用于识别被变更的用户)
  815. * @param userId 被变更的用户ID
  816. * @param licenseId 入驻信息ID
  817. * @param diff 变更字段差异(key=字段名,value=[本地值, 第三方值])
  818. */
  819. private void recordThirdPartyUpdateAudit(String phone, Long userId, Long licenseId, Map<String, String[]> diff) {
  820. try {
  821. Map<String, Object> beforeData = new java.util.LinkedHashMap<>();
  822. Map<String, Object> afterData = new java.util.LinkedHashMap<>();
  823. diff.forEach((field, values) -> {
  824. beforeData.put(field, values[0]);
  825. afterData.put(field, values[1]);
  826. });
  827. // 目标标识:用户手机号 + 用户ID,便于审计时定位"变更了谁的信息"
  828. String targetIdentifier = String.format("PHONE:%s|USER:%d|LICENSE:%d",
  829. phone != null ? phone : "UNKNOWN", userId, licenseId);
  830. auditLogService.logSuccess(
  831. 0L,
  832. OperatorRole.SYSTEM,
  833. OperationType.LICENSE_THIRD_UPDATE,
  834. targetIdentifier,
  835. licenseId,
  836. beforeData,
  837. afterData,
  838. String.format("第三方变更入驻信息(双方均已审核通过,待运营确认):用户手机号 %s(ID=%d),共 %d 个字段不一致: %s",
  839. phone != null ? phone : "UNKNOWN", userId, diff.size(), diff.keySet()),
  840. OperationSource.SYSTEM_SYNC);
  841. } catch (Exception e) {
  842. log.warn("记录第三方变更入驻信息审计日志失败: licenseId={}, error={}", licenseId, e.getMessage());
  843. }
  844. }
  845. /**
  846. * 第三方登录接口同步入驻信息(适配 ExternalTokenRequest → SyncInfoRequest)
  847. */
  848. private void syncBusinessLicenseFromTokenRequest(ExternalTokenRequest req, Long userId) {
  849. SyncInfoRequest syncReq = new SyncInfoRequest();
  850. syncReq.setStoreName(req.getStoreName());
  851. syncReq.setPharmacyName(req.getPharmacyName());
  852. syncReq.setTerminalType(req.getTerminalType());
  853. syncReq.setBusinessLicenseUrl(req.getBusinessLicenseUrl());
  854. syncReq.setDrugLicenseUrl(req.getDrugLicenseUrl());
  855. syncReq.setMedicalDeviceClass2Url(req.getMedicalDeviceClass2Url());
  856. syncReq.setMedicalDeviceClass3Url(req.getMedicalDeviceClass3Url());
  857. syncReq.setProvince(req.getProvince());
  858. syncReq.setCity(req.getCity());
  859. syncReq.setDistrict(req.getDistrict());
  860. syncReq.setPharmacyAddress(req.getPharmacyAddress());
  861. syncReq.setContactPerson(req.getContactPerson());
  862. syncReq.setContactPhone(req.getContactPhone());
  863. syncReq.setBusinessLicenseNo(req.getBusinessLicenseNo());
  864. syncReq.setStatus(req.getStatus());
  865. syncBusinessLicense(syncReq, userId);
  866. }
  867. private void syncBusinessLicense(SyncInfoRequest request, Long userId) {
  868. String storeName = (request.getStoreName() != null && !request.getStoreName().isBlank())
  869. ? request.getStoreName() : request.getPharmacyName();
  870. boolean hasLicenseField = isNotBlank(storeName)
  871. || isNotBlank(request.getTerminalType())
  872. || isNotBlank(request.getBusinessLicenseUrl())
  873. || isNotBlank(request.getDrugLicenseUrl())
  874. || isNotBlank(request.getMedicalDeviceClass2Url())
  875. || isNotBlank(request.getMedicalDeviceClass3Url());
  876. if (!hasLicenseField) {
  877. return;
  878. }
  879. com.xuekairui.user.entity.BusinessLicense existing =
  880. businessLicenseService.getByUserId(userId);
  881. // 审核状态:0-审核中→PENDING / 1-审核成功→APPROVED / 2-审核失败→REJECTED(不传默认0)
  882. Integer status = request.getStatus();
  883. String reviewStatus;
  884. if (status == null || status == 0) {
  885. reviewStatus = LicenseStatus.PENDING.name();
  886. } else if (status == 1) {
  887. reviewStatus = LicenseStatus.APPROVED.name();
  888. } else {
  889. reviewStatus = LicenseStatus.REJECTED.name();
  890. }
  891. if (existing == null) {
  892. com.xuekairui.user.entity.BusinessLicense license =
  893. com.xuekairui.user.entity.BusinessLicense.builder()
  894. .userId(userId)
  895. .storeName(storeName)
  896. .terminalType(request.getTerminalType())
  897. .province(request.getProvince())
  898. .city(request.getCity())
  899. .district(request.getDistrict())
  900. .storeAddress(request.getPharmacyAddress())
  901. .contactPerson(request.getContactPerson())
  902. .contactPhone(request.getContactPhone())
  903. .licenseImageUrl(request.getBusinessLicenseUrl())
  904. .drugLicenseUrl(request.getDrugLicenseUrl())
  905. .creditCode(request.getBusinessLicenseNo())
  906. .medicalDeviceClass2Url(request.getMedicalDeviceClass2Url())
  907. .medicalDeviceClass3Url(request.getMedicalDeviceClass3Url())
  908. .reviewStatus(reviewStatus)
  909. .showVerifiedBadge(false)
  910. .syncSource("MINIAPP_SYNC")
  911. .build();
  912. businessLicenseService.insert(license);
  913. log.info("第三方用户{}同步入驻信息,创建新记录: licenseId={}, status={}",
  914. userId, license.getId(), reviewStatus);
  915. // 第三方已审核通过:赠送30天高级会员 + 推送Redis事件
  916. if (LicenseStatus.APPROVED.name().equals(reviewStatus)) {
  917. grantLicenseApprovedReward(userId, license.getId());
  918. }
  919. } else {
  920. // 已存在记录:仅允许更新审核状态,其他字段保留原值(后续由运营端确认更新)
  921. boolean upgradedToApproved = false;
  922. if (!LicenseStatus.APPROVED.name().equals(existing.getReviewStatus())) {
  923. existing.setReviewStatus(reviewStatus);
  924. upgradedToApproved = LicenseStatus.APPROVED.name().equals(reviewStatus);
  925. }
  926. businessLicenseService.updateById(existing);
  927. log.info("第三方用户{}重新同步入驻信息,已存在记录仅更新状态: licenseId={}, oldStatus={}, newStatus={}",
  928. userId, existing.getId(), existing.getReviewStatus(), reviewStatus);
  929. // 从非APPROVED升级到APPROVED:赠送30天高级会员 + 推送Redis事件
  930. if (upgradedToApproved) {
  931. grantLicenseApprovedReward(userId, existing.getId());
  932. }
  933. }
  934. }
  935. private boolean isNotBlank(String s) {
  936. return s != null && !s.isBlank();
  937. }
  938. /**
  939. * 首次入驻审核通过:赠送30天高级会员 + 推送Redis事件供活动模块消费
  940. * <p>与 BusinessLicenseService.approve() 的奖励逻辑保持一致。
  941. */
  942. private void grantLicenseApprovedReward(Long userId, Long licenseId) {
  943. membershipService.grantOrExtendMembership(
  944. userId,
  945. MembershipLevel.PRO,
  946. 30,
  947. "LICENSE",
  948. "入驻信息审核通过赠送30天高级会员");
  949. log.info("入驻信息审核通过(第三方同步),赠送用户{} 30天高级会员", userId);
  950. // 事务提交后推送 Redis 事件,供活动模块消费(邀请有礼等)
  951. TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
  952. @Override
  953. public void afterCommit() {
  954. try {
  955. activityEventRedisService.pushLicenseApproved(userId, licenseId, "BUSINESS_LICENSE");
  956. } catch (Exception e) {
  957. log.warn("存入入驻审核通过事件失败,不影响主流程: userId={}", userId, e);
  958. }
  959. }
  960. });
  961. }
  962. private UserGateStatus buildGateStatus(Long userId) {
  963. // 执照审核状态
  964. com.xuekairui.user.entity.BusinessLicense license =
  965. businessLicenseService.getByUserId(userId);
  966. String licenseStatus = license != null ? license.getReviewStatus() : LicenseStatus.NOT_SUBMITTED.name();
  967. // 已绑定平台数
  968. Long boundCount = platformAccountMapper.selectCount(
  969. new LambdaQueryWrapper<com.xuekairui.user.entity.PlatformAccount>()
  970. .eq(com.xuekairui.user.entity.PlatformAccount::getUserId, userId)
  971. .eq(com.xuekairui.user.entity.PlatformAccount::getEnabled, true));
  972. boolean canQuery = LicenseStatus.APPROVED.name().equals(licenseStatus) && boundCount >= 1;
  973. java.util.List<String> messages = new java.util.ArrayList<>();
  974. if (!LicenseStatus.APPROVED.name().equals(licenseStatus)) {
  975. messages.add("请先提交入驻信息并通过审核");
  976. }
  977. if (boundCount == 0) {
  978. messages.add("请至少绑定1个B2B平台账号");
  979. }
  980. return UserGateStatus.builder()
  981. .canQueryOnDesktop(canQuery)
  982. .licenseStatus(licenseStatus)
  983. .platformBoundCount(boundCount.intValue())
  984. .gateMessages(messages)
  985. .build();
  986. }
  987. private java.util.Map<String, Object> buildQuotaSummary(Long userId) {
  988. // 今日已用 + 每日上限
  989. int dailyUsed = crawlerUsageLogMapper.sumTodayUsage(userId);
  990. java.util.Map<String, Object> quota = new java.util.LinkedHashMap<>();
  991. quota.put("dailyQueryUsed", dailyUsed);
  992. quota.put("dailyQueryLimit", 20); // TODO: 从会员等级动态获取
  993. return quota;
  994. }
  995. private java.util.List<java.util.Map<String, Object>> getPlatformBindings(Long userId) {
  996. java.util.List<com.xuekairui.user.entity.PlatformAccount> accounts =
  997. platformAccountMapper.selectList(
  998. new LambdaQueryWrapper<com.xuekairui.user.entity.PlatformAccount>()
  999. .eq(com.xuekairui.user.entity.PlatformAccount::getUserId, userId));
  1000. return accounts.stream().map(a -> {
  1001. java.util.Map<String, Object> m = new java.util.LinkedHashMap<>();
  1002. m.put("platformCode", a.getPlatformCode());
  1003. m.put("platformName", a.getPlatformName());
  1004. m.put("status", Boolean.TRUE.equals(a.getEnabled()) ? "bound" : "disabled");
  1005. m.put("lastVerifyTime", a.getLastVerifyTime());
  1006. return m;
  1007. }).toList();
  1008. }
  1009. private java.util.Map<String, Object> toMap(UserInfoResponse info) {
  1010. // 简单反射转 Map,避免引入额外依赖
  1011. java.util.Map<String, Object> map = new java.util.LinkedHashMap<>();
  1012. map.put("id", info.getId());
  1013. map.put("phone", info.getPhone());
  1014. map.put("nickname", info.getNickname());
  1015. map.put("avatar", info.getAvatar());
  1016. map.put("wechatBound", info.getWechatBound());
  1017. map.put("createTime", info.getCreateTime());
  1018. map.put("loginSource", info.getLoginSource());
  1019. map.put("pharmacyName", info.getPharmacyName());
  1020. map.put("province", info.getProvince());
  1021. map.put("city", info.getCity());
  1022. map.put("district", info.getDistrict());
  1023. map.put("role", info.getRole());
  1024. map.put("membershipLevel", info.getMembershipLevel());
  1025. map.put("membershipLevelName", info.getMembershipLevelName());
  1026. map.put("inviterId", info.getInviterId());
  1027. map.put("inviterNickname", info.getInviterNickname());
  1028. map.put("hasBoundInviteCode", info.getHasBoundInviteCode());
  1029. map.put("licenseStatus", info.getLicenseStatus());
  1030. map.put("levelCode", info.getLevelCode());
  1031. map.put("version", info.getVersion());
  1032. return map;
  1033. }
  1034. /**
  1035. * Refresh Token 旋转 + 重用检测
  1036. * <p>
  1037. * 每次刷新:旧 Refresh Token 标记为 USED,签发新的一对 Token。
  1038. * 如果检测到已 USED 的 Refresh Token 被再次使用 → 说明被盗 → 踢出所有设备。
  1039. */
  1040. @Transactional
  1041. public LoginResponse refreshToken(String refreshToken) {
  1042. // 1. 验证JWT格式
  1043. String tokenType = jwtUtil.getTokenType(refreshToken);
  1044. if (!"refresh".equals(tokenType)) {
  1045. throw new BusinessException(ErrorCode.REFRESH_TOKEN_INVALID);
  1046. }
  1047. Long userId = jwtUtil.getUserId(refreshToken);
  1048. // 只查 Refresh Token 校验所需的字段,减少DB开销
  1049. User user = userMapper.selectOne(
  1050. new LambdaQueryWrapper<User>()
  1051. .select(User::getId, User::getPhone,
  1052. User::getTokenVersion, User::getRefreshTokenHash,
  1053. User::getRefreshTokenStatus, User::getStatus)
  1054. .eq(User::getId, userId));
  1055. if (user == null) {
  1056. throw new BusinessException(ErrorCode.USER_NOT_FOUND);
  1057. }
  1058. // 2. 计算哈希,比对DB中的记录
  1059. String tokenHash = JwtUtil.hashToken(refreshToken);
  1060. String storedHash = user.getRefreshTokenHash();
  1061. String storedStatus = user.getRefreshTokenStatus();
  1062. // 3. 重用检测:Refresh Token 已被使用过 → 被盗!
  1063. if (tokenHash.equals(storedHash) && "USED".equals(storedStatus)) {
  1064. log.warn("用户 {} 的Refresh Token被重用(疑似被盗),踢出所有设备", userId);
  1065. // 递增 tokenVersion,使所有旧 access token 立即失效
  1066. long newVersion = (user.getTokenVersion() != null ? user.getTokenVersion() : 0L) + 1;
  1067. user.setTokenVersion(newVersion);
  1068. user.setRefreshTokenHash(null);
  1069. user.setRefreshTokenStatus(null);
  1070. userMapper.updateById(user);
  1071. tokenVersionCache.putVersion(userId, false, newVersion);
  1072. throw new BusinessException(ErrorCode.TOKEN_KICKED);
  1073. }
  1074. // 4. 校验:哈希不匹配 → 无效或过期的 Refresh Token
  1075. if (!tokenHash.equals(storedHash) || !"ACTIVE".equals(storedStatus)) {
  1076. throw new BusinessException(ErrorCode.REFRESH_TOKEN_INVALID);
  1077. }
  1078. // 5. 旋转:标记旧 Refresh Token 为 USED,签发新的一对
  1079. user.setRefreshTokenStatus("USED");
  1080. String roleCode = "USER";
  1081. long currentVersion = user.getTokenVersion() != null ? user.getTokenVersion() : 0L;
  1082. String loginSource = jwtUtil.getLoginSource(refreshToken);
  1083. String newAccessToken = jwtUtil.generateAccessToken(
  1084. user.getId(), roleCode, loginSource, currentVersion);
  1085. String newRefreshToken = jwtUtil.generateRefreshToken(user.getId(), loginSource);
  1086. // 存储新 Refresh Token 哈希
  1087. user.setRefreshTokenHash(JwtUtil.hashToken(newRefreshToken));
  1088. user.setRefreshTokenStatus("ACTIVE");
  1089. userMapper.updateById(user);
  1090. log.debug("用户 {} Refresh Token 旋转成功", userId);
  1091. return LoginResponse.builder()
  1092. .userId(user.getId())
  1093. .accessToken(newAccessToken)
  1094. .refreshToken(newRefreshToken)
  1095. .expiresIn(jwtUtil.getExpirationInSeconds(roleCode, loginSource))
  1096. .userInfo(convertToUserInfoResponse(user))
  1097. .needBindPhone(false)
  1098. .newUser(false)
  1099. .role(roleCode)
  1100. .build();
  1101. }
  1102. /** PC 端构建登录响应:
  1103. * 递增 tokenVersion,踢掉旧 PC,正常刷新 token */
  1104. private LoginResponse buildLoginResponse(User user) {
  1105. return buildLoginResponseInternal(user, true, "PC", false);
  1106. }
  1107. /** 小程序构建登录响应:不递增 tokenVersion,不踢 PC,正常刷新 token */
  1108. private LoginResponse buildMiniAppLoginResponse(User user) {
  1109. return buildLoginResponseInternal(user, false, "MINIAPP", false);
  1110. }
  1111. /**
  1112. * 构建登录响应(内部通用方法)
  1113. * @param skipRefreshToken 不生成/保存新 refreshToken(仅返回 accessToken)
  1114. */
  1115. private LoginResponse buildLoginResponseInternal(User user, boolean incrementVersion,
  1116. String loginSource, boolean skipRefreshToken) {
  1117. MDC.put("uid", String.valueOf(user.getId()));
  1118. String roleCode = "USER";
  1119. long version;
  1120. if (incrementVersion) {
  1121. long oldVersion = user.getTokenVersion() != null ? user.getTokenVersion() : 0L;
  1122. version = oldVersion + 1;
  1123. user.setTokenVersion(version);
  1124. tokenVersionCache.putVersion(user.getId(), false, version);
  1125. } else {
  1126. version = user.getTokenVersion() != null ? user.getTokenVersion() : 0L;
  1127. }
  1128. String accessToken = jwtUtil.generateAccessToken(
  1129. user.getId(), "USER", loginSource, version);
  1130. String refreshToken = null;
  1131. if (!skipRefreshToken) {
  1132. // 仅当无 ACTIVE 的 refreshToken 时才生成新的(首次登录),已有则复用
  1133. // 避免 PC/小程序两端登录互相覆盖 refreshToken 导致另一端无法刷新
  1134. if (user.getRefreshTokenHash() == null || !"ACTIVE".equals(user.getRefreshTokenStatus())) {
  1135. refreshToken = jwtUtil.generateRefreshToken(user.getId(), loginSource);
  1136. user.setRefreshTokenHash(JwtUtil.hashToken(refreshToken));
  1137. user.setRefreshTokenStatus("ACTIVE");
  1138. }
  1139. }
  1140. userMapper.updateById(user);
  1141. return LoginResponse.builder()
  1142. .userId(user.getId())
  1143. .accessToken(accessToken)
  1144. .refreshToken(refreshToken)
  1145. .expiresIn(jwtUtil.getExpirationInSeconds(roleCode, loginSource))
  1146. .userInfo(convertToUserInfoResponse(user))
  1147. .needBindPhone(false)
  1148. .newUser(false)
  1149. .role(roleCode)
  1150. .build();
  1151. }
  1152. /**
  1153. * 登录时检查入驻信息并同步到第三方(智价云药店版)
  1154. * <p>
  1155. * 仅在我方系统自行登录(短信/密码/微信/小程序)时触发,第三方登录不走此逻辑。
  1156. * 幂等策略:仅当入驻记录存在且 status != 1(未同步成功)时才补推一次,
  1157. * 避免每次登录都重复调用第三方接口。
  1158. * 同步在事务提交后异步执行,不阻塞登录响应,失败仅记录日志。
  1159. */
  1160. private void syncShopResourceOnLogin(Long userId) {
  1161. if (userId == null) {
  1162. return;
  1163. }
  1164. try {
  1165. com.xuekairui.user.entity.BusinessLicense license =
  1166. businessLicenseService.getByUserId(userId);
  1167. if (license == null) {
  1168. return;
  1169. }
  1170. // status=1 表示已同步成功,跳过;0=未同步,2=同步失败,需补推
  1171. if (license.getStatus() != null && license.getStatus() == 1) {
  1172. return;
  1173. }
  1174. User user = userMapper.selectById(userId);
  1175. if (user == null) {
  1176. return;
  1177. }
  1178. String phone = user.getPhone();
  1179. String userName = user.getNickname() != null
  1180. ? user.getNickname() : String.valueOf(userId);
  1181. String storeName = license.getStoreName() != null ? license.getStoreName() : "未知药店";
  1182. String targetIdentifier = String.format("PHONE:%s|USER:%d|LICENSE:%d",
  1183. phone != null ? phone : "UNKNOWN", userId, license.getId());
  1184. TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
  1185. @Override
  1186. public void afterCommit() {
  1187. try {
  1188. ShopResourceSyncService.SyncResult result =
  1189. shopResourceSyncService.syncShopResource(user, license);
  1190. if (result != null && result.getUserId() != null) {
  1191. license.setExternalId(result.getUserId());
  1192. license.setStatus(1);
  1193. businessLicenseService.updateById(license);
  1194. log.info("登录时同步入驻信息到第三方成功: userId={}, externalUserId={}",
  1195. userId, result.getUserId());
  1196. auditLogService.logSuccess(
  1197. 0L,
  1198. OperatorRole.SYSTEM,
  1199. OperationType.LICENSE_SYNC_THIRD,
  1200. targetIdentifier,
  1201. license.getId(),
  1202. null,
  1203. Map.of("userId", userId, "userName", userName, "storeName", storeName,
  1204. "phone", phone != null ? phone : "UNKNOWN",
  1205. "trigger", "LOGIN",
  1206. "externalUserId", result.getUserId(), "newUser", result.getNewUser()),
  1207. String.format("登录时同步客户「%s」(手机号:%s,药店:%s)的入驻信息到第三方成功,第三方用户ID:%s",
  1208. userName, phone != null ? phone : "UNKNOWN", storeName, result.getUserId()),
  1209. OperationSource.SYSTEM_SYNC);
  1210. } else {
  1211. license.setStatus(2);
  1212. businessLicenseService.updateById(license);
  1213. log.warn("登录时同步入驻信息到第三方未成功(未返回user_id): userId={}", userId);
  1214. auditLogService.logSuccess(
  1215. 0L,
  1216. OperatorRole.SYSTEM,
  1217. OperationType.LICENSE_SYNC_THIRD,
  1218. targetIdentifier,
  1219. license.getId(),
  1220. null,
  1221. Map.of("userId", userId, "userName", userName, "storeName", storeName,
  1222. "phone", phone != null ? phone : "UNKNOWN",
  1223. "trigger", "LOGIN", "result", "no_user_id"),
  1224. String.format("登录时同步客户「%s」(手机号:%s,药店:%s)的入驻信息到第三方未成功:第三方未返回用户ID",
  1225. userName, phone != null ? phone : "UNKNOWN", storeName),
  1226. OperationSource.SYSTEM_SYNC);
  1227. }
  1228. } catch (Exception e) {
  1229. log.warn("登录时同步入驻信息到第三方失败(不阻断登录): userId={}, error={}",
  1230. userId, e.getMessage());
  1231. auditLogService.logSuccess(
  1232. 0L,
  1233. OperatorRole.SYSTEM,
  1234. OperationType.LICENSE_SYNC_THIRD,
  1235. targetIdentifier,
  1236. license.getId(),
  1237. null,
  1238. Map.of("userId", userId, "userName", userName, "storeName", storeName,
  1239. "phone", phone != null ? phone : "UNKNOWN",
  1240. "trigger", "LOGIN", "error", e.getMessage()),
  1241. String.format("登录时同步客户「%s」(手机号:%s,药店:%s)的入驻信息到第三方失败:%s",
  1242. userName, phone != null ? phone : "UNKNOWN", storeName, e.getMessage()),
  1243. OperationSource.SYSTEM_SYNC);
  1244. }
  1245. }
  1246. });
  1247. } catch (Exception e) {
  1248. log.warn("登录时检查入驻信息失败(不阻断登录): userId={}, error={}", userId, e.getMessage());
  1249. }
  1250. }
  1251. /**
  1252. * 转换用户信息响应
  1253. */
  1254. private UserInfoResponse convertToUserInfoResponse(User user) {
  1255. UserLevelResponse levelInfo = null;
  1256. if (user != null && user.getId() != null) {
  1257. try {
  1258. levelInfo = membershipService.getUserLevel(user.getId());
  1259. } catch (Exception e) {
  1260. log.warn("获取用户等级信息失败: userId={}, error={}", user.getId(), e.getMessage());
  1261. }
  1262. }
  1263. // 营业执照审核状态
  1264. String licenseStatus = LicenseStatus.NOT_SUBMITTED.name();
  1265. try {
  1266. assert user != null;
  1267. com.xuekairui.user.entity.BusinessLicense license =
  1268. businessLicenseService.getByUserId(user.getId());
  1269. if (license != null && license.getReviewStatus() != null) {
  1270. licenseStatus = license.getReviewStatus();
  1271. }
  1272. } catch (Exception e) {
  1273. log.warn("查询营业执照状态失败: userId={}, error={}", user.getId(), e.getMessage());
  1274. }
  1275. // 邀请人信息
  1276. Long inviterId = user.getInviterId();
  1277. String inviterNickname = null;
  1278. if (inviterId != null) {
  1279. User inviter = userMapper.selectById(inviterId);
  1280. if (inviter != null) {
  1281. inviterNickname = inviter.getWechatNickname() != null ? inviter.getWechatNickname()
  1282. : inviter.getNickname() != null ? inviter.getNickname()
  1283. : "一位用户";
  1284. }
  1285. }
  1286. return UserInfoResponse.builder()
  1287. .id(user.getId())
  1288. .phone(ValidateUtil.maskPhone(user.getPhone()))
  1289. .nickname(user.getNickname())
  1290. .userName(user.getUserName())
  1291. .avatar(user.getAvatar())
  1292. .wechatBound(user.getWechatOpenId() != null)
  1293. .levelInfo(levelInfo)
  1294. .createTime(user.getCreateTime())
  1295. .loginSource(user.getLoginSource() != null ? user.getLoginSource() : "UNKNOWN")
  1296. .pharmacyName(user.getPharmacyName())
  1297. .province(user.getProvince())
  1298. .city(user.getCity())
  1299. .district(user.getDistrict())
  1300. .role("USER")
  1301. .membershipLevel(levelInfo != null ? levelInfo.getLevelCode() : "PLUS")
  1302. .membershipLevelName(levelInfo != null ? levelInfo.getLevelName() : "普通会员")
  1303. .inviterId(inviterId)
  1304. .inviterNickname(inviterNickname)
  1305. .hasBoundInviteCode(inviterId != null)
  1306. .licenseStatus(licenseStatus)
  1307. .build();
  1308. }
  1309. /**
  1310. * 密码登录
  1311. * 支持手机号或用户名登录:phone 和 username 至少填一个
  1312. */
  1313. @Transactional
  1314. public LoginResponse passwordLogin(PasswordLoginRequest request, String ip) {
  1315. // 1. 校验:phone 和 username 至少填一个
  1316. boolean hasPhone = request.getPhone() != null && !request.getPhone().isBlank();
  1317. boolean hasUsername = request.getUsername() != null && !request.getUsername().isBlank();
  1318. if (!hasPhone && !hasUsername) {
  1319. throw new BusinessException(ErrorCode.PARAM_ERROR, "手机号和用户名至少填写一个");
  1320. }
  1321. // 2. 查找用户(优先手机号,其次用户名/昵称)
  1322. User user;
  1323. if (hasPhone) {
  1324. user = userMapper.selectOne(
  1325. new LambdaQueryWrapper<User>().eq(User::getPhone, request.getPhone()));
  1326. } else {
  1327. user = userMapper.selectOne(
  1328. new LambdaQueryWrapper<User>().eq(User::getNickname, request.getUsername()));
  1329. }
  1330. if (user == null) {
  1331. return adminAuthService.passwordLogin(
  1332. hasPhone ? request.getPhone() : request.getUsername(),
  1333. request.getPassword(), ip);
  1334. }
  1335. // 2. 检查是否设置了密码
  1336. if (user.getPassword() == null) {
  1337. throw new BusinessException(ErrorCode.PASSWORD_NOT_SET);
  1338. }
  1339. // 3. 验证密码
  1340. if (!passwordUtil.matches(request.getPassword(), user.getPassword())) {
  1341. throw new BusinessException(ErrorCode.PASSWORD_INCORRECT);
  1342. }
  1343. // 4. 检查用户状态
  1344. if (user.getStatus() != null && user.getStatus() == 0) {
  1345. throw new BusinessException(ErrorCode.USER_DISABLED);
  1346. }
  1347. // 5. 更新登录信息
  1348. user.setLastLoginTime(LocalDateTime.now());
  1349. user.setLastLoginIp(ip);
  1350. user.setLoginSource("PC");
  1351. userMapper.updateById(user);
  1352. // 6. 生成Token
  1353. LoginResponse response = buildLoginResponse(user);
  1354. auditLogService.logSuccess(user.getId(), user.getNickname(), OperatorRole.USER.getCode(),
  1355. OperationType.USER_LOGIN, "USER:" + user.getId(), user.getId());
  1356. syncShopResourceOnLogin(user.getId());
  1357. return response;
  1358. }
  1359. /**
  1360. * 设置/修改密码
  1361. */
  1362. @Transactional
  1363. public void setPassword(Long userId, SetPasswordRequest request) {
  1364. User user = userMapper.selectById(userId);
  1365. if (user == null) {
  1366. throw new BusinessException(ErrorCode.USER_NOT_FOUND);
  1367. }
  1368. // 如果已有密码,需要验证旧密码
  1369. if (user.getPassword() != null && request.getOldPassword() != null) {
  1370. if (!passwordUtil.matches(request.getOldPassword(), user.getPassword())) {
  1371. throw new BusinessException(ErrorCode.OLD_PASSWORD_INCORRECT);
  1372. }
  1373. } else if (user.getPassword() != null) {
  1374. throw new BusinessException(ErrorCode.OLD_PASSWORD_REQUIRED);
  1375. }
  1376. // 加密并更新密码
  1377. user.setPassword(passwordUtil.encode(request.getNewPassword()));
  1378. userMapper.updateById(user);
  1379. log.info("用户{}设置/修改密码成功", userId);
  1380. auditLogService.logSuccess(userId, user.getNickname(), OperatorRole.USER.getCode(),
  1381. OperationType.USER_UPDATE, "PHONE:" + userId, userId);
  1382. }
  1383. /**
  1384. * 修改手机号
  1385. */
  1386. @Transactional
  1387. public void changePhone(Long userId, ChangePhoneRequest request) {
  1388. User user = userMapper.selectById(userId);
  1389. if (user == null) {
  1390. throw new BusinessException(ErrorCode.USER_NOT_FOUND);
  1391. }
  1392. // 1. 验证验证码(使用新手机号和change场景)
  1393. verificationCodeService.verifyCode(request.getNewPhone(), request.getCode());
  1394. // 2. 检查新手机号是否已被其他用户占用
  1395. User existUser = userMapper.selectOne(
  1396. new LambdaQueryWrapper<User>().eq(User::getPhone, request.getNewPhone()));
  1397. if (existUser != null && !existUser.getId().equals(userId)) {
  1398. throw new BusinessException(ErrorCode.PHONE_ALREADY_EXISTS);
  1399. }
  1400. // 3. 更新手机号
  1401. String oldPhone = user.getPhone();
  1402. user.setPhone(request.getNewPhone());
  1403. userMapper.updateById(user);
  1404. log.info("用户{}修改手机号成功,旧手机号:{},新手机号:{}", userId, oldPhone, request.getNewPhone());
  1405. auditLogService.logSuccess(userId, user.getNickname(), OperatorRole.USER.getCode(),
  1406. OperationType.USER_UPDATE, "PHONE:" + userId, userId);
  1407. }
  1408. // ====================================
  1409. // 小程序统一登录(处理四种用户身份场景)
  1410. // ====================================
  1411. /**
  1412. * 小程序统一登录
  1413. * <p>
  1414. * 自动处理"小程序老/新用户 × 当前系统老/新用户"四种场景:
  1415. * <ol>
  1416. * <li>外部老 + 本地老 → 直接登录</li>
  1417. * <li>外部老 + 本地新 → 本地注册 + 异步同步证书</li>
  1418. * <li>外部新 + 本地老 → 异步注册外部 + 本地直接登录</li>
  1419. * <li>外部新 + 本地新 → 两端都注册</li>
  1420. * </ol>
  1421. * <p>
  1422. * 关键原则:外部系统操作异步执行,不阻塞登录响应。
  1423. */
  1424. @Transactional
  1425. public LoginResponse miniappUnifiedLogin(MiniProgramLoginRequest request, String ip) {
  1426. // 1. code2Session
  1427. WechatMiniProgramService.MiniProgramSessionInfo session =
  1428. wechatMiniProgramService.code2Session(request.getJsCode());
  1429. // 2. 获取手机号(如果提供了 phoneCode)
  1430. String phone = null;
  1431. if (request.getPhoneCode() != null && !request.getPhoneCode().isBlank()) {
  1432. try {
  1433. phone = wechatMiniProgramService.getPhoneNumber(request.getPhoneCode());
  1434. } catch (Exception e) {
  1435. log.warn("获取小程序手机号失败(不阻断登录): {}", e.getMessage());
  1436. }
  1437. }
  1438. // 3. 并行检查双系统
  1439. User user = findUserByWechatIdentity(session.getUnionId(), session.getOpenId());
  1440. boolean existsExternal = checkExternalUserSafe(session.getUnionId());
  1441. boolean isNewLocal = (user == null);
  1442. log.info("小程序统一登录: unionId={}, isNewLocal={}, existsExternal={}",
  1443. session.getUnionId(), isNewLocal, existsExternal);
  1444. // 4. 场景路由
  1445. if (!isNewLocal && existsExternal) {
  1446. // 场景1: 两端都有 → 更新微信信息,直接登录
  1447. updateWechatMiniInfo(user, session.getOpenId(), session.getUnionId(), phone, ip);
  1448. } else if (isNewLocal && existsExternal) {
  1449. // 场景2: 外部老 + 本地新 → 本地注册 + 异步同步证书
  1450. user = createUserFromMiniProgram(session, phone, request.getInviteCode(), ip);
  1451. final Long newUserId = user.getId();
  1452. final String syncUnionId = session.getUnionId();
  1453. try {
  1454. certificateSyncService.syncCertificatesFromExternal(newUserId, syncUnionId);
  1455. } catch (Exception e) {
  1456. log.warn("触发证书同步失败(不阻断登录): userId={}", newUserId, e);
  1457. }
  1458. } else if (!isNewLocal) {
  1459. // 场景3: 外部新 + 本地老 → 更新微信信息,异步注册外部
  1460. updateWechatMiniInfo(user, session.getOpenId(), session.getUnionId(), phone, ip);
  1461. asyncRegisterInExternal(user, session.getOpenId());
  1462. } else {
  1463. // 场景4: 两端都新 → 本地注册 + 异步注册外部
  1464. user = createUserFromMiniProgram(session, phone, request.getInviteCode(), ip);
  1465. asyncRegisterInExternal(user, session.getOpenId());
  1466. }
  1467. // 5. 没有手机号 → 返回需要绑定
  1468. if (user.getPhone() == null) {
  1469. String tempToken = jwtUtil.generateTempToken(session.getOpenId(), session.getUnionId());
  1470. MDC.put("uid", String.valueOf(user.getId()));
  1471. return LoginResponse.builder()
  1472. .needBindPhone(true)
  1473. .tempToken(tempToken)
  1474. .userInfo(convertToUserInfoResponse(user))
  1475. .build();
  1476. }
  1477. // 6. 更新登录信息并签发 Token
  1478. user.setLastLoginTime(LocalDateTime.now());
  1479. user.setLastLoginIp(ip);
  1480. user.setLoginSource("MINIAPP");
  1481. userMapper.updateById(user);
  1482. LoginResponse response = buildMiniAppLoginResponse(user);
  1483. auditLogService.logSuccess(user.getId(), user.getNickname(), OperatorRole.USER.getCode(),
  1484. isNewLocal ? OperationType.USER_REGISTER : OperationType.USER_LOGIN,
  1485. "USER:" + user.getId(), user.getId());
  1486. syncShopResourceOnLogin(user.getId());
  1487. return response;
  1488. }
  1489. /**
  1490. * 小程序绑定手机号(needBindPhone=true 时调用)
  1491. */
  1492. @Transactional
  1493. public LoginResponse miniappBindPhone(MiniProgramPhoneRequest request, String ip) {
  1494. // 1. 验证临时 Token
  1495. Claims claims = jwtUtil.parseToken(request.getTempToken());
  1496. if (!"temp".equals(claims.get("type", String.class))) {
  1497. throw new BusinessException(ErrorCode.TOKEN_INVALID);
  1498. }
  1499. String openId = claims.get("openId", String.class);
  1500. String unionId = claims.get("unionId", String.class);
  1501. // 2. 获取手机号
  1502. String phone = wechatMiniProgramService.getPhoneNumber(request.getPhoneCode());
  1503. // 3. 查找小程序用户
  1504. User wechatUser = userMapper.selectOne(
  1505. new LambdaQueryWrapper<User>().eq(User::getWechatMiniOpenId, openId));
  1506. if (wechatUser == null) {
  1507. throw new BusinessException(ErrorCode.WECHAT_LOGIN_FAILED);
  1508. }
  1509. // 4. 检查手机号是否已被其他用户绑定
  1510. User existUser = userMapper.selectOne(
  1511. new LambdaQueryWrapper<User>().eq(User::getPhone, phone));
  1512. if (existUser != null && !existUser.getId().equals(wechatUser.getId())) {
  1513. // 手机号已被其他账号绑定 → 合并账号
  1514. existUser.setWechatMiniOpenId(openId);
  1515. if (unionId != null) {
  1516. existUser.setWechatUnionId(unionId);
  1517. }
  1518. existUser.setLastLoginTime(LocalDateTime.now());
  1519. existUser.setLastLoginIp(ip);
  1520. userMapper.updateById(existUser);
  1521. // 禁用旧的小程序用户(不物理删除,仅改状态)
  1522. wechatUser.setStatus(0);
  1523. userMapper.updateById(wechatUser);
  1524. wechatUser = existUser;
  1525. } else if (existUser == null) {
  1526. // 绑定手机号 + 补全微信身份标识(完成注册)
  1527. wechatUser.setPhone(phone);
  1528. if (unionId != null && wechatUser.getWechatUnionId() == null) {
  1529. wechatUser.setWechatUnionId(unionId);
  1530. }
  1531. wechatUser.setLoginSource("MINIAPP");
  1532. wechatUser.setLastLoginTime(LocalDateTime.now());
  1533. wechatUser.setLastLoginIp(ip);
  1534. userMapper.updateById(wechatUser);
  1535. eventPublisher.publishEvent(new UserRegisteredEvent(
  1536. this, wechatUser.getId(), request.getInviteCode()));
  1537. }
  1538. syncShopResourceOnLogin(wechatUser.getId());
  1539. return buildMiniAppLoginResponse(wechatUser);
  1540. }
  1541. // ====================================
  1542. // 小程序登录辅助方法
  1543. // ====================================
  1544. /** 通过微信身份标识查找用户(unionId 优先,fallback miniOpenId) */
  1545. private User findUserByWechatIdentity(String unionId, String miniOpenId) {
  1546. if (unionId != null) {
  1547. User user = userMapper.selectOne(
  1548. new LambdaQueryWrapper<User>().eq(User::getWechatUnionId, unionId));
  1549. if (user != null) {
  1550. return user;
  1551. }
  1552. }
  1553. if (miniOpenId != null) {
  1554. return userMapper.selectOne(
  1555. new LambdaQueryWrapper<User>().eq(User::getWechatMiniOpenId, miniOpenId));
  1556. }
  1557. return null;
  1558. }
  1559. /** 安全检查外部用户(不可用时降级为"外部新用户",不阻塞流程) */
  1560. private boolean checkExternalUserSafe(String unionId) {
  1561. try {
  1562. return externalUserSyncService.userExists(unionId);
  1563. } catch (Exception e) {
  1564. log.warn("检查外部用户失败(降级为外部新用户): unionId={}", unionId, e);
  1565. return false;
  1566. }
  1567. }
  1568. /** 从小程序注册新用户 */
  1569. private User createUserFromMiniProgram(
  1570. WechatMiniProgramService.MiniProgramSessionInfo session,
  1571. String phone, String inviteCode, String ip) {
  1572. User user = User.builder()
  1573. .wechatMiniOpenId(session.getOpenId())
  1574. .wechatUnionId(session.getUnionId())
  1575. .phone(phone)
  1576. .nickname("药店用户")
  1577. .membershipLevel(MembershipLevel.PLUS)
  1578. .loginSource("MINIAPP")
  1579. .lastLoginTime(LocalDateTime.now())
  1580. .lastLoginIp(ip)
  1581. .status(1)
  1582. .build();
  1583. userMapper.insert(user);
  1584. membershipService.initPlusMembership(user.getId());
  1585. if (inviteCode != null) {
  1586. eventPublisher.publishEvent(new UserRegisteredEvent(this, user.getId(), inviteCode));
  1587. }
  1588. log.info("小程序新用户注册: userId={}, unionId={}", user.getId(), session.getUnionId());
  1589. return user;
  1590. }
  1591. /** 更新已有用户的微信小程序信息(始终写入 DB 保存登录时间戳和身份信息) */
  1592. private void updateWechatMiniInfo(User user, String miniOpenId, String unionId,
  1593. String phone, String ip) {
  1594. if (miniOpenId != null && !miniOpenId.equals(user.getWechatMiniOpenId())) {
  1595. user.setWechatMiniOpenId(miniOpenId);
  1596. }
  1597. if (unionId != null && !unionId.equals(user.getWechatUnionId())) {
  1598. user.setWechatUnionId(unionId);
  1599. }
  1600. if (phone != null && user.getPhone() == null) {
  1601. user.setPhone(phone);
  1602. }
  1603. user.setLastLoginTime(LocalDateTime.now());
  1604. user.setLastLoginIp(ip);
  1605. user.setLoginSource("MINIAPP");
  1606. userMapper.updateById(user);
  1607. }
  1608. /** 异步在外部系统注册用户(不阻塞主流程) */
  1609. private void asyncRegisterInExternal(User user, String miniProgramOpenId) {
  1610. try {
  1611. ExternalUserSyncService.ExternalUserInfo extInfo =
  1612. new ExternalUserSyncService.ExternalUserInfo();
  1613. extInfo.setMiniProgramOpenId(miniProgramOpenId);
  1614. extInfo.setUnionId(user.getWechatUnionId());
  1615. extInfo.setPhone(user.getPhone());
  1616. extInfo.setNickname(user.getNickname());
  1617. extInfo.setAvatar(user.getAvatar());
  1618. String extUserId = externalUserSyncService.registerUser(extInfo);
  1619. if (extUserId != null) {
  1620. user.setExternalUserId(extUserId);
  1621. user.setExternalSyncTime(LocalDateTime.now());
  1622. userMapper.updateById(user);
  1623. log.info("外部系统注册成功: userId={}, extUserId={}", user.getId(), extUserId);
  1624. }
  1625. } catch (Exception e) {
  1626. log.warn("外部系统注册失败(不阻断主流程): userId={}", user.getId(), e);
  1627. }
  1628. }
  1629. }