package com.xuekairui.activity.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.xuekairui.activity.dto.AdminCheckInRecordResponse; import com.xuekairui.activity.dto.CheckInCalendarResponse; import com.xuekairui.activity.dto.CheckInResultResponse; import com.xuekairui.activity.dto.CheckInStatsResponse; import com.xuekairui.activity.dto.CheckInStatusResponse; import com.xuekairui.activity.entity.CheckInConfig; import com.xuekairui.activity.entity.CheckInRecord; import com.xuekairui.activity.mapper.CheckInRecordMapper; import com.xuekairui.common.BusinessException; import com.xuekairui.common.ErrorCode; import com.xuekairui.user.entity.CrawlerQuotaGrant; import com.xuekairui.user.service.CrawlerQuotaGrantService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.YearMonth; import java.util.List; import java.util.stream.Collectors; /** * 签到核心服务(用户端) *

* 业务规则: * 1. 每个用户每个自然日仅可签到一次(DB 唯一索引兜底) * 2. 连续签到:若昨日已签到,则连续天数 +1;否则重置为 1 * 3. 奖励计算:基础奖励 + (连续天数 >= 阈值 ? 连续奖励 : 0),且累计连续奖励不超过 continuousCap * 4. 奖励以爬虫次数发放,复用 t_crawler_quota_grant(grant_type=CHECKIN) */ @Slf4j @Service @RequiredArgsConstructor public class CheckInService { /** 签到发放爬虫配额类型 */ public static final String GRANT_TYPE_CHECKIN = "CHECKIN"; private final CheckInConfigService checkinConfigService; private final CheckInRecordMapper checkinRecordMapper; private final CrawlerQuotaGrantService crawlerQuotaGrantService; private final ActivityCheckinCycleService activityCheckinCycleService; /** * 查询今日签到状态 */ public CheckInStatusResponse getCheckInStatus(Long userId) { CheckInConfig config = checkinConfigService.getConfig(); LocalDate today = LocalDate.now(); CheckInRecord todayRecord = findRecord(userId, today); boolean checkedToday = todayRecord != null; int continuousDays; if (checkedToday) { continuousDays = todayRecord.getContinuousDays(); } else { continuousDays = calcContinuousDays(userId, today); } int todayReward = checkedToday ? todayRecord.getRewardAmount() : calcReward(config, continuousDays + 1); return CheckInStatusResponse.builder() .checkedToday(checkedToday) .continuousDays(continuousDays) .todayReward(todayReward) .nextCheckinDate(checkedToday ? today.plusDays(1) : today) .enabled(config.getEnabled() != null && config.getEnabled() == 1) .build(); } /** * 执行签到 */ @Transactional public CheckInResultResponse doCheckIn(Long userId, String clientSource) { checkinConfigService.ensureEnabled(); CheckInConfig config = checkinConfigService.getConfig(); LocalDate today = LocalDate.now(); CheckInRecord exist = findRecord(userId, today); if (exist != null) { throw new BusinessException(ErrorCode.BUSINESS_ERROR, "今日已签到"); } int continuousDays = calcContinuousDays(userId, today) + 1; int rewardAmount = calcReward(config, continuousDays); LocalDateTime expireTime = config.getRewardExpireDays() != null ? LocalDateTime.now().plusDays(config.getRewardExpireDays()) : null; CrawlerQuotaGrant grant = crawlerQuotaGrantService.grantAdminQuota( userId, rewardAmount, expireTime, "签到奖励 连续第" + continuousDays + "天"); CheckInRecord record = CheckInRecord.builder() .userId(userId) .checkinDate(today) .continuousDays(continuousDays) .rewardType(config.getRewardType()) .rewardAmount(rewardAmount) .grantId(grant != null ? grant.getId() : null) .clientSource(clientSource) .build(); checkinRecordMapper.insert(record); log.info("用户签到成功: userId={}, date={}, continuous={}, reward={}", userId, today, continuousDays, rewardAmount); // 签到送会员活动:尝试发放上一周期奖励(活动关闭时内部直接返回,不影响原逻辑) try { activityCheckinCycleService.tryGrantCycleReward(userId); } catch (Exception e) { log.warn("签到后尝试发放周期奖励失败,不影响签到主流程: userId={}", userId, e); } return CheckInResultResponse.builder() .id(record.getId()) .checkinDate(record.getCheckinDate()) .continuousDays(record.getContinuousDays()) .rewardAmount(record.getRewardAmount()) .rewardType(record.getRewardType()) .createTime(record.getCreateTime()) .build(); } /** * 获取当月签到日历 */ public CheckInCalendarResponse getMonthlyCalendar(Long userId, Integer year, Integer month) { YearMonth ym = (year != null && month != null) ? YearMonth.of(year, month) : YearMonth.now(); LocalDate start = ym.atDay(1); LocalDate end = ym.atEndOfMonth(); List records = checkinRecordMapper.selectMonthlyRecords(userId, start, end); List checkedDates = records.stream() .map(CheckInRecord::getCheckinDate) .collect(Collectors.toList()); int continuousDays = calcContinuousDays(userId, LocalDate.now()); boolean checkedToday = checkedDates.contains(LocalDate.now()); return CheckInCalendarResponse.builder() .month(ym.toString()) .checkedDays(checkedDates.size()) .continuousDays(continuousDays) .checkedDates(checkedDates) .checkedToday(checkedToday) .build(); } /** * 获取签到记录列表 */ public List listRecords(Long userId, Integer limit) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .eq(CheckInRecord::getUserId, userId) .orderByDesc(CheckInRecord::getCheckinDate); if (limit != null && limit > 0) { wrapper.last("LIMIT " + limit); } return checkinRecordMapper.selectList(wrapper); } // ========================================== // 运营端管理方法 // ========================================== /** * 运营端:分页查询签到记录(关联用户信息) * * @param page 分页对象 * @param userId 用户ID(可选) * @param startDate 起始日期(可选) * @param endDate 截止日期(可选) * @param clientSource 签到端(可选) */ public IPage adminListRecords(Page page, Long userId, LocalDate startDate, LocalDate endDate, String clientSource) { return checkinRecordMapper.selectAdminRecordsPage(page, userId, startDate, endDate, clientSource); } /** * 运营端:签到统计概览 */ public CheckInStatsResponse adminGetStats() { LocalDate today = LocalDate.now(); CheckInConfig config = checkinConfigService.getConfig(); return CheckInStatsResponse.builder() .todayCheckinCount(nvl(checkinRecordMapper.countByDate(today))) .yesterdayCheckinCount(nvl(checkinRecordMapper.countByDate(today.minusDays(1)))) .last7DaysCount(nvl(checkinRecordMapper.countByDateRange(today.minusDays(6), today))) .last30DaysCount(nvl(checkinRecordMapper.countByDateRange(today.minusDays(29), today))) .totalCheckinCount(nvl(checkinRecordMapper.countTotalRecords())) .totalCheckinUsers(nvl(checkinRecordMapper.countTotalUsers())) .totalRewardAmount(nvl(checkinRecordMapper.sumTotalReward())) .statDate(today) .enabled(config.getEnabled() != null && config.getEnabled() == 1) .build(); } /** * 运营端:查询指定用户的签到记录(含用户基础信息,最多500条) */ public List adminListUserRecords(Long userId) { if (userId == null) { throw new BusinessException(ErrorCode.PARAM_ERROR, "用户ID不能为空"); } Page page = new Page<>(1, 500); return checkinRecordMapper.selectAdminRecordsPage(page, userId, null, null, null).getRecords(); } private Long nvl(Long v) { return v == null ? 0L : v; } // ========================================== // 私有辅助方法 // ========================================== private CheckInRecord findRecord(Long userId, LocalDate date) { return checkinRecordMapper.selectOne( new LambdaQueryWrapper() .eq(CheckInRecord::getUserId, userId) .eq(CheckInRecord::getCheckinDate, date)); } /** * 计算截至 today 的连续签到天数(不含 today) * 若昨天签到则为昨天连续天数+1,否则为 0 */ private int calcContinuousDays(Long userId, LocalDate today) { CheckInRecord yesterday = findRecord(userId, today.minusDays(1)); if (yesterday == null) { return 0; } return yesterday.getContinuousDays() + 1; } /** * 计算签到奖励:基础 + 连续奖励(达阈值后叠加,受上限约束) */ private int calcReward(CheckInConfig config, int continuousDays) { int reward = config.getBaseReward(); if (continuousDays >= config.getContinuousThreshold()) { int extra = continuousDays - config.getContinuousThreshold() + 1; extra = Math.min(extra, config.getContinuousCap()); reward += config.getContinuousReward() * Math.max(0, extra); } return reward; } }