InviteService.java 59 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322
  1. package com.xuekairui.invite.service;
  2. import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
  3. import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
  4. import com.xuekairui.common.BusinessException;
  5. import com.xuekairui.common.ErrorCode;
  6. import com.xuekairui.common.ValidateUtil;
  7. import com.xuekairui.common.activity.ActivityInviteGate;
  8. import com.xuekairui.invite.dto.InviteCodeResponse;
  9. import com.xuekairui.invite.dto.InviteConversionStatsResponse;
  10. import com.xuekairui.invite.dto.InviteLinkResolveResponse;
  11. import com.xuekairui.invite.dto.InvitePageResponse;
  12. import com.xuekairui.invite.dto.InviteRewardResponse;
  13. import com.xuekairui.invite.dto.InviteStatsResponse;
  14. import com.xuekairui.invite.dto.MyInviterResponse;
  15. import com.xuekairui.invite.dto.QuotaGrantRequest;
  16. import com.xuekairui.invite.entity.InviteCode;
  17. import com.xuekairui.invite.entity.InviteConfig;
  18. import com.xuekairui.invite.entity.InviteRelation;
  19. import com.xuekairui.invite.mapper.InviteCodeMapper;
  20. import com.xuekairui.invite.mapper.InviteRelationMapper;
  21. import com.xuekairui.user.dto.CrawlerPlatformStatsResponse;
  22. import com.xuekairui.user.dto.CrawlerQuotaGrantResponse;
  23. import com.xuekairui.user.dto.CrawlerUserDetailResponse;
  24. import com.xuekairui.user.entity.CrawlerPlatform;
  25. import com.xuekairui.user.entity.CrawlerQuotaGrant;
  26. import com.xuekairui.user.entity.User;
  27. import com.xuekairui.user.enums.LicenseStatus;
  28. import com.xuekairui.user.mapper.CrawlerUsageLogMapper;
  29. import com.xuekairui.user.mapper.UserMapper;
  30. import com.xuekairui.user.service.CrawlerQuotaGrantService;
  31. import lombok.RequiredArgsConstructor;
  32. import lombok.extern.slf4j.Slf4j;
  33. import org.jetbrains.annotations.NotNull;
  34. import org.springframework.beans.factory.annotation.Autowired;
  35. import org.springframework.beans.factory.annotation.Value;
  36. import org.springframework.data.redis.core.StringRedisTemplate;
  37. import org.springframework.stereotype.Service;
  38. import org.springframework.transaction.annotation.Transactional;
  39. import java.time.Duration;
  40. import java.time.LocalDate;
  41. import java.time.LocalDateTime;
  42. import java.time.format.DateTimeFormatter;
  43. import java.util.Comparator;
  44. import java.util.HashMap;
  45. import java.util.HashSet;
  46. import java.util.List;
  47. import java.util.Map;
  48. import java.util.Objects;
  49. import java.util.Optional;
  50. import java.util.Set;
  51. import java.util.stream.Collectors;
  52. /**
  53. * 邀请核心服务
  54. * 处理邀请码生成、邀请关系建立、奖励发放、运营手动发放等
  55. */
  56. @Slf4j
  57. @Service
  58. @RequiredArgsConstructor
  59. public class InviteService {
  60. private final InviteConfigService inviteConfigService;
  61. private final InviteCodeMapper inviteCodeMapper;
  62. private final InviteRelationMapper inviteRelationMapper;
  63. private final CrawlerQuotaGrantService crawlerQuotaGrantService;
  64. private final UserMapper userMapper;
  65. private final CrawlerUsageLogMapper crawlerUsageLogMapper;
  66. private final com.xuekairui.user.service.CrawlerService crawlerService;
  67. private final com.xuekairui.user.service.BusinessLicenseService businessLicenseService;
  68. private final com.xuekairui.user.service.WechatMiniProgramService wechatMiniProgramService;
  69. private final StringRedisTemplate stringRedisTemplate;
  70. private static final String INVITE_CODE_CACHE_PREFIX = "invite:code:user:";
  71. private static final Duration INVITE_CODE_CACHE_TTL = Duration.ofMinutes(5);
  72. /** 活动模块可选依赖:未加载时跳过活动相关发奖 */
  73. @Autowired(required = false)
  74. private ActivityInviteGate activityInviteGate;
  75. /** 邀请链接基础URL,可通过配置覆盖 */
  76. @Value("${invite.base-url:https://app.zhijiayun.com}")
  77. private String inviteBaseUrl;
  78. /** 客户端安装包本地存储目录 */
  79. @Value("${invite.download-base-path:./downloads}")
  80. private String downloadBasePath;
  81. /** Windows 安装包文件名 */
  82. @Value("${invite.windows-filename:zhijiayun-x64.exe}")
  83. private String windowsFilename;
  84. /** macOS 安装包文件名 */
  85. @Value("${invite.mac-filename:zhijiayun-arm64.dmg}")
  86. private String macFilename;
  87. /** 补填邀请码有效窗口(天),默认30天 */
  88. @Value("${invite.bind-window-days:30}")
  89. private int bindWindowDays;
  90. // ==========================================
  91. // 邀请码管理
  92. // ==========================================
  93. /**
  94. * 获取或创建用户的邀请码
  95. * 使用 Redis 缓存防止并发创建多个邀请码
  96. */
  97. public InviteCodeResponse getOrCreateInviteCode(Long userId, String loginSource) {
  98. String cacheKey = INVITE_CODE_CACHE_PREFIX + userId;
  99. // 1. 先从 Redis 缓存中查找
  100. String cachedCode = stringRedisTemplate.opsForValue().get(cacheKey);
  101. if (cachedCode != null) {
  102. InviteCode cachedInviteCode = inviteCodeMapper.selectOne(
  103. new LambdaQueryWrapper<InviteCode>()
  104. .eq(InviteCode::getCode, cachedCode)
  105. .eq(InviteCode::getStatus, 1));
  106. if (cachedInviteCode != null) {
  107. log.debug("从缓存获取邀请码: userId={}, code={}", userId, cachedCode);
  108. return toCodeResponse(cachedInviteCode, loginSource);
  109. }
  110. }
  111. // 2. 从数据库查找已有的有效邀请码
  112. InviteCode existingCode = inviteCodeMapper.selectOne(
  113. new LambdaQueryWrapper<InviteCode>()
  114. .eq(InviteCode::getUserId, userId)
  115. .eq(InviteCode::getStatus, 1)
  116. .and(w -> w.isNull(InviteCode::getExpireTime)
  117. .or().gt(InviteCode::getExpireTime, LocalDateTime.now()))
  118. .orderByDesc(InviteCode::getCreateTime)
  119. .last("LIMIT 1"));
  120. if (existingCode != null) {
  121. // 写入缓存
  122. stringRedisTemplate.opsForValue().set(cacheKey, existingCode.getCode(), INVITE_CODE_CACHE_TTL);
  123. return toCodeResponse(existingCode, loginSource);
  124. }
  125. // 3. 使用 Redis 分布式锁防止并发创建
  126. String lockKey = "invite:code:lock:" + userId;
  127. Boolean locked = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "1", Duration.ofSeconds(10));
  128. if (locked == null || !locked) {
  129. // 未获取到锁,等待后重试查询
  130. try {
  131. Thread.sleep(100);
  132. } catch (InterruptedException e) {
  133. Thread.currentThread().interrupt();
  134. }
  135. // 重新查询
  136. InviteCode retryCode = inviteCodeMapper.selectOne(
  137. new LambdaQueryWrapper<InviteCode>()
  138. .eq(InviteCode::getUserId, userId)
  139. .eq(InviteCode::getStatus, 1)
  140. .orderByDesc(InviteCode::getCreateTime)
  141. .last("LIMIT 1"));
  142. if (retryCode != null) {
  143. stringRedisTemplate.opsForValue().set(cacheKey, retryCode.getCode(), INVITE_CODE_CACHE_TTL);
  144. return toCodeResponse(retryCode, loginSource);
  145. }
  146. throw new BusinessException(ErrorCode.BUSINESS_ERROR, "系统繁忙,请重试");
  147. }
  148. try {
  149. // 4. 再次检查(双重检查)
  150. existingCode = inviteCodeMapper.selectOne(
  151. new LambdaQueryWrapper<InviteCode>()
  152. .eq(InviteCode::getUserId, userId)
  153. .eq(InviteCode::getStatus, 1)
  154. .orderByDesc(InviteCode::getCreateTime)
  155. .last("LIMIT 1"));
  156. if (existingCode != null) {
  157. stringRedisTemplate.opsForValue().set(cacheKey, existingCode.getCode(), INVITE_CODE_CACHE_TTL);
  158. return toCodeResponse(existingCode, loginSource);
  159. }
  160. // 5. 创建新邀请码
  161. InviteConfig config = inviteConfigService.getActiveConfig();
  162. LocalDateTime expireTime = LocalDateTime.now().plusDays(config.getInviteCodeExpireDays());
  163. String code = generateUniqueCode();
  164. InviteCode newCode = InviteCode.builder()
  165. .userId(userId)
  166. .code(code)
  167. .maxUses(-1)
  168. .usedCount(0)
  169. .clickCount(0)
  170. .expireTime(expireTime)
  171. .status(1)
  172. .build();
  173. inviteCodeMapper.insert(newCode);
  174. log.info("用户 {} 创建邀请码: {}", userId, code);
  175. // 6. 写入缓存
  176. stringRedisTemplate.opsForValue().set(cacheKey, code, INVITE_CODE_CACHE_TTL);
  177. return toCodeResponse(newCode, loginSource);
  178. } finally {
  179. // 7. 释放锁
  180. stringRedisTemplate.delete(lockKey);
  181. }
  182. }
  183. // ==========================================
  184. // 落地页 & 链接追踪(公开接口)
  185. // ==========================================
  186. /**
  187. * 获取邀请落地页数据(公开,无需登录)
  188. * 浏览器打开邀请链接时调用,展示邀请人信息和下载链接
  189. * 支持多渠道:app、wechat、miniapp、dingtalk、feishu
  190. *
  191. * @param code 邀请码
  192. * @param channel 渠道(可选,默认app)
  193. */
  194. public InvitePageResponse getInvitePage(String code, String channel) {
  195. log.info("[getInvitePage] code={}, channel={}", code, channel);
  196. InviteCode inviteCode = inviteCodeMapper.selectOne(
  197. new LambdaQueryWrapper<InviteCode>()
  198. .eq(InviteCode::getCode, code));
  199. if (inviteCode == null) {
  200. throw new BusinessException(ErrorCode.INVITE_CODE_INVALID);
  201. }
  202. // 自动计入点击数(浏览器打开即算一次点击)
  203. inviteCode.setClickCount(inviteCode.getClickCount() + 1);
  204. inviteCodeMapper.updateById(inviteCode);
  205. log.debug("邀请落地页 {} 被访问,累计点击 {} 次", code, inviteCode.getClickCount());
  206. // 获取邀请人信息
  207. User inviter = userMapper.selectById(inviteCode.getUserId());
  208. String inviterNickname = getDisplayName(inviter);
  209. String inviterAvatar = inviter != null ? inviter.getAvatar() : null;
  210. // 获取配置
  211. InviteConfig config = inviteConfigService.getActiveConfig();
  212. // 默认渠道(当前主推 Windows 桌面客户端)
  213. if (channel == null || channel.isBlank()) {
  214. channel = "windows";
  215. }
  216. // 根据渠道确定打开方式
  217. String openType = determineOpenType(channel);
  218. // 构建小程序路径(含邀请码参数)
  219. String miniappPath = config.getMiniappPath();
  220. String mpLink = null;
  221. if ("miniapp".equals(channel) && miniappPath != null && !miniappPath.isBlank()) {
  222. String query = "inviteCode=" + code;
  223. mpLink = wechatMiniProgramService.generateMpLink(miniappPath, query);
  224. if (mpLink != null) {
  225. log.info("[getInvitePage] 落地页生成小程序 mp:// 链接成功: code={}, mpLink={}", code, mpLink);
  226. } else {
  227. if (!miniappPath.contains("?")) {
  228. miniappPath = miniappPath + "?inviteCode=" + code;
  229. } else {
  230. miniappPath = miniappPath + "&inviteCode=" + code;
  231. }
  232. log.warn("[getInvitePage] 落地页生成小程序 mp:// 链接失败,降级为普通路径: code={}", code);
  233. }
  234. } else if (miniappPath != null && !miniappPath.contains("?")) {
  235. miniappPath = miniappPath + "?inviteCode=" + code;
  236. } else if (miniappPath != null) {
  237. miniappPath = miniappPath + "&inviteCode=" + code;
  238. }
  239. // 生成URL Scheme和DeepLink(用于客户端自动填入邀请码)
  240. String urlScheme = "zhijiayun://invite?code=" + code;
  241. String deepLink = "https://app.zhijiayun.com/invite/" + code + "?autoFill=true";
  242. // 根据渠道生成提示文案
  243. String instructionText = generateInstructionText(code, channel, config);
  244. // 落地页下载按钮指向 /api/invite/{code},服务端按设备流式返回对应安装包
  245. String inviteDownloadUrl = inviteBaseUrl + "/api/invite/" + code;
  246. // Windows/app 渠道使用邀请链接作为下载入口(自动识别设备并重定向),其他渠道保持配置中的下载地址
  247. String appDownloadUrl = ("windows".equals(channel) || "app".equals(channel))
  248. ? inviteDownloadUrl
  249. : config.getAppDownloadUrl();
  250. // 处理落地页标题:支持 {inviter} 和 {pharmacy} 占位符
  251. String landingTitle = config.getLandingTitle();
  252. if (landingTitle == null || landingTitle.isBlank()) {
  253. // 没有配置时,使用邀请人+药店名(如有)个性化展示
  254. String pharmacyName = inviter != null ? inviter.getPharmacyName() : null;
  255. if (pharmacyName != null && !pharmacyName.isBlank()) {
  256. landingTitle = inviterNickname + " · " + pharmacyName + " 邀请您使用";
  257. } else {
  258. landingTitle = inviterNickname + " 邀请您使用" + config.getAppName();
  259. }
  260. } else {
  261. // 替换占位符
  262. if (landingTitle.contains("{inviter}")) {
  263. landingTitle = landingTitle.replace("{inviter}", inviterNickname);
  264. }
  265. if (inviter != null && inviter.getPharmacyName() != null
  266. && landingTitle.contains("{pharmacy}")) {
  267. landingTitle = landingTitle.replace("{pharmacy}", inviter.getPharmacyName());
  268. }
  269. }
  270. return InvitePageResponse.builder()
  271. .inviterNickname(inviterNickname)
  272. .inviterAvatar(inviterAvatar)
  273. .inviteCode(code)
  274. .channel(channel)
  275. .openType(openType)
  276. .appName(config.getAppName())
  277. .landingTitle(landingTitle)
  278. .landingDesc(config.getLandingDesc())
  279. // 渠道特定字段
  280. .appDownloadUrl(appDownloadUrl)
  281. .miniappPath(miniappPath)
  282. .mpLink(mpLink)
  283. .miniappAppId(config.getMiniappAppId())
  284. .wechatRedirectUrl(config.getWechatRedirectUrl())
  285. .dingtalkAppId(config.getDingtalkAppId())
  286. .feishuAppId(config.getFeishuAppId())
  287. .rewardPerRegister(config.getRewardMonths() != null ? config.getRewardMonths() : 1)
  288. // 新增:自动填入支持
  289. .autoFillSupported(true)
  290. .urlScheme(urlScheme)
  291. .deepLink(deepLink)
  292. .instructionText(instructionText)
  293. .build();
  294. }
  295. /**
  296. * 根据渠道生成操作提示文案(从配置读取,支持占位符 {code}、{appName})
  297. */
  298. private String generateInstructionText(String code, String channel, InviteConfig config) {
  299. String template = switch (channel) {
  300. case "windows" -> config.getInstructionWindows();
  301. case "app" -> config.getInstructionApp();
  302. case "miniapp" -> config.getInstructionMiniapp();
  303. case "wechat" -> config.getInstructionWechat();
  304. case "dingtalk" -> config.getInstructionDingtalk();
  305. case "feishu" -> config.getInstructionFeishu();
  306. default -> config.getInstructionDefault();
  307. };
  308. if (template == null || template.isBlank()) {
  309. template = "下载客户端后,在注册页面输入邀请码:" + code + " 即可享受奖励";
  310. }
  311. return template.replace("{code}", code)
  312. .replace("{appName}", config.getAppName() != null ? config.getAppName() : "智价云(药店版)");
  313. }
  314. /**
  315. * 根据渠道确定打开方式
  316. */
  317. private String determineOpenType(String channel) {
  318. return switch (channel) {
  319. case "windows" -> "download"; // 下载Windows桌面客户端
  320. case "app" -> "download"; // 下载客户端(移动端通用)
  321. case "miniapp" -> "miniapp"; // 打开小程序
  322. case "wechat" -> "redirect"; // 跳转公众号/H5
  323. case "dingtalk", "feishu" -> "deeplink"; // 应用内深度链接
  324. default -> "download";
  325. };
  326. }
  327. /**
  328. * 根据 User-Agent 解析本次应下载的安装包文件
  329. * 其他默认返回 Windows 安装包
  330. */
  331. public java.io.File resolveDownloadFile(String userAgent) {
  332. String filename = windowsFilename;
  333. if (userAgent != null) {
  334. String ua = userAgent.toLowerCase();
  335. boolean isMac = ua.contains("macintosh") || ua.contains("mac os");
  336. boolean isArm = ua.contains("arm64") || ua.contains("aarch64");
  337. if (isMac && isArm) {
  338. filename = macFilename;
  339. }
  340. }
  341. return new java.io.File(downloadBasePath, filename);
  342. }
  343. /**
  344. * 构造支持中文文件名的 Content-Disposition 响应头
  345. * 使用 RFC 5987 的 filename*=UTF-8'' 编码,兼容主流浏览器
  346. */
  347. private String buildContentDisposition(String filename) {
  348. String asciiOnly = filename.replaceAll("[^\\x00-\\x7F]", "_");
  349. String encoded = java.net.URLEncoder.encode(filename, java.nio.charset.StandardCharsets.UTF_8)
  350. .replaceAll("\\+", "%20");
  351. return "attachment; filename=\"" + asciiOnly + "\"; filename*=UTF-8''" + encoded;
  352. }
  353. /**
  354. * 流式下载邀请链接对应的安装包(公开,无需登录)
  355. * 自动计入点击数,并根据 User-Agent 选择安装包文件写入响应流
  356. */
  357. public void downloadInviteFile(String code, String userAgent, jakarta.servlet.http.HttpServletResponse response) {
  358. // 校验邀请码存在性并记录点击
  359. InviteCode inviteCode = inviteCodeMapper.selectOne(
  360. new LambdaQueryWrapper<InviteCode>()
  361. .eq(InviteCode::getCode, code));
  362. if (inviteCode == null) {
  363. throw new BusinessException(ErrorCode.INVITE_CODE_INVALID);
  364. }
  365. inviteCode.setClickCount(inviteCode.getClickCount() + 1);
  366. inviteCodeMapper.updateById(inviteCode);
  367. log.debug("下载链接 {} 被访问,累计点击 {} 次", code, inviteCode.getClickCount());
  368. java.io.File file = resolveDownloadFile(userAgent);
  369. if (!file.exists() || !file.isFile()) {
  370. log.error("安装包文件不存在: {}", file.getAbsolutePath());
  371. throw new BusinessException(ErrorCode.BUSINESS_ERROR, "安装包文件不存在,请联系管理员");
  372. }
  373. response.setContentType("application/octet-stream");
  374. response.setHeader("Content-Disposition", buildContentDisposition(file.getName()));
  375. response.setContentLengthLong(file.length());
  376. try (java.io.InputStream in = new java.io.FileInputStream(file);
  377. java.io.OutputStream out = response.getOutputStream()) {
  378. byte[] buffer = new byte[8192];
  379. int len;
  380. while ((len = in.read(buffer)) != -1) {
  381. out.write(buffer, 0, len);
  382. }
  383. out.flush();
  384. } catch (java.io.IOException e) {
  385. log.error("流式下载安装包失败: {}", file.getAbsolutePath(), e);
  386. throw new BusinessException(ErrorCode.BUSINESS_ERROR, "下载失败,请重试");
  387. }
  388. }
  389. /**
  390. * 追踪邀请链接被打开(公开,浏览器打开时调用)
  391. * 记录到 invite_code.click_count
  392. */
  393. public void trackClick(String code) {
  394. InviteCode inviteCode = inviteCodeMapper.selectOne(
  395. new LambdaQueryWrapper<InviteCode>()
  396. .eq(InviteCode::getCode, code));
  397. if (inviteCode != null) {
  398. inviteCode.setClickCount(inviteCode.getClickCount() + 1);
  399. inviteCodeMapper.updateById(inviteCode);
  400. log.debug("邀请链接 {} 被打开,累计 {} 次", code, inviteCode.getClickCount());
  401. }
  402. }
  403. /**
  404. * 解析任意邀请链接或邀请码,返回邀请人信息
  405. * 支持:
  406. * 1. 纯邀请码:A3K7M9P2
  407. * 2. 完整邀请链接:{@code https://app.zhijiayun.com/invite/A3K7M9P2?channel=wechat}
  408. * 3. 带查询参数的链接:...?inviteCode=A3K7M9P2 或 ?code=A3K7M9P2
  409. */
  410. public InviteLinkResolveResponse resolveInviteLink(String linkOrCode) {
  411. if (linkOrCode == null || linkOrCode.isBlank()) {
  412. throw new BusinessException(ErrorCode.PARAM_ERROR, "邀请链接或邀请码不能为空");
  413. }
  414. String trimmed = linkOrCode.trim();
  415. String code = extractInviteCode(trimmed);
  416. if (code == null || code.isBlank()) {
  417. log.warn("邀请链接解析失败,无法提取邀请码: input={}", trimmed);
  418. throw new BusinessException(ErrorCode.INVITE_CODE_INVALID,
  419. "无法从输入内容中识别出邀请码,请确认输入的是邀请码或邀请链接");
  420. }
  421. InviteCode inviteCode = inviteCodeMapper.selectOne(
  422. new LambdaQueryWrapper<InviteCode>()
  423. .eq(InviteCode::getCode, code));
  424. if (inviteCode == null) {
  425. log.warn("邀请码在数据库中不存在: code={}", code);
  426. throw new BusinessException(ErrorCode.INVITE_CODE_INVALID,
  427. "邀请码 " + code + " 不存在或已失效");
  428. }
  429. User inviter = userMapper.selectById(inviteCode.getUserId());
  430. String inviterNickname = getDisplayName(inviter);
  431. String inviterAvatar = inviter != null ? inviter.getAvatar() : null;
  432. InviteConfig config = inviteConfigService.getActiveConfig();
  433. String landingTitle = getString(config, inviter, inviterNickname);
  434. return InviteLinkResolveResponse.builder()
  435. .inviteCode(code)
  436. .inviteLink(inviteBaseUrl + "/api/invite/" + code)
  437. .inviterNickname(inviterNickname)
  438. .inviterAvatar(inviterAvatar)
  439. .appName(config.getAppName())
  440. .landingTitle(landingTitle)
  441. .landingDesc(config.getLandingDesc())
  442. .build();
  443. }
  444. @NotNull
  445. private static String getString(InviteConfig config, User inviter, String inviterNickname) {
  446. String landingTitle = config.getLandingTitle();
  447. if (landingTitle == null || landingTitle.isBlank()) {
  448. String pharmacyName = inviter != null ? inviter.getPharmacyName() : null;
  449. if (pharmacyName != null && !pharmacyName.isBlank()) {
  450. landingTitle = inviterNickname + " · " + pharmacyName + " 邀请您使用";
  451. } else {
  452. landingTitle = inviterNickname + " 邀请您使用" + config.getAppName();
  453. }
  454. } else {
  455. if (landingTitle.contains("{inviter}")) {
  456. landingTitle = landingTitle.replace("{inviter}", inviterNickname);
  457. }
  458. if (inviter != null && inviter.getPharmacyName() != null
  459. && landingTitle.contains("{pharmacy}")) {
  460. landingTitle = landingTitle.replace("{pharmacy}", inviter.getPharmacyName());
  461. }
  462. }
  463. return landingTitle;
  464. }
  465. /**
  466. * 从链接或邀请码字符串中提取邀请码
  467. * 支持:纯邀请码、完整URL路径、query参数、以及被截断/编码的URL
  468. */
  469. private String extractInviteCode(String linkOrCode) {
  470. // 1. 纯邀请码精确匹配
  471. if (CODE_PURE.matcher(linkOrCode).matches()) {
  472. log.debug("提取邀请码[纯码匹配]: {}", linkOrCode);
  473. return linkOrCode;
  474. }
  475. // 2. 从路径 /invite/{code} 提取
  476. java.util.regex.Matcher pathMatcher = CODE_PATH.matcher(linkOrCode);
  477. if (pathMatcher.find()) {
  478. String code = pathMatcher.group(1);
  479. log.debug("提取邀请码[路径匹配]: {} from {}", code, linkOrCode);
  480. return code;
  481. }
  482. // 3. 从查询参数 ?inviteCode=XXX 或 ?code=XXX 提取
  483. java.util.regex.Matcher queryMatcher = CODE_QUERY.matcher(linkOrCode);
  484. if (queryMatcher.find()) {
  485. return queryMatcher.group(1);
  486. }
  487. // 4. 兜底:从任意位置提取
  488. java.util.regex.Matcher fallbackMatcher = CODE_FALLBACK.matcher(linkOrCode);
  489. if (fallbackMatcher.find()) {
  490. return fallbackMatcher.group(1);
  491. }
  492. return null;
  493. }
  494. // ==========================================
  495. // 邀请注册处理
  496. // ==========================================
  497. /**
  498. * 通过邀请码注册(新用户注册时调用)
  499. * @param inviteCodeStr 邀请码
  500. * @param inviteeId 新用户ID
  501. */
  502. @Transactional
  503. public void processInviteRegistration(String inviteCodeStr, Long inviteeId) {
  504. if (inviteCodeStr == null || inviteCodeStr.isBlank()) {
  505. return; // 没有邀请码,跳过
  506. }
  507. // 1. 查找邀请码
  508. InviteCode inviteCode = inviteCodeMapper.selectOne(
  509. new LambdaQueryWrapper<InviteCode>()
  510. .eq(InviteCode::getCode, inviteCodeStr));
  511. if (inviteCode == null) {
  512. log.warn("邀请码无效: {}", inviteCodeStr);
  513. return; // 邀请码无效,不阻断注册
  514. }
  515. // 2. 校验邀请码状态
  516. if (inviteCode.getStatus() != 1) {
  517. log.warn("邀请码已禁用: {}", inviteCodeStr);
  518. return;
  519. }
  520. if (inviteCode.getExpireTime() != null && inviteCode.getExpireTime().isBefore(LocalDateTime.now())) {
  521. log.warn("邀请码已过期: {}", inviteCodeStr);
  522. return;
  523. }
  524. if (inviteCode.getMaxUses() != -1 && inviteCode.getUsedCount() >= inviteCode.getMaxUses()) {
  525. log.warn("邀请码已达使用上限: {}", inviteCodeStr);
  526. return;
  527. }
  528. Long inviterId = inviteCode.getUserId();
  529. // 3. 不能邀请自己
  530. if (inviterId.equals(inviteeId)) {
  531. throw new BusinessException(ErrorCode.INVITE_SELF_FORBIDDEN);
  532. }
  533. // 4. 检查被邀请人是否已被邀请过(inviter_id 不可变,删 t_invite_relation 也无法绕过)
  534. User invitee = userMapper.selectById(inviteeId);
  535. if (invitee != null && invitee.getInviterId() != null) {
  536. log.warn("用户 {} 已被邀请过, inviterId={}", inviteeId, invitee.getInviterId());
  537. return;
  538. }
  539. long existingCount = Optional.ofNullable(inviteRelationMapper.selectCount(
  540. new LambdaQueryWrapper<InviteRelation>()
  541. .eq(InviteRelation::getInviteeId, inviteeId))).orElse(0L);
  542. if (existingCount > 0) {
  543. log.warn("用户 {} 已有邀请关系(异常数据)", inviteeId);
  544. return;
  545. }
  546. // 5. 获取邀请配置
  547. InviteConfig config = inviteConfigService.getActiveConfig();
  548. if (config.getStatus() != 1) {
  549. log.warn("邀请功能已停用");
  550. return;
  551. }
  552. // 5.1 奖励统一由活动模块处理(ActivityInviteGate),此处不再做上限校验
  553. // 6. 创建邀请关系(直接标记为已注册)
  554. int rewardMonths = config.getRewardMonths() != null ? config.getRewardMonths() : 1;
  555. InviteRelation relation = InviteRelation.builder()
  556. .inviterId(inviterId)
  557. .inviteeId(inviteeId)
  558. .inviteCodeId(inviteCode.getId())
  559. .registered(1)
  560. .rewardGranted(rewardMonths)
  561. .status(1)
  562. .build();
  563. inviteRelationMapper.insert(relation);
  564. // 7. 更新邀请码使用次数
  565. inviteCode.setUsedCount(inviteCode.getUsedCount() + 1);
  566. inviteCodeMapper.updateById(inviteCode);
  567. // 8. 写入被邀请人的 inviter_id(不可变,防止删 t_invite_relation 绕过)
  568. if (invitee != null && invitee.getInviterId() == null) {
  569. invitee.setInviterId(inviterId);
  570. userMapper.updateById(invitee);
  571. }
  572. // 9. 委托活动模块根据 rewardTrigger 配置决定是否发放奖励
  573. if (activityInviteGate != null) {
  574. activityInviteGate.grantInviteReward(inviterId, inviteeId);
  575. }
  576. }
  577. /**
  578. * 补填邀请码(注册时未填,注册后有效窗口内可补填)
  579. * 补填成功后:被邀请人额外获赠1个月普通会员,邀请人获赠1个月同等级会员时长
  580. *
  581. * @param userId 当前登录用户ID
  582. * @param inviteCodeInput 邀请码或邀请链接
  583. * @return 成功提示信息
  584. */
  585. @Transactional
  586. public String bindInviteCode(Long userId, String inviteCodeInput) {
  587. if (inviteCodeInput == null || inviteCodeInput.isBlank()) {
  588. throw new BusinessException(ErrorCode.PARAM_ERROR, "邀请码不能为空");
  589. }
  590. // 1. 提取邀请码(支持纯码或链接)
  591. String inviteCodeStr = extractInviteCode(inviteCodeInput.trim());
  592. if (inviteCodeStr == null || inviteCodeStr.isBlank()) {
  593. throw new BusinessException(ErrorCode.INVITE_CODE_INVALID, "无法识别邀请码,请检查输入");
  594. }
  595. // 2. 查找邀请码
  596. InviteCode inviteCode = inviteCodeMapper.selectOne(
  597. new LambdaQueryWrapper<InviteCode>()
  598. .eq(InviteCode::getCode, inviteCodeStr));
  599. if (inviteCode == null) {
  600. throw new BusinessException(ErrorCode.INVITE_CODE_INVALID);
  601. }
  602. // 3. 校验邀请码状态
  603. if (inviteCode.getStatus() != 1) {
  604. throw new BusinessException(ErrorCode.INVITE_CODE_INVALID, "邀请码已禁用");
  605. }
  606. if (inviteCode.getExpireTime() != null && inviteCode.getExpireTime().isBefore(LocalDateTime.now())) {
  607. throw new BusinessException(ErrorCode.INVITE_CODE_EXPIRED);
  608. }
  609. Long inviterId = inviteCode.getUserId();
  610. // 4. 不能邀请自己
  611. if (inviterId.equals(userId)) {
  612. throw new BusinessException(ErrorCode.INVITE_SELF_FORBIDDEN);
  613. }
  614. // 5. 加载用户,检查是否已被邀请过(inviter_id 不可变,删表也无法绕过)
  615. User user = userMapper.selectById(userId);
  616. if (user == null) {
  617. throw new BusinessException(ErrorCode.USER_NOT_FOUND);
  618. }
  619. if (user.getInviterId() != null) {
  620. User inviter = userMapper.selectById(user.getInviterId());
  621. String inviterName = inviter != null ? getDisplayName(inviter) : "一位用户";
  622. throw new BusinessException(ErrorCode.INVITE_RELATION_EXISTS,
  623. "您已绑定过邀请码,您是由「" + inviterName + "」邀请的,无法重复绑定");
  624. }
  625. long existingCount = inviteRelationMapper.selectCount(
  626. new LambdaQueryWrapper<InviteRelation>()
  627. .eq(InviteRelation::getInviteeId, userId));
  628. if (existingCount > 0) {
  629. // 数据异常:t_user.inviter_id 为空但 t_invite_relation 有记录
  630. log.warn("用户{} inviter_id为空但存在邀请关系(异常数据),修复inviter_id", userId);
  631. InviteRelation rel = inviteRelationMapper.selectOne(
  632. new LambdaQueryWrapper<InviteRelation>()
  633. .eq(InviteRelation::getInviteeId, userId));
  634. if (rel != null) {
  635. user.setInviterId(rel.getInviterId());
  636. userMapper.updateById(user);
  637. }
  638. throw new BusinessException(ErrorCode.INVITE_RELATION_EXISTS, "您已绑定过邀请码,无法重复绑定");
  639. }
  640. // 6. 检查注册时间是否在有效窗口内
  641. if (user.getCreateTime() == null) {
  642. throw new BusinessException(ErrorCode.BUSINESS_ERROR, "用户注册时间缺失,无法补填");
  643. }
  644. LocalDateTime deadline = user.getCreateTime().plusDays(bindWindowDays);
  645. if (LocalDateTime.now().isAfter(deadline)) {
  646. throw new BusinessException(ErrorCode.INVITE_BIND_EXPIRED,
  647. String.format("注册已超过%d天,无法补填邀请码", bindWindowDays));
  648. }
  649. // 7. 获取邀请配置
  650. InviteConfig config = inviteConfigService.getActiveConfig();
  651. if (config.getStatus() != 1) {
  652. throw new BusinessException(ErrorCode.BUSINESS_ERROR, "邀请功能已停用");
  653. }
  654. // 7.1 奖励统一由活动模块处理,此处不再做上限校验
  655. // 8. 创建邀请关系
  656. int rewardMonths = config.getRewardMonths() != null ? config.getRewardMonths() : 1;
  657. InviteRelation relation = InviteRelation.builder()
  658. .inviterId(inviterId)
  659. .inviteeId(userId)
  660. .inviteCodeId(inviteCode.getId())
  661. .registered(1)
  662. .rewardGranted(rewardMonths)
  663. .status(1)
  664. .build();
  665. inviteRelationMapper.insert(relation);
  666. // 9. 更新邀请码使用次数
  667. inviteCode.setUsedCount(inviteCode.getUsedCount() + 1);
  668. inviteCodeMapper.updateById(inviteCode);
  669. // 9.1 写入被邀请人的 inviter_id(不可变,防止删表绕过)
  670. user.setInviterId(inviterId);
  671. userMapper.updateById(user);
  672. // 10. 根据活动配置决定是否发奖
  673. com.xuekairui.user.entity.BusinessLicense license = businessLicenseService.getByUserId(userId);
  674. if (license != null && LicenseStatus.APPROVED.name().equals(license.getReviewStatus())) {
  675. // 已入驻 → 发奖(依赖活动开关)
  676. if (activityInviteGate != null) {
  677. activityInviteGate.grantRewardForApprovedUser(inviterId, userId);
  678. } else {
  679. log.warn("活动模块未加载,无法发放已入驻用户补填奖励: inviter={}, invitee={}", inviterId, userId);
  680. }
  681. } else if (activityInviteGate != null) {
  682. // 未入驻 → 委托活动模块根据 rewardTrigger 配置决定
  683. activityInviteGate.grantInviteReward(inviterId, userId);
  684. }
  685. log.info("补填邀请码成功: invitee={}, inviter={}", userId, inviterId);
  686. return "补填邀请码成功!邀请人获赠1个月会员时长";
  687. }
  688. // ==========================================
  689. // 运营手动发放
  690. // ==========================================
  691. /**
  692. * 运营手动发放爬虫次数(通过统一配额服务)
  693. */
  694. @Transactional
  695. public CrawlerQuotaGrant adminGrantQuota(QuotaGrantRequest request) {
  696. Long userId = request.getUserId();
  697. if (userId == null && request.getPhone() != null && !request.getPhone().isBlank()) {
  698. User u = userMapper.selectOne(
  699. new LambdaQueryWrapper<User>().eq(User::getPhone, request.getPhone()));
  700. if (u == null) {
  701. throw new BusinessException(ErrorCode.USER_NOT_FOUND);
  702. }
  703. userId = u.getId();
  704. }
  705. if (userId == null) {
  706. throw new BusinessException(ErrorCode.PARAM_ERROR, "用户ID和手机号至少填写一个");
  707. }
  708. return crawlerQuotaGrantService.grantAdminQuota(
  709. userId,
  710. request.getQuotaCount(),
  711. request.getExpireTime(),
  712. request.getRemark());
  713. }
  714. // ==========================================
  715. // 统计 & 记录查询
  716. // ==========================================
  717. /**
  718. * 获取用户邀请统计
  719. * 返回如:"已邀请5人,3人注册 +21次"
  720. */
  721. public InviteStatsResponse getInviteStats(Long userId, String loginSource) {
  722. InviteConfig config = inviteConfigService.getActiveConfig();
  723. // 获取用户邀请码
  724. InviteCodeResponse codeResponse = getOrCreateInviteCode(userId, loginSource);
  725. // 所有邀请记录
  726. List<InviteRelation> allRelations = inviteRelationMapper.selectList(
  727. new LambdaQueryWrapper<InviteRelation>()
  728. .eq(InviteRelation::getInviterId, userId));
  729. // 已邀请人数(所有记录)
  730. int totalInvited = allRelations.size();
  731. // 已注册人数
  732. int registeredCount = (int) allRelations.stream()
  733. .filter(r -> r.getRegistered() != null && r.getRegistered() == 1)
  734. .count();
  735. // 累计奖励会员月数
  736. int totalReward = allRelations.stream()
  737. .mapToInt(InviteRelation::getRewardGranted)
  738. .sum();
  739. // 链接被打开次数
  740. int clickedCount = codeResponse.getClickCount() != null ? codeResponse.getClickCount() : 0;
  741. // 今日邀请数
  742. LocalDateTime todayStart = LocalDate.now().atStartOfDay();
  743. Long todayInvited = inviteRelationMapper.selectCount(
  744. new LambdaQueryWrapper<InviteRelation>()
  745. .eq(InviteRelation::getInviterId, userId)
  746. .ge(InviteRelation::getCreateTime, todayStart));
  747. int todayRemaining = Math.max(0, config.getMaxInvitePerDay() - todayInvited.intValue());
  748. // 从爬虫服务获取统一配额数据(保证与头像/爬虫状态页一致)
  749. int crawlerTodayRemaining = 0;
  750. int crawlerQuotaFromInvites = 0;
  751. try {
  752. com.xuekairui.user.dto.CrawlerStatusResponse crawlerStatus =
  753. crawlerService.getCrawlerStatus(userId);
  754. crawlerTodayRemaining = crawlerStatus.getTodayRemaining() != null
  755. ? crawlerStatus.getTodayRemaining() : 0;
  756. // 从配额明细中提取邀请奖励的可用配额
  757. if (crawlerStatus.getExtraQuotaBreakdown() != null) {
  758. crawlerQuotaFromInvites = crawlerStatus.getExtraQuotaBreakdown().stream()
  759. .filter(item -> "INVITE".equals(item.getSourceType()))
  760. .mapToInt(item -> item.getAvailable() != null ? item.getAvailable() : 0)
  761. .sum();
  762. }
  763. } catch (Exception e) {
  764. log.warn("获取爬虫配额状态失败: userId={}, error={}", userId, e.getMessage());
  765. }
  766. // 生成摘要文本(从配置模板读取,支持占位符 {totalInvited}、{registeredCount}、{totalReward})
  767. String summaryTemplate = config.getSummaryTextTemplate();
  768. String summaryText;
  769. if (summaryTemplate != null && !summaryTemplate.isBlank()) {
  770. summaryText = summaryTemplate
  771. .replace("{totalInvited}", String.valueOf(totalInvited))
  772. .replace("{registeredCount}", String.valueOf(registeredCount))
  773. .replace("{totalReward}", String.valueOf(totalReward));
  774. } else {
  775. summaryText = String.format("已邀请%d家药店,%d家注册,累计获得%d个月高级会员",
  776. totalInvited, registeredCount, totalReward);
  777. }
  778. return InviteStatsResponse.builder()
  779. .myInviteCode(codeResponse.getCode())
  780. .inviteLink(codeResponse.getInviteLink())
  781. .clickedCount(clickedCount)
  782. .totalInvited(totalInvited)
  783. .registeredCount(registeredCount)
  784. .totalReward(totalReward)
  785. .summaryText(summaryText)
  786. .todayInvited(todayInvited.intValue())
  787. .todayRemaining(todayRemaining)
  788. .rewardPerInvite(config.getRewardMonths() != null ? config.getRewardMonths() : 1)
  789. .crawlerTodayRemaining(crawlerTodayRemaining)
  790. .crawlerQuotaFromInvites(crawlerQuotaFromInvites)
  791. .build();
  792. }
  793. public List<InviteRewardResponse> getInviteRewards(Long userId) {
  794. return getInviteRewards(userId, 1, Integer.MAX_VALUE).getRecords();
  795. }
  796. /**
  797. * 分页查询邀请记录(批量查询被邀请人,避免 N+1)
  798. */
  799. public Page<InviteRewardResponse> getInviteRewards(Long userId, int pageNum, int pageSize) {
  800. Page<InviteRelation> page = new Page<>(pageNum, pageSize);
  801. LambdaQueryWrapper<InviteRelation> wrapper = new LambdaQueryWrapper<InviteRelation>()
  802. .eq(InviteRelation::getInviterId, userId)
  803. .orderByDesc(InviteRelation::getCreateTime);
  804. Page<InviteRelation> result = inviteRelationMapper.selectPage(page, wrapper);
  805. List<InviteRelation> relations = result.getRecords();
  806. if (relations.isEmpty()) {
  807. return new Page<>(pageNum, pageSize, 0);
  808. }
  809. // 批量查被邀请人和邀请码
  810. List<Long> inviteeIds = relations.stream()
  811. .map(InviteRelation::getInviteeId).distinct().toList();
  812. Map<Long, User> userMap = userMapper.selectByIds(inviteeIds).stream()
  813. .collect(Collectors.toMap(User::getId, u -> u));
  814. List<Long> codeIds = relations.stream()
  815. .map(InviteRelation::getInviteCodeId).filter(Objects::nonNull).distinct().toList();
  816. Map<Long, String> codeMap = codeIds.isEmpty() ? Map.of()
  817. : inviteCodeMapper.selectByIds(codeIds).stream()
  818. .collect(Collectors.toMap(InviteCode::getId, InviteCode::getCode));
  819. // 批量查入驻状态
  820. Map<Long, String> licenseStatusMap = businessLicenseService.getLicenseStatusByUserIds(inviteeIds);
  821. // 邀请人信息只查一次
  822. String inviterNickname = getUserNickname(userId);
  823. List<InviteRewardResponse> list = relations.stream().map(r -> {
  824. boolean isRegistered = r.getRegistered() != null && r.getRegistered() == 1;
  825. User invitee = userMap.get(r.getInviteeId());
  826. String inviteeNickname = isRegistered
  827. ? getDisplayName(invitee) : "待注册";
  828. String inviteePhone = invitee != null
  829. ? ValidateUtil.maskPhone(invitee.getPhone()) : null;
  830. String inviteeLevel = invitee != null && invitee.getMembershipLevel() != null
  831. ? invitee.getMembershipLevel().getCode() : "PLUS";
  832. return InviteRewardResponse.builder()
  833. .inviterId(r.getInviterId())
  834. .inviterNickname(inviterNickname)
  835. .inviteeId(r.getInviteeId())
  836. .inviteeNickname(inviteeNickname)
  837. .inviteePhone(inviteePhone)
  838. .inviteeMembershipLevel(inviteeLevel)
  839. .registered(r.getRegistered() != null ? r.getRegistered() : 0)
  840. .registeredText(isRegistered ? "已注册" : "待注册")
  841. .rewardCount(r.getRewardGranted())
  842. .inviteCode(r.getInviteCodeId() != null ? codeMap.get(r.getInviteCodeId()) : null)
  843. .licenseStatus(licenseStatusMap.getOrDefault(r.getInviteeId(), LicenseStatus.NOT_SUBMITTED.name()))
  844. .inviteTime(r.getCreateTime() != null ?
  845. r.getCreateTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) : null)
  846. .build();
  847. }).collect(Collectors.toList());
  848. Page<InviteRewardResponse> responsePage = new Page<>(pageNum, pageSize, result.getTotal());
  849. responsePage.setRecords(list);
  850. return responsePage;
  851. }
  852. /**
  853. * 查询是谁邀请了我(被邀请人视角)
  854. * 从 t_invite_relation 中查找 inviteeId = 当前用户的记录
  855. */
  856. public MyInviterResponse getMyInviter(Long userId) {
  857. InviteRelation relation = inviteRelationMapper.selectOne(
  858. new LambdaQueryWrapper<InviteRelation>()
  859. .eq(InviteRelation::getInviteeId, userId)
  860. .orderByDesc(InviteRelation::getCreateTime)
  861. .last("LIMIT 1"));
  862. if (relation == null) {
  863. return MyInviterResponse.builder()
  864. .invited(false)
  865. .build();
  866. }
  867. User inviter = userMapper.selectById(relation.getInviterId());
  868. InviteCode inviteCode = relation.getInviteCodeId() != null
  869. ? inviteCodeMapper.selectById(relation.getInviteCodeId())
  870. : null;
  871. return MyInviterResponse.builder()
  872. .invited(true)
  873. .inviterId(relation.getInviterId())
  874. .inviterNickname(getDisplayName(inviter))
  875. .inviterPhone(inviter != null ? ValidateUtil.maskPhone(inviter.getPhone()) : null)
  876. .inviterAvatar(inviter != null ? inviter.getAvatar() : null)
  877. .pharmacyName(inviter != null ? inviter.getPharmacyName() : null)
  878. .inviteCode(inviteCode != null ? inviteCode.getCode() : null)
  879. .inviteTime(relation.getCreateTime() != null ?
  880. relation.getCreateTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) : null)
  881. .build();
  882. }
  883. /**
  884. * 获取用户可用的额外爬虫次数
  885. */
  886. public int getAvailableExtraQuota(Long userId) {
  887. return crawlerQuotaGrantService.getAvailableExtraQuota(userId);
  888. }
  889. /**
  890. * 获取发放记录列表
  891. */
  892. public Page<CrawlerQuotaGrantResponse> listGrants(Long userId, String grantType,
  893. int page, int size) {
  894. return crawlerQuotaGrantService.listGrants(userId, grantType, page, size);
  895. }
  896. // ==========================================
  897. // 邀请码常量
  898. // ==========================================
  899. /** 邀请码字符集(32个,去掉易混淆的 I/O/0/1) */
  900. private static final String CODE_RE = "[A-HJ-NP-Z2-9]{8}";
  901. /** 纯邀请码精确匹配 */
  902. private static final java.util.regex.Pattern CODE_PURE = java.util.regex.Pattern.compile("^" + CODE_RE + "$");
  903. /** 从 URL 路径 /invite/{code} 提取 */
  904. private static final java.util.regex.Pattern CODE_PATH =
  905. java.util.regex.Pattern.compile("/invite/(" + CODE_RE + ")(?:[?/#]|$)");
  906. /** 从 query 参数提取 */
  907. private static final java.util.regex.Pattern CODE_QUERY =
  908. java.util.regex.Pattern.compile("[?&](?:inviteCode|code)=(" + CODE_RE + ")(?:&|$|#)");
  909. /** 从任意位置提取(兜底) */
  910. private static final java.util.regex.Pattern CODE_FALLBACK =
  911. java.util.regex.Pattern.compile("(" + CODE_RE + ")");
  912. // ==========================================
  913. // 私有工具方法
  914. // ==========================================
  915. /**
  916. * 生成唯一邀请码(8位大写字母+数字)
  917. * <p>
  918. * 防重复:先查 DB(快速路径),再用 uk_code 唯一索引兜底。
  919. * 32^8 ≈ 1.1万亿种组合,正常不会碰撞,此处为防御性设计。
  920. */
  921. private String generateUniqueCode() {
  922. int maxRetries = 5;
  923. for (int attempt = 0; attempt < maxRetries; attempt++) {
  924. String code = com.xuekairui.common.RandomUtil.generateUpperCode(8);
  925. if (inviteCodeMapper.selectCount(
  926. new LambdaQueryWrapper<InviteCode>().eq(InviteCode::getCode, code)) == 0) {
  927. return code;
  928. }
  929. log.warn("邀请码碰撞(attempt={}): {}", attempt + 1, code);
  930. }
  931. throw new BusinessException(ErrorCode.BUSINESS_ERROR, "邀请码生成失败,请重试");
  932. }
  933. /**
  934. * 获取用户对外展示名:
  935. * 优先顺序:微信昵称 > 用户自定义昵称 > 手机尾号 > 兜底文案
  936. */
  937. private String getDisplayName(User user) {
  938. if (user == null) {
  939. return "一位药店用户";
  940. }
  941. if (user.getWechatNickname() != null && !user.getWechatNickname().isBlank()) {
  942. return user.getWechatNickname();
  943. }
  944. if (user.getNickname() != null && !user.getNickname().isBlank()) {
  945. return user.getNickname();
  946. }
  947. if (user.getPhone() != null && user.getPhone().length() >= 4) {
  948. return "手机尾号" + user.getPhone().substring(user.getPhone().length() - 4);
  949. }
  950. return "一位药店用户";
  951. }
  952. /**
  953. * 根据用户ID获取展示名
  954. */
  955. private String getUserNickname(Long userId) {
  956. return getDisplayName(userMapper.selectById(userId));
  957. }
  958. /**
  959. * 实体转响应(含邀请链接,文案从配置读取)
  960. */
  961. private InviteCodeResponse toCodeResponse(InviteCode code, String loginSource) {
  962. log.info("[toCodeResponse] code={}, loginSource={}, userId={}", code.getCode(), loginSource, code.getUserId());
  963. // 获取配置与邀请人信息
  964. InviteConfig config = inviteConfigService.getActiveConfig();
  965. User inviter = userMapper.selectById(code.getUserId());
  966. String inviterNickname = getDisplayName(inviter);
  967. String appName = config.getAppName() != null ? config.getAppName() : "智价云(药店版)";
  968. int rewardMonths = config.getRewardMonths() != null ? config.getRewardMonths() : 1;
  969. // 根据登录来源生成不同的邀请链接
  970. String inviteLink;
  971. if ("MINIAPP".equals(loginSource)) {
  972. // 小程序来源:调用微信 API 生成 mp:// 链接
  973. String miniappPath = config.getMiniappPath();
  974. if (miniappPath != null && !miniappPath.isBlank()) {
  975. // 构建查询参数(带邀请码)
  976. String query = "inviteCode=" + code.getCode();
  977. // 调用微信 API 生成 mp:// 链接
  978. String mpLink = wechatMiniProgramService.generateMpLink(miniappPath, query);
  979. if (mpLink != null) {
  980. inviteLink = mpLink;
  981. log.info("[toCodeResponse] 生成小程序 mp:// 链接成功: code={}, mpLink={}", code.getCode(), inviteLink);
  982. } else {
  983. // 生成失败,降级为普通链接
  984. inviteLink = inviteBaseUrl + "/api/invite/" + code.getCode();
  985. log.warn("[toCodeResponse] 生成小程序 mp:// 链接失败,降级为普通链接: code={}", code.getCode());
  986. }
  987. } else {
  988. // 配置不完整,降级为普通链接
  989. inviteLink = inviteBaseUrl + "/api/invite/" + code.getCode();
  990. log.warn("[toCodeResponse] miniappPath 配置为空,降级为普通链接: code={}", code.getCode());
  991. }
  992. } else {
  993. // 其他来源:使用下载链接
  994. inviteLink = inviteBaseUrl + "/api/invite/" + code.getCode();
  995. log.info("[toCodeResponse] 非小程序来源,生成普通链接: code={}, loginSource={}, link={}", code.getCode(), loginSource, inviteLink);
  996. }
  997. // 分享标题:从配置模板读取,支持占位符 {inviter}、{appName}
  998. String shareTitleTemplate = config.getShareTitleTemplate();
  999. String shareTitle = (shareTitleTemplate != null && !shareTitleTemplate.isBlank())
  1000. ? shareTitleTemplate.replace("{inviter}", inviterNickname).replace("{appName}", appName)
  1001. : inviterNickname + " 邀请你加入" + appName;
  1002. // 分享描述:使用落地页描述
  1003. String shareDescription = config.getLandingDesc() != null
  1004. && !config.getLandingDesc().isBlank()
  1005. ? config.getLandingDesc() : "注册登录" + appName + "平台,聚合比价查低价";
  1006. // 奖励说明:从配置读取,支持占位符 {rewardMonths}
  1007. String rewardDescTemplate = config.getRewardDescription();
  1008. String rewardDesc = (rewardDescTemplate != null && !rewardDescTemplate.isBlank())
  1009. ? rewardDescTemplate.replace("{rewardMonths}", String.valueOf(rewardMonths))
  1010. : "邀请人每成功邀请1家药店注册,赠送" + rewardMonths + "个月高级会员时长";
  1011. // 一键复制文本:从配置模板读取,支持占位符 {inviter}、{appName}、{inviteLink}、{code}
  1012. String copyTextTemplate = config.getCopyTextTemplate();
  1013. String copyText = (copyTextTemplate != null && !copyTextTemplate.isBlank())
  1014. ? copyTextTemplate
  1015. .replace("{inviter}", inviterNickname)
  1016. .replace("{appName}", appName)
  1017. .replace("{inviteLink}", inviteLink)
  1018. .replace("{code}", code.getCode())
  1019. : String.format(
  1020. "我是%s,在这里发现了一个药店采购神器——%s!它聚合比价功能特别方便,能快速查到最低价,帮你节省采购成本。下载链接%s填我的邀请码 %s 完成注册,你也会获得会员权益!",
  1021. inviterNickname, appName, inviteLink, code.getCode());
  1022. return InviteCodeResponse.builder()
  1023. .code(code.getCode())
  1024. .inviteLink(inviteLink)
  1025. .downloadUrl(inviteLink)
  1026. .inviterNickname(inviterNickname)
  1027. .clickCount(code.getClickCount() != null ? code.getClickCount() : 0)
  1028. .usedCount(code.getUsedCount())
  1029. .maxUses(code.getMaxUses())
  1030. .expireTime(code.getExpireTime())
  1031. .status(code.getStatus())
  1032. .createTime(code.getCreateTime())
  1033. // 新增分享字段
  1034. .shareTitle(shareTitle)
  1035. .shareDescription(shareDescription)
  1036. .copyText(copyText)
  1037. .rewardDescription(rewardDesc)
  1038. .build();
  1039. }
  1040. // ==========================================
  1041. // 新增:用户爬虫详情查询
  1042. // ==========================================
  1043. /**
  1044. * 获取用户爬虫使用详情(含平台分布统计)
  1045. */
  1046. public CrawlerUserDetailResponse getCrawlerUserDetail(Long userId, String phone) {
  1047. if (userId == null && phone != null && !phone.isBlank()) {
  1048. User u = userMapper.selectOne(
  1049. new LambdaQueryWrapper<User>().eq(User::getPhone, phone));
  1050. if (u == null) {
  1051. throw new BusinessException(ErrorCode.USER_NOT_FOUND);
  1052. }
  1053. userId = u.getId();
  1054. }
  1055. if (userId == null) {
  1056. throw new BusinessException(ErrorCode.PARAM_ERROR, "用户ID和手机号至少填写一个");
  1057. }
  1058. User user = userMapper.selectById(userId);
  1059. if (user == null) {
  1060. throw new BusinessException(ErrorCode.USER_NOT_FOUND);
  1061. }
  1062. // 获取总使用次数
  1063. int todayTotal = crawlerUsageLogMapper.sumTodayUsage(userId);
  1064. int monthlyTotal = crawlerUsageLogMapper.sumMonthlyUsage(userId);
  1065. // 获取各平台统计
  1066. List<Map<String, Object>> todayByPlatform = crawlerUsageLogMapper.sumTodayUsageByPlatform(userId);
  1067. List<Map<String, Object>> monthlyByPlatform = crawlerUsageLogMapper.sumMonthlyUsageByPlatform(userId);
  1068. List<Map<String, Object>> totalByPlatform = crawlerUsageLogMapper.sumTotalUsageByPlatform(userId);
  1069. // 构建平台统计Map
  1070. Map<String, Integer> todayMap = buildPlatformMap(todayByPlatform);
  1071. Map<String, Integer> monthlyMap = buildPlatformMap(monthlyByPlatform);
  1072. Map<String, Integer> totalMap = buildPlatformMap(totalByPlatform);
  1073. // 合并所有出现过的平台
  1074. Set<String> allPlatforms = new HashSet<>();
  1075. allPlatforms.addAll(todayMap.keySet());
  1076. allPlatforms.addAll(monthlyMap.keySet());
  1077. allPlatforms.addAll(totalMap.keySet());
  1078. // 构建平台统计列表(容错处理:未知平台显示原始code+“其他平台”)
  1079. List<CrawlerPlatformStatsResponse> platformStats = allPlatforms.stream()
  1080. .map(platformCode -> {
  1081. CrawlerPlatform platform = CrawlerPlatform.findByCode(platformCode);
  1082. String pCode = platform != null ? platform.getCode() : platformCode;
  1083. String pName = platform != null ? platform.getName() : "其他平台(" + platformCode + ")";
  1084. return CrawlerPlatformStatsResponse.builder()
  1085. .platformCode(pCode)
  1086. .platformName(pName)
  1087. .todayUsage(todayMap.getOrDefault(platformCode, 0))
  1088. .monthlyUsage(monthlyMap.getOrDefault(platformCode, 0))
  1089. .totalUsage(totalMap.getOrDefault(platformCode, 0))
  1090. .build();
  1091. })
  1092. .sorted(Comparator.comparingInt(CrawlerPlatformStatsResponse::getTotalUsage).reversed())
  1093. .collect(Collectors.toList());
  1094. return CrawlerUserDetailResponse.builder()
  1095. .userId(userId)
  1096. .nickname(user.getNickname())
  1097. .phone(ValidateUtil.maskPhone(user.getPhone()))
  1098. .todayTotalUsage(todayTotal)
  1099. .monthlyTotalUsage(monthlyTotal)
  1100. .platformStats(platformStats)
  1101. .build();
  1102. }
  1103. /**
  1104. * 将平台统计结果转换为Map
  1105. */
  1106. private Map<String, Integer> buildPlatformMap(List<Map<String, Object>> rows) {
  1107. Map<String, Integer> map = new HashMap<>();
  1108. for (Map<String, Object> row : rows) {
  1109. String platform = (String) row.get("platform");
  1110. Number usage = (Number) row.get(row.containsKey("today_usage") ? "today_usage"
  1111. : row.containsKey("monthly_usage") ? "monthly_usage" : "total_usage");
  1112. if (platform != null && usage != null) {
  1113. map.put(platform, usage.intValue());
  1114. }
  1115. }
  1116. return map;
  1117. }
  1118. // ==========================================
  1119. // 新增:邀请转化统计
  1120. // ==========================================
  1121. /**
  1122. * 获取用户邀请转化统计(点击-注册转化率)
  1123. */
  1124. public InviteConversionStatsResponse getInviteConversionStats(Long userId, String phone, String loginSource) {
  1125. if (userId == null && phone != null && !phone.isBlank()) {
  1126. User u = userMapper.selectOne(
  1127. new LambdaQueryWrapper<User>().eq(User::getPhone, phone));
  1128. if (u == null) {
  1129. throw new BusinessException(ErrorCode.USER_NOT_FOUND);
  1130. }
  1131. userId = u.getId();
  1132. }
  1133. if (userId == null) {
  1134. throw new BusinessException(ErrorCode.PARAM_ERROR, "用户ID和手机号至少填写一个");
  1135. }
  1136. // 获取用户邀请码
  1137. InviteCodeResponse codeResponse = getOrCreateInviteCode(userId, loginSource);
  1138. // 获取邀请码实体
  1139. InviteCode myCode = inviteCodeMapper.selectOne(
  1140. new LambdaQueryWrapper<InviteCode>()
  1141. .eq(InviteCode::getUserId, userId)
  1142. .eq(InviteCode::getCode, codeResponse.getCode()));
  1143. int clickedCount = myCode != null ? myCode.getClickCount() : 0;
  1144. // 获取所有邀请关系
  1145. List<InviteRelation> relations = inviteRelationMapper.selectList(
  1146. new LambdaQueryWrapper<InviteRelation>()
  1147. .eq(InviteRelation::getInviterId, userId));
  1148. int totalInvited = relations.size();
  1149. int registeredCount = (int) relations.stream()
  1150. .filter(r -> r.getRegistered() != null && r.getRegistered() == 1)
  1151. .count();
  1152. int pendingCount = totalInvited - registeredCount;
  1153. // 计算转化率
  1154. double conversionRate = 0.0;
  1155. if (clickedCount > 0) {
  1156. conversionRate = Math.round((double) registeredCount / clickedCount * 10000.0) / 100.0;
  1157. }
  1158. // 累计奖励
  1159. int totalReward = relations.stream()
  1160. .mapToInt(InviteRelation::getRewardGranted)
  1161. .sum();
  1162. return InviteConversionStatsResponse.builder()
  1163. .inviteCode(codeResponse.getCode())
  1164. .clickedCount(clickedCount)
  1165. .totalInvited(totalInvited)
  1166. .registeredCount(registeredCount)
  1167. .pendingCount(pendingCount)
  1168. .conversionRate(conversionRate)
  1169. .totalReward(totalReward)
  1170. .build();
  1171. }
  1172. }