package com.xuekairui.invite.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.xuekairui.common.BusinessException; import com.xuekairui.common.ErrorCode; import com.xuekairui.common.ValidateUtil; import com.xuekairui.common.activity.ActivityInviteGate; import com.xuekairui.invite.dto.InviteCodeResponse; import com.xuekairui.invite.dto.InviteConversionStatsResponse; import com.xuekairui.invite.dto.InviteLinkResolveResponse; import com.xuekairui.invite.dto.InvitePageResponse; import com.xuekairui.invite.dto.InviteRewardResponse; import com.xuekairui.invite.dto.InviteStatsResponse; import com.xuekairui.invite.dto.MyInviterResponse; import com.xuekairui.invite.dto.QuotaGrantRequest; import com.xuekairui.invite.entity.InviteCode; import com.xuekairui.invite.entity.InviteConfig; import com.xuekairui.invite.entity.InviteRelation; import com.xuekairui.invite.mapper.InviteCodeMapper; import com.xuekairui.invite.mapper.InviteRelationMapper; import com.xuekairui.user.dto.CrawlerPlatformStatsResponse; import com.xuekairui.user.dto.CrawlerQuotaGrantResponse; import com.xuekairui.user.dto.CrawlerUserDetailResponse; import com.xuekairui.user.entity.CrawlerPlatform; import com.xuekairui.user.entity.CrawlerQuotaGrant; import com.xuekairui.user.entity.User; import com.xuekairui.user.enums.LicenseStatus; import com.xuekairui.user.mapper.CrawlerUsageLogMapper; import com.xuekairui.user.mapper.UserMapper; import com.xuekairui.user.service.CrawlerQuotaGrantService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.jetbrains.annotations.NotNull; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.Duration; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; /** * 邀请核心服务 * 处理邀请码生成、邀请关系建立、奖励发放、运营手动发放等 */ @Slf4j @Service @RequiredArgsConstructor public class InviteService { private final InviteConfigService inviteConfigService; private final InviteCodeMapper inviteCodeMapper; private final InviteRelationMapper inviteRelationMapper; private final CrawlerQuotaGrantService crawlerQuotaGrantService; private final UserMapper userMapper; private final CrawlerUsageLogMapper crawlerUsageLogMapper; private final com.xuekairui.user.service.CrawlerService crawlerService; private final com.xuekairui.user.service.BusinessLicenseService businessLicenseService; private final com.xuekairui.user.service.WechatMiniProgramService wechatMiniProgramService; private final StringRedisTemplate stringRedisTemplate; private static final String INVITE_CODE_CACHE_PREFIX = "invite:code:user:"; private static final Duration INVITE_CODE_CACHE_TTL = Duration.ofMinutes(5); /** 活动模块可选依赖:未加载时跳过活动相关发奖 */ @Autowired(required = false) private ActivityInviteGate activityInviteGate; /** 邀请链接基础URL,可通过配置覆盖 */ @Value("${invite.base-url:https://app.zhijiayun.com}") private String inviteBaseUrl; /** 客户端安装包本地存储目录 */ @Value("${invite.download-base-path:./downloads}") private String downloadBasePath; /** Windows 安装包文件名 */ @Value("${invite.windows-filename:zhijiayun-x64.exe}") private String windowsFilename; /** macOS 安装包文件名 */ @Value("${invite.mac-filename:zhijiayun-arm64.dmg}") private String macFilename; /** 补填邀请码有效窗口(天),默认30天 */ @Value("${invite.bind-window-days:30}") private int bindWindowDays; // ========================================== // 邀请码管理 // ========================================== /** * 获取或创建用户的邀请码 * 使用 Redis 缓存防止并发创建多个邀请码 */ public InviteCodeResponse getOrCreateInviteCode(Long userId, String loginSource) { String cacheKey = INVITE_CODE_CACHE_PREFIX + userId; // 1. 先从 Redis 缓存中查找 String cachedCode = stringRedisTemplate.opsForValue().get(cacheKey); if (cachedCode != null) { InviteCode cachedInviteCode = inviteCodeMapper.selectOne( new LambdaQueryWrapper() .eq(InviteCode::getCode, cachedCode) .eq(InviteCode::getStatus, 1)); if (cachedInviteCode != null) { log.debug("从缓存获取邀请码: userId={}, code={}", userId, cachedCode); return toCodeResponse(cachedInviteCode, loginSource); } } // 2. 从数据库查找已有的有效邀请码 InviteCode existingCode = inviteCodeMapper.selectOne( new LambdaQueryWrapper() .eq(InviteCode::getUserId, userId) .eq(InviteCode::getStatus, 1) .and(w -> w.isNull(InviteCode::getExpireTime) .or().gt(InviteCode::getExpireTime, LocalDateTime.now())) .orderByDesc(InviteCode::getCreateTime) .last("LIMIT 1")); if (existingCode != null) { // 写入缓存 stringRedisTemplate.opsForValue().set(cacheKey, existingCode.getCode(), INVITE_CODE_CACHE_TTL); return toCodeResponse(existingCode, loginSource); } // 3. 使用 Redis 分布式锁防止并发创建 String lockKey = "invite:code:lock:" + userId; Boolean locked = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "1", Duration.ofSeconds(10)); if (locked == null || !locked) { // 未获取到锁,等待后重试查询 try { Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } // 重新查询 InviteCode retryCode = inviteCodeMapper.selectOne( new LambdaQueryWrapper() .eq(InviteCode::getUserId, userId) .eq(InviteCode::getStatus, 1) .orderByDesc(InviteCode::getCreateTime) .last("LIMIT 1")); if (retryCode != null) { stringRedisTemplate.opsForValue().set(cacheKey, retryCode.getCode(), INVITE_CODE_CACHE_TTL); return toCodeResponse(retryCode, loginSource); } throw new BusinessException(ErrorCode.BUSINESS_ERROR, "系统繁忙,请重试"); } try { // 4. 再次检查(双重检查) existingCode = inviteCodeMapper.selectOne( new LambdaQueryWrapper() .eq(InviteCode::getUserId, userId) .eq(InviteCode::getStatus, 1) .orderByDesc(InviteCode::getCreateTime) .last("LIMIT 1")); if (existingCode != null) { stringRedisTemplate.opsForValue().set(cacheKey, existingCode.getCode(), INVITE_CODE_CACHE_TTL); return toCodeResponse(existingCode, loginSource); } // 5. 创建新邀请码 InviteConfig config = inviteConfigService.getActiveConfig(); LocalDateTime expireTime = LocalDateTime.now().plusDays(config.getInviteCodeExpireDays()); String code = generateUniqueCode(); InviteCode newCode = InviteCode.builder() .userId(userId) .code(code) .maxUses(-1) .usedCount(0) .clickCount(0) .expireTime(expireTime) .status(1) .build(); inviteCodeMapper.insert(newCode); log.info("用户 {} 创建邀请码: {}", userId, code); // 6. 写入缓存 stringRedisTemplate.opsForValue().set(cacheKey, code, INVITE_CODE_CACHE_TTL); return toCodeResponse(newCode, loginSource); } finally { // 7. 释放锁 stringRedisTemplate.delete(lockKey); } } // ========================================== // 落地页 & 链接追踪(公开接口) // ========================================== /** * 获取邀请落地页数据(公开,无需登录) * 浏览器打开邀请链接时调用,展示邀请人信息和下载链接 * 支持多渠道:app、wechat、miniapp、dingtalk、feishu * * @param code 邀请码 * @param channel 渠道(可选,默认app) */ public InvitePageResponse getInvitePage(String code, String channel) { log.info("[getInvitePage] code={}, channel={}", code, channel); InviteCode inviteCode = inviteCodeMapper.selectOne( new LambdaQueryWrapper() .eq(InviteCode::getCode, code)); if (inviteCode == null) { throw new BusinessException(ErrorCode.INVITE_CODE_INVALID); } // 自动计入点击数(浏览器打开即算一次点击) inviteCode.setClickCount(inviteCode.getClickCount() + 1); inviteCodeMapper.updateById(inviteCode); log.debug("邀请落地页 {} 被访问,累计点击 {} 次", code, inviteCode.getClickCount()); // 获取邀请人信息 User inviter = userMapper.selectById(inviteCode.getUserId()); String inviterNickname = getDisplayName(inviter); String inviterAvatar = inviter != null ? inviter.getAvatar() : null; // 获取配置 InviteConfig config = inviteConfigService.getActiveConfig(); // 默认渠道(当前主推 Windows 桌面客户端) if (channel == null || channel.isBlank()) { channel = "windows"; } // 根据渠道确定打开方式 String openType = determineOpenType(channel); // 构建小程序路径(含邀请码参数) String miniappPath = config.getMiniappPath(); String mpLink = null; if ("miniapp".equals(channel) && miniappPath != null && !miniappPath.isBlank()) { String query = "inviteCode=" + code; mpLink = wechatMiniProgramService.generateMpLink(miniappPath, query); if (mpLink != null) { log.info("[getInvitePage] 落地页生成小程序 mp:// 链接成功: code={}, mpLink={}", code, mpLink); } else { if (!miniappPath.contains("?")) { miniappPath = miniappPath + "?inviteCode=" + code; } else { miniappPath = miniappPath + "&inviteCode=" + code; } log.warn("[getInvitePage] 落地页生成小程序 mp:// 链接失败,降级为普通路径: code={}", code); } } else if (miniappPath != null && !miniappPath.contains("?")) { miniappPath = miniappPath + "?inviteCode=" + code; } else if (miniappPath != null) { miniappPath = miniappPath + "&inviteCode=" + code; } // 生成URL Scheme和DeepLink(用于客户端自动填入邀请码) String urlScheme = "zhijiayun://invite?code=" + code; String deepLink = "https://app.zhijiayun.com/invite/" + code + "?autoFill=true"; // 根据渠道生成提示文案 String instructionText = generateInstructionText(code, channel, config); // 落地页下载按钮指向 /api/invite/{code},服务端按设备流式返回对应安装包 String inviteDownloadUrl = inviteBaseUrl + "/api/invite/" + code; // Windows/app 渠道使用邀请链接作为下载入口(自动识别设备并重定向),其他渠道保持配置中的下载地址 String appDownloadUrl = ("windows".equals(channel) || "app".equals(channel)) ? inviteDownloadUrl : config.getAppDownloadUrl(); // 处理落地页标题:支持 {inviter} 和 {pharmacy} 占位符 String landingTitle = config.getLandingTitle(); if (landingTitle == null || landingTitle.isBlank()) { // 没有配置时,使用邀请人+药店名(如有)个性化展示 String pharmacyName = inviter != null ? inviter.getPharmacyName() : null; if (pharmacyName != null && !pharmacyName.isBlank()) { landingTitle = inviterNickname + " · " + pharmacyName + " 邀请您使用"; } else { landingTitle = inviterNickname + " 邀请您使用" + config.getAppName(); } } else { // 替换占位符 if (landingTitle.contains("{inviter}")) { landingTitle = landingTitle.replace("{inviter}", inviterNickname); } if (inviter != null && inviter.getPharmacyName() != null && landingTitle.contains("{pharmacy}")) { landingTitle = landingTitle.replace("{pharmacy}", inviter.getPharmacyName()); } } return InvitePageResponse.builder() .inviterNickname(inviterNickname) .inviterAvatar(inviterAvatar) .inviteCode(code) .channel(channel) .openType(openType) .appName(config.getAppName()) .landingTitle(landingTitle) .landingDesc(config.getLandingDesc()) // 渠道特定字段 .appDownloadUrl(appDownloadUrl) .miniappPath(miniappPath) .mpLink(mpLink) .miniappAppId(config.getMiniappAppId()) .wechatRedirectUrl(config.getWechatRedirectUrl()) .dingtalkAppId(config.getDingtalkAppId()) .feishuAppId(config.getFeishuAppId()) .rewardPerRegister(config.getRewardMonths() != null ? config.getRewardMonths() : 1) // 新增:自动填入支持 .autoFillSupported(true) .urlScheme(urlScheme) .deepLink(deepLink) .instructionText(instructionText) .build(); } /** * 根据渠道生成操作提示文案(从配置读取,支持占位符 {code}、{appName}) */ private String generateInstructionText(String code, String channel, InviteConfig config) { String template = switch (channel) { case "windows" -> config.getInstructionWindows(); case "app" -> config.getInstructionApp(); case "miniapp" -> config.getInstructionMiniapp(); case "wechat" -> config.getInstructionWechat(); case "dingtalk" -> config.getInstructionDingtalk(); case "feishu" -> config.getInstructionFeishu(); default -> config.getInstructionDefault(); }; if (template == null || template.isBlank()) { template = "下载客户端后,在注册页面输入邀请码:" + code + " 即可享受奖励"; } return template.replace("{code}", code) .replace("{appName}", config.getAppName() != null ? config.getAppName() : "智价云(药店版)"); } /** * 根据渠道确定打开方式 */ private String determineOpenType(String channel) { return switch (channel) { case "windows" -> "download"; // 下载Windows桌面客户端 case "app" -> "download"; // 下载客户端(移动端通用) case "miniapp" -> "miniapp"; // 打开小程序 case "wechat" -> "redirect"; // 跳转公众号/H5 case "dingtalk", "feishu" -> "deeplink"; // 应用内深度链接 default -> "download"; }; } /** * 根据 User-Agent 解析本次应下载的安装包文件 * 其他默认返回 Windows 安装包 */ public java.io.File resolveDownloadFile(String userAgent) { String filename = windowsFilename; if (userAgent != null) { String ua = userAgent.toLowerCase(); boolean isMac = ua.contains("macintosh") || ua.contains("mac os"); boolean isArm = ua.contains("arm64") || ua.contains("aarch64"); if (isMac && isArm) { filename = macFilename; } } return new java.io.File(downloadBasePath, filename); } /** * 构造支持中文文件名的 Content-Disposition 响应头 * 使用 RFC 5987 的 filename*=UTF-8'' 编码,兼容主流浏览器 */ private String buildContentDisposition(String filename) { String asciiOnly = filename.replaceAll("[^\\x00-\\x7F]", "_"); String encoded = java.net.URLEncoder.encode(filename, java.nio.charset.StandardCharsets.UTF_8) .replaceAll("\\+", "%20"); return "attachment; filename=\"" + asciiOnly + "\"; filename*=UTF-8''" + encoded; } /** * 流式下载邀请链接对应的安装包(公开,无需登录) * 自动计入点击数,并根据 User-Agent 选择安装包文件写入响应流 */ public void downloadInviteFile(String code, String userAgent, jakarta.servlet.http.HttpServletResponse response) { // 校验邀请码存在性并记录点击 InviteCode inviteCode = inviteCodeMapper.selectOne( new LambdaQueryWrapper() .eq(InviteCode::getCode, code)); if (inviteCode == null) { throw new BusinessException(ErrorCode.INVITE_CODE_INVALID); } inviteCode.setClickCount(inviteCode.getClickCount() + 1); inviteCodeMapper.updateById(inviteCode); log.debug("下载链接 {} 被访问,累计点击 {} 次", code, inviteCode.getClickCount()); java.io.File file = resolveDownloadFile(userAgent); if (!file.exists() || !file.isFile()) { log.error("安装包文件不存在: {}", file.getAbsolutePath()); throw new BusinessException(ErrorCode.BUSINESS_ERROR, "安装包文件不存在,请联系管理员"); } response.setContentType("application/octet-stream"); response.setHeader("Content-Disposition", buildContentDisposition(file.getName())); response.setContentLengthLong(file.length()); try (java.io.InputStream in = new java.io.FileInputStream(file); java.io.OutputStream out = response.getOutputStream()) { byte[] buffer = new byte[8192]; int len; while ((len = in.read(buffer)) != -1) { out.write(buffer, 0, len); } out.flush(); } catch (java.io.IOException e) { log.error("流式下载安装包失败: {}", file.getAbsolutePath(), e); throw new BusinessException(ErrorCode.BUSINESS_ERROR, "下载失败,请重试"); } } /** * 追踪邀请链接被打开(公开,浏览器打开时调用) * 记录到 invite_code.click_count */ public void trackClick(String code) { InviteCode inviteCode = inviteCodeMapper.selectOne( new LambdaQueryWrapper() .eq(InviteCode::getCode, code)); if (inviteCode != null) { inviteCode.setClickCount(inviteCode.getClickCount() + 1); inviteCodeMapper.updateById(inviteCode); log.debug("邀请链接 {} 被打开,累计 {} 次", code, inviteCode.getClickCount()); } } /** * 解析任意邀请链接或邀请码,返回邀请人信息 * 支持: * 1. 纯邀请码:A3K7M9P2 * 2. 完整邀请链接:{@code https://app.zhijiayun.com/invite/A3K7M9P2?channel=wechat} * 3. 带查询参数的链接:...?inviteCode=A3K7M9P2 或 ?code=A3K7M9P2 */ public InviteLinkResolveResponse resolveInviteLink(String linkOrCode) { if (linkOrCode == null || linkOrCode.isBlank()) { throw new BusinessException(ErrorCode.PARAM_ERROR, "邀请链接或邀请码不能为空"); } String trimmed = linkOrCode.trim(); String code = extractInviteCode(trimmed); if (code == null || code.isBlank()) { log.warn("邀请链接解析失败,无法提取邀请码: input={}", trimmed); throw new BusinessException(ErrorCode.INVITE_CODE_INVALID, "无法从输入内容中识别出邀请码,请确认输入的是邀请码或邀请链接"); } InviteCode inviteCode = inviteCodeMapper.selectOne( new LambdaQueryWrapper() .eq(InviteCode::getCode, code)); if (inviteCode == null) { log.warn("邀请码在数据库中不存在: code={}", code); throw new BusinessException(ErrorCode.INVITE_CODE_INVALID, "邀请码 " + code + " 不存在或已失效"); } User inviter = userMapper.selectById(inviteCode.getUserId()); String inviterNickname = getDisplayName(inviter); String inviterAvatar = inviter != null ? inviter.getAvatar() : null; InviteConfig config = inviteConfigService.getActiveConfig(); String landingTitle = getString(config, inviter, inviterNickname); return InviteLinkResolveResponse.builder() .inviteCode(code) .inviteLink(inviteBaseUrl + "/api/invite/" + code) .inviterNickname(inviterNickname) .inviterAvatar(inviterAvatar) .appName(config.getAppName()) .landingTitle(landingTitle) .landingDesc(config.getLandingDesc()) .build(); } @NotNull private static String getString(InviteConfig config, User inviter, String inviterNickname) { String landingTitle = config.getLandingTitle(); if (landingTitle == null || landingTitle.isBlank()) { String pharmacyName = inviter != null ? inviter.getPharmacyName() : null; if (pharmacyName != null && !pharmacyName.isBlank()) { landingTitle = inviterNickname + " · " + pharmacyName + " 邀请您使用"; } else { landingTitle = inviterNickname + " 邀请您使用" + config.getAppName(); } } else { if (landingTitle.contains("{inviter}")) { landingTitle = landingTitle.replace("{inviter}", inviterNickname); } if (inviter != null && inviter.getPharmacyName() != null && landingTitle.contains("{pharmacy}")) { landingTitle = landingTitle.replace("{pharmacy}", inviter.getPharmacyName()); } } return landingTitle; } /** * 从链接或邀请码字符串中提取邀请码 * 支持:纯邀请码、完整URL路径、query参数、以及被截断/编码的URL */ private String extractInviteCode(String linkOrCode) { // 1. 纯邀请码精确匹配 if (CODE_PURE.matcher(linkOrCode).matches()) { log.debug("提取邀请码[纯码匹配]: {}", linkOrCode); return linkOrCode; } // 2. 从路径 /invite/{code} 提取 java.util.regex.Matcher pathMatcher = CODE_PATH.matcher(linkOrCode); if (pathMatcher.find()) { String code = pathMatcher.group(1); log.debug("提取邀请码[路径匹配]: {} from {}", code, linkOrCode); return code; } // 3. 从查询参数 ?inviteCode=XXX 或 ?code=XXX 提取 java.util.regex.Matcher queryMatcher = CODE_QUERY.matcher(linkOrCode); if (queryMatcher.find()) { return queryMatcher.group(1); } // 4. 兜底:从任意位置提取 java.util.regex.Matcher fallbackMatcher = CODE_FALLBACK.matcher(linkOrCode); if (fallbackMatcher.find()) { return fallbackMatcher.group(1); } return null; } // ========================================== // 邀请注册处理 // ========================================== /** * 通过邀请码注册(新用户注册时调用) * @param inviteCodeStr 邀请码 * @param inviteeId 新用户ID */ @Transactional public void processInviteRegistration(String inviteCodeStr, Long inviteeId) { if (inviteCodeStr == null || inviteCodeStr.isBlank()) { return; // 没有邀请码,跳过 } // 1. 查找邀请码 InviteCode inviteCode = inviteCodeMapper.selectOne( new LambdaQueryWrapper() .eq(InviteCode::getCode, inviteCodeStr)); if (inviteCode == null) { log.warn("邀请码无效: {}", inviteCodeStr); return; // 邀请码无效,不阻断注册 } // 2. 校验邀请码状态 if (inviteCode.getStatus() != 1) { log.warn("邀请码已禁用: {}", inviteCodeStr); return; } if (inviteCode.getExpireTime() != null && inviteCode.getExpireTime().isBefore(LocalDateTime.now())) { log.warn("邀请码已过期: {}", inviteCodeStr); return; } if (inviteCode.getMaxUses() != -1 && inviteCode.getUsedCount() >= inviteCode.getMaxUses()) { log.warn("邀请码已达使用上限: {}", inviteCodeStr); return; } Long inviterId = inviteCode.getUserId(); // 3. 不能邀请自己 if (inviterId.equals(inviteeId)) { throw new BusinessException(ErrorCode.INVITE_SELF_FORBIDDEN); } // 4. 检查被邀请人是否已被邀请过(inviter_id 不可变,删 t_invite_relation 也无法绕过) User invitee = userMapper.selectById(inviteeId); if (invitee != null && invitee.getInviterId() != null) { log.warn("用户 {} 已被邀请过, inviterId={}", inviteeId, invitee.getInviterId()); return; } long existingCount = Optional.ofNullable(inviteRelationMapper.selectCount( new LambdaQueryWrapper() .eq(InviteRelation::getInviteeId, inviteeId))).orElse(0L); if (existingCount > 0) { log.warn("用户 {} 已有邀请关系(异常数据)", inviteeId); return; } // 5. 获取邀请配置 InviteConfig config = inviteConfigService.getActiveConfig(); if (config.getStatus() != 1) { log.warn("邀请功能已停用"); return; } // 5.1 奖励统一由活动模块处理(ActivityInviteGate),此处不再做上限校验 // 6. 创建邀请关系(直接标记为已注册) int rewardMonths = config.getRewardMonths() != null ? config.getRewardMonths() : 1; InviteRelation relation = InviteRelation.builder() .inviterId(inviterId) .inviteeId(inviteeId) .inviteCodeId(inviteCode.getId()) .registered(1) .rewardGranted(rewardMonths) .status(1) .build(); inviteRelationMapper.insert(relation); // 7. 更新邀请码使用次数 inviteCode.setUsedCount(inviteCode.getUsedCount() + 1); inviteCodeMapper.updateById(inviteCode); // 8. 写入被邀请人的 inviter_id(不可变,防止删 t_invite_relation 绕过) if (invitee != null && invitee.getInviterId() == null) { invitee.setInviterId(inviterId); userMapper.updateById(invitee); } // 9. 委托活动模块根据 rewardTrigger 配置决定是否发放奖励 if (activityInviteGate != null) { activityInviteGate.grantInviteReward(inviterId, inviteeId); } } /** * 补填邀请码(注册时未填,注册后有效窗口内可补填) * 补填成功后:被邀请人额外获赠1个月普通会员,邀请人获赠1个月同等级会员时长 * * @param userId 当前登录用户ID * @param inviteCodeInput 邀请码或邀请链接 * @return 成功提示信息 */ @Transactional public String bindInviteCode(Long userId, String inviteCodeInput) { if (inviteCodeInput == null || inviteCodeInput.isBlank()) { throw new BusinessException(ErrorCode.PARAM_ERROR, "邀请码不能为空"); } // 1. 提取邀请码(支持纯码或链接) String inviteCodeStr = extractInviteCode(inviteCodeInput.trim()); if (inviteCodeStr == null || inviteCodeStr.isBlank()) { throw new BusinessException(ErrorCode.INVITE_CODE_INVALID, "无法识别邀请码,请检查输入"); } // 2. 查找邀请码 InviteCode inviteCode = inviteCodeMapper.selectOne( new LambdaQueryWrapper() .eq(InviteCode::getCode, inviteCodeStr)); if (inviteCode == null) { throw new BusinessException(ErrorCode.INVITE_CODE_INVALID); } // 3. 校验邀请码状态 if (inviteCode.getStatus() != 1) { throw new BusinessException(ErrorCode.INVITE_CODE_INVALID, "邀请码已禁用"); } if (inviteCode.getExpireTime() != null && inviteCode.getExpireTime().isBefore(LocalDateTime.now())) { throw new BusinessException(ErrorCode.INVITE_CODE_EXPIRED); } Long inviterId = inviteCode.getUserId(); // 4. 不能邀请自己 if (inviterId.equals(userId)) { throw new BusinessException(ErrorCode.INVITE_SELF_FORBIDDEN); } // 5. 加载用户,检查是否已被邀请过(inviter_id 不可变,删表也无法绕过) User user = userMapper.selectById(userId); if (user == null) { throw new BusinessException(ErrorCode.USER_NOT_FOUND); } if (user.getInviterId() != null) { User inviter = userMapper.selectById(user.getInviterId()); String inviterName = inviter != null ? getDisplayName(inviter) : "一位用户"; throw new BusinessException(ErrorCode.INVITE_RELATION_EXISTS, "您已绑定过邀请码,您是由「" + inviterName + "」邀请的,无法重复绑定"); } long existingCount = inviteRelationMapper.selectCount( new LambdaQueryWrapper() .eq(InviteRelation::getInviteeId, userId)); if (existingCount > 0) { // 数据异常:t_user.inviter_id 为空但 t_invite_relation 有记录 log.warn("用户{} inviter_id为空但存在邀请关系(异常数据),修复inviter_id", userId); InviteRelation rel = inviteRelationMapper.selectOne( new LambdaQueryWrapper() .eq(InviteRelation::getInviteeId, userId)); if (rel != null) { user.setInviterId(rel.getInviterId()); userMapper.updateById(user); } throw new BusinessException(ErrorCode.INVITE_RELATION_EXISTS, "您已绑定过邀请码,无法重复绑定"); } // 6. 检查注册时间是否在有效窗口内 if (user.getCreateTime() == null) { throw new BusinessException(ErrorCode.BUSINESS_ERROR, "用户注册时间缺失,无法补填"); } LocalDateTime deadline = user.getCreateTime().plusDays(bindWindowDays); if (LocalDateTime.now().isAfter(deadline)) { throw new BusinessException(ErrorCode.INVITE_BIND_EXPIRED, String.format("注册已超过%d天,无法补填邀请码", bindWindowDays)); } // 7. 获取邀请配置 InviteConfig config = inviteConfigService.getActiveConfig(); if (config.getStatus() != 1) { throw new BusinessException(ErrorCode.BUSINESS_ERROR, "邀请功能已停用"); } // 7.1 奖励统一由活动模块处理,此处不再做上限校验 // 8. 创建邀请关系 int rewardMonths = config.getRewardMonths() != null ? config.getRewardMonths() : 1; InviteRelation relation = InviteRelation.builder() .inviterId(inviterId) .inviteeId(userId) .inviteCodeId(inviteCode.getId()) .registered(1) .rewardGranted(rewardMonths) .status(1) .build(); inviteRelationMapper.insert(relation); // 9. 更新邀请码使用次数 inviteCode.setUsedCount(inviteCode.getUsedCount() + 1); inviteCodeMapper.updateById(inviteCode); // 9.1 写入被邀请人的 inviter_id(不可变,防止删表绕过) user.setInviterId(inviterId); userMapper.updateById(user); // 10. 根据活动配置决定是否发奖 com.xuekairui.user.entity.BusinessLicense license = businessLicenseService.getByUserId(userId); if (license != null && LicenseStatus.APPROVED.name().equals(license.getReviewStatus())) { // 已入驻 → 发奖(依赖活动开关) if (activityInviteGate != null) { activityInviteGate.grantRewardForApprovedUser(inviterId, userId); } else { log.warn("活动模块未加载,无法发放已入驻用户补填奖励: inviter={}, invitee={}", inviterId, userId); } } else if (activityInviteGate != null) { // 未入驻 → 委托活动模块根据 rewardTrigger 配置决定 activityInviteGate.grantInviteReward(inviterId, userId); } log.info("补填邀请码成功: invitee={}, inviter={}", userId, inviterId); return "补填邀请码成功!邀请人获赠1个月会员时长"; } // ========================================== // 运营手动发放 // ========================================== /** * 运营手动发放爬虫次数(通过统一配额服务) */ @Transactional public CrawlerQuotaGrant adminGrantQuota(QuotaGrantRequest request) { Long userId = request.getUserId(); if (userId == null && request.getPhone() != null && !request.getPhone().isBlank()) { User u = userMapper.selectOne( new LambdaQueryWrapper().eq(User::getPhone, request.getPhone())); if (u == null) { throw new BusinessException(ErrorCode.USER_NOT_FOUND); } userId = u.getId(); } if (userId == null) { throw new BusinessException(ErrorCode.PARAM_ERROR, "用户ID和手机号至少填写一个"); } return crawlerQuotaGrantService.grantAdminQuota( userId, request.getQuotaCount(), request.getExpireTime(), request.getRemark()); } // ========================================== // 统计 & 记录查询 // ========================================== /** * 获取用户邀请统计 * 返回如:"已邀请5人,3人注册 +21次" */ public InviteStatsResponse getInviteStats(Long userId, String loginSource) { InviteConfig config = inviteConfigService.getActiveConfig(); // 获取用户邀请码 InviteCodeResponse codeResponse = getOrCreateInviteCode(userId, loginSource); // 所有邀请记录 List allRelations = inviteRelationMapper.selectList( new LambdaQueryWrapper() .eq(InviteRelation::getInviterId, userId)); // 已邀请人数(所有记录) int totalInvited = allRelations.size(); // 已注册人数 int registeredCount = (int) allRelations.stream() .filter(r -> r.getRegistered() != null && r.getRegistered() == 1) .count(); // 累计奖励会员月数 int totalReward = allRelations.stream() .mapToInt(InviteRelation::getRewardGranted) .sum(); // 链接被打开次数 int clickedCount = codeResponse.getClickCount() != null ? codeResponse.getClickCount() : 0; // 今日邀请数 LocalDateTime todayStart = LocalDate.now().atStartOfDay(); Long todayInvited = inviteRelationMapper.selectCount( new LambdaQueryWrapper() .eq(InviteRelation::getInviterId, userId) .ge(InviteRelation::getCreateTime, todayStart)); int todayRemaining = Math.max(0, config.getMaxInvitePerDay() - todayInvited.intValue()); // 从爬虫服务获取统一配额数据(保证与头像/爬虫状态页一致) int crawlerTodayRemaining = 0; int crawlerQuotaFromInvites = 0; try { com.xuekairui.user.dto.CrawlerStatusResponse crawlerStatus = crawlerService.getCrawlerStatus(userId); crawlerTodayRemaining = crawlerStatus.getTodayRemaining() != null ? crawlerStatus.getTodayRemaining() : 0; // 从配额明细中提取邀请奖励的可用配额 if (crawlerStatus.getExtraQuotaBreakdown() != null) { crawlerQuotaFromInvites = crawlerStatus.getExtraQuotaBreakdown().stream() .filter(item -> "INVITE".equals(item.getSourceType())) .mapToInt(item -> item.getAvailable() != null ? item.getAvailable() : 0) .sum(); } } catch (Exception e) { log.warn("获取爬虫配额状态失败: userId={}, error={}", userId, e.getMessage()); } // 生成摘要文本(从配置模板读取,支持占位符 {totalInvited}、{registeredCount}、{totalReward}) String summaryTemplate = config.getSummaryTextTemplate(); String summaryText; if (summaryTemplate != null && !summaryTemplate.isBlank()) { summaryText = summaryTemplate .replace("{totalInvited}", String.valueOf(totalInvited)) .replace("{registeredCount}", String.valueOf(registeredCount)) .replace("{totalReward}", String.valueOf(totalReward)); } else { summaryText = String.format("已邀请%d家药店,%d家注册,累计获得%d个月高级会员", totalInvited, registeredCount, totalReward); } return InviteStatsResponse.builder() .myInviteCode(codeResponse.getCode()) .inviteLink(codeResponse.getInviteLink()) .clickedCount(clickedCount) .totalInvited(totalInvited) .registeredCount(registeredCount) .totalReward(totalReward) .summaryText(summaryText) .todayInvited(todayInvited.intValue()) .todayRemaining(todayRemaining) .rewardPerInvite(config.getRewardMonths() != null ? config.getRewardMonths() : 1) .crawlerTodayRemaining(crawlerTodayRemaining) .crawlerQuotaFromInvites(crawlerQuotaFromInvites) .build(); } public List getInviteRewards(Long userId) { return getInviteRewards(userId, 1, Integer.MAX_VALUE).getRecords(); } /** * 分页查询邀请记录(批量查询被邀请人,避免 N+1) */ public Page getInviteRewards(Long userId, int pageNum, int pageSize) { Page page = new Page<>(pageNum, pageSize); LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .eq(InviteRelation::getInviterId, userId) .orderByDesc(InviteRelation::getCreateTime); Page result = inviteRelationMapper.selectPage(page, wrapper); List relations = result.getRecords(); if (relations.isEmpty()) { return new Page<>(pageNum, pageSize, 0); } // 批量查被邀请人和邀请码 List inviteeIds = relations.stream() .map(InviteRelation::getInviteeId).distinct().toList(); Map userMap = userMapper.selectByIds(inviteeIds).stream() .collect(Collectors.toMap(User::getId, u -> u)); List codeIds = relations.stream() .map(InviteRelation::getInviteCodeId).filter(Objects::nonNull).distinct().toList(); Map codeMap = codeIds.isEmpty() ? Map.of() : inviteCodeMapper.selectByIds(codeIds).stream() .collect(Collectors.toMap(InviteCode::getId, InviteCode::getCode)); // 批量查入驻状态 Map licenseStatusMap = businessLicenseService.getLicenseStatusByUserIds(inviteeIds); // 邀请人信息只查一次 String inviterNickname = getUserNickname(userId); List list = relations.stream().map(r -> { boolean isRegistered = r.getRegistered() != null && r.getRegistered() == 1; User invitee = userMap.get(r.getInviteeId()); String inviteeNickname = isRegistered ? getDisplayName(invitee) : "待注册"; String inviteePhone = invitee != null ? ValidateUtil.maskPhone(invitee.getPhone()) : null; String inviteeLevel = invitee != null && invitee.getMembershipLevel() != null ? invitee.getMembershipLevel().getCode() : "PLUS"; return InviteRewardResponse.builder() .inviterId(r.getInviterId()) .inviterNickname(inviterNickname) .inviteeId(r.getInviteeId()) .inviteeNickname(inviteeNickname) .inviteePhone(inviteePhone) .inviteeMembershipLevel(inviteeLevel) .registered(r.getRegistered() != null ? r.getRegistered() : 0) .registeredText(isRegistered ? "已注册" : "待注册") .rewardCount(r.getRewardGranted()) .inviteCode(r.getInviteCodeId() != null ? codeMap.get(r.getInviteCodeId()) : null) .licenseStatus(licenseStatusMap.getOrDefault(r.getInviteeId(), LicenseStatus.NOT_SUBMITTED.name())) .inviteTime(r.getCreateTime() != null ? r.getCreateTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) : null) .build(); }).collect(Collectors.toList()); Page responsePage = new Page<>(pageNum, pageSize, result.getTotal()); responsePage.setRecords(list); return responsePage; } /** * 查询是谁邀请了我(被邀请人视角) * 从 t_invite_relation 中查找 inviteeId = 当前用户的记录 */ public MyInviterResponse getMyInviter(Long userId) { InviteRelation relation = inviteRelationMapper.selectOne( new LambdaQueryWrapper() .eq(InviteRelation::getInviteeId, userId) .orderByDesc(InviteRelation::getCreateTime) .last("LIMIT 1")); if (relation == null) { return MyInviterResponse.builder() .invited(false) .build(); } User inviter = userMapper.selectById(relation.getInviterId()); InviteCode inviteCode = relation.getInviteCodeId() != null ? inviteCodeMapper.selectById(relation.getInviteCodeId()) : null; return MyInviterResponse.builder() .invited(true) .inviterId(relation.getInviterId()) .inviterNickname(getDisplayName(inviter)) .inviterPhone(inviter != null ? ValidateUtil.maskPhone(inviter.getPhone()) : null) .inviterAvatar(inviter != null ? inviter.getAvatar() : null) .pharmacyName(inviter != null ? inviter.getPharmacyName() : null) .inviteCode(inviteCode != null ? inviteCode.getCode() : null) .inviteTime(relation.getCreateTime() != null ? relation.getCreateTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) : null) .build(); } /** * 获取用户可用的额外爬虫次数 */ public int getAvailableExtraQuota(Long userId) { return crawlerQuotaGrantService.getAvailableExtraQuota(userId); } /** * 获取发放记录列表 */ public Page listGrants(Long userId, String grantType, int page, int size) { return crawlerQuotaGrantService.listGrants(userId, grantType, page, size); } // ========================================== // 邀请码常量 // ========================================== /** 邀请码字符集(32个,去掉易混淆的 I/O/0/1) */ private static final String CODE_RE = "[A-HJ-NP-Z2-9]{8}"; /** 纯邀请码精确匹配 */ private static final java.util.regex.Pattern CODE_PURE = java.util.regex.Pattern.compile("^" + CODE_RE + "$"); /** 从 URL 路径 /invite/{code} 提取 */ private static final java.util.regex.Pattern CODE_PATH = java.util.regex.Pattern.compile("/invite/(" + CODE_RE + ")(?:[?/#]|$)"); /** 从 query 参数提取 */ private static final java.util.regex.Pattern CODE_QUERY = java.util.regex.Pattern.compile("[?&](?:inviteCode|code)=(" + CODE_RE + ")(?:&|$|#)"); /** 从任意位置提取(兜底) */ private static final java.util.regex.Pattern CODE_FALLBACK = java.util.regex.Pattern.compile("(" + CODE_RE + ")"); // ========================================== // 私有工具方法 // ========================================== /** * 生成唯一邀请码(8位大写字母+数字) *

* 防重复:先查 DB(快速路径),再用 uk_code 唯一索引兜底。 * 32^8 ≈ 1.1万亿种组合,正常不会碰撞,此处为防御性设计。 */ private String generateUniqueCode() { int maxRetries = 5; for (int attempt = 0; attempt < maxRetries; attempt++) { String code = com.xuekairui.common.RandomUtil.generateUpperCode(8); if (inviteCodeMapper.selectCount( new LambdaQueryWrapper().eq(InviteCode::getCode, code)) == 0) { return code; } log.warn("邀请码碰撞(attempt={}): {}", attempt + 1, code); } throw new BusinessException(ErrorCode.BUSINESS_ERROR, "邀请码生成失败,请重试"); } /** * 获取用户对外展示名: * 优先顺序:微信昵称 > 用户自定义昵称 > 手机尾号 > 兜底文案 */ private String getDisplayName(User user) { if (user == null) { return "一位药店用户"; } if (user.getWechatNickname() != null && !user.getWechatNickname().isBlank()) { return user.getWechatNickname(); } if (user.getNickname() != null && !user.getNickname().isBlank()) { return user.getNickname(); } if (user.getPhone() != null && user.getPhone().length() >= 4) { return "手机尾号" + user.getPhone().substring(user.getPhone().length() - 4); } return "一位药店用户"; } /** * 根据用户ID获取展示名 */ private String getUserNickname(Long userId) { return getDisplayName(userMapper.selectById(userId)); } /** * 实体转响应(含邀请链接,文案从配置读取) */ private InviteCodeResponse toCodeResponse(InviteCode code, String loginSource) { log.info("[toCodeResponse] code={}, loginSource={}, userId={}", code.getCode(), loginSource, code.getUserId()); // 获取配置与邀请人信息 InviteConfig config = inviteConfigService.getActiveConfig(); User inviter = userMapper.selectById(code.getUserId()); String inviterNickname = getDisplayName(inviter); String appName = config.getAppName() != null ? config.getAppName() : "智价云(药店版)"; int rewardMonths = config.getRewardMonths() != null ? config.getRewardMonths() : 1; // 根据登录来源生成不同的邀请链接 String inviteLink; if ("MINIAPP".equals(loginSource)) { // 小程序来源:调用微信 API 生成 mp:// 链接 String miniappPath = config.getMiniappPath(); if (miniappPath != null && !miniappPath.isBlank()) { // 构建查询参数(带邀请码) String query = "inviteCode=" + code.getCode(); // 调用微信 API 生成 mp:// 链接 String mpLink = wechatMiniProgramService.generateMpLink(miniappPath, query); if (mpLink != null) { inviteLink = mpLink; log.info("[toCodeResponse] 生成小程序 mp:// 链接成功: code={}, mpLink={}", code.getCode(), inviteLink); } else { // 生成失败,降级为普通链接 inviteLink = inviteBaseUrl + "/api/invite/" + code.getCode(); log.warn("[toCodeResponse] 生成小程序 mp:// 链接失败,降级为普通链接: code={}", code.getCode()); } } else { // 配置不完整,降级为普通链接 inviteLink = inviteBaseUrl + "/api/invite/" + code.getCode(); log.warn("[toCodeResponse] miniappPath 配置为空,降级为普通链接: code={}", code.getCode()); } } else { // 其他来源:使用下载链接 inviteLink = inviteBaseUrl + "/api/invite/" + code.getCode(); log.info("[toCodeResponse] 非小程序来源,生成普通链接: code={}, loginSource={}, link={}", code.getCode(), loginSource, inviteLink); } // 分享标题:从配置模板读取,支持占位符 {inviter}、{appName} String shareTitleTemplate = config.getShareTitleTemplate(); String shareTitle = (shareTitleTemplate != null && !shareTitleTemplate.isBlank()) ? shareTitleTemplate.replace("{inviter}", inviterNickname).replace("{appName}", appName) : inviterNickname + " 邀请你加入" + appName; // 分享描述:使用落地页描述 String shareDescription = config.getLandingDesc() != null && !config.getLandingDesc().isBlank() ? config.getLandingDesc() : "注册登录" + appName + "平台,聚合比价查低价"; // 奖励说明:从配置读取,支持占位符 {rewardMonths} String rewardDescTemplate = config.getRewardDescription(); String rewardDesc = (rewardDescTemplate != null && !rewardDescTemplate.isBlank()) ? rewardDescTemplate.replace("{rewardMonths}", String.valueOf(rewardMonths)) : "邀请人每成功邀请1家药店注册,赠送" + rewardMonths + "个月高级会员时长"; // 一键复制文本:从配置模板读取,支持占位符 {inviter}、{appName}、{inviteLink}、{code} String copyTextTemplate = config.getCopyTextTemplate(); String copyText = (copyTextTemplate != null && !copyTextTemplate.isBlank()) ? copyTextTemplate .replace("{inviter}", inviterNickname) .replace("{appName}", appName) .replace("{inviteLink}", inviteLink) .replace("{code}", code.getCode()) : String.format( "我是%s,在这里发现了一个药店采购神器——%s!它聚合比价功能特别方便,能快速查到最低价,帮你节省采购成本。下载链接%s填我的邀请码 %s 完成注册,你也会获得会员权益!", inviterNickname, appName, inviteLink, code.getCode()); return InviteCodeResponse.builder() .code(code.getCode()) .inviteLink(inviteLink) .downloadUrl(inviteLink) .inviterNickname(inviterNickname) .clickCount(code.getClickCount() != null ? code.getClickCount() : 0) .usedCount(code.getUsedCount()) .maxUses(code.getMaxUses()) .expireTime(code.getExpireTime()) .status(code.getStatus()) .createTime(code.getCreateTime()) // 新增分享字段 .shareTitle(shareTitle) .shareDescription(shareDescription) .copyText(copyText) .rewardDescription(rewardDesc) .build(); } // ========================================== // 新增:用户爬虫详情查询 // ========================================== /** * 获取用户爬虫使用详情(含平台分布统计) */ public CrawlerUserDetailResponse getCrawlerUserDetail(Long userId, String phone) { if (userId == null && phone != null && !phone.isBlank()) { User u = userMapper.selectOne( new LambdaQueryWrapper().eq(User::getPhone, phone)); if (u == null) { throw new BusinessException(ErrorCode.USER_NOT_FOUND); } userId = u.getId(); } if (userId == null) { throw new BusinessException(ErrorCode.PARAM_ERROR, "用户ID和手机号至少填写一个"); } User user = userMapper.selectById(userId); if (user == null) { throw new BusinessException(ErrorCode.USER_NOT_FOUND); } // 获取总使用次数 int todayTotal = crawlerUsageLogMapper.sumTodayUsage(userId); int monthlyTotal = crawlerUsageLogMapper.sumMonthlyUsage(userId); // 获取各平台统计 List> todayByPlatform = crawlerUsageLogMapper.sumTodayUsageByPlatform(userId); List> monthlyByPlatform = crawlerUsageLogMapper.sumMonthlyUsageByPlatform(userId); List> totalByPlatform = crawlerUsageLogMapper.sumTotalUsageByPlatform(userId); // 构建平台统计Map Map todayMap = buildPlatformMap(todayByPlatform); Map monthlyMap = buildPlatformMap(monthlyByPlatform); Map totalMap = buildPlatformMap(totalByPlatform); // 合并所有出现过的平台 Set allPlatforms = new HashSet<>(); allPlatforms.addAll(todayMap.keySet()); allPlatforms.addAll(monthlyMap.keySet()); allPlatforms.addAll(totalMap.keySet()); // 构建平台统计列表(容错处理:未知平台显示原始code+“其他平台”) List platformStats = allPlatforms.stream() .map(platformCode -> { CrawlerPlatform platform = CrawlerPlatform.findByCode(platformCode); String pCode = platform != null ? platform.getCode() : platformCode; String pName = platform != null ? platform.getName() : "其他平台(" + platformCode + ")"; return CrawlerPlatformStatsResponse.builder() .platformCode(pCode) .platformName(pName) .todayUsage(todayMap.getOrDefault(platformCode, 0)) .monthlyUsage(monthlyMap.getOrDefault(platformCode, 0)) .totalUsage(totalMap.getOrDefault(platformCode, 0)) .build(); }) .sorted(Comparator.comparingInt(CrawlerPlatformStatsResponse::getTotalUsage).reversed()) .collect(Collectors.toList()); return CrawlerUserDetailResponse.builder() .userId(userId) .nickname(user.getNickname()) .phone(ValidateUtil.maskPhone(user.getPhone())) .todayTotalUsage(todayTotal) .monthlyTotalUsage(monthlyTotal) .platformStats(platformStats) .build(); } /** * 将平台统计结果转换为Map */ private Map buildPlatformMap(List> rows) { Map map = new HashMap<>(); for (Map row : rows) { String platform = (String) row.get("platform"); Number usage = (Number) row.get(row.containsKey("today_usage") ? "today_usage" : row.containsKey("monthly_usage") ? "monthly_usage" : "total_usage"); if (platform != null && usage != null) { map.put(platform, usage.intValue()); } } return map; } // ========================================== // 新增:邀请转化统计 // ========================================== /** * 获取用户邀请转化统计(点击-注册转化率) */ public InviteConversionStatsResponse getInviteConversionStats(Long userId, String phone, String loginSource) { if (userId == null && phone != null && !phone.isBlank()) { User u = userMapper.selectOne( new LambdaQueryWrapper().eq(User::getPhone, phone)); if (u == null) { throw new BusinessException(ErrorCode.USER_NOT_FOUND); } userId = u.getId(); } if (userId == null) { throw new BusinessException(ErrorCode.PARAM_ERROR, "用户ID和手机号至少填写一个"); } // 获取用户邀请码 InviteCodeResponse codeResponse = getOrCreateInviteCode(userId, loginSource); // 获取邀请码实体 InviteCode myCode = inviteCodeMapper.selectOne( new LambdaQueryWrapper() .eq(InviteCode::getUserId, userId) .eq(InviteCode::getCode, codeResponse.getCode())); int clickedCount = myCode != null ? myCode.getClickCount() : 0; // 获取所有邀请关系 List relations = inviteRelationMapper.selectList( new LambdaQueryWrapper() .eq(InviteRelation::getInviterId, userId)); int totalInvited = relations.size(); int registeredCount = (int) relations.stream() .filter(r -> r.getRegistered() != null && r.getRegistered() == 1) .count(); int pendingCount = totalInvited - registeredCount; // 计算转化率 double conversionRate = 0.0; if (clickedCount > 0) { conversionRate = Math.round((double) registeredCount / clickedCount * 10000.0) / 100.0; } // 累计奖励 int totalReward = relations.stream() .mapToInt(InviteRelation::getRewardGranted) .sum(); return InviteConversionStatsResponse.builder() .inviteCode(codeResponse.getCode()) .clickedCount(clickedCount) .totalInvited(totalInvited) .registeredCount(registeredCount) .pendingCount(pendingCount) .conversionRate(conversionRate) .totalReward(totalReward) .build(); } }