package com.xuekairui.invite.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.xuekairui.common.BusinessException; import com.xuekairui.common.ErrorCode; import com.xuekairui.common.ValidateUtil; import com.xuekairui.invite.dto.*; import com.xuekairui.invite.entity.*; import com.xuekairui.invite.mapper.InviteCodeMapper; import com.xuekairui.invite.mapper.InviteRelationMapper; import com.xuekairui.user.dto.CrawlerPlatformStatsResponse; 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.mapper.CrawlerUsageLogMapper; import com.xuekairui.user.mapper.UserMapper; import com.xuekairui.user.service.CrawlerQuotaGrantService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.*; 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.MembershipService membershipService; /** 邀请链接基础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; // ========================================== // 邀请码管理 // ========================================== /** * 获取或创建用户的邀请码 */ public InviteCodeResponse getOrCreateInviteCode(Long userId) { // 查找用户已有的有效邀请码 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) { return toCodeResponse(existingCode); } // 创建新邀请码 InviteConfig config = inviteConfigService.getActiveConfig(); String code = generateUniqueCode(); LocalDateTime expireTime = LocalDateTime.now().plusDays(config.getInviteCodeExpireDays()); 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); return toCodeResponse(newCode); } // ========================================== // 落地页 & 链接追踪(公开接口) // ========================================== /** * 获取邀请落地页数据(公开,无需登录) * 浏览器打开邀请链接时调用,展示邀请人信息和下载链接 * 支持多渠道:app、wechat、miniapp、dingtalk、feishu * * @param code 邀请码 * @param channel 渠道(可选,默认app) * @param userAgent 浏览器 User-Agent,用于判断桌面客户端下载包 */ public InvitePageResponse getInvitePage(String code, String channel, String userAgent) { 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, config); // 构建小程序路径(含邀请码参数) String miniappPath = config.getMiniappPath(); 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) .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(); } /** * 根据渠道生成操作提示文案 * * @param code 邀请码 * @param channel 渠道 * @param config 邀请配置 */ private String generateInstructionText(String code, String channel, InviteConfig config) { return switch (channel) { case "windows" -> "下载安装Windows客户端后,在注册页面输入邀请码:" + code + " 或点击链接自动填入"; case "app" -> "下载安装客户端后,在注册页面输入邀请码:" + code + " 或点击链接自动填入"; case "miniapp" -> "点击按钮打开小程序,邀请码已自动填入"; case "wechat" -> "关注公众号后,在菜单中输入邀请码:" + code + " 完成注册"; case "dingtalk" -> "打开钉钉应用,搜索\"" + config.getAppName() + "\"并输入邀请码:" + code; case "feishu" -> "打开飞书应用,搜索\"" + config.getAppName() + "\"并输入邀请码:" + code; default -> "下载客户端后,在注册页面输入邀请码:" + code + " 即可享受奖励"; }; } /** * 根据渠道确定打开方式 */ private String determineOpenType(String channel, InviteConfig config) { return switch (channel) { case "windows" -> "download"; // 下载Windows桌面客户端 case "app" -> "download"; // 下载客户端(移动端通用) case "miniapp" -> "miniapp"; // 打开小程序 case "wechat" -> "redirect"; // 跳转公众号/H5 case "dingtalk" -> "deeplink"; // 应用内深度链接 case "feishu" -> "deeplink"; // 应用内深度链接 default -> "download"; }; } /** * 根据 User-Agent 解析本次应下载的安装包文件 * Windows x64 -> {downloadBasePath}/智价云-x64.exe * macOS ARM64 -> {downloadBasePath}/智价云-arm64.dmg * 其他默认返回 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. 完整邀请链接: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 = 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 InviteLinkResolveResponse.builder() .inviteCode(code) .inviteLink(inviteBaseUrl + "/api/invite/" + code) .inviterNickname(inviterNickname) .inviterAvatar(inviterAvatar) .appName(config.getAppName()) .landingTitle(landingTitle) .landingDesc(config.getLandingDesc()) .build(); } /** * 从链接或邀请码字符串中提取邀请码 * 支持:纯邀请码、完整URL路径、query参数、以及被截断/编码的URL */ private String extractInviteCode(String linkOrCode) { // 1. 纯邀请码:8位(去掉了易混淆的 I,O,0,1) if (linkOrCode.matches("^[A-HJ-NP-Z2-9]{8}$")) { log.debug("提取邀请码[纯码匹配]: {}", linkOrCode); return linkOrCode; } // 2. 从路径 /invite/{code} 提取(兼容各种端口、域名) java.util.regex.Pattern pathPattern = java.util.regex.Pattern .compile("/invite/([A-HJ-NP-Z2-9]{8})(?:[?/#]|$)"); java.util.regex.Matcher pathMatcher = pathPattern.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.Pattern queryPattern = java.util.regex.Pattern .compile("[?&](?:inviteCode|code)=([A-HJ-NP-Z2-9]{8})(?:&|$|#)"); java.util.regex.Matcher queryMatcher = queryPattern.matcher(linkOrCode); if (queryMatcher.find()) { return queryMatcher.group(1); } // 4. 兜底:从任意位置提取符合格式的8位邀请码(处理URL被截断、编码等异常情况) java.util.regex.Pattern fallbackPattern = java.util.regex.Pattern .compile("([A-HJ-NP-Z2-9]{8})"); java.util.regex.Matcher fallbackMatcher = fallbackPattern.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. 检查是否已被邀请过(每个用户只能被邀请一次) Long existingCount = inviteRelationMapper.selectCount( new LambdaQueryWrapper() .eq(InviteRelation::getInviteeId, inviteeId)); if (existingCount > 0) { log.warn("用户 {} 已被邀请过", inviteeId); return; } // 5. 获取邀请配置 InviteConfig config = inviteConfigService.getActiveConfig(); if (config.getStatus() != 1) { log.warn("邀请功能已停用"); return; } // 5.1 检查邀请人总邀请奖励上限(仍可继续邀请,但超过上限后不再发放奖励) Long totalInvited = inviteRelationMapper.selectCount( new LambdaQueryWrapper() .eq(InviteRelation::getInviterId, inviterId) .eq(InviteRelation::getRegistered, 1)); int maxTotal = config.getMaxTotalInvites() != null ? config.getMaxTotalInvites() : 30; boolean rewardAllowed = totalInvited < maxTotal; if (!rewardAllowed) { log.warn("邀请人{}已达会员时长奖励上限{}人,继续邀请不再发放奖励", inviterId, maxTotal); } // 6. 创建邀请关系(直接标记为已注册,奖励类型为赠送会员时长) Integer rewardMonths = config.getRewardMonths() != null ? config.getRewardMonths() : 1; InviteRelation relation = InviteRelation.builder() .inviterId(inviterId) .inviteeId(inviteeId) .inviteCodeId(inviteCode.getId()) .registered(1) .rewardGranted(rewardAllowed ? rewardMonths : 0) .status(1) .build(); inviteRelationMapper.insert(relation); // 7. 更新邀请码使用次数 inviteCode.setUsedCount(inviteCode.getUsedCount() + 1); inviteCodeMapper.updateById(inviteCode); // 8. 给邀请人发放奖励(在奖励上限内赠送30天会员时长) if (rewardAllowed) { grantInviteReward(inviterId, inviteeId); log.info("邀请成功: 用户 {} 邀请了用户 {},邀请人获赠30天会员时长", inviterId, inviteeId); } else { log.info("邀请成功: 用户 {} 邀请了用户 {},已达奖励上限,不发放会员时长", 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. 检查是否已被邀请过(每个用户只能被邀请一次) Long existingCount = inviteRelationMapper.selectCount( new LambdaQueryWrapper() .eq(InviteRelation::getInviteeId, userId)); if (existingCount > 0) { throw new BusinessException(ErrorCode.INVITE_RELATION_EXISTS, "您已绑定过邀请码,无法重复绑定"); } // 6. 检查注册时间是否在有效窗口内 User user = userMapper.selectById(userId); if (user == null) { throw new BusinessException(ErrorCode.USER_NOT_FOUND); } 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 检查邀请人总邀请奖励上限(仍可继续绑定,但超过上限后不再发放奖励) Long totalInvited = inviteRelationMapper.selectCount( new LambdaQueryWrapper() .eq(InviteRelation::getInviterId, inviterId) .eq(InviteRelation::getRegistered, 1)); int maxTotal = config.getMaxTotalInvites() != null ? config.getMaxTotalInvites() : 30; boolean rewardAllowed = totalInvited < maxTotal; // 8. 创建邀请关系 Integer rewardMonths = config.getRewardMonths() != null ? config.getRewardMonths() : 1; InviteRelation relation = InviteRelation.builder() .inviterId(inviterId) .inviteeId(userId) .inviteCodeId(inviteCode.getId()) .registered(1) .rewardGranted(rewardAllowed ? rewardMonths : 0) .status(1) .build(); inviteRelationMapper.insert(relation); // 9. 更新邀请码使用次数 inviteCode.setUsedCount(inviteCode.getUsedCount() + 1); inviteCodeMapper.updateById(inviteCode); // 10. 发放奖励:邀请人30天会员时长(在奖励上限内) if (rewardAllowed) { membershipService.grantInviteRewardMembership(inviterId, userId); log.info("补填邀请码成功: 用户{}绑定邀请码{},邀请人{},邀请人获赠30天会员时长", userId, inviteCodeStr, inviterId); return "补填邀请码成功!邀请人获赠1个月会员时长"; } else { log.info("补填邀请码成功: 用户{}绑定邀请码{},邀请人{},已达奖励上限,不发放会员时长", userId, inviteCodeStr, inviterId); return "补填邀请码成功!邀请人已达奖励上限,本次不发放会员时长"; } } /** * 发放邀请奖励:邀请人30天高级会员 */ private void grantInviteReward(Long inviterId, Long inviteeId) { membershipService.grantInviteRewardMembership(inviterId, inviteeId); } // ========================================== // 运营手动发放 // ========================================== /** * 运营手动发放爬虫次数(通过统一配额服务) */ @Transactional public CrawlerQuotaGrant adminGrantQuota(QuotaGrantRequest request) { return crawlerQuotaGrantService.grantAdminQuota( request.getUserId(), request.getQuotaCount(), request.getExpireTime(), request.getRemark()); } // ========================================== // 统计 & 记录查询 // ========================================== /** * 获取用户邀请统计 * 返回如:"已邀请5人,3人注册 +21次" */ public InviteStatsResponse getInviteStats(Long userId) { InviteConfig config = inviteConfigService.getActiveConfig(); // 获取用户邀请码 InviteCodeResponse codeResponse = getOrCreateInviteCode(userId); // 所有邀请记录 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()); // 生成摘要文本(每邀请1家注册 = 邀请人获1个月会员) String summaryText = String.format("已邀请%d家药店,%d家注册,累计获得%d个月高级会员", totalInvited, registeredCount, registeredCount); 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) .build(); } /** * 获取用户邀请记录列表 */ public List getInviteRewards(Long userId) { List relations = inviteRelationMapper.selectList( new LambdaQueryWrapper() .eq(InviteRelation::getInviterId, userId) .orderByDesc(InviteRelation::getCreateTime)); return relations.stream().map(r -> { boolean isRegistered = r.getRegistered() != null && r.getRegistered() == 1; User invitee = userMapper.selectById(r.getInviteeId()); String inviteeNickname = isRegistered ? getDisplayName(invitee) : "待注册"; return InviteRewardResponse.builder() .inviterId(r.getInviterId()) .inviterNickname(getUserNickname(r.getInviterId())) .inviteeId(r.getInviteeId()) .inviteeNickname(inviteeNickname) .registered(r.getRegistered() != null ? r.getRegistered() : 0) .registeredText(isRegistered ? "已注册" : "待注册") .rewardCount(r.getRewardGranted()) .inviteTime(r.getCreateTime() != null ? r.getCreateTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) : null) .build(); }).collect(Collectors.toList()); } /** * 查询是谁邀请了我(被邀请人视角) * 从 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 List listGrants(Long userId, String grantType) { return crawlerQuotaGrantService.listGrants(userId, grantType); } // ========================================== // 私有工具方法 // ========================================== /** * 生成唯一邀请码(8位大写字母+数字) * 字符集:ABCDEFGHJKLMNPQRSTUVWXYZ23456789(去掉易混淆字符 I,O,0,1) * 示例:A3K7M9P2、X5B8N4Q6 */ private String generateUniqueCode() { String chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // 32个字符,去掉易混淆的 java.security.SecureRandom random = new java.security.SecureRandom(); String code; do { StringBuilder sb = new StringBuilder(8); for (int i = 0; i < 8; i++) { sb.append(chars.charAt(random.nextInt(chars.length()))); } code = sb.toString(); } while (inviteCodeMapper.selectCount( new LambdaQueryWrapper().eq(InviteCode::getCode, code)) > 0); return code; } /** * 获取用户昵称 */ /** * 获取用户对外展示名: * 优先顺序:微信昵称 > 用户自定义昵称 > 手机尾号 > 兜底文案 */ 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) { // 获取配置与邀请人信息 InviteConfig config = inviteConfigService.getActiveConfig(); User inviter = userMapper.selectById(code.getUserId()); String inviterNickname = getDisplayName(inviter); String inviteLink = inviteBaseUrl + "/api/invite/" + code.getCode(); // 生成分享文案(邀请人 + 产品价值 + 操作指引) // 分享标题:让接收方一眼知道是谁邀请的 String shareTitle = inviterNickname + " 邀请你加入" + config.getAppName(); // 分享描述:说明产品核心价值(绑定B2B平台→聚合查价) String shareDescription = config.getLandingDesc() != null && !config.getLandingDesc().isBlank() ? config.getLandingDesc() : "注册登录" + config.getAppName() + "平台,聚合比价查低价"; String rewardDesc = "邀请人每成功邀请1家药店注册,赠送1个月高级会员时长"; // 邀请链接即下载入口,访问后根据对方设备自动重定向到对应安装包 String downloadLink = inviteLink; // 复制文本:与示例文案保持一致,便于用户一键转发 String copyText = String.format( "我是%s,在这里发现了一个药店采购神器——%s!它聚合比价功能特别方便,能快速查到最低价,帮你节省采购成本。下载链接%s填我的邀请码 %s 完成注册,你也会获得会员权益!", inviterNickname, config.getAppName(), downloadLink, 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) { 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) { // 获取用户邀请码 InviteCodeResponse codeResponse = getOrCreateInviteCode(userId); // 获取邀请码实体 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(); } }