Browse Source

比价活动代码优化

liuchengsen 1 month ago
parent
commit
74de6ff5c5

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

@@ -31,4 +31,7 @@ public class ActivityLotteryStatusResponse {
 
     /** 今日已使用抽奖机会数 */
     private Integer usedChances;
+
+    /** 状态原因说明(机会为0时告知原因,有机会时为null) */
+    private String reason;
 }

+ 43 - 7
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.event.ActivityEventRedisService;
 import com.xuekairui.user.entity.User;
 import com.xuekairui.user.enums.MembershipLevel;
 import com.xuekairui.user.mapper.UserMapper;
@@ -58,6 +59,7 @@ public class ActivityLotteryService {
     private final ActivityLotteryRecordMapper recordMapper;
     private final UserMapper userMapper;
     private final MembershipService membershipService;
+    private final ActivityEventRedisService activityEventRedisService;
 
     // ==========================================
     // 配置管理
@@ -90,8 +92,11 @@ public class ActivityLotteryService {
         } else {
             configMapper.updateById(config);
         }
-        log.info("比价抽奖活动配置已更新: enabled={}, start={}, end={}",
-                config.getEnabled(), config.getStartTime(), config.getEndTime());
+        // 同步每日上限到 Redis 缓存,供事件防重模块实时读取
+        int maxChance = config.getDailyMaxChance() != null ? config.getDailyMaxChance() : 1;
+        activityEventRedisService.updateCachedMaxChance(maxChance);
+        log.info("比价抽奖活动配置已更新: enabled={}, start={}, end={}, dailyMaxChance={}",
+                config.getEnabled(), config.getStartTime(), config.getEndTime(), maxChance);
         return config;
     }
 
