ActivityLotteryService.java 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415
  1. package com.xuekairui.activity.service;
  2. import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
  3. import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
  4. import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
  5. import com.xuekairui.activity.dto.ActivityLotteryConfigRequest;
  6. import com.xuekairui.activity.dto.ActivityLotteryPrizeRequest;
  7. import com.xuekairui.activity.dto.ActivityLotteryResultResponse;
  8. import com.xuekairui.activity.dto.ActivityLotteryStatusResponse;
  9. import com.xuekairui.activity.entity.ActivityLotteryChance;
  10. import com.xuekairui.activity.entity.ActivityLotteryConfig;
  11. import com.xuekairui.activity.entity.ActivityLotteryPrize;
  12. import com.xuekairui.activity.entity.ActivityLotteryRecord;
  13. import com.xuekairui.activity.mapper.ActivityLotteryChanceMapper;
  14. import com.xuekairui.activity.mapper.ActivityLotteryConfigMapper;
  15. import com.xuekairui.activity.mapper.ActivityLotteryPrizeMapper;
  16. import com.xuekairui.activity.mapper.ActivityLotteryRecordMapper;
  17. import com.xuekairui.common.BusinessException;
  18. import com.xuekairui.common.ErrorCode;
  19. import com.xuekairui.user.entity.User;
  20. import com.xuekairui.user.enums.MembershipLevel;
  21. import com.xuekairui.user.mapper.UserMapper;
  22. import com.xuekairui.user.service.MembershipService;
  23. import lombok.RequiredArgsConstructor;
  24. import lombok.extern.slf4j.Slf4j;
  25. import org.springframework.dao.DuplicateKeyException;
  26. import org.springframework.stereotype.Service;
  27. import org.springframework.transaction.annotation.Transactional;
  28. import java.time.LocalDate;
  29. import java.time.LocalDateTime;
  30. import java.util.List;
  31. import java.util.Optional;
  32. import java.util.concurrent.ThreadLocalRandom;
  33. /**
  34. * 比价抽奖活动服务。
  35. * <p>
  36. * 规则:
  37. * 1. 活动时间窗 2026-07-10 ~ 2026-07-31,所有高级会员(PRO及以上)可参加;
  38. * 2. 活动期间每自然日首次成功比价获得一次抽奖机会(先到先得,每日1次);
  39. * 3. 抽奖按奖品权重随机中奖,中奖后即时发放对应奖励。
  40. */
  41. @Slf4j
  42. @Service
  43. @RequiredArgsConstructor
  44. public class ActivityLotteryService {
  45. private static final Long CONFIG_ID = 1L;
  46. private static final String SOURCE_COMPARE = "COMPARE";
  47. private static final String REWARD_TYPE_MEMBERSHIP = "MEMBERSHIP";
  48. private static final String REWARD_TYPE_EMPTY = "EMPTY";
  49. private final ActivityLotteryConfigMapper configMapper;
  50. private final ActivityLotteryChanceMapper chanceMapper;
  51. private final ActivityLotteryPrizeMapper prizeMapper;
  52. private final ActivityLotteryRecordMapper recordMapper;
  53. private final UserMapper userMapper;
  54. private final MembershipService membershipService;
  55. // ==========================================
  56. // 配置管理
  57. // ==========================================
  58. public ActivityLotteryConfig getConfig() {
  59. ActivityLotteryConfig config = configMapper.selectById(CONFIG_ID);
  60. if (config == null) {
  61. return ActivityLotteryConfig.builder()
  62. .id(CONFIG_ID).enabled(0)
  63. .startTime(LocalDateTime.now()).endTime(LocalDateTime.now())
  64. .dailyMaxChance(1).requireMembershipLevel("PRO")
  65. .build();
  66. }
  67. return config;
  68. }
  69. @Transactional
  70. public ActivityLotteryConfig updateConfig(ActivityLotteryConfigRequest request) {
  71. ActivityLotteryConfig config = getConfig();
  72. config.setEnabled(request.getEnabled());
  73. config.setStartTime(request.getStartTime());
  74. config.setEndTime(request.getEndTime());
  75. Optional.ofNullable(request.getDailyMaxChance()).ifPresent(config::setDailyMaxChance);
  76. Optional.ofNullable(request.getRequireMembershipLevel()).ifPresent(config::setRequireMembershipLevel);
  77. Optional.ofNullable(request.getRemark()).ifPresent(config::setRemark);
  78. if (config.getId() == null) {
  79. config.setId(CONFIG_ID);
  80. configMapper.insert(config);
  81. } else {
  82. configMapper.updateById(config);
  83. }
  84. log.info("比价抽奖活动配置已更新: enabled={}, start={}, end={}",
  85. config.getEnabled(), config.getStartTime(), config.getEndTime());
  86. return config;
  87. }
  88. // ==========================================
  89. // 事件处理:比价成功后发放抽奖机会
  90. // ==========================================
  91. /**
  92. * 监听比价消耗事件:活动开启且用户具备资格时,每自然日首次比价发放一次抽奖机会。
  93. * 幂等:t_activity_lottery_chance 的 uk_user_date_source 保证每日仅一次。
  94. */
  95. @Transactional
  96. public void onCrawlerConsumed(Long userId, String usageDateStr) {
  97. ActivityLotteryConfig config = getConfig();
  98. if (!isActive(config)) {
  99. return;
  100. }
  101. if (isEligible(userId, config)) {
  102. return;
  103. }
  104. LocalDate usageDate = usageDateStr != null ? LocalDate.parse(usageDateStr) : LocalDate.now();
  105. ActivityLotteryChance chance = ActivityLotteryChance.builder()
  106. .userId(userId)
  107. .chanceDate(usageDate)
  108. .source(SOURCE_COMPARE)
  109. .used(0)
  110. .build();
  111. try {
  112. chanceMapper.insert(chance);
  113. log.info("比价抽奖机会已发放: userId={}, date={}", userId, usageDate);
  114. } catch (DuplicateKeyException e) {
  115. // 今日已发放过机会,忽略
  116. }
  117. }
  118. // ==========================================
  119. // 用户端:抽奖
  120. // ==========================================
  121. /**
  122. * 执行抽奖:消耗一个当日未使用的抽奖机会,按权重随机中奖并发放奖励。
  123. */
  124. @Transactional
  125. public ActivityLotteryResultResponse draw(Long userId) {
  126. ActivityLotteryConfig config = getConfig();
  127. if (!isActive(config)) {
  128. throw new BusinessException(ErrorCode.BUSINESS_ERROR, "比价抽奖活动未开启或已结束");
  129. }
  130. if (isEligible(userId, config)) {
  131. throw new BusinessException(ErrorCode.BUSINESS_ERROR, "仅高级会员可参与比价抽奖");
  132. }
  133. LocalDate today = LocalDate.now();
  134. // 取一条未使用的机会
  135. ActivityLotteryChance chance = chanceMapper.selectOne(
  136. new LambdaQueryWrapper<ActivityLotteryChance>()
  137. .eq(ActivityLotteryChance::getUserId, userId)
  138. .eq(ActivityLotteryChance::getChanceDate, today)
  139. .eq(ActivityLotteryChance::getSource, SOURCE_COMPARE)
  140. .eq(ActivityLotteryChance::getUsed, 0)
  141. .last("LIMIT 1"));
  142. if (chance == null) {
  143. throw new BusinessException(ErrorCode.BUSINESS_ERROR, "今日暂无可用抽奖机会,完成首次比价即可获得");
  144. }
  145. // 乐观锁占用机会,防止并发重复抽奖
  146. int affected = chanceMapper.update(null,
  147. new LambdaUpdateWrapper<ActivityLotteryChance>()
  148. .eq(ActivityLotteryChance::getId, chance.getId())
  149. .eq(ActivityLotteryChance::getUsed, 0)
  150. .set(ActivityLotteryChance::getUsed, 1));
  151. if (affected == 0) {
  152. throw new BusinessException(ErrorCode.BUSINESS_ERROR, "抽奖机会已被使用");
  153. }
  154. // 加权随机选中奖品
  155. ActivityLotteryPrize prize = pickPrize();
  156. if (prize == null) {
  157. // 无可用奖品,兜底为未中奖
  158. prize = ActivityLotteryPrize.builder()
  159. .name("谢谢参与").rewardType(REWARD_TYPE_EMPTY).rewardAmount(0).build();
  160. }
  161. // 发放奖励
  162. applyReward(userId, prize);
  163. // 记录抽奖结果
  164. ActivityLotteryRecord record = ActivityLotteryRecord.builder()
  165. .userId(userId)
  166. .prizeId(prize.getId())
  167. .prizeName(prize.getName())
  168. .rewardType(prize.getRewardType())
  169. .rewardAmount(prize.getRewardAmount() != null ? prize.getRewardAmount() : 0)
  170. .rewardDays(prize.getRewardDays())
  171. .rewardLevel(prize.getRewardLevel())
  172. .chanceId(chance.getId())
  173. .build();
  174. recordMapper.insert(record);
  175. log.info("抽奖完成: userId={}, prize={}, type={}", userId, prize.getName(), prize.getRewardType());
  176. boolean win = !REWARD_TYPE_EMPTY.equalsIgnoreCase(prize.getRewardType());
  177. return ActivityLotteryResultResponse.builder()
  178. .recordId(record.getId())
  179. .prizeName(prize.getName())
  180. .rewardType(prize.getRewardType())
  181. .rewardAmount(record.getRewardAmount())
  182. .rewardDays(prize.getRewardDays())
  183. .rewardLevel(prize.getRewardLevel())
  184. .win(win)
  185. .createTime(record.getCreateTime())
  186. .build();
  187. }
  188. /**
  189. * 查询用户抽奖状态
  190. */
  191. public ActivityLotteryStatusResponse getStatus(Long userId) {
  192. ActivityLotteryConfig config = getConfig();
  193. LocalDate today = LocalDate.now();
  194. Long available = chanceMapper.selectCount(
  195. new LambdaQueryWrapper<ActivityLotteryChance>()
  196. .eq(ActivityLotteryChance::getUserId, userId)
  197. .eq(ActivityLotteryChance::getChanceDate, today)
  198. .eq(ActivityLotteryChance::getSource, SOURCE_COMPARE)
  199. .eq(ActivityLotteryChance::getUsed, 0));
  200. Long used = chanceMapper.selectCount(
  201. new LambdaQueryWrapper<ActivityLotteryChance>()
  202. .eq(ActivityLotteryChance::getUserId, userId)
  203. .eq(ActivityLotteryChance::getChanceDate, today)
  204. .eq(ActivityLotteryChance::getSource, SOURCE_COMPARE)
  205. .eq(ActivityLotteryChance::getUsed, 1));
  206. MembershipLevel userLevel = getUserLevel(userId);
  207. String requireLevel = config.getRequireMembershipLevel() != null ? config.getRequireMembershipLevel() : "PRO";
  208. boolean eligible = isLevelEnough(userLevel, requireLevel);
  209. return ActivityLotteryStatusResponse.builder()
  210. .enabled(isActive(config))
  211. .inWindow(inWindow(config))
  212. .eligible(eligible)
  213. .membershipLevel(userLevel != null ? userLevel.getCode() : null)
  214. .requireMembershipLevel(requireLevel)
  215. .availableChances(available != null ? available.intValue() : 0)
  216. .usedChances(used != null ? used.intValue() : 0)
  217. .build();
  218. }
  219. /**
  220. * 查询用户抽奖记录
  221. */
  222. public List<ActivityLotteryRecord> listMyRecords(Long userId, Integer limit) {
  223. LambdaQueryWrapper<ActivityLotteryRecord> wrapper = new LambdaQueryWrapper<ActivityLotteryRecord>()
  224. .eq(ActivityLotteryRecord::getUserId, userId)
  225. .orderByDesc(ActivityLotteryRecord::getCreateTime);
  226. if (limit != null && limit > 0) {
  227. wrapper.last("LIMIT " + limit);
  228. }
  229. return recordMapper.selectList(wrapper);
  230. }
  231. /**
  232. * 运营端:分页查询所有用户抽奖记录(支持按用户ID筛选)
  233. */
  234. public Page<ActivityLotteryRecord> listAllRecords(
  235. Long userId, int pageNum, int pageSize) {
  236. Page<ActivityLotteryRecord> page =
  237. new Page<>(pageNum, pageSize);
  238. LambdaQueryWrapper<ActivityLotteryRecord> wrapper = new LambdaQueryWrapper<>();
  239. if (userId != null) {
  240. wrapper.eq(ActivityLotteryRecord::getUserId, userId);
  241. }
  242. wrapper.orderByDesc(ActivityLotteryRecord::getCreateTime);
  243. return recordMapper.selectPage(page, wrapper);
  244. }
  245. // ==========================================
  246. // 奖品管理(运营端)
  247. // ==========================================
  248. public List<ActivityLotteryPrize> listPrizes() {
  249. return prizeMapper.selectList(
  250. new LambdaQueryWrapper<ActivityLotteryPrize>().orderByAsc(ActivityLotteryPrize::getSort));
  251. }
  252. /**
  253. * 用户端:获取启用的奖品列表(不含概率权重)
  254. */
  255. public List<ActivityLotteryPrize> listEnabledPrizes() {
  256. List<ActivityLotteryPrize> prizes = prizeMapper.selectList(
  257. new LambdaQueryWrapper<ActivityLotteryPrize>()
  258. .eq(ActivityLotteryPrize::getEnabled, 1)
  259. .orderByAsc(ActivityLotteryPrize::getSort));
  260. for (ActivityLotteryPrize p : prizes) {
  261. p.setProbabilityWeight(null);
  262. }
  263. return prizes;
  264. }
  265. @Transactional
  266. public ActivityLotteryPrize createPrize(ActivityLotteryPrizeRequest request) {
  267. ActivityLotteryPrize prize = ActivityLotteryPrize.builder()
  268. .name(request.getName())
  269. .rewardType(request.getRewardType())
  270. .rewardAmount(request.getRewardAmount() != null ? request.getRewardAmount() : 0)
  271. .rewardDays(request.getRewardDays())
  272. .rewardLevel(request.getRewardLevel())
  273. .probabilityWeight(request.getProbabilityWeight())
  274. .enabled(request.getEnabled() != null ? request.getEnabled() : 1)
  275. .sort(request.getSort() != null ? request.getSort() : 0)
  276. .build();
  277. prizeMapper.insert(prize);
  278. return prize;
  279. }
  280. @Transactional
  281. public ActivityLotteryPrize updatePrize(Long id, ActivityLotteryPrizeRequest request) {
  282. ActivityLotteryPrize prize = prizeMapper.selectById(id);
  283. if (prize == null) {
  284. throw new BusinessException(ErrorCode.PARAM_ERROR, "奖品不存在");
  285. }
  286. prize.setName(request.getName());
  287. prize.setRewardType(request.getRewardType());
  288. Optional.ofNullable(request.getRewardAmount()).ifPresent(prize::setRewardAmount);
  289. prize.setRewardDays(request.getRewardDays());
  290. prize.setRewardLevel(request.getRewardLevel());
  291. Optional.ofNullable(request.getProbabilityWeight()).ifPresent(prize::setProbabilityWeight);
  292. Optional.ofNullable(request.getEnabled()).ifPresent(prize::setEnabled);
  293. Optional.ofNullable(request.getSort()).ifPresent(prize::setSort);
  294. prizeMapper.updateById(prize);
  295. return prize;
  296. }
  297. @Transactional
  298. public void deletePrize(Long id) {
  299. prizeMapper.deleteById(id);
  300. }
  301. // ==========================================
  302. // 私有辅助
  303. // ==========================================
  304. private boolean isActive(ActivityLotteryConfig config) {
  305. return config.getEnabled() != null && config.getEnabled() == 1 && inWindow(config);
  306. }
  307. private boolean inWindow(ActivityLotteryConfig config) {
  308. LocalDateTime now = LocalDateTime.now();
  309. return !now.isBefore(config.getStartTime()) && !now.isAfter(config.getEndTime());
  310. }
  311. private boolean isEligible(Long userId, ActivityLotteryConfig config) {
  312. String requireLevel = config.getRequireMembershipLevel() != null ? config.getRequireMembershipLevel() : "PRO";
  313. return !isLevelEnough(getUserLevel(userId), requireLevel);
  314. }
  315. private MembershipLevel getUserLevel(Long userId) {
  316. User user = userMapper.selectById(userId);
  317. return user != null ? user.getMembershipLevel() : null;
  318. }
  319. private boolean isLevelEnough(MembershipLevel userLevel, String requireCode) {
  320. if (userLevel == null) {
  321. return false;
  322. }
  323. MembershipLevel require = MembershipLevel.fromCode(requireCode);
  324. if (require == null) {
  325. return false;
  326. }
  327. return userLevel.getWeight() >= require.getWeight();
  328. }
  329. /**
  330. * 按权重随机选择一个启用的奖品
  331. */
  332. private ActivityLotteryPrize pickPrize() {
  333. List<ActivityLotteryPrize> prizes = prizeMapper.listEnabled();
  334. if (prizes.isEmpty()) {
  335. return null;
  336. }
  337. int totalWeight = prizes.stream()
  338. .mapToInt(p -> p.getProbabilityWeight() != null ? p.getProbabilityWeight() : 0)
  339. .sum();
  340. if (totalWeight <= 0) {
  341. return prizes.get(prizes.size() - 1);
  342. }
  343. int rand = ThreadLocalRandom.current().nextInt(totalWeight);
  344. int acc = 0;
  345. for (ActivityLotteryPrize p : prizes) {
  346. acc += (p.getProbabilityWeight() != null ? p.getProbabilityWeight() : 0);
  347. if (rand < acc) {
  348. return p;
  349. }
  350. }
  351. return prizes.get(prizes.size() - 1);
  352. }
  353. /**
  354. * 根据奖品类型发放奖励(MEMBERSHIP 或 EMPTY)
  355. */
  356. private void applyReward(Long userId, ActivityLotteryPrize prize) {
  357. if (REWARD_TYPE_EMPTY.equalsIgnoreCase(prize.getRewardType())) {
  358. return;
  359. }
  360. if (REWARD_TYPE_MEMBERSHIP.equalsIgnoreCase(prize.getRewardType())) {
  361. int days = prize.getRewardDays() != null ? prize.getRewardDays() : 0;
  362. if (days <= 0) {
  363. return;
  364. }
  365. MembershipLevel level = MembershipLevel.fromCode(
  366. prize.getRewardLevel() != null ? prize.getRewardLevel() : "PRO");
  367. if (level == null) {
  368. level = MembershipLevel.PRO;
  369. }
  370. membershipService.grantOrExtendMembership(userId, level, days,
  371. "ACTIVITY_LOTTERY", "比价抽奖中奖:" + prize.getName());
  372. }
  373. }
  374. }