ActivityCheckinCycleService.java 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  1. package com.xuekairui.activity.service;
  2. import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
  3. import com.xuekairui.activity.dto.ActivityCheckinCycleConfigRequest;
  4. import com.xuekairui.activity.dto.ActivityCheckinCycleStatusResponse;
  5. import com.xuekairui.activity.entity.ActivityCheckinCycleConfig;
  6. import com.xuekairui.activity.entity.ActivityCheckinCycleRecord;
  7. import com.xuekairui.activity.entity.CheckInRecord;
  8. import com.xuekairui.activity.mapper.ActivityCheckinCycleConfigMapper;
  9. import com.xuekairui.activity.mapper.ActivityCheckinCycleRecordMapper;
  10. import com.xuekairui.activity.mapper.CheckInRecordMapper;
  11. import com.xuekairui.user.enums.MembershipLevel;
  12. import com.xuekairui.user.service.MembershipService;
  13. import lombok.RequiredArgsConstructor;
  14. import lombok.extern.slf4j.Slf4j;
  15. import org.springframework.dao.DuplicateKeyException;
  16. import org.springframework.stereotype.Service;
  17. import org.springframework.transaction.annotation.Transactional;
  18. import java.time.LocalDate;
  19. import java.time.LocalDateTime;
  20. import java.util.List;
  21. /**
  22. * 签到送高级会员活动服务(周期制)。
  23. * <p>
  24. * 规则:
  25. * 1. 活动时间窗 2026-07-10 ~ 2026-07-31,所有用户可参加;
  26. * 2. 每周五至下周四为一个周期,累计 4 天签到即获得 7 天高级会员;
  27. * 3. 周期结束后的第一天(即下一周期首日周五)发放奖励;
  28. * 4. 每用户最多领取 3 次。
  29. * <p>
  30. * 数据复用现有 t_checkin_record 签到记录统计周期内签到天数,不改动每日签到奖励逻辑。
  31. */
  32. @Slf4j
  33. @Service
  34. @RequiredArgsConstructor
  35. public class ActivityCheckinCycleService {
  36. private static final Long CONFIG_ID = 1L;
  37. /** 周期模式:周五至下周四 */
  38. private static final String CYCLE_MODE_FRI_THU = "FRI_THU";
  39. private final ActivityCheckinCycleConfigMapper configMapper;
  40. private final ActivityCheckinCycleRecordMapper cycleRecordMapper;
  41. private final CheckInRecordMapper checkInRecordMapper;
  42. private final MembershipService membershipService;
  43. // ==========================================
  44. // 配置管理
  45. // ==========================================
  46. public ActivityCheckinCycleConfig getConfig() {
  47. ActivityCheckinCycleConfig config = configMapper.selectById(CONFIG_ID);
  48. if (config == null) {
  49. return ActivityCheckinCycleConfig.builder()
  50. .id(CONFIG_ID).enabled(0)
  51. .startTime(LocalDateTime.now()).endTime(LocalDateTime.now())
  52. .cycleMode(CYCLE_MODE_FRI_THU).requiredDays(4).rewardDays(7).maxRewardCount(3)
  53. .build();
  54. }
  55. return config;
  56. }
  57. @Transactional
  58. public ActivityCheckinCycleConfig updateConfig(ActivityCheckinCycleConfigRequest request) {
  59. ActivityCheckinCycleConfig config = getConfig();
  60. config.setEnabled(request.getEnabled());
  61. config.setStartTime(request.getStartTime());
  62. config.setEndTime(request.getEndTime());
  63. if (request.getCycleMode() != null) config.setCycleMode(request.getCycleMode());
  64. if (request.getRequiredDays() != null) config.setRequiredDays(request.getRequiredDays());
  65. if (request.getRewardDays() != null) config.setRewardDays(request.getRewardDays());
  66. if (request.getMaxRewardCount() != null) config.setMaxRewardCount(request.getMaxRewardCount());
  67. if (request.getRemark() != null) config.setRemark(request.getRemark());
  68. if (config.getId() == null) {
  69. config.setId(CONFIG_ID);
  70. configMapper.insert(config);
  71. } else {
  72. configMapper.updateById(config);
  73. }
  74. log.info("签到送会员活动配置已更新: enabled={}, required={}, reward={}, max={}",
  75. config.getEnabled(), config.getRequiredDays(), config.getRewardDays(), config.getMaxRewardCount());
  76. return config;
  77. }
  78. // ==========================================
  79. // 周期奖励发放(签到或查询状态时触发)
  80. // ==========================================
  81. /**
  82. * 尝试发放最近一个已结束周期的奖励。
  83. * <p>
  84. * 在用户每日签到成功后、或查询周期状态时调用。
  85. * 仅当活动开启、该周期累计签到达标、未发放过、且用户未达领取上限时发放。
  86. */
  87. @Transactional
  88. public boolean tryGrantCycleReward(Long userId) {
  89. ActivityCheckinCycleConfig config = getConfig();
  90. if (!isActive(config)) {
  91. return false;
  92. }
  93. LocalDate today = LocalDate.now();
  94. LocalDate currentCycleStart = cycleStartOf(today);
  95. LocalDate prevCycleStart = currentCycleStart.minusDays(7);
  96. LocalDate prevCycleEnd = prevCycleStart.plusDays(6);
  97. // 仅处理已完整结束的周期(周期截止日 < 今天)
  98. if (!prevCycleEnd.isBefore(today)) {
  99. return false;
  100. }
  101. int checkinCount = countCheckinInCycle(userId, prevCycleStart, prevCycleEnd);
  102. int requiredDays = config.getRequiredDays() != null ? config.getRequiredDays() : 4;
  103. if (checkinCount < requiredDays) {
  104. return false;
  105. }
  106. // 校验领取上限
  107. long rewarded = cycleRecordMapper.countRewardedByUser(userId);
  108. int maxReward = config.getMaxRewardCount() != null ? config.getMaxRewardCount() : 3;
  109. if (rewarded >= maxReward) {
  110. log.info("用户{}已达签到周期奖励上限{}次", userId, maxReward);
  111. return false;
  112. }
  113. // 幂等:插入周期记录(uk_user_cycle 兜底),已存在则更新为已发放
  114. ActivityCheckinCycleRecord exist = cycleRecordMapper.selectOne(
  115. new LambdaQueryWrapper<ActivityCheckinCycleRecord>()
  116. .eq(ActivityCheckinCycleRecord::getUserId, userId)
  117. .eq(ActivityCheckinCycleRecord::getCycleStart, prevCycleStart));
  118. if (exist != null && exist.getRewardGranted() == 1) {
  119. return false;
  120. }
  121. int rewardDays = config.getRewardDays() != null ? config.getRewardDays() : 7;
  122. membershipService.grantOrExtendMembership(userId, MembershipLevel.PRO, rewardDays,
  123. "ACTIVITY_CHECKIN_CYCLE", "签到送会员活动:周期" + prevCycleStart + "~" + prevCycleEnd + "累计签到" + checkinCount + "天奖励" + rewardDays + "天高级会员");
  124. if (exist == null) {
  125. ActivityCheckinCycleRecord record = ActivityCheckinCycleRecord.builder()
  126. .userId(userId)
  127. .cycleStart(prevCycleStart)
  128. .cycleEnd(prevCycleEnd)
  129. .checkinCount(checkinCount)
  130. .rewardGranted(1)
  131. .grantTime(LocalDateTime.now())
  132. .build();
  133. try {
  134. cycleRecordMapper.insert(record);
  135. } catch (DuplicateKeyException e) {
  136. // 并发兜底:已被其他线程处理
  137. log.info("签到周期奖励并发处理,已跳过: userId={}, cycle={}", userId, prevCycleStart);
  138. return false;
  139. }
  140. } else {
  141. exist.setCheckinCount(checkinCount);
  142. exist.setRewardGranted(1);
  143. exist.setGrantTime(LocalDateTime.now());
  144. cycleRecordMapper.updateById(exist);
  145. }
  146. log.info("签到周期奖励已发放: userId={}, cycle={}~{}, checkinCount={}, days={}",
  147. userId, prevCycleStart, prevCycleEnd, checkinCount, rewardDays);
  148. return true;
  149. }
  150. // ==========================================
  151. // 用户端状态查询
  152. // ==========================================
  153. public ActivityCheckinCycleStatusResponse getStatus(Long userId) {
  154. ActivityCheckinCycleConfig config = getConfig();
  155. // 查询时顺带尝试发放上一周期奖励(用户若未在发放日签到,查询时补发)
  156. if (userId != null && isActive(config)) {
  157. try {
  158. tryGrantCycleReward(userId);
  159. } catch (Exception e) {
  160. log.warn("查询签到周期状态时尝试发奖失败,不影响查询: userId={}", userId, e);
  161. }
  162. }
  163. LocalDate today = LocalDate.now();
  164. LocalDate currentCycleStart = cycleStartOf(today);
  165. LocalDate currentCycleEnd = currentCycleStart.plusDays(6);
  166. int currentCount = userId != null ? countCheckinInCycle(userId, currentCycleStart, currentCycleEnd) : 0;
  167. int requiredDays = config.getRequiredDays() != null ? config.getRequiredDays() : 4;
  168. int rewardDays = config.getRewardDays() != null ? config.getRewardDays() : 7;
  169. long rewarded = userId != null ? cycleRecordMapper.countRewardedByUser(userId) : 0L;
  170. int maxReward = config.getMaxRewardCount() != null ? config.getMaxRewardCount() : 3;
  171. long remaining = Math.max(0, maxReward - rewarded);
  172. return ActivityCheckinCycleStatusResponse.builder()
  173. .enabled(isActive(config))
  174. .inWindow(inWindow(config))
  175. .currentCycleStart(currentCycleStart)
  176. .currentCycleEnd(currentCycleEnd)
  177. .currentCycleCheckinCount(currentCount)
  178. .requiredDays(requiredDays)
  179. .reachedTarget(currentCount >= requiredDays)
  180. .rewardedCount(rewarded)
  181. .maxRewardCount(maxReward)
  182. .remainingCount(remaining)
  183. .rewardDays(rewardDays)
  184. .build();
  185. }
  186. /**
  187. * 用户端:查询我的签到周期奖励记录
  188. * 返回已发放的周期奖励列表,按发放时间倒序
  189. */
  190. public List<ActivityCheckinCycleRecord> listMyCycleRewards(Long userId) {
  191. return cycleRecordMapper.selectList(
  192. new LambdaQueryWrapper<ActivityCheckinCycleRecord>()
  193. .eq(ActivityCheckinCycleRecord::getUserId, userId)
  194. .eq(ActivityCheckinCycleRecord::getRewardGranted, 1)
  195. .orderByDesc(ActivityCheckinCycleRecord::getGrantTime));
  196. }
  197. // ==========================================
  198. // 私有辅助
  199. // ==========================================
  200. private boolean isActive(ActivityCheckinCycleConfig config) {
  201. return config.getEnabled() != null && config.getEnabled() == 1 && inWindow(config);
  202. }
  203. private boolean inWindow(ActivityCheckinCycleConfig config) {
  204. LocalDateTime now = LocalDateTime.now();
  205. return !now.isBefore(config.getStartTime()) && !now.isAfter(config.getEndTime());
  206. }
  207. /**
  208. * 计算日期所属周期的起始日(周五)。
  209. * 周期为周五~下周四:以周五为起点,offset = (dayOfWeek - 5 + 7) % 7。
  210. */
  211. private LocalDate cycleStartOf(LocalDate date) {
  212. int dow = date.getDayOfWeek().getValue(); // MON=1 ... SUN=7
  213. int offset = (dow - 5 + 7) % 7;
  214. return date.minusDays(offset);
  215. }
  216. private int countCheckinInCycle(Long userId, LocalDate start, LocalDate end) {
  217. if (userId == null) {
  218. return 0;
  219. }
  220. Long count = checkInRecordMapper.selectCount(
  221. new LambdaQueryWrapper<CheckInRecord>()
  222. .eq(CheckInRecord::getUserId, userId)
  223. .ge(CheckInRecord::getCheckinDate, start)
  224. .le(CheckInRecord::getCheckinDate, end));
  225. return count != null ? count.intValue() : 0;
  226. }
  227. }