InviteService.java 55 KB

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