Просмотр исходного кода

抽奖活动,邀请有礼活动,签到活动这几种更新

liuchengsen 1 месяц назад
Родитель
Сommit
527d694607

+ 46 - 3
zhijiayun-activity/src/main/java/com/xuekairui/activity/controller/AdminCheckInController.java

@@ -1,10 +1,12 @@
 package com.xuekairui.activity.controller;
+
 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.CheckInConfigRequest;
 import com.xuekairui.activity.dto.CheckInStatsResponse;
 import com.xuekairui.activity.entity.CheckInConfig;
+import com.xuekairui.activity.entity.CheckInPeriod;
 import com.xuekairui.activity.service.CheckInConfigService;
 import com.xuekairui.activity.service.CheckInService;
 import com.xuekairui.common.Result;
@@ -19,7 +21,7 @@ import java.util.List;
 /**
  * 签到管理接口(运营端)
  * <p>
- * 提供签到配置管理、签到记录查询、签到统计等运营能力。
+ * 提供签到全局配置、周期管理、签到记录查询、签到统计等运营能力。
  */
 @RestController
 @RequestMapping("/api/admin/checkin")
@@ -30,7 +32,7 @@ public class AdminCheckInController {
     private final CheckInService checkInService;
 
     /**
-     * 获取签到配置
+     * 获取签到全局配置
      * GET /api/admin/checkin/config
      */
     @GetMapping("/config")
@@ -39,7 +41,7 @@ public class AdminCheckInController {
     }
 
     /**
-     * 更新签到配置
+     * 更新签到全局配置
      * PUT /api/admin/checkin/config
      */
     @PutMapping("/config")
@@ -80,4 +82,45 @@ public class AdminCheckInController {
     public Result<List<AdminCheckInRecordResponse>> listUserRecords(@PathVariable Long userId) {
         return Result.success(checkInService.adminListUserRecords(userId));
     }
+
+    // ==========================================
+    // 周期管理
+    // ==========================================
+
+    /**
+     * 周期列表
+     * GET /api/admin/checkin/periods
+     */
+    @GetMapping("/periods")
+    public Result<List<CheckInPeriod>> listPeriods() {
+        return Result.success(checkInConfigService.listPeriods());
+    }
+
+    /**
+     * 创建周期
+     * POST /api/admin/checkin/periods
+     */
+    @PostMapping("/periods")
+    public Result<CheckInPeriod> createPeriod(@RequestBody CheckInPeriod request) {
+        return Result.success(checkInConfigService.createPeriod(request));
+    }
+
+    /**
+     * 更新周期
+     * PUT /api/admin/checkin/periods/{id}
+     */
+    @PutMapping("/periods/{id}")
+    public Result<CheckInPeriod> updatePeriod(@PathVariable Long id, @RequestBody CheckInPeriod request) {
+        return Result.success(checkInConfigService.updatePeriod(id, request));
+    }
+
+    /**
+     * 删除周期
+     * DELETE /api/admin/checkin/periods/{id}
+     */
+    @DeleteMapping("/periods/{id}")
+    public Result<Void> deletePeriod(@PathVariable Long id) {
+        checkInConfigService.deletePeriod(id);
+        return Result.success(null);
+    }
 }

+ 23 - 15
zhijiayun-activity/src/main/java/com/xuekairui/activity/dto/CheckInConfigRequest.java

@@ -4,11 +4,12 @@ import jakarta.validation.constraints.Min;
 import jakarta.validation.constraints.NotNull;
 import lombok.Data;
 
+import java.time.LocalDateTime;
+
 /**
- * 签到配置更新请求(运营端)
+ * 签到全局配置更新请求(运营端)
  * <p>
- * 奖励为高级会员天数:baseReward=基础天数,连续签到满 continuousThreshold 天后每天额外叠加 continuousReward 天,
- * 叠加累计不超过 continuousCap 天。
+ * 控制活动开关、周期默认值。具体每个周期在 t_checkin_period 表中管理。
  */
 @Data
 public class CheckInConfigRequest {
@@ -16,21 +17,28 @@ public class CheckInConfigRequest {
     @NotNull(message = "启用状态不能为空")
     private Integer enabled;
 
-    @NotNull(message = "基础会员天数不能为空")
-    @Min(value = 0, message = "基础会员天数不能小于0")
-    private Integer baseReward;
+    @NotNull(message = "开始时间不能为空")
+    private LocalDateTime startTime;
+
+    @NotNull(message = "结束时间不能为空")
+    private LocalDateTime endTime;
+
+    private String rewardLevel;
+
+    /** 周期模式(默认值):FRI_THU(周五~下周四) / MON_SUN(周一~周日) */
+    private String cycleMode;
+
+    @Min(value = 1, message = "周期需签到天数至少为1")
+    private Integer requiredDays;
 
-    @NotNull(message = "连续额外天数不能为空")
-    @Min(value = 0, message = "连续额外天数不能小于0")
-    private Integer continuousReward;
+    /** 达标模式:CUMULATIVE(累计签到) / CONSECUTIVE(连续签到) */
+    private String rewardMode;
 
-    @NotNull(message = "连续奖励阈值不能为空")
-    @Min(value = 1, message = "连续奖励阈值至少为1")
-    private Integer continuousThreshold;
+    @Min(value = 1, message = "奖励天数至少为1")
+    private Integer rewardDays;
 
-    @NotNull(message = "连续奖励上限不能为空")
-    @Min(value = 1, message = "连续奖励上限至少为1")
-    private Integer continuousCap;
+    @Min(value = 1, message = "最多领取次数至少为1")
+    private Integer maxRewardCount;
 
     private String remark;
 }

+ 44 - 5
zhijiayun-activity/src/main/java/com/xuekairui/activity/dto/CheckInStatusResponse.java

@@ -8,7 +8,9 @@ import lombok.AllArgsConstructor;
 import java.time.LocalDate;
 
 /**
- * 签到状态响应
+ * 签到状态响应(用户端)
+ * <p>
+ * 包含今日签到状态 + 当前周期进度 + 累计奖励统计。
  */
 @Data
 @Builder
@@ -22,12 +24,49 @@ public class CheckInStatusResponse {
     /** 当前连续签到天数 */
     private Integer continuousDays;
 
-    /** 今日签到可获奖励(基础+连续) */
-    private Integer todayReward;
-
-    /** 下一次签到日(今日未签到则为今天) */
+    /** 下一次可签到日(今日未签到则为今天) */
     private LocalDate nextCheckinDate;
 
     /** 签到功能是否开启 */
     private Boolean enabled;
+
+    // ======== 当前周期信息 ========
+
+    /** 当前周期ID */
+    private Long currentPeriodId;
+
+    /** 周期模式 */
+    private String cycleMode;
+
+    /** 当前周期起始日 */
+    private LocalDate periodStartDate;
+
+    /** 当前周期截止日 */
+    private LocalDate periodEndDate;
+
+    /** 周期需签到天数 */
+    private Integer periodRequiredDays;
+
+    /** 达标模式:CUMULATIVE-累计 / CONSECUTIVE-连续 */
+    private String rewardMode;
+
+    /** 当前周期已签到天数 */
+    private Integer periodCheckinCount;
+
+    /** 当前周期是否已达标 */
+    private Boolean periodReachedTarget;
+
+    /** 达标可获奖励天数 */
+    private Integer periodRewardDays;
+
+    // ======== 累计统计 ========
+
+    /** 累计已领取奖励次数 */
+    private Long rewardedCount;
+
+    /** 最多可领取次数 */
+    private Integer maxRewardCount;
+
+    /** 剩余可领取次数 */
+    private Long remainingCount;
 }

+ 26 - 9
zhijiayun-activity/src/main/java/com/xuekairui/activity/entity/CheckInConfig.java

@@ -9,7 +9,10 @@ import lombok.Builder;
 import java.time.LocalDateTime;
 
 /**
- * 签到配置实体(全局唯一,id=1)
+ * 签到全局配置实体(全局唯一,id=1)
+ * <p>
+ * 控制签到活动开关和周期默认值。
+ * 具体每个周期的起止日期、所需天数、奖励等在 t_checkin_period 表中。
  */
 @Data
 @NoArgsConstructor
@@ -25,25 +28,39 @@ public class CheckInConfig {
     @Builder.Default
     private Integer enabled = 1;
 
+    /** 活动开始时间 */
+    private LocalDateTime startTime;
+
+    /** 活动结束时间 */
+    private LocalDateTime endTime;
+
     /** 奖励类型:MEMBERSHIP-会员天数 */
     @Builder.Default
     private String rewardType = "MEMBERSHIP";
 
-    /** 基础签到奖励(每日) */
+    /** 奖励会员等级 */
+    @Builder.Default
+    private String rewardLevel = "PRO";
+
+    /** 周期模式(默认值,新建周期时使用):FRI_THU / MON_SUN */
+    @Builder.Default
+    private String cycleMode = "FRI_THU";
+
+    /** 周期需签到天数(默认值) */
     @Builder.Default
-    private Integer baseReward = 1;
+    private Integer requiredDays = 4;
 
-    /** 连续签到额外奖励(满足阈值后每日叠加) */
+    /** 达标模式:CUMULATIVE-累计签到 / CONSECUTIVE-连续签到 */
     @Builder.Default
-    private Integer continuousReward = 1;
+    private String rewardMode = "CUMULATIVE";
 
-    /** 连续签到奖励触发阈值(天) */
+    /** 周期达标奖励天数(默认值) */
     @Builder.Default
-    private Integer continuousThreshold = 7;
+    private Integer rewardDays = 7;
 
-    /** 连续签到最大累计额外天数上限 */
+    /** 每用户活动期间最多领取奖励次数 */
     @Builder.Default
-    private Integer continuousCap = 30;
+    private Integer maxRewardCount = 3;
 
     /** 备注 */
     private String remark;

+ 68 - 0
zhijiayun-activity/src/main/java/com/xuekairui/activity/entity/CheckInPeriod.java

@@ -0,0 +1,68 @@
+package com.xuekairui.activity.entity;
+
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+/**
+ * 签到周期表(每个周期一行,运营端可手动增删改)
+ * <p>
+ * 周期模式 cycleMode 决定周期的星期范围(如 FRI_THU=周五到下周四),
+ * 每条记录定义该周期的起止日期、所需签到天数、达标奖励。
+ * 活动全局开关和默认值在 t_checkin_config 中。
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@TableName("t_checkin_period")
+public class CheckInPeriod {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 周期名称,如"第1周(7/10~7/16)" */
+    private String periodName;
+
+    /** 周期模式:FRI_THU(周五~下周四) / MON_SUN(周一~周日) / WEEKLY(自然周) */
+    private String cycleMode;
+
+    /** 周期起始日 */
+    private LocalDate startDate;
+
+    /** 周期截止日 */
+    private LocalDate endDate;
+
+    /** 周期内需签到天数 */
+    private Integer requiredDays;
+
+    /** 达标模式:CUMULATIVE-周期内累计签到N天 / CONSECUTIVE-周期内连续签到N天 */
+    @Builder.Default
+    private String rewardMode = "CUMULATIVE";
+
+    /** 达标奖励会员天数 */
+    private Integer rewardDays;
+
+    /** 奖励会员等级 */
+    @Builder.Default
+    private String rewardLevel = "PRO";
+
+    /** 状态:PENDING-待开始 / ACTIVE-进行中 / CLOSED-已结束 */
+    @Builder.Default
+    private String status = "PENDING";
+
+    /** 排序(数字越小越靠前) */
+    @Builder.Default
+    private Integer sort = 0;
+
+    @TableField(fill = FieldFill.INSERT)
+    private LocalDateTime createTime;
+
+    @TableField(fill = FieldFill.INSERT_UPDATE)
+    private LocalDateTime updateTime;
+}

+ 9 - 4
zhijiayun-activity/src/main/java/com/xuekairui/activity/entity/CheckInRecord.java

@@ -11,6 +11,9 @@ import java.time.LocalDateTime;
 
 /**
  * 用户签到记录实体
+ * <p>
+ * 每次签到记录所属周期(periodId),当日已签到则不可重复签到。
+ * 签到本身不再发放奖励,奖励在周期达标后统一发放。
  */
 @Data
 @NoArgsConstructor
@@ -27,16 +30,18 @@ public class CheckInRecord {
     /** 签到日期(自然日) */
     private LocalDate checkinDate;
 
-    /** 签到时连续天数 */
+    /** 所属周期ID */
+    private Long periodId;
+
+    /** 签到时连续天数(历史兼容) */
     @Builder.Default
     private Integer continuousDays = 1;
 
-    /** 奖励类型 */
+    /** 奖励类型(历史兼容,无实际意义) */
     @Builder.Default
-
     private String rewardType = "MEMBERSHIP";
 
-    /** 本次奖励数量(会员天数) */
+    /** 本次奖励数量(历史兼容,无实际意义,统一为0) */
     @Builder.Default
     private Integer rewardAmount = 0;
 

+ 22 - 0
zhijiayun-activity/src/main/java/com/xuekairui/activity/mapper/CheckInPeriodMapper.java

@@ -0,0 +1,22 @@
+package com.xuekairui.activity.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.xuekairui.activity.entity.CheckInPeriod;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+import java.time.LocalDate;
+import java.util.Optional;
+
+@Mapper
+public interface CheckInPeriodMapper extends BaseMapper<CheckInPeriod> {
+
+    /** 查询包含指定日期的进行中周期 */
+    @Select("SELECT * FROM t_checkin_period WHERE start_date <= #{date} AND end_date >= #{date} AND status = 'ACTIVE' ORDER BY start_date DESC LIMIT 1")
+    CheckInPeriod findActiveByDate(@Param("date") LocalDate date);
+
+    /** 查询最近一个已结束的周期(已结束但可能未发奖) */
+    @Select("SELECT * FROM t_checkin_period WHERE end_date < #{date} AND status = 'CLOSED' ORDER BY end_date DESC LIMIT 1")
+    CheckInPeriod findLastClosedByDate(@Param("date") LocalDate date);
+}

+ 119 - 18
zhijiayun-activity/src/main/java/com/xuekairui/activity/service/CheckInConfigService.java

@@ -2,7 +2,9 @@ package com.xuekairui.activity.service;
 
 import com.xuekairui.activity.dto.CheckInConfigRequest;
 import com.xuekairui.activity.entity.CheckInConfig;
+import com.xuekairui.activity.entity.CheckInPeriod;
 import com.xuekairui.activity.mapper.CheckInConfigMapper;
+import com.xuekairui.activity.mapper.CheckInPeriodMapper;
 import com.xuekairui.common.BusinessException;
 import com.xuekairui.common.ErrorCode;
 import lombok.RequiredArgsConstructor;
@@ -10,11 +12,14 @@ 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.util.List;
+
 /**
  * 签到配置服务(运营端)
  * <p>
- * 奖励体系为高级会员天数:
- * baseReward = 每日基础会员天数,continuousReward = 连续满阈值后每日额外会员天数。
+ * 管理签到全局配置和周期表。
  */
 @Slf4j
 @Service
@@ -24,9 +29,10 @@ public class CheckInConfigService {
     private static final Long CONFIG_ID = 1L;
 
     private final CheckInConfigMapper checkinConfigMapper;
+    private final CheckInPeriodMapper periodMapper;
 
     /**
-     * 获取签到配置(不存在则初始化)
+     * 获取签到配置(不存在则初始化默认值
      */
     public CheckInConfig getConfig() {
         CheckInConfig config = checkinConfigMapper.selectById(CONFIG_ID);
@@ -34,12 +40,15 @@ public class CheckInConfigService {
             config = CheckInConfig.builder()
                     .id(CONFIG_ID)
                     .enabled(1)
+                    .startTime(LocalDateTime.of(2026, 7, 10, 0, 0, 0))
+                    .endTime(LocalDateTime.of(2026, 7, 31, 23, 59, 59))
                     .rewardType("MEMBERSHIP")
-                    .baseReward(1)
-                    .continuousReward(1)
-                    .continuousThreshold(7)
-                    .continuousCap(30)
-                    .remark("默认签到配置:每日1天高级会员,连续7天起每日额外+1天")
+                    .rewardLevel("PRO")
+                    .cycleMode("FRI_THU")
+                    .requiredDays(4)
+                    .rewardDays(7)
+                    .maxRewardCount(3)
+                    .remark("签到送会员:每周期累计签到达标后发放奖励")
                     .build();
             checkinConfigMapper.insert(config);
         }
@@ -47,22 +56,24 @@ public class CheckInConfigService {
     }
 
     /**
-     * 更新签到配置
+     * 更新签到全局配置
      */
     @Transactional
     public CheckInConfig updateConfig(CheckInConfigRequest request) {
         CheckInConfig config = getConfig();
         config.setEnabled(request.getEnabled());
-        config.setRewardType("MEMBERSHIP");
-        config.setBaseReward(request.getBaseReward());
-        config.setContinuousReward(request.getContinuousReward());
-        config.setContinuousThreshold(request.getContinuousThreshold());
-        config.setContinuousCap(request.getContinuousCap());
-        config.setRemark(request.getRemark());
+        config.setStartTime(request.getStartTime());
+        config.setEndTime(request.getEndTime());
+        if (request.getRewardLevel() != null) config.setRewardLevel(request.getRewardLevel());
+        if (request.getCycleMode() != null) config.setCycleMode(request.getCycleMode());
+        if (request.getRequiredDays() != null) config.setRequiredDays(request.getRequiredDays());
+        if (request.getRewardMode() != null) config.setRewardMode(request.getRewardMode());
+        if (request.getRewardDays() != null) config.setRewardDays(request.getRewardDays());
+        if (request.getMaxRewardCount() != null) config.setMaxRewardCount(request.getMaxRewardCount());
+        if (request.getRemark() != null) config.setRemark(request.getRemark());
         checkinConfigMapper.updateById(config);
-        log.info("签到配置已更新: enabled={}, baseReward={}天, continuousReward={}天, threshold={}天",
-                config.getEnabled(), config.getBaseReward(),
-                config.getContinuousReward(), config.getContinuousThreshold());
+        log.info("签到全局配置已更新: enabled={}, cycleMode={}, requiredDays={}, rewardDays={}",
+                config.getEnabled(), config.getCycleMode(), config.getRequiredDays(), config.getRewardDays());
         return config;
     }
 
@@ -74,5 +85,95 @@ public class CheckInConfigService {
         if (config.getEnabled() == null || config.getEnabled() != 1) {
             throw new BusinessException(ErrorCode.BUSINESS_ERROR, "签到功能已关闭");
         }
+        LocalDateTime now = LocalDateTime.now();
+        if (config.getStartTime() != null && now.isBefore(config.getStartTime())) {
+            throw new BusinessException(ErrorCode.BUSINESS_ERROR, "签到活动尚未开始");
+        }
+        if (config.getEndTime() != null && now.isAfter(config.getEndTime())) {
+            throw new BusinessException(ErrorCode.BUSINESS_ERROR, "签到活动已结束");
+        }
+    }
+
+    /**
+     * 判断活动是否进行中
+     */
+    public boolean isActive(CheckInConfig config) {
+        if (config.getEnabled() == null || config.getEnabled() != 1) return false;
+        LocalDateTime now = LocalDateTime.now();
+        if (config.getStartTime() != null && now.isBefore(config.getStartTime())) return false;
+        if (config.getEndTime() != null && now.isAfter(config.getEndTime())) return false;
+        return true;
+    }
+
+    // ==========================================
+    // 周期管理(运营端)
+    // ==========================================
+
+    public List<CheckInPeriod> listPeriods() {
+        com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<CheckInPeriod> wrapper =
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<>();
+        wrapper.orderByAsc(CheckInPeriod::getStartDate);
+        return periodMapper.selectList(wrapper);
+    }
+
+    @Transactional
+    public CheckInPeriod createPeriod(CheckInPeriod request) {
+        if (request.getStartDate() == null || request.getEndDate() == null) {
+            throw new BusinessException(ErrorCode.PARAM_ERROR, "周期起止日期不能为空");
+        }
+        if (request.getEndDate().isBefore(request.getStartDate())) {
+            throw new BusinessException(ErrorCode.PARAM_ERROR, "截止日不能早于起始日");
+        }
+        CheckInConfig config = getConfig();
+        CheckInPeriod period = CheckInPeriod.builder()
+                .periodName(request.getPeriodName())
+                .cycleMode(request.getCycleMode() != null ? request.getCycleMode() : config.getCycleMode())
+                .startDate(request.getStartDate())
+                .endDate(request.getEndDate())
+                .requiredDays(request.getRequiredDays() != null ? request.getRequiredDays() : config.getRequiredDays())
+                .rewardMode(request.getRewardMode() != null ? request.getRewardMode() : config.getRewardMode())
+                .rewardDays(request.getRewardDays() != null ? request.getRewardDays() : config.getRewardDays())
+                .rewardLevel(request.getRewardLevel() != null ? request.getRewardLevel() : config.getRewardLevel())
+                .status(determineStatus(request.getStartDate(), request.getEndDate()))
+                .sort(request.getSort() != null ? request.getSort() : 0)
+                .build();
+        periodMapper.insert(period);
+        log.info("签到周期已创建: id={}, {}~{}", period.getId(), period.getStartDate(), period.getEndDate());
+        return period;
+    }
+
+    @Transactional
+    public CheckInPeriod updatePeriod(Long id, CheckInPeriod request) {
+        CheckInPeriod period = periodMapper.selectById(id);
+        if (period == null) {
+            throw new BusinessException(ErrorCode.PARAM_ERROR, "周期不存在");
+        }
+        if (request.getPeriodName() != null) period.setPeriodName(request.getPeriodName());
+        if (request.getCycleMode() != null) period.setCycleMode(request.getCycleMode());
+        if (request.getStartDate() != null) period.setStartDate(request.getStartDate());
+        if (request.getEndDate() != null) period.setEndDate(request.getEndDate());
+        if (request.getRequiredDays() != null) period.setRequiredDays(request.getRequiredDays());
+        if (request.getRewardMode() != null) period.setRewardMode(request.getRewardMode());
+        if (request.getRewardDays() != null) period.setRewardDays(request.getRewardDays());
+        if (request.getRewardLevel() != null) period.setRewardLevel(request.getRewardLevel());
+        if (request.getSort() != null) period.setSort(request.getSort());
+        period.setStatus(determineStatus(period.getStartDate(), period.getEndDate()));
+        periodMapper.updateById(period);
+        log.info("签到周期已更新: id={}, {}~{}", period.getId(), period.getStartDate(), period.getEndDate());
+        return period;
+    }
+
+    @Transactional
+    public void deletePeriod(Long id) {
+        periodMapper.deleteById(id);
+        log.info("签到周期已删除: id={}", id);
+    }
+
+    /** 根据日期自动判断周期状态 */
+    private String determineStatus(LocalDate start, LocalDate end) {
+        LocalDate today = LocalDate.now();
+        if (today.isBefore(start)) return "PENDING";
+        if (today.isAfter(end)) return "CLOSED";
+        return "ACTIVE";
     }
 }

+ 204 - 71
zhijiayun-activity/src/main/java/com/xuekairui/activity/service/CheckInService.java

@@ -8,8 +8,12 @@ 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.ActivityCheckinCycleRecord;
 import com.xuekairui.activity.entity.CheckInConfig;
+import com.xuekairui.activity.entity.CheckInPeriod;
 import com.xuekairui.activity.entity.CheckInRecord;
+import com.xuekairui.activity.mapper.ActivityCheckinCycleRecordMapper;
+import com.xuekairui.activity.mapper.CheckInPeriodMapper;
 import com.xuekairui.activity.mapper.CheckInRecordMapper;
 import com.xuekairui.common.BusinessException;
 import com.xuekairui.common.ErrorCode;
@@ -17,22 +21,25 @@ 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. 连续签到:若昨日已签到,则连续天数 +1;否则重置为 1
- * 3. 奖励计算:基础会员天数 + (连续天数 >= 阈值 ? 额外会员天数 : 0),累计额外不超过 continuousCap
- * 4. 奖励以高级会员天数发放,通过 MembershipService 续期
+ * 1. 每个用户每天仅可签到一次(DB 唯一索引兜底),签到记录归属到当前进行中的周期
+ * 2. 签到本身不立即发放奖励,仅记录签到行为
+ * 3. 周期达标奖励:周期结束后,检查该周期累计签到是否>=所需天数,达标则发放会员奖励
+ * 4. 奖励发放触发时机:用户签到后 / 查询状态时 自动检查上一周期
+ * 5. 每人活动期间最多领取 maxRewardCount 次奖励
  */
 @Slf4j
 @Service
@@ -41,45 +48,82 @@ public class CheckInService {
 
     private final CheckInConfigService checkinConfigService;
     private final CheckInRecordMapper checkinRecordMapper;
+    private final CheckInPeriodMapper periodMapper;
+    private final ActivityCheckinCycleRecordMapper cycleRecordMapper;
     private final MembershipService membershipService;
-    private final ActivityCheckinCycleService activityCheckinCycleService;
 
     /**
-     * 查询今日签到状态
+     * 查询今日签到状态 + 当前周期进度
      */
     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;
-        if (checkedToday) {
-            continuousDays = todayRecord.getContinuousDays();
-        } else {
-            continuousDays = calcContinuousDays(userId, today);
+        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);
         }
 
-        int todayReward = checkedToday ? todayRecord.getRewardAmount()
-                : calcReward(config, continuousDays + 1);
+        long rewardedCount = cycleRecordMapper.countRewardedByUser(userId);
+        int maxReward = config.getMaxRewardCount() != null ? config.getMaxRewardCount() : 3;
+        long remaining = Math.max(0, maxReward - rewardedCount);
 
         return CheckInStatusResponse.builder()
                 .checkedToday(checkedToday)
                 .continuousDays(continuousDays)
-                .todayReward(todayReward)
                 .nextCheckinDate(checkedToday ? today.plusDays(1) : today)
-                .enabled(config.getEnabled() != null && config.getEnabled() == 1)
+                .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();
-        CheckInConfig config = checkinConfigService.getConfig();
         LocalDate today = LocalDate.now();
 
         CheckInRecord exist = findRecord(userId, today);
@@ -87,48 +131,45 @@ public class CheckInService {
             throw new BusinessException(ErrorCode.BUSINESS_ERROR, "今日已签到");
         }
 
-        int continuousDays = calcContinuousDays(userId, today) + 1;
-        int rewardAmount = calcReward(config, continuousDays);
+        CheckInPeriod currentPeriod = periodMapper.findActiveByDate(today);
+        if (currentPeriod == null) {
+            throw new BusinessException(ErrorCode.BUSINESS_ERROR, "当前没有进行中的签到周期,暂无法签到");
+        }
 
-        membershipService.grantOrExtendMembership(userId, MembershipLevel.PRO, rewardAmount,
-                "CHECKIN", "签到奖励 连续第" + continuousDays + "天 获" + rewardAmount + "天高级会员");
+        int continuousDays = calcContinuousDays(userId, today) + 1;
 
         CheckInRecord record = CheckInRecord.builder()
                 .userId(userId)
                 .checkinDate(today)
+                .periodId(currentPeriod.getId())
                 .continuousDays(continuousDays)
-                .rewardType(config.getRewardType())
-                .rewardAmount(rewardAmount)
+                .rewardType("MEMBERSHIP")
+                .rewardAmount(0)
                 .clientSource(clientSource)
                 .build();
         checkinRecordMapper.insert(record);
 
-        log.info("用户签到成功: userId={}, date={}, continuous={}, reward={}天高级会员",
-                userId, today, continuousDays, rewardAmount);
+        log.info("用户签到成功: userId={}, date={}, periodId={}, continuousDays={}",
+                userId, today, currentPeriod.getId(), continuousDays);
 
         try {
-            activityCheckinCycleService.tryGrantCycleReward(userId);
+            tryGrantCycleReward(userId);
         } catch (Exception e) {
-            log.warn("签到后尝试发周期奖励失败,不影响签到主流程: userId={}", userId, e);
+            log.warn("签到后尝试发周期奖励失败: userId={}", userId, e);
         }
 
         return CheckInResultResponse.builder()
                 .id(record.getId())
                 .checkinDate(record.getCheckinDate())
                 .continuousDays(record.getContinuousDays())
-                .rewardAmount(record.getRewardAmount())
-                .rewardType(record.getRewardType())
+                .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();
+        YearMonth ym = (year != null && month != null) ? YearMonth.of(year, month) : YearMonth.now();
         LocalDate start = ym.atDay(1);
         LocalDate end = ym.atEndOfMonth();
 
@@ -149,9 +190,6 @@ public class CheckInService {
                 .build();
     }
 
-    /**
-     * 获取签到记录列表
-     */
     public List<CheckInRecord> listRecords(Long userId, Integer limit) {
         LambdaQueryWrapper<CheckInRecord> wrapper = new LambdaQueryWrapper<CheckInRecord>()
                 .eq(CheckInRecord::getUserId, userId)
@@ -166,20 +204,12 @@ public class CheckInService {
     // 运营端管理方法
     // ==========================================
 
-    /**
-     * 运营端:分页查询签到记录(关联用户信息)
-     */
     public IPage<AdminCheckInRecordResponse> adminListRecords(Page<AdminCheckInRecordResponse> page,
-                                                              Long userId,
-                                                              LocalDate startDate,
-                                                              LocalDate endDate,
-                                                              String clientSource) {
+                                                              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();
@@ -193,13 +223,10 @@ public class CheckInService {
                 .totalCheckinUsers(nvl(checkinRecordMapper.countTotalUsers()))
                 .totalRewardAmount(nvl(checkinRecordMapper.sumTotalReward()))
                 .statDate(today)
-                .enabled(config.getEnabled() != null && config.getEnabled() == 1)
+                .enabled(checkinConfigService.isActive(config))
                 .build();
     }
 
-    /**
-     * 运营端:查询指定用户的签到记录
-     */
     public List<AdminCheckInRecordResponse> adminListUserRecords(Long userId) {
         if (userId == null) {
             throw new BusinessException(ErrorCode.PARAM_ERROR, "用户ID不能为空");
@@ -208,12 +235,72 @@ public class CheckInService {
         return checkinRecordMapper.selectAdminRecordsPage(page, userId, null, null, null).getRecords();
     }
 
-    private Long nvl(Long v) {
-        return v == null ? 0L : v;
+    private Long nvl(Long v) { return v == null ? 0L : v; }
+
+    // ==========================================
+    // 周期奖励发放
+    // ==========================================
+
+    /**
+     * 检查并发放最近一个已结束周期的奖励。幂等:DB 唯一索引兜底。
+     */
+    @Transactional
+    public void tryGrantCycleReward(Long userId) {
+        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 = cycleRecordMapper.countRewardedByUser(userId);
+        CheckInConfig config = checkinConfigService.getConfig();
+        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 {
+            ActivityCheckinCycleRecord cycleRecord = ActivityCheckinCycleRecord.builder()
+                    .userId(userId)
+                    .cycleStart(lastClosed.getStartDate())
+                    .cycleEnd(lastClosed.getEndDate())
+                    .checkinCount(checkinCount)
+                    .rewardGranted(1)
+                    .grantTime(LocalDateTime.now())
+                    .build();
+            cycleRecordMapper.insert(cycleRecord);
+        } 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) {
@@ -223,27 +310,73 @@ public class CheckInService {
                         .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;
+    }
+
     /**
-     * 计算截至 today 的连续签到天数(不含 today)
+     * 判断用户是否达到周期签到目标。
+     * CUMULATIVE:累计签到天数 >= requiredDays
+     * CONSECUTIVE:周期内最长连续签到天数 >= requiredDays
      */
-    private int calcContinuousDays(Long userId, LocalDate today) {
-        CheckInRecord yesterday = findRecord(userId, today.minusDays(1));
-        if (yesterday == null) {
-            return 0;
+    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;
         }
-        return yesterday.getContinuousDays() + 1;
+        // 默认累计模式
+        int total = countCheckinsInRange(userId, period.getStartDate(), period.getEndDate());
+        return total >= requiredDays;
     }
 
     /**
-     * 计算签到奖励(会员天数):基础天数 + 连续额外天数(达阈值后叠加,受上限约束)
+     * 统计周期内最长连续签到天数
      */
-    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);
+    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 reward;
+        return maxLen;
+    }
+
+    private boolean isPeriodRewardGranted(Long userId, CheckInPeriod period) {
+        return cycleRecordMapper.selectCount(
+                new LambdaQueryWrapper<ActivityCheckinCycleRecord>()
+                        .eq(ActivityCheckinCycleRecord::getUserId, userId)
+                        .eq(ActivityCheckinCycleRecord::getCycleStart, period.getStartDate())
+                        .eq(ActivityCheckinCycleRecord::getRewardGranted, 1)) > 0;
     }
 }

+ 60 - 0
zhijiayun-gateway/src/main/resources/db/migration-v12.sql

@@ -0,0 +1,60 @@
+-- ============================================
+-- 迁移 v12:签到体系重构 — 统一周期签到奖励
+-- 说明:
+--   1. t_checkin_config 字段重构:去掉每日奖励字段,改为全局活动配置+默认值
+--   2. 新增 t_checkin_period 周期表:每个周期一行,运营端可配起止日期/所需天数/奖励
+--   3. t_checkin_record 新增 period_id 字段,签到不再立即发奖励
+--   4. 兼容旧数据:保留旧字段,reward_amount 统一为 0
+-- ============================================
+
+-- 1. 签到配置表字段变更(保留旧字段兼容,增加新字段)
+ALTER TABLE `t_checkin_config`
+    ADD COLUMN IF NOT EXISTS `start_time`        DATETIME     DEFAULT NULL COMMENT '活动开始时间' AFTER `enabled`,
+    ADD COLUMN IF NOT EXISTS `end_time`          DATETIME     DEFAULT NULL COMMENT '活动结束时间' AFTER `start_time`,
+    ADD COLUMN IF NOT EXISTS `reward_level`      VARCHAR(20)  NOT NULL DEFAULT 'PRO' COMMENT '奖励会员等级' AFTER `reward_type`,
+    ADD COLUMN IF NOT EXISTS `max_reward_count`  INT          NOT NULL DEFAULT 3 COMMENT '每用户最多领取次数' AFTER `continuous_cap`,
+    ADD COLUMN IF NOT EXISTS `cycle_mode`        VARCHAR(20)  NOT NULL DEFAULT 'FRI_THU' COMMENT '周期模式默认值' AFTER `max_reward_count`,
+    ADD COLUMN IF NOT EXISTS `required_days`     INT          NOT NULL DEFAULT 4 COMMENT '周期需签到天数默认值' AFTER `cycle_mode`,
+    ADD COLUMN IF NOT EXISTS `reward_mode`       VARCHAR(20)  NOT NULL DEFAULT 'CUMULATIVE' COMMENT '达标模式:CUMULATIVE-累计/CONSECUTIVE-连续' AFTER `required_days`;
+
+-- 更新已有默认配置
+UPDATE `t_checkin_config` SET
+    `start_time` = COALESCE(`start_time`, '2026-07-10 00:00:00'),
+    `end_time` = COALESCE(`end_time`, '2026-07-31 23:59:59'),
+    `reward_level` = COALESCE(`reward_level`, 'PRO'),
+    `max_reward_count` = COALESCE(`max_reward_count`, 3),
+    `cycle_mode` = COALESCE(`cycle_mode`, 'FRI_THU'),
+    `required_days` = COALESCE(`required_days`, 4),
+    `reward_mode` = COALESCE(`reward_mode`, 'CUMULATIVE')
+WHERE `id` = 1;
+
+-- 2. 签到记录表新增 period_id 字段
+ALTER TABLE `t_checkin_record`
+    ADD COLUMN IF NOT EXISTS `period_id` BIGINT DEFAULT NULL COMMENT '所属周期ID' AFTER `checkin_date`,
+    ADD INDEX IF NOT EXISTS `idx_period` (`period_id`);
+
+-- 3. 新建签到周期表
+CREATE TABLE IF NOT EXISTS `t_checkin_period` (
+    `id`             BIGINT       NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+    `period_name`    VARCHAR(128) DEFAULT NULL COMMENT '周期名称,如"第1周(7/10~7/16)"',
+    `cycle_mode`     VARCHAR(20)  NOT NULL DEFAULT 'FRI_THU' COMMENT '周期模式:FRI_THU/MON_SUN',
+    `start_date`     DATE         NOT NULL COMMENT '周期起始日',
+    `end_date`       DATE         NOT NULL COMMENT '周期截止日',
+    `required_days`  INT          NOT NULL DEFAULT 4 COMMENT '需签到天数',
+    `reward_mode`    VARCHAR(20)  NOT NULL DEFAULT 'CUMULATIVE' COMMENT '达标模式:CUMULATIVE-累计/CONSECUTIVE-连续',
+    `reward_days`    INT          NOT NULL DEFAULT 7 COMMENT '奖励会员天数',
+    `reward_level`   VARCHAR(20)  NOT NULL DEFAULT 'PRO' COMMENT '奖励会员等级',
+    `status`         VARCHAR(20)  NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING-待开始/ACTIVE-进行中/CLOSED-已结束',
+    `sort`           INT          NOT NULL DEFAULT 0 COMMENT '排序',
+    `create_time`    DATETIME     DEFAULT CURRENT_TIMESTAMP,
+    `update_time`    DATETIME     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    PRIMARY KEY (`id`),
+    INDEX `idx_status_date` (`status`, `start_date`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='签到周期表(运营端可配每个周期的起止日期和奖励规则)';
+
+-- 初始化示例周期数据(活动期间 7/10 ~ 7/31,每周五~下周四为一个周期)
+INSERT INTO `t_checkin_period` (`period_name`, `cycle_mode`, `start_date`, `end_date`, `required_days`, `reward_mode`, `reward_days`, `reward_level`, `status`, `sort`) VALUES
+('第1周(7/10~7/16)', 'FRI_THU', '2026-07-10', '2026-07-16', 4, 'CUMULATIVE', 7, 'PRO', 'PENDING', 1),
+('第2周(7/17~7/23)', 'FRI_THU', '2026-07-17', '2026-07-23', 4, 'CUMULATIVE', 7, 'PRO', 'PENDING', 2),
+('第3周(7/24~7/30)+1', 'FRI_THU', '2026-07-24', '2026-07-31', 4, 'CUMULATIVE', 7, 'PRO', 'PENDING', 3)
+ON DUPLICATE KEY UPDATE id = id;

+ 93 - 26
zhijiayun-test/src/main/resources/static/activity-test.html

@@ -109,7 +109,7 @@
 <body>
 <div class="container">
     <h1>智价云(药店版) - 活动模块API验收测试工具</h1>
-    <h2>覆盖签到有礼 + 邀请有礼 + 签到送会员 + 比价抽奖 用户端 &amp; 运营端 全量API | 独立登录管理 | 开发环境验证码固定 123456</h2>
+    <h2>覆盖签到有礼(周期制) + 邀请有礼 + 比价抽奖 用户端 &amp; 运营端 全量API | 独立登录管理 | 开发环境验证码固定 123456</h2>
 
     <!-- 用户侧/运营侧 切换器 -->
     <div class="side-switcher">
@@ -464,16 +464,27 @@ const FIELD_DESC = {
     ],
     'checkinStatus': [
         ['data.checkedToday', 'Boolean', '今日是否已签到'],
-        ['data.continuousDays', 'Integer', '当前连续签到天数(今日未签到时为若签到将达成的天数-1)'],
-        ['data.todayReward', 'Integer', '今日签到可获会员天数'],
-        ['data.nextCheckinDate', 'Date', '下一次可签到日期(今日未签到=今天)'],
-        ['data.enabled', 'Boolean', '签到功能是否启用']
+        ['data.continuousDays', 'Integer', '当前连续签到天数'],
+        ['data.nextCheckinDate', 'Date', '下一次可签到日期'],
+        ['data.enabled', 'Boolean', '签到功能是否启用'],
+        ['data.currentPeriodId', 'Long', '当前周期ID(无周期时为null)'],
+        ['data.cycleMode', 'String', '周期模式(FRI_THU/MON_SUN)'],
+        ['data.periodStartDate', 'Date', '当前周期起始日'],
+        ['data.periodEndDate', 'Date', '当前周期截止日'],
+        ['data.periodRequiredDays', 'Integer', '周期需签到天数'],
+        ['data.rewardMode', 'String', '达标模式:CUMULATIVE-累计/CONSECUTIVE-连续'],
+        ['data.periodCheckinCount', 'Integer', '当前周期已签到天数'],
+        ['data.periodReachedTarget', 'Boolean', '当前周期是否已达标'],
+        ['data.periodRewardDays', 'Integer', '达标可获会员天数'],
+        ['data.rewardedCount', 'Long', '累计已领取奖励次数'],
+        ['data.maxRewardCount', 'Integer', '活动期间最多可领取次数'],
+        ['data.remainingCount', 'Long', '剩余可领取次数']
     ],
     'checkinResult': [
         ['data.id', 'Long', '签到记录ID'],
         ['data.checkinDate', 'Date', '签到日期'],
         ['data.continuousDays', 'Integer', '签到时连续天数'],
-        ['data.rewardAmount', 'Integer', '本次奖励会员天数'],
+        ['data.rewardAmount', 'Integer', '本次奖励(统一0,周期达标后才发奖)'],
         ['data.rewardType', 'String', '奖励类型(MEMBERSHIP)'],
         ['data.createTime', 'DateTime', '签到时间']
     ],
@@ -487,15 +498,32 @@ const FIELD_DESC = {
     'checkinConfig': [
         ['data.id', 'Long', '配置ID(固定1)'],
         ['data.enabled', 'Integer', '是否启用:0-停用 1-启用'],
+        ['data.startTime', 'DateTime', '活动开始时间'],
+        ['data.endTime', 'DateTime', '活动结束时间'],
         ['data.rewardType', 'String', '奖励类型(MEMBERSHIP-会员天数)'],
-        ['data.baseReward', 'Integer', '基础会员天数(每日)'],
-        ['data.continuousReward', 'Integer', '连续签到额外会员天数'],
-        ['data.continuousThreshold', 'Integer', '连续额外触发阈值(天)'],
-        ['data.continuousCap', 'Integer', '连续额外累计上限(天)'],
+        ['data.rewardLevel', 'String', '奖励会员等级(PRO/ULTRA)'],
+        ['data.cycleMode', 'String', '周期模式默认值(FRI_THU/MON_SUN)'],
+        ['data.requiredDays', 'Integer', '周期需签到天数默认值'],
+        ['data.rewardMode', 'String', '达标模式默认值:CUMULATIVE-累计/CONSECUTIVE-连续'],
+        ['data.rewardDays', 'Integer', '周期达标奖励天数默认值'],
+        ['data.maxRewardCount', 'Integer', '活动期间每用户最多领取次数'],
         ['data.remark', 'String', '备注'],
         ['data.createTime', 'DateTime', '创建时间'],
         ['data.updateTime', 'DateTime', '更新时间']
     ],
+    'checkinPeriod': [
+        ['data[].id', 'Long', '周期ID'],
+        ['data[].periodName', 'String', '周期名称'],
+        ['data[].cycleMode', 'String', '周期模式(FRI_THU/MON_SUN)'],
+        ['data[].startDate', 'Date', '周期起始日'],
+        ['data[].endDate', 'Date', '周期截止日'],
+        ['data[].requiredDays', 'Integer', '需签到天数'],
+        ['data[].rewardMode', 'String', '达标模式:CUMULATIVE/CONSECUTIVE'],
+        ['data[].rewardDays', 'Integer', '奖励会员天数'],
+        ['data[].rewardLevel', 'String', '奖励会员等级'],
+        ['data[].status', 'String', 'PENDING-待开始/ACTIVE-进行中/CLOSED-已结束'],
+        ['data[].sort', 'Integer', '排序']
+    ],
     'checkinStats': [
         ['data.todayCheckinCount', 'Long', '今日签到人数'],
         ['data.yesterdayCheckinCount', 'Long', '昨日签到人数'],
@@ -748,9 +776,9 @@ function renderCheckinModule() {
     <div class="batch-buttons">
         <button class="btn btn-warning btn-sm" onclick="runCheckinTests()">运行全部签到测试</button>
     </div>
-    ${testSection('checkin-status', '今日签到状态', 'GET', 'get', '/api/checkin/status', '需认证', 'auth-required', null, true, 'checkinStatus', '获取当前用户今日签到状态、连续天数、可获会员天数')}
+    ${testSection('checkin-status', '今日签到状态+周期进度', 'GET', 'get', '/api/checkin/status', '需认证', 'auth-required', null, true, 'checkinStatus', '获取当前用户今日签到状态、连续天数、当前周期信息(起止日/需签到天数/达标模式/已签到天数/是否达标/可获奖励)、累计领取统计')}
     ${testSection('checkin-do', '执行签到', 'POST', 'post', '/api/checkin', '需认证', 'auth-required',
-        [{label:'签到端(clientSource,可选)', name:'clientSource', placeholder:'WINDOWS/ANDROID/MINIAPP', default:'WINDOWS'}], true, 'checkinResult', '执行当日签到,发放高级会员天数奖励。每个自然日仅可签到一次')}
+        [{label:'签到端(clientSource,可选)', name:'clientSource', placeholder:'WINDOWS/ANDROID/MINIAPP', default:'WINDOWS'}], true, 'checkinResult', '执行当日签到(仅记录,不立即发奖)。自动关联当前进行中周期,签到后触发上一周期达标奖励检查。每个自然日仅可签到一次')}
     ${testSection('checkin-calendar', '当月签到日历', 'GET', 'get', '/api/checkin/calendar?year=2026&month=7', '需认证', 'auth-required',
         [{label:'年份(year,可选)', name:'year', placeholder:'2026', default:'2026'},
          {label:'月份(month,可选)', name:'month', placeholder:'7', default:'7'}], true, 'checkinCalendar', '获取指定月份的签到日历,不传默认当月')}
@@ -767,13 +795,13 @@ function renderActivityInviteModule() {
     ${testSection('activity-invite-status', '邀请有礼活动状态', 'GET', 'get', '/api/activity/invite/status', '需认证', 'auth-required', null, true, 'activityInviteStatus', '查询当前邀请有礼活动状态:是否开启、奖励模式、已获/剩余次数')}`;
 }
 
-// ==================== 用户端:签到送会员模块 ====================
+// ==================== 用户端:签到送会员模块(旧版独立接口,周期信息已整合到签到状态接口) ====================
 function renderActivityCheckinModule() {
     return `
     <div class="batch-buttons">
         <button class="btn btn-warning btn-sm" onclick="runActivityCheckinTests()">运行全部签到送会员测试</button>
     </div>
-    ${testSection('activity-checkin-status', '签到送会员周期状态', 'GET', 'get', '/api/activity/checkin-cycle/status', '需认证', 'auth-required', null, true, 'activityCheckinStatus', '查询当前周期签到进度、是否达标、累计领取次数')}`;
+    ${testSection('activity-checkin-status', '签到送会员周期状态(旧版)', 'GET', 'get', '/api/activity/checkin-cycle/status', '需认证', 'auth-required', null, true, 'activityCheckinStatus', '【旧版】查询当前周期签到进度。新版签到状态已整合周期信息到 /api/checkin/status')}`;
 }
 
 // ==================== 用户端:比价抽奖模块 ====================
@@ -795,16 +823,48 @@ function renderAdminCheckinModule() {
     <div class="batch-buttons">
         <button class="btn btn-warning btn-sm" onclick="runAdminCheckinTests()">运行全部签到管理测试</button>
     </div>
-    ${testSection('admin-checkin-config', '签到配置查询', 'GET', 'get', '/api/admin/checkin/config', '需认证', 'auth-required', null, true, 'checkinConfig', '获取当前签到配置(全局唯一)')}
-    ${testSection('admin-checkin-config-update', '更新签到配置', 'PUT', 'put', '/api/admin/checkin/config', '需认证', 'auth-required',
+    ${testSection('admin-checkin-config', '签到全局配置查询', 'GET', 'get', '/api/admin/checkin/config', '需认证', 'auth-required', null, true, 'checkinConfig', '获取签到全局配置:活动时间窗、周期默认值、奖励规则')}
+    ${testSection('admin-checkin-config-update', '更新签到全局配置', 'PUT', 'put', '/api/admin/checkin/config', '需认证', 'auth-required',
         [
          {label:'启用(enabled)', name:'enabled', placeholder:'0停用/1启用', default:'1'},
-         {label:'基础会员天数(baseReward)', name:'baseReward', placeholder:'1', default:'1'},
-         {label:'连续额外天数(continuousReward)', name:'continuousReward', placeholder:'1', default:'1'},
-         {label:'连续阈值(continuousThreshold)', name:'continuousThreshold', placeholder:'7', default:'7'},
-         {label:'连续额外上限(continuousCap)', name:'continuousCap', placeholder:'30', default:'30'},
-         {label:'备注(remark)', name:'remark', placeholder:'默认签到配置', default:'默认签到配置:每日1天高级会员,连续7天起每日额外+1天'}
-        ], true, 'checkinConfig', '更新签到规则配置。连续签到满阈值后每日额外发放 continuousReward 天,累计不超过 continuousCap 天')}
+         {label:'开始时间(startTime)', name:'startTime', placeholder:'2026-07-10 00:00:00', default:'2026-07-10 00:00:00'},
+         {label:'结束时间(endTime)', name:'endTime', placeholder:'2026-07-31 23:59:59', default:'2026-07-31 23:59:59'},
+         {label:'会员等级(rewardLevel)', name:'rewardLevel', placeholder:'PRO/ULTRA', default:'PRO'},
+         {label:'周期模式(cycleMode)', name:'cycleMode', placeholder:'FRI_THU/MON_SUN', default:'FRI_THU'},
+         {label:'需签到天数(requiredDays)', name:'requiredDays', placeholder:'4', default:'4'},
+         {label:'达标模式(rewardMode)', name:'rewardMode', placeholder:'CUMULATIVE/CONSECUTIVE', default:'CUMULATIVE'},
+         {label:'奖励天数(rewardDays)', name:'rewardDays', placeholder:'7', default:'7'},
+         {label:'最多领取次数(maxRewardCount)', name:'maxRewardCount', placeholder:'3', default:'3'},
+         {label:'备注(remark)', name:'remark', placeholder:'签到送会员', default:'签到送会员:每周期累计签到达标后发放奖励'}
+        ], true, 'checkinConfig', '更新签到全局配置。rewardMode: CUMULATIVE-周期内累计签到N天 / CONSECUTIVE-周期内连续签到N天')}
+    ${testSection('admin-checkin-periods', '周期列表', 'GET', 'get', '/api/admin/checkin/periods', '需认证', 'auth-required', null, true, 'checkinPeriod', '获取所有签到周期,每个周期可独立配置起止日期、需签到天数、达标模式、奖励')}
+    ${testSection('admin-checkin-period-create', '创建周期', 'POST', 'post', '/api/admin/checkin/periods', '需认证', 'auth-required',
+        [
+         {label:'周期名称(periodName)', name:'periodName', placeholder:'第N周', default:'测试周期'},
+         {label:'周期模式(cycleMode)', name:'cycleMode', placeholder:'FRI_THU', default:'FRI_THU'},
+         {label:'起始日(startDate)', name:'startDate', placeholder:'2026-07-10', default:'2026-07-10'},
+         {label:'截止日(endDate)', name:'endDate', placeholder:'2026-07-16', default:'2026-07-16'},
+         {label:'需签到天数(requiredDays)', name:'requiredDays', placeholder:'4', default:'4'},
+         {label:'达标模式(rewardMode)', name:'rewardMode', placeholder:'CUMULATIVE/CONSECUTIVE', default:'CUMULATIVE'},
+         {label:'奖励天数(rewardDays)', name:'rewardDays', placeholder:'7', default:'7'},
+         {label:'奖励等级(rewardLevel)', name:'rewardLevel', placeholder:'PRO', default:'PRO'},
+         {label:'排序(sort)', name:'sort', placeholder:'0', default:'99'}
+        ], true, 'checkinPeriod', '创建一个签到周期。rewardMode: CUMULATIVE累计/CONSECUTIVE连续')}
+    ${testSection('admin-checkin-period-update', '更新周期', 'PUT', 'put', '/api/admin/checkin/periods/{id}', '需认证', 'auth-required',
+        [
+         {label:'周期ID(id)', name:'id', placeholder:'1', default:''},
+         {label:'周期名称(periodName)', name:'periodName', placeholder:'第N周', default:''},
+         {label:'周期模式(cycleMode)', name:'cycleMode', placeholder:'FRI_THU', default:''},
+         {label:'起始日(startDate)', name:'startDate', placeholder:'2026-07-10', default:''},
+         {label:'截止日(endDate)', name:'endDate', placeholder:'2026-07-16', default:''},
+         {label:'需签到天数(requiredDays)', name:'requiredDays', placeholder:'4', default:''},
+         {label:'达标模式(rewardMode)', name:'rewardMode', placeholder:'CUMULATIVE/CONSECUTIVE', default:''},
+         {label:'奖励天数(rewardDays)', name:'rewardDays', placeholder:'7', default:''},
+         {label:'奖励等级(rewardLevel)', name:'rewardLevel', placeholder:'PRO', default:''},
+         {label:'排序(sort)', name:'sort', placeholder:'0', default:''}
+        ], true, 'checkinPeriod', '更新签到周期配置')}
+    ${testSection('admin-checkin-period-delete', '删除周期', 'DELETE', 'delete', '/api/admin/checkin/periods/{id}', '需认证', 'auth-required',
+        [{label:'周期ID(id)', name:'id', placeholder:'需删除的周期ID', default:''}], true, null, '删除指定签到周期')}
     ${testSection('admin-checkin-stats', '签到统计概览', 'GET', 'get', '/api/admin/checkin/stats', '需认证', 'auth-required', null, true, 'checkinStats', '签到数据统计:今日/昨日/近7天/近30天签到人数、累计签到次数与用户数、累计发放奖励')}
     ${testSection('admin-checkin-records', '签到记录分页查询', 'GET', 'get', '/api/admin/checkin/records?page=1&size=10&userId=1&startDate=2026-07-01&endDate=2026-07-07&clientSource=WINDOWS', '需认证', 'auth-required',
         [{label:'页码(page)', name:'page', placeholder:'1', default:'1'},
@@ -843,14 +903,14 @@ function renderAdminActivityInviteModule() {
         ], true, 'adminActivityInviteRevoke', '反作弊:根据被邀请人ID撤销其对应的邀请奖励,标记为已撤销状态')}`;
 }
 
-// ==================== 运营端:签到送会员管理模块 ====================
+// ==================== 运营端:签到送会员管理模块(旧版独立配置,已被签到全局配置+周期表替代,保留兼容) ====================
 function renderAdminActivityCheckinModule() {
     return `
     <div class="batch-buttons">
         <button class="btn btn-warning btn-sm" onclick="runAdminActivityCheckinTests()">运行全部签到送会员管理测试</button>
     </div>
-    ${testSection('admin-activity-checkin-config', '签到送会员配置查询', 'GET', 'get', '/api/admin/activity/checkin-cycle/config', '需认证', 'auth-required', null, true, 'adminActivityCheckinConfig', '获取当前签到送会员活动配置:周期模式、需签到天数、奖励天数、最多领取次数')}
-    ${testSection('admin-activity-checkin-config-update', '更新签到送会员配置', 'PUT', 'put', '/api/admin/activity/checkin-cycle/config', '需认证', 'auth-required',
+    ${testSection('admin-activity-checkin-config', '签到送会员旧版配置查询', 'GET', 'get', '/api/admin/activity/checkin-cycle/config', '需认证', 'auth-required', null, true, 'adminActivityCheckinConfig', '【旧版】获取签到送会员活动独立配置。新版签到体系使用 /api/admin/checkin/config + 周期表管理')}
+    ${testSection('admin-activity-checkin-config-update', '更新签到送会员旧版配置', 'PUT', 'put', '/api/admin/activity/checkin-cycle/config', '需认证', 'auth-required',
         [
          {label:'启用(enabled)', name:'enabled', placeholder:'0停用/1启用', default:'1'},
          {label:'开始时间(startTime)', name:'startTime', placeholder:'2026-07-10 00:00:00', default:'2026-07-10 00:00:00'},
@@ -860,7 +920,7 @@ function renderAdminActivityCheckinModule() {
          {label:'奖励天数(rewardDays)', name:'rewardDays', placeholder:'7', default:'7'},
          {label:'最多领取次数(maxRewardCount)', name:'maxRewardCount', placeholder:'3', default:'3'},
          {label:'备注(remark)', name:'remark', placeholder:'签到送会员', default:'每周五至下周四为一周期,累计4天签到得7天高级会员,最多3次'}
-        ], true, 'adminActivityCheckinConfig', '更新签到送会员活动规则。周期结束后第一天自动发放奖励')}`;
+        ], true, 'adminActivityCheckinConfig', '【旧版】更新签到送会员活动独立配置。新版请使用签到管理面板的全局配置+周期表')}`;
 }
 
 // ==================== 运营端:比价抽奖管理模块 ====================
@@ -1021,6 +1081,10 @@ function validate_checkinCalendar(result) { return result.data?.code === 200 &&
 function validate_checkinRecords(result) { return result.data?.code === 200 && Array.isArray(result.data?.data); }
 function validate_adminCheckinConfig(result) { return result.data?.code === 200 && result.data?.data?.id; }
 function validate_adminCheckinConfigUpdate(result) { return result.data?.code === 200 && result.data?.data?.id; }
+function validate_adminCheckinPeriods(result) { return result.data?.code === 200 && Array.isArray(result.data?.data); }
+function validate_adminCheckinPeriodCreate(result) { return result.data?.code === 200 && result.data?.data?.id; }
+function validate_adminCheckinPeriodUpdate(result) { return result.data?.code === 200 && result.data?.data?.id; }
+function validate_adminCheckinPeriodDelete(result) { return result.data?.code === 200; }
 function validate_adminCheckinStats(result) { return result.data?.code === 200 && result.data?.data?.todayCheckinCount !== undefined; }
 function validate_adminCheckinRecords(result) { return result.data?.code === 200 && result.data?.data?.records !== undefined; }
 function validate_adminCheckinUserRecords(result) { return result.data?.code === 200 && Array.isArray(result.data?.data); }
@@ -1035,6 +1099,9 @@ function validate_adminActivityInviteRevoke(result) { return result.data?.code =
 function validate_adminActivityCheckinConfig(result) { return result.data?.code === 200 && result.data?.data?.id; }
 function validate_adminActivityLotteryConfig(result) { return result.data?.code === 200 && result.data?.data?.id; }
 function validate_adminActivityLotteryPrize(result) { return result.data?.code === 200 && result.data?.data?.id; }
+function validate_adminActivityLotteryPrizeCreate(result) { return result.data?.code === 200 && result.data?.data?.id; }
+function validate_adminActivityLotteryPrizeUpdate(result) { return result.data?.code === 200 && result.data?.data?.id; }
+function validate_adminActivityLotteryPrizeDelete(result) { return result.data?.code === 200; }
 
 // ===================== 初始化 =====================
 window.addEventListener('DOMContentLoaded', () => {