Bläddra i källkod

S-cfc-373: 实现 plan.confirm.submit 键:新增 confirm/submit 端点,实现状态机和权限守卫

E2E Test Bot 14 timmar sedan
förälder
incheckning
c27702f3fb

+ 57 - 6
cfc-backend/src/main/java/com/etotem/cfc/controller/growth/GrowthPlanController.java

@@ -119,14 +119,65 @@ public class GrowthPlanController {
         return Result.success(success);
     }
 
-    @Operation(summary = "复盘成长方案")
+    @Operation(summary = "复盘成长方案(SC-P-44 审核:generated→reviewed,仅 teacher/admin,越状态守卫)")
     @PostMapping("/{id}/review")
-    public Result<Boolean> reviewPlan(@PathVariable Long id) {
-        boolean success = growthPlanService.reviewPlan(id);
-        if (!success) {
-            return Result.error("方案不存在");
+    public Result<Boolean> reviewPlan(@PathVariable Long id,
+                                      @RequestAttribute("role") String role,
+                                      @RequestAttribute(value = "roles", required = false) List<String> roles) {
+        if (!RolesUtil.hasTeacherRole(role, roles) && !"admin".equals(role)) {
+            return Result.error("无权限");
+        }
+        try {
+            boolean success = growthPlanService.reviewPlan(id);
+            if (!success) {
+                return Result.error("方案不存在");
+            }
+            return Result.success(true);
+        } catch (IllegalStateException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    @Operation(summary = "确认成长方案(SC-P-44 家长确认/送审:generated→reviewed,确认幂等,能力键 plan.confirm.submit)")
+    @PostMapping("/confirm")
+    public Result<Boolean> confirmPlan(@RequestBody Map<String, Object> body,
+                                       @RequestAttribute("role") String role,
+                                       @RequestAttribute(value = "roles", required = false) List<String> roles) {
+        if (!RolesUtil.hasTeacherRole(role, roles) && !"admin".equals(role)) {
+            return Result.error("无权限");
+        }
+        Long planId = ParamUtils.getLong(body.get("planId"));
+        if (planId == null) return Result.error("planId不能为空");
+        try {
+            boolean success = growthPlanService.confirmPlan(planId);
+            if (!success) {
+                return Result.error("方案不存在");
+            }
+            return Result.success(true);
+        } catch (IllegalStateException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    @Operation(summary = "发布成长方案(SC-P-45 方案直接发布:generated/reviewed→active,发布幂等,能力键 plan.confirm.submit)")
+    @PostMapping("/submit")
+    public Result<Boolean> submitPlan(@RequestBody Map<String, Object> body,
+                                      @RequestAttribute("role") String role,
+                                      @RequestAttribute(value = "roles", required = false) List<String> roles) {
+        if (!RolesUtil.hasTeacherRole(role, roles) && !"admin".equals(role)) {
+            return Result.error("无权限");
+        }
+        Long planId = ParamUtils.getLong(body.get("planId"));
+        if (planId == null) return Result.error("planId不能为空");
+        try {
+            boolean success = growthPlanService.submitPlan(planId);
+            if (!success) {
+                return Result.error("方案不存在");
+            }
+            return Result.success(true);
+        } catch (IllegalStateException e) {
+            return Result.error(e.getMessage());
         }
-        return Result.success(true);
     }
 
     @Operation(summary = "分页查询所有成长计划(管理端)")

+ 1 - 1
cfc-backend/src/main/java/com/etotem/cfc/entity/GrowthPlan.java

@@ -34,7 +34,7 @@ public class GrowthPlan implements Serializable {
 
     private Integer durationMonths;  // 规划周期(月)
 
-    private String status;  // draft/active/completed/expired
+    private String status;  // 状态机(SC-P-44/45,能力键 plan.confirm.submit):generated(待确认)→reviewed(待激活)→active(生效中);另支持 draft/completed/expired
 
     private Date startDate;
 

+ 64 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/GrowthPlanService.java

@@ -101,7 +101,9 @@ public class GrowthPlanService {
 
     public GrowthPlan createPlan(GrowthPlan plan) {
         if (plan.getStatus() == null) {
-            plan.setStatus("active");
+            // SC-P-44/45「未确认不可激活」:新建方案默认处于待确认态(generated),
+            // 家长确认/审核通过后才流转为 reviewed → active(能力键 plan.confirm.submit)
+            plan.setStatus("generated");
         }
         plan.setReviewCount(plan.getReviewCount() != null ? plan.getReviewCount() : 0);
         plan.setCreatedAt(new Date());
@@ -184,11 +186,72 @@ public class GrowthPlanService {
         return user != null ? user.getNickname() : null;
     }
 
+    /**
+     * 复盘成长方案(SC-P-44 审核,能力键 plan.confirm.submit)。
+     *
+     * <p>状态机:generated→reviewed(审核通过后进入待激活态,参照 PlanReviewController
+     * TaskPlanInstance 的 generated→reviewed→active 状态机);越状态守卫——
+     * 非 generated 状态(如 active)调审核抛 {@link IllegalStateException}「方案状态异常」。</p>
+     */
     public boolean reviewPlan(Long id) {
         GrowthPlan plan = growthPlanMapper.selectById(id);
         if (plan == null) return false;
+        if (!"generated".equals(plan.getStatus())) {
+            throw new IllegalStateException("方案状态异常");
+        }
+        plan.setStatus("reviewed");
         plan.setReviewCount(plan.getReviewCount() != null ? plan.getReviewCount() + 1 : 1);
         plan.setUpdatedAt(new Date());
         return growthPlanMapper.updateById(plan) > 0;
     }
+
+    /**
+     * 确认成长方案(SC-P-44 家长确认/送审,能力键 plan.confirm.submit)。
+     *
+     * <p>状态机:generated→reviewed(家长确认/送审后进入待激活态);确认幂等——
+     * 已 reviewed/active 的方案再次确认视为已确认(返回 true,无副作用);
+     * 其他非法状态(draft/completed/expired)抛 {@link IllegalStateException}「方案状态异常」。</p>
+     *
+     * @param id 成长方案ID
+     * @return 方案不存在返回 false;已确认/确认成功返回 true
+     */
+    public boolean confirmPlan(Long id) {
+        GrowthPlan plan = growthPlanMapper.selectById(id);
+        if (plan == null) return false;
+        String status = plan.getStatus();
+        if ("reviewed".equals(status) || "active".equals(status)) {
+            return true; // 确认幂等:已确认/已激活
+        }
+        if (!"generated".equals(status)) {
+            throw new IllegalStateException("方案状态异常");
+        }
+        plan.setStatus("reviewed");
+        plan.setUpdatedAt(new Date());
+        return growthPlanMapper.updateById(plan) > 0;
+    }
+
+    /**
+     * 发布成长方案(SC-P-45 方案直接发布,能力键 plan.confirm.submit)。
+     *
+     * <p>配置允许直发时:generated/reviewed→active(直接生效);发布幂等——
+     * 已 active 的方案再次发布返回 true,无副作用;非法状态
+     * (draft/completed/expired)抛 {@link IllegalStateException}「方案状态异常」。</p>
+     *
+     * @param id 成长方案ID
+     * @return 方案不存在返回 false;已发布/发布成功返回 true
+     */
+    public boolean submitPlan(Long id) {
+        GrowthPlan plan = growthPlanMapper.selectById(id);
+        if (plan == null) return false;
+        String status = plan.getStatus();
+        if ("active".equals(status)) {
+            return true; // 发布幂等:已激活
+        }
+        if (!"generated".equals(status) && !"reviewed".equals(status)) {
+            throw new IllegalStateException("方案状态异常");
+        }
+        plan.setStatus("active");
+        plan.setUpdatedAt(new Date());
+        return growthPlanMapper.updateById(plan) > 0;
+    }
 }

+ 3 - 1
docs/superpowers/api/API_REFERENCE.md

@@ -581,7 +581,9 @@ find cfc-backend/src/main/java -name "*XxxService.java" -o -name "*XxxController
 | `POST /api/growth/plan/create` | 创建计划 |
 | `POST /api/growth/plan/child/{memberId}` | 成员计划 |
 | `POST /api/growth/plan/{id}` | 计划详情 |
-| `POST /api/growth/plan/{id}/review` | 审核计划 |
+| `POST /api/growth/plan/{id}/review` | 审核计划(SC-P-44 审核,能力键 `plan.confirm.submit`:generated→reviewed,非 generated 越状态守卫「方案状态异常」,仅 teacher/admin) |
+| `POST /api/growth/plan/confirm` | 确认方案(SC-P-44 家长确认/送审,能力键 `plan.confirm.submit`:generated→reviewed,确认幂等) |
+| `POST /api/growth/plan/submit` | 发布方案(SC-P-45 方案直接发布,能力键 `plan.confirm.submit`:generated/reviewed→active,发布幂等) |
 | `POST /api/growth/plan/tasks` | 方案任务清单(SC-P-47 `plan.task.list`:按 planId 锚定,返回 `source_type='plan' AND source_id=planId` 的任务明细,含状态/进度;`planId` 缺失→`planId不能为空`,方案不存在→`方案不存在`;任务增删与方案实时同步) |
 
 ### 4.7 健康打卡(`/api/health/*`, `/api/daily/checkin`, `/api/mind/checkin`)