AuthService.java 86 KB

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