Просмотр исходного кода

feat(plan.draft.save): SC-P-81 GrowthPlan草稿保存与恢复(S-cfc-382)

实现红单测 PlanDraftSaveContractTest 全量 10 断言转绿:
- GrowthPlan 实体新增 draftExpiresAt / draftVersion 字段
- GrowthPlanService 新增 saveDraft / restoreDraft(Long) / restoreDraft(Long,Long) /
  isDraftExpired / isDraftVersionConflict 契约方法(最小闭环)
- GrowthPlanController 新增 POST /api/growth/plan/draft-save + draft-restore 端点
- schema.sql / DatabaseInitializer 迁移485 对齐 growth_plans 新列
- API_REFERENCE §4.6 同步新增两条端点登记

覆盖正常流(暂存+恢复)、异常流(草稿已失效、并发版本冲突检测)、
权限流(越权恢复 403/401 不泄露数据)三类 SC-P-81 边界。
Agnes Agent 13 часов назад
Родитель
Сommit
c72df131be

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

@@ -3631,6 +3631,13 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
 
         // 迁移484: users / product_orders / package_orders 加 appid 列 + users 唯一索引(OID 防串号)
         migrateMultiAppDataDimensions();
+
+        // 迁移485: growth_plans 添加草稿列 draft_expires_at / draft_version
+        //(plan.draft.save,SC-P-81 方案草稿保存与恢复:草稿有效期『草稿已失效』判定 + 并发两版草稿版本号递增检测)
+        ensureColumn("growth_plans", "draft_expires_at",
+                "DATETIME NULL COMMENT '草稿有效期(SC-P-81:暂存未完,超期恢复提示草稿已失效)'");
+        ensureColumn("growth_plans", "draft_version",
+                "INT DEFAULT 1 COMMENT '草稿版本号(SC-P-81:并发存两版草稿,后者覆盖且提示冲突)'");
     }
 
 

+ 27 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/growth/GrowthPlanController.java

@@ -44,6 +44,33 @@ public class GrowthPlanController {
         return Result.success(result);
     }
 
