CheckInService.java 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. package com.xuekairui.activity.service;
  2. import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
  3. import com.baomidou.mybatisplus.core.metadata.IPage;
  4. import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
  5. import com.xuekairui.activity.dto.AdminCheckInRecordResponse;
  6. import com.xuekairui.activity.dto.CheckInCalendarResponse;
  7. import com.xuekairui.activity.dto.CheckInResultResponse;
  8. import com.xuekairui.activity.dto.CheckInStatsResponse;
  9. import com.xuekairui.activity.dto.CheckInStatusResponse;
  10. import com.xuekairui.activity.entity.CheckInConfig;
  11. import com.xuekairui.activity.entity.CheckInRecord;
  12. import com.xuekairui.activity.mapper.CheckInRecordMapper;
  13. import com.xuekairui.common.BusinessException;
  14. import com.xuekairui.common.ErrorCode;
  15. import com.xuekairui.user.entity.CrawlerQuotaGrant;
  16. import com.xuekairui.user.service.CrawlerQuotaGrantService;
  17. import lombok.RequiredArgsConstructor;
  18. import lombok.extern.slf4j.Slf4j;
  19. import org.springframework.stereotype.Service;
  20. import org.springframework.transaction.annotation.Transactional;
  21. import java.time.LocalDate;
  22. import java.time.LocalDateTime;
  23. import java.time.YearMonth;
  24. import java.util.List;
  25. import java.util.stream.Collectors;
  26. /**
  27. * 签到核心服务(用户端)
  28. * <p>
  29. * 业务规则:
  30. * 1. 每个用户每个自然日仅可签到一次(DB 唯一索引兜底)
  31. * 2. 连续签到:若昨日已签到,则连续天数 +1;否则重置为 1
  32. * 3. 奖励计算:基础奖励 + (连续天数 >= 阈值 ? 连续奖励 : 0),且累计连续奖励不超过 continuousCap
  33. * 4. 奖励以爬虫次数发放,复用 t_crawler_quota_grant(grant_type=CHECKIN)
  34. */
  35. @Slf4j
  36. @Service
  37. @RequiredArgsConstructor
  38. public class CheckInService {
  39. /** 签到发放爬虫配额类型 */
  40. public static final String GRANT_TYPE_CHECKIN = "CHECKIN";
  41. private final CheckInConfigService checkinConfigService;
  42. private final CheckInRecordMapper checkinRecordMapper;
  43. private final CrawlerQuotaGrantService crawlerQuotaGrantService;
  44. private final ActivityCheckinCycleService activityCheckinCycleService;
  45. /**
  46. * 查询今日签到状态
  47. */
  48. public CheckInStatusResponse getCheckInStatus(Long userId) {
  49. CheckInConfig config = checkinConfigService.getConfig();
  50. LocalDate today = LocalDate.now();
  51. CheckInRecord todayRecord = findRecord(userId, today);
  52. boolean checkedToday = todayRecord != null;
  53. int continuousDays;
  54. if (checkedToday) {
  55. continuousDays = todayRecord.getContinuousDays();
  56. } else {
  57. continuousDays = calcContinuousDays(userId, today);
  58. }
  59. int todayReward = checkedToday ? todayRecord.getRewardAmount()
  60. : calcReward(config, continuousDays + 1);
  61. return CheckInStatusResponse.builder()
  62. .checkedToday(checkedToday)
  63. .continuousDays(continuousDays)
  64. .todayReward(todayReward)
  65. .nextCheckinDate(checkedToday ? today.plusDays(1) : today)
  66. .enabled(config.getEnabled() != null && config.getEnabled() == 1)
  67. .build();
  68. }
  69. /**
  70. * 执行签到
  71. */
  72. @Transactional
  73. public CheckInResultResponse doCheckIn(Long userId, String clientSource) {
  74. checkinConfigService.ensureEnabled();
  75. CheckInConfig config = checkinConfigService.getConfig();
  76. LocalDate today = LocalDate.now();
  77. CheckInRecord exist = findRecord(userId, today);
  78. if (exist != null) {
  79. throw new BusinessException(ErrorCode.BUSINESS_ERROR, "今日已签到");
  80. }
  81. int continuousDays = calcContinuousDays(userId, today) + 1;
  82. int rewardAmount = calcReward(config, continuousDays);
  83. LocalDateTime expireTime = config.getRewardExpireDays() != null
  84. ? LocalDateTime.now().plusDays(config.getRewardExpireDays())
  85. : null;
  86. CrawlerQuotaGrant grant = crawlerQuotaGrantService.grantAdminQuota(
  87. userId, rewardAmount, expireTime,
  88. "签到奖励 连续第" + continuousDays + "天");
  89. CheckInRecord record = CheckInRecord.builder()
  90. .userId(userId)
  91. .checkinDate(today)
  92. .continuousDays(continuousDays)
  93. .rewardType(config.getRewardType())
  94. .rewardAmount(rewardAmount)
  95. .grantId(grant != null ? grant.getId() : null)
  96. .clientSource(clientSource)
  97. .build();
  98. checkinRecordMapper.insert(record);
  99. log.info("用户签到成功: userId={}, date={}, continuous={}, reward={}",
  100. userId, today, continuousDays, rewardAmount);
  101. // 签到送会员活动:尝试发放上一周期奖励(活动关闭时内部直接返回,不影响原逻辑)
  102. try {
  103. activityCheckinCycleService.tryGrantCycleReward(userId);
  104. } catch (Exception e) {
  105. log.warn("签到后尝试发放周期奖励失败,不影响签到主流程: userId={}", userId, e);
  106. }
  107. return CheckInResultResponse.builder()
  108. .id(record.getId())
  109. .checkinDate(record.getCheckinDate())
  110. .continuousDays(record.getContinuousDays())
  111. .rewardAmount(record.getRewardAmount())
  112. .rewardType(record.getRewardType())
  113. .createTime(record.getCreateTime())
  114. .build();
  115. }
  116. /**
  117. * 获取当月签到日历
  118. */
  119. public CheckInCalendarResponse getMonthlyCalendar(Long userId, Integer year, Integer month) {
  120. YearMonth ym = (year != null && month != null)
  121. ? YearMonth.of(year, month)
  122. : YearMonth.now();
  123. LocalDate start = ym.atDay(1);
  124. LocalDate end = ym.atEndOfMonth();
  125. List<CheckInRecord> records = checkinRecordMapper.selectMonthlyRecords(userId, start, end);
  126. List<LocalDate> checkedDates = records.stream()
  127. .map(CheckInRecord::getCheckinDate)
  128. .collect(Collectors.toList());
  129. int continuousDays = calcContinuousDays(userId, LocalDate.now());
  130. boolean checkedToday = checkedDates.contains(LocalDate.now());
  131. return CheckInCalendarResponse.builder()
  132. .month(ym.toString())
  133. .checkedDays(checkedDates.size())
  134. .continuousDays(continuousDays)
  135. .checkedDates(checkedDates)
  136. .checkedToday(checkedToday)
  137. .build();
  138. }
  139. /**
  140. * 获取签到记录列表
  141. */
  142. public List<CheckInRecord> listRecords(Long userId, Integer limit) {
  143. LambdaQueryWrapper<CheckInRecord> wrapper = new LambdaQueryWrapper<CheckInRecord>()
  144. .eq(CheckInRecord::getUserId, userId)
  145. .orderByDesc(CheckInRecord::getCheckinDate);
  146. if (limit != null && limit > 0) {
  147. wrapper.last("LIMIT " + limit);
  148. }
  149. return checkinRecordMapper.selectList(wrapper);
  150. }
  151. // ==========================================
  152. // 运营端管理方法
  153. // ==========================================
  154. /**
  155. * 运营端:分页查询签到记录(关联用户信息)
  156. *
  157. * @param page 分页对象
  158. * @param userId 用户ID(可选)
  159. * @param startDate 起始日期(可选)
  160. * @param endDate 截止日期(可选)
  161. * @param clientSource 签到端(可选)
  162. */
  163. public IPage<AdminCheckInRecordResponse> adminListRecords(Page<AdminCheckInRecordResponse> page,
  164. Long userId,
  165. LocalDate startDate,
  166. LocalDate endDate,
  167. String clientSource) {
  168. return checkinRecordMapper.selectAdminRecordsPage(page, userId, startDate, endDate, clientSource);
  169. }
  170. /**
  171. * 运营端:签到统计概览
  172. */
  173. public CheckInStatsResponse adminGetStats() {
  174. LocalDate today = LocalDate.now();
  175. CheckInConfig config = checkinConfigService.getConfig();
  176. return CheckInStatsResponse.builder()
  177. .todayCheckinCount(nvl(checkinRecordMapper.countByDate(today)))
  178. .yesterdayCheckinCount(nvl(checkinRecordMapper.countByDate(today.minusDays(1))))
  179. .last7DaysCount(nvl(checkinRecordMapper.countByDateRange(today.minusDays(6), today)))
  180. .last30DaysCount(nvl(checkinRecordMapper.countByDateRange(today.minusDays(29), today)))
  181. .totalCheckinCount(nvl(checkinRecordMapper.countTotalRecords()))
  182. .totalCheckinUsers(nvl(checkinRecordMapper.countTotalUsers()))
  183. .totalRewardAmount(nvl(checkinRecordMapper.sumTotalReward()))
  184. .statDate(today)
  185. .enabled(config.getEnabled() != null && config.getEnabled() == 1)
  186. .build();
  187. }
  188. /**
  189. * 运营端:查询指定用户的签到记录(含用户基础信息,最多500条)
  190. */
  191. public List<AdminCheckInRecordResponse> adminListUserRecords(Long userId) {
  192. if (userId == null) {
  193. throw new BusinessException(ErrorCode.PARAM_ERROR, "用户ID不能为空");
  194. }
  195. Page<AdminCheckInRecordResponse> page = new Page<>(1, 500);
  196. return checkinRecordMapper.selectAdminRecordsPage(page, userId, null, null, null).getRecords();
  197. }
  198. private Long nvl(Long v) {
  199. return v == null ? 0L : v;
  200. }
  201. // ==========================================
  202. // 私有辅助方法
  203. // ==========================================
  204. private CheckInRecord findRecord(Long userId, LocalDate date) {
  205. return checkinRecordMapper.selectOne(
  206. new LambdaQueryWrapper<CheckInRecord>()
  207. .eq(CheckInRecord::getUserId, userId)
  208. .eq(CheckInRecord::getCheckinDate, date));
  209. }
  210. /**
  211. * 计算截至 today 的连续签到天数(不含 today)
  212. * 若昨天签到则为昨天连续天数+1,否则为 0
  213. */
  214. private int calcContinuousDays(Long userId, LocalDate today) {
  215. CheckInRecord yesterday = findRecord(userId, today.minusDays(1));
  216. if (yesterday == null) {
  217. return 0;
  218. }
  219. return yesterday.getContinuousDays() + 1;
  220. }
  221. /**
  222. * 计算签到奖励:基础 + 连续奖励(达阈值后叠加,受上限约束)
  223. */
  224. private int calcReward(CheckInConfig config, int continuousDays) {
  225. int reward = config.getBaseReward();
  226. if (continuousDays >= config.getContinuousThreshold()) {
  227. int extra = continuousDays - config.getContinuousThreshold() + 1;
  228. extra = Math.min(extra, config.getContinuousCap());
  229. reward += config.getContinuousReward() * Math.max(0, extra);
  230. }
  231. return reward;
  232. }
  233. }