package com.xuekairui.activity.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.xuekairui.activity.dto.ActivityCheckinCycleConfigRequest; import com.xuekairui.activity.dto.ActivityCheckinCycleStatusResponse; import com.xuekairui.activity.entity.ActivityCheckinCycleConfig; import com.xuekairui.activity.entity.ActivityCheckinCycleRecord; import com.xuekairui.activity.entity.CheckInRecord; import com.xuekairui.activity.mapper.ActivityCheckinCycleConfigMapper; import com.xuekairui.activity.mapper.ActivityCheckinCycleRecordMapper; import com.xuekairui.activity.mapper.CheckInRecordMapper; 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.util.List; /** * 签到送高级会员活动服务(周期制)。 *

* 规则: * 1. 活动时间窗 2026-07-10 ~ 2026-07-31,所有用户可参加; * 2. 每周五至下周四为一个周期,累计 4 天签到即获得 7 天高级会员; * 3. 周期结束后的第一天(即下一周期首日周五)发放奖励; * 4. 每用户最多领取 3 次。 *

* 数据复用现有 t_checkin_record 签到记录统计周期内签到天数,不改动每日签到奖励逻辑。 */ @Slf4j @Service @RequiredArgsConstructor public class ActivityCheckinCycleService { private static final Long CONFIG_ID = 1L; /** 周期模式:周五至下周四 */ private static final String CYCLE_MODE_FRI_THU = "FRI_THU"; private final ActivityCheckinCycleConfigMapper configMapper; private final ActivityCheckinCycleRecordMapper cycleRecordMapper; private final CheckInRecordMapper checkInRecordMapper; private final MembershipService membershipService; // ========================================== // 配置管理 // ========================================== public ActivityCheckinCycleConfig getConfig() { ActivityCheckinCycleConfig config = configMapper.selectById(CONFIG_ID); if (config == null) { return ActivityCheckinCycleConfig.builder() .id(CONFIG_ID).enabled(0) .startTime(LocalDateTime.now()).endTime(LocalDateTime.now()) .cycleMode(CYCLE_MODE_FRI_THU).requiredDays(4).rewardDays(7).maxRewardCount(3) .build(); } return config; } @Transactional public ActivityCheckinCycleConfig updateConfig(ActivityCheckinCycleConfigRequest request) { ActivityCheckinCycleConfig config = getConfig(); config.setEnabled(request.getEnabled()); config.setStartTime(request.getStartTime()); config.setEndTime(request.getEndTime()); if (request.getCycleMode() != null) config.setCycleMode(request.getCycleMode()); if (request.getRequiredDays() != null) config.setRequiredDays(request.getRequiredDays()); if (request.getRewardDays() != null) config.setRewardDays(request.getRewardDays()); if (request.getMaxRewardCount() != null) config.setMaxRewardCount(request.getMaxRewardCount()); if (request.getRemark() != null) config.setRemark(request.getRemark()); if (config.getId() == null) { config.setId(CONFIG_ID); configMapper.insert(config); } else { configMapper.updateById(config); } log.info("签到送会员活动配置已更新: enabled={}, required={}, reward={}, max={}", config.getEnabled(), config.getRequiredDays(), config.getRewardDays(), config.getMaxRewardCount()); return config; } // ========================================== // 周期奖励发放(签到或查询状态时触发) // ========================================== /** * 尝试发放最近一个已结束周期的奖励。 *

* 在用户每日签到成功后、或查询周期状态时调用。 * 仅当活动开启、该周期累计签到达标、未发放过、且用户未达领取上限时发放。 */ @Transactional public boolean tryGrantCycleReward(Long userId) { ActivityCheckinCycleConfig config = getConfig(); if (!isActive(config)) { return false; } LocalDate today = LocalDate.now(); LocalDate currentCycleStart = cycleStartOf(today); LocalDate prevCycleStart = currentCycleStart.minusDays(7); LocalDate prevCycleEnd = prevCycleStart.plusDays(6); // 仅处理已完整结束的周期(周期截止日 < 今天) if (!prevCycleEnd.isBefore(today)) { return false; } int checkinCount = countCheckinInCycle(userId, prevCycleStart, prevCycleEnd); int requiredDays = config.getRequiredDays() != null ? config.getRequiredDays() : 4; if (checkinCount < requiredDays) { return false; } // 校验领取上限 long rewarded = cycleRecordMapper.countRewardedByUser(userId); int maxReward = config.getMaxRewardCount() != null ? config.getMaxRewardCount() : 3; if (rewarded >= maxReward) { log.info("用户{}已达签到周期奖励上限{}次", userId, maxReward); return false; } // 幂等:插入周期记录(uk_user_cycle 兜底),已存在则更新为已发放 ActivityCheckinCycleRecord exist = cycleRecordMapper.selectOne( new LambdaQueryWrapper() .eq(ActivityCheckinCycleRecord::getUserId, userId) .eq(ActivityCheckinCycleRecord::getCycleStart, prevCycleStart)); if (exist != null && exist.getRewardGranted() == 1) { return false; } int rewardDays = config.getRewardDays() != null ? config.getRewardDays() : 7; membershipService.grantOrExtendMembership(userId, MembershipLevel.PRO, rewardDays, "ACTIVITY_CHECKIN_CYCLE", "签到送会员活动:周期" + prevCycleStart + "~" + prevCycleEnd + "累计签到" + checkinCount + "天奖励" + rewardDays + "天高级会员"); if (exist == null) { ActivityCheckinCycleRecord record = ActivityCheckinCycleRecord.builder() .userId(userId) .cycleStart(prevCycleStart) .cycleEnd(prevCycleEnd) .checkinCount(checkinCount) .rewardGranted(1) .grantTime(LocalDateTime.now()) .build(); try { cycleRecordMapper.insert(record); } catch (DuplicateKeyException e) { // 并发兜底:已被其他线程处理 log.info("签到周期奖励并发处理,已跳过: userId={}, cycle={}", userId, prevCycleStart); return false; } } else { exist.setCheckinCount(checkinCount); exist.setRewardGranted(1); exist.setGrantTime(LocalDateTime.now()); cycleRecordMapper.updateById(exist); } log.info("签到周期奖励已发放: userId={}, cycle={}~{}, checkinCount={}, days={}", userId, prevCycleStart, prevCycleEnd, checkinCount, rewardDays); return true; } // ========================================== // 用户端状态查询 // ========================================== public ActivityCheckinCycleStatusResponse getStatus(Long userId) { ActivityCheckinCycleConfig config = getConfig(); // 查询时顺带尝试发放上一周期奖励(用户若未在发放日签到,查询时补发) if (userId != null && isActive(config)) { try { tryGrantCycleReward(userId); } catch (Exception e) { log.warn("查询签到周期状态时尝试发奖失败,不影响查询: userId={}", userId, e); } } LocalDate today = LocalDate.now(); LocalDate currentCycleStart = cycleStartOf(today); LocalDate currentCycleEnd = currentCycleStart.plusDays(6); int currentCount = userId != null ? countCheckinInCycle(userId, currentCycleStart, currentCycleEnd) : 0; int requiredDays = config.getRequiredDays() != null ? config.getRequiredDays() : 4; int rewardDays = config.getRewardDays() != null ? config.getRewardDays() : 7; long rewarded = userId != null ? cycleRecordMapper.countRewardedByUser(userId) : 0L; int maxReward = config.getMaxRewardCount() != null ? config.getMaxRewardCount() : 3; long remaining = Math.max(0, maxReward - rewarded); return ActivityCheckinCycleStatusResponse.builder() .enabled(isActive(config)) .inWindow(inWindow(config)) .currentCycleStart(currentCycleStart) .currentCycleEnd(currentCycleEnd) .currentCycleCheckinCount(currentCount) .requiredDays(requiredDays) .reachedTarget(currentCount >= requiredDays) .rewardedCount(rewarded) .maxRewardCount(maxReward) .remainingCount(remaining) .rewardDays(rewardDays) .build(); } /** * 用户端:查询我的签到周期奖励记录 * 返回已发放的周期奖励列表,按发放时间倒序 */ public List listMyCycleRewards(Long userId) { return cycleRecordMapper.selectList( new LambdaQueryWrapper() .eq(ActivityCheckinCycleRecord::getUserId, userId) .eq(ActivityCheckinCycleRecord::getRewardGranted, 1) .orderByDesc(ActivityCheckinCycleRecord::getGrantTime)); } // ========================================== // 私有辅助 // ========================================== private boolean isActive(ActivityCheckinCycleConfig config) { return config.getEnabled() != null && config.getEnabled() == 1 && inWindow(config); } private boolean inWindow(ActivityCheckinCycleConfig config) { LocalDateTime now = LocalDateTime.now(); return !now.isBefore(config.getStartTime()) && !now.isAfter(config.getEndTime()); } /** * 计算日期所属周期的起始日(周五)。 * 周期为周五~下周四:以周五为起点,offset = (dayOfWeek - 5 + 7) % 7。 */ private LocalDate cycleStartOf(LocalDate date) { int dow = date.getDayOfWeek().getValue(); // MON=1 ... SUN=7 int offset = (dow - 5 + 7) % 7; return date.minusDays(offset); } private int countCheckinInCycle(Long userId, LocalDate start, LocalDate end) { if (userId == null) { return 0; } Long count = checkInRecordMapper.selectCount( new LambdaQueryWrapper() .eq(CheckInRecord::getUserId, userId) .ge(CheckInRecord::getCheckinDate, start) .le(CheckInRecord::getCheckinDate, end)); return count != null ? count.intValue() : 0; } }