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);
}
}