InviteService.java 61 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354
  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. if (isClientAbortException(e)) {
  386. log.info("用户中断下载安装包(客户端断开): file={}, error={}",
  387. file.getAbsolutePath(), e.getMessage());
  388. } else {
  389. log.error("流式下载安装包失败: {}", file.getAbsolutePath(), e);
  390. throw new BusinessException(ErrorCode.BUSINESS_ERROR, "下载失败,请重试");
  391. }
  392. }
  393. }
  394. /**
  395. * 判断 IOException 是否为客户端主动中断下载导致
  396. * <p>
  397. * 用户取消下载/关闭浏览器/网络中断时会抛出此类异常,属于正常行为,
  398. * 不应记录错误堆栈,仅记录一条 info 日志即可。
  399. * 典型特征:ClientAbortException、Broken pipe、Connection reset by peer
  400. */
  401. private static boolean isClientAbortException(Throwable e) {
  402. if (e == null) {
  403. return false;
  404. }
  405. String className = e.getClass().getName();
  406. if (className.contains("ClientAbortException")) {
  407. return true;
  408. }
  409. String msg = e.getMessage();
  410. if (msg != null) {
  411. String lower = msg.toLowerCase();
  412. if (lower.contains("broken pipe")
  413. || lower.contains("connection reset")
  414. || lower.contains("connection abort")
  415. || lower.contains("client abort")) {
  416. return true;
  417. }
  418. }
  419. return isClientAbortException(e.getCause());
  420. }
  421. /**
  422. * 追踪邀请链接被打开(公开,浏览器打开时调用)
  423. * 记录到 invite_code.click_count
  424. */
  425. public void trackClick(String code) {
  426. InviteCode inviteCode = inviteCodeMapper.selectOne(
  427. new LambdaQueryWrapper<InviteCode>()
  428. .eq(InviteCode::getCode, code));
  429. if (inviteCode != null) {
  430. inviteCode.setClickCount(inviteCode.getClickCount() + 1);
  431. inviteCodeMapper.updateById(inviteCode);
  432. log.debug("邀请链接 {} 被打开,累计 {} 次", code, inviteCode.getClickCount());
  433. }
  434. }
  435. /**
  436. * 解析任意邀请链接或邀请码,返回邀请人信息
  437. * 支持:
  438. * 1. 纯邀请码:A3K7M9P2
  439. * 2. 完整邀请链接:{@code https://app.zhijiayun.com/invite/A3K7M9P2?channel=wechat}
  440. * 3. 带查询参数的链接:...?inviteCode=A3K7M9P2 或 ?code=A3K7M9P2
  441. */
  442. public InviteLinkResolveResponse resolveInviteLink(String linkOrCode) {
  443. if (linkOrCode == null || linkOrCode.isBlank()) {
  444. throw new BusinessException(ErrorCode.PARAM_ERROR, "邀请链接或邀请码不能为空");
  445. }
  446. String trimmed = linkOrCode.trim();
  447. String code = extractInviteCode(trimmed);
  448. if (code == null || code.isBlank()) {
  449. log.warn("邀请链接解析失败,无法提取邀请码: input={}", trimmed);
  450. throw new BusinessException(ErrorCode.INVITE_CODE_INVALID,
  451. "无法从输入内容中识别出邀请码,请确认输入的是邀请码或邀请链接");
  452. }
  453. InviteCode inviteCode = inviteCodeMapper.selectOne(
  454. new LambdaQueryWrapper<InviteCode>()
  455. .eq(InviteCode::getCode, code));
  456. if (inviteCode == null) {
  457. log.warn("邀请码在数据库中不存在: code={}", code);
  458. throw new BusinessException(ErrorCode.INVITE_CODE_INVALID,
  459. "邀请码 " + code + " 不存在或已失效");
  460. }
  461. User inviter = userMapper.selectById(inviteCode.getUserId());
  462. String inviterNickname = getDisplayName(inviter);
  463. String inviterAvatar = inviter != null ? inviter.getAvatar() : null;
  464. InviteConfig config = inviteConfigService.getActiveConfig();
  465. String landingTitle = getString(config, inviter, inviterNickname);
  466. return InviteLinkResolveResponse.builder()
  467. .inviteCode(code)
  468. .inviteLink(inviteBaseUrl + "/api/invite/" + code)
  469. .inviterNickname(inviterNickname)
  470. .inviterAvatar(inviterAvatar)
  471. .appName(config.getAppName())
  472. .landingTitle(landingTitle)
  473. .landingDesc(config.getLandingDesc())
  474. .build();
  475. }
  476. @NotNull
  477. private static String getString(InviteConfig config, User inviter, String inviterNickname) {
  478. String landingTitle = config.getLandingTitle();
  479. if (landingTitle == null || landingTitle.isBlank()) {
  480. String pharmacyName = inviter != null ? inviter.getPharmacyName() : null;
  481. if (pharmacyName != null && !pharmacyName.isBlank()) {
  482. landingTitle = inviterNickname + " · " + pharmacyName + " 邀请您使用";
  483. } else {
  484. landingTitle = inviterNickname + " 邀请您使用" + config.getAppName();
  485. }
  486. } else {
  487. if (landingTitle.contains("{inviter}")) {
  488. landingTitle = landingTitle.replace("{inviter}", inviterNickname);
  489. }
  490. if (inviter != null && inviter.getPharmacyName() != null
  491. && landingTitle.contains("{pharmacy}")) {
  492. landingTitle = landingTitle.replace("{pharmacy}", inviter.getPharmacyName());
  493. }
  494. }
  495. return landingTitle;
  496. }
  497. /**
  498. * 从链接或邀请码字符串中提取邀请码
  499. * 支持:纯邀请码、完整URL路径、query参数、以及被截断/编码的URL
  500. */
  501. private String extractInviteCode(String linkOrCode) {
  502. // 1. 纯邀请码精确匹配
  503. if (CODE_PURE.matcher(linkOrCode).matches()) {
  504. log.debug("提取邀请码[纯码匹配]: {}", linkOrCode);
  505. return linkOrCode;
  506. }
  507. // 2. 从路径 /invite/{code} 提取
  508. java.util.regex.Matcher pathMatcher = CODE_PATH.matcher(linkOrCode);
  509. if (pathMatcher.find()) {
  510. String code = pathMatcher.group(1);
  511. log.debug("提取邀请码[路径匹配]: {} from {}", code, linkOrCode);
  512. return code;
  513. }
  514. // 3. 从查询参数 ?inviteCode=XXX 或 ?code=XXX 提取
  515. java.util.regex.Matcher queryMatcher = CODE_QUERY.matcher(linkOrCode);
  516. if (queryMatcher.find()) {
  517. return queryMatcher.group(1);
  518. }
  519. // 4. 兜底:从任意位置提取
  520. java.util.regex.Matcher fallbackMatcher = CODE_FALLBACK.matcher(linkOrCode);
  521. if (fallbackMatcher.find()) {
  522. return fallbackMatcher.group(1);
  523. }
  524. return null;
  525. }
  526. // ==========================================
  527. // 邀请注册处理
  528. // ==========================================
  529. /**
  530. * 通过邀请码注册(新用户注册时调用)
  531. * @param inviteCodeStr 邀请码
  532. * @param inviteeId 新用户ID
  533. */
  534. @Transactional
  535. public void processInviteRegistration(String inviteCodeStr, Long inviteeId) {
  536. if (inviteCodeStr == null || inviteCodeStr.isBlank()) {
  537. return; // 没有邀请码,跳过
  538. }
  539. // 1. 查找邀请码
  540. InviteCode inviteCode = inviteCodeMapper.selectOne(
  541. new LambdaQueryWrapper<InviteCode>()
  542. .eq(InviteCode::getCode, inviteCodeStr));
  543. if (inviteCode == null) {
  544. log.warn("邀请码无效: {}", inviteCodeStr);
  545. return; // 邀请码无效,不阻断注册
  546. }
  547. // 2. 校验邀请码状态
  548. if (inviteCode.getStatus() != 1) {
  549. log.warn("邀请码已禁用: {}", inviteCodeStr);
  550. return;
  551. }
  552. if (inviteCode.getExpireTime() != null && inviteCode.getExpireTime().isBefore(LocalDateTime.now())) {
  553. log.warn("邀请码已过期: {}", inviteCodeStr);
  554. return;
  555. }
  556. if (inviteCode.getMaxUses() != -1 && inviteCode.getUsedCount() >= inviteCode.getMaxUses()) {
  557. log.warn("邀请码已达使用上限: {}", inviteCodeStr);
  558. return;
  559. }
  560. Long inviterId = inviteCode.getUserId();
  561. // 3. 不能邀请自己
  562. if (inviterId.equals(inviteeId)) {
  563. throw new BusinessException(ErrorCode.INVITE_SELF_FORBIDDEN);
  564. }
  565. // 4. 检查被邀请人是否已被邀请过(inviter_id 不可变,删 t_invite_relation 也无法绕过)
  566. User invitee = userMapper.selectById(inviteeId);
  567. if (invitee != null && invitee.getInviterId() != null) {
  568. log.warn("用户 {} 已被邀请过, inviterId={}", inviteeId, invitee.getInviterId());
  569. return;
  570. }
  571. long existingCount = Optional.ofNullable(inviteRelationMapper.selectCount(
  572. new LambdaQueryWrapper<InviteRelation>()
  573. .eq(InviteRelation::getInviteeId, inviteeId))).orElse(0L);
  574. if (existingCount > 0) {
  575. log.warn("用户 {} 已有邀请关系(异常数据)", inviteeId);
  576. return;
  577. }
  578. // 5. 获取邀请配置
  579. InviteConfig config = inviteConfigService.getActiveConfig();
  580. if (config.getStatus() != 1) {
  581. log.warn("邀请功能已停用");
  582. return;
  583. }
  584. // 5.1 奖励统一由活动模块处理(ActivityInviteGate),此处不再做上限校验
  585. // 6. 创建邀请关系(rewardGranted 初始为 0,由活动模块实际发奖后回写)
  586. InviteRelation relation = InviteRelation.builder()
  587. .inviterId(inviterId)
  588. .inviteeId(inviteeId)
  589. .inviteCodeId(inviteCode.getId())
  590. .registered(1)
  591. .rewardGranted(0)
  592. .status(1)
  593. .build();
  594. inviteRelationMapper.insert(relation);
  595. // 7. 更新邀请码使用次数
  596. inviteCode.setUsedCount(inviteCode.getUsedCount() + 1);
  597. inviteCodeMapper.updateById(inviteCode);
  598. // 8. 写入被邀请人的 inviter_id(不可变,防止删 t_invite_relation 绕过)
  599. if (invitee != null && invitee.getInviterId() == null) {
  600. invitee.setInviterId(inviterId);
  601. userMapper.updateById(invitee);
  602. }
  603. // 9. 被邀请人已入驻审核通过 → 直接触发发奖(否则等后续入驻审核通过时触发)
  604. com.xuekairui.user.entity.BusinessLicense license = businessLicenseService.getByUserId(inviteeId);
  605. if (license != null && LicenseStatus.APPROVED.name().equals(license.getReviewStatus())) {
  606. if (activityInviteGate != null) {
  607. activityInviteGate.grantRewardForApprovedUser(inviterId, inviteeId);
  608. } else {
  609. log.warn("活动模块未加载,无法发放已入驻用户奖励: inviter={}, invitee={}", inviterId, inviteeId);
  610. }
  611. }
  612. }
  613. /**
  614. * 补填邀请码(注册时未填,注册后有效窗口内可补填)
  615. * 补填成功后:被邀请人额外获赠1个月普通会员,邀请人获赠1个月同等级会员时长
  616. *
  617. * @param userId 当前登录用户ID
  618. * @param inviteCodeInput 邀请码或邀请链接
  619. * @return 成功提示信息
  620. */
  621. @Transactional
  622. public String bindInviteCode(Long userId, String inviteCodeInput) {
  623. if (inviteCodeInput == null || inviteCodeInput.isBlank()) {
  624. throw new BusinessException(ErrorCode.PARAM_ERROR, "邀请码不能为空");
  625. }
  626. // 1. 提取邀请码(支持纯码或链接)
  627. String inviteCodeStr = extractInviteCode(inviteCodeInput.trim());
  628. if (inviteCodeStr == null || inviteCodeStr.isBlank()) {
  629. throw new BusinessException(ErrorCode.INVITE_CODE_INVALID, "无法识别邀请码,请检查输入");
  630. }
  631. // 2. 查找邀请码
  632. InviteCode inviteCode = inviteCodeMapper.selectOne(
  633. new LambdaQueryWrapper<InviteCode>()
  634. .eq(InviteCode::getCode, inviteCodeStr));
  635. if (inviteCode == null) {
  636. throw new BusinessException(ErrorCode.INVITE_CODE_INVALID);
  637. }
  638. // 3. 校验邀请码状态
  639. if (inviteCode.getStatus() != 1) {
  640. throw new BusinessException(ErrorCode.INVITE_CODE_INVALID, "邀请码已禁用");
  641. }
  642. if (inviteCode.getExpireTime() != null && inviteCode.getExpireTime().isBefore(LocalDateTime.now())) {
  643. throw new BusinessException(ErrorCode.INVITE_CODE_EXPIRED);
  644. }
  645. Long inviterId = inviteCode.getUserId();
  646. // 4. 不能邀请自己
  647. if (inviterId.equals(userId)) {
  648. throw new BusinessException(ErrorCode.INVITE_SELF_FORBIDDEN);
  649. }
  650. // 5. 加载用户,检查是否已被邀请过(inviter_id 不可变,删表也无法绕过)
  651. User user = userMapper.selectById(userId);
  652. if (user == null) {
  653. throw new BusinessException(ErrorCode.USER_NOT_FOUND);
  654. }
  655. if (user.getInviterId() != null) {
  656. User inviter = userMapper.selectById(user.getInviterId());
  657. String inviterName = inviter != null ? getDisplayName(inviter) : "一位用户";
  658. throw new BusinessException(ErrorCode.INVITE_RELATION_EXISTS,
  659. "您已绑定过邀请码,您是由「" + inviterName + "」邀请的,无法重复绑定");
  660. }
  661. long existingCount = inviteRelationMapper.selectCount(
  662. new LambdaQueryWrapper<InviteRelation>()
  663. .eq(InviteRelation::getInviteeId, userId));
  664. if (existingCount > 0) {
  665. // 数据异常:t_user.inviter_id 为空但 t_invite_relation 有记录
  666. log.warn("用户{} inviter_id为空但存在邀请关系(异常数据),修复inviter_id", userId);
  667. InviteRelation rel = inviteRelationMapper.selectOne(
  668. new LambdaQueryWrapper<InviteRelation>()
  669. .eq(InviteRelation::getInviteeId, userId));
  670. if (rel != null) {
  671. user.setInviterId(rel.getInviterId());
  672. userMapper.updateById(user);
  673. }
  674. throw new BusinessException(ErrorCode.INVITE_RELATION_EXISTS, "您已绑定过邀请码,无法重复绑定");
  675. }
  676. // 6. 检查注册时间是否在有效窗口内
  677. if (user.getCreateTime() == null) {
  678. throw new BusinessException(ErrorCode.BUSINESS_ERROR, "用户注册时间缺失,无法补填");
  679. }
  680. LocalDateTime deadline = user.getCreateTime().plusDays(bindWindowDays);
  681. if (LocalDateTime.now().isAfter(deadline)) {
  682. throw new BusinessException(ErrorCode.INVITE_BIND_EXPIRED,
  683. String.format("注册已超过%d天,无法补填邀请码", bindWindowDays));
  684. }
  685. // 7. 获取邀请配置
  686. InviteConfig config = inviteConfigService.getActiveConfig();
  687. if (config.getStatus() != 1) {
  688. throw new BusinessException(ErrorCode.BUSINESS_ERROR, "邀请功能已停用");
  689. }
  690. // 7.1 奖励统一由活动模块处理,此处不再做上限校验
  691. // 8. 创建邀请关系(rewardGranted 初始为 0,由活动模块实际发奖后回写)
  692. InviteRelation relation = InviteRelation.builder()
  693. .inviterId(inviterId)
  694. .inviteeId(userId)
  695. .inviteCodeId(inviteCode.getId())
  696. .registered(1)
  697. .rewardGranted(0)
  698. .status(1)
  699. .build();
  700. inviteRelationMapper.insert(relation);
  701. // 9. 更新邀请码使用次数
  702. inviteCode.setUsedCount(inviteCode.getUsedCount() + 1);
  703. inviteCodeMapper.updateById(inviteCode);
  704. // 9.1 写入被邀请人的 inviter_id(不可变,防止删表绕过)
  705. user.setInviterId(inviterId);
  706. userMapper.updateById(user);
  707. // 10. 被邀请人已入驻审核通过 → 直接触发发奖(否则等后续入驻审核通过时触发)
  708. com.xuekairui.user.entity.BusinessLicense license = businessLicenseService.getByUserId(userId);
  709. if (license != null && LicenseStatus.APPROVED.name().equals(license.getReviewStatus())) {
  710. if (activityInviteGate != null) {
  711. activityInviteGate.grantRewardForApprovedUser(inviterId, userId);
  712. } else {
  713. log.warn("活动模块未加载,无法发放已入驻用户补填奖励: inviter={}, invitee={}", inviterId, userId);
  714. }
  715. }
  716. log.info("补填邀请码成功: invitee={}, inviter={}", userId, inviterId);
  717. return "补填邀请码成功!";
  718. }
  719. // ==========================================
  720. // 运营手动发放
  721. // ==========================================
  722. /**
  723. * 运营手动发放爬虫次数(通过统一配额服务)
  724. */
  725. @Transactional
  726. public CrawlerQuotaGrant adminGrantQuota(QuotaGrantRequest request) {
  727. Long userId = request.getUserId();
  728. if (userId == null && request.getPhone() != null && !request.getPhone().isBlank()) {
  729. User u = userMapper.selectOne(
  730. new LambdaQueryWrapper<User>().eq(User::getPhone, request.getPhone()));
  731. if (u == null) {
  732. throw new BusinessException(ErrorCode.USER_NOT_FOUND);
  733. }
  734. userId = u.getId();
  735. }
  736. if (userId == null) {
  737. throw new BusinessException(ErrorCode.PARAM_ERROR, "用户ID和手机号至少填写一个");
  738. }
  739. return crawlerQuotaGrantService.grantAdminQuota(
  740. userId,
  741. request.getQuotaCount(),
  742. request.getExpireTime(),
  743. request.getRemark());
  744. }
  745. // ==========================================
  746. // 统计 & 记录查询
  747. // ==========================================
  748. /**
  749. * 获取用户邀请统计
  750. * 返回如:"已邀请5人,3人注册 +21次"
  751. */
  752. public InviteStatsResponse getInviteStats(Long userId, String loginSource) {
  753. InviteConfig config = inviteConfigService.getActiveConfig();
  754. // 获取用户邀请码
  755. InviteCodeResponse codeResponse = getOrCreateInviteCode(userId, loginSource);
  756. // 所有邀请记录
  757. List<InviteRelation> allRelations = inviteRelationMapper.selectList(
  758. new LambdaQueryWrapper<InviteRelation>()
  759. .eq(InviteRelation::getInviterId, userId));
  760. // 已邀请人数(所有记录)
  761. int totalInvited = allRelations.size();
  762. // 已注册人数
  763. int registeredCount = (int) allRelations.stream()
  764. .filter(r -> r.getRegistered() != null && r.getRegistered() == 1)
  765. .count();
  766. // 累计奖励次数(由活动模块实际发奖后回写到 InviteRelation,1 次 = 1 个月高级会员)
  767. int totalRewardMonths = allRelations.stream()
  768. .mapToInt(InviteRelation::getRewardGranted)
  769. .sum();
  770. // 链接被打开次数
  771. int clickedCount = codeResponse.getClickCount() != null ? codeResponse.getClickCount() : 0;
  772. // 今日邀请数
  773. LocalDateTime todayStart = LocalDate.now().atStartOfDay();
  774. Long todayInvited = inviteRelationMapper.selectCount(
  775. new LambdaQueryWrapper<InviteRelation>()
  776. .eq(InviteRelation::getInviterId, userId)
  777. .ge(InviteRelation::getCreateTime, todayStart));
  778. int todayRemaining = Math.max(0, config.getMaxInvitePerDay() - todayInvited.intValue());
  779. // 从爬虫服务获取统一配额数据(保证与头像/爬虫状态页一致)
  780. int crawlerTodayRemaining = 0;
  781. int crawlerQuotaFromInvites = 0;
  782. try {
  783. com.xuekairui.user.dto.CrawlerStatusResponse crawlerStatus =
  784. crawlerService.getCrawlerStatus(userId);
  785. crawlerTodayRemaining = crawlerStatus.getTodayRemaining() != null
  786. ? crawlerStatus.getTodayRemaining() : 0;
  787. // 从配额明细中提取邀请奖励的可用配额
  788. if (crawlerStatus.getExtraQuotaBreakdown() != null) {
  789. crawlerQuotaFromInvites = crawlerStatus.getExtraQuotaBreakdown().stream()
  790. .filter(item -> "INVITE".equals(item.getSourceType()))
  791. .mapToInt(item -> item.getAvailable() != null ? item.getAvailable() : 0)
  792. .sum();
  793. }
  794. } catch (Exception e) {
  795. log.warn("获取爬虫配额状态失败: userId={}, error={}", userId, e.getMessage());
  796. }
  797. // 生成摘要文本(从配置模板读取,支持占位符 {totalInvited}、{registeredCount}、{totalReward})
  798. String summaryTemplate = config.getSummaryTextTemplate();
  799. String summaryText;
  800. if (summaryTemplate != null && !summaryTemplate.isBlank()) {
  801. summaryText = summaryTemplate
  802. .replace("{totalInvited}", String.valueOf(totalInvited))
  803. .replace("{registeredCount}", String.valueOf(registeredCount))
  804. .replace("{totalReward}", String.valueOf(totalRewardMonths));
  805. } else {
  806. summaryText = String.format("已邀请%d家药店,%d家注册,累计获得%d个月高级会员",
  807. totalInvited, registeredCount, totalRewardMonths);
  808. }
  809. return InviteStatsResponse.builder()
  810. .myInviteCode(codeResponse.getCode())
  811. .inviteLink(codeResponse.getInviteLink())
  812. .clickedCount(clickedCount)
  813. .totalInvited(totalInvited)
  814. .registeredCount(registeredCount)
  815. .totalReward(totalRewardMonths)
  816. .summaryText(summaryText)
  817. .todayInvited(todayInvited.intValue())
  818. .todayRemaining(todayRemaining)
  819. .rewardPerInvite(config.getRewardMonths() != null ? config.getRewardMonths() : 1)
  820. .crawlerTodayRemaining(crawlerTodayRemaining)
  821. .crawlerQuotaFromInvites(crawlerQuotaFromInvites)
  822. .build();
  823. }
  824. public List<InviteRewardResponse> getInviteRewards(Long userId) {
  825. return getInviteRewards(userId, 1, Integer.MAX_VALUE).getRecords();
  826. }
  827. /**
  828. * 分页查询邀请记录(批量查询被邀请人,避免 N+1)
  829. */
  830. public Page<InviteRewardResponse> getInviteRewards(Long userId, int pageNum, int pageSize) {
  831. Page<InviteRelation> page = new Page<>(pageNum, pageSize);
  832. LambdaQueryWrapper<InviteRelation> wrapper = new LambdaQueryWrapper<InviteRelation>()
  833. .eq(InviteRelation::getInviterId, userId)
  834. .orderByDesc(InviteRelation::getCreateTime);
  835. Page<InviteRelation> result = inviteRelationMapper.selectPage(page, wrapper);
  836. List<InviteRelation> relations = result.getRecords();
  837. if (relations.isEmpty()) {
  838. return new Page<>(pageNum, pageSize, 0);
  839. }
  840. // 批量查被邀请人和邀请码
  841. List<Long> inviteeIds = relations.stream()
  842. .map(InviteRelation::getInviteeId).distinct().toList();
  843. Map<Long, User> userMap = userMapper.selectByIds(inviteeIds).stream()
  844. .collect(Collectors.toMap(User::getId, u -> u));
  845. List<Long> codeIds = relations.stream()
  846. .map(InviteRelation::getInviteCodeId).filter(Objects::nonNull).distinct().toList();
  847. Map<Long, String> codeMap = codeIds.isEmpty() ? Map.of()
  848. : inviteCodeMapper.selectByIds(codeIds).stream()
  849. .collect(Collectors.toMap(InviteCode::getId, InviteCode::getCode));
  850. // 批量查入驻状态
  851. Map<Long, String> licenseStatusMap = businessLicenseService.getLicenseStatusByUserIds(inviteeIds);
  852. // 邀请人信息只查一次
  853. String inviterNickname = getUserNickname(userId);
  854. List<InviteRewardResponse> list = relations.stream().map(r -> {
  855. boolean isRegistered = r.getRegistered() != null && r.getRegistered() == 1;
  856. User invitee = userMap.get(r.getInviteeId());
  857. String inviteeNickname = isRegistered
  858. ? getDisplayName(invitee) : "待注册";
  859. String inviteePhone = invitee != null
  860. ? ValidateUtil.maskPhone(invitee.getPhone()) : null;
  861. String inviteeLevel = invitee != null && invitee.getMembershipLevel() != null
  862. ? invitee.getMembershipLevel().getCode() : "PLUS";
  863. return InviteRewardResponse.builder()
  864. .inviterId(r.getInviterId())
  865. .inviterNickname(inviterNickname)
  866. .inviteeId(r.getInviteeId())
  867. .inviteeNickname(inviteeNickname)
  868. .inviteePhone(inviteePhone)
  869. .inviteeMembershipLevel(inviteeLevel)
  870. .registered(r.getRegistered() != null ? r.getRegistered() : 0)
  871. .registeredText(isRegistered ? "已注册" : "待注册")
  872. .rewardCount(r.getRewardGranted())
  873. .inviteCode(r.getInviteCodeId() != null ? codeMap.get(r.getInviteCodeId()) : null)
  874. .licenseStatus(licenseStatusMap.getOrDefault(r.getInviteeId(), LicenseStatus.NOT_SUBMITTED.name()))
  875. .inviteTime(r.getCreateTime() != null ?
  876. r.getCreateTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) : null)
  877. .build();
  878. }).collect(Collectors.toList());
  879. Page<InviteRewardResponse> responsePage = new Page<>(pageNum, pageSize, result.getTotal());
  880. responsePage.setRecords(list);
  881. return responsePage;
  882. }
  883. /**
  884. * 查询是谁邀请了我(被邀请人视角)
  885. * 从 t_invite_relation 中查找 inviteeId = 当前用户的记录
  886. */
  887. public MyInviterResponse getMyInviter(Long userId) {
  888. InviteRelation relation = inviteRelationMapper.selectOne(
  889. new LambdaQueryWrapper<InviteRelation>()
  890. .eq(InviteRelation::getInviteeId, userId)
  891. .orderByDesc(InviteRelation::getCreateTime)
  892. .last("LIMIT 1"));
  893. if (relation == null) {
  894. return MyInviterResponse.builder()
  895. .invited(false)
  896. .build();
  897. }
  898. User inviter = userMapper.selectById(relation.getInviterId());
  899. InviteCode inviteCode = relation.getInviteCodeId() != null
  900. ? inviteCodeMapper.selectById(relation.getInviteCodeId())
  901. : null;
  902. return MyInviterResponse.builder()
  903. .invited(true)
  904. .inviterId(relation.getInviterId())
  905. .inviterNickname(getDisplayName(inviter))
  906. .inviterPhone(inviter != null ? ValidateUtil.maskPhone(inviter.getPhone()) : null)
  907. .inviterAvatar(inviter != null ? inviter.getAvatar() : null)
  908. .pharmacyName(inviter != null ? inviter.getPharmacyName() : null)
  909. .inviteCode(inviteCode != null ? inviteCode.getCode() : null)
  910. .inviteTime(relation.getCreateTime() != null ?
  911. relation.getCreateTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) : null)
  912. .build();
  913. }
  914. /**
  915. * 获取用户可用的额外爬虫次数
  916. */
  917. public int getAvailableExtraQuota(Long userId) {
  918. return crawlerQuotaGrantService.getAvailableExtraQuota(userId);
  919. }
  920. /**
  921. * 获取发放记录列表
  922. */
  923. public Page<CrawlerQuotaGrantResponse> listGrants(Long userId, String grantType,
  924. int page, int size) {
  925. return crawlerQuotaGrantService.listGrants(userId, grantType, page, size);
  926. }
  927. // ==========================================
  928. // 邀请码常量
  929. // ==========================================
  930. /** 邀请码字符集(32个,去掉易混淆的 I/O/0/1) */
  931. private static final String CODE_RE = "[A-HJ-NP-Z2-9]{8}";
  932. /** 纯邀请码精确匹配 */
  933. private static final java.util.regex.Pattern CODE_PURE = java.util.regex.Pattern.compile("^" + CODE_RE + "$");
  934. /** 从 URL 路径 /invite/{code} 提取 */
  935. private static final java.util.regex.Pattern CODE_PATH =
  936. java.util.regex.Pattern.compile("/invite/(" + CODE_RE + ")(?:[?/#]|$)");
  937. /** 从 query 参数提取 */
  938. private static final java.util.regex.Pattern CODE_QUERY =
  939. java.util.regex.Pattern.compile("[?&](?:inviteCode|code)=(" + CODE_RE + ")(?:&|$|#)");
  940. /** 从任意位置提取(兜底) */
  941. private static final java.util.regex.Pattern CODE_FALLBACK =
  942. java.util.regex.Pattern.compile("(" + CODE_RE + ")");
  943. // ==========================================
  944. // 私有工具方法
  945. // ==========================================
  946. /**
  947. * 生成唯一邀请码(8位大写字母+数字)
  948. * <p>
  949. * 防重复:先查 DB(快速路径),再用 uk_code 唯一索引兜底。
  950. * 32^8 ≈ 1.1万亿种组合,正常不会碰撞,此处为防御性设计。
  951. */
  952. private String generateUniqueCode() {
  953. int maxRetries = 5;
  954. for (int attempt = 0; attempt < maxRetries; attempt++) {
  955. String code = com.xuekairui.common.RandomUtil.generateUpperCode(8);
  956. if (inviteCodeMapper.selectCount(
  957. new LambdaQueryWrapper<InviteCode>().eq(InviteCode::getCode, code)) == 0) {
  958. return code;
  959. }
  960. log.warn("邀请码碰撞(attempt={}): {}", attempt + 1, code);
  961. }
  962. throw new BusinessException(ErrorCode.BUSINESS_ERROR, "邀请码生成失败,请重试");
  963. }
  964. /**
  965. * 获取用户对外展示名:
  966. * 优先顺序:微信昵称 > 用户自定义昵称 > 手机尾号 > 兜底文案
  967. */
  968. private String getDisplayName(User user) {
  969. if (user == null) {
  970. return "一位药店用户";
  971. }
  972. if (user.getWechatNickname() != null && !user.getWechatNickname().isBlank()) {
  973. return user.getWechatNickname();
  974. }
  975. if (user.getNickname() != null && !user.getNickname().isBlank()) {
  976. return user.getNickname();
  977. }
  978. if (user.getPhone() != null && user.getPhone().length() >= 4) {
  979. return "手机尾号" + user.getPhone().substring(user.getPhone().length() - 4);
  980. }
  981. return "一位药店用户";
  982. }
  983. /**
  984. * 根据用户ID获取展示名
  985. */
  986. private String getUserNickname(Long userId) {
  987. return getDisplayName(userMapper.selectById(userId));
  988. }
  989. /**
  990. * 实体转响应(含邀请链接,文案从配置读取)
  991. */
  992. private InviteCodeResponse toCodeResponse(InviteCode code, String loginSource) {
  993. log.info("[toCodeResponse] code={}, loginSource={}, userId={}", code.getCode(), loginSource, code.getUserId());
  994. // 获取配置与邀请人信息
  995. InviteConfig config = inviteConfigService.getActiveConfig();
  996. User inviter = userMapper.selectById(code.getUserId());
  997. String inviterNickname = getDisplayName(inviter);
  998. String appName = config.getAppName() != null ? config.getAppName() : "智价云(药店版)";
  999. int rewardMonths = config.getRewardMonths() != null ? config.getRewardMonths() : 1;
  1000. // 根据登录来源生成不同的邀请链接
  1001. String inviteLink;
  1002. if ("MINIAPP".equals(loginSource)) {
  1003. // 小程序来源:调用微信 API 生成 mp:// 链接
  1004. String miniappPath = config.getMiniappPath();
  1005. if (miniappPath != null && !miniappPath.isBlank()) {
  1006. // 构建查询参数(带邀请码)
  1007. String query = "inviteCode=" + code.getCode();
  1008. // 调用微信 API 生成 mp:// 链接
  1009. String mpLink = wechatMiniProgramService.generateMpLink(miniappPath, query);
  1010. if (mpLink != null) {
  1011. inviteLink = mpLink;
  1012. log.info("[toCodeResponse] 生成小程序 mp:// 链接成功: code={}, mpLink={}", code.getCode(), inviteLink);
  1013. } else {
  1014. // 生成失败,降级为普通链接
  1015. inviteLink = inviteBaseUrl + "/api/invite/" + code.getCode();
  1016. log.warn("[toCodeResponse] 生成小程序 mp:// 链接失败,降级为普通链接: code={}", code.getCode());
  1017. }
  1018. } else {
  1019. // 配置不完整,降级为普通链接
  1020. inviteLink = inviteBaseUrl + "/api/invite/" + code.getCode();
  1021. log.warn("[toCodeResponse] miniappPath 配置为空,降级为普通链接: code={}", code.getCode());
  1022. }
  1023. } else {
  1024. // 其他来源:使用下载链接
  1025. inviteLink = inviteBaseUrl + "/api/invite/" + code.getCode();
  1026. log.info("[toCodeResponse] 非小程序来源,生成普通链接: code={}, loginSource={}, link={}", code.getCode(), loginSource, inviteLink);
  1027. }
  1028. // 分享标题:从配置模板读取,支持占位符 {inviter}、{appName}
  1029. String shareTitleTemplate = config.getShareTitleTemplate();
  1030. String shareTitle = (shareTitleTemplate != null && !shareTitleTemplate.isBlank())
  1031. ? shareTitleTemplate.replace("{inviter}", inviterNickname).replace("{appName}", appName)
  1032. : inviterNickname + " 邀请你加入" + appName;
  1033. // 分享描述:使用落地页描述
  1034. String shareDescription = config.getLandingDesc() != null
  1035. && !config.getLandingDesc().isBlank()
  1036. ? config.getLandingDesc() : "注册登录" + appName + "平台,聚合比价查低价";
  1037. // 奖励说明:从配置读取,支持占位符 {rewardMonths}
  1038. String rewardDescTemplate = config.getRewardDescription();
  1039. String rewardDesc = (rewardDescTemplate != null && !rewardDescTemplate.isBlank())
  1040. ? rewardDescTemplate.replace("{rewardMonths}", String.valueOf(rewardMonths))
  1041. : "邀请人每成功邀请1家药店注册,赠送" + rewardMonths + "个月高级会员时长";
  1042. // 一键复制文本:从配置模板读取,支持占位符 {inviter}、{appName}、{inviteLink}、{code}
  1043. String copyTextTemplate = config.getCopyTextTemplate();
  1044. String copyText = (copyTextTemplate != null && !copyTextTemplate.isBlank())
  1045. ? copyTextTemplate
  1046. .replace("{inviter}", inviterNickname)
  1047. .replace("{appName}", appName)
  1048. .replace("{inviteLink}", inviteLink)
  1049. .replace("{code}", code.getCode())
  1050. : String.format(
  1051. "我是%s,在这里发现了一个药店采购神器——%s!它聚合比价功能特别方便,能快速查到最低价,帮你节省采购成本。下载链接%s填我的邀请码 %s 完成注册,你也会获得会员权益!",
  1052. inviterNickname, appName, inviteLink, code.getCode());
  1053. return InviteCodeResponse.builder()
  1054. .code(code.getCode())
  1055. .inviteLink(inviteLink)
  1056. .downloadUrl(inviteLink)
  1057. .inviterNickname(inviterNickname)
  1058. .clickCount(code.getClickCount() != null ? code.getClickCount() : 0)
  1059. .usedCount(code.getUsedCount())
  1060. .maxUses(code.getMaxUses())
  1061. .expireTime(code.getExpireTime())
  1062. .status(code.getStatus())
  1063. .createTime(code.getCreateTime())
  1064. // 新增分享字段
  1065. .shareTitle(shareTitle)
  1066. .shareDescription(shareDescription)
  1067. .copyText(copyText)
  1068. .rewardDescription(rewardDesc)
  1069. .build();
  1070. }
  1071. // ==========================================
  1072. // 新增:用户爬虫详情查询
  1073. // ==========================================
  1074. /**
  1075. * 获取用户爬虫使用详情(含平台分布统计)
  1076. */
  1077. public CrawlerUserDetailResponse getCrawlerUserDetail(Long userId, String phone) {
  1078. if (userId == null && phone != null && !phone.isBlank()) {
  1079. User u = userMapper.selectOne(
  1080. new LambdaQueryWrapper<User>().eq(User::getPhone, phone));
  1081. if (u == null) {
  1082. throw new BusinessException(ErrorCode.USER_NOT_FOUND);
  1083. }
  1084. userId = u.getId();
  1085. }
  1086. if (userId == null) {
  1087. throw new BusinessException(ErrorCode.PARAM_ERROR, "用户ID和手机号至少填写一个");
  1088. }
  1089. User user = userMapper.selectById(userId);
  1090. if (user == null) {
  1091. throw new BusinessException(ErrorCode.USER_NOT_FOUND);
  1092. }
  1093. // 获取总使用次数
  1094. int todayTotal = crawlerUsageLogMapper.sumTodayUsage(userId);
  1095. int monthlyTotal = crawlerUsageLogMapper.sumMonthlyUsage(userId);
  1096. // 获取各平台统计
  1097. List<Map<String, Object>> todayByPlatform = crawlerUsageLogMapper.sumTodayUsageByPlatform(userId);
  1098. List<Map<String, Object>> monthlyByPlatform = crawlerUsageLogMapper.sumMonthlyUsageByPlatform(userId);
  1099. List<Map<String, Object>> totalByPlatform = crawlerUsageLogMapper.sumTotalUsageByPlatform(userId);
  1100. // 构建平台统计Map
  1101. Map<String, Integer> todayMap = buildPlatformMap(todayByPlatform);
  1102. Map<String, Integer> monthlyMap = buildPlatformMap(monthlyByPlatform);
  1103. Map<String, Integer> totalMap = buildPlatformMap(totalByPlatform);
  1104. // 合并所有出现过的平台
  1105. Set<String> allPlatforms = new HashSet<>();
  1106. allPlatforms.addAll(todayMap.keySet());
  1107. allPlatforms.addAll(monthlyMap.keySet());
  1108. allPlatforms.addAll(totalMap.keySet());
  1109. // 构建平台统计列表(容错处理:未知平台显示原始code+“其他平台”)
  1110. List<CrawlerPlatformStatsResponse> platformStats = allPlatforms.stream()
  1111. .map(platformCode -> {
  1112. CrawlerPlatform platform = CrawlerPlatform.findByCode(platformCode);
  1113. String pCode = platform != null ? platform.getCode() : platformCode;
  1114. String pName = platform != null ? platform.getName() : "其他平台(" + platformCode + ")";
  1115. return CrawlerPlatformStatsResponse.builder()
  1116. .platformCode(pCode)
  1117. .platformName(pName)
  1118. .todayUsage(todayMap.getOrDefault(platformCode, 0))
  1119. .monthlyUsage(monthlyMap.getOrDefault(platformCode, 0))
  1120. .totalUsage(totalMap.getOrDefault(platformCode, 0))
  1121. .build();
  1122. })
  1123. .sorted(Comparator.comparingInt(CrawlerPlatformStatsResponse::getTotalUsage).reversed())
  1124. .collect(Collectors.toList());
  1125. return CrawlerUserDetailResponse.builder()
  1126. .userId(userId)
  1127. .nickname(user.getNickname())
  1128. .phone(ValidateUtil.maskPhone(user.getPhone()))
  1129. .todayTotalUsage(todayTotal)
  1130. .monthlyTotalUsage(monthlyTotal)
  1131. .platformStats(platformStats)
  1132. .build();
  1133. }
  1134. /**
  1135. * 将平台统计结果转换为Map
  1136. */
  1137. private Map<String, Integer> buildPlatformMap(List<Map<String, Object>> rows) {
  1138. Map<String, Integer> map = new HashMap<>();
  1139. for (Map<String, Object> row : rows) {
  1140. String platform = (String) row.get("platform");
  1141. Number usage = (Number) row.get(row.containsKey("today_usage") ? "today_usage"
  1142. : row.containsKey("monthly_usage") ? "monthly_usage" : "total_usage");
  1143. if (platform != null && usage != null) {
  1144. map.put(platform, usage.intValue());
  1145. }
  1146. }
  1147. return map;
  1148. }
  1149. // ==========================================
  1150. // 新增:邀请转化统计
  1151. // ==========================================
  1152. /**
  1153. * 获取用户邀请转化统计(点击-注册转化率)
  1154. */
  1155. public InviteConversionStatsResponse getInviteConversionStats(Long userId, String phone, String loginSource) {
  1156. if (userId == null && phone != null && !phone.isBlank()) {
  1157. User u = userMapper.selectOne(
  1158. new LambdaQueryWrapper<User>().eq(User::getPhone, phone));
  1159. if (u == null) {
  1160. throw new BusinessException(ErrorCode.USER_NOT_FOUND);
  1161. }
  1162. userId = u.getId();
  1163. }
  1164. if (userId == null) {
  1165. throw new BusinessException(ErrorCode.PARAM_ERROR, "用户ID和手机号至少填写一个");
  1166. }
  1167. // 获取用户邀请码
  1168. InviteCodeResponse codeResponse = getOrCreateInviteCode(userId, loginSource);
  1169. // 获取邀请码实体
  1170. InviteCode myCode = inviteCodeMapper.selectOne(
  1171. new LambdaQueryWrapper<InviteCode>()
  1172. .eq(InviteCode::getUserId, userId)
  1173. .eq(InviteCode::getCode, codeResponse.getCode()));
  1174. int clickedCount = myCode != null ? myCode.getClickCount() : 0;
  1175. // 获取所有邀请关系
  1176. List<InviteRelation> relations = inviteRelationMapper.selectList(
  1177. new LambdaQueryWrapper<InviteRelation>()
  1178. .eq(InviteRelation::getInviterId, userId));
  1179. int totalInvited = relations.size();
  1180. int registeredCount = (int) relations.stream()
  1181. .filter(r -> r.getRegistered() != null && r.getRegistered() == 1)
  1182. .count();
  1183. int pendingCount = totalInvited - registeredCount;
  1184. // 计算转化率
  1185. double conversionRate = 0.0;
  1186. if (clickedCount > 0) {
  1187. conversionRate = Math.round((double) registeredCount / clickedCount * 10000.0) / 100.0;
  1188. }
  1189. // 累计奖励
  1190. int totalReward = relations.stream()
  1191. .mapToInt(InviteRelation::getRewardGranted)
  1192. .sum();
  1193. return InviteConversionStatsResponse.builder()
  1194. .inviteCode(codeResponse.getCode())
  1195. .clickedCount(clickedCount)
  1196. .totalInvited(totalInvited)
  1197. .registeredCount(registeredCount)
  1198. .pendingCount(pendingCount)
  1199. .conversionRate(conversionRate)
  1200. .totalReward(totalReward)
  1201. .build();
  1202. }
  1203. }