| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415 |
- 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;
- /**
- * 比价抽奖活动服务。
- * <p>
- * 规则:
- * 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<ActivityLotteryChance>()
- .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<ActivityLotteryChance>()
- .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<ActivityLotteryChance>()
- .eq(ActivityLotteryChance::getUserId, userId)
- .eq(ActivityLotteryChance::getChanceDate, today)
- .eq(ActivityLotteryChance::getSource, SOURCE_COMPARE)
- .eq(ActivityLotteryChance::getUsed, 0));
- Long used = chanceMapper.selectCount(
- new LambdaQueryWrapper<ActivityLotteryChance>()
- .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<ActivityLotteryRecord> listMyRecords(Long userId, Integer limit) {
- LambdaQueryWrapper<ActivityLotteryRecord> wrapper = new LambdaQueryWrapper<ActivityLotteryRecord>()
- .eq(ActivityLotteryRecord::getUserId, userId)
- .orderByDesc(ActivityLotteryRecord::getCreateTime);
- if (limit != null && limit > 0) {
- wrapper.last("LIMIT " + limit);
- }
- return recordMapper.selectList(wrapper);
- }
- /**
- * 运营端:分页查询所有用户抽奖记录(支持按用户ID筛选)
- */
- public Page<ActivityLotteryRecord> listAllRecords(
- Long userId, int pageNum, int pageSize) {
- Page<ActivityLotteryRecord> page =
- new Page<>(pageNum, pageSize);
- LambdaQueryWrapper<ActivityLotteryRecord> wrapper = new LambdaQueryWrapper<>();
- if (userId != null) {
- wrapper.eq(ActivityLotteryRecord::getUserId, userId);
- }
- wrapper.orderByDesc(ActivityLotteryRecord::getCreateTime);
- return recordMapper.selectPage(page, wrapper);
- }
- // ==========================================
- // 奖品管理(运营端)
- // ==========================================
- public List<ActivityLotteryPrize> listPrizes() {
- return prizeMapper.selectList(
- new LambdaQueryWrapper<ActivityLotteryPrize>().orderByAsc(ActivityLotteryPrize::getSort));
- }
- /**
- * 用户端:获取启用的奖品列表(不含概率权重)
- */
- public List<ActivityLotteryPrize> listEnabledPrizes() {
- List<ActivityLotteryPrize> prizes = prizeMapper.selectList(
- new LambdaQueryWrapper<ActivityLotteryPrize>()
- .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<ActivityLotteryPrize> 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());
- }
- }
- }
|