Bläddra i källkod

feat(activity): 报名审核开关+名额原子占用+签到审核落地+清理总结死代码

依据 docs/整改方案-活动管理差异修复-2026-10-22.md 已锁定决策 D1-D4 落地活动域代码:

P0-1 报名审核与名额(D4): Activity 新增 requireReview(默认免审) 与 approvedCount(名额占用计数);register/batchRegister 按 requireReview 决定 pending/approved,免审即占名额;approve 审过才占名额;名额用原子 SQL(approved_count<max 条件自增)防超卖;cancel 支持 approved 并回退名额;getRegistrationRoster 改读 approved_count;ActivityOrderService.writeRegistrations 同步占名额。

P0-3 签到审核(D1): checkin 按活动级 requireCheckinReview 进入 pending_review,抽取 awardCheckinRewards;新增 reviewCheckin 审核通过发积分/能量/CF、驳回不发;ActivityController 新增 /checkin-review 端点。

P1-4 清理死代码(D2): ActivityDTO 删除 summarizing/总结中 注释并暴露 requireReview 字段与映射。

迁移: DatabaseInitializer 与 schema.sql 同步新增列,并将存量 require_checkin_review 归一为 0(免审)、按已通过报名回填 approved_count。

注意: 退款链路(P0-2)与删除规划师/履约子系统(P2-2)按方案仍待独立实施。
zxyj-web 3 veckor sedan
förälder
incheckning
cc794e617a

+ 13 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -2210,6 +2210,19 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         ensureColumn("activities", "require_registration", "TINYINT DEFAULT 1 COMMENT '是否需要报名: 1=需要, 0=直接参与'");
         ensureColumn("activities", "require_registration", "TINYINT DEFAULT 1 COMMENT '是否需要报名: 1=需要, 0=直接参与'");
         ensureColumn("activities", "require_checkin_review", "TINYINT DEFAULT 1 COMMENT '打卡是否需要审核: 1=需要, 0=直接通过'");
         ensureColumn("activities", "require_checkin_review", "TINYINT DEFAULT 1 COMMENT '打卡是否需要审核: 1=需要, 0=直接通过'");
         ensureColumn("activities", "energy_penalty_unregistered", "INT DEFAULT 5 COMMENT '未报名打卡的行能量扣减值'");
         ensureColumn("activities", "energy_penalty_unregistered", "INT DEFAULT 5 COMMENT '未报名打卡的行能量扣减值'");
+        // 2026-10-22 整改:报名审核开关 + 名额占用计数
+        ensureColumn("activities", "require_review", "TINYINT DEFAULT 0 COMMENT '报名是否需要审核: 1=需要, 0=免审即报名通过'");
+        ensureColumn("activities", "approved_count", "INT DEFAULT 0 COMMENT '已通过报名人数(名额占用统计)'");
+        // 修正 require_checkin_review 默认值并归一化存量(该字段此前未被读取,无业务依赖,避免存量活动突然进入待审核)
+        // 同时按已通过报名回填 approved_count,使名额统计与存量数据一致
+        try {
+            jdbcTemplate.execute("ALTER TABLE activities MODIFY COLUMN require_checkin_review TINYINT DEFAULT 0 COMMENT '打卡是否需要审核: 1=需要, 0=免审直接通过'");
+            jdbcTemplate.execute("UPDATE activities SET require_checkin_review = 0 WHERE require_checkin_review = 1");
+            jdbcTemplate.execute("UPDATE activities a SET a.approved_count = (SELECT cnt FROM (SELECT COUNT(*) AS cnt FROM activity_registrations r WHERE r.activity_id = a.id AND r.status = 'approved') t)");
+            log.info("已修正 require_checkin_review 默认值并回填 approved_count");
+        } catch (Exception e) {
+            log.warn("修正 activities 报名/签到审核字段失败: {}", e.getMessage());
+        }
 
 
         // family_members 表添加关系评分字段
         // family_members 表添加关系评分字段
         ensureColumn("family_members", "trust_score", "DECIMAL(5,2) DEFAULT 0 COMMENT '信任度 0-100'");
         ensureColumn("family_members", "trust_score", "DECIMAL(5,2) DEFAULT 0 COMMENT '信任度 0-100'");

