package com.xuekairui.activity.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.xuekairui.activity.dto.ActivityLotteryConfigRequest; import com.xuekairui.activity.dto.ActivityLotteryPrizeRequest; import com.xuekairui.activity.dto.ActivityLotteryResultResponse; import com.xuekairui.activity.dto.ActivityLotteryStatusResponse; import com.xuekairui.activity.entity.ActivityLotteryChance; import com.xuekairui.activity.entity.ActivityLotteryConfig; import com.xuekairui.activity.entity.ActivityLotteryPrize; import com.xuekairui.activity.entity.ActivityLotteryRecord; import com.xuekairui.activity.mapper.ActivityLotteryChanceMapper; import com.xuekairui.activity.mapper.ActivityLotteryConfigMapper; 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.user.entity.User; import com.xuekairui.user.enums.MembershipLevel; import com.xuekairui.user.mapper.UserMapper; import com.xuekairui.user.service.MembershipService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; import java.util.Optional; import java.util.concurrent.ThreadLocalRandom; /** * 比价抽奖活动服务。 *

* 规则: * 1. 活动时间窗 2026-07-10 ~ 2026-07-31,所有高级会员(PRO及以上)可参加; * 2. 活动期间每自然日首次成功比价获得一次抽奖机会(先到先得,每日1次); * 3. 抽奖按奖品权重随机中奖,中奖后即时发放对应奖励。 */ @Slf4j @Service @RequiredArgsConstructor public class ActivityLotteryService { 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 final ActivityLotteryConfigMapper configMapper; private final ActivityLotteryChanceMapper chanceMapper; private final ActivityLotteryPrizeMapper prizeMapper; private final ActivityLotteryRecordMapper recordMapper; private final UserMapper userMapper; private final MembershipService membershipService; // ========================================== // 配置管理 // ========================================== public ActivityLotteryConfig getConfig() { ActivityLotteryConfig config = configMapper.selectById(CONFIG_ID); if (config == null) { return ActivityLotteryConfig.builder() .id(CONFIG_ID).enabled(0) .startTime(LocalDateTime.now()).endTime(LocalDateTime.now()) .dailyMaxChance(1).requireMembershipLevel("PRO") .build(); } return config; } @Transactional public ActivityLotteryConfig updateConfig(ActivityLotteryConfigRequest request) { ActivityLotteryConfig config = getConfig(); config.setEnabled(request.getEnabled()); config.setStartTime(request.getStartTime()); config.setEndTime(request.getEndTime()); Optional.ofNullable(request.getDailyMaxChance()).ifPresent(config::setDailyMaxChance); Optional.ofNullable(request.getRequireMembershipLevel()).ifPresent(config::setRequireMembershipLevel); Optional.ofNullable(request.getRemark()).ifPresent(config::setRemark); if (config.getId() == null) { config.setId(CONFIG_ID); configMapper.insert(config); } else { configMapper.updateById(config); } log.info("比价抽奖活动配置已更新: enabled={}, start={}, end={}", config.getEnabled(), config.getStartTime(), config.getEndTime()); return config; } // ========================================== // 事件处理:比价成功后发放抽奖机会 // ========================================== /** * 监听比价消耗事件:活动开启且用户具备资格时,每自然日首次比价发放一次抽奖机会。 * 幂等:t_activity_lottery_chance 的 uk_user_date_source 保证每日仅一次。 */ @Transactional public void onCrawlerConsumed(Long userId, String usageDateStr) { ActivityLotteryConfig config = getConfig(); if (!isActive(config)) { return; } if (isEligible(userId, config)) { return; } LocalDate usageDate = usageDateStr != null ? LocalDate.parse(usageDateStr) : LocalDate.now(); ActivityLotteryChance chance = ActivityLotteryChance.builder() .userId(userId) .chanceDate(usageDate) .source(SOURCE_COMPARE) .used(0) .build(); try { chanceMapper.insert(chance); log.info("比价抽奖机会已发放: userId={}, date={}", userId, usageDate); } catch (DuplicateKeyException e) { // 今日已发放过机会,忽略 } } // ========================================== // 用户端:抽奖 // ========================================== /** * 执行抽奖:消耗一个当日未使用的抽奖机会,按权重随机中奖并发放奖励。 */ @Transactional public ActivityLotteryResultResponse draw(Long userId) { ActivityLotteryConfig config = getConfig(); if (!isActive(config)) { throw new BusinessException(ErrorCode.BUSINESS_ERROR, "比价抽奖活动未开启或已结束"); } if (isEligible(userId, config)) { throw new BusinessException(ErrorCode.BUSINESS_ERROR, "仅高级会员可参与比价抽奖"); } LocalDate today = LocalDate.now(); // 取一条未使用的机会 ActivityLotteryChance chance = chanceMapper.selectOne( new LambdaQueryWrapper() .eq(ActivityLotteryChance::getUserId, userId) .eq(ActivityLotteryChance::getChanceDate, today) .eq(ActivityLotteryChance::getSource, SOURCE_COMPARE) .eq(ActivityLotteryChance::getUsed, 0) .last("LIMIT 1")); if (chance == null) { throw new BusinessException(ErrorCode.BUSINESS_ERROR, "今日暂无可用抽奖机会,完成首次比价即可获得"); } // 乐观锁占用机会,防止并发重复抽奖 int affected = chanceMapper.update(null, new LambdaUpdateWrapper() .eq(ActivityLotteryChance::getId, chance.getId()) .eq(ActivityLotteryChance::getUsed, 0) .set(ActivityLotteryChance::getUsed, 1)); if (affected == 0) { throw new BusinessException(ErrorCode.BUSINESS_ERROR, "抽奖机会已被使用"); } // 加权随机选中奖品 ActivityLotteryPrize prize = pickPrize(); if (prize == null) { // 无可用奖品,兜底为未中奖 prize = ActivityLotteryPrize.builder() .name("谢谢参与").rewardType(REWARD_TYPE_EMPTY).rewardAmount(0).build(); } // 发放奖励 applyReward(userId, prize); // 记录抽奖结果 ActivityLotteryRecord record = ActivityLotteryRecord.builder() .userId(userId) .prizeId(prize.getId()) .prizeName(prize.getName()) .rewardType(prize.getRewardType()) .rewardAmount(prize.getRewardAmount() != null ? prize.getRewardAmount() : 0) .rewardDays(prize.getRewardDays()) .rewardLevel(prize.getRewardLevel()) .chanceId(chance.getId()) .build(); recordMapper.insert(record); log.info("抽奖完成: userId={}, prize={}, type={}", userId, prize.getName(), prize.getRewardType()); boolean win = !REWARD_TYPE_EMPTY.equalsIgnoreCase(prize.getRewardType()); return ActivityLotteryResultResponse.builder() .recordId(record.getId()) .prizeName(prize.getName()) .rewardType(prize.getRewardType()) .rewardAmount(record.getRewardAmount()) .rewardDays(prize.getRewardDays()) .rewardLevel(prize.getRewardLevel()) .win(win) .createTime(record.getCreateTime()) .build(); } /** * 查询用户抽奖状态 */ public ActivityLotteryStatusResponse getStatus(Long userId) { ActivityLotteryConfig config = getConfig(); LocalDate today = LocalDate.now(); Long available = chanceMapper.selectCount( new LambdaQueryWrapper() .eq(ActivityLotteryChance::getUserId, userId) .eq(ActivityLotteryChance::getChanceDate, today) .eq(ActivityLotteryChance::getSource, SOURCE_COMPARE) .eq(ActivityLotteryChance::getUsed, 0)); Long used = chanceMapper.selectCount( new LambdaQueryWrapper() .eq(ActivityLotteryChance::getUserId, userId) .eq(ActivityLotteryChance::getChanceDate, today) .eq(ActivityLotteryChance::getSource, SOURCE_COMPARE) .eq(ActivityLotteryChance::getUsed, 1)); MembershipLevel userLevel = getUserLevel(userId); String requireLevel = config.getRequireMembershipLevel() != null ? config.getRequireMembershipLevel() : "PRO"; boolean eligible = isLevelEnough(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) .build(); } /** * 查询用户抽奖记录 */ public List listMyRecords(Long userId, Integer limit) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .eq(ActivityLotteryRecord::getUserId, userId) .orderByDesc(ActivityLotteryRecord::getCreateTime); if (limit != null && limit > 0) { wrapper.last("LIMIT " + limit); } return recordMapper.selectList(wrapper); } /** * 运营端:分页查询所有用户抽奖记录(支持按用户ID筛选) */ public Page listAllRecords( Long userId, int pageNum, int pageSize) { Page page = new Page<>(pageNum, pageSize); LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); if (userId != null) { wrapper.eq(ActivityLotteryRecord::getUserId, userId); } wrapper.orderByDesc(ActivityLotteryRecord::getCreateTime); return recordMapper.selectPage(page, wrapper); } // ========================================== // 奖品管理(运营端) // ========================================== public List listPrizes() { return prizeMapper.selectList( new LambdaQueryWrapper().orderByAsc(ActivityLotteryPrize::getSort)); } /** * 用户端:获取启用的奖品列表(不含概率权重) */ public List listEnabledPrizes() { List prizes = prizeMapper.selectList( new LambdaQueryWrapper() .eq(ActivityLotteryPrize::getEnabled, 1) .orderByAsc(ActivityLotteryPrize::getSort)); for (ActivityLotteryPrize p : prizes) { p.setProbabilityWeight(null); } return prizes; } @Transactional public ActivityLotteryPrize createPrize(ActivityLotteryPrizeRequest request) { ActivityLotteryPrize prize = ActivityLotteryPrize.builder() .name(request.getName()) .rewardType(request.getRewardType()) .rewardAmount(request.getRewardAmount() != null ? request.getRewardAmount() : 0) .rewardDays(request.getRewardDays()) .rewardLevel(request.getRewardLevel()) .probabilityWeight(request.getProbabilityWeight()) .enabled(request.getEnabled() != null ? request.getEnabled() : 1) .sort(request.getSort() != null ? request.getSort() : 0) .build(); prizeMapper.insert(prize); return prize; } @Transactional public ActivityLotteryPrize updatePrize(Long id, ActivityLotteryPrizeRequest request) { ActivityLotteryPrize prize = prizeMapper.selectById(id); if (prize == null) { throw new BusinessException(ErrorCode.PARAM_ERROR, "奖品不存在"); } prize.setName(request.getName()); prize.setRewardType(request.getRewardType()); Optional.ofNullable(request.getRewardAmount()).ifPresent(prize::setRewardAmount); prize.setRewardDays(request.getRewardDays()); prize.setRewardLevel(request.getRewardLevel()); Optional.ofNullable(request.getProbabilityWeight()).ifPresent(prize::setProbabilityWeight); Optional.ofNullable(request.getEnabled()).ifPresent(prize::setEnabled); Optional.ofNullable(request.getSort()).ifPresent(prize::setSort); prizeMapper.updateById(prize); return prize; } @Transactional public void deletePrize(Long id) { prizeMapper.deleteById(id); } // ========================================== // 私有辅助 // ========================================== private boolean isActive(ActivityLotteryConfig config) { return config.getEnabled() != null && config.getEnabled() == 1 && inWindow(config); } private boolean inWindow(ActivityLotteryConfig config) { LocalDateTime now = LocalDateTime.now(); return !now.isBefore(config.getStartTime()) && !now.isAfter(config.getEndTime()); } private boolean isEligible(Long userId, ActivityLotteryConfig config) { String requireLevel = config.getRequireMembershipLevel() != null ? config.getRequireMembershipLevel() : "PRO"; return !isLevelEnough(getUserLevel(userId), requireLevel); } private MembershipLevel getUserLevel(Long userId) { User user = userMapper.selectById(userId); return user != null ? user.getMembershipLevel() : null; } private boolean isLevelEnough(MembershipLevel userLevel, String requireCode) { if (userLevel == null) { return false; } MembershipLevel require = MembershipLevel.fromCode(requireCode); if (require == null) { return false; } return userLevel.getWeight() >= require.getWeight(); } /** * 按权重随机选择一个启用的奖品 */ private ActivityLotteryPrize pickPrize() { List prizes = prizeMapper.listEnabled(); if (prizes.isEmpty()) { return null; } int totalWeight = prizes.stream() .mapToInt(p -> p.getProbabilityWeight() != null ? p.getProbabilityWeight() : 0) .sum(); if (totalWeight <= 0) { return prizes.get(prizes.size() - 1); } int rand = ThreadLocalRandom.current().nextInt(totalWeight); int acc = 0; for (ActivityLotteryPrize p : prizes) { acc += (p.getProbabilityWeight() != null ? p.getProbabilityWeight() : 0); if (rand < acc) { return p; } } return prizes.get(prizes.size() - 1); } /** * 根据奖品类型发放奖励(MEMBERSHIP 或 EMPTY) */ private void applyReward(Long userId, ActivityLotteryPrize prize) { if (REWARD_TYPE_EMPTY.equalsIgnoreCase(prize.getRewardType())) { return; } if (REWARD_TYPE_MEMBERSHIP.equalsIgnoreCase(prize.getRewardType())) { int days = prize.getRewardDays() != null ? prize.getRewardDays() : 0; if (days <= 0) { return; } MembershipLevel level = MembershipLevel.fromCode( prize.getRewardLevel() != null ? prize.getRewardLevel() : "PRO"); if (level == null) { level = MembershipLevel.PRO; } membershipService.grantOrExtendMembership(userId, level, days, "ACTIVITY_LOTTERY", "比价抽奖中奖:" + prize.getName()); } } }