InviteService.java 45 KB

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