| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354 |
- 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<InviteCode>()
- .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<InviteCode>()
- .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<InviteCode>()
- .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<InviteCode>()
- .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<InviteCode>()
- .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<InviteCode>()
- .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) {
- if (isClientAbortException(e)) {
- log.info("用户中断下载安装包(客户端断开): file={}, error={}",
- file.getAbsolutePath(), e.getMessage());
- } else {
- log.error("流式下载安装包失败: {}", file.getAbsolutePath(), e);
- throw new BusinessException(ErrorCode.BUSINESS_ERROR, "下载失败,请重试");
- }
- }
- }
- /**
- * 判断 IOException 是否为客户端主动中断下载导致
- * <p>
- * 用户取消下载/关闭浏览器/网络中断时会抛出此类异常,属于正常行为,
- * 不应记录错误堆栈,仅记录一条 info 日志即可。
- * 典型特征:ClientAbortException、Broken pipe、Connection reset by peer
- */
- private static boolean isClientAbortException(Throwable e) {
- if (e == null) {
- return false;
- }
- String className = e.getClass().getName();
- if (className.contains("ClientAbortException")) {
- return true;
- }
- String msg = e.getMessage();
- if (msg != null) {
- String lower = msg.toLowerCase();
- if (lower.contains("broken pipe")
- || lower.contains("connection reset")
- || lower.contains("connection abort")
- || lower.contains("client abort")) {
- return true;
- }
- }
- return isClientAbortException(e.getCause());
- }
- /**
- * 追踪邀请链接被打开(公开,浏览器打开时调用)
- * 记录到 invite_code.click_count
- */
- public void trackClick(String code) {
- InviteCode inviteCode = inviteCodeMapper.selectOne(
- new LambdaQueryWrapper<InviteCode>()
- .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<InviteCode>()
- .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<InviteCode>()
- .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<InviteRelation>()
- .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. 创建邀请关系(rewardGranted 初始为 0,由活动模块实际发奖后回写)
- InviteRelation relation = InviteRelation.builder()
- .inviterId(inviterId)
- .inviteeId(inviteeId)
- .inviteCodeId(inviteCode.getId())
- .registered(1)
- .rewardGranted(0)
- .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. 被邀请人已入驻审核通过 → 直接触发发奖(否则等后续入驻审核通过时触发)
- com.xuekairui.user.entity.BusinessLicense license = businessLicenseService.getByUserId(inviteeId);
- if (license != null && LicenseStatus.APPROVED.name().equals(license.getReviewStatus())) {
- if (activityInviteGate != null) {
- activityInviteGate.grantRewardForApprovedUser(inviterId, inviteeId);
- } else {
- log.warn("活动模块未加载,无法发放已入驻用户奖励: inviter={}, invitee={}", 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<InviteCode>()
- .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<InviteRelation>()
- .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<InviteRelation>()
- .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. 创建邀请关系(rewardGranted 初始为 0,由活动模块实际发奖后回写)
- InviteRelation relation = InviteRelation.builder()
- .inviterId(inviterId)
- .inviteeId(userId)
- .inviteCodeId(inviteCode.getId())
- .registered(1)
- .rewardGranted(0)
- .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);
- }
- }
- log.info("补填邀请码成功: invitee={}, inviter={}", userId, inviterId);
- return "补填邀请码成功!";
- }
- // ==========================================
- // 运营手动发放
- // ==========================================
- /**
- * 运营手动发放爬虫次数(通过统一配额服务)
- */
- @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<User>().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<InviteRelation> allRelations = inviteRelationMapper.selectList(
- new LambdaQueryWrapper<InviteRelation>()
- .eq(InviteRelation::getInviterId, userId));
- // 已邀请人数(所有记录)
- int totalInvited = allRelations.size();
- // 已注册人数
- int registeredCount = (int) allRelations.stream()
- .filter(r -> r.getRegistered() != null && r.getRegistered() == 1)
- .count();
- // 累计奖励次数(由活动模块实际发奖后回写到 InviteRelation,1 次 = 1 个月高级会员)
- int totalRewardMonths = 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<InviteRelation>()
- .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(totalRewardMonths));
- } else {
- summaryText = String.format("已邀请%d家药店,%d家注册,累计获得%d个月高级会员",
- totalInvited, registeredCount, totalRewardMonths);
- }
- return InviteStatsResponse.builder()
- .myInviteCode(codeResponse.getCode())
- .inviteLink(codeResponse.getInviteLink())
- .clickedCount(clickedCount)
- .totalInvited(totalInvited)
- .registeredCount(registeredCount)
- .totalReward(totalRewardMonths)
- .summaryText(summaryText)
- .todayInvited(todayInvited.intValue())
- .todayRemaining(todayRemaining)
- .rewardPerInvite(config.getRewardMonths() != null ? config.getRewardMonths() : 1)
- .crawlerTodayRemaining(crawlerTodayRemaining)
- .crawlerQuotaFromInvites(crawlerQuotaFromInvites)
- .build();
- }
- public List<InviteRewardResponse> getInviteRewards(Long userId) {
- return getInviteRewards(userId, 1, Integer.MAX_VALUE).getRecords();
- }
- /**
- * 分页查询邀请记录(批量查询被邀请人,避免 N+1)
- */
- public Page<InviteRewardResponse> getInviteRewards(Long userId, int pageNum, int pageSize) {
- Page<InviteRelation> page = new Page<>(pageNum, pageSize);
- LambdaQueryWrapper<InviteRelation> wrapper = new LambdaQueryWrapper<InviteRelation>()
- .eq(InviteRelation::getInviterId, userId)
- .orderByDesc(InviteRelation::getCreateTime);
- Page<InviteRelation> result = inviteRelationMapper.selectPage(page, wrapper);
- List<InviteRelation> relations = result.getRecords();
- if (relations.isEmpty()) {
- return new Page<>(pageNum, pageSize, 0);
- }
- // 批量查被邀请人和邀请码
- List<Long> inviteeIds = relations.stream()
- .map(InviteRelation::getInviteeId).distinct().toList();
- Map<Long, User> userMap = userMapper.selectByIds(inviteeIds).stream()
- .collect(Collectors.toMap(User::getId, u -> u));
- List<Long> codeIds = relations.stream()
- .map(InviteRelation::getInviteCodeId).filter(Objects::nonNull).distinct().toList();
- Map<Long, String> codeMap = codeIds.isEmpty() ? Map.of()
- : inviteCodeMapper.selectByIds(codeIds).stream()
- .collect(Collectors.toMap(InviteCode::getId, InviteCode::getCode));
- // 批量查入驻状态
- Map<Long, String> licenseStatusMap = businessLicenseService.getLicenseStatusByUserIds(inviteeIds);
- // 邀请人信息只查一次
- String inviterNickname = getUserNickname(userId);
- List<InviteRewardResponse> 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<InviteRewardResponse> 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<InviteRelation>()
- .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<CrawlerQuotaGrantResponse> 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位大写字母+数字)
- * <p>
- * 防重复:先查 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<InviteCode>().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<User>().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<Map<String, Object>> todayByPlatform = crawlerUsageLogMapper.sumTodayUsageByPlatform(userId);
- List<Map<String, Object>> monthlyByPlatform = crawlerUsageLogMapper.sumMonthlyUsageByPlatform(userId);
- List<Map<String, Object>> totalByPlatform = crawlerUsageLogMapper.sumTotalUsageByPlatform(userId);
- // 构建平台统计Map
- Map<String, Integer> todayMap = buildPlatformMap(todayByPlatform);
- Map<String, Integer> monthlyMap = buildPlatformMap(monthlyByPlatform);
- Map<String, Integer> totalMap = buildPlatformMap(totalByPlatform);
- // 合并所有出现过的平台
- Set<String> allPlatforms = new HashSet<>();
- allPlatforms.addAll(todayMap.keySet());
- allPlatforms.addAll(monthlyMap.keySet());
- allPlatforms.addAll(totalMap.keySet());
- // 构建平台统计列表(容错处理:未知平台显示原始code+“其他平台”)
- List<CrawlerPlatformStatsResponse> 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<String, Integer> buildPlatformMap(List<Map<String, Object>> rows) {
- Map<String, Integer> map = new HashMap<>();
- for (Map<String, Object> 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<User>().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<InviteCode>()
- .eq(InviteCode::getUserId, userId)
- .eq(InviteCode::getCode, codeResponse.getCode()));
- int clickedCount = myCode != null ? myCode.getClickCount() : 0;
- // 获取所有邀请关系
- List<InviteRelation> relations = inviteRelationMapper.selectList(
- new LambdaQueryWrapper<InviteRelation>()
- .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();
- }
- }
|