+ 15 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/ActivityController.java

@@ -173,4 +173,19 @@ public class ActivityController {
         if (checkinResult.containsKey("error")) return Result.error(checkinResult.get("error").toString());
         if (checkinResult.containsKey("error")) return Result.error(checkinResult.get("error").toString());
         return Result.success(checkinResult);
         return Result.success(checkinResult);
     }
     }
+
+    @PostMapping("/checkin-review")
+    public Result<Map<String, Object>> checkinReview(@RequestBody Map<String, Object> params,
+                                                    @RequestAttribute("role") String role,
+                                                    @RequestAttribute(value = "roles", required = false) List<String> roles,
+                                                    @RequestAttribute(value = "userId", required = false) Long userId) {
+        if (!hasActivityManageRole(role, roles)) {
+            return Result.error("只有管理员或活动管理员可以审核签到");
+        }
+        Long taskId = ParamUtils.getLong(params.get("taskId"));
+        if (taskId == null) return Result.error("taskId不能为空");
+        String action = (String) params.get("action");
+        boolean approve = "approve".equals(action) || "pass".equals(action);
+        return activityService.reviewCheckin(taskId, userId, approve);
+    }
 }
 }

+ 6 - 2
cfc-backend/src/main/java/com/etotem/cfc/dto/ActivityDTO.java