+    @Operation(summary = "保存方案草稿(计划创建前可暂存,SC-P-81)")
+    @PostMapping("/draft-save")
+    public Result<GrowthPlan> saveDraft(@RequestBody GrowthPlan plan,
+                                        @RequestAttribute("userId") Long userId) {
+        if (plan.getMemberId() == null) {
+            return Result.error("请选择家庭成员");
+        }
+        plan.setTeacherId(userId);
+        GrowthPlan result = growthPlanService.saveDraft(plan);
+        return Result.success(result);
+    }
+
+    @Operation(summary = "恢复方案草稿继续编辑(SC-P-81)")
+    @PostMapping("/draft-restore")
+    public Result<GrowthPlan> restoreDraft(@RequestBody Map<String, Object> body,
+                                           @RequestAttribute("userId") Long userId) {
+        Long draftId = ParamUtils.getLong(body.get("id"));
+        if (draftId == null) {
+            return Result.badRequest("草稿ID不能为空");
+        }
+        GrowthPlan plan = growthPlanService.restoreDraft(draftId, userId);
+        if (plan == null) {
+            return Result.notFound("草稿不存在或无权限");
+        }
+        return Result.success(plan);
+    }
+
     @Operation(summary = "获取家庭成员的成长方案列表")
     @PostMapping("/child/{memberId}")
     public Result<List<GrowthPlan>> getChildPlans(@PathVariable Long memberId) {

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

@@ -45,4 +45,8 @@ public class GrowthPlan implements Serializable {
     private Date createdAt;
 
     private Date updatedAt;
+
+    private Date draftExpiresAt;  // 草稿有效期(SC-P-81:暂存未完,超期恢复提示『草稿已失效』)
+
+    private Integer draftVersion;  // 草稿版本号(SC-P-81:并发存两版草稿,后者覆盖且提示冲突)
 }

+ 74 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/GrowthPlanService.java

@@ -254,4 +254,78 @@ public class GrowthPlanService {
         plan.setUpdatedAt(new Date());
         return growthPlanMapper.updateById(plan) > 0;
     }
+
+    /** 草稿有效期默认 7 天(SC-P-81 边界:草稿有效期)。 */
+    private static final long DRAFT_VALIDITY_DAYS = 7L;
+    private static final long DRAFT_VALIDITY_MS = DRAFT_VALIDITY_DAYS * 24L * 3600_000L;
+
+    /**
+     * 存草稿(SC-P-81 正常流):暂存未完方案,持久化 status=draft,
+     * 设置有效期 draftExpiresAt = now + 7 天,返回已存草稿(含 id/版本)。
+     * <p>新建草稿版本号从 1 起;更新已存草稿版本号递增(并发两版:后者覆盖且提示冲突)。</p>
+     */
+    public GrowthPlan saveDraft(GrowthPlan plan) {
+        plan.setStatus("draft");
+        Date now = new Date();
+        plan.setDraftExpiresAt(new Date(now.getTime() + DRAFT_VALIDITY_MS));
+        if (plan.getId() != null) {
+            GrowthPlan existing = growthPlanMapper.selectById(plan.getId());
+            int baseVersion = existing != null && existing.getDraftVersion() != null ? existing.getDraftVersion() : 0;
+            plan.setDraftVersion(baseVersion + 1);
+            plan.setUpdatedAt(now);
+            growthPlanMapper.updateById(plan);
+        } else {
+            plan.setDraftVersion(1);
+            plan.setReviewCount(plan.getReviewCount() != null ? plan.getReviewCount() : 0);
+            plan.setCreatedAt(now);
+            plan.setUpdatedAt(now);
+            growthPlanMapper.insert(plan);
+        }
+        return plan;
+    }
+
+    /** 恢复草稿继续编辑(SC-P-81 正常流);草稿已超有效期 → 提示『草稿已失效』。 */
+    public GrowthPlan restoreDraft(Long draftId) {
+        GrowthPlan plan = growthPlanMapper.selectById(draftId);
+        if (plan == null) {
+            throw new IllegalArgumentException("方案不存在");
+        }
+        if (isDraftExpired(plan)) {
+            throw new IllegalStateException("草稿已失效");
+        }
+        return plan;
+    }
+
+    /** 权限恢复(SC-P-81 权限流):非归属(非本人创建)→ 403/401 拒绝,不返回他人草稿。 */
+    public GrowthPlan restoreDraft(Long draftId, Long userId) {
+        GrowthPlan plan = growthPlanMapper.selectById(draftId);
+        if (plan == null) {
+            return null; // 草稿不存在 → 无数据可泄露
+        }
+        if (userId == null || !userId.equals(plan.getTeacherId())) {
+            throw new IllegalStateException("无权限恢复他人草稿");
+        }
+        if (isDraftExpired(plan)) {
+            throw new IllegalStateException("草稿已失效");
+        }
+        return plan;
+    }
+
+    /** 草稿是否已超有效期(SC-P-81 边界:草稿有效期);无有效期视作未过期。 */
+    public boolean isDraftExpired(GrowthPlan draft) {
+        if (draft == null || draft.getDraftExpiresAt() == null) {
+            return false;
+        }
+        return draft.getDraftExpiresAt().getTime() < System.currentTimeMillis();
+    }
+
+    /** 并发检测(SC-P-81 异常流):服务端已存版本 &gt; 本地版本 → 冲突(后存覆盖前存且提示冲突)。 */
+    public boolean isDraftVersionConflict(Long draftId, long localVersion) {
+        GrowthPlan existing = growthPlanMapper.selectById(draftId);
+        if (existing == null) {
+            return false;
+        }
+        int serverVersion = existing.getDraftVersion() != null ? existing.getDraftVersion() : 0;
+        return serverVersion > localVersion;
+    }
 }

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

@@ -522,6 +522,8 @@ CREATE TABLE IF NOT EXISTS growth_plans (
     start_date DATE,
     end_date DATE,
     review_count INT DEFAULT 0 COMMENT '已复盘次数',
+    draft_expires_at DATETIME COMMENT '草稿有效期(SC-P-81:暂存未完,超期恢复提示草稿已失效)',
+    draft_version INT DEFAULT 1 COMMENT '草稿版本号(SC-P-81:并发存两版草稿,后者覆盖且提示冲突)',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
     INDEX idx_child_id (child_id),

+ 2 - 0
docs/superpowers/api/API_REFERENCE.md

@@ -579,6 +579,8 @@ find cfc-backend/src/main/java -name "*XxxService.java" -o -name "*XxxController
 | `POST /api/growth/record/create-from-upload` | 从上传创建 |
 | `POST /api/growth/record/supplement` | 补充记录 |
 | `POST /api/growth/plan/create` | 创建计划 |
+| `POST /api/growth/plan/draft-save` | 保存方案草稿(暂存未完,计划创建前可存,SC-P-81 plan.draft.save) |
+| `POST /api/growth/plan/draft-restore` | 恢复方案草稿继续编辑(草稿已失效/越权拒绝,SC-P-81 plan.draft.save) |
 | `POST /api/growth/plan/child/{memberId}` | 成员计划 |
 | `POST /api/growth/plan/{id}` | 计划详情 |
 | `POST /api/growth/plan/{id}/review` | 审核计划(SC-P-44 审核,能力键 `plan.confirm.submit`:generated→reviewed,非 generated 越状态守卫「方案状态异常」,仅 teacher/admin) |