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());
}
}
}