AuthService.java 83 KB

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