Przeglądaj źródła

优化代码,添加接口

liuchengsen 1 miesiąc temu
rodzic
commit
b72178de67

+ 3 - 5
zhijiayun-invite/src/main/java/com/xuekairui/invite/controller/InviteController.java

@@ -75,7 +75,7 @@ public class InviteController {
      * 补填邀请码(注册时未填,注册后有效窗口内可补填)
      * 补填成功后:邀请人获赠1个月高级会员时长
      * POST /api/invite/bind
-     * Body: {"inviteCode": "A3K7M9P2"} 或 {"inviteCode": "https://app.zhijiayun.com/invite/A3K7M9P2"}
+     * Body: {"inviteCode": "A3K7M9P2"}
      */
     @PostMapping("/bind")
     public Result<String> bindInviteCode(@RequestBody Map<String, String> body,
@@ -97,10 +97,8 @@ public class InviteController {
     @GetMapping("/page/{code}")
     public Result<InvitePageResponse> getInvitePage(
             @PathVariable String code,
-            @RequestParam(required = false) String channel,
-            HttpServletRequest request) {
-        String userAgent = request.getHeader("User-Agent");
-        return Result.success(inviteService.getInvitePage(code, channel, userAgent));
+            @RequestParam(required = false) String channel) {
+        return Result.success(inviteService.getInvitePage(code, channel));
     }
 
     /**

+ 99 - 73
zhijiayun-invite/src/main/java/com/xuekairui/invite/service/InviteService.java

@@ -18,6 +18,7 @@ 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.Value;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
@@ -88,24 +89,33 @@ public class InviteService {
             return toCodeResponse(existingCode);
         }
 
-        // 创建新邀请码
+        // 创建新邀请码(uk_code 唯一索引兜底防止并发碰撞)
         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);
+        int maxInsertRetries = 3;
+        for (int attempt = 0; attempt < maxInsertRetries; attempt++) {
+            String code = generateUniqueCode();
+            InviteCode newCode = InviteCode.builder()
+                    .userId(userId)
+                    .code(code)
+                    .maxUses(-1)
+                    .usedCount(0)
+                    .clickCount(0)
+                    .expireTime(expireTime)
+                    .status(1)
+                    .build();
+            try {
+                inviteCodeMapper.insert(newCode);
+                log.info("用户 {} 创建邀请码: {}", userId, code);
+                return toCodeResponse(newCode);
+            } catch (org.springframework.dao.DuplicateKeyException e) {
+                log.warn("邀请码并发碰撞(attempt={}): {}", attempt + 1, code);
+                if (attempt == maxInsertRetries - 1) {
+                    throw new BusinessException(ErrorCode.BUSINESS_ERROR, "邀请码生成失败,请重试");
+                }
+            }
+        }
+        throw new BusinessException(ErrorCode.BUSINESS_ERROR, "邀请码生成失败,请重试");
     }
 
     // ==========================================
@@ -119,9 +129,8 @@ public class InviteService {
      *
      * @param code       邀请码
      * @param channel    渠道(可选,默认app)
-     * @param userAgent  浏览器 User-Agent,用于判断桌面客户端下载包
      */
-    public InvitePageResponse getInvitePage(String code, String channel, String userAgent) {
+    public InvitePageResponse getInvitePage(String code, String channel) {
         InviteCode inviteCode = inviteCodeMapper.selectOne(
                 new LambdaQueryWrapper<InviteCode>()
                         .eq(InviteCode::getCode, code));
@@ -149,7 +158,7 @@ public class InviteService {
         }
 
         // 根据渠道确定打开方式
-        String openType = determineOpenType(channel, config);
+        String openType = determineOpenType(channel);
 
         // 构建小程序路径(含邀请码参数)
         String miniappPath = config.getMiniappPath();
@@ -242,14 +251,13 @@ public class InviteService {
     /**
      * 根据渠道确定打开方式
      */
-    private String determineOpenType(String channel, InviteConfig config) {
+    private String determineOpenType(String channel) {
         return switch (channel) {
             case "windows" -> "download";   // 下载Windows桌面客户端
             case "app" -> "download";       // 下载客户端(移动端通用)
             case "miniapp" -> "miniapp";    // 打开小程序
             case "wechat" -> "redirect";    // 跳转公众号/H5
-            case "dingtalk" -> "deeplink";  // 应用内深度链接
-            case "feishu" -> "deeplink";    // 应用内深度链接
+            case "dingtalk", "feishu" -> "deeplink";  // 应用内深度链接
             default -> "download";
         };
     }
@@ -344,7 +352,7 @@ public class InviteService {
      * 解析任意邀请链接或邀请码,返回邀请人信息
      * 支持:
      * 1. 纯邀请码:A3K7M9P2
-     * 2. 完整邀请链接:https://app.zhijiayun.com/invite/A3K7M9P2?channel=wechat
+     * 2. 完整邀请链接:{@code https://app.zhijiayun.com/invite/A3K7M9P2?channel=wechat}
      * 3. 带查询参数的链接:...?inviteCode=A3K7M9P2 或 ?code=A3K7M9P2
      */
     public InviteLinkResolveResponse resolveInviteLink(String linkOrCode) {
@@ -375,6 +383,21 @@ public class InviteService {
         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;
@@ -392,16 +415,7 @@ public class InviteService {
                 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();
+        return landingTitle;
     }
 
     /**
@@ -409,16 +423,14 @@ public class InviteService {
      * 支持:纯邀请码、完整URL路径、query参数、以及被截断/编码的URL
      */
     private String extractInviteCode(String linkOrCode) {
-        // 1. 纯邀请码:8位(去掉了易混淆的 I,O,0,1)
-        if (linkOrCode.matches("^[A-HJ-NP-Z2-9]{8}$")) {
+        // 1. 纯邀请码精确匹配
+        if (CODE_PURE.matcher(linkOrCode).matches()) {
             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);
+        // 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);
@@ -426,17 +438,13 @@ public class InviteService {
         }
 
         // 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);
+        java.util.regex.Matcher queryMatcher = CODE_QUERY.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);
+        // 4. 兜底:从任意位置提取
+        java.util.regex.Matcher fallbackMatcher = CODE_FALLBACK.matcher(linkOrCode);
         if (fallbackMatcher.find()) {
             return fallbackMatcher.group(1);
         }
@@ -496,9 +504,9 @@ public class InviteService {
             log.warn("用户 {} 已被邀请过, inviterId={}", inviteeId, invitee.getInviterId());
             return;
         }
-        Long existingCount = inviteRelationMapper.selectCount(
+        long existingCount = Optional.ofNullable(inviteRelationMapper.selectCount(
                 new LambdaQueryWrapper<InviteRelation>()
-                        .eq(InviteRelation::getInviteeId, inviteeId));
+                        .eq(InviteRelation::getInviteeId, inviteeId))).orElse(0L);
         if (existingCount > 0) {
             log.warn("用户 {} 已有邀请关系(异常数据)", inviteeId);
             return;
@@ -512,10 +520,10 @@ public class InviteService {
         }
 
         // 5.1 检查邀请人总邀请奖励上限(仍可继续邀请,但超过上限后不再发放奖励)
-        Long totalInvited = inviteRelationMapper.selectCount(
+        long totalInvited = Optional.ofNullable(inviteRelationMapper.selectCount(
                 new LambdaQueryWrapper<InviteRelation>()
                         .eq(InviteRelation::getInviterId, inviterId)
-                        .eq(InviteRelation::getRegistered, 1));
+                        .eq(InviteRelation::getRegistered, 1))).orElse(0L);
         int maxTotal = config.getMaxTotalInvites() != null ? config.getMaxTotalInvites() : 30;
         boolean rewardAllowed = totalInvited < maxTotal;
         if (!rewardAllowed) {
@@ -523,7 +531,7 @@ public class InviteService {
         }
 
         // 6. 创建邀请关系(直接标记为已注册,奖励类型为赠送会员时长)
-        Integer rewardMonths = config.getRewardMonths() != null ? config.getRewardMonths() : 1;
+        int rewardMonths = config.getRewardMonths() != null ? config.getRewardMonths() : 1;
         InviteRelation relation = InviteRelation.builder()
                 .inviterId(inviterId)
                 .inviteeId(inviteeId)
@@ -607,7 +615,7 @@ public class InviteService {
             throw new BusinessException(ErrorCode.INVITE_RELATION_EXISTS,
                     "您已绑定过邀请码,您是由「" + inviterName + "」邀请的,无法重复绑定");
         }
-        Long existingCount = inviteRelationMapper.selectCount(
+        long existingCount = inviteRelationMapper.selectCount(
                 new LambdaQueryWrapper<InviteRelation>()
                         .eq(InviteRelation::getInviteeId, userId));
         if (existingCount > 0) {
@@ -640,7 +648,7 @@ public class InviteService {
         }
 
         // 7.1 检查邀请人总邀请奖励上限(仍可继续绑定,但超过上限后不再发放奖励)
-        Long totalInvited = inviteRelationMapper.selectCount(
+        long totalInvited = inviteRelationMapper.selectCount(
                 new LambdaQueryWrapper<InviteRelation>()
                         .eq(InviteRelation::getInviterId, inviterId)
                         .eq(InviteRelation::getRegistered, 1));
@@ -648,7 +656,7 @@ public class InviteService {
         boolean rewardAllowed = totalInvited < maxTotal;
 
         // 8. 创建邀请关系
-        Integer rewardMonths = config.getRewardMonths() != null ? config.getRewardMonths() : 1;
+        int rewardMonths = config.getRewardMonths() != null ? config.getRewardMonths() : 1;
         InviteRelation relation = InviteRelation.builder()
                 .inviterId(inviterId)
                 .inviteeId(userId)
@@ -880,35 +888,56 @@ public class InviteService {
         return crawlerQuotaGrantService.listGrants(userId, grantType);
     }
 
+    // ==========================================
+    // 邀请码常量
+    // ==========================================
+
+    /** 邀请码字符集(32个,去掉易混淆的 I/O/0/1) */
+    private static final String CODE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
+
+    /** 邀请码正则片段:8位不含易混淆字符 */
+    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位大写字母+数字)
-     * 字符集:ABCDEFGHJKLMNPQRSTUVWXYZ23456789(去掉易混淆字符 I,O,0,1)
-     * 示例:A3K7M9P2、X5B8N4Q6
+     * <p>
+     * 防重复:先查 DB(快速路径),再用 uk_code 唯一索引兜底。
+     * 32^8 ≈ 1.1万亿种组合,正常不会碰撞,此处为防御性设计。
      */
     private String generateUniqueCode() {
-        String chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // 32个字符,去掉易混淆的
         java.security.SecureRandom random = new java.security.SecureRandom();
-        String code;
-        
-        do {
+        int maxRetries = 5;
+        for (int attempt = 0; attempt < maxRetries; attempt++) {
             StringBuilder sb = new StringBuilder(8);
             for (int i = 0; i < 8; i++) {
-                sb.append(chars.charAt(random.nextInt(chars.length())));
+                sb.append(CODE_CHARS.charAt(random.nextInt(CODE_CHARS.length())));
             }
-            code = sb.toString();
-        } while (inviteCodeMapper.selectCount(
-                new LambdaQueryWrapper<InviteCode>().eq(InviteCode::getCode, code)) > 0);
-        
-        return code;
+            String code = sb.toString();
+            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, "邀请码生成失败,请重试");
     }
 
-    /**
-     * 获取用户昵称
-     */
     /**
      * 获取用户对外展示名:
      * 优先顺序:微信昵称 > 用户自定义昵称 > 手机尾号 > 兜底文案
@@ -955,15 +984,12 @@ public class InviteService {
                 ? config.getLandingDesc() : "注册登录" + config.getAppName() + "平台,聚合比价查低价";
         String rewardDesc = "邀请人每成功邀请1家药店注册,赠送1个月高级会员时长";
 
-        // 邀请链接即下载入口,访问后根据对方设备自动重定向到对应安装包
-        String downloadLink = inviteLink;
-
         // 复制文本:与示例文案保持一致,便于用户一键转发
         String copyText = String.format(
             "我是%s,在这里发现了一个药店采购神器——%s!它聚合比价功能特别方便,能快速查到最低价,帮你节省采购成本。下载链接%s填我的邀请码 %s 完成注册,你也会获得会员权益!",
             inviterNickname,
             config.getAppName(),
-            downloadLink,
+                inviteLink,
             code.getCode()
         );