| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049 |
- 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<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) {
- 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<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, 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<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) {
- 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<InviteCode>()
- .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<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 = 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<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. 检查是否已被邀请过(每个用户只能被邀请一次)
- Long existingCount = inviteRelationMapper.selectCount(
- new LambdaQueryWrapper<InviteRelation>()
- .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<InviteRelation>()
- .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<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. 检查是否已被邀请过(每个用户只能被邀请一次)
- Long existingCount = inviteRelationMapper.selectCount(
- new LambdaQueryWrapper<InviteRelation>()
- .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<InviteRelation>()
- .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<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();
- // 累计奖励
- 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<InviteRelation>()
- .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<InviteRewardResponse> getInviteRewards(Long userId) {
- List<InviteRelation> relations = inviteRelationMapper.selectList(
- new LambdaQueryWrapper<InviteRelation>()
- .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<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 List<CrawlerQuotaGrant> 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<InviteCode>().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<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) {
- // 获取用户邀请码
- InviteCodeResponse codeResponse = getOrCreateInviteCode(userId);
- // 获取邀请码实体
- 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();
- }
- }
|