@@ -33,6 +33,9 @@ public class ActivityDTO {
     /** 是否需要审核签到(0=否,1=是) */
     /** 是否需要审核签到(0=否,1=是) */
     private Integer requireCheckinReview;
     private Integer requireCheckinReview;
 
 
+    /** 报名是否需要审核(0=否,1=是) */
+    private Integer requireReview;
+
     /** 会员价(分) */
     /** 会员价(分) */
     private Integer memberPrice;
     private Integer memberPrice;
 
 
@@ -54,10 +57,10 @@ public class ActivityDTO {
     /** 创建人userId */
     /** 创建人userId */
     private Long creatorId;
     private Long creatorId;
 
 
-    /** 展示状态: open/registered/ongoing/checked_in/summarizing/ended/pending_start */
+    /** 展示状态: open/registered/ongoing/checked_in/ended/pending_start */
     private String displayStatus;
     private String displayStatus;
 
 
-    /** 展示状态文案: 报名中/已报名/进行中/已签到/总结中/已结束/待开始 */
+    /** 展示状态文案: 报名中/已报名/进行中/已签到/已结束/待开始 */
     private String displayLabel;
     private String displayLabel;
 
 
     /** 报名状态: not_registered/registered/pending/rejected/cancelled/checked_in */
     /** 报名状态: not_registered/registered/pending/rejected/cancelled/checked_in */
@@ -97,6 +100,7 @@ public class ActivityDTO {
         dto.setVendorName(activity.getVendorName());
         dto.setVendorName(activity.getVendorName());
         dto.setRequireRegistration(activity.getRequireRegistration());
         dto.setRequireRegistration(activity.getRequireRegistration());
         dto.setRequireCheckinReview(activity.getRequireCheckinReview());
         dto.setRequireCheckinReview(activity.getRequireCheckinReview());
+        dto.setRequireReview(activity.getRequireReview());
         dto.setMemberPrice(activity.getMemberPrice());
         dto.setMemberPrice(activity.getMemberPrice());
         dto.setRegistrationDeadline(activity.getRegistrationDeadline() != null ? activity.getRegistrationDeadline().toString() : null);
         dto.setRegistrationDeadline(activity.getRegistrationDeadline() != null ? activity.getRegistrationDeadline().toString() : null);
         dto.setChildFee(activity.getChildFee());
         dto.setChildFee(activity.getChildFee());

+ 6 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/Activity.java

@@ -58,6 +58,9 @@ public class Activity implements Serializable {
 
 
     private Integer currentParticipants;
     private Integer currentParticipants;
 
 
+    /** 已通过报名人数(名额占用统计,区别于 currentParticipants 的签到计数) */
+    private Integer approvedCount;
+
     private Integer price;
     private Integer price;
 
 
     /** 会员价(分) */
     /** 会员价(分) */
@@ -96,6 +99,9 @@ public class Activity implements Serializable {
     /** 是否需要审核签到(0=否,1=是) */
     /** 是否需要审核签到(0=否,1=是) */
     private Integer requireCheckinReview;
     private Integer requireCheckinReview;
 
 
+    /** 报名是否需要审核(0=否,1=是);默认 0,免审即报名通过并占用名额 */
+    private Integer requireReview;
+
     /** 未报名签到扣减能量值(正数) */
     /** 未报名签到扣减能量值(正数) */
     private Integer energyPenaltyUnregistered;
     private Integer energyPenaltyUnregistered;
 
 

+ 8 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ActivityOrderService.java

@@ -47,6 +47,9 @@ public class ActivityOrderService {
     @Resource
     @Resource
     private PaymentService paymentService;
     private PaymentService paymentService;
 
 
+    @Resource
+    private ActivityRegistrationService registrationService;
+
     public Result<Map<String, Object>> create(Long activityId, Long userId, List<Map<String, Object>> registrants) {
     public Result<Map<String, Object>> create(Long activityId, Long userId, List<Map<String, Object>> registrants) {
         Activity activity = activityMapper.selectById(activityId);
         Activity activity = activityMapper.selectById(activityId);
         if (activity == null) return Result.error("活动不存在");
         if (activity == null) return Result.error("活动不存在");
@@ -177,6 +180,11 @@ public class ActivityOrderService {
             Object memberIdObj = item.get("memberId");
             Object memberIdObj = item.get("memberId");
             Long memberId = memberIdObj != null ? Long.valueOf(memberIdObj.toString()) : null;
             Long memberId = memberIdObj != null ? Long.valueOf(memberIdObj.toString()) : null;
             if (memberId != null && isRegistered(activityId, memberId)) continue;
             if (memberId != null && isRegistered(activityId, memberId)) continue;
+            // 报名通过(免审/支付成功)时占用名额;名额已满则跳过该报名人(退款见 P0-2)
+            if ("approved".equals(status) && !registrationService.occupyQuota(activityId)) {
+                log.warn("活动 {} 名额已满,跳过报名人 {}", activityId, item.get("name"));
+                continue;
+            }
             ActivityRegistration ar = new ActivityRegistration();
             ActivityRegistration ar = new ActivityRegistration();
             ar.setActivityId(activityId);
             ar.setActivityId(activityId);
             ar.setUserId(userId);
             ar.setUserId(userId);

+ 76 - 9
cfc-backend/src/main/java/com/etotem/cfc/service/ActivityRegistrationService.java

@@ -1,6 +1,7 @@
 package com.etotem.cfc.service;
 package com.etotem.cfc.service;
 
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.etotem.cfc.common.DataMaskUtil;
 import com.etotem.cfc.common.DataMaskUtil;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.common.Result;
@@ -45,6 +46,7 @@ public class ActivityRegistrationService {
         }
         }
         Result<Void> gate = checkRegistrationGate(activity, userId);
         Result<Void> gate = checkRegistrationGate(activity, userId);
         if (gate != null) return Result.error(gate.getMessage());
         if (gate != null) return Result.error(gate.getMessage());
+        boolean needReview = activity.getRequireReview() != null && activity.getRequireReview() == 1;
         List<Long> ids = new ArrayList<>();
         List<Long> ids = new ArrayList<>();
         for (Map<String, Object> reg : registrants) {
         for (Map<String, Object> reg : registrants) {
             String name = (String) reg.get("name");
             String name = (String) reg.get("name");
@@ -57,11 +59,21 @@ public class ActivityRegistrationService {
                     return Result.error("已报名: " + name);
                     return Result.error("已报名: " + name);
                 }
                 }
             }
             }
+            // 免审活动:报名即通过并占用名额;名额已满则中止(已写入的本次批次有效)
+            if (!needReview) {
+                if (!occupyQuota(activityId)) {
+                    return Result.error("名额已满,报名失败(含: " + name + ")");
+                }
+            }
             ActivityRegistration ar = new ActivityRegistration();
             ActivityRegistration ar = new ActivityRegistration();
             ar.setActivityId(activityId); ar.setUserId(userId); ar.setChildId(memberId);
             ar.setActivityId(activityId); ar.setUserId(userId); ar.setChildId(memberId);
             ar.setRegistrantName(name.trim()); ar.setRegistrantPhone(phone != null ? phone.trim() : null);
             ar.setRegistrantName(name.trim()); ar.setRegistrantPhone(phone != null ? phone.trim() : null);
-            ar.setStatus("pending"); ar.setRegisteredAt(LocalDateTime.now());
+            ar.setStatus(needReview ? "pending" : "approved"); ar.setRegisteredAt(LocalDateTime.now());
             ar.setCreatedAt(LocalDateTime.now()); ar.setUpdatedAt(LocalDateTime.now());
             ar.setCreatedAt(LocalDateTime.now()); ar.setUpdatedAt(LocalDateTime.now());
+            if (!needReview) {
+                ar.setApprovedAt(LocalDateTime.now());
+                ar.setApprovedBy(userId);
+            }
             registrationMapper.insert(ar);
             registrationMapper.insert(ar);
             ids.add(ar.getId());
             ids.add(ar.getId());
         }
         }
@@ -82,22 +94,43 @@ public class ActivityRegistrationService {
         Result<Void> gate = checkRegistrationGate(activity, userId);
         Result<Void> gate = checkRegistrationGate(activity, userId);
         if (gate != null) return Result.error(gate.getMessage());
         if (gate != null) return Result.error(gate.getMessage());
         if (isRegistered(activityId, memberId)) return Result.error("您已报名此活动");
         if (isRegistered(activityId, memberId)) return Result.error("您已报名此活动");
+        boolean needReview = activity.getRequireReview() != null && activity.getRequireReview() == 1;
+        // 免审活动:报名即通过并占用名额
+        if (!needReview) {
+            if (!occupyQuota(activityId)) {
+                return Result.error("名额已满,报名失败");
+            }
+        }
         ActivityRegistration reg = new ActivityRegistration();
         ActivityRegistration reg = new ActivityRegistration();
         reg.setActivityId(activityId); reg.setUserId(userId); reg.setChildId(memberId);
         reg.setActivityId(activityId); reg.setUserId(userId); reg.setChildId(memberId);
-        reg.setStatus("pending"); reg.setRegisteredAt(LocalDateTime.now());
+        reg.setStatus(needReview ? "pending" : "approved"); reg.setRegisteredAt(LocalDateTime.now());
         reg.setCreatedAt(LocalDateTime.now()); reg.setUpdatedAt(LocalDateTime.now());
         reg.setCreatedAt(LocalDateTime.now()); reg.setUpdatedAt(LocalDateTime.now());
+        if (!needReview) {
+            reg.setApprovedAt(LocalDateTime.now());
+            reg.setApprovedBy(userId);
+        }
         registrationMapper.insert(reg);
         registrationMapper.insert(reg);
         Map<String, Object> result = new HashMap<>();
         Map<String, Object> result = new HashMap<>();
-        result.put("registrationId", reg.getId()); result.put("status", "pending");
+        result.put("registrationId", reg.getId()); result.put("status", reg.getStatus());
         return Result.success(result);
         return Result.success(result);
     }
     }
 
 
     @Transactional
     @Transactional
     public Result<Void> cancel(Long activityId, Long userId, Long memberId) {
     public Result<Void> cancel(Long activityId, Long userId, Long memberId) {
-        ActivityRegistration reg = findRegistration(activityId, memberId, userId, "pending");
+        ActivityRegistration reg = registrationMapper.selectOne(new LambdaQueryWrapper<ActivityRegistration>()
+                .eq(ActivityRegistration::getActivityId, activityId)
+                .eq(ActivityRegistration::getChildId, memberId)
+                .eq(ActivityRegistration::getUserId, userId)
+                .in(ActivityRegistration::getStatus, "pending", "approved")
+                .last("LIMIT 1"));
         if (reg == null) return Result.error("报名记录不存在");
         if (reg == null) return Result.error("报名记录不存在");
+        String prevStatus = reg.getStatus();
         reg.setStatus("cancelled"); reg.setUpdatedAt(LocalDateTime.now());
         reg.setStatus("cancelled"); reg.setUpdatedAt(LocalDateTime.now());
         registrationMapper.updateById(reg);
         registrationMapper.updateById(reg);
+        // 已通过报名取消时回退名额(退款链路见 P0-2)
+        if ("approved".equals(prevStatus)) {
+            releaseQuota(activityId);
+        }
         return Result.success(null);
         return Result.success(null);
     }
     }
 
 
@@ -106,6 +139,10 @@ public class ActivityRegistrationService {
         ActivityRegistration reg = registrationMapper.selectById(registrationId);
         ActivityRegistration reg = registrationMapper.selectById(registrationId);
         if (reg == null) return Result.error("报名记录不存在");
         if (reg == null) return Result.error("报名记录不存在");
         if (!"pending".equals(reg.getStatus())) return Result.error("当前状态不允许审核");
         if (!"pending".equals(reg.getStatus())) return Result.error("当前状态不允许审核");
+        // 审核通过即占用名额(原子校验,满员则无法通过)
+        if (!occupyQuota(reg.getActivityId())) {
+            return Result.error("名额已满,无法通过审核");
+        }
         reg.setStatus("approved"); reg.setApprovedAt(LocalDateTime.now());
         reg.setStatus("approved"); reg.setApprovedAt(LocalDateTime.now());
         reg.setApprovedBy(adminUserId); reg.setUpdatedAt(LocalDateTime.now());
         reg.setApprovedBy(adminUserId); reg.setUpdatedAt(LocalDateTime.now());
         registrationMapper.updateById(reg);
         registrationMapper.updateById(reg);
@@ -131,6 +168,37 @@ public class ActivityRegistrationService {
         return Result.success(null);
         return Result.success(null);
     }
     }
 
 
+    /**
+     * 原子占用一个名额:仅当 approved_count < max_participants 时自增,返回是否成功。
+     * 无人数限制(maxParticipants 为空或 <=0)视为不限,直接自增计数。
+     */
+    @Transactional
+    public boolean occupyQuota(Long activityId) {
+        Activity activity = activityMapper.selectById(activityId);
+        if (activity == null) return false;
+        Integer max = activity.getMaxParticipants();
+        if (max != null && max > 0) {
+            int rows = activityMapper.update(null, new UpdateWrapper<Activity>()
+                    .eq("id", activityId)
+                    .lt("approved_count", max)
+                    .setSql("approved_count = approved_count + 1"));
+            return rows > 0;
+        }
+        activityMapper.update(null, new UpdateWrapper<Activity>()
+                .eq("id", activityId)
+                .setSql("approved_count = approved_count + 1"));
+        return true;
+    }
+
+    /** 原子回退一个名额(仅当 approved_count > 0 时减一)。 */
+    @Transactional
+    public void releaseQuota(Long activityId) {
+        activityMapper.update(null, new UpdateWrapper<Activity>()
+                .eq("id", activityId)
+                .gt("approved_count", 0)
+                .setSql("approved_count = approved_count - 1"));
+    }
+
     public Result<Map<String, Object>> getMyRegistrations(Long userId, Long memberId, Integer page, Integer size) {
     public Result<Map<String, Object>> getMyRegistrations(Long userId, Long memberId, Integer page, Integer size) {
         if (page == null || page < 1) page = 1;
         if (page == null || page < 1) page = 1;
         if (size == null || size < 1) size = 10;
         if (size == null || size < 1) size = 10;
@@ -202,9 +270,6 @@ public class ActivityRegistrationService {
         Activity activity = activityMapper.selectById(activityId);
         Activity activity = activityMapper.selectById(activityId);
         if (activity == null) return Result.error("活动不存在");
         if (activity == null) return Result.error("活动不存在");
 
 
-        long approvedCount = registrationMapper.selectCount(new LambdaQueryWrapper<ActivityRegistration>()
-            .eq(ActivityRegistration::getActivityId, activityId)
-            .eq(ActivityRegistration::getStatus, "approved"));
         long pendingCount = registrationMapper.selectCount(new LambdaQueryWrapper<ActivityRegistration>()
         long pendingCount = registrationMapper.selectCount(new LambdaQueryWrapper<ActivityRegistration>()
             .eq(ActivityRegistration::getActivityId, activityId)
             .eq(ActivityRegistration::getActivityId, activityId)
             .eq(ActivityRegistration::getStatus, "pending"));
             .eq(ActivityRegistration::getStatus, "pending"));
@@ -223,15 +288,17 @@ public class ActivityRegistrationService {
         }).collect(Collectors.toList());
         }).collect(Collectors.toList());
 
 
         Integer max = activity.getMaxParticipants();
         Integer max = activity.getMaxParticipants();
+        // 名额占用以 approved_count 列(原子维护)为准,与报名审核状态一致
+        Integer approved = activity.getApprovedCount() != null ? activity.getApprovedCount() : 0;
         Integer remaining = null;
         Integer remaining = null;
         if (max != null && max > 0) {
         if (max != null && max > 0) {
-            remaining = (int) Math.max(0, max - approvedCount);
+            remaining = (int) Math.max(0, max - approved);
         }
         }
 
 
         Map<String, Object> data = new HashMap<>();
         Map<String, Object> data = new HashMap<>();
         data.put("activityId", activityId);
         data.put("activityId", activityId);
         data.put("maxParticipants", max != null ? max : 0);
         data.put("maxParticipants", max != null ? max : 0);
-        data.put("approvedCount", approvedCount);
+        data.put("approvedCount", approved);
         data.put("pendingCount", pendingCount);
         data.put("pendingCount", pendingCount);
         data.put("remaining", remaining);
         data.put("remaining", remaining);
         data.put("registrants", registrants);
         data.put("registrants", registrants);

+ 73 - 22
cfc-backend/src/main/java/com/etotem/cfc/service/ActivityService.java

@@ -317,7 +317,10 @@ public class ActivityService extends ServiceImpl<ActivityMapper, Activity> {
             }
             }
         }
         }
 
 
-        // 5. Create task with status='completed' (auto-completed by check-in)
+        // 是否需要审核签到(活动级开关,默认免审)
+        boolean needReview = activity.getRequireCheckinReview() != null && activity.getRequireCheckinReview() == 1;
+
+        // 6. 创建签到任务;开启审核时进入 pending_review,待后台审核通过才发积分/能量
         Task task = new Task();
         Task task = new Task();
         if (child != null) {
         if (child != null) {
             task.setFamilyId(child.getFamilyId());
             task.setFamilyId(child.getFamilyId());
@@ -331,13 +334,17 @@ public class ActivityService extends ServiceImpl<ActivityMapper, Activity> {
         task.setPoints(points);
         task.setPoints(points);
         task.setRepeatType("none");
         task.setRepeatType("none");
         task.setCategory("行动类");
         task.setCategory("行动类");
-        task.setNeedReview(0);
+        task.setNeedReview(needReview ? 1 : 0);
         task.setReviewByCategory(0);
         task.setReviewByCategory(0);
         task.setTaskType("onetime");
         task.setTaskType("onetime");
         task.setSourceType("activity");
         task.setSourceType("activity");
         task.setSourceId(activityId);
         task.setSourceId(activityId);
-        task.setStatus("completed");
-        task.setCompletedAt(new Date());
+        if (needReview) {
+            task.setStatus("pending_review");
+        } else {
+            task.setStatus("completed");
+            task.setCompletedAt(new Date());
+        }
         task.setCreatedAt(new Date());
         task.setCreatedAt(new Date());
         task.setUpdatedAt(new Date());
         task.setUpdatedAt(new Date());
 
 
@@ -350,21 +357,40 @@ public class ActivityService extends ServiceImpl<ActivityMapper, Activity> {
 
 
         taskMapper.insert(task);
         taskMapper.insert(task);
 
 
-        // 6. Update activity currentParticipants
+        // 7. 签到计数(无论是否需审核都计入 currentParticipants)
         activity.setCurrentParticipants(activity.getCurrentParticipants() != null ? activity.getCurrentParticipants() + 1 : 1);
         activity.setCurrentParticipants(activity.getCurrentParticipants() != null ? activity.getCurrentParticipants() + 1 : 1);
         activity.setUpdatedAt(new Date());
         activity.setUpdatedAt(new Date());
         this.updateById(activity);
         this.updateById(activity);
 
 
-        // 7. Update child totalPoints
+        // 8. 发放奖励:免审直接发放;需审核在审核通过(reviewCheckin)时发放
+        if (needReview) {
+            result.put("needReview", true);
+            result.put("pointsEarned", 0);
+            log.info("活动签到进入待审核: activityId={}, memberId={}, taskId={}", activityId, memberId, task.getId());
+        } else {
+            awardCheckinRewards(activity, child, task, userId, points);
+            result.put("needReview", false);
+            result.put("pointsEarned", points);
+        }
+
+        // 9. Return result
+        result.put("taskId", task.getId());
+        return result;
+    }
+
+    /**
+     * 签到奖励发放(积分/能量/CF)。免审签到在 checkin 内调用;需审核签到在审核通过时调用。
+     */
+    private void awardCheckinRewards(Activity activity, FamilyMember child, Task task, Long userId, int points) {
+        // 更新孩子总积分
         if (child != null) {
         if (child != null) {
             child.setTotalPoints(child.getTotalPoints() + points);
             child.setTotalPoints(child.getTotalPoints() + points);
             child.setUpdatedAt(new Date());
             child.setUpdatedAt(new Date());
             familyMemberMapper.updateById(child);
             familyMemberMapper.updateById(child);
         }
         }
-
-        // 8. Insert PointsLog
+        // 积分流水
         PointsLog pointsLog = new PointsLog();
         PointsLog pointsLog = new PointsLog();
-        pointsLog.setChildId(memberId);
+        pointsLog.setChildId(task.getChildId());
         pointsLog.setTaskId(task.getId());
         pointsLog.setTaskId(task.getId());
         pointsLog.setAmount(points);
         pointsLog.setAmount(points);
         pointsLog.setType("earn");
         pointsLog.setType("earn");
@@ -372,31 +398,56 @@ public class ActivityService extends ServiceImpl<ActivityMapper, Activity> {
         pointsLog.setDescription("活动签到: " + activity.getTitle());
         pointsLog.setDescription("活动签到: " + activity.getTitle());
         pointsLog.setCreatedAt(new Date());
         pointsLog.setCreatedAt(new Date());
         pointsLogMapper.insert(pointsLog);
         pointsLogMapper.insert(pointsLog);
-
-        // 9. Award energy via EnergyService
+        // 发放能量
         try {
         try {
-            energyService.awardEnergy(memberId, "activity", activityId, points,
+            energyService.awardEnergy(task.getChildId(), "activity", activity.getId(), points,
                 "活动签到: " + activity.getTitle(), null);
                 "活动签到: " + activity.getTitle(), null);
         } catch (Exception e) {
         } catch (Exception e) {
-            log.warn("能量发放失败(不影响积分): memberId={}, activityId={}, error={}",
-            memberId, activityId, e.getMessage());
+            log.warn("能量发放失败(不影响积分): childId={}, activityId={}, error={}",
+                task.getChildId(), activity.getId(), e.getMessage());
         }
         }
-
-        // 9b. 活动下发 CF 值(平台积分)
+        // 活动下发 CF 值(平台积分)
         if (activity.getPlatformPoints() != null && activity.getPlatformPoints() > 0 && userId != null) {
         if (activity.getPlatformPoints() != null && activity.getPlatformPoints() > 0 && userId != null) {
             try {
             try {
                 platformPointsService.earn(userId, activity.getPlatformPoints(),
                 platformPointsService.earn(userId, activity.getPlatformPoints(),
-                        "activity", activityId, "活动签到: " + activity.getTitle());
+                        "activity", activity.getId(), "活动签到: " + activity.getTitle());
             } catch (Exception e) {
             } catch (Exception e) {
                 log.warn("活动CF值发放失败: userId={}, activityId={}, error={}",
                 log.warn("活动CF值发放失败: userId={}, activityId={}, error={}",
-                        userId, activityId, e.getMessage());
+                        userId, activity.getId(), e.getMessage());
             }
             }
         }
         }
+    }
 
 
-        // 10. Return result
-        result.put("taskId", task.getId());
-        result.put("pointsEarned", points);
-        return result;
+    /**
+     * 审核签到(活动级 requireCheckinReview 开启时):审批通过发放积分/能量/CF;驳回则不发。
+     */
+    @Transactional
+    public Result<Map<String, Object>> reviewCheckin(Long taskId, Long reviewerId, boolean approve) {
+        Task task = taskMapper.selectById(taskId);
+        if (task == null) return Result.error("签到任务不存在");
+        if (!"activity".equals(task.getSourceType())) return Result.error("非活动签到任务");
+        if (!"pending_review".equals(task.getStatus())) return Result.error("当前状态不可审核");
+        Map<String, Object> result = new HashMap<>();
+        if (approve) {
+            Activity activity = this.getById(task.getSourceId());
+            FamilyMember child = task.getChildId() != null ? familyMemberMapper.selectById(task.getChildId()) : null;
+            task.setStatus("completed");
+            task.setCompletedAt(new Date());
+            task.setUpdatedAt(new Date());
+            taskMapper.updateById(task);
+            int points = task.getPoints() != null ? task.getPoints() : 5;
+            if (activity != null) {
+                awardCheckinRewards(activity, child, task, task.getCreatorId(), points);
+            }
+            result.put("approved", true);
+            return Result.success(result);
+        } else {
+            task.setStatus("rejected");
+            task.setUpdatedAt(new Date());
+            taskMapper.updateById(task);
+            result.put("approved", false);
+            return Result.success(result);
+        }
     }
     }
 
 
     private void autoEndExpiredActivities() {
     private void autoEndExpiredActivities() {

+ 8 - 0
cfc-backend/src/main/resources/schema.sql

@@ -2626,6 +2626,14 @@ ALTER TABLE activities ADD COLUMN require_registration TINYINT DEFAULT 1 COMMENT
 ALTER TABLE activities ADD COLUMN require_checkin_review TINYINT DEFAULT 1 COMMENT '打卡是否需要审核: 1=需要, 0=直接通过';
 ALTER TABLE activities ADD COLUMN require_checkin_review TINYINT DEFAULT 1 COMMENT '打卡是否需要审核: 1=需要, 0=直接通过';
 ALTER TABLE activities ADD COLUMN energy_penalty_unregistered INT DEFAULT 5 COMMENT '未报名打卡的行能量扣减值';
 ALTER TABLE activities ADD COLUMN energy_penalty_unregistered INT DEFAULT 5 COMMENT '未报名打卡的行能量扣减值';
 
 
+-- 2026-10-22 整改:报名审核开关 + 名额占用计数
+ALTER TABLE activities ADD COLUMN require_review TINYINT DEFAULT 0 COMMENT '报名是否需要审核: 1=需要, 0=免审即报名通过';
+ALTER TABLE activities ADD COLUMN approved_count INT DEFAULT 0 COMMENT '已通过报名人数(名额占用统计)';
+-- 修正打卡审核默认值与存量归一化(2026-10-22)
+ALTER TABLE activities MODIFY COLUMN require_checkin_review TINYINT DEFAULT 0 COMMENT '打卡是否需要审核: 1=需要, 0=免审直接通过';
+UPDATE activities SET require_checkin_review = 0 WHERE require_checkin_review = 1;
+UPDATE activities a SET a.approved_count = (SELECT cnt FROM (SELECT COUNT(*) AS cnt FROM activity_registrations r WHERE r.activity_id = a.id AND r.status = 'approved') t);
+
 -- ============================================================
 -- ============================================================
 -- 新增:系统配置表(角色年龄阈值等)
 -- 新增:系统配置表(角色年龄阈值等)
 -- ============================================================
 -- ============================================================