| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548 |
- 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;
- /**
- * 签到核心服务(用户端)—— 统一签到 + 周期奖励
- * <p>
- * 业务规则:
- * 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<CheckInRecord> records = checkinRecordMapper.selectMonthlyRecords(userId, start, end);
- List<LocalDate> 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<CheckInRecord> listRecords(Long userId, int pageNum, int pageSize) {
- Page<CheckInRecord> page = new Page<>(pageNum, pageSize);
- LambdaQueryWrapper<CheckInRecord> wrapper = new LambdaQueryWrapper<CheckInRecord>()
- .eq(CheckInRecord::getUserId, userId)
- .orderByDesc(CheckInRecord::getCheckinDate);
- return checkinRecordMapper.selectPage(page, wrapper);
- }
- // ==========================================
- // 运营端管理方法
- // ==========================================
- public IPage<AdminCheckInRecordResponse> adminListRecords(Page<AdminCheckInRecordResponse> 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<CheckInPeriodStatsResponse> adminGetPeriodStats() {
- List<CheckInPeriod> periods = checkinConfigService.listPeriods();
- List<CheckInPeriodStatsResponse> 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<CheckInRewardRecord>()
- .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<AdminCheckInRecordResponse> adminListUserRecords(Long userId) {
- if (userId == null) {
- throw new BusinessException(ErrorCode.PARAM_ERROR, "用户ID不能为空");
- }
- Page<AdminCheckInRecordResponse> 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<CheckInRecord>()
- .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<CheckInRecord>()
- .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<CheckInRecord> records = checkinRecordMapper.selectList(
- new LambdaQueryWrapper<CheckInRecord>()
- .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<CheckInRewardRecord>()
- .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<ActivityRewardRecordResponse> listMyCycleRewards(Long userId, int pageNum, int pageSize) {
- return listCycleRewards(userId, pageNum, pageSize);
- }
- /**
- * 运营端:分页查询签到周期奖励记录,支持按用户ID筛选
- */
- public Page<ActivityRewardRecordResponse> listCycleRewards(Long userId, int pageNum, int pageSize) {
- Page<CheckInRewardRecord> page = new Page<>(pageNum, pageSize);
- LambdaQueryWrapper<CheckInRewardRecord> wrapper = new LambdaQueryWrapper<>();
- if (userId != null) {
- wrapper.eq(CheckInRewardRecord::getUserId, userId);
- }
- wrapper.orderByDesc(CheckInRewardRecord::getGrantTime);
- Page<CheckInRewardRecord> recordPage = rewardRecordMapper.selectPage(page, wrapper);
- CheckInConfig config = checkinConfigService.getConfig();
- int rewardDays = config.getRewardDays() != null ? config.getRewardDays() : 7;
- Page<ActivityRewardRecordResponse> resultPage = new Page<>(pageNum, pageSize, recordPage.getTotal());
- List<ActivityRewardRecordResponse> 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);
- }
- }
|