Ver Fonte

feat(api): 方案确认生成会员任务 + 补全会员校验绕过路径 (P0)

- ProblemPackageController 新增 /confirm:生成 memberOnly=1 方案任务并推进旅程 stage=6(幂等复用激活方案)
- TaskPlanService.generatePlanTasks:plan items 生成可执行任务,打通 ProblemCompletionService 发奖链路
- TaskController:complete-minigame / batch-complete 补 checkMemberOnly
- GuideFamilyTaskController:单个/批量审核补会员校验

Ultraworked with [Sisyphus](https://github.com/OhMyOpenCode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
E2E Test Bot há 1 mês atrás
pai
commit
bb2bcaf022

+ 106 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/ProblemPackageController.java

@@ -1,17 +1,29 @@
 package com.etotem.cfc.controller;
 
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.ApplyPackageDTO;
+import com.etotem.cfc.entity.ProblemDomain;
+import com.etotem.cfc.entity.TaskPlanInstance;
 import com.etotem.cfc.entity.TaskTemplateItem;
 import com.etotem.cfc.entity.TaskTemplatePackage;
+import com.etotem.cfc.entity.UserIntent;
+import com.etotem.cfc.mapper.ProblemDomainMapper;
+import com.etotem.cfc.mapper.TaskPlanInstanceMapper;
+import com.etotem.cfc.mapper.UserIntentMapper;
+import com.etotem.cfc.service.MembershipService;
+import com.etotem.cfc.service.TaskPlanService;
 import com.etotem.cfc.service.TaskTemplatePackageService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import javax.annotation.Resource;
 import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
 import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RestController;
 
+import java.util.Date;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -20,7 +32,7 @@ import com.etotem.cfc.util.ParamUtils;
 /**
  * 问题域套餐 — 改版v1(套餐详情:介绍/使用方式/包含检测/任务模板/奖励说明)
  */
-@Tag(name = "问题域套餐", description = "套餐详情查询")
+@Tag(name = "问题域套餐", description = "套餐详情查询与方案确认")
 @RestController
 @RequestMapping("/api/problem-package")
 public class ProblemPackageController {
@@ -28,6 +40,21 @@ public class ProblemPackageController {
     @Resource
     private TaskTemplatePackageService taskTemplatePackageService;
 
+    @Resource
+    private TaskPlanService taskPlanService;
+
+    @Resource
+    private TaskPlanInstanceMapper planInstanceMapper;
+
+    @Resource
+    private UserIntentMapper userIntentMapper;
+
+    @Resource
+    private ProblemDomainMapper problemDomainMapper;
+
+    @Resource
+    private MembershipService membershipService;
+
     @Operation(summary = "套餐详情(名称/介绍/周期/包含任务模板/奖励说明)")
     @PostMapping("/detail")
     public Result<Map<String, Object>> detail(@RequestBody Map<String, Object> params) {
@@ -55,4 +82,82 @@ public class ProblemPackageController {
         data.put("items", items);
         return Result.success(data);
     }
+
+    /**
+     * 方案确认(B17/B20 补全):用户确认方案后生成可执行任务并推进旅程 stage=6。
+     * <p>
+     * 幂等:同一家庭同一套餐已存在激活中的方案实例则复用,不重复生成任务。
+     * 参数 packageId 可省略:缺省取用户意图(问题域)绑定的第一个套餐。
+     */
+    @Operation(summary = "方案确认:生成方案任务(memberOnly=1)并推进旅程 stage=6")
+    @PostMapping("/confirm")
+    public Result<Map<String, Object>> confirm(@RequestAttribute("userId") Long userId,
+                                               @RequestBody Map<String, Object> params) {
+        Long familyId = membershipService.getUserFamilyId(userId);
+        if (familyId == null) {
+            return Result.noFamily("请先创建或加入家庭");
+        }
+        Long packageId = ParamUtils.getLong(params.get("packageId"));
+
+        // 缺省套餐:从用户意图(问题域)取绑定的第一个套餐
+        if (packageId == null) {
+            UserIntent intent = findIntent(userId);
+            if (intent == null || intent.getProblemDomainCode() == null
+                    || intent.getProblemDomainCode().isEmpty()) {
+                return Result.error("未找到问题域,请先选择问题域");
+            }
+            ProblemDomain domain = problemDomainMapper.selectOne(
+                    new LambdaQueryWrapper<ProblemDomain>()
+                            .eq(ProblemDomain::getCode, intent.getProblemDomainCode())
+                            .last("LIMIT 1"));
+            if (domain == null || domain.getTaskTemplatePackageIds() == null
+                    || domain.getTaskTemplatePackageIds().trim().isEmpty()) {
+                return Result.error("该问题域未绑定套餐,请先在管理端配置");
+            }
+            packageId = Long.parseLong(domain.getTaskTemplatePackageIds().trim().split(",")[0]);
+        }
+
+        // 幂等:同一家庭同一套餐已有激活中的方案实例则复用
+        TaskPlanInstance plan = planInstanceMapper.selectOne(
+                new LambdaQueryWrapper<TaskPlanInstance>()
+                        .eq(TaskPlanInstance::getFamilyId, familyId)
+                        .eq(TaskPlanInstance::getPackageId, packageId)
+                        .eq(TaskPlanInstance::getStatus, "active")
+                        .last("LIMIT 1"));
+        Long planId;
+        if (plan == null) {
+            ApplyPackageDTO dto = new ApplyPackageDTO();
+            dto.setPackageId(packageId);
+            TaskPlanInstance created = taskPlanService.applyPackage(dto, familyId);
+            planId = created.getId();
+        } else {
+            planId = plan.getId();
+        }
+
+        // 生成方案任务(memberOnly=1,幂等)
+        int taskCount = taskPlanService.generatePlanTasks(planId, familyId, userId);
+
+        // 推进旅程 stage=6(方案已确认,任务已生成)
+        UserIntent intent = findIntent(userId);
+        if (intent != null && (intent.getJourneyStage() == null || intent.getJourneyStage() < 6)) {
+            Date now = new Date();
+            intent.setJourneyStage(6);
+            intent.setStageUpdatedAt(now);
+            intent.setUpdatedAt(now);
+            userIntentMapper.updateById(intent);
+        }
+
+        Map<String, Object> data = new HashMap<>();
+        data.put("planId", planId);
+        data.put("taskCount", taskCount);
+        data.put("journeyStage", 6);
+        return Result.success(data);
+    }
+
+    private UserIntent findIntent(Long userId) {
+        return userIntentMapper.selectOne(
+                new LambdaQueryWrapper<UserIntent>()
+                        .eq(UserIntent::getUserId, userId)
+                        .last("LIMIT 1"));
+    }
 }

+ 32 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/guide/GuideFamilyTaskController.java

@@ -13,6 +13,7 @@ import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.mapper.TaskMapper;
 import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.MembershipService;
 import com.etotem.cfc.service.TaskService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
@@ -40,6 +41,27 @@ public class GuideFamilyTaskController {
     @Resource
     private UserMapper userMapper;
 
+    @Resource
+    private MembershipService membershipService;
+
+    /**
+     * 会员任务校验:task.memberOnly==1 且家庭非会员 → 拒绝(提示开通会员)
+     */
+    private Result<?> checkMemberOnly(Long taskId) {
+        Task task = taskMapper.selectById(taskId);
+        if (task == null) {
+            return Result.error("任务不存在");
+        }
+        if (task.getMemberOnly() != null && task.getMemberOnly() == 1) {
+            com.etotem.cfc.dto.MembershipLevelDTO level = membershipService.getCurrentLevel(task.getFamilyId());
+            boolean isMember = level != null && !"FREE".equals(level.getLevelCode());
+            if (!isMember) {
+                return Result.error("该任务是会员任务,请先开通会员后再操作");
+            }
+        }
+        return null;
+    }
+
     /**
      * 获取指导师关联的家庭列表(带简要任务统计)
      */
@@ -241,6 +263,10 @@ public class GuideFamilyTaskController {
             @RequestBody TaskReviewDTO dto) {
 
         if (!"teacher".equals(role)) { return Result.error("Access denied"); }
+        Result<?> memberCheck = checkMemberOnly(taskId);
+        if (memberCheck != null) {
+            return Result.error(memberCheck.getMessage());
+        }
         boolean success = taskService.reviewTask(taskId, dto);
         return Result.success(success);
     }
@@ -277,6 +303,12 @@ public class GuideFamilyTaskController {
 
         for (Long taskId : taskIds) {
             try {
+                // 会员任务校验:审核通过会员任务需家庭为会员
+                Result<?> memberCheck = checkMemberOnly(taskId);
+                if (memberCheck != null) {
+                    failCount++;
+                    continue;
+                }
                 TaskReviewDTO dto = new TaskReviewDTO();
                 dto.setApproved(approved);
                 dto.setComment(approved ? "指导师审核通过" : "指导师审核拒绝");

+ 11 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/task/TaskController.java

@@ -185,6 +185,10 @@ public class TaskController {
             @PathVariable Long id,
             @RequestBody Map<String, Object> body,
             @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
+        Result<?> memberCheck = checkMemberOnly(id);
+        if (memberCheck != null) {
+            return Result.error(memberCheck.getMessage());
+        }
         Long memberId = body.get("memberId") != null ? ParamUtils.getLong(body.get("memberId")) : currentMemberId;
         Integer completionTime = body.get("completionTime") != null ?
             Integer.valueOf(body.get("completionTime").toString()) : null;
@@ -207,6 +211,13 @@ public class TaskController {
         if (taskIds == null || taskIds.isEmpty()) {
             return Result.error("taskIds不能为空");
         }
+        // 会员任务校验:任一任务为会员任务且非会员 → 拒绝整批
+        for (Long taskId : taskIds) {
+            Result<?> memberCheck = checkMemberOnly(taskId);
+            if (memberCheck != null) {
+                return Result.error(memberCheck.getMessage());
+            }
+        }
         try {
             taskService.batchComplete(taskIds, userId);
             return Result.success(true);

+ 71 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/TaskPlanService.java

@@ -7,10 +7,14 @@ import com.etotem.cfc.entity.TaskPlanInstance;
 import com.etotem.cfc.entity.TaskPlanItem;
 import com.etotem.cfc.entity.TaskTemplateItem;
 import com.etotem.cfc.entity.TaskTemplatePackage;
+import com.etotem.cfc.entity.Task;
 import com.etotem.cfc.mapper.TaskPlanInstanceMapper;
 import com.etotem.cfc.mapper.TaskPlanItemMapper;
 import com.etotem.cfc.mapper.TaskTemplateItemMapper;
+import com.etotem.cfc.mapper.TaskMapper;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.etotem.cfc.dto.ApplyPackageDTO;
+import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
@@ -19,6 +23,7 @@ import java.util.Date;
 import java.util.List;
 import java.util.stream.Collectors;
 
+@Slf4j
 @Service
 public class TaskPlanService implements TaskPlanServiceInterface {
 
@@ -34,6 +39,9 @@ public class TaskPlanService implements TaskPlanServiceInterface {
     @Resource
     private TaskTemplatePackageService packageService;
 
+    @Resource
+    private TaskMapper taskMapper;
+
     /**
      * 家长应用任务模板 - 创建任务计划实例
      */
@@ -108,6 +116,69 @@ public class TaskPlanService implements TaskPlanServiceInterface {
         );
     }
 
+    /**
+     * 方案确认:将套餐计划实例生成可执行任务(B20 补全)
+     * 每个 plan item 生成一条 Task:memberOnly=1、sourceType=plan、sourceId=planId,
+     * 使 ProblemCompletionService 能按 (familyId, sourceType=plan) 统计完成度发奖。
+     * 幂等:同一计划已生成过任务则跳过。
+     */
+    @Transactional
+    public int generatePlanTasks(Long planId, Long familyId, Long userId) {
+        TaskPlanInstance plan = instanceMapper.selectById(planId);
+        if (plan == null) {
+            throw new IllegalStateException("方案不存在");
+        }
+        // 幂等:该计划已生成过任务则跳过
+        Long existing = taskMapper.selectCount(
+                new LambdaQueryWrapper<Task>()
+                        .eq(Task::getSourceType, "plan")
+                        .eq(Task::getSourceId, planId));
+        if (existing != null && existing > 0) {
+            return 0;
+        }
+        List<TaskPlanItem> items = planItemMapper.selectList(
+                new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<TaskPlanItem>()
+                        .eq("instance_id", planId)
+                        .orderByAsc("id"));
+        if (items.isEmpty()) {
+            return 0;
+        }
+        Date now = new Date();
+        int count = 0;
+        for (TaskPlanItem item : items) {
+            Task task = new Task();
+            task.setFamilyId(familyId);
+            task.setChildId(plan.getChildId());
+            task.setCreatorId(userId);
+            task.setExecutorType("child");
+            task.setExecutorId(plan.getChildId());
+            task.setTitle(item.getTaskName());
+            task.setDescription(item.getDescription());
+            task.setPoints(item.getPoints() != null ? item.getPoints() : 0);
+            task.setCategory("成长");
+            task.setStatus("pending");
+            task.setNeedReview(0);
+            task.setIsTemplate(0);
+            task.setSourceType("plan");
+            task.setSourceId(planId);
+            task.setMemberOnly(1); // B20: 方案/套餐生成的任务为会员专属
+            task.setDeadline(plan.getEndDate());
+            task.setCreatedAt(now);
+            task.setUpdatedAt(now);
+            taskMapper.insert(task);
+            count++;
+        }
+        // 标记方案已激活(幂等)
+        if (!"active".equals(plan.getStatus())) {
+            plan.setStatus("active");
+            plan.setActivatedAt(now);
+            plan.setUpdatedAt(now);
+            instanceMapper.updateById(plan);
+        }
+        log.info("方案确认生成任务: planId={}, familyId={}, taskCount={}", planId, familyId, count);
+        return count;
+    }
+
     /**
      * 获取计划详情
      */