Bläddra i källkod

活动管理更新

liuchengsen 1 månad sedan
förälder
incheckning
d1a32f3564

+ 4 - 0
docs/03-接口文档.md

@@ -1720,6 +1720,8 @@ POST /api/activity/lottery/draw
 | MEMBERSHIP | 会员奖励(rewardDays天,rewardLevel等级) |
 | CRAWLER | 爬虫次数(rewardAmount次) |
 | EMPTY | 谢谢参与 |
+| PHYSICAL | 实物奖品(需客服核实) |
+| OTHER | 其他奖品(需客服核实) |
 
 ---
 
@@ -1932,6 +1934,8 @@ POST /api/admin/activity/lottery/prizes
 | MEMBERSHIP | 会员奖励(需填rewardDays、rewardLevel) |
 | CRAWLER | 爬虫次数(需填rewardAmount) |
 | EMPTY | 谢谢参与 |
+| PHYSICAL | 实物奖品(中奖后需客服核实,不自动发放) |
+| OTHER | 其他奖品(中奖后需客服核实,不自动发放) |
 
 ---
 

+ 1 - 1
zhijiayun-activity/src/main/java/com/xuekairui/activity/dto/ActivityLotteryResultResponse.java

@@ -23,7 +23,7 @@ public class ActivityLotteryResultResponse {
 
     private String prizeName;
 
-    /** MEMBERSHIP / CRAWLER / EMPTY / PHYSICAL */
+    /** MEMBERSHIP / CRAWLER / EMPTY / PHYSICAL / OTHER */
     private String rewardType;
 
     private Integer rewardAmount;

+ 3 - 0
zhijiayun-activity/src/main/java/com/xuekairui/activity/dto/ActivityLotteryStatusResponse.java

@@ -40,6 +40,9 @@ public class ActivityLotteryStatusResponse {
     /** 今日已使用抽奖机会数 */
     private Integer usedChances;
 
+    /** 每日最大抽奖机会数(由后台配置) */
+    private Integer dailyMaxChance;
+
     /** 状态原因说明(机会为0时告知具体原因,有机会时为null) */
     private String reason;
 }

+ 1 - 1
zhijiayun-activity/src/main/java/com/xuekairui/activity/entity/ActivityLotteryPrize.java

@@ -23,7 +23,7 @@ public class ActivityLotteryPrize {
 
     private String name;
 
-    /** MEMBERSHIP / CRAWLER / EMPTY / PHYSICAL */
+    /** MEMBERSHIP / CRAWLER / EMPTY / PHYSICAL / OTHER */
     private String rewardType;
 
     @Builder.Default

+ 2 - 2
zhijiayun-activity/src/main/java/com/xuekairui/activity/scheduler/ActivityEventScheduler.java

@@ -53,10 +53,10 @@ public class ActivityEventScheduler {
     }
 
     /**
-     * 每 5 秒轮询一次,消费比价消耗事件。
+     * 每 1 秒轮询一次,消费比价消耗事件。
      * 优先消费备份队列中的失败事件(重试),再消费主队列中的新事件。
      */
-    @Scheduled(fixedDelay = 5000)
+    @Scheduled(fixedDelay = 1000)
     public void consumeCrawlerConsumedEvents() {
         Map<String, Object> event;
         while ((event = eventRedisService.popCrawlerConsumedEvent()) != null) {

+ 48 - 22
zhijiayun-activity/src/main/java/com/xuekairui/activity/service/ActivityLotteryService.java

@@ -18,6 +18,7 @@ import com.xuekairui.activity.mapper.ActivityLotteryPrizeMapper;
 import com.xuekairui.activity.mapper.ActivityLotteryRecordMapper;
 import com.xuekairui.common.BusinessException;
 import com.xuekairui.common.ErrorCode;
+import com.xuekairui.common.activity.ActivityLotteryGate;
 import com.xuekairui.common.event.ActivityEventRedisService;
 import com.xuekairui.user.entity.BusinessLicense;
 import com.xuekairui.user.entity.OperationAuditLog;
@@ -25,8 +26,8 @@ import com.xuekairui.user.entity.User;
 import com.xuekairui.user.enums.AuditResult;
 import com.xuekairui.user.enums.LicenseStatus;
 import com.xuekairui.user.enums.MembershipLevel;
-import com.xuekairui.user.enums.OperatorRole;
 import com.xuekairui.user.enums.OperationType;
+import com.xuekairui.user.enums.OperatorRole;
 import com.xuekairui.user.mapper.BusinessLicenseMapper;
 import com.xuekairui.user.mapper.UserMapper;
 import com.xuekairui.user.service.MembershipService;
@@ -57,13 +58,14 @@ import java.util.concurrent.ThreadLocalRandom;
 @Slf4j
 @Service
 @RequiredArgsConstructor
-public class ActivityLotteryService {
+public class ActivityLotteryService implements ActivityLotteryGate {
 
     private static final Long CONFIG_ID = 1L;
     private static final String SOURCE_COMPARE = "COMPARE";
     private static final String REWARD_TYPE_MEMBERSHIP = "MEMBERSHIP";
     private static final String REWARD_TYPE_EMPTY = "EMPTY";
     private static final String REWARD_TYPE_PHYSICAL = "PHYSICAL";
+    private static final String REWARD_TYPE_OTHER = "OTHER";
 
     private final ActivityLotteryConfigMapper configMapper;
     private final ActivityLotteryChanceMapper chanceMapper;
@@ -117,6 +119,7 @@ public class ActivityLotteryService {
     // 事件处理:比价成功后发放抽奖机会
     // ==========================================
 
+    @Override
     @Transactional
     public void onCrawlerConsumed(Long userId, String usageDateStr) {
         ActivityLotteryConfig config = getConfig();
@@ -128,6 +131,18 @@ public class ActivityLotteryService {
         }
 
         LocalDate usageDate = usageDateStr != null ? LocalDate.parse(usageDateStr) : LocalDate.now();
+        int maxChance = config.getDailyMaxChance() != null ? config.getDailyMaxChance() : 1;
+
+        // 检查当日已发放的机会数量是否已达上限
+        Long todayChanceCount = chanceMapper.selectCount(
+                new LambdaQueryWrapper<ActivityLotteryChance>()
+                        .eq(ActivityLotteryChance::getUserId, userId)
+                        .eq(ActivityLotteryChance::getChanceDate, usageDate)
+                        .eq(ActivityLotteryChance::getSource, SOURCE_COMPARE));
+        if (todayChanceCount != null && todayChanceCount >= maxChance) {
+            log.info("今日抽奖机会已达上限({}/{}): userId={}, date={}", todayChanceCount, maxChance, userId, usageDate);
+            return;
+        }
 
         ActivityLotteryChance chance = ActivityLotteryChance.builder()
                 .userId(userId)
@@ -137,9 +152,10 @@ public class ActivityLotteryService {
                 .build();
         try {
             chanceMapper.insert(chance);
-            log.info("比价抽奖机会已发放: userId={}, date={}", userId, usageDate);
+            log.info("比价抽奖机会已发放: userId={}, date={}, 当日累计={}/{}", userId, usageDate, todayChanceCount != null ? todayChanceCount + 1 : 1, maxChance);
         } catch (DuplicateKeyException e) {
-            // 今日已发放过机会,忽略
+            // 并发插入同一条记录,忽略
+            log.debug("并发插入抽奖机会,忽略: userId={}, date={}", userId, usageDate);
         }
     }
 
@@ -164,8 +180,9 @@ public class ActivityLotteryService {
         }
 
         LocalDate today = LocalDate.now();
+        int maxChance = config.getDailyMaxChance() != null ? config.getDailyMaxChance() : 1;
 
-        // 检查今日是否已抽过(used=1 的机会记录存在即表示已抽奖)
+        // 检查今日已抽奖次数是否已达上限
         Long usedCount = chanceMapper.selectCount(
                 new LambdaQueryWrapper<ActivityLotteryChance>()
                         .eq(ActivityLotteryChance::getUserId, userId)
@@ -173,11 +190,12 @@ public class ActivityLotteryService {
                         .eq(ActivityLotteryChance::getSource, SOURCE_COMPARE)
                         .eq(ActivityLotteryChance::getUsed, 1));
 
-        if (usedCount != null && usedCount > 0) {
-            // 今日已抽过,记录违规审计日志并返回提示
+        int used = usedCount != null ? usedCount.intValue() : 0;
+        if (used >= maxChance) {
+            // 今日抽奖次数已用完,记录违规审计日志并返回提示
             recordViolation(userId, clientIp, today);
-            log.warn("重复抽奖违规: userId={}, ip={}, date={}", userId, clientIp, today);
-            throw new BusinessException(ErrorCode.BUSINESS_ERROR, "今日抽奖次数已用完");
+            log.warn("重复抽奖违规: userId={}, ip={}, date={}, used={}/{}", userId, clientIp, today, used, maxChance);
+            throw new BusinessException(ErrorCode.BUSINESS_ERROR, "今日抽奖次数已用完(" + used + "/" + maxChance + ")");
         }
 
         // 取一条未使用的机会
@@ -284,7 +302,8 @@ public class ActivityLotteryService {
 
         int avail = available != null ? available.intValue() : 0;
         int usedCount = used != null ? used.intValue() : 0;
-        String reason = computeReason(config, avail, usedCount, eligible, levelEnough, licenseApproved, userLevel, requireLevel);
+        int maxChance = config.getDailyMaxChance() != null ? config.getDailyMaxChance() : 1;
+        String reason = computeReason(config, avail, usedCount, levelEnough, licenseApproved, userLevel, requireLevel);
 
         return ActivityLotteryStatusResponse.builder()
                 .enabled(isActive(config))
@@ -296,6 +315,7 @@ public class ActivityLotteryService {
                 .requireMembershipLevel(requireLevel)
                 .availableChances(avail)
                 .usedChances(usedCount)
+                .dailyMaxChance(maxChance)
                 .reason(reason)
                 .build();
     }
@@ -463,6 +483,9 @@ public class ActivityLotteryService {
         }
     }
 
+    private static final List<String> PRIZE_TIERS = List.of(
+            "特等奖", "一等奖", "二等奖", "三等奖", "四等奖", "五等奖", "谢谢惠顾");
+
     /**
      * 从奖品名称中提取奖项等级(特等奖/一等奖/.../谢谢惠顾)
      */
@@ -470,13 +493,11 @@ public class ActivityLotteryService {
         if (prizeName == null) {
             return null;
         }
-        if (prizeName.contains("特等奖")) return "特等奖";
-        if (prizeName.contains("一等奖")) return "一等奖";
-        if (prizeName.contains("二等奖")) return "二等奖";
-        if (prizeName.contains("三等奖")) return "三等奖";
-        if (prizeName.contains("四等奖")) return "四等奖";
-        if (prizeName.contains("五等奖")) return "五等奖";
-        if (prizeName.contains("谢谢")) return "谢谢惠顾";
+        for (String tier : PRIZE_TIERS) {
+            if (prizeName.contains(tier)) {
+                return tier;
+            }
+        }
         return null;
     }
 
@@ -484,8 +505,7 @@ public class ActivityLotteryService {
      * 按优先级计算抽奖状态原因。有机会时返回 null。
      */
     private String computeReason(ActivityLotteryConfig config, int avail, int usedCount,
-                                  boolean eligible, boolean levelEnough, boolean licenseApproved,
-                                  MembershipLevel userLevel, String requireLevel) {
+                                 boolean levelEnough, boolean licenseApproved, MembershipLevel userLevel, String requireLevel) {
         if (avail > 0) {
             return null;
         }
@@ -504,8 +524,8 @@ public class ActivityLotteryService {
         if (!licenseApproved) {
             return "入驻信息未审核通过,无法参与抽奖";
         }
-        if (usedCount == 0) {
-            return "今日尚未比价,完成首次比价后自动获得抽奖机会";
+        if (usedCount == 0 && avail == 0) {
+            return "今日尚未比价,完成比价后即可获得抽奖机会";
         }
         return "今日抽奖机会已用完";
     }
@@ -563,7 +583,7 @@ public class ActivityLotteryService {
         if (prizes.isEmpty()) {
             return null;
         }
-        // 过滤掉库存为0的奖品(EMPTY类型不受库存限制,始终参与)
+        // 过滤掉库存为0的奖品(EMPTY 类型不受库存限制,始终参与)
         List<ActivityLotteryPrize> available = prizes.stream()
                 .filter(p -> REWARD_TYPE_EMPTY.equalsIgnoreCase(p.getRewardType())
                         || (p.getRemainingStock() != null && p.getRemainingStock() > 0))
@@ -593,6 +613,7 @@ public class ActivityLotteryService {
      * 根据奖品类型发放奖励
      * - MEMBERSHIP:会员时长即刻到账
      * - PHYSICAL:实物奖品,记录中奖,需客服后续核实(不自动发放)
+     * - OTHER:其他奖品,记录中奖,需客服后续核实(不自动发放)
      * - EMPTY:无奖励
      */
     private void applyReward(Long userId, ActivityLotteryPrize prize) {
@@ -604,6 +625,11 @@ public class ActivityLotteryService {
             log.info("实物奖品中奖,待客服核实: userId={}, prize={}", userId, prize.getName());
             return;
         }
+        if (REWARD_TYPE_OTHER.equalsIgnoreCase(prize.getRewardType())) {
+            // 其他奖品不自动发放,中奖记录已入库,需客服后续核实
+            log.info("其他奖品中奖,待客服核实: userId={}, prize={}", userId, prize.getName());
+            return;
+        }
         if (REWARD_TYPE_MEMBERSHIP.equalsIgnoreCase(prize.getRewardType())) {
             int days = prize.getRewardDays() != null ? prize.getRewardDays() : 0;
             if (days <= 0) {

+ 25 - 0
zhijiayun-common/src/main/java/com/xuekairui/common/activity/ActivityLotteryGate.java

@@ -0,0 +1,25 @@
+package com.xuekairui.common.activity;
+
+/**
+ * 比价抽奖活动门面(可选实现,由活动模块提供)。
+ * <p>
+ * 用户模块在比价消耗后,通过该接口实时触发抽奖机会发放。
+ * 活动模块根据当前活动配置决定是否发放:
+ * <ul>
+ *   <li>活动未开启 → 不发放</li>
+ *   <li>用户不具备资格(会员等级不足/入驻未审核通过)→ 不发放</li>
+ *   <li>当日机会已达上限 → 不发放</li>
+ *   <li>其他情况 → 发放一次抽奖机会</li>
+ * </ul>
+ * 默认无实现时:用户模块保持兼容,不发放抽奖机会(活动模块未加载)。
+ */
+public interface ActivityLotteryGate {
+
+    /**
+     * 比价消耗后实时发放抽奖机会(幂等,当日已达上限则跳过)。
+     *
+     * @param userId    用户ID
+     * @param usageDate 比价日期(yyyy-MM-dd格式)
+     */
+    void onCrawlerConsumed(Long userId, String usageDate);
+}

+ 2 - 2
zhijiayun-gateway/src/main/resources/db/migration-v11.sql

@@ -103,7 +103,7 @@ VALUES (1, 0, '2026-07-10 00:00:00', '2026-07-31 23:59:59', 1, 'PRO',
     '比价抽奖:高级会员每自然日首次成功比价获得1次抽奖机会,先到先得')
 ON DUPLICATE KEY UPDATE id = id;
 
--- 抽奖机会表(每用户每日首次比价产生一条机会)
+-- 抽奖机会表(每用户每日比价产生机会,数量由 daily_max_chance 配置控制
 CREATE TABLE IF NOT EXISTS `t_activity_lottery_chance` (
     `id`           BIGINT       NOT NULL AUTO_INCREMENT,
     `user_id`      BIGINT       NOT NULL,
@@ -112,7 +112,7 @@ CREATE TABLE IF NOT EXISTS `t_activity_lottery_chance` (
     `used`         TINYINT      NOT NULL DEFAULT 0 COMMENT '是否已使用:0-未 1-已',
     `create_time`  DATETIME     DEFAULT CURRENT_TIMESTAMP,
     PRIMARY KEY (`id`),
-    UNIQUE KEY `uk_user_date_source` (`user_id`, `chance_date`, `source`) COMMENT '每日每来源仅1次机会'
+    KEY `idx_user_date_source` (`user_id`, `chance_date`, `source`) COMMENT '每日每来源可多次机会,数量由配置控制'
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='比价抽奖机会表';
 
 -- 奖品池

+ 0 - 38
zhijiayun-gateway/src/main/resources/db/migration-v13.sql

@@ -1,38 +0,0 @@
-,-- ============================================
--- 迁移 v13:全局系统配置表(替换 t_external_api_config)
--- 说明:
---   1. 新建 t_system_config 通用键值表,支持按 config_type 分组
---   2. config_key 为语义化标识符(如 miniapp_external_api),用于定位配置项
---   3. config_value 为 JSON,包含该配置项的所有参数(如 apiKey、allowedIps)
---   4. 后续新增配置类型只需在运营端新增记录,无需代码改动
--- ============================================
-
--- 删除旧的专用表(上一轮临时创建的,v13 尚未上线,安全删除)
-DROP TABLE IF EXISTS `t_external_api_config`;
-
--- 全局系统配置表
-CREATE TABLE IF NOT EXISTS `t_system_config` (
-    `id`            BIGINT       NOT NULL AUTO_INCREMENT COMMENT '主键ID',
-    `config_key`    VARCHAR(256) NOT NULL COMMENT '配置键(语义化标识,如 miniapp_external_api,用于程序按 key 获取配置)',
-    `config_value`  TEXT         DEFAULT NULL COMMENT '配置值(JSON 格式,包含该配置项的所有参数,如 {\"apiKey\":\"xxx\",\"allowedIps\":\"...\"})',
-    `config_type`   VARCHAR(64)  NOT NULL DEFAULT 'GENERAL' COMMENT '配置类型/分组:EXTERNAL_API-外部API / GENERAL-通用',
-    `config_name`   VARCHAR(128) NOT NULL COMMENT '配置名称(中文描述,如"小程序后台外部API")',
-    `enabled`       TINYINT(1)   DEFAULT 1 COMMENT '是否启用:0-禁用 1-启用',
-    `remark`        VARCHAR(255) DEFAULT '' COMMENT '备注说明',
-    `create_time`   DATETIME     DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
-    `update_time`   DATETIME     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
-    PRIMARY KEY (`id`),
-    UNIQUE KEY `uk_config_key` (`config_key`),
-    INDEX `idx_config_type` (`config_type`),
-    INDEX `idx_enabled` (`enabled`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='全局系统配置表(键值对模式,运营端可管理任意系统配置)';
-
--- 初始数据:小程序外部 API 配置(config_key 为语义标识,apiKey 存在 config_value JSON 中)
-INSERT INTO `t_system_config` (`config_key`, `config_value`, `config_type`, `config_name`, `enabled`, `remark`) VALUES
-    ('miniapp_external_api',
-     '{"apiKey":"a3f8c21e67b94d0e5f1a6c8d3b9e2071","allowedIps":""}',
-     'EXTERNAL_API',
-     '小程序后台外部API配置',
-     1,
-     '系统默认初始化配置,请尽快通过运营端修改 API Key 并配置 IP 白名单')
-ON DUPLICATE KEY UPDATE id = id;

+ 94 - 20
zhijiayun-invite/src/main/java/com/xuekairui/invite/service/InviteService.java

@@ -6,8 +6,17 @@ 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.*;
-import com.xuekairui.invite.entity.*;
+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;
@@ -25,13 +34,22 @@ 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.*;
+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;
 
 /**
@@ -52,6 +70,10 @@ public class InviteService {
     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)
@@ -83,9 +105,25 @@ public class InviteService {
 
     /**
      * 获取或创建用户的邀请码
+     * 使用 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)
@@ -96,14 +134,52 @@ public class InviteService {
                         .last("LIMIT 1"));
 
         if (existingCode != null) {
+            // 写入缓存
+            stringRedisTemplate.opsForValue().set(cacheKey, existingCode.getCode(), INVITE_CODE_CACHE_TTL);
             return toCodeResponse(existingCode, loginSource);
         }
 
-        // 创建新邀请码(uk_code 唯一索引兜底防止并发碰撞)
-        InviteConfig config = inviteConfigService.getActiveConfig();
-        LocalDateTime expireTime = LocalDateTime.now().plusDays(config.getInviteCodeExpireDays());
-        int maxInsertRetries = 3;
-        for (int attempt = 0; attempt < maxInsertRetries; attempt++) {
+        // 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)
@@ -114,18 +190,16 @@ public class InviteService {
                     .expireTime(expireTime)
                     .status(1)
                     .build();
-            try {
-                inviteCodeMapper.insert(newCode);
-                log.info("用户 {} 创建邀请码: {}", userId, code);
-                return toCodeResponse(newCode, loginSource);
-            } catch (org.springframework.dao.DuplicateKeyException e) {
-                log.warn("邀请码并发碰撞(attempt={}): {}", attempt + 1, code);
-                if (attempt == maxInsertRetries - 1) {
-                    throw new BusinessException(ErrorCode.BUSINESS_ERROR, "邀请码生成失败,请重试");
-                }
-            }
+            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);
         }
-        throw new BusinessException(ErrorCode.BUSINESS_ERROR, "邀请码生成失败,请重试");
     }
 
     // ==========================================

+ 3 - 3
zhijiayun-test/src/main/resources/static/activity-test.html

@@ -1304,19 +1304,19 @@ function renderAdminActivityLotteryModule() {
     ${testSection('admin-activity-lottery-prize-create', '新增奖品', 'POST', 'post', '/api/admin/activity/lottery/prizes', '需认证', 'auth-required',
         [
          {label:'奖品名称(name)', name:'name', placeholder:'高级会员3天', default:'高级会员3天'},
-         {label:'奖励类型(rewardType)', name:'rewardType', placeholder:'MEMBERSHIP/CRAWLER/EMPTY', default:'MEMBERSHIP'},
+         {label:'奖励类型(rewardType)', name:'rewardType', placeholder:'MEMBERSHIP/CRAWLER/EMPTY/PHYSICAL/OTHER', default:'MEMBERSHIP'},
          {label:'会员天数(rewardDays)', name:'rewardDays', placeholder:'3', default:'3'},
          {label:'会员等级(rewardLevel)', name:'rewardLevel', placeholder:'PRO/ULTRA', default:'PRO'},
          {label:'爬虫次数(rewardAmount)', name:'rewardAmount', placeholder:'CRAWLER类型时填写', default:'0'},
          {label:'概率权重(probabilityWeight)', name:'probabilityWeight', placeholder:'权重越大越容易中', default:'20'},
          {label:'启用(enabled)', name:'enabled', placeholder:'0停用/1启用', default:'1'},
          {label:'排序(sort)', name:'sort', placeholder:'0', default:'3'}
-        ], true, 'adminActivityLotteryPrize', '新增奖品到奖品池。rewardType: MEMBERSHIP会员/CRAWLER爬虫次数(暂未实现)/EMPTY谢谢参与')}
+        ], true, 'adminActivityLotteryPrize', '新增奖品到奖品池。rewardType: MEMBERSHIP会员/CRAWLER爬虫次数(暂未实现)/EMPTY谢谢参与/PHYSICAL实物/OTHER其他(需客服核实)')}
     ${testSection('admin-activity-lottery-prize-update', '更新奖品', 'PUT', 'put', '/api/admin/activity/lottery/prizes/{id}', '需认证', 'auth-required',
         [
          {label:'奖品ID(id)', name:'id', placeholder:'1', default:''},
          {label:'奖品名称(name)', name:'name', placeholder:'高级会员3天', default:'高级会员3天'},
-         {label:'奖励类型(rewardType)', name:'rewardType', placeholder:'MEMBERSHIP/CRAWLER/EMPTY', default:'MEMBERSHIP'},
+         {label:'奖励类型(rewardType)', name:'rewardType', placeholder:'MEMBERSHIP/CRAWLER/EMPTY/PHYSICAL/OTHER', default:'MEMBERSHIP'},
          {label:'会员天数(rewardDays)', name:'rewardDays', placeholder:'3', default:'3'},
          {label:'会员等级(rewardLevel)', name:'rewardLevel', placeholder:'PRO/ULTRA', default:'PRO'},
          {label:'爬虫次数(rewardAmount)', name:'rewardAmount', placeholder:'CRAWLER类型时填写', default:'0'},

+ 20 - 4
zhijiayun-user/src/main/java/com/xuekairui/user/service/CrawlerService.java

@@ -3,6 +3,7 @@ package com.xuekairui.user.service;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.xuekairui.common.BusinessException;
 import com.xuekairui.common.ErrorCode;
+import com.xuekairui.common.activity.ActivityLotteryGate;
 import com.xuekairui.common.event.ActivityEventRedisService;
 import com.xuekairui.user.dto.CrawlerStatusResponse;
 import com.xuekairui.user.dto.QuotaBreakdownItem;
@@ -18,6 +19,7 @@ import com.xuekairui.user.mapper.UserCouponMapper;
 import com.xuekairui.user.mapper.UserMapper;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.transaction.support.TransactionSynchronization;
@@ -52,6 +54,10 @@ public class CrawlerService {
     private final OperationAuditLogService auditLogService;
     private final ActivityEventRedisService activityEventRedisService;
 
+    /** 活动模块可选依赖:未加载时跳过实时抽奖机会发放 */
+    @Autowired(required = false)
+    private ActivityLotteryGate activityLotteryGate;
+
     /**
      * 获取用户爬虫权益状态(统一展示等级配额 + 额外配额来源明细)
      */
@@ -277,14 +283,24 @@ public class CrawlerService {
                 .build();
         crawlerUsageLogMapper.insert(usageLog);
 
-        // 存入 Redis 事件队列,事务提交后由活动模块消费(比价抽奖:每日首次比价发抽奖机会
+        // 事务提交后实时发放抽奖机会(比价抽奖:每日比价发抽奖机会,受 dailyMaxChance 限制
         TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
             @Override
             public void afterCommit() {
-                // tryIncrementCrawlerPushedCount 返回 true 表示在每日限制内(首次比价),应该推送
-                // 返回 false 表示已超出每日限制,跳过推送
+                // 优先使用实时接口调用(活动模块加载时可用)
+                if (activityLotteryGate != null) {
+                    try {
+                        activityLotteryGate.onCrawlerConsumed(userId, usageLog.getUsageDate().toString());
+                        log.debug("实时发放抽奖机会成功: userId={}, date={}", userId, usageLog.getUsageDate());
+                        return;
+                    } catch (Exception e) {
+                        log.warn("实时发放抽奖机会失败,降级为Redis队列: userId={}", userId, e);
+                    }
+                }
+
+                // 降级:活动模块未加载或实时调用失败时,通过 Redis 队列异步处理
                 if (!activityEventRedisService.tryIncrementCrawlerPushedCount(userId)) {
-                    return; // 已超出每日限制,跳过
+                    return;
                 }
                 try {
                     activityEventRedisService.pushCrawlerConsumed(userId, count, platform,