Переглянути джерело

第三方接口调整提交代码

liuchengsen 1 місяць тому
батько
коміт
3ea79930d7
24 змінених файлів з 503 додано та 256 видалено
  1. 16 0
      zhijiayun-activity/src/main/java/com/xuekairui/activity/controller/AdminActivityController.java
  2. 1 4
      zhijiayun-activity/src/main/java/com/xuekairui/activity/dto/ActivityInviteConfigRequest.java
  3. 2 5
      zhijiayun-activity/src/main/java/com/xuekairui/activity/entity/ActivityInviteConfig.java
  4. 25 53
      zhijiayun-activity/src/main/java/com/xuekairui/activity/service/ActivityInviteService.java
  5. 72 1
      zhijiayun-activity/src/main/java/com/xuekairui/activity/service/ActivityLotteryService.java
  6. 15 17
      zhijiayun-common/src/main/java/com/xuekairui/common/activity/ActivityInviteGate.java
  7. 19 0
      zhijiayun-gateway/src/main/resources/db/migration-v21.sql
  8. 19 44
      zhijiayun-gateway/src/test/java/com/xuekairui/gateway/AdminControllerTest.java
  9. 8 4
      zhijiayun-gateway/src/test/java/com/xuekairui/gateway/AdminLicenseTest.java
  10. 4 2
      zhijiayun-gateway/src/test/java/com/xuekairui/gateway/EndToEndBusinessFlowTest.java
  11. 29 4
      zhijiayun-invite/src/main/java/com/xuekairui/invite/controller/AdminInviteController.java
  12. 2 23
      zhijiayun-invite/src/main/java/com/xuekairui/invite/dto/InviteConfigRequest.java
  13. 19 10
      zhijiayun-invite/src/main/java/com/xuekairui/invite/dto/InviteConversionStatsResponse.java
  14. 2 27
      zhijiayun-invite/src/main/java/com/xuekairui/invite/entity/InviteConfig.java
  15. 30 0
      zhijiayun-invite/src/main/java/com/xuekairui/invite/mapper/InviteRelationMapper.java
  16. 13 30
      zhijiayun-invite/src/main/java/com/xuekairui/invite/service/InviteConfigService.java
  17. 151 19
      zhijiayun-invite/src/main/java/com/xuekairui/invite/service/InviteService.java
  18. 6 3
      zhijiayun-user/src/main/java/com/xuekairui/user/controller/AdminLicenseController.java
  19. 6 3
      zhijiayun-user/src/main/java/com/xuekairui/user/mapper/BusinessLicenseMapper.java
  20. 9 4
      zhijiayun-user/src/main/java/com/xuekairui/user/mapper/OperationAuditLogMapper.java
  21. 10 0
      zhijiayun-user/src/main/java/com/xuekairui/user/service/AuthService.java
  22. 7 3
      zhijiayun-user/src/main/java/com/xuekairui/user/service/BusinessLicenseService.java
  23. 27 0
      zhijiayun-user/src/main/java/com/xuekairui/user/service/MembershipService.java
  24. 11 0
      zhijiayun-user/src/main/java/com/xuekairui/user/service/OperationAuditLogService.java

+ 16 - 0
zhijiayun-activity/src/main/java/com/xuekairui/activity/controller/AdminActivityController.java

@@ -16,6 +16,8 @@ import com.xuekairui.activity.service.ActivityLotteryService;
 import com.xuekairui.activity.service.CheckInConfigService;
 import com.xuekairui.activity.service.CheckInService;
 import com.xuekairui.common.Result;
+import com.xuekairui.user.entity.OperationAuditLog;
+import com.xuekairui.user.service.OperationAuditLogService;
 import jakarta.servlet.http.HttpServletRequest;
 import jakarta.validation.Valid;
 import jakarta.validation.constraints.NotBlank;
