liuchengsen hai 1 mes
pai
achega
28ed4c21f3
Modificáronse 21 ficheiros con 479 adicións e 34 borrados
  1. 17 4
      docs/01-产品文档.md
  2. 7 0
      zhijiayun-activity/src/main/java/com/xuekairui/activity/controller/ActivityController.java
  3. 19 1
      zhijiayun-activity/src/main/java/com/xuekairui/activity/controller/AdminCheckInController.java
  4. 10 1
      zhijiayun-activity/src/main/java/com/xuekairui/activity/dto/ActivityAvailableResponse.java
  5. 7 0
      zhijiayun-activity/src/main/java/com/xuekairui/activity/dto/ActivityCheckinCycleStatusResponse.java
  6. 8 0
      zhijiayun-activity/src/main/java/com/xuekairui/activity/dto/ActivityInviteStatusResponse.java
  7. 9 1
      zhijiayun-activity/src/main/java/com/xuekairui/activity/dto/ActivityLotteryStatusResponse.java
  8. 57 0
      zhijiayun-activity/src/main/java/com/xuekairui/activity/dto/CheckInPeriodStatsResponse.java
  9. 13 0
      zhijiayun-activity/src/main/java/com/xuekairui/activity/mapper/CheckInRecordMapper.java
  10. 30 10
      zhijiayun-activity/src/main/java/com/xuekairui/activity/service/ActivityCheckinCycleService.java
  11. 2 0
      zhijiayun-activity/src/main/java/com/xuekairui/activity/service/ActivityInviteService.java
  12. 2 0
      zhijiayun-activity/src/main/java/com/xuekairui/activity/service/ActivityLotteryService.java
  13. 108 1
      zhijiayun-activity/src/main/java/com/xuekairui/activity/service/CheckInConfigService.java
  14. 40 0
      zhijiayun-activity/src/main/java/com/xuekairui/activity/service/CheckInService.java
  15. 20 0
      zhijiayun-activity/src/main/resources/mapper/CheckInRecordMapper.xml
  16. 2 2
      zhijiayun-invite/src/main/java/com/xuekairui/invite/controller/AdminInviteController.java
  17. 9 3
      zhijiayun-invite/src/main/java/com/xuekairui/invite/controller/InviteController.java
  18. 4 1
      zhijiayun-invite/src/main/java/com/xuekairui/invite/dto/InvitePageResponse.java
  19. 56 10
      zhijiayun-invite/src/main/java/com/xuekairui/invite/service/InviteService.java
  20. 2 0
      zhijiayun-user/src/main/java/com/xuekairui/user/security/JwtAuthenticationFilter.java
  21. 57 0
      zhijiayun-user/src/main/java/com/xuekairui/user/service/WechatMiniProgramService.java

+ 17 - 4
docs/01-产品文档.md

@@ -448,15 +448,28 @@
 
 每日签到 + 周期达标奖励机制:
 
-- 用户每天可签到一次
-- 每个活动周期内累计签到达到要求天数,自动获得会员天数奖励
-- 支持签到日历查看、签到记录分页
+- **活动时间**:运营可配置活动开始和结束时间
+- **周期模式**:支持两种周期模式
+  - FRI_THU:周五至下周四(7天)
+  - MON_SUN:周一至周日(7天)
+- **自动生成周期**:配置活动时间后,系统自动根据周期模式生成所有活动周期
+- **周期管理**:
+  - PENDING周期:可自动删除重建(当活动时间或周期模式变更时)
+  - ACTIVE/CLOSED周期:保留(已有用户签到数据)
+- **签到规则**:
+  - 用户每天可签到一次
+  - 每个活动周期内累计签到达到要求天数,自动获得会员天数奖励
+  - 支持累计模式(CUMULATIVE)和连续模式(CONSECUTIVE)
+- **数据统计**:
+  - 总体统计:今日/昨日/近7天/近30天签到人数、累计签到次数、累计签到用户数、累计发放奖励
+  - 周期维度统计:每个周期的签到人数、达标人数、达标率、已发放奖励人数
+  - 药店名称:签到记录关联用户药店名称
 
 **用户侧接口**:
 
 | 接口 | 说明 |
 |------|------|
-| GET /api/checkin/status | 查询今日签到状态 |
+| GET /api/checkin/status | 查询今日签到状态(含当前周期进度) |
 | POST /api/checkin | 执行签到 |
 | GET /api/checkin/calendar?year=2026&month=7 | 查询当月签到日历 |
 | GET /api/checkin/records | 分页查询签到记录 |

+ 7 - 0
zhijiayun-activity/src/main/java/com/xuekairui/activity/controller/ActivityController.java

