InviteService.java 53 KB

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