@@ -109,7 +114,7 @@ public class ActivityLotteryService {
         if (!isActive(config)) {
             return;
         }
-        if (isEligible(userId, config)) {
+        if (isNotQualified(userId, config)) {
             return;
         }
 
@@ -142,7 +147,7 @@ public class ActivityLotteryService {
         if (!isActive(config)) {
             throw new BusinessException(ErrorCode.BUSINESS_ERROR, "比价抽奖活动未开启或已结束");
         }
-        if (isEligible(userId, config)) {
+        if (isNotQualified(userId, config)) {
             throw new BusinessException(ErrorCode.BUSINESS_ERROR, "仅高级会员可参与比价抽奖");
         }
 
@@ -232,14 +237,19 @@ public class ActivityLotteryService {
         String requireLevel = config.getRequireMembershipLevel() != null ? config.getRequireMembershipLevel() : "PRO";
         boolean eligible = isLevelEnough(userLevel, requireLevel);
 
+        int avail = available != null ? available.intValue() : 0;
+        int usedCount = used != null ? used.intValue() : 0;
+        String reason = computeReason(config, avail, usedCount, eligible, userLevel, requireLevel);
+
         return ActivityLotteryStatusResponse.builder()
                 .enabled(isActive(config))
                 .inWindow(inWindow(config))
                 .eligible(eligible)
                 .membershipLevel(userLevel != null ? userLevel.getCode() : null)
                 .requireMembershipLevel(requireLevel)
-                .availableChances(available != null ? available.intValue() : 0)
-                .usedChances(used != null ? used.intValue() : 0)
+                .availableChances(avail)
+                .usedChances(usedCount)
+                .reason(reason)
                 .build();
     }
 
@@ -362,6 +372,32 @@ public class ActivityLotteryService {
     // 私有辅助
     // ==========================================
 
+    /**
+     * 按优先级计算抽奖状态原因。有机会时返回 null。
+     */
+    private String computeReason(ActivityLotteryConfig config, int avail, int usedCount,
+                                  boolean eligible, MembershipLevel userLevel, String requireLevel) {
+        if (avail > 0) {
+            return null;
+        }
+        boolean enabled = config.getEnabled() != null && config.getEnabled() == 1;
+        boolean inWin = inWindow(config);
+        if (!enabled) {
+            return inWin ? "活动未开启" : "活动未开启且不在时间窗内";
+        }
+        if (!inWin) {
+            return "不在活动时间窗内";
+        }
+        if (!eligible) {
+            String level = userLevel != null ? userLevel.getCode() : "无";
+            return "会员等级不足(当前" + level + ",需要" + requireLevel + ")";
+        }
+        if (usedCount == 0) {
+            return "今日尚未比价,完成首次比价后自动获得抽奖机会";
+        }
+        return "今日抽奖机会已用完";
+    }
+
     private boolean isActive(ActivityLotteryConfig config) {
         return config.getEnabled() != null && config.getEnabled() == 1 && inWindow(config);
     }
@@ -371,7 +407,7 @@ public class ActivityLotteryService {
         return !now.isBefore(config.getStartTime()) && !now.isAfter(config.getEndTime());
     }
 
-    private boolean isEligible(Long userId, ActivityLotteryConfig config) {
+    private boolean isNotQualified(Long userId, ActivityLotteryConfig config) {
         String requireLevel = config.getRequireMembershipLevel() != null ? config.getRequireMembershipLevel() : "PRO";
         return !isLevelEnough(getUserLevel(userId), requireLevel);
     }

+ 38 - 16
zhijiayun-common/src/main/java/com/xuekairui/common/event/ActivityEventRedisService.java

@@ -8,6 +8,7 @@ import org.springframework.data.redis.core.StringRedisTemplate;
 import org.springframework.stereotype.Service;
 
 import java.time.Duration;
+import java.time.LocalDate;
 import java.util.HashMap;
 import java.util.Map;
 
@@ -34,7 +35,10 @@ public class ActivityEventRedisService {
 
     private static final String KEY_LICENSE_APPROVED = "activity:events:license_approved";
     private static final String KEY_CRAWLER_CONSUMED = "activity:events:crawler_consumed";
+    private static final String KEY_CRAWLER_PUSHED_PREFIX = "activity:event:crawler_pushed:";
+    private static final String KEY_LOTTERY_MAX_CHANCE = "activity:config:lottery:max_chance";
     private static final String SUFFIX_BACKUP = ":backup";
+    private static final int DEFAULT_MAX_CHANCE = 1;
 
     /** 事件在 Redis 中的最大存活时间(防止异常情况下堆积) */
     private static final Duration EVENT_TTL = Duration.ofDays(7);
@@ -75,6 +79,40 @@ public class ActivityEventRedisService {
         push(KEY_CRAWLER_CONSUMED, data);
     }
 
+    /**
+     * 递增今日推送计数并检查是否在每日上限内(INCR + 当日 TTL)。
+     * 上限从 Redis 缓存读取,由运营端更新配置时同步写入。缓存未命中时默认 1。
+     * @return true-在限制内,可以推送  false-已达上限,跳过
+     */
+    public boolean tryIncrementCrawlerPushedCount(Long userId) {
+        String key = KEY_CRAWLER_PUSHED_PREFIX + userId + ":" + LocalDate.now();
+        Long count = stringRedisTemplate.opsForValue().increment(key);
+        if (count != null && count == 1) {
+            stringRedisTemplate.expire(key, Duration.ofDays(1));
+        }
+        return count != null && count <= getCachedMaxChance();
+    }
+
+    /**
+     * 更新缓存的每日最大抽奖机会数(由 ActivityLotteryService.updateConfig 调用)。
+     */
+    public void updateCachedMaxChance(int maxChance) {
+        stringRedisTemplate.opsForValue().set(KEY_LOTTERY_MAX_CHANCE, String.valueOf(maxChance));
+        log.info("已更新抽奖每日上限缓存: maxChance={}", maxChance);
+    }
+
+    private int getCachedMaxChance() {
+        String val = stringRedisTemplate.opsForValue().get(KEY_LOTTERY_MAX_CHANCE);
+        if (val != null) {
+            try {
+                return Integer.parseInt(val);
+            } catch (NumberFormatException e) {
+                log.warn("抽奖上限缓存值异常: {}, 使用默认值 {}", val, DEFAULT_MAX_CHANCE);
+            }
+        }
+        return DEFAULT_MAX_CHANCE;
+    }
+
     /**
      * 从入驻审核通过队列安全取出一条事件(使用 RPOPLPUSH 到备份队列,容错不丢事件)
      */
@@ -104,22 +142,6 @@ public class ActivityEventRedisService {
         removeFromBackup(KEY_CRAWLER_CONSUMED, eventData);
     }
 
-    /**
-     * 获取备份队列中待重试的事件数量(用于监控)
-     */
-    public long getLicenseApprovedBacklog() {
-        Long size = stringRedisTemplate.opsForList().size(KEY_LICENSE_APPROVED + SUFFIX_BACKUP);
-        return size != null ? size : 0;
-    }
-
-    /**
-     * 获取备份队列中待重试的事件数量(用于监控)
-     */
-    public long getCrawlerConsumedBacklog() {
-        Long size = stringRedisTemplate.opsForList().size(KEY_CRAWLER_CONSUMED + SUFFIX_BACKUP);
-        return size != null ? size : 0;
-    }
-
     private void push(String key, Map<String, Object> data) {
         try {
             String json = objectMapper.writeValueAsString(data);

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

@@ -191,6 +191,7 @@
         <div class="module-tab" data-module="business-license">4.入驻信息提交</div>
         <div class="module-tab" data-module="activity-available">5.我可参加的活动</div>
         <div class="module-tab" data-module="activity-rewards-tab">6.各种活动奖励</div>
+        <div class="module-tab" data-module="search-record">7.搜索记录</div>
     </div>
 
     <!-- 运营侧模块标签栏 -->
@@ -217,7 +218,7 @@ const SMS_CODE = () => document.getElementById('smsCode')?.value || '123456';
 
 let stats = { pass: 0, fail: 0, pending: 0, total: 0 };
 
-const USER_MODULES = ['checkin', 'activity-invite', 'activity-lottery', 'business-license', 'activity-available', 'activity-rewards-tab'];
+const USER_MODULES = ['checkin', 'activity-invite', 'activity-lottery', 'business-license', 'activity-available', 'activity-rewards-tab', 'search-record'];
 const ADMIN_MODULES = ['admin-checkin', 'admin-activity-invite', 'admin-activity-checkin', 'admin-activity-lottery', 'admin-license'];
 
 // ===================== 大整数安全JSON解析 =====================
@@ -438,6 +439,7 @@ function renderModule(module) {
         case 'business-license': html = renderBusinessLicenseModule(); break;
         case 'activity-available': html = renderActivityAvailableModule(); break;
         case 'activity-rewards-tab': html = renderActivityRewardsModule(); break;
+        case 'search-record': html = renderSearchRecordModule(); break;
         case 'admin-checkin': html = renderAdminCheckinModule(); break;
         case 'admin-activity-invite': html = renderAdminActivityInviteModule(); break;
         case 'admin-activity-checkin': html = renderAdminActivityCheckinModule(); break;
@@ -606,7 +608,8 @@ const FIELD_DESC = {
         ['data.membershipLevel', 'String', '当前用户会员等级'],
         ['data.requireMembershipLevel', 'String', '所需会员等级'],
         ['data.availableChances', 'Integer', '今日可用抽奖机会数'],
-        ['data.usedChances', 'Integer', '今日已用抽奖机会数']
+        ['data.usedChances', 'Integer', '今日已用抽奖机会数'],
+        ['data.reason', 'String/null', '状态原因说明(机会为0时返回具体原因,有机会时为null)']
     ],
     'activityLotteryPrizes': [
         ['data[].id', 'Long', '奖品ID'],
@@ -763,6 +766,33 @@ const FIELD_DESC = {
         ['data.showVerifiedBadge', 'Boolean', '是否显示认证标识'],
         ['data.reviewRemark', 'String', '审核备注'],
         ['data.createTime', 'DateTime', '创建时间']
+    ],
+    'searchRecordResponse': [
+        ['id', 'Long', '记录ID(雪花算法)'],
+        ['drugName', 'String', '药品名称'],
+        ['searchedAt', 'DateTime', '搜索时间(yyyy-MM-dd HH:mm:ss)'],
+        ['statusText', 'String', '状态文案'],
+        ['platformCount', 'Integer', '涉及平台数量'],
+        ['quoteCount', 'Integer', '总报价条数'],
+        ['platformResults', 'Object/Array', '平台搜索结果(仅详情接口返回)'],
+        ['searchLog', 'Object/null', '搜索日志(仅详情接口返回)']
+    ],
+    'searchQueryResponse': [
+        ['data.keyword', 'String', '搜索关键词'],
+        ['data.totalCount', 'Integer', '结果总数'],
+        ['data.lowestPrice', 'BigDecimal', '最低价'],
+        ['data.lowestPricePlatform', 'String', '最低价平台名称'],
+        ['data.searchTime', 'DateTime', '搜索时间'],
+        ['data.prices[].platformCode', 'String', '平台代码'],
+        ['data.prices[].platformName', 'String', '平台名称'],
+        ['data.prices[].price', 'BigDecimal', '价格(元)'],
+        ['data.prices[].spec', 'String', '规格'],
+        ['data.prices[].manufacturer', 'String', '厂家'],
+        ['data.prices[].expiry', 'String', '效期'],
+        ['data.prices[].moq', 'Integer', '最小起订量'],
+        ['data.prices[].stockStatus', 'String', '库存状态(IN_STOCK/LOW_STOCK/OUT_OF_STOCK)'],
+        ['data.prices[].isLowest', 'Boolean', '是否为最低价'],
+        ['data.prices[].purchaseUrl', 'String', '平台跳转链接']
     ]
 };
 
@@ -918,7 +948,7 @@ function renderActivityLotteryModule() {
         <button class="btn btn-warning btn-sm" onclick="runActivityLotteryTests()">运行全部比价抽奖测试</button>
     </div>
     ${testSection('activity-lottery-prizes', '奖励信息(可抽奖品列表)', 'GET', 'get', '/api/activity/lottery/prizes', '需认证', 'auth-required', null, true, 'activityLotteryPrizes', '查看当前可抽奖品列表(仅启用奖品,不显示概率权重等敏感信息)')}
-    ${testSection('activity-lottery-status', '比价抽奖活动状态', 'GET', 'get', '/api/activity/lottery/status', '需认证', 'auth-required', null, true, 'activityLotteryStatus', '查询抽奖活动状态:是否开启、是否满足资格、今日可用机会')}
+    ${testSection('activity-lottery-status', '比价抽奖活动状态', 'GET', 'get', '/api/activity/lottery/status', '需认证', 'auth-required', null, true, 'activityLotteryStatus', '查询抽奖活动状态:是否开启、是否满足资格、今日可用机会。机会为0时reason字段告知具体原因(活动未开启/不在时间窗/会员等级不足/尚未比价/机会已用完)')}
     ${testSection('activity-lottery-draw', '执行抽奖', 'POST', 'post', '/api/activity/lottery/draw', '需认证', 'auth-required', null, true, 'activityLotteryDraw', '消耗一次抽奖机会,随机抽取奖品(会员/爬虫次数/谢谢参与)')}
     ${testSection('activity-lottery-records', '我的抽奖记录', 'GET', 'get', '/api/activity/lottery/records?limit=20', '需认证', 'auth-required',
         [{label:'条数(limit,可选)', name:'limit', placeholder:'20', default:'20'}], true, 'activityLotteryRecords', '获取当前用户的抽奖历史记录,包含奖品名称、奖励类型、会员天数、是否中奖等完整中奖信息')}`;
@@ -999,6 +1029,30 @@ function renderActivityRewardsModule() {
         ], true, 'activityRewards', '按活动类型分页查询当前用户的三种活动中奖/奖励记录。type: INVITE-邀请有礼 / CHECKIN-签到有礼 / LOTTERY-比价抽奖')}`;
 }
 
+// ==================== 用户端:搜索记录模块 ====================
+function renderSearchRecordModule() {
+    return `
+    <div class="batch-buttons">
+        <button class="btn btn-warning btn-sm" onclick="runSearchRecordTests()">运行全部搜索记录测试</button>
+    </div>
+    ${testSection('sr-save', '保存搜索记录', 'POST', 'post', '/api/search-record/history', '需认证', 'auth-required',
+        [{label:'药品名称(drugName)', name:'drugName', placeholder:'阿莫西林', default:'阿莫西林'},
+         {label:'搜索时间(searchedAt)', name:'searchedAt', placeholder:'yyyy-MM-dd HH:mm:ss', default:'2026-07-10 08:30:00'},
+         {label:'状态文案(statusText)', name:'statusText', placeholder:'已返回 2 个平台的可比价结果。', default:'已返回 2 个平台的可比价结果。'},
+         {label:'平台结果(platformResults)', name:'platformResults', placeholder:'JSON数组', default:'[{"platformCode":"yiyaocheng","platformName":"壹药城","response":{"products":[{"drugName":"阿莫西林","price":12.5}]}}]', type:'json'},
+         {label:'搜索日志(searchLog)', name:'searchLog', placeholder:'null', default:'null', type:'json'}], true, 'searchRecordResponse', '保存一次完整的比价搜索快照。platformResults为JSON数组,每个元素含platformCode/platformName/response;后端自动计算platformCount和quoteCount')}
+    ${testSection('sr-list', '搜索记录列表', 'GET', 'get', '/api/search-record/history?pageNo={pageNo}&pageSize={pageSize}', '需认证', 'auth-required',
+        [{label:'页码(pageNo)', name:'pageNo', placeholder:'1', default:'1'},
+         {label:'每页条数(pageSize)', name:'pageSize', placeholder:'20', default:'20'}], true, 'searchRecordResponse', '分页查询当前用户搜索记录(轻量摘要,不含platformResults/searchLog大字段)')}
+    ${testSection('sr-detail', '记录详情', 'GET', 'get', '/api/search-record/history/{id}', '需认证', 'auth-required',
+        [{label:'记录ID(id)', name:'id', placeholder:'1', default:'1'}], true, 'searchRecordResponse', '读取单条搜索记录详情(含完整platformResults和searchLog)')}
+    ${testSection('sr-lottery-status', '比价抽奖状态', 'GET', 'get', '/api/activity/lottery/status', '需认证', 'auth-required', null, true, 'activityLotteryStatus', '查询今日抽奖状态。活动规则:2026.7.10-7.31,高级会员每自然日首次成功比价后自动获得1次抽奖机会,先到先得。reason字段说明为什么无机会')}
+    ${testSection('sr-lottery-draw', '执行抽奖', 'POST', 'post', '/api/activity/lottery/draw', '需认证', 'auth-required', null, true, 'activityLotteryDraw', '消耗一次抽奖机会随机中奖(会员天数/谢谢参与),先到先得。前提:当日首次成功比价后后端自动发放机会,每日限1次')}
+    ${testSection('sr-delete', '删除记录', 'DELETE', 'delete', '/api/search-record/history/{id}', '需认证', 'auth-required',
+        [{label:'记录ID(id)', name:'id', placeholder:'1', default:'1'}], false, null, '删除单条搜索记录(幂等,不存在也返回成功)')}
+    ${testSection('sr-clear', '清空记录', 'DELETE', 'delete', '/api/search-record/history', '需认证', 'auth-required', null, false, null, '清空当前用户全部搜索记录')}`;
+}
+
 // ==================== 运营端:签到管理模块 ====================
 function renderAdminCheckinModule() {
     return `
@@ -1349,6 +1403,7 @@ function runAdminActivityInviteTests() { runBatchTests('admin-activity-invite');
 function runAdminActivityCheckinTests() { runBatchTests('admin-activity-checkin'); }
 function runAdminActivityLotteryTests() { runBatchTests('admin-activity-lottery'); }
 function runAdminLicenseTests() { runBatchTests('admin-license'); }
+function runSearchRecordTests() { runBatchTests('search-record'); }
 
 // ===================== 验证器 =====================
 function validate_checkinStatus(result) { return result.data?.code === 200 && result.data?.data?.checkedToday !== undefined; }
@@ -1393,6 +1448,13 @@ function validate_adminLicenseList(result) { return result.data?.code === 200 &&
 function validate_adminLicenseApprove(result) { return result.data?.code === 200; }
 function validate_adminLicenseReject(result) { return result.data?.code === 200; }
 
+// 搜索记录模块
+function validate_srSave(result) { return result.data?.code === 200 && result.data?.data?.id; }
+function validate_srList(result) { return result.data?.code === 200 && result.data?.data?.records !== undefined; }
+function validate_srDetail(result) { return result.data?.code === 200 && result.data?.data?.id; }
+function validate_srLotteryStatus(result) { return result.data?.code === 200 && result.data?.data?.enabled !== undefined; }
+function validate_srLotteryDraw(result) { return result.data?.code === 200 && result.data?.data?.prizeName !== undefined; }
+
 // ===================== 初始化 =====================
 window.addEventListener('DOMContentLoaded', () => {
     const params = new URLSearchParams(window.location.search);

+ 3 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/service/CrawlerService.java

@@ -278,6 +278,9 @@ public class CrawlerService {
         TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
             @Override
             public void afterCommit() {
+                if (activityEventRedisService.tryIncrementCrawlerPushedCount(userId)) {
+                    return; // 今日已推送过,跳过
+                }
                 try {
                     activityEventRedisService.pushCrawlerConsumed(userId, count, platform,
                             usageLog.getUsageDate().toString());