@@ -137,6 +137,8 @@ public class ActivityController {
                         .description("邀请好友注册并通过入驻审核,双方各得会员天数奖励")
                         .enabled(inviteStatus.getEnabled())
                         .inWindow(inviteStatus.getInWindow())
+                        .startTime(inviteStatus.getStartTime())
+                        .endTime(inviteStatus.getEndTime())
                         .rewardedCount(inviteStatus.getRewardedCount())
                         .maxRewardCount(inviteStatus.getMaxInviterReward())
                         .remainingCount(inviteStatus.getRemainingCount())
@@ -147,6 +149,8 @@ public class ActivityController {
                         .description("每周期累计签到达标即可领取会员天数奖励")
                         .enabled(checkinStatus.getEnabled())
                         .inWindow(checkinStatus.getInWindow())
+                        .startTime(checkinStatus.getStartTime())
+                        .endTime(checkinStatus.getEndTime())
                         .rewardedCount(checkinStatus.getRewardedCount())
                         .maxRewardCount(checkinStatus.getMaxRewardCount())
                         .remainingCount(checkinStatus.getRemainingCount())
@@ -163,11 +167,14 @@ public class ActivityController {
                         .description("每日比价获得抽奖机会,赢取会员天数等好礼")
                         .enabled(lotteryStatus.getEnabled())
                         .inWindow(lotteryStatus.getInWindow())
+                        .startTime(lotteryStatus.getStartTime())
+                        .endTime(lotteryStatus.getEndTime())
                         .eligible(lotteryStatus.getEligible())
                         .membershipLevel(lotteryStatus.getMembershipLevel())
                         .requireMembershipLevel(lotteryStatus.getRequireMembershipLevel())
                         .availableChances(lotteryStatus.getAvailableChances())
                         .usedChances(lotteryStatus.getUsedChances())
+                        .reason(lotteryStatus.getReason())
                         .build()
         );
 

+ 19 - 1
zhijiayun-activity/src/main/java/com/xuekairui/activity/controller/AdminCheckInController.java

@@ -4,6 +4,7 @@ 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.CheckInPeriodStatsResponse;
 import com.xuekairui.activity.dto.CheckInStatsResponse;
 import com.xuekairui.activity.entity.CheckInConfig;
 import com.xuekairui.activity.entity.CheckInPeriod;
@@ -13,7 +14,15 @@ import com.xuekairui.common.Result;
 import jakarta.validation.Valid;
 import lombok.RequiredArgsConstructor;
 import org.springframework.format.annotation.DateTimeFormat;
-import org.springframework.web.bind.annotation.*;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
 
 import java.time.LocalDate;
 import java.util.List;
@@ -58,6 +67,15 @@ public class AdminCheckInController {
         return Result.success(checkInService.adminGetStats());
     }
 
+    /**
+     * 按周期维度统计签到数据
+     * GET /api/admin/checkin/period-stats
+     */
+    @GetMapping("/period-stats")
+    public Result<List<CheckInPeriodStatsResponse>> getPeriodStats() {
+        return Result.success(checkInService.adminGetPeriodStats());
+    }
+
     /**
      * 分页查询签到记录(关联用户信息)
      * GET /api/admin/checkin/records?page=1&size=10&userId=1&startDate=2026-07-01&endDate=2026-07-07&clientSource=WINDOWS

+ 10 - 1
zhijiayun-activity/src/main/java/com/xuekairui/activity/dto/ActivityAvailableResponse.java

@@ -6,6 +6,7 @@ import lombok.Data;
 import lombok.NoArgsConstructor;
 
 import java.time.LocalDate;
+import java.time.LocalDateTime;
 import java.util.List;
 
 /**
@@ -47,6 +48,12 @@ public class ActivityAvailableResponse {
         /** 是否在活动时间窗内 */
         private Boolean inWindow;
 
+        /** 活动开始时间 */
+        private LocalDateTime startTime;
+
+        /** 活动结束时间 */
+        private LocalDateTime endTime;
+
         // ===== 通用统计 =====
         /** 已获奖励次数 */
         private Long rewardedCount;
@@ -80,5 +87,7 @@ public class ActivityAvailableResponse {
         private Integer availableChances;
         /** 今日已用抽奖机会 */
         private Integer usedChances;
+        /** 状态原因说明 */
+        private String reason;
     }
-}
+}

+ 7 - 0
zhijiayun-activity/src/main/java/com/xuekairui/activity/dto/ActivityCheckinCycleStatusResponse.java

@@ -6,6 +6,7 @@ import lombok.NoArgsConstructor;
 import lombok.AllArgsConstructor;
 
 import java.time.LocalDate;
+import java.time.LocalDateTime;
 
 /**
  * 签到送会员活动状态响应(用户端)
@@ -19,6 +20,12 @@ public class ActivityCheckinCycleStatusResponse {
     private Boolean enabled;
     private Boolean inWindow;
 
+    /** 活动开始时间 */
+    private LocalDateTime startTime;
+
+    /** 活动结束时间 */
+    private LocalDateTime endTime;
+
     /** 当前周期起始日(周五) */
     private LocalDate currentCycleStart;
 

+ 8 - 0
zhijiayun-activity/src/main/java/com/xuekairui/activity/dto/ActivityInviteStatusResponse.java

@@ -5,6 +5,8 @@ import lombok.Data;
 import lombok.NoArgsConstructor;
 import lombok.AllArgsConstructor;
 
+import java.time.LocalDateTime;
+
 /**
  * 邀请有礼活动状态响应(用户端)
  */
