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.CheckInPeriodStatsResponse; import com.xuekairui.activity.dto.CheckInResultResponse; import com.xuekairui.activity.dto.CheckInStatsResponse; import com.xuekairui.activity.dto.CheckInStatusResponse; import com.xuekairui.activity.dto.ActivityCheckinCycleStatusResponse; import com.xuekairui.activity.dto.ActivityRewardRecordResponse; import com.xuekairui.activity.entity.CheckInConfig; import com.xuekairui.activity.entity.CheckInPeriod; import com.xuekairui.activity.entity.CheckInRecord; import com.xuekairui.activity.entity.CheckInRewardRecord; import com.xuekairui.activity.mapper.CheckInPeriodMapper; import com.xuekairui.activity.mapper.CheckInRecordMapper; import com.xuekairui.activity.mapper.CheckInRewardRecordMapper; import com.xuekairui.common.BusinessException; import com.xuekairui.common.ErrorCode; import com.xuekairui.user.enums.MembershipLevel; 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.time.YearMonth; import java.util.List; import java.util.stream.Collectors; /** * 签到核心服务(用户端)—— 统一签到 + 周期奖励 *

* 业务规则: * 1. 每个用户每天仅可签到一次(DB 唯一索引兜底),签到记录归属到当前进行中的周期 * 2. 签到本身不立即发放奖励,仅记录签到行为 * 3. 周期达标奖励:周期结束后,检查该周期累计签到是否>=所需天数,达标则发放会员奖励 * 4. 奖励发放触发时机:用户签到后 / 查询状态时 自动检查上一周期 * 5. 每人活动期间最多领取 maxRewardCount 次奖励 */ @Slf4j @Service @RequiredArgsConstructor public class CheckInService { private final CheckInConfigService checkinConfigService; private final CheckInRecordMapper checkinRecordMapper; private final CheckInPeriodMapper periodMapper; private final CheckInRewardRecordMapper rewardRecordMapper; private final MembershipService membershipService; /** * 查询今日签到状态 + 当前周期进度 */ public CheckInStatusResponse getCheckInStatus(Long userId) { CheckInConfig config = checkinConfigService.getConfig(); LocalDate today = LocalDate.now(); if (checkinConfigService.isActive(config)) { try { tryGrantCycleReward(userId); } catch (Exception e) { log.warn("查询状态时尝试发放周期奖励失败: userId={}", userId, e); } } CheckInRecord todayRecord = findRecord(userId, today); boolean checkedToday = todayRecord != null; int continuousDays = calcContinuousDays(userId, today); CheckInPeriod currentPeriod = periodMapper.findActiveByDate(today); int periodCheckinCount = 0; boolean periodReached = false; Long currentPeriodId = null; String cycleMode = null; LocalDate periodStart = null; LocalDate periodEnd = null; Integer periodRequiredDays = null; Integer periodRewardDays = null; String periodRewardMode = null; if (currentPeriod != null) { currentPeriodId = currentPeriod.getId(); cycleMode = currentPeriod.getCycleMode(); periodStart = currentPeriod.getStartDate(); periodEnd = currentPeriod.getEndDate(); periodRequiredDays = currentPeriod.getRequiredDays(); periodRewardDays = currentPeriod.getRewardDays(); periodRewardMode = currentPeriod.getRewardMode() != null ? currentPeriod.getRewardMode() : "CUMULATIVE"; periodCheckinCount = countCheckinsInRange(userId, periodStart, periodEnd); periodReached = isPeriodTargetReached(userId, currentPeriod); } long rewardedCount = rewardRecordMapper.countRewardedByUser(userId); int maxReward = config.getMaxRewardCount() != null ? config.getMaxRewardCount() : 3; long remaining = Math.max(0, maxReward - rewardedCount); return CheckInStatusResponse.builder() .checkedToday(checkedToday) .continuousDays(continuousDays) .nextCheckinDate(checkedToday ? today.plusDays(1) : today) .enabled(checkinConfigService.isActive(config)) .currentPeriodId(currentPeriodId) .cycleMode(cycleMode) .periodStartDate(periodStart) .periodEndDate(periodEnd) .periodRequiredDays(periodRequiredDays) .rewardMode(periodRewardMode) .periodCheckinCount(periodCheckinCount) .periodReachedTarget(periodReached) .periodRewardDays(periodRewardDays) .rewardedCount(rewardedCount) .maxRewardCount(maxReward) .remainingCount(remaining) .build(); } /** * 执行签到(不立即发放奖励) */ @Transactional public CheckInResultResponse doCheckIn(Long userId, String clientSource) { checkinConfigService.ensureEnabled(); LocalDate today = LocalDate.now(); CheckInRecord exist = findRecord(userId, today); if (exist != null) { throw new BusinessException(ErrorCode.BUSINESS_ERROR, "今日已签到"); } CheckInPeriod currentPeriod = periodMapper.findActiveByDate(today); if (currentPeriod == null) { throw new BusinessException(ErrorCode.BUSINESS_ERROR, "当前没有进行中的签到周期,暂无法签到"); } int continuousDays = calcContinuousDays(userId, today) + 1; CheckInRecord record = CheckInRecord.builder() .userId(userId) .checkinDate(today) .periodId(currentPeriod.getId()) .continuousDays(continuousDays) .rewardType("MEMBERSHIP") .rewardAmount(0) .clientSource(clientSource) .build(); checkinRecordMapper.insert(record); log.info("用户签到成功: userId={}, date={}, periodId={}, continuousDays={}", userId, today, currentPeriod.getId(), continuousDays); try { tryGrantCycleReward(userId); } catch (Exception e) { log.warn("签到后尝试发周期奖励失败: userId={}", userId, e); } return CheckInResultResponse.builder() .id(record.getId()) .checkinDate(record.getCheckinDate()) .continuousDays(record.getContinuousDays()) .rewardAmount(0) .rewardType("MEMBERSHIP") .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 Page listRecords(Long userId, int pageNum, int pageSize) { Page page = new Page<>(pageNum, pageSize); LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .eq(CheckInRecord::getUserId, userId) .orderByDesc(CheckInRecord::getCheckinDate); return checkinRecordMapper.selectPage(page, wrapper); } // ========================================== // 运营端管理方法 // ========================================== 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(checkinConfigService.isActive(config)) .build(); } /** * 运营端:按周期维度统计签到数据 */ public List adminGetPeriodStats() { List periods = checkinConfigService.listPeriods(); List statsList = new java.util.ArrayList<>(); for (CheckInPeriod period : periods) { Long totalUsers = checkinRecordMapper.countUsersByPeriod(period.getStartDate(), period.getEndDate()); Long reachedUsers = checkinRecordMapper.countReachedUsersByPeriod( period.getStartDate(), period.getEndDate(), period.getRequiredDays(), period.getRewardMode()); Long rewardedUsers = rewardRecordMapper.selectCount( new LambdaQueryWrapper() .eq(CheckInRewardRecord::getPeriodId, period.getId())); double reachRate = totalUsers > 0 ? (reachedUsers * 100.0 / totalUsers) : 0.0; statsList.add(CheckInPeriodStatsResponse.builder() .periodId(period.getId()) .periodName(period.getPeriodName()) .startDate(period.getStartDate()) .endDate(period.getEndDate()) .status(period.getStatus()) .cycleMode(period.getCycleMode()) .requiredDays(period.getRequiredDays()) .rewardMode(period.getRewardMode()) .rewardDays(period.getRewardDays()) .totalCheckinUsers(totalUsers) .reachedUsers(reachedUsers) .reachRate(Math.round(reachRate * 100.0) / 100.0) .rewardedUsers(rewardedUsers != null ? rewardedUsers : 0L) .build()); } return statsList; } 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; } // ========================================== // 周期奖励发放 // ========================================== /** * 检查并发放最近一个已结束周期的奖励。幂等:DB 唯一索引兜底。 */ @Transactional public void tryGrantCycleReward(Long userId) { CheckInConfig config = checkinConfigService.getConfig(); if (!checkinConfigService.isActive(config)) { return; } LocalDate today = LocalDate.now(); CheckInPeriod lastClosed = periodMapper.findLastClosedByDate(today); if (lastClosed == null) { return; } int checkinCount = countCheckinsInRange(userId, lastClosed.getStartDate(), lastClosed.getEndDate()); int requiredDays = lastClosed.getRequiredDays() != null ? lastClosed.getRequiredDays() : 4; // 根据 rewardMode 判断是否达标 boolean reached = isPeriodTargetReached(userId, lastClosed); if (!reached) { return; } long rewarded = rewardRecordMapper.countRewardedByUser(userId); int maxReward = config.getMaxRewardCount() != null ? config.getMaxRewardCount() : 3; if (rewarded >= maxReward) { return; } if (isPeriodRewardGranted(userId, lastClosed)) { return; } int rewardDays = lastClosed.getRewardDays() != null ? lastClosed.getRewardDays() : 7; String rewardLevelStr = lastClosed.getRewardLevel() != null ? lastClosed.getRewardLevel() : "PRO"; MembershipLevel level = MembershipLevel.fromCode(rewardLevelStr); if (level == null) { level = MembershipLevel.PRO; } membershipService.grantOrExtendMembership(userId, level, rewardDays, "CHECKIN_PERIOD", "签到周期奖励:" + lastClosed.getStartDate() + "~" + lastClosed.getEndDate() + " 累计签到" + checkinCount + "天 获" + rewardDays + "天" + rewardLevelStr + "会员"); try { CheckInRewardRecord rewardRecord = CheckInRewardRecord.builder() .userId(userId) .periodId(lastClosed.getId()) .checkinCount(checkinCount) .grantTime(LocalDateTime.now()) .build(); rewardRecordMapper.insert(rewardRecord); } catch (DuplicateKeyException e) { log.info("周期奖励并发处理: userId={}, period={}", userId, lastClosed.getStartDate()); } log.info("周期奖励已发放: userId={}, period={}~{}={}, days={}", userId, lastClosed.getStartDate(), lastClosed.getEndDate(), checkinCount, rewardDays); } // ========================================== // 私有辅助 // ========================================== private CheckInRecord findRecord(Long userId, LocalDate date) { return checkinRecordMapper.selectOne( new LambdaQueryWrapper() .eq(CheckInRecord::getUserId, userId) .eq(CheckInRecord::getCheckinDate, date)); } private int calcContinuousDays(Long userId, LocalDate today) { int days = 0; LocalDate d = today.minusDays(1); while (findRecord(userId, d) != null) { days++; d = d.minusDays(1); } return days; } private int countCheckinsInRange(Long userId, LocalDate start, LocalDate end) { Long count = checkinRecordMapper.selectCount( new LambdaQueryWrapper() .eq(CheckInRecord::getUserId, userId) .ge(CheckInRecord::getCheckinDate, start) .le(CheckInRecord::getCheckinDate, end)); return count != null ? count.intValue() : 0; } /** * 判断用户是否达到周期签到目标。 * CUMULATIVE:累计签到天数 >= requiredDays * CONSECUTIVE:周期内最长连续签到天数 >= requiredDays */ private boolean isPeriodTargetReached(Long userId, CheckInPeriod period) { int requiredDays = period.getRequiredDays() != null ? period.getRequiredDays() : 4; String mode = period.getRewardMode() != null ? period.getRewardMode() : "CUMULATIVE"; if ("CONSECUTIVE".equalsIgnoreCase(mode)) { int maxConsecutive = countMaxConsecutiveInRange(userId, period.getStartDate(), period.getEndDate()); return maxConsecutive >= requiredDays; } // 默认累计模式 int total = countCheckinsInRange(userId, period.getStartDate(), period.getEndDate()); return total >= requiredDays; } /** * 统计周期内最长连续签到天数 */ private int countMaxConsecutiveInRange(Long userId, LocalDate start, LocalDate end) { List records = checkinRecordMapper.selectList( new LambdaQueryWrapper() .eq(CheckInRecord::getUserId, userId) .ge(CheckInRecord::getCheckinDate, start) .le(CheckInRecord::getCheckinDate, end) .orderByAsc(CheckInRecord::getCheckinDate)); if (records.isEmpty()) { return 0; } int maxLen = 1; int curLen = 1; for (int i = 1; i < records.size(); i++) { if (records.get(i).getCheckinDate().minusDays(1).equals(records.get(i - 1).getCheckinDate())) { curLen++; maxLen = Math.max(maxLen, curLen); } else { curLen = 1; } } return maxLen; } private boolean isPeriodRewardGranted(Long userId, CheckInPeriod period) { return rewardRecordMapper.selectCount( new LambdaQueryWrapper() .eq(CheckInRewardRecord::getUserId, userId) .eq(CheckInRewardRecord::getPeriodId, period.getId())) > 0; } // ========================================== // 活动中心:签到送会员周期状态(供 ActivityController 使用) // ========================================== /** * 签到送会员周期状态(当前周期签到天数、是否达标、累计领取次数、剩余次数) */ public ActivityCheckinCycleStatusResponse getCheckinCycleStatus(Long userId) { CheckInConfig config = checkinConfigService.getConfig(); // 查询时顺带尝试发放上一周期奖励 if (userId != null && checkinConfigService.isActive(config)) { try { tryGrantCycleReward(userId); } catch (Exception e) { log.warn("查询签到周期状态时尝试发奖失败,不影响查询: userId={}", userId, e); } } LocalDate today = LocalDate.now(); CheckInPeriod currentPeriod = periodMapper.findActiveByDate(today); LocalDate currentCycleStart; LocalDate currentCycleEnd; int requiredDays; int rewardDays; if (currentPeriod != null) { currentCycleStart = currentPeriod.getStartDate(); currentCycleEnd = currentPeriod.getEndDate(); requiredDays = currentPeriod.getRequiredDays() != null ? currentPeriod.getRequiredDays() : 4; rewardDays = currentPeriod.getRewardDays() != null ? currentPeriod.getRewardDays() : 7; } else { String cycleMode = config.getCycleMode(); currentCycleStart = cycleStartOf(today, cycleMode); currentCycleEnd = currentCycleStart.plusDays(6); requiredDays = config.getRequiredDays() != null ? config.getRequiredDays() : 4; rewardDays = config.getRewardDays() != null ? config.getRewardDays() : 7; } int currentCount = userId != null ? countCheckinsInRange(userId, currentCycleStart, currentCycleEnd) : 0; long rewarded = userId != null ? rewardRecordMapper.countRewardedByUser(userId) : 0L; int maxReward = config.getMaxRewardCount() != null ? config.getMaxRewardCount() : 3; long remaining = Math.max(0, maxReward - rewarded); return ActivityCheckinCycleStatusResponse.builder() .enabled(checkinConfigService.isActive(config)) .inWindow(checkinConfigService.isActive(config)) .startTime(config.getStartTime()) .endTime(config.getEndTime()) .currentCycleStart(currentCycleStart) .currentCycleEnd(currentCycleEnd) .currentCycleCheckinCount(currentCount) .requiredDays(requiredDays) .reachedTarget(currentCount >= requiredDays) .rewardedCount(rewarded) .maxRewardCount(maxReward) .remainingCount(remaining) .rewardDays(rewardDays) .build(); } // ========================================== // 签到周期奖励记录查询(用户端 + 运营端) // ========================================== /** * 用户端:分页查询我的签到周期奖励记录 */ public Page listMyCycleRewards(Long userId, int pageNum, int pageSize) { return listCycleRewards(userId, pageNum, pageSize); } /** * 运营端:分页查询签到周期奖励记录,支持按用户ID筛选 */ public Page listCycleRewards(Long userId, int pageNum, int pageSize) { Page page = new Page<>(pageNum, pageSize); LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); if (userId != null) { wrapper.eq(CheckInRewardRecord::getUserId, userId); } wrapper.orderByDesc(CheckInRewardRecord::getGrantTime); Page recordPage = rewardRecordMapper.selectPage(page, wrapper); CheckInConfig config = checkinConfigService.getConfig(); int rewardDays = config.getRewardDays() != null ? config.getRewardDays() : 7; Page resultPage = new Page<>(pageNum, pageSize, recordPage.getTotal()); List list = recordPage.getRecords().stream() .map(r -> ActivityRewardRecordResponse.builder() .id(r.getId()) .userId(r.getUserId()) .activityType("CHECKIN") .rewardName("签到有礼奖励") .rewardDays(rewardDays) .rewardLevel("PRO") .rewardType("MEMBERSHIP") .checkinCount(r.getCheckinCount()) .createTime(r.getGrantTime()) .build()) .collect(Collectors.toList()); resultPage.setRecords(list); return resultPage; } // ========================================== // 私有辅助 // ========================================== /** * 计算日期所属周期的起始日(FRI_THU 或 MON_SUN) */ private LocalDate cycleStartOf(LocalDate date, String cycleMode) { int dow = date.getDayOfWeek().getValue(); // MON=1 ... SUN=7 int offset; if ("MON_SUN".equalsIgnoreCase(cycleMode)) { offset = (dow - 1 + 7) % 7; } else { offset = (dow - 5 + 7) % 7; } return date.minusDays(offset); } }