@@ -51,6 +53,7 @@ public class AdminActivityController {
     private final CheckInConfigService checkInConfigService;
     private final CheckInService checkInService;
     private final ActivityLotteryService activityLotteryService;
+    private final OperationAuditLogService auditLogService;
 
     // ========== 邀请有礼配置 ==========
 
@@ -194,6 +197,19 @@ public class AdminActivityController {
         private String reason;
     }
 
+    // ========== 抽奖配置历史 ==========
+
+    /**
+     * 查询抽奖活动配置 + 奖品池变更历史
+     * GET /api/admin/activity/lottery/history?page=1&size=20
+     */
+    @GetMapping("/lottery/history")
+    public Result<Page<OperationAuditLog>> listLotteryHistory(
+            @RequestParam(defaultValue = "1") int page,
+            @RequestParam(defaultValue = "20") int size) {
+        return Result.success(auditLogService.listByTargetPrefix("LOTTERY", page, size));
+    }
+
     @Data
     public static class RetroactiveGrantRequest {
         @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)

+ 1 - 4
zhijiayun-activity/src/main/java/com/xuekairui/activity/dto/ActivityInviteConfigRequest.java

@@ -25,10 +25,7 @@ public class ActivityInviteConfigRequest {
 
     private Integer inviterRewardDays;
     private Integer maxInviterReward;
+    private Integer maxTotalInvites;
 
-    /** REGISTRATION / LICENSE_APPROVED */
-    private String rewardTrigger;
-
-    private Integer requireMiniappInviter;
     private String remark;
 }

+ 2 - 5
zhijiayun-activity/src/main/java/com/xuekairui/activity/entity/ActivityInviteConfig.java

@@ -33,12 +33,9 @@ public class ActivityInviteConfig {
     @Builder.Default
     private Integer maxInviterReward = 30;
 
-    /** 奖励触发模式:REGISTRATION / LICENSE_APPROVED */
+    /** 每个邀请人总邀请上限(与 maxInviterReward 协同控制) */
     @Builder.Default
-    private String rewardTrigger = "LICENSE_APPROVED";
-
-    @Builder.Default
-    private Integer requireMiniappInviter = 0;
+    private Integer maxTotalInvites = 30;
 
     private String remark;
 

+ 25 - 53
zhijiayun-activity/src/main/java/com/xuekairui/activity/service/ActivityInviteService.java

@@ -38,28 +38,26 @@ import java.util.Optional;
 /**
  * 邀请有礼活动服务(统一管理所有邀请奖励发放)。
  * <p>
- * 通过实现 {@link ActivityInviteGate} 接管 InviteService 的奖励发放,
- * 根据活动配置决定发放策略:
+ * 发奖规则(有且仅有一条):
+ * <ol>
+ *   <li>被邀请人注册且入驻审核通过 → 发放 1 次 PRO 高级会员(默认30天)给邀请人</li>
+ *   <li>同一被邀请人全局仅发放 1 次(t_activity_invite_reward_record.uk_invitee 唯一索引)</li>
+ *   <li>同一邀请人活动期内最多获 maxInviterReward 次奖励(默认30次)</li>
+ *   <li>活动未开启或不在时间窗内 → 不发放</li>
+ * </ol>
+ * <p>
+ * 触发路径:
  * <ul>
- *   <li>活动未开启 → 按原逻辑发放 30 天高级会员(无活动限制)</li>
- *   <li>活动开启 + REGISTRATION 模式 → 立即发放 30 天高级会员(受活动时间窗、30 次上限约束)</li>
- *   <li>活动开启 + LICENSE_APPROVED 模式 → 暂不发放,等待被邀请人入驻审核通过后由事件触发</li>
+ *   <li>注册时已入驻 → {@link #grantRewardForApprovedUser}(同步)</li>
+ *   <li>入驻审核通过 → {@link #onLicenseApproved}(Redis 事件异步)</li>
+ *   <li>运营补发 → {@link #retroactiveGrant}(手动触发)</li>
  * </ul>
- * <p>
- * 规则要点:
- * 1. 活动时间窗内有效;
- * 2. LICENSE_APPROVED 模式:被邀请人需上传入驻信息且审核通过后才发放;
- * 3. 邀请人最多获得 30 次邀请奖励(活动期间内);
- * 4. 每个被邀请人只触发一次邀请人奖励(DB 唯一索引兜底)。
  */
 @Slf4j
 @Service
 @RequiredArgsConstructor
 public class ActivityInviteService implements ActivityInviteGate {
 
-    /** 奖励触发模式:入驻审核通过后发 */
-    public static final String TRIGGER_LICENSE_APPROVED = "LICENSE_APPROVED";
-
     private static final Long CONFIG_ID = 1L;
 
     private final ActivityInviteConfigMapper configMapper;
@@ -74,38 +72,20 @@ public class ActivityInviteService implements ActivityInviteGate {
     // ActivityInviteGate 门面实现(供 InviteService 调用)
     // ==========================================
 
-    /**
-     * 发放邀请奖励(统一入口,根据 rewardTrigger 配置决定策略)。
-     * <p>
-     * LICENSE_APPROVED:不发放,等待入驻审核通过后触发。
-     * REGISTRATION:立即发放,受活动时间窗和 30 次上限约束。
-     */
     @Override
-    public void grantInviteReward(Long inviterId, Long inviteeId) {
+    public int getInviterRewardDays() {
         ActivityInviteConfig config = getConfig();
+        return config.getInviterRewardDays() != null ? config.getInviterRewardDays() : 30;
+    }
 
-        // 活动未开启 → 不发放
-        if (!isActive(config)) {
-            log.info("邀请有礼活动未开启,跳过发奖: inviter={}, invitee={}", inviterId, inviteeId);
-            return;
-        }
-
-        // LICENSE_APPROVED 模式 → 不发放,等待入驻审核通过
-        if (TRIGGER_LICENSE_APPROVED.equalsIgnoreCase(config.getRewardTrigger())) {
-            log.info("邀请有礼活动(LICENSE_APPROVED模式),注册环节暂不发奖: inviter={}, invitee={}", inviterId, inviteeId);
-            return;
-        }
-
-        // REGISTRATION 模式 → 校验被邀请人入驻审核通过后发放
-        if (!isInviteeLicenseApproved(inviteeId)) {
-            log.info("被邀请人入驻未审核通过,跳过发奖(等待入驻审核通过后触发): inviter={}, invitee={}", inviterId, inviteeId);
-            return;
-        }
-        grantWithLimit(inviterId, inviteeId, config, "REGISTRATION");
+    @Override
+    public int getMaxTotalInvites() {
+        ActivityInviteConfig config = getConfig();
+        return config.getMaxTotalInvites() != null ? config.getMaxTotalInvites() : 30;
     }
 
     /**
-     * 已入驻用户补填邀请码时发放奖励(依赖活动配置)。
+     * 发放邀请奖励(统一入口,始终在被邀请人入驻审核通过后触发)。
      * <p>
      * 场景:被邀请人已通过入驻审核,后补填邀请码 → 邀请人应获得奖励。
      * 活动未开启则不发放。幂等由 uk_invitee 唯一索引保障。
@@ -214,8 +194,7 @@ public class ActivityInviteService implements ActivityInviteGate {
                     .id(CONFIG_ID).enabled(0)
                     .startTime(LocalDateTime.now())
                     .endTime(LocalDateTime.now())
-                    .inviterRewardDays(30).maxInviterReward(30)
-                    .rewardTrigger(TRIGGER_LICENSE_APPROVED).requireMiniappInviter(0)
+                    .inviterRewardDays(30).maxInviterReward(30).maxTotalInvites(30)
                     .build();
         }
         return config;
@@ -231,8 +210,7 @@ public class ActivityInviteService implements ActivityInviteGate {
         config.setEndTime(request.getEndTime());
         Optional.ofNullable(request.getInviterRewardDays()).ifPresent(config::setInviterRewardDays);
         Optional.ofNullable(request.getMaxInviterReward()).ifPresent(config::setMaxInviterReward);
-        Optional.ofNullable(request.getRewardTrigger()).ifPresent(config::setRewardTrigger);
-        Optional.ofNullable(request.getRequireMiniappInviter()).ifPresent(config::setRequireMiniappInviter);
+        Optional.ofNullable(request.getMaxTotalInvites()).ifPresent(config::setMaxTotalInvites);
         Optional.ofNullable(request.getRemark()).ifPresent(config::setRemark);
         if (config.getId() == null) {
             config.setId(CONFIG_ID);
@@ -248,8 +226,8 @@ public class ActivityInviteService implements ActivityInviteGate {
                 beforeData, afterData,
                 "邀请有礼活动配置变更",
                 OperationSource.ADMIN_CREATE);
-        log.info("邀请有礼活动配置已更新: enabled={}, trigger={}, start={}, end={}",
-                config.getEnabled(), config.getRewardTrigger(), config.getStartTime(), config.getEndTime());
+        log.info("邀请有礼活动配置已更新: enabled={}, start={}, end={}",
+                config.getEnabled(), config.getStartTime(), config.getEndTime());
         return config;
     }
 
@@ -260,8 +238,7 @@ public class ActivityInviteService implements ActivityInviteGate {
         s.put("endTime", c.getEndTime());
         s.put("inviterRewardDays", c.getInviterRewardDays());
         s.put("maxInviterReward", c.getMaxInviterReward());
-        s.put("rewardTrigger", c.getRewardTrigger());
-        s.put("requireMiniappInviter", c.getRequireMiniappInviter());
+        s.put("maxTotalInvites", c.getMaxTotalInvites());
         s.put("remark", c.getRemark());
         return s;
     }
@@ -381,11 +358,6 @@ public class ActivityInviteService implements ActivityInviteGate {
                 .toList();
     }
 
-    /** 无 storeNameMap 的重载(兼容旧调用) */
-    private List<ActivityRewardRecordResponse> toRewardResponseList(List<ActivityInviteRewardRecord> records) {
-        return toRewardResponseList(records, Map.of());
-    }
-
     // ==========================================
     // 私有辅助
     // ==========================================

+ 72 - 1
zhijiayun-activity/src/main/java/com/xuekairui/activity/service/ActivityLotteryService.java

@@ -26,6 +26,7 @@ import com.xuekairui.user.entity.User;
 import com.xuekairui.user.enums.AuditResult;
 import com.xuekairui.user.enums.LicenseStatus;
 import com.xuekairui.user.enums.MembershipLevel;
+import com.xuekairui.user.enums.OperationSource;
 import com.xuekairui.user.enums.OperationType;
 import com.xuekairui.user.enums.OperatorRole;
 import com.xuekairui.user.mapper.BusinessLicenseMapper;
@@ -41,6 +42,7 @@ import org.springframework.transaction.annotation.Transactional;
 import java.time.LocalDate;
 import java.time.LocalDateTime;
 import java.util.List;
+import java.util.Map;
 import java.util.Optional;
 import java.util.concurrent.ThreadLocalRandom;
 
@@ -96,6 +98,8 @@ public class ActivityLotteryService implements ActivityLotteryGate {
     @Transactional
     public ActivityLotteryConfig updateConfig(ActivityLotteryConfigRequest request) {
         ActivityLotteryConfig config = getConfig();
+        // 变更前快照
+        java.util.Map<String, Object> beforeData = configSnapshot(config);
         config.setEnabled(request.getEnabled());
         config.setStartTime(request.getStartTime());
         config.setEndTime(request.getEndTime());
@@ -110,11 +114,30 @@ public class ActivityLotteryService implements ActivityLotteryGate {
         }
         int maxChance = config.getDailyMaxChance() != null ? config.getDailyMaxChance() : 1;
         activityEventRedisService.updateCachedMaxChance(maxChance);
+        // 变更后快照 → 审计日志
+        java.util.Map<String, Object> afterData = configSnapshot(config);
+        operationAuditLogService.logSuccess(
+                0L, OperatorRole.ADMIN, OperationType.USER_UPDATE,
+                "LOTTERY_CONFIG:" + CONFIG_ID, CONFIG_ID,
+                beforeData, afterData,
+                "比价抽奖活动配置变更",
+                OperationSource.ADMIN_CREATE);
         log.info("比价抽奖活动配置已更新: enabled={}, start={}, end={}, dailyMaxChance={}",
                 config.getEnabled(), config.getStartTime(), config.getEndTime(), maxChance);
         return config;
     }
 
+    private java.util.Map<String, Object> configSnapshot(ActivityLotteryConfig c) {
+        java.util.Map<String, Object> s = new java.util.HashMap<>();
+        s.put("enabled", c.getEnabled());
+        s.put("startTime", c.getStartTime());
+        s.put("endTime", c.getEndTime());
+        s.put("dailyMaxChance", c.getDailyMaxChance());
+        s.put("requireMembershipLevel", c.getRequireMembershipLevel());
+        s.put("remark", c.getRemark());
+        return s;
+    }
+
     // ==========================================
     // 事件处理:比价成功后发放抽奖机会
     // ==========================================
@@ -133,7 +156,7 @@ public class ActivityLotteryService implements ActivityLotteryGate {
         LocalDate usageDate = usageDateStr != null ? LocalDate.parse(usageDateStr) : LocalDate.now();
         int maxChance = config.getDailyMaxChance() != null ? config.getDailyMaxChance() : 1;
 
-        // 检查当日已发放的机会数量是否已达上限
+        // 检查当日已发放的机会总数(含已用和未用)是否已达上限
         Long todayChanceCount = chanceMapper.selectCount(
                 new LambdaQueryWrapper<ActivityLotteryChance>()
                         .eq(ActivityLotteryChance::getUserId, userId)
@@ -235,8 +258,20 @@ public class ActivityLotteryService implements ActivityLotteryGate {
                 if (stockAffected == 0) {
                     // 库存不足(并发竞争),兜底为谢谢惠顾
                     log.warn("奖品库存不足,兜底谢谢惠顾: prizeId={}, name={}", prize.getId(), prize.getName());
+                    operationAuditLogService.logSuccess(
+                            userId, OperatorRole.USER, OperationType.USER_UPDATE,
+                            "LOTTERY_PRIZE:" + prize.getId(), prize.getId(),
+                            Map.of("action", "DECREMENT_STOCK", "prizeName", prize.getName(), "result", "FAILED"),
+                            null, "奖品库存扣减失败(已发完),兜底谢谢惠顾");
                     prize = ActivityLotteryPrize.builder()
                             .name("谢谢惠顾").rewardType(REWARD_TYPE_EMPTY).rewardAmount(0).build();
+                } else {
+                    operationAuditLogService.logSuccess(
+                            userId, OperatorRole.USER, OperationType.USER_UPDATE,
+                            "LOTTERY_PRIZE:" + prize.getId(), prize.getId(),
+                            Map.of("action", "DECREMENT_STOCK", "prizeName", prize.getName(),
+                                    "remainingAfter", prize.getRemainingStock() != null ? prize.getRemainingStock() - 1 : -1),
+                            null, "奖品库存扣减成功");
                 }
             }
         }
@@ -398,6 +433,12 @@ public class ActivityLotteryService implements ActivityLotteryGate {
 
     @Transactional
     public ActivityLotteryPrize createPrize(ActivityLotteryPrizeRequest request) {
+        // 会员奖励必须设置 rewardDays > 0
+        if ("MEMBERSHIP".equalsIgnoreCase(request.getRewardType())) {
+            if (request.getRewardDays() == null || request.getRewardDays() <= 0) {
+                throw new BusinessException(ErrorCode.PARAM_ERROR, "会员类型奖品必须设置奖励天数(>0)");
+            }
+        }
         int stock = request.getStock() != null ? request.getStock() : 0;
         ActivityLotteryPrize prize = ActivityLotteryPrize.builder()
                 .name(request.getName())
@@ -412,6 +453,13 @@ public class ActivityLotteryService implements ActivityLotteryGate {
                 .sort(request.getSort() != null ? request.getSort() : 0)
                 .build();
         prizeMapper.insert(prize);
+        operationAuditLogService.logSuccess(
+                0L, OperatorRole.ADMIN, OperationType.USER_UPDATE,
+                "LOTTERY_PRIZE:" + prize.getId(), prize.getId(),
+                null,
+                Map.of("name", prize.getName(), "rewardType", prize.getRewardType(),
+                        "stock", stock, "rewardDays", prize.getRewardDays()),
+                "创建抽奖奖品:" + prize.getName());
         return prize;
     }
 
@@ -421,6 +469,12 @@ public class ActivityLotteryService implements ActivityLotteryGate {
         if (prize == null) {
             throw new BusinessException(ErrorCode.PARAM_ERROR, "奖品不存在");
         }
+        // 会员奖励必须设置 rewardDays > 0
+        if ("MEMBERSHIP".equalsIgnoreCase(request.getRewardType())) {
+            if (request.getRewardDays() == null || request.getRewardDays() <= 0) {
+                throw new BusinessException(ErrorCode.PARAM_ERROR, "会员类型奖品必须设置奖励天数(>0)");
+            }
+        }
         prize.setName(request.getName());
         prize.setRewardType(request.getRewardType());
         Optional.ofNullable(request.getRewardAmount()).ifPresent(prize::setRewardAmount);
@@ -438,11 +492,28 @@ public class ActivityLotteryService implements ActivityLotteryGate {
         Optional.ofNullable(request.getEnabled()).ifPresent(prize::setEnabled);
         Optional.ofNullable(request.getSort()).ifPresent(prize::setSort);
         prizeMapper.updateById(prize);
+        operationAuditLogService.logSuccess(
+                0L, OperatorRole.ADMIN, OperationType.USER_UPDATE,
+                "LOTTERY_PRIZE:" + prize.getId(), prize.getId(),
+                null,
+                Map.of("name", prize.getName(), "rewardType", prize.getRewardType(),
+                        "stock", prize.getStock(), "remainingStock", prize.getRemainingStock(),
+                        "rewardDays", prize.getRewardDays()),
+                "更新抽奖奖品:" + prize.getName());
         return prize;
     }
 
     @Transactional
     public void deletePrize(Long id) {
+        ActivityLotteryPrize prize = prizeMapper.selectById(id);
+        if (prize != null) {
+            operationAuditLogService.logSuccess(
+                    0L, OperatorRole.ADMIN, OperationType.USER_UPDATE,
+                    "LOTTERY_PRIZE:" + id, id,
+                    Map.of("name", prize.getName(), "stock", prize.getStock(),
+                            "remainingStock", prize.getRemainingStock()),
+                    null, "删除抽奖奖品:" + prize.getName());
+        }
         prizeMapper.deleteById(id);
     }
 

+ 15 - 17
zhijiayun-common/src/main/java/com/xuekairui/common/activity/ActivityInviteGate.java

@@ -4,28 +4,12 @@
  * 邀请有礼活动门面(可选实现,由活动模块提供)。
  * <p>
  * 邀请模块不直接发放邀请奖励,而是通过该接口委托给活动模块统一处理。
- * 活动模块根据当前活动配置(rewardTrigger)决定发放策略:
- * <ul>
- *   <li>LICENSE_APPROVED(默认):注册/补填时不发奖,等待被邀请人入驻审核通过后触发</li>
- *   <li>REGISTRATION:注册/补填时立即发奖,受活动时间窗和次数上限约束</li>
- * </ul>
- * 已入驻用户补填邀请码时通过 {@link #grantRewardForApprovedUser} 发放(同样依赖活动开关)。
+ * 被邀请人入驻审核通过后触发发奖。已入驻用户补填邀请码时通过 {@link #grantRewardForApprovedUser} 发放。
  * <p>
  * 默认无实现时:邀请模块保持兼容,不发放奖励(活动模块未加载)。
  */
 public interface ActivityInviteGate {
 
-    /**
-     * 发放邀请奖励(统一入口,由活动模块根据 rewardTrigger 配置决定)。
-     * <p>
-     * LICENSE_APPROVED 模式:不发放,等待入驻审核通过后由 onLicenseApproved 触发。
-     * REGISTRATION 模式:立即发放 30 天高级会员(受活动时间窗、30 次上限约束)。
-     *
-     * @param inviterId 邀请人ID
-     * @param inviteeId 被邀请人ID
-     */
-    void grantInviteReward(Long inviterId, Long inviteeId);
-
     /**
      * 已入驻用户补填邀请码时发放奖励(依赖活动配置,活动未开启则不发放)。
      * <p>
@@ -36,4 +20,18 @@ public interface ActivityInviteGate {
      * @param inviteeId 被邀请人ID(已入驻)
      */
     void grantRewardForApprovedUser(Long inviterId, Long inviteeId);
+
+    /**
+     * 获取邀请人奖励天数(从活动配置,默认30天=1个月)
+     */
+    default int getInviterRewardDays() {
+        return 30;
+    }
+
+    /**
+     * 获取邀请人总邀请上限(从活动配置,默认30人)
+     */
+    default int getMaxTotalInvites() {
+        return 30;
+    }
 }

+ 19 - 0
zhijiayun-gateway/src/main/resources/db/migration-v21.sql

@@ -0,0 +1,19 @@
+-- ============================================
+-- 迁移 v21:t_activity_invite_config 补充 max_total_invites 列
+-- 问题:ActivityInviteConfig 实体已有 maxTotalInvites 字段,但数据库表缺少对应列
+-- ============================================
+
+-- 为 t_activity_invite_config 添加 max_total_invites 列(每个邀请人总邀请上限)
+SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
+                   WHERE TABLE_SCHEMA = DATABASE()
+                     AND TABLE_NAME = 't_activity_invite_config'
+                     AND COLUMN_NAME = 'max_total_invites');
+SET @sql = IF(@col_exists = 0,
+    'ALTER TABLE `t_activity_invite_config`
+     ADD COLUMN `max_total_invites` INT NOT NULL DEFAULT 30
+     COMMENT ''每个邀请人总邀请上限(与max_inviter_reward协同控制)''
+     AFTER `max_inviter_reward`',
+    'SELECT 1');
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;

+ 19 - 44
zhijiayun-gateway/src/test/java/com/xuekairui/gateway/AdminControllerTest.java

@@ -28,8 +28,8 @@ class AdminControllerTest extends GatewayBaseTest {
                         .header("Authorization", "Bearer " + adminToken()))
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.code").value(200))
-                .andExpect(jsonPath("$.data.rewardCrawlerCount").isNumber())
-                .andExpect(jsonPath("$.data.maxDailyReward").isNumber())
+                .andExpect(jsonPath("$.data.status").isNumber())
+                .andExpect(jsonPath("$.data.inviteCodeExpireDays").isNumber())
                 .andExpect(jsonPath("$.data.inviteCodeExpireDays").isNumber())
                 .andExpect(jsonPath("$.data.maxInvitePerDay").isNumber());
     }
@@ -58,8 +58,6 @@ class AdminControllerTest extends GatewayBaseTest {
     void updateInviteConfig_shouldSucceed() throws Exception {
         String body = """
                 {
-                    "rewardCrawlerCount": 5,
-                    "maxDailyReward": 50,
                     "inviteCodeExpireDays": 30,
                     "maxInvitePerDay": 10,
                     "landingTitle": "测试标题",
@@ -80,8 +78,6 @@ class AdminControllerTest extends GatewayBaseTest {
     void updateInviteConfig_withChannels_shouldSucceed() throws Exception {
         String body = """
                 {
-                    "rewardCrawlerCount": 5,
-                    "maxDailyReward": 50,
                     "inviteCodeExpireDays": 30,
                     "maxInvitePerDay": 10,
                     "appDownloadUrl": "https://download.example.com/app.exe",
@@ -136,7 +132,7 @@ class AdminControllerTest extends GatewayBaseTest {
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.code").value(200))
                 .andExpect(jsonPath("$.data.id").value(1))
-                .andExpect(jsonPath("$.data.rewardCrawlerCount").isNumber());
+                .andExpect(jsonPath("$.data.inviteCodeExpireDays").isNumber());
     }
 
     @Test
@@ -155,13 +151,8 @@ class AdminControllerTest extends GatewayBaseTest {
     void createInviteConfig_shouldSucceed() throws Exception {
         String body = """
                 {
-                    "rewardCrawlerCount": 3,
-                    "rewardType": "MEMBERSHIP",
-                    "rewardMonths": 2,
-                    "maxDailyReward": 30,
-                    "inviteCodeExpireDays": 15,
+                    "inviteCodeExpireDays": 0,
                     "maxInvitePerDay": 5,
-                    "maxTotalInvites": 20,
                     "landingTitle": "新配置标题",
                     "appName": "智价云(药店版)"
                 }
@@ -182,7 +173,6 @@ class AdminControllerTest extends GatewayBaseTest {
     void updateInviteConfigById_shouldSucceed() throws Exception {
         String body = """
                 {
-                    "rewardMonths": 3,
                     "landingTitle": "更新后的标题"
                 }
                 """;
@@ -201,7 +191,7 @@ class AdminControllerTest extends GatewayBaseTest {
         mockMvc.perform(put("/api/admin/invite/config/99999")
                         .header("Authorization", "Bearer " + adminToken())
                         .contentType(MediaType.APPLICATION_JSON)
-                        .content("{\"rewardMonths\": 3}"))
+                        .content("{\"landingTitle\": \"测试\"}"))
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.code").isNumber());
     }
@@ -213,7 +203,6 @@ class AdminControllerTest extends GatewayBaseTest {
         // 先创建一个临时配置用于删除
         String createBody = """
                 {
-                    "rewardMonths": 1,
                     "landingTitle": "待删除配置"
                 }
                 """;
@@ -464,51 +453,37 @@ class AdminControllerTest extends GatewayBaseTest {
                 .andExpect(jsonPath("$.code").isNumber());
     }
 
-    // ==================== 邀请转化统计(新增)====================
+    // ==================== 邀请转化统计 ====================
 
     @Test
     @Order(40)
-    @DisplayName("GET /api/admin/invite/conversion-stats?userId=1 - 应返回转化统计")
-    void getInviteConversionStats_shouldReturnStats() throws Exception {
-        mockMvc.perform(get("/api/admin/invite/conversion-stats?userId=1")
+    @DisplayName("GET /api/admin/invite/conversion-stats - 全员列表应返回分页数据")
+    void listConversionStats_shouldReturnPage() throws Exception {
+        mockMvc.perform(get("/api/admin/invite/conversion-stats")
                         .header("Authorization", "Bearer " + adminToken()))
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.code").value(200))
-                .andExpect(jsonPath("$.data.inviteCode").exists())
-                .andExpect(jsonPath("$.data.clickedCount").isNumber())
-                .andExpect(jsonPath("$.data.totalInvited").isNumber())
-                .andExpect(jsonPath("$.data.registeredCount").isNumber())
-                .andExpect(jsonPath("$.data.pendingCount").isNumber())
-                .andExpect(jsonPath("$.data.conversionRate").isNumber())
-                .andExpect(jsonPath("$.data.totalReward").isNumber());
+                .andExpect(jsonPath("$.data.records").isArray())
+                .andExpect(jsonPath("$.data.total").isNumber());
     }
 
     @Test
     @Order(41)
-    @DisplayName("GET /api/admin/invite/conversion-stats - 转化率应在0-100之间")
-    void getInviteConversionStats_conversionRateShouldBeInRange() throws Exception {
-        mockMvc.perform(get("/api/admin/invite/conversion-stats?userId=1")
+    @DisplayName("GET /api/admin/invite/conversion-stats/user?userId=1 - 单用户统计")
+    void getUserConversionStats_shouldReturnStats() throws Exception {
+        mockMvc.perform(get("/api/admin/invite/conversion-stats/user?userId=1")
                         .header("Authorization", "Bearer " + adminToken()))
                 .andExpect(status().isOk())
-                .andExpect(jsonPath("$.data.conversionRate").value(org.hamcrest.Matchers.greaterThanOrEqualTo(0.0)))
-                .andExpect(jsonPath("$.data.conversionRate").value(org.hamcrest.Matchers.lessThanOrEqualTo(100.0)));
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data.inviterId").isNumber())
+                .andExpect(jsonPath("$.data.totalInvited").isNumber());
     }
 
     @Test
     @Order(42)
-    @DisplayName("GET /api/admin/invite/conversion-stats - 待注册人数应>=0")
-    void getInviteConversionStats_pendingCountShouldBeNonNegative() throws Exception {
-        mockMvc.perform(get("/api/admin/invite/conversion-stats?userId=1")
-                        .header("Authorization", "Bearer " + adminToken()))
-                .andExpect(status().isOk())
-                .andExpect(jsonPath("$.data.pendingCount").value(org.hamcrest.Matchers.greaterThanOrEqualTo(0)));
-    }
-
-    @Test
-    @Order(43)
     @DisplayName("GET /api/admin/invite/conversion-stats - 无Token应返回401")
-    void getInviteConversionStats_withoutToken_shouldReturn401() throws Exception {
-        mockMvc.perform(get("/api/admin/invite/conversion-stats?userId=1"))
+    void listConversionStats_withoutToken_shouldReturn401() throws Exception {
+        mockMvc.perform(get("/api/admin/invite/conversion-stats"))
                 .andExpect(status().isUnauthorized());
     }
 }

+ 8 - 4
zhijiayun-gateway/src/test/java/com/xuekairui/gateway/AdminLicenseTest.java

@@ -57,7 +57,8 @@ class AdminLicenseTest extends GatewayBaseTest {
                         .header("Authorization", "Bearer " + adminToken()))
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.code").value(200))
-                .andExpect(jsonPath("$.data").isArray());
+                .andExpect(jsonPath("$.data.records").isArray())
+                .andExpect(jsonPath("$.data.total").isNumber());
     }
 
     @Test
@@ -68,7 +69,8 @@ class AdminLicenseTest extends GatewayBaseTest {
                         .header("Authorization", "Bearer " + adminToken()))
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.code").value(200))
-                .andExpect(jsonPath("$.data").isArray());
+                .andExpect(jsonPath("$.data.records").isArray())
+                .andExpect(jsonPath("$.data.total").isNumber());
     }
 
     @Test
@@ -79,7 +81,8 @@ class AdminLicenseTest extends GatewayBaseTest {
                         .header("Authorization", "Bearer " + adminToken()))
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.code").value(200))
-                .andExpect(jsonPath("$.data").isArray());
+                .andExpect(jsonPath("$.data.records").isArray())
+                .andExpect(jsonPath("$.data.total").isNumber());
     }
 
     @Test
@@ -90,7 +93,8 @@ class AdminLicenseTest extends GatewayBaseTest {
                         .header("Authorization", "Bearer " + adminToken()))
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.code").value(200))
-                .andExpect(jsonPath("$.data").isArray());
+                .andExpect(jsonPath("$.data.records").isArray())
+                .andExpect(jsonPath("$.data.total").isNumber());
     }
 
     @Test

+ 4 - 2
zhijiayun-gateway/src/test/java/com/xuekairui/gateway/EndToEndBusinessFlowTest.java

@@ -260,7 +260,8 @@ public class EndToEndBusinessFlowTest extends GatewayBaseTest {
                 .header("Authorization", "Bearer " + adminToken))
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.code").value(200))
-                .andExpect(jsonPath("$.data").isArray());
+                .andExpect(jsonPath("$.data.records").isArray())
+                .andExpect(jsonPath("$.data.total").isNumber());
 
         // 7.2 审核通过
         mockMvc.perform(post("/api/admin/license/review")
@@ -290,7 +291,8 @@ public class EndToEndBusinessFlowTest extends GatewayBaseTest {
                 .header("Authorization", "Bearer " + adminToken)
                 .param("limit", "50"))
                 .andExpect(status().isOk())
-                .andExpect(jsonPath("$.data").isArray());
+                .andExpect(jsonPath("$.data.records").isArray())
+                .andExpect(jsonPath("$.data.total").isNumber());
 
         // 8.2 管理员分页查询所有操作日志
         mockMvc.perform(get("/api/admin/audit-log")

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

@@ -113,6 +113,17 @@ public class AdminInviteController {
         return Result.success(result);
     }
 
+    /**
+     * 查询邀请配置变更历史(从审计日志读取 before/after 快照)
+     * GET /api/admin/invite/config/history?page=1&size=20
+     */
+    @GetMapping("/invite/config/history")
+    public Result<Page<com.xuekairui.user.entity.OperationAuditLog>> getConfigHistory(
+            @RequestParam(defaultValue = "1") int page,
+            @RequestParam(defaultValue = "20") int size) {
+        return Result.success(inviteConfigService.getConfigHistory(page, size));
+    }
+
     /**
      * 删除邀请配置
      * DELETE /api/admin/invite/config/{id}
@@ -161,9 +172,10 @@ public class AdminInviteController {
     @GetMapping("/invite/rewards")
     public Result<Page<InviteRewardResponse>> listInviteRewards(
             @RequestParam(required = false) Long userId,
+            @RequestParam(required = false) Integer rewardGranted,
             @RequestParam(defaultValue = "1") int page,
             @RequestParam(defaultValue = "20") int size) {
-        return Result.success(inviteService.getInviteRewards(userId, page, size));
+        return Result.success(inviteService.getInviteRewards(userId, rewardGranted, page, size));
     }
 
     // ==================== 用户爬虫详情查询 ====================
@@ -180,11 +192,24 @@ public class AdminInviteController {
     }
 
     /**
-     * 查看用户邀请转化统计(点击-注册转化率
-     * GET /api/admin/invite/conversion-stats?userId=1 或 ?phone=13800138000
+     * 全员邀请转化统计列表(按邀请人分组,分页;可选按用户ID或手机号筛选
+     * GET /api/admin/invite/conversion-stats?page=1&size=20&userId=1&phone=138xxx
      */
     @GetMapping("/invite/conversion-stats")
-    public Result<InviteConversionStatsResponse> getInviteConversionStats(
+    public Result<Page<InviteConversionStatsResponse>> listConversionStats(
+            @RequestParam(defaultValue = "1") int page,
+            @RequestParam(defaultValue = "20") int size,
+            @RequestParam(required = false) Long userId,
+            @RequestParam(required = false) String phone) {
+        return Result.success(inviteService.getAllConversionStats(page, size, userId, phone));
+    }
+
+    /**
+     * 查看单个用户邀请转化统计
+     * GET /api/admin/invite/conversion-stats/user?userId=1 或 ?phone=13800138000
+     */
+    @GetMapping("/invite/conversion-stats/user")
+    public Result<InviteConversionStatsResponse> getUserConversionStats(
             @RequestParam(required = false) Long userId,
             @RequestParam(required = false) String phone) {
         return Result.success(inviteService.getInviteConversionStats(userId, phone, null));

+ 2 - 23
zhijiayun-invite/src/main/java/com/xuekairui/invite/dto/InviteConfigRequest.java

@@ -11,24 +11,8 @@ import lombok.Data;
 @Data
 public class InviteConfigRequest {
 
-    /** 每次邀请奖励爬虫次数(历史字段,已废弃) */
-    @Min(1)
-    private Integer rewardCrawlerCount;
-
-    /** 奖励类型:MEMBERSHIP-赠送会员时长 CRAWLER-爬虫次数(历史) */
-    private String rewardType;
-
-    /** 邀请人奖励会员时长(月) */
-    @Min(1)
-    @Max(12)
-    private Integer rewardMonths;
-
-    /** 每日最大奖励次数上限 */
-    @Min(1)
-    private Integer maxDailyReward;
-
-    /** 邀请码有效天数 */
-    @Min(1)
+    /** 邀请码有效天数(0=永不过期) */
+    @Min(0)
     private Integer inviteCodeExpireDays;
 
     /** 每人每天最大邀请次数 */
@@ -36,11 +20,6 @@ public class InviteConfigRequest {
     @Max(100)
     private Integer maxInvitePerDay;
 
-    /** 单个账号总邀请上限 */
-    @Min(1)
-    @Max(999)
-    private Integer maxTotalInvites;
-
     // ========== 多渠道配置 ==========
 
     /** Windows客户端下载地址 */

+ 19 - 10
zhijiayun-invite/src/main/java/com/xuekairui/invite/dto/InviteConversionStatsResponse.java

@@ -14,24 +14,33 @@ import lombok.NoArgsConstructor;
 @AllArgsConstructor
 public class InviteConversionStatsResponse {
     
+    /** 邀请人ID */
+    private Long inviterId;
+
+    /** 邀请人昵称 */
+    private String inviterNickname;
+
     /** 邀请码 */
     private String inviteCode;
-    
+
     /** 邀请链接被打开次数 */
     private Integer clickedCount;
-    
-    /** 总邀请人数(点击后访问落地页的人数) */
+
+    /** 总邀请人数 */
     private Integer totalInvited;
-    
+
     /** 已注册人数 */
     private Integer registeredCount;
-    
-    /** 待注册人数(点击但未注册) */
+
+    /** 待注册人数 */
     private Integer pendingCount;
-    
-    /** 邀请→注册转化率(百分比,= 已注册/总邀请) */
+
+    /** 已入驻人数(审核通过) */
+    private Integer approvedCount;
+
+    /** 邀请→注册转化率(百分比) */
     private Double conversionRate;
-    
-    /** 累计奖励爬虫次数 */
+
+    /** 累计奖励次数 */
     private Integer totalReward;
 }

+ 2 - 27
zhijiayun-invite/src/main/java/com/xuekairui/invite/entity/InviteConfig.java

@@ -22,39 +22,14 @@ public class InviteConfig {
     @TableId(type = IdType.AUTO)
     private Long id;
 
-    /**
-     * 每次邀请额外奖励爬虫次数(默认0=仅送1个月高级会员,不送额外配额)
-     * <p>
-     * 邀请奖励核心:邀请人固定获得1个月高级会员(PRO),被邀请人注册即得普通会员(PLUS)。
-     * 本字段为可选的额外爬虫配额,运营可按需配置。
-     */
+    /** 邀请码有效天数(0=永不过期) */
     @Builder.Default
-    private Integer rewardCrawlerCount = 0;
-
-    /** 奖励类型:MEMBERSHIP-赠送会员时长 CRAWLER-爬虫次数(历史) */
-    @Builder.Default
-    private String rewardType = "MEMBERSHIP";
-
-    /** 邀请人奖励会员时长(月) */
-    @Builder.Default
-    private Integer rewardMonths = 1;
-
-    /** 每日最大奖励次数上限 */
-    @Builder.Default
-    private Integer maxDailyReward = 50;
-
-    /** 邀请码有效天数 */
-    @Builder.Default
-    private Integer inviteCodeExpireDays = 30;
+    private Integer inviteCodeExpireDays = 0;
 
     /** 每人每天最大邀请次数 */
     @Builder.Default
     private Integer maxInvitePerDay = 10;
 
-    /** 单个账号总邀请上限 */
-    @Builder.Default
-    private Integer maxTotalInvites = 30;
-
     // ========== 多渠道配置 ==========
 
     /** Windows客户端下载地址 */

+ 30 - 0
zhijiayun-invite/src/main/java/com/xuekairui/invite/mapper/InviteRelationMapper.java

@@ -3,10 +3,40 @@ package com.xuekairui.invite.mapper;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.xuekairui.invite.entity.InviteRelation;
 import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+import java.util.List;
+import java.util.Map;
 
 /**
  * 邀请关系Mapper
  */
 @Mapper
 public interface InviteRelationMapper extends BaseMapper<InviteRelation> {
+
+    /** 按邀请人分组统计:邀请人数、注册人数、入驻人数、累计奖励次数 */
+    @Select("SELECT r.inviter_id, COUNT(*) as total_invited, " +
+            "SUM(CASE WHEN r.registered = 1 THEN 1 ELSE 0 END) as registered_count, " +
+            "SUM(CASE WHEN bl.review_status = 'APPROVED' THEN 1 ELSE 0 END) as approved_count, " +
+            "SUM(COALESCE(r.reward_granted, 0)) as total_reward " +
+            "FROM t_invite_relation r " +
+            "LEFT JOIN t_business_license bl ON bl.user_id = r.invitee_id " +
+            "GROUP BY r.inviter_id " +
+            "ORDER BY total_invited DESC LIMIT #{offset}, #{limit}")
+    List<Map<String, Object>> groupByInviter(@Param("offset") int offset, @Param("limit") int limit);
+
+    /** 统计有邀请关系的邀请人数 */
+    @Select("SELECT COUNT(DISTINCT inviter_id) FROM t_invite_relation")
+    long countDistinctInviters();
+
+    /** 单个邀请人的统计(含入驻数) */
+    @Select("SELECT r.inviter_id, COUNT(*) as total_invited, " +
+            "SUM(CASE WHEN r.registered = 1 THEN 1 ELSE 0 END) as registered_count, " +
+            "SUM(CASE WHEN bl.review_status = 'APPROVED' THEN 1 ELSE 0 END) as approved_count, " +
+            "SUM(COALESCE(r.reward_granted, 0)) as total_reward " +
+            "FROM t_invite_relation r " +
+            "LEFT JOIN t_business_license bl ON bl.user_id = r.invitee_id " +
+            "WHERE r.inviter_id = #{inviterId} GROUP BY r.inviter_id")
+    Map<String, Object> statsByInviter(@Param("inviterId") Long inviterId);
 }

+ 13 - 30
zhijiayun-invite/src/main/java/com/xuekairui/invite/service/InviteConfigService.java

@@ -46,13 +46,8 @@ public class InviteConfigService {
         }
         // 无启用配置时返回默认
         return InviteConfig.builder()
-                .rewardType("MEMBERSHIP")
-                .rewardMonths(1)
-                .rewardCrawlerCount(0)
-                .maxDailyReward(50)
-                .inviteCodeExpireDays(30)
+                .inviteCodeExpireDays(0)
                 .maxInvitePerDay(10)
-                .maxTotalInvites(30)
                 .landingTitle("邀请你加入智价云(药店版)")
                 .landingDesc("登录智价云(药店版),邀请好友各得好礼")
                 .appName("智价云(药店版)")
@@ -84,8 +79,8 @@ public class InviteConfigService {
                 "邀请配置变更",
                 OperationSource.ADMIN_CREATE);
 
-        log.info("邀请配置已更新: rewardType={}, rewardMonths={}, appDownloadUrl={}, miniappPath={}",
-                config.getRewardType(), config.getRewardMonths(), config.getAppDownloadUrl(), config.getMiniappPath());
+        log.info("邀请配置已更新: status={}, appDownloadUrl={}, miniappPath={}",
+                config.getStatus(), config.getAppDownloadUrl(), config.getMiniappPath());
         return config;
     }
 
@@ -101,6 +96,14 @@ public class InviteConfigService {
         return inviteConfigMapper.selectPage(pageParam, wrapper);
     }
 
+    /**
+     * 分页查询所有邀请配置的变更历史(从审计日志读取 before/after 快照)
+     */
+    public Page<com.xuekairui.user.entity.OperationAuditLog> getConfigHistory(
+            int pageNum, int pageSize) {
+        return auditLogService.getTargetOperationHistory("INVITE", null, pageNum, pageSize);
+    }
+
     /**
      * 根据ID获取配置
      */
@@ -118,7 +121,7 @@ public class InviteConfigService {
     public InviteConfig createConfig(InviteConfigRequest request) {
         InviteConfig config = buildConfigFromRequest(new InviteConfig(), request);
         inviteConfigMapper.insert(config);
-        log.info("创建邀请配置: id={}, rewardType={}, rewardMonths={}", config.getId(), config.getRewardType(), config.getRewardMonths());
+        log.info("创建邀请配置: id={}, status={}", config.getId(), config.getStatus());
         return config;
     }
 
@@ -140,7 +143,7 @@ public class InviteConfigService {
                 beforeData, afterData,
                 "邀请配置变更(指定ID)",
                 OperationSource.ADMIN_CREATE);
-        log.info("更新邀请配置: id={}, rewardType={}, rewardMonths={}", config.getId(), config.getRewardType(), config.getRewardMonths());
+        log.info("更新邀请配置: id={}, status={}", config.getId(), config.getStatus());
         return config;
     }
 
@@ -160,27 +163,12 @@ public class InviteConfigService {
      * 将请求参数填充到配置实体
      */
     private InviteConfig buildConfigFromRequest(InviteConfig config, InviteConfigRequest request) {
-        if (request.getRewardCrawlerCount() != null) {
-            config.setRewardCrawlerCount(request.getRewardCrawlerCount());
-        }
-        if (request.getRewardType() != null) {
-            config.setRewardType(request.getRewardType());
-        }
-        if (request.getRewardMonths() != null) {
-            config.setRewardMonths(request.getRewardMonths());
-        }
-        if (request.getMaxDailyReward() != null) {
-            config.setMaxDailyReward(request.getMaxDailyReward());
-        }
         if (request.getInviteCodeExpireDays() != null) {
             config.setInviteCodeExpireDays(request.getInviteCodeExpireDays());
         }
         if (request.getMaxInvitePerDay() != null) {
             config.setMaxInvitePerDay(request.getMaxInvitePerDay());
         }
-        if (request.getMaxTotalInvites() != null) {
-            config.setMaxTotalInvites(request.getMaxTotalInvites());
-        }
         if (request.getAppDownloadUrl() != null) {
             config.setAppDownloadUrl(request.getAppDownloadUrl());
         }
@@ -254,13 +242,8 @@ public class InviteConfigService {
     private Map<String, Object> configSnapshot(InviteConfig c) {
         Map<String, Object> s = new HashMap<>();
         s.put("status", c.getStatus());
-        s.put("rewardType", c.getRewardType());
-        s.put("rewardMonths", c.getRewardMonths());
-        s.put("rewardCrawlerCount", c.getRewardCrawlerCount());
-        s.put("maxDailyReward", c.getMaxDailyReward());
         s.put("inviteCodeExpireDays", c.getInviteCodeExpireDays());
         s.put("maxInvitePerDay", c.getMaxInvitePerDay());
-        s.put("maxTotalInvites", c.getMaxTotalInvites());
         s.put("appName", c.getAppName());
         s.put("landingTitle", c.getLandingTitle());
         s.put("landingDesc", c.getLandingDesc());

+ 151 - 19
zhijiayun-invite/src/main/java/com/xuekairui/invite/service/InviteService.java

@@ -179,7 +179,9 @@ public class InviteService {
 
             // 5. 创建新邀请码
             InviteConfig config = inviteConfigService.getActiveConfig();
-            LocalDateTime expireTime = LocalDateTime.now().plusDays(config.getInviteCodeExpireDays());
+            int expireDays = config.getInviteCodeExpireDays() != null ? config.getInviteCodeExpireDays() : 0;
+            LocalDateTime expireTime = expireDays > 0
+                    ? LocalDateTime.now().plusDays(expireDays) : null;
             String code = generateUniqueCode();
             InviteCode newCode = InviteCode.builder()
                     .userId(userId)
@@ -320,7 +322,7 @@ public class InviteService {
                 .wechatRedirectUrl(config.getWechatRedirectUrl())
                 .dingtalkAppId(config.getDingtalkAppId())
                 .feishuAppId(config.getFeishuAppId())
-                .rewardPerRegister(config.getRewardMonths() != null ? config.getRewardMonths() : 1)
+                .rewardPerRegister(getInviterRewardMonths())
                 // 新增:自动填入支持
                 .autoFillSupported(true)
                 .urlScheme(urlScheme)
@@ -651,7 +653,17 @@ public class InviteService {
             return;
         }
 
-        // 5.1 奖励统一由活动模块处理(ActivityInviteGate),此处不再做上限校验
+        // 5.1 每日邀请上限校验(邀请配置,防滥用;总人数不限,奖励由活动配置 maxInviterReward 控制)
+        int maxPerDay = config.getMaxInvitePerDay() != null ? config.getMaxInvitePerDay() : 10;
+        LocalDateTime todayStart = LocalDate.now().atStartOfDay();
+        Long todayInvites = inviteRelationMapper.selectCount(
+                new LambdaQueryWrapper<InviteRelation>()
+                        .eq(InviteRelation::getInviterId, inviterId)
+                        .ge(InviteRelation::getCreateTime, todayStart));
+        if (todayInvites != null && todayInvites >= maxPerDay) {
+            log.warn("邀请人{}已达每日邀请上限{},拒绝创建邀请: invitee={}", inviterId, maxPerDay, inviteeId);
+            return;
+        }
         // 6. 创建邀请关系(rewardGranted 初始为 0,由活动模块实际发奖后回写)
         InviteRelation relation = InviteRelation.builder()
                 .inviterId(inviterId)
@@ -770,8 +782,17 @@ public class InviteService {
             throw new BusinessException(ErrorCode.BUSINESS_ERROR, "邀请功能已停用");
         }
 
-        // 7.1 奖励统一由活动模块处理,此处不再做上限校验
-
+        // 7.1 每日邀请上限校验(邀请配置,防滥用;总人数不限)
+        int maxPerDay = config.getMaxInvitePerDay() != null ? config.getMaxInvitePerDay() : 10;
+        LocalDateTime todayStart = LocalDate.now().atStartOfDay();
+        Long todayInvites = inviteRelationMapper.selectCount(
+                new LambdaQueryWrapper<InviteRelation>()
+                        .eq(InviteRelation::getInviterId, inviterId)
+                        .ge(InviteRelation::getCreateTime, todayStart));
+        if (todayInvites != null && todayInvites >= maxPerDay) {
+            throw new BusinessException(ErrorCode.BUSINESS_ERROR,
+                    "今日邀请已达上限(" + maxPerDay + "人),请明天再试");
+        }
         // 8. 创建邀请关系(rewardGranted 初始为 0,由活动模块实际发奖后回写)
         InviteRelation relation = InviteRelation.builder()
                 .inviterId(inviterId)
@@ -919,24 +940,34 @@ public class InviteService {
                 .summaryText(summaryText)
                 .todayInvited(todayInvited.intValue())
                 .todayRemaining(todayRemaining)
-                .rewardPerInvite(config.getRewardMonths() != null ? config.getRewardMonths() : 1)
+                .rewardPerInvite(getInviterRewardMonths())
                 .crawlerTodayRemaining(crawlerTodayRemaining)
                 .crawlerQuotaFromInvites(crawlerQuotaFromInvites)
                 .build();
     }
 
     public List<InviteRewardResponse> getInviteRewards(Long userId) {
-        return getInviteRewards(userId, 1, Integer.MAX_VALUE).getRecords();
+        return getInviteRewards(userId, null, 1, Integer.MAX_VALUE).getRecords();
     }
 
     /**
      * 分页查询邀请记录(批量查询被邀请人,避免 N+1)
      */
-    public Page<InviteRewardResponse> getInviteRewards(Long userId, int pageNum, int pageSize) {
+    public Page<InviteRewardResponse> getInviteRewards(Long userId, Integer rewardGranted,
+                                                        int pageNum, int pageSize) {
         Page<InviteRelation> page = new Page<>(pageNum, pageSize);
-        LambdaQueryWrapper<InviteRelation> wrapper = new LambdaQueryWrapper<InviteRelation>()
-                .eq(InviteRelation::getInviterId, userId)
-                .orderByDesc(InviteRelation::getCreateTime);
+        LambdaQueryWrapper<InviteRelation> wrapper = new LambdaQueryWrapper<>();
+        if (userId != null) {
+            wrapper.eq(InviteRelation::getInviterId, userId);
+        }
+        if (rewardGranted != null) {
+            // 改用奖励记录表判定(与发奖在同一事务,无延迟)
+            String sql = (rewardGranted == 1)
+                    ? "invitee_id IN (SELECT invitee_id FROM t_activity_invite_reward_record)"
+                    : "invitee_id NOT IN (SELECT invitee_id FROM t_activity_invite_reward_record)";
+            wrapper.apply(sql);
+        }
+        wrapper.orderByDesc(InviteRelation::getCreateTime);
         Page<InviteRelation> result = inviteRelationMapper.selectPage(page, wrapper);
 
         List<InviteRelation> relations = result.getRecords();
@@ -960,8 +991,16 @@ public class InviteService {
         Map<Long, String> licenseStatusMap = businessLicenseService.getLicenseStatusByUserIds(inviteeIds);
         Map<Long, String> storeNameMap = businessLicenseService.getStoreNameByUserIds(inviteeIds);
 
-        // 邀请人信息只查一次
-        String inviterNickname = getUserNickname(userId);
+        // 批量查邀请人信息(查全部时邀请人不同,需按记录取对应昵称)
+        List<Long> inviterIds = relations.stream()
+                .map(InviteRelation::getInviterId).distinct().toList();
+        Map<Long, String> inviterNicknameMap = new HashMap<>();
+        if (!inviterIds.isEmpty()) {
+            List<User> inviters = userMapper.selectByIds(inviterIds);
+            for (User inv : inviters) {
+                inviterNicknameMap.put(inv.getId(), getDisplayName(inv));
+            }
+        }
 
         List<InviteRewardResponse> list = relations.stream().map(r -> {
             boolean isRegistered = r.getRegistered() != null && r.getRegistered() == 1;
@@ -975,7 +1014,7 @@ public class InviteService {
 
             return InviteRewardResponse.builder()
                     .inviterId(r.getInviterId())
-                    .inviterNickname(inviterNickname)
+                    .inviterNickname(inviterNicknameMap.getOrDefault(r.getInviterId(), "一位药店用户"))
                     .inviteeId(r.getInviteeId())
                     .inviteeNickname(inviteeNickname)
                     .inviteePhone(inviteePhone)
@@ -1109,10 +1148,18 @@ public class InviteService {
     }
 
     /**
-     * 根据用户ID获取展示名
+     * 获取活动配置的邀请人奖励天数(通过 ActivityInviteGate,默认30天=1个月)
      */
-    private String getUserNickname(Long userId) {
-        return getDisplayName(userMapper.selectById(userId));
+    private int getInviterRewardDays() {
+        if (activityInviteGate != null) {
+            return activityInviteGate.getInviterRewardDays();
+        }
+        return 30;
+    }
+
+    /** 用户端展示:奖励天数转为月数(30天=1个月) */
+    private int getInviterRewardMonths() {
+        return Math.max(1, getInviterRewardDays() / 30);
     }
 
     /**
@@ -1140,7 +1187,7 @@ public class InviteService {
         User inviter = userMapper.selectById(code.getUserId());
         String inviterNickname = getDisplayName(inviter);
         String appName = config.getAppName() != null ? config.getAppName() : "智价云(药店版)";
-        int rewardMonths = config.getRewardMonths() != null ? config.getRewardMonths() : 1;
+        int rewardMonths = getInviterRewardMonths();
 
         // 根据登录来源生成不同的邀请链接
         String inviteLink;
@@ -1311,7 +1358,80 @@ public class InviteService {
     // ==========================================
 
     /**
-     * 获取用户邀请转化统计(点击-注册转化率)
+     * 全员邀请转化统计(按邀请人分组,分页)
+     */
+    public Page<InviteConversionStatsResponse> getAllConversionStats(int pageNum, int pageSize,
+                                                                     Long userId, String phone) {
+        // 解析手机号为 userId
+        Long filterUserId = userId;
+        if (filterUserId == null && phone != null && !phone.isBlank()) {
+            User u = userMapper.selectOne(new LambdaQueryWrapper<User>().eq(User::getPhone, phone));
+            if (u != null) filterUserId = u.getId();
+        }
+
+        long total;
+        List<Map<String, Object>> inviterStats;
+        if (filterUserId != null) {
+            // 查单个邀请人
+            Map<String, Object> stat = inviteRelationMapper.statsByInviter(filterUserId);
+            inviterStats = stat != null ? List.of(stat) : List.of();
+            total = inviterStats.size();
+        } else {
+            int offset = (pageNum - 1) * pageSize;
+            inviterStats = inviteRelationMapper.groupByInviter(offset, pageSize);
+            total = inviteRelationMapper.countDistinctInviters();
+        }
+
+        List<Long> inviterIds = inviterStats.stream()
+                .map(m -> (Long) m.get("inviter_id")).distinct().toList();
+        Map<Long, User> userMap = inviterIds.isEmpty() ? Map.of()
+                : userMapper.selectByIds(inviterIds).stream()
+                        .collect(Collectors.toMap(User::getId, u -> u));
+        Map<Long, String> codeMap = new HashMap<>();
+        Map<Long, Integer> clickMap = new HashMap<>();
+        if (!inviterIds.isEmpty()) {
+            List<InviteCode> codes = inviteCodeMapper.selectList(
+                    new LambdaQueryWrapper<InviteCode>().in(InviteCode::getUserId, inviterIds));
+            for (InviteCode c : codes) {
+                codeMap.put(c.getUserId(), c.getCode());
+                clickMap.put(c.getUserId(), c.getClickCount());
+            }
+        }
+
+        List<InviteConversionStatsResponse> list = inviterStats.stream().map(m -> {
+            Long inviterId = (Long) m.get("inviter_id");
+            int totalInvited = ((Number) m.get("total_invited")).intValue();
+            int registered = ((Number) m.get("registered_count")).intValue();
+            int approved = ((Number) m.get("approved_count")).intValue();
+            int totalReward = ((Number) m.get("total_reward")).intValue();
+
+            User inviter = userMap.get(inviterId);
+            String nickname = inviter != null ? getDisplayName(inviter) : String.valueOf(inviterId);
+            int clicks = clickMap.getOrDefault(inviterId, 0);
+            double rate = totalInvited > 0
+                    ? Math.round((double) registered / totalInvited * 10000.0) / 100.0 : 0.0;
+
+            return InviteConversionStatsResponse.builder()
+                    .inviterId(inviterId)
+                    .inviterNickname(nickname)
+                    .inviteCode(codeMap.get(inviterId))
+                    .clickedCount(clicks)
+                    .totalInvited(totalInvited)
+                    .registeredCount(registered)
+                    .pendingCount(totalInvited - registered)
+                    .approvedCount(approved)
+                    .conversionRate(rate)
+                    .totalReward(totalReward)
+                    .build();
+        }).toList();
+
+        Page<InviteConversionStatsResponse> result = new Page<>(pageNum, pageSize, total);
+        result.setRecords(list);
+        return result;
+    }
+
+    /**
+     * 单用户邀请转化统计(点击-注册转化率)
      */
     public InviteConversionStatsResponse getInviteConversionStats(Long userId, String phone, String loginSource) {
         if (userId == null && phone != null && !phone.isBlank()) {
@@ -1347,6 +1467,14 @@ public class InviteService {
                 .count();
         int pendingCount = totalInvited - registeredCount;
 
+        // 已入驻人数
+        int approvedCount = 0;
+        if (!relations.isEmpty()) {
+            List<Long> inviteeIds = relations.stream().map(InviteRelation::getInviteeId).toList();
+            approvedCount = (int) businessLicenseService.getLicenseStatusByUserIds(inviteeIds)
+                    .values().stream().filter("APPROVED"::equals).count();
+        }
+
         // 计算邀请→注册转化率(注册人数/总邀请人数,不含点击数噪声)
         double conversionRate = 0.0;
         if (totalInvited > 0) {
@@ -1358,12 +1486,16 @@ public class InviteService {
                 .mapToInt(InviteRelation::getRewardGranted)
                 .sum();
 
+        User inviter = userMapper.selectById(userId);
         return InviteConversionStatsResponse.builder()
+                .inviterId(userId)
+                .inviterNickname(inviter != null ? getDisplayName(inviter) : String.valueOf(userId))
                 .inviteCode(codeResponse.getCode())
                 .clickedCount(clickedCount)
                 .totalInvited(totalInvited)
                 .registeredCount(registeredCount)
                 .pendingCount(pendingCount)
+                .approvedCount(approvedCount)
                 .conversionRate(conversionRate)
                 .totalReward(totalReward)
                 .build();

+ 6 - 3
zhijiayun-user/src/main/java/com/xuekairui/user/controller/AdminLicenseController.java

@@ -1,5 +1,6 @@
 package com.xuekairui.user.controller;
 
+import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.xuekairui.common.Result;
 import com.xuekairui.user.dto.BusinessLicenseReviewResponse;
 import com.xuekairui.user.enums.LicenseStatus;
@@ -39,10 +40,12 @@ public class AdminLicenseController {
      * @param keyword      手机号关键词(可选):匹配用户手机号或入驻联系电话
      */
     @GetMapping("/review")
-    public Result<List<BusinessLicenseReviewResponse>> listPendingReviews(
+    public Result<IPage<BusinessLicenseReviewResponse>> listPendingReviews(
             @RequestParam(required = false) String reviewStatus,
-            @RequestParam(required = false) String keyword) {
-        return Result.success(businessLicenseService.listByReviewStatus(reviewStatus, keyword));
+            @RequestParam(required = false) String keyword,
+            @RequestParam(defaultValue = "1") int page,
+            @RequestParam(defaultValue = "20") int size) {
+        return Result.success(businessLicenseService.listByReviewStatus(reviewStatus, keyword, page, size));
     }
 
     /**

+ 6 - 3
zhijiayun-user/src/main/java/com/xuekairui/user/mapper/BusinessLicenseMapper.java

@@ -1,6 +1,8 @@
 package com.xuekairui.user.mapper;
 
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.xuekairui.user.dto.BusinessLicenseReviewResponse;
 import com.xuekairui.user.entity.BusinessLicense;
 import org.apache.ibatis.annotations.*;
@@ -27,7 +29,7 @@ public interface BusinessLicenseMapper extends BaseMapper<BusinessLicense> {
     int markRewardGranted(@Param("id") Long id);
 
     /**
-     * 审核列表查询(LEFT JOIN t_user,支持按审核状态筛选 + 手机号/联系电话关键词搜索)
+     * 审核列表分页查询(LEFT JOIN t_user,支持按审核状态筛选 + 手机号/联系电话关键词搜索)
      */
     @Select("<script>" +
             "SELECT bl.id, bl.store_name, bl.terminal_type, bl.province, bl.city, bl.district, " +
@@ -47,6 +49,7 @@ public interface BusinessLicenseMapper extends BaseMapper<BusinessLicense> {
             "</where>" +
             "ORDER BY bl.create_time DESC" +
             "</script>")
-    List<BusinessLicenseReviewResponse> selectReviewList(@Param("reviewStatus") String reviewStatus,
-                                                         @Param("keyword") String keyword);
+    IPage<BusinessLicenseReviewResponse> selectReviewPage(Page<?> page,
+                                                          @Param("reviewStatus") String reviewStatus,
+                                                          @Param("keyword") String keyword);
 }

+ 9 - 4
zhijiayun-user/src/main/java/com/xuekairui/user/mapper/OperationAuditLogMapper.java

@@ -3,6 +3,7 @@ package com.xuekairui.user.mapper;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.xuekairui.user.entity.OperationAuditLog;
 import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
 import org.apache.ibatis.annotations.Select;
 
 import java.time.LocalDateTime;
@@ -19,26 +20,30 @@ public interface OperationAuditLogMapper extends BaseMapper<OperationAuditLog> {
      */
     @Select("SELECT * FROM t_operation_audit_log WHERE operator_id = #{operatorId} " +
             "ORDER BY operation_time DESC LIMIT #{limit}")
-    List<OperationAuditLog> selectByOperatorId(Long operatorId, int limit);
+    List<OperationAuditLog> selectByOperatorId(@Param("operatorId") Long operatorId, @Param("limit") int limit);
 
     /**
      * 查询指定目标对象的操作日志
      */
     @Select("SELECT * FROM t_operation_audit_log WHERE target_type = #{targetType} AND target_id = #{targetId} " +
             "ORDER BY operation_time DESC LIMIT #{limit}")
-    List<OperationAuditLog> selectByTarget(String targetType, Long targetId, int limit);
+    List<OperationAuditLog> selectByTarget(@Param("targetType") String targetType,
+                                           @Param("targetId") Long targetId,
+                                           @Param("limit") int limit);
 
     /**
      * 查询敏感操作日志
      */
     @Select("SELECT * FROM t_operation_audit_log WHERE is_sensitive = true " +
             "AND operation_time >= #{startTime} ORDER BY operation_time DESC")
-    List<OperationAuditLog> selectSensitiveLogs(LocalDateTime startTime);
+    List<OperationAuditLog> selectSensitiveLogs(@Param("startTime") LocalDateTime startTime);
 
     /**
      * 统计指定模块的操作次数
      */
     @Select("SELECT COUNT(*) FROM t_operation_audit_log WHERE module = #{module} " +
             "AND operation_time >= #{startTime} AND operation_time <= #{endTime}")
-    long countByModuleAndTimeRange(String module, LocalDateTime startTime, LocalDateTime endTime);
+    long countByModuleAndTimeRange(@Param("module") String module,
+                                    @Param("startTime") LocalDateTime startTime,
+                                    @Param("endTime") LocalDateTime endTime);
 }

+ 10 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/service/AuthService.java

@@ -590,6 +590,16 @@ public class AuthService {
         // 同步入驻信息到 t_business_license
         syncBusinessLicense(request, user.getId());
 
+        auditLogService.logSuccess(
+                0L, OperatorRole.SYSTEM,
+                com.xuekairui.user.enums.OperationType.LICENSE_SYNC_THIRD,
+                "SYNC_INFO:" + request.getPhone(),
+                user.getId(),
+                null,
+                Map.of("phone", request.getPhone(), "userId", user.getId(),
+                        "status", request.getStatus(), "storeName", request.getStoreName()),
+                "第三方同步用户信息:phone=" + request.getPhone(),
+                com.xuekairui.user.enums.OperationSource.SYSTEM_SYNC);
         log.info("第三方信息同步完成: phone={}, userId={}", request.getPhone(), user.getId());
     }
 

+ 7 - 3
zhijiayun-user/src/main/java/com/xuekairui/user/service/BusinessLicenseService.java

@@ -1,6 +1,8 @@
 package com.xuekairui.user.service;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.xuekairui.common.BusinessException;
 import com.xuekairui.common.ErrorCode;
 import com.xuekairui.common.event.ActivityEventRedisService;
@@ -51,16 +53,18 @@ public class BusinessLicenseService {
     // ======================== 查询方法 ========================
 
     /**
-     * 按审核状态 + 手机号关键词查询列表(不传则查全部),LEFT JOIN 查提交者信息,一次 SQL
+     * 按审核状态 + 手机号关键词分页查询,LEFT JOIN 查提交者信息
      */
-    public List<BusinessLicenseReviewResponse> listByReviewStatus(String reviewStatus, String keyword) {
+    public IPage<BusinessLicenseReviewResponse> listByReviewStatus(String reviewStatus, String keyword,
+                                                                    int pageNum, int pageSize) {
         if (reviewStatus != null && reviewStatus.isBlank()) {
             reviewStatus = null;
         }
         if (keyword != null && keyword.isBlank()) {
             keyword = null;
         }
-        return businessLicenseMapper.selectReviewList(reviewStatus, keyword);
+        Page<BusinessLicenseReviewResponse> page = new Page<>(pageNum, pageSize);
+        return businessLicenseMapper.selectReviewPage(page, reviewStatus, keyword);
     }
 
     /**

+ 27 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/service/MembershipService.java

@@ -182,6 +182,7 @@ public class MembershipService {
         if (user == null) {
             return;
         }
+        MembershipLevel oldLevel = user.getMembershipLevel();
 
         LocalDateTime newExpireAt;
         MembershipLevel effectiveLevel = level;
@@ -255,6 +256,24 @@ public class MembershipService {
         if (needUpdate) {
             userMapper.updateById(user);
         }
+
+        // 审计日志:记录会员变动(仅 PRO 及以上)
+        if (effectiveLevel.getWeight() > MembershipLevel.PLUS.getWeight()) {
+            auditLogService.logSuccess(
+                    userId,
+                    user.getNickname() != null ? user.getNickname() : String.valueOf(userId),
+                    OperatorRole.SYSTEM,
+                    OperationType.USER_UPDATE,
+                    "MEMBERSHIP:" + userId,
+                    userId,
+                    Map.of("oldLevel", oldLevel != null ? oldLevel.getCode() : "PLUS",
+                           "newLevel", effectiveLevel.getCode(), "days", days,
+                           "source", source, "expireAt", newExpireAt),
+                    null,
+                    "会员变动:" + (oldLevel != null ? oldLevel.getCode() : "PLUS") + " → " + effectiveLevel.getCode() + " +" + days + "天 (" + source + ")");
+        }
+        log.info("会员变动: userId={}, {}→{}+{}d, expireAt={}, source={}",
+                userId, oldLevel != null ? oldLevel.getCode() : "PLUS", effectiveLevel.getCode(), days, newExpireAt, source);
     }
 
     /**
@@ -623,9 +642,17 @@ public class MembershipService {
                     // 无有效会员,降级为普通会员
                     User user = userMapper.selectById(userId);
                     if (user != null && user.getMembershipLevel() != MembershipLevel.PLUS) {
+                        MembershipLevel oldLevel = user.getMembershipLevel();
                         user.setMembershipLevel(MembershipLevel.PLUS);
                         user.setMembershipExpireAt(null);
                         userMapper.updateById(user);
+                        auditLogService.logSuccess(
+                                0L, OperatorRole.SYSTEM, OperationType.USER_UPDATE,
+                                "MEMBERSHIP:" + userId, userId,
+                                Map.of("oldLevel", oldLevel != null ? oldLevel.getCode() : "?",
+                                       "newLevel", "PLUS", "reason", "AUTO_EXPIRE"),
+                                null,
+                                "会员自动过期降级:" + oldLevel + " → PLUS");
                         log.info("用户{}会员已到期,降级为普通会员", userId);
                     }
                 }

+ 11 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/service/OperationAuditLogService.java

@@ -204,6 +204,17 @@ public class OperationAuditLogService {
         return auditLogMapper.selectByOperatorId(userId, limit);
     }
 
+    /**
+     * 按目标标识前缀分页查询操作历史(如 LOTTERY、INVITE 等模块)
+     */
+    public Page<OperationAuditLog> listByTargetPrefix(String targetPrefix, int pageNum, int pageSize) {
+        Page<OperationAuditLog> page = new Page<>(pageNum, pageSize);
+        LambdaQueryWrapper<OperationAuditLog> wrapper = new LambdaQueryWrapper<>();
+        wrapper.likeRight(OperationAuditLog::getTargetIdentifier, targetPrefix + ":%");
+        wrapper.orderByDesc(OperationAuditLog::getCreateTime);
+        return auditLogMapper.selectPage(page, wrapper);
+    }
+
     /**
      * 查询目标对象的操作历史
      */