@@ -20,6 +22,12 @@ public class ActivityInviteStatusResponse {
     /** 是否在活动时间窗内 */
     private Boolean inWindow;
 
+    /** 活动开始时间 */
+    private LocalDateTime startTime;
+
+    /** 活动结束时间 */
+    private LocalDateTime endTime;
+
     /** 邀请人已获活动奖励次数 */
     private Long rewardedCount;
 

+ 9 - 1
zhijiayun-activity/src/main/java/com/xuekairui/activity/dto/ActivityLotteryStatusResponse.java

@@ -5,6 +5,8 @@ import lombok.Data;
 import lombok.NoArgsConstructor;
 import lombok.AllArgsConstructor;
 
+import java.time.LocalDateTime;
+
 /**
  * 比价抽奖活动状态响应(用户端)
  */
@@ -17,6 +19,12 @@ public class ActivityLotteryStatusResponse {
     private Boolean enabled;
     private Boolean inWindow;
 
+    /** 活动开始时间 */
+    private LocalDateTime startTime;
+
+    /** 活动结束时间 */
+    private LocalDateTime endTime;
+
     /** 用户是否具备参与资格(会员等级达标) */
     private Boolean eligible;
 
@@ -32,6 +40,6 @@ public class ActivityLotteryStatusResponse {
     /** 今日已使用抽奖机会数 */
     private Integer usedChances;
 
-    /** 状态原因说明(机会为0时告知原因,有机会时为null) */
+    /** 状态原因说明(机会为0时告知具体原因,有机会时为null) */
     private String reason;
 }

+ 57 - 0
zhijiayun-activity/src/main/java/com/xuekairui/activity/dto/CheckInPeriodStatsResponse.java

@@ -0,0 +1,57 @@
+package com.xuekairui.activity.dto;
+
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.AllArgsConstructor;
+
+import java.time.LocalDate;
+
+/**
+ * 周期统计响应(运营端)
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class CheckInPeriodStatsResponse {
+
+    /** 周期ID */
+    private Long periodId;
+
+    /** 周期名称 */
+    private String periodName;
+
+    /** 周期起始日 */
+    private LocalDate startDate;
+
+    /** 周期截止日 */
+    private LocalDate endDate;
+
+    /** 周期状态:PENDING/ACTIVE/CLOSED */
+    private String status;
+
+    /** 周期模式:FRI_THU/MON_SUN */
+    private String cycleMode;
+
+    /** 需签到天数 */
+    private Integer requiredDays;
+
+    /** 达标模式:CUMULATIVE/CONSECUTIVE */
+    private String rewardMode;
+
+    /** 奖励天数 */
+    private Integer rewardDays;
+
+    /** 周期内签到总人数(去重) */
+    private Long totalCheckinUsers;
+
+    /** 周期内达标人数 */
+    private Long reachedUsers;
+
+    /** 达标率(百分比,如 75.5 表示 75.5%) */
+    private Double reachRate;
+
+    /** 已发放奖励人数 */
+    private Long rewardedUsers;
+}

+ 13 - 0
zhijiayun-activity/src/main/java/com/xuekairui/activity/mapper/CheckInRecordMapper.java

@@ -62,4 +62,17 @@ public interface CheckInRecordMapper extends BaseMapper<CheckInRecord> {
      * 运营端:累计发放奖励次数总和
      */
     Long sumTotalReward();
+
+    /**
+     * 运营端:统计指定周期内的签到人数(去重)
+     */
+    Long countUsersByPeriod(@Param("startDate") LocalDate startDate, @Param("endDate") LocalDate endDate);
+
+    /**
+     * 运营端:统计指定周期内达标的用户数
+     */
+    Long countReachedUsersByPeriod(@Param("startDate") LocalDate startDate, 
+                                    @Param("endDate") LocalDate endDate,
+                                    @Param("requiredDays") Integer requiredDays,
+                                    @Param("rewardMode") String rewardMode);
 }

+ 30 - 10
zhijiayun-activity/src/main/java/com/xuekairui/activity/service/ActivityCheckinCycleService.java

@@ -27,10 +27,11 @@ import java.util.List;
  * 签到送高级会员活动服务(周期制)。
  * <p>
  * 规则:
- * 1. 活动时间窗 2026-07-10 ~ 2026-07-31,所有用户可参加;
- * 2. 每周五至下周四为一个周期,累计 4 天签到即获得 7 天高级会员;
- * 3. 周期结束后的第一天(即下一周期首日周五)发放奖励;
- * 4. 每用户最多领取 3 次。
+ * 1. 活动时间窗可配置,所有用户可参加;
+ * 2. 周期模式可配置:MON_SUN(周一至周日)或 FRI_THU(周五至下周四);
+ * 3. 周期内累计签到达标天数即获得奖励;
+ * 4. 周期结束后的第一天发放奖励;
+ * 5. 每用户最多领取指定次数。
  * <p>
  * 数据复用现有 t_checkin_record 签到记录统计周期内签到天数,不改动每日签到奖励逻辑。
  */
@@ -40,6 +41,8 @@ import java.util.List;
 public class ActivityCheckinCycleService {
 
     private static final Long CONFIG_ID = 1L;
+    /** 周期模式:周一至周日 */
+    private static final String CYCLE_MODE_MON_SUN = "MON_SUN";
     /** 周期模式:周五至下周四 */
     private static final String CYCLE_MODE_FRI_THU = "FRI_THU";
 
@@ -103,8 +106,9 @@ public class ActivityCheckinCycleService {
             return false;
         }
 
+        String cycleMode = config.getCycleMode();
         LocalDate today = LocalDate.now();
-        LocalDate currentCycleStart = cycleStartOf(today);
+        LocalDate currentCycleStart = cycleStartOf(today, cycleMode);
         LocalDate prevCycleStart = currentCycleStart.minusDays(7);
         LocalDate prevCycleEnd = prevCycleStart.plusDays(6);
 
@@ -185,7 +189,8 @@ public class ActivityCheckinCycleService {
         }
 
         LocalDate today = LocalDate.now();
-        LocalDate currentCycleStart = cycleStartOf(today);
+        String cycleMode = config.getCycleMode();
+        LocalDate currentCycleStart = cycleStartOf(today, cycleMode);
         LocalDate currentCycleEnd = currentCycleStart.plusDays(6);
 
         int currentCount = userId != null ? countCheckinInCycle(userId, currentCycleStart, currentCycleEnd) : 0;
@@ -198,6 +203,8 @@ public class ActivityCheckinCycleService {
         return ActivityCheckinCycleStatusResponse.builder()
                 .enabled(isActive(config))
                 .inWindow(inWindow(config))
+                .startTime(config.getStartTime())
+                .endTime(config.getEndTime())
                 .currentCycleStart(currentCycleStart)
                 .currentCycleEnd(currentCycleEnd)
                 .currentCycleCheckinCount(currentCount)
@@ -278,12 +285,25 @@ public class ActivityCheckinCycleService {
     }
 
     /**
-     * 计算日期所属周期的起始日(周五)。
-     * 周期为周五~下周四:以周五为起点,offset = (dayOfWeek - 5 + 7) % 7。
+     * 计算日期所属周期的起始日。
+     * 根据配置的周期模式计算:
+     * - MON_SUN:周一至周日,以周一为起点
+     * - FRI_THU:周五至下周四,以周五为起点
+     *
+     * @param date      目标日期
+     * @param cycleMode 周期模式(MON_SUN 或 FRI_THU)
+     * @return 周期起始日
      */
-    private LocalDate cycleStartOf(LocalDate date) {
+    private LocalDate cycleStartOf(LocalDate date, String cycleMode) {
         int dow = date.getDayOfWeek().getValue(); // MON=1 ... SUN=7
-        int offset = (dow - 5 + 7) % 7;
+        int offset;
+        if (CYCLE_MODE_MON_SUN.equalsIgnoreCase(cycleMode)) {
+            // 周一为起点:offset = (dow - 1 + 7) % 7
+            offset = (dow - 1 + 7) % 7;
+        } else {
+            // 默认周五为起点:offset = (dow - 5 + 7) % 7
+            offset = (dow - 5 + 7) % 7;
+        }
         return date.minusDays(offset);
     }
 

+ 2 - 0
zhijiayun-activity/src/main/java/com/xuekairui/activity/service/ActivityInviteService.java

@@ -270,6 +270,8 @@ public class ActivityInviteService implements ActivityInviteGate {
         return ActivityInviteStatusResponse.builder()
                 .enabled(isActive(config))
                 .inWindow(inWindow(config))
+                .startTime(config.getStartTime())
+                .endTime(config.getEndTime())
                 .rewardedCount(rewarded)
                 .maxInviterReward(maxReward)
                 .remainingCount(remaining)

+ 2 - 0
zhijiayun-activity/src/main/java/com/xuekairui/activity/service/ActivityLotteryService.java

@@ -289,6 +289,8 @@ public class ActivityLotteryService {
         return ActivityLotteryStatusResponse.builder()
                 .enabled(isActive(config))
                 .inWindow(inWindow(config))
+                .startTime(config.getStartTime())
+                .endTime(config.getEndTime())
                 .eligible(eligible)
                 .membershipLevel(userLevel != null ? userLevel.getCode() : null)
                 .requireMembershipLevel(requireLevel)

+ 108 - 1
zhijiayun-activity/src/main/java/com/xuekairui/activity/service/CheckInConfigService.java

@@ -56,11 +56,16 @@ public class CheckInConfigService {
     }
 
     /**
-     * 更新签到全局配置
+     * 更新签到全局配置(自动根据活动时间和周期模式生成周期)
      */
     @Transactional
     public CheckInConfig updateConfig(CheckInConfigRequest request) {
         CheckInConfig config = getConfig();
+        
+        boolean timeChanged = !java.util.Objects.equals(config.getStartTime(), request.getStartTime())
+                || !java.util.Objects.equals(config.getEndTime(), request.getEndTime());
+        boolean modeChanged = request.getCycleMode() != null && !request.getCycleMode().equals(config.getCycleMode());
+        
         config.setEnabled(request.getEnabled());
         config.setStartTime(request.getStartTime());
         config.setEndTime(request.getEndTime());
@@ -72,10 +77,112 @@ public class CheckInConfigService {
         if (request.getMaxRewardCount() != null) config.setMaxRewardCount(request.getMaxRewardCount());
         if (request.getRemark() != null) config.setRemark(request.getRemark());
         checkinConfigMapper.updateById(config);
+        
+        if (timeChanged || modeChanged) {
+            regeneratePeriods(config);
+        }
+        
         log.info("签到全局配置已更新: enabled={}, cycleMode={}, requiredDays={}, rewardDays={}",
                 config.getEnabled(), config.getCycleMode(), config.getRequiredDays(), config.getRewardDays());
         return config;
     }
+    
+    /**
+     * 根据活动时间和周期模式重新生成周期
+     * 规则:删除所有PENDING周期,保留ACTIVE/CLOSED周期(已有用户签到数据)
+     */
+    @Transactional
+    public void regeneratePeriods(CheckInConfig config) {
+        if (config.getStartTime() == null || config.getEndTime() == null) {
+            log.warn("活动时间未设置,跳过周期生成");
+            return;
+        }
+        
+        com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<CheckInPeriod> deleteWrapper =
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<>();
+        deleteWrapper.eq(CheckInPeriod::getStatus, "PENDING");
+        int deleted = periodMapper.delete(deleteWrapper);
+        log.info("已删除{}个PENDING周期", deleted);
+        
+        LocalDate activityStart = config.getStartTime().toLocalDate();
+        LocalDate activityEnd = config.getEndTime().toLocalDate();
+        String cycleMode = config.getCycleMode() != null ? config.getCycleMode() : "FRI_THU";
+        
+        generatePeriodsInRange(activityStart, activityEnd, cycleMode, config);
+    }
+    
+    /**
+     * 在指定时间范围内生成周期
+     */
+    private void generatePeriodsInRange(LocalDate start, LocalDate end, String cycleMode, CheckInConfig config) {
+        LocalDate current = start;
+        int periodIndex = 1;
+        
+        while (!current.isAfter(end)) {
+            LocalDate periodStart;
+            LocalDate periodEnd;
+            
+            if ("FRI_THU".equalsIgnoreCase(cycleMode)) {
+                periodStart = getNextFriday(current);
+                periodEnd = periodStart.plusDays(6);
+            } else {
+                periodStart = getNextMonday(current);
+                periodEnd = periodStart.plusDays(6);
+            }
+            
+            if (periodStart.isAfter(end)) {
+                break;
+            }
+            
+            if (periodEnd.isAfter(end)) {
+                periodEnd = end;
+            }
+            
+            CheckInPeriod period = CheckInPeriod.builder()
+                    .periodName("第" + periodIndex + "周期(" + periodStart.getMonthValue() + "/" + periodStart.getDayOfMonth() 
+                            + "~" + periodEnd.getMonthValue() + "/" + periodEnd.getDayOfMonth() + ")")
+                    .cycleMode(cycleMode)
+                    .startDate(periodStart)
+                    .endDate(periodEnd)
+                    .requiredDays(config.getRequiredDays())
+                    .rewardMode(config.getRewardMode() != null ? config.getRewardMode() : "CUMULATIVE")
+                    .rewardDays(config.getRewardDays())
+                    .rewardLevel(config.getRewardLevel() != null ? config.getRewardLevel() : "PRO")
+                    .status(determineStatus(periodStart, periodEnd))
+                    .sort(periodIndex)
+                    .build();
+            
+            periodMapper.insert(period);
+            log.info("自动生成周期: {} {}~{}", period.getPeriodName(), periodStart, periodEnd);
+            
+            current = periodEnd.plusDays(1);
+            periodIndex++;
+        }
+    }
+    
+    /**
+     * 获取下一个周五
+     */
+    private LocalDate getNextFriday(LocalDate date) {
+        int dayOfWeek = date.getDayOfWeek().getValue();
+        int daysUntilFriday = (5 - dayOfWeek + 7) % 7;
+        if (daysUntilFriday == 0 && date.getDayOfWeek().getValue() != 5) {
+            daysUntilFriday = 7;
+        }
+        return date.plusDays(daysUntilFriday == 0 ? 0 : daysUntilFriday);
+    }
+    
+    /**
+     * 获取下一个周一
+     */
+    private LocalDate getNextMonday(LocalDate date) {
+        int dayOfWeek = date.getDayOfWeek().getValue();
+        int daysUntilMonday = (1 - dayOfWeek + 7) % 7;
+        if (daysUntilMonday == 0 && date.getDayOfWeek().getValue() != 1) {
+            daysUntilMonday = 7;
+        }
+        return date.plusDays(daysUntilMonday == 0 ? 0 : daysUntilMonday);
+    }
 
     /**
      * 校验签到功能是否开启

+ 40 - 0
zhijiayun-activity/src/main/java/com/xuekairui/activity/service/CheckInService.java

@@ -5,6 +5,7 @@ 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;
@@ -225,6 +226,45 @@ public class CheckInService {
                 .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 = cycleRecordMapper.selectCount(
+                    new LambdaQueryWrapper<ActivityCheckinCycleRecord>()
+                            .eq(ActivityCheckinCycleRecord::getCycleStart, period.getStartDate())
+                            .eq(ActivityCheckinCycleRecord::getRewardGranted, 1));
+            
+            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不能为空");

+ 20 - 0
zhijiayun-activity/src/main/resources/mapper/CheckInRecordMapper.xml

@@ -66,4 +66,24 @@
         SELECT COALESCE(SUM(reward_amount), 0) FROM t_checkin_record
     </select>
 
+    <!-- 运营端:统计指定周期内的签到人数(去重) -->
+    <select id="countUsersByPeriod" resultType="java.lang.Long">
+        SELECT COUNT(DISTINCT user_id)
+        FROM t_checkin_record
+        WHERE checkin_date &gt;= #{startDate}
+          AND checkin_date &lt;= #{endDate}
+    </select>
+
+    <!-- 运营端:统计指定周期内达标的用户数(累计模式) -->
+    <select id="countReachedUsersByPeriod" resultType="java.lang.Long">
+        SELECT COUNT(*) FROM (
+            SELECT user_id, COUNT(*) as checkin_count
+            FROM t_checkin_record
+            WHERE checkin_date &gt;= #{startDate}
+              AND checkin_date &lt;= #{endDate}
+            GROUP BY user_id
+            HAVING checkin_count &gt;= #{requiredDays}
+        ) t
+    </select>
+
 </mapper>

+ 2 - 2
zhijiayun-invite/src/main/java/com/xuekairui/invite/controller/AdminInviteController.java

@@ -187,7 +187,7 @@ public class AdminInviteController {
     public Result<InviteConversionStatsResponse> getInviteConversionStats(
             @RequestParam(required = false) Long userId,
             @RequestParam(required = false) String phone) {
-        return Result.success(inviteService.getInviteConversionStats(userId, phone));
+        return Result.success(inviteService.getInviteConversionStats(userId, phone, null));
     }
 
-}
+}

+ 9 - 3
zhijiayun-invite/src/main/java/com/xuekairui/invite/controller/InviteController.java

@@ -6,6 +6,7 @@ import com.xuekairui.invite.service.InviteService;
 import jakarta.servlet.http.HttpServletRequest;
 import jakarta.servlet.http.HttpServletResponse;
 import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
 import org.springframework.web.bind.annotation.*;
 
 import java.util.List;
@@ -14,6 +15,7 @@ import java.util.Map;
 /**
  * 邀请接口(用户端)
  */
+@Slf4j
 @RestController
 @RequestMapping("/api/invite")
 @RequiredArgsConstructor
@@ -28,7 +30,9 @@ public class InviteController {
     @GetMapping("/code")
     public Result<InviteCodeResponse> getMyInviteCode(HttpServletRequest request) {
         Long userId = (Long) request.getAttribute("userId");
-        return Result.success(inviteService.getOrCreateInviteCode(userId));
+        String loginSource = (String) request.getAttribute("loginSource");
+        log.info("[邀请码] userId={}, loginSource={}", userId, loginSource);
+        return Result.success(inviteService.getOrCreateInviteCode(userId, loginSource));
     }
 
     /**
@@ -38,7 +42,9 @@ public class InviteController {
     @GetMapping("/stats")
     public Result<InviteStatsResponse> getInviteStats(HttpServletRequest request) {
         Long userId = (Long) request.getAttribute("userId");
-        return Result.success(inviteService.getInviteStats(userId));
+        String loginSource = (String) request.getAttribute("loginSource");
+        log.info("[邀请统计] userId={}, loginSource={}", userId, loginSource);
+        return Result.success(inviteService.getInviteStats(userId, loginSource));
     }
 
     /**
@@ -136,4 +142,4 @@ public class InviteController {
     public Result<InviteLinkResolveResponse> resolveInviteLink(@RequestParam String link) {
         return Result.success(inviteService.resolveInviteLink(link));
     }
-}
+}

+ 4 - 1
zhijiayun-invite/src/main/java/com/xuekairui/invite/dto/InvitePageResponse.java

@@ -44,6 +44,9 @@ public class InvitePageResponse {
     /** 小程序路径(channel=miniapp时使用,如 /pages/download/index?code=ABC123) */
     private String miniappPath;
 
+    /** 小程序 mp:// 链接(channel=miniapp时使用,用于微信聊天窗口内跳转) */
+    private String mpLink;
+
     /** 小程序AppID(channel=miniapp时使用) */
     private String miniappAppId;
 
@@ -72,4 +75,4 @@ public class InvitePageResponse {
 
     /** 提示文案(引导用户如何操作) */
     private String instructionText;
-}
+}

+ 56 - 10
zhijiayun-invite/src/main/java/com/xuekairui/invite/service/InviteService.java

@@ -51,6 +51,7 @@ public class InviteService {
     private final CrawlerUsageLogMapper crawlerUsageLogMapper;
     private final com.xuekairui.user.service.CrawlerService crawlerService;
     private final com.xuekairui.user.service.BusinessLicenseService businessLicenseService;
+    private final com.xuekairui.user.service.WechatMiniProgramService wechatMiniProgramService;
 
     /** 活动模块可选依赖:未加载时跳过活动相关发奖 */
     @Autowired(required = false)
@@ -83,7 +84,7 @@ public class InviteService {
     /**
      * 获取或创建用户的邀请码
      */
-    public InviteCodeResponse getOrCreateInviteCode(Long userId) {
+    public InviteCodeResponse getOrCreateInviteCode(Long userId, String loginSource) {
         // 查找用户已有的有效邀请码
         InviteCode existingCode = inviteCodeMapper.selectOne(
                 new LambdaQueryWrapper<InviteCode>()
@@ -95,7 +96,7 @@ public class InviteService {
                         .last("LIMIT 1"));
 
         if (existingCode != null) {
-            return toCodeResponse(existingCode);
+            return toCodeResponse(existingCode, loginSource);
         }
 
         // 创建新邀请码(uk_code 唯一索引兜底防止并发碰撞)
@@ -116,7 +117,7 @@ public class InviteService {
             try {
                 inviteCodeMapper.insert(newCode);
                 log.info("用户 {} 创建邀请码: {}", userId, code);
-                return toCodeResponse(newCode);
+                return toCodeResponse(newCode, loginSource);
             } catch (org.springframework.dao.DuplicateKeyException e) {
                 log.warn("邀请码并发碰撞(attempt={}): {}", attempt + 1, code);
                 if (attempt == maxInsertRetries - 1) {
@@ -140,6 +141,7 @@ public class InviteService {
      * @param channel    渠道(可选,默认app)
      */
     public InvitePageResponse getInvitePage(String code, String channel) {
+        log.info("[getInvitePage] code={}, channel={}", code, channel);
         InviteCode inviteCode = inviteCodeMapper.selectOne(
                 new LambdaQueryWrapper<InviteCode>()
                         .eq(InviteCode::getCode, code));
@@ -171,7 +173,21 @@ public class InviteService {
 
         // 构建小程序路径(含邀请码参数)
         String miniappPath = config.getMiniappPath();
-        if (miniappPath != null && !miniappPath.contains("?")) {
+        String mpLink = null;
+        if ("miniapp".equals(channel) && miniappPath != null && !miniappPath.isBlank()) {
+            String query = "inviteCode=" + code;
+            mpLink = wechatMiniProgramService.generateMpLink(miniappPath, query);
+            if (mpLink != null) {
+                log.info("[getInvitePage] 落地页生成小程序 mp:// 链接成功: code={}, mpLink={}", code, mpLink);
+            } else {
+                if (!miniappPath.contains("?")) {
+                    miniappPath = miniappPath + "?inviteCode=" + code;
+                } else {
+                    miniappPath = miniappPath + "&inviteCode=" + code;
+                }
+                log.warn("[getInvitePage] 落地页生成小程序 mp:// 链接失败,降级为普通路径: code={}", code);
+            }
+        } else if (miniappPath != null && !miniappPath.contains("?")) {
             miniappPath = miniappPath + "?inviteCode=" + code;
         } else if (miniappPath != null) {
             miniappPath = miniappPath + "&inviteCode=" + code;
@@ -225,6 +241,7 @@ public class InviteService {
                 // 渠道特定字段
                 .appDownloadUrl(appDownloadUrl)
                 .miniappPath(miniappPath)
+                .mpLink(mpLink)
                 .miniappAppId(config.getMiniappAppId())
                 .wechatRedirectUrl(config.getWechatRedirectUrl())
                 .dingtalkAppId(config.getDingtalkAppId())
@@ -718,11 +735,11 @@ public class InviteService {
      * 获取用户邀请统计
      * 返回如:"已邀请5人,3人注册 +21次"
      */
-    public InviteStatsResponse getInviteStats(Long userId) {
+    public InviteStatsResponse getInviteStats(Long userId, String loginSource) {
         InviteConfig config = inviteConfigService.getActiveConfig();
 
         // 获取用户邀请码
-        InviteCodeResponse codeResponse = getOrCreateInviteCode(userId);
+        InviteCodeResponse codeResponse = getOrCreateInviteCode(userId, loginSource);
 
         // 所有邀请记录
         List<InviteRelation> allRelations = inviteRelationMapper.selectList(
@@ -993,15 +1010,44 @@ public class InviteService {
     /**
      * 实体转响应(含邀请链接,文案从配置读取)
      */
-    private InviteCodeResponse toCodeResponse(InviteCode code) {
+    private InviteCodeResponse toCodeResponse(InviteCode code, String loginSource) {
+        log.info("[toCodeResponse] code={}, loginSource={}, userId={}", code.getCode(), loginSource, code.getUserId());
         // 获取配置与邀请人信息
         InviteConfig config = inviteConfigService.getActiveConfig();
         User inviter = userMapper.selectById(code.getUserId());
         String inviterNickname = getDisplayName(inviter);
-        String inviteLink = inviteBaseUrl + "/api/invite/" + code.getCode();
         String appName = config.getAppName() != null ? config.getAppName() : "智价云(药店版)";
         int rewardMonths = config.getRewardMonths() != null ? config.getRewardMonths() : 1;
 
+        // 根据登录来源生成不同的邀请链接
+        String inviteLink;
+        if ("MINIAPP".equals(loginSource)) {
+            // 小程序来源:调用微信 API 生成 mp:// 链接
+            String miniappPath = config.getMiniappPath();
+            if (miniappPath != null && !miniappPath.isBlank()) {
+                // 构建查询参数(带邀请码)
+                String query = "inviteCode=" + code.getCode();
+                // 调用微信 API 生成 mp:// 链接
+                String mpLink = wechatMiniProgramService.generateMpLink(miniappPath, query);
+                if (mpLink != null) {
+                    inviteLink = mpLink;
+                    log.info("[toCodeResponse] 生成小程序 mp:// 链接成功: code={}, mpLink={}", code.getCode(), inviteLink);
+                } else {
+                    // 生成失败,降级为普通链接
+                    inviteLink = inviteBaseUrl + "/api/invite/" + code.getCode();
+                    log.warn("[toCodeResponse] 生成小程序 mp:// 链接失败,降级为普通链接: code={}", code.getCode());
+                }
+            } else {
+                // 配置不完整,降级为普通链接
+                inviteLink = inviteBaseUrl + "/api/invite/" + code.getCode();
+                log.warn("[toCodeResponse] miniappPath 配置为空,降级为普通链接: code={}", code.getCode());
+            }
+        } else {
+            // 其他来源:使用下载链接
+            inviteLink = inviteBaseUrl + "/api/invite/" + code.getCode();
+            log.info("[toCodeResponse] 非小程序来源,生成普通链接: code={}, loginSource={}, link={}", code.getCode(), loginSource, inviteLink);
+        }
+
         // 分享标题:从配置模板读取,支持占位符 {inviter}、{appName}
         String shareTitleTemplate = config.getShareTitleTemplate();
         String shareTitle = (shareTitleTemplate != null && !shareTitleTemplate.isBlank())
@@ -1144,7 +1190,7 @@ public class InviteService {
     /**
      * 获取用户邀请转化统计(点击-注册转化率)
      */
-    public InviteConversionStatsResponse getInviteConversionStats(Long userId, String phone) {
+    public InviteConversionStatsResponse getInviteConversionStats(Long userId, String phone, String loginSource) {
         if (userId == null && phone != null && !phone.isBlank()) {
             User u = userMapper.selectOne(
                     new LambdaQueryWrapper<User>().eq(User::getPhone, phone));
@@ -1157,7 +1203,7 @@ public class InviteService {
             throw new BusinessException(ErrorCode.PARAM_ERROR, "用户ID和手机号至少填写一个");
         }
         // 获取用户邀请码
-        InviteCodeResponse codeResponse = getOrCreateInviteCode(userId);
+        InviteCodeResponse codeResponse = getOrCreateInviteCode(userId, loginSource);
 
         // 获取邀请码实体
         InviteCode myCode = inviteCodeMapper.selectOne(

+ 2 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/security/JwtAuthenticationFilter.java

@@ -126,6 +126,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
 
                         // 设置请求属性,供控制器使用
                         request.setAttribute("role", role);
+                        request.setAttribute("loginSource", loginSource);
                     } else if (isHeartbeat) {
                         log.warn("[heartbeat] 401原因: token类型不是access,实际type={},请用accessToken调用heartbeat", tokenType);
                     }
@@ -261,6 +262,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
                 if (loginSource != null) {
                     request.setAttribute("loginSource", loginSource);
                 }
+                log.info("[JWT解析] uri={}, userId={}, loginSource={}", request.getRequestURI(), uid, loginSource);
             }
         } catch (Exception ignored) {
             // token 完全无法解析,忽略

+ 57 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/service/WechatMiniProgramService.java

@@ -47,6 +47,7 @@ public class WechatMiniProgramService {
     private static final String JSCODE2SESSION_URL = "https://api.weixin.qq.com/sns/jscode2session";
     private static final String ACCESS_TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token";
     private static final String PHONE_NUMBER_URL = "https://api.weixin.qq.com/wxa/business/getuserphonenumber";
+    private static final String SHORT_LINK_URL = "https://api.weixin.qq.com/wxa/genwxashortlink";
 
     /** Redis 缓存 key */
     private static final String MINIAPP_ACCESS_TOKEN_KEY = "wechat:miniapp:access_token";
@@ -171,6 +172,62 @@ public class WechatMiniProgramService {
         }
     }
 
+    /**
+     * 生成小程序 Short Link 并转换为 mp:// 格式
+     * <p>
+     * 调用微信 genwxashortlink 接口生成短链,格式为 #小程序://小程序名称/页面路径/随机字符,
+     * 提取最后的随机字符并拼接为 mp://随机字符 格式,适用于微信聊天窗口内跳转小程序。
+     *
+     * @param pagePath 小程序页面路径(如 pages/index/index)
+     * @param query    页面参数(如 inviteCode=ABC123)
+     * @return mp:// 格式的短链接
+     */
+    public String generateMpLink(String pagePath, String query) {
+        String accessToken = getAccessToken();
+        String url = SHORT_LINK_URL + "?access_token=" + accessToken;
+
+        JSONObject body = new JSONObject();
+        body.set("page_path", pagePath);
+        if (query != null && !query.isBlank()) {
+            body.set("query", query);
+        }
+        body.set("is_persistent", false);
+
+        try {
+            String response = HttpUtil.post(url, body.toString(), 5000);
+            JSONObject json = JSONUtil.parseObj(response);
+
+            int errcode = json.getInt("errcode", 0);
+            if (errcode != 0) {
+                log.error("生成小程序短链接失败: errcode={}, errmsg={}", errcode, json.getStr("errmsg"));
+                return null;
+            }
+
+            String shortLink = json.getStr("link");
+            log.info("生成小程序短链接成功: {}", shortLink);
+
+            return convertToMpLink(shortLink);
+        } catch (Exception e) {
+            log.error("调用短链接接口异常", e);
+            return null;
+        }
+    }
+
+    /**
+     * 将微信短链 #小程序://xxx/xxx/随机字符 转换为 mp://随机字符 格式
+     */
+    private String convertToMpLink(String shortLink) {
+        if (shortLink == null || shortLink.isBlank()) {
+            return null;
+        }
+        int lastSlash = shortLink.lastIndexOf('/');
+        if (lastSlash < 0 || lastSlash >= shortLink.length() - 1) {
+            return null;
+        }
+        String token = shortLink.substring(lastSlash + 1);
+        return "mp://" + token;
+    }
+
     /**
      * 小程序会话信息
      */