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

feat(任务编排): 任务编排引擎与执行服务

Sisyphus Agent 1 день назад
Родитель
Сommit
59833dba48
36 измененных файлов с 5515 добавлено и 1 удалено
  1. 6 0
      cfc-backend/pom.xml
  2. 85 0
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  3. 54 0
      cfc-backend/src/main/java/com/etotem/cfc/config/QuartzConfig.java
  4. 199 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/OrchestrationController.java
  5. 31 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/TaskOrchestrationEdge.java
  6. 39 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/TaskOrchestrationExecution.java
  7. 37 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/TaskOrchestrationFlow.java
  8. 41 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/TaskOrchestrationNodeInstance.java
  9. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/TaskOrchestrationEdgeMapper.java
  10. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/TaskOrchestrationExecutionMapper.java
  11. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/TaskOrchestrationFlowMapper.java
  12. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/TaskOrchestrationNodeInstanceMapper.java
  13. 457 0
      cfc-backend/src/main/java/com/etotem/cfc/service/OrchestrationEngine.java
  14. 172 0
      cfc-backend/src/main/java/com/etotem/cfc/service/OrchestrationExecutionService.java
  15. 166 0
      cfc-backend/src/main/java/com/etotem/cfc/service/OrchestrationFlowService.java
  16. 11 0
      cfc-backend/src/main/java/com/etotem/cfc/service/TaskService.java
  17. 84 0
      cfc-backend/src/main/java/com/etotem/cfc/task/OrchestrationPollingJob.java
  18. 70 0
      cfc-backend/src/main/resources/schema.sql
  19. 205 0
      cfc-backend/src/test/java/com/etotem/cfc/orchestration/OrchestrationEngineTest.java
  20. 190 0
      cfc-backend/src/test/java/com/etotem/cfc/orchestration/OrchestrationIntegrationTest.java
  21. 23 0
      cfc-frontend/pages.json
  22. 644 0
      cfc-frontend/pages/orchestration/editor.vue
  23. 310 0
      cfc-frontend/pages/orchestration/index.vue
  24. 213 0
      cfc-frontend/pages/orchestration/node-edit.vue
  25. 25 0
      cfc-frontend/pages/tasks/tasks.vue
  26. 30 1
      cfc-frontend/utils/api.js
  27. 7 0
      cfc-web/package-lock.json
  28. 1 0
      cfc-web/package.json
  29. 64 0
      cfc-web/src/api/orchestration.js
  30. 18 0
      cfc-web/src/router/index.js
  31. 180 0
      cfc-web/src/views/orchestration/ExecutionDetail.vue
  32. 376 0
      cfc-web/src/views/orchestration/FlowEditor.vue
  33. 204 0
      cfc-web/src/views/orchestration/OrchestrationFlow.vue
  34. 1 0
      docs/superpowers/PROJECT-OVERVIEW.md
  35. 1107 0
      docs/superpowers/plans/2026-09-19-task-orchestration-plan.md
  36. 429 0
      docs/superpowers/specs/2026-09-19-task-orchestration-design.md

+ 6 - 0
cfc-backend/pom.xml

@@ -108,6 +108,12 @@
             <artifactId>spring-security-crypto</artifactId>
         </dependency>
 
+        <!-- Quartz 调度(任务编排轮询) -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-quartz</artifactId>
+        </dependency>
+
         <!-- Logback (显式依赖,避免 Tomcat 线程类加载器看不到 BOOT-INF/lib/ 导致 NoClassDefFoundError) -->
         <dependency>
             <groupId>ch.qos.logback</groupId>

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

@@ -11298,6 +11298,91 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         } catch (Exception e) {
             // 索引已存在,忽略错误
         }
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `task_orchestration_flows` (" +
+                    "`id` bigint NOT NULL AUTO_INCREMENT," +
+                    "`name` varchar(100) NOT NULL," +
+                    "`description` text," +
+                    "`creator_id` bigint NOT NULL," +
+                    "`family_id` bigint DEFAULT NULL," +
+                    "`version` int NOT NULL DEFAULT 1," +
+                    "`status` varchar(20) NOT NULL DEFAULT 'draft'," +
+                    "`schedule_cron` varchar(64) DEFAULT NULL," +
+                    "`config_json` json DEFAULT NULL," +
+                    "`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP," +
+                    "`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP," +
+                    "PRIMARY KEY (`id`)," +
+                    "KEY `idx_family_id` (`family_id`)," +
+                    "KEY `idx_status` (`status`)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='任务编排流定义'");
+            log.info("迁移329: 已创建task_orchestration_flows表");
+        } catch (Exception e) {
+            log.warn("迁移329: task_orchestration_flows 已存在或创建失败: {}", e.getMessage());
+        }
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `task_orchestration_edges` (" +
+                    "`id` bigint NOT NULL AUTO_INCREMENT," +
+                    "`flow_id` bigint NOT NULL," +
+                    "`from_node_id` varchar(64) NOT NULL," +
+                    "`to_node_id` varchar(64) NOT NULL," +
+                    "`edge_type` varchar(20) NOT NULL," +
+                    "`operator` varchar(10) NOT NULL DEFAULT 'AND'," +
+                    "`sort_order` int NOT NULL DEFAULT 0," +
+                    "`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP," +
+                    "PRIMARY KEY (`id`)," +
+                    "KEY `idx_flow_id` (`flow_id`)," +
+                    "UNIQUE KEY `uk_flow_edge` (`flow_id`, `from_node_id`, `to_node_id`)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='任务编排流边定义'");
+            log.info("迁移329: 已创建task_orchestration_edges表");
+        } catch (Exception e) {
+            log.warn("迁移329: task_orchestration_edges 已存在或创建失败: {}", e.getMessage());
+        }
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `task_orchestration_executions` (" +
+                    "`id` bigint NOT NULL AUTO_INCREMENT," +
+                    "`flow_id` bigint NOT NULL," +
+                    "`flow_version` int NOT NULL DEFAULT 1," +
+                    "`family_id` bigint NOT NULL," +
+                    "`family_member_id` bigint NOT NULL," +
+                    "`trigger_source` varchar(20) NOT NULL DEFAULT 'manual'," +
+                    "`status` varchar(20) NOT NULL DEFAULT 'running'," +
+                    "`started_at` datetime NOT NULL," +
+                    "`finished_at` datetime DEFAULT NULL," +
+                    "`error_reason` varchar(255) DEFAULT NULL," +
+                    "`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP," +
+                    "`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP," +
+                    "PRIMARY KEY (`id`)," +
+                    "KEY `idx_execution_flow` (`flow_id`)," +
+                    "KEY `idx_execution_status` (`status`)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='任务编排流执行记录'");
+            log.info("迁移329: 已创建task_orchestration_executions表");
+        } catch (Exception e) {
+            log.warn("迁移329: task_orchestration_executions 已存在或创建失败: {}", e.getMessage());
+        }
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `task_orchestration_node_instances` (" +
+                    "`id` bigint NOT NULL AUTO_INCREMENT," +
+                    "`execution_id` bigint NOT NULL," +
+                    "`node_id` varchar(64) NOT NULL," +
+                    "`task_id` bigint DEFAULT NULL," +
+                    "`status` varchar(20) NOT NULL DEFAULT 'pending'," +
+                    "`generation` int NOT NULL DEFAULT 0," +
+                    "`condition_met_at` datetime DEFAULT NULL COMMENT '条件终止:外部API通知条件达成时间'," +
+                    "`loop_termination_reason` varchar(50) DEFAULT NULL," +
+                    "`started_at` datetime DEFAULT NULL," +
+                    "`completed_at` datetime DEFAULT NULL," +
+                    "`error_reason` varchar(255) DEFAULT NULL," +
+                    "`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP," +
+                    "`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP," +
+                    "PRIMARY KEY (`id`)," +
+                    "UNIQUE KEY `uk_node_instance` (`execution_id`, `node_id`, `generation`)," +
+                    "KEY `idx_execution_id` (`execution_id`)," +
+                    "KEY `idx_task_id` (`task_id`)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='任务编排流节点实例'");
+            log.info("迁移329: 已创建task_orchestration_node_instances表");
+        } catch (Exception e) {
+            log.warn("迁移329: task_orchestration_node_instances 已存在或创建失败: {}", e.getMessage());
+        }
 
         // 迁移318: sys_config 预置首页引导浮层默认配置(onboarding_guide_config key)
         try {

+ 54 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/QuartzConfig.java

@@ -0,0 +1,54 @@
+package com.etotem.cfc.config;
+
+import com.etotem.cfc.task.OrchestrationPollingJob;
+import org.quartz.*;
+import org.springframework.context.ApplicationContext;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.quartz.SchedulerFactoryBean;
+import org.springframework.scheduling.quartz.SpringBeanJobFactory;
+
+@Configuration
+public class QuartzConfig {
+
+    /**
+     * SpringBeanJobFactory:让 Quartz 通过 Spring 容器创建 Job 实例,
+     * 使 Job 中的 @Resource/@Autowired 注入生效。
+     */
+    @Bean
+    public SpringBeanJobFactory springBeanJobFactory(ApplicationContext applicationContext) {
+        SpringBeanJobFactory jobFactory = new SpringBeanJobFactory();
+        jobFactory.setApplicationContext(applicationContext);
+        return jobFactory;
+    }
+
+    @Bean
+    public SchedulerFactoryBean quartzScheduler(SpringBeanJobFactory jobFactory) {
+        SchedulerFactoryBean schedulerFactoryBean = new SchedulerFactoryBean();
+        schedulerFactoryBean.setJobFactory(jobFactory);
+        schedulerFactoryBean.setJobDetails(
+            new JobDetail[]{orchestrationPollingJobDetail()});
+        schedulerFactoryBean.setTriggers(
+            new Trigger[]{orchestrationPollingTrigger()});
+        return schedulerFactoryBean;
+    }
+
+    @Bean
+    public JobDetail orchestrationPollingJobDetail() {
+        return JobBuilder.newJob(OrchestrationPollingJob.class)
+            .withIdentity("orchestrationPollingJob")
+            .storeDurably()
+            .build();
+    }
+
+    @Bean
+    public Trigger orchestrationPollingTrigger() {
+        return TriggerBuilder.newTrigger()
+            .forJob(orchestrationPollingJobDetail())
+            .withIdentity("orchestrationPollingTrigger")
+            .withSchedule(SimpleScheduleBuilder.simpleSchedule()
+                .withIntervalInSeconds(30)
+                .repeatForever())
+            .build();
+    }
+}

+ 199 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/OrchestrationController.java

@@ -0,0 +1,199 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.TaskOrchestrationFlow;
+import com.etotem.cfc.mapper.TaskOrchestrationNodeInstanceMapper;
+import com.etotem.cfc.service.OrchestrationExecutionService;
+import com.etotem.cfc.service.OrchestrationFlowService;
+import com.etotem.cfc.service.OrchestrationEngine;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/orchestration")
+public class OrchestrationController {
+
+    @Resource
+    private OrchestrationFlowService flowService;
+
+    @Resource
+    private OrchestrationExecutionService executionService;
+
+    @Resource
+    private OrchestrationEngine orchestrationEngine;
+
+    @Resource
+    private TaskOrchestrationNodeInstanceMapper nodeInstanceMapper;
+
+    // ===== Flow 管理 =====
+
+    @PostMapping("/flow/save")
+    public Result<TaskOrchestrationFlow> saveFlow(@RequestBody TaskOrchestrationFlow flow,
+                                                   @RequestAttribute(value = "userId", required = false) Long userId,
+                                                   @RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) return Result.error("无权限");
+        if (userId != null) flow.setCreatorId(userId);
+        return Result.success(flowService.save(flow));
+    }
+
+    @PostMapping("/flow/publish")
+    public Result<TaskOrchestrationFlow> publishFlow(@RequestBody Map<String, Object> params,
+                                                      @RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) return Result.error("无权限");
+        Long flowId = params.get("flowId") != null ? Long.valueOf(params.get("flowId").toString()) : null;
+        if (flowId == null) return Result.error("flowId 不能为空");
+        return Result.success(flowService.publish(flowId));
+    }
+
+    @PostMapping("/flow/list")
+    public Result<Map<String, Object>> listFlows(@RequestBody Map<String, Object> params) {
+        Integer page = params.get("page") != null ? Integer.valueOf(params.get("page").toString()) : 1;
+        Integer pageSize = params.get("pageSize") != null ? Integer.valueOf(params.get("pageSize").toString()) : 10;
+        Long familyId = params.get("familyId") != null ? Long.valueOf(params.get("familyId").toString()) : null;
+        String status = params.get("status") != null ? params.get("status").toString() : null;
+        return Result.success(flowService.list(page, pageSize, familyId, status));
+    }
+
+    @PostMapping("/flow/detail")
+    public Result<Map<String, Object>> detailFlow(@RequestBody Map<String, Object> params) {
+        Long flowId = params.get("flowId") != null ? Long.valueOf(params.get("flowId").toString()) : null;
+        if (flowId == null) return Result.error("flowId 不能为空");
+        Map<String, Object> data = flowService.detail(flowId);
+        return data != null ? Result.success(data) : Result.error("流不存在");
+    }
+
+    @PostMapping("/flow/archive")
+    public Result<Boolean> archiveFlow(@RequestBody Map<String, Object> params,
+                                        @RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) return Result.error("无权限");
+        Long flowId = params.get("flowId") != null ? Long.valueOf(params.get("flowId").toString()) : null;
+        if (flowId == null) return Result.error("flowId 不能为空");
+        return Result.success(flowService.archive(flowId));
+    }
+
+    @PostMapping("/flow/delete")
+    public Result<Boolean> deleteFlow(@RequestBody Map<String, Object> params,
+                                       @RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) return Result.error("无权限");
+        Long flowId = params.get("flowId") != null ? Long.valueOf(params.get("flowId").toString()) : null;
+        if (flowId == null) return Result.error("flowId 不能为空");
+        try {
+            return Result.success(flowService.delete(flowId));
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    // ===== Execution 管理 =====
+
+    @PostMapping("/execution/start")
+    public Result<Map<String, Object>> startExecution(@RequestBody Map<String, Object> params,
+                                                       @RequestAttribute(value = "userId", required = false) Long userId) {
+        Long flowId = params.get("flowId") != null ? Long.valueOf(params.get("flowId").toString()) : null;
+        Long familyMemberId = params.get("familyMemberId") != null ? Long.valueOf(params.get("familyMemberId").toString()) : null;
+        if (flowId == null) return Result.error("flowId 不能为空");
+        if (familyMemberId == null) return Result.error("familyMemberId 不能为空");
+        try {
+            return Result.success(executionService.start(flowId, familyMemberId, userId));
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    @PostMapping("/execution/pause")
+    public Result<Boolean> pauseExecution(@RequestBody Map<String, Object> params,
+                                           @RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) return Result.error("无权限");
+        Long executionId = params.get("executionId") != null ? Long.valueOf(params.get("executionId").toString()) : null;
+        if (executionId == null) return Result.error("executionId 不能为空");
+        return Result.success(executionService.pause(executionId));
+    }
+
+    @PostMapping("/execution/resume")
+    public Result<Boolean> resumeExecution(@RequestBody Map<String, Object> params,
+                                            @RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) return Result.error("无权限");
+        Long executionId = params.get("executionId") != null ? Long.valueOf(params.get("executionId").toString()) : null;
+        if (executionId == null) return Result.error("executionId 不能为空");
+        return Result.success(executionService.resume(executionId));
+    }
+
+    @PostMapping("/execution/terminate")
+    public Result<Boolean> terminateExecution(@RequestBody Map<String, Object> params,
+                                               @RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) return Result.error("无权限");
+        Long executionId = params.get("executionId") != null ? Long.valueOf(params.get("executionId").toString()) : null;
+        String reason = params.get("reason") != null ? params.get("reason").toString() : null;
+        if (executionId == null) return Result.error("executionId 不能为空");
+        return Result.success(executionService.terminate(executionId, reason));
+    }
+
+    @PostMapping("/execution/detail")
+    public Result<Map<String, Object>> detailExecution(@RequestBody Map<String, Object> params) {
+        Long executionId = params.get("executionId") != null ? Long.valueOf(params.get("executionId").toString()) : null;
+        if (executionId == null) return Result.error("executionId 不能为空");
+        Map<String, Object> data = executionService.detail(executionId);
+        return data != null ? Result.success(data) : Result.error("执行不存在");
+    }
+
+    @PostMapping("/execution/list")
+    public Result<Map<String, Object>> listExecutions(@RequestBody Map<String, Object> params) {
+        Long familyMemberId = params.get("familyMemberId") != null ? Long.valueOf(params.get("familyMemberId").toString()) : null;
+        Integer page = params.get("page") != null ? Integer.valueOf(params.get("page").toString()) : 1;
+        Integer pageSize = params.get("pageSize") != null ? Integer.valueOf(params.get("pageSize").toString()) : 10;
+        return Result.success(executionService.list(familyMemberId, page, pageSize));
+    }
+
+    // ===== 节点管理 =====
+
+    @PostMapping("/node/fail")
+    public Result<Boolean> failNode(@RequestBody Map<String, Object> params,
+                                     @RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) return Result.error("无权限");
+        Long nodeInstanceId = params.get("nodeInstanceId") != null ? Long.valueOf(params.get("nodeInstanceId").toString()) : null;
+        if (nodeInstanceId == null) return Result.error("nodeInstanceId 不能为空");
+        String reason = params.get("reason") != null ? params.get("reason").toString() : "管理员手动标记失败";
+        // 查找节点实例,获取 taskId,然后调用引擎回调
+        com.etotem.cfc.entity.TaskOrchestrationNodeInstance ni = nodeInstanceMapper.selectById(nodeInstanceId);
+        if (ni == null) return Result.error("节点实例不存在");
+        if (ni.getTaskId() != null) {
+            orchestrationEngine.onTaskFailed(ni.getTaskId());
+        }
+        return Result.success(true);
+    }
+
+    @PostMapping("/node/restart")
+    public Result<Boolean> restartNode(@RequestBody Map<String, Object> params,
+                                        @RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) return Result.error("无权限");
+        Long nodeInstanceId = params.get("nodeInstanceId") != null ? Long.valueOf(params.get("nodeInstanceId").toString()) : null;
+        if (nodeInstanceId == null) return Result.error("nodeInstanceId 不能为空");
+        // 重置 generation=0,重新创建任务(简化实现:仅标记为待处理,由 evaluateFlow 重新触发)
+        com.etotem.cfc.entity.TaskOrchestrationNodeInstance ni = nodeInstanceMapper.selectById(nodeInstanceId);
+        if (ni == null) return Result.error("节点实例不存在");
+        ni.setGeneration(0);
+        ni.setStatus("pending");
+        ni.setTaskId(null);
+        ni.setCompletedAt(null);
+        ni.setErrorReason(null);
+        nodeInstanceMapper.updateById(ni);
+        orchestrationEngine.evaluateFlow(ni.getExecutionId());
+        return Result.success(true);
+    }
+
+    @PostMapping("/node/condition-met")
+    public Result<Boolean> conditionMet(@RequestBody Map<String, Object> params,
+                                         @RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) return Result.error("无权限");
+        Long nodeInstanceId = params.get("nodeInstanceId") != null ? Long.valueOf(params.get("nodeInstanceId").toString()) : null;
+        if (nodeInstanceId == null) return Result.error("nodeInstanceId 不能为空");
+        com.etotem.cfc.entity.TaskOrchestrationNodeInstance ni = nodeInstanceMapper.selectById(nodeInstanceId);
+        if (ni == null) return Result.error("节点实例不存在");
+        ni.setConditionMetAt(new java.util.Date());
+        nodeInstanceMapper.updateById(ni);
+        orchestrationEngine.evaluateFlow(ni.getExecutionId());
+        return Result.success(true);
+    }
+}

+ 31 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/TaskOrchestrationEdge.java

@@ -0,0 +1,31 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("task_orchestration_edges")
+public class TaskOrchestrationEdge implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long flowId;
+
+    private String fromNodeId;
+
+    private String toNodeId;
+
+    private String edgeType;
+
+    private String operator;
+
+    private Integer sortOrder;
+
+    private Date createdAt;
+}

+ 39 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/TaskOrchestrationExecution.java

@@ -0,0 +1,39 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("task_orchestration_executions")
+public class TaskOrchestrationExecution implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long flowId;
+
+    private Integer flowVersion;
+
+    private Long familyId;
+
+    private Long familyMemberId;
+
+    private String triggerSource;
+
+    private String status;
+
+    private Date startedAt;
+
+    private Date finishedAt;
+
+    private String errorReason;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}

+ 37 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/TaskOrchestrationFlow.java

@@ -0,0 +1,37 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("task_orchestration_flows")
+public class TaskOrchestrationFlow implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private String name;
+
+    private String description;
+
+    private Long creatorId;
+
+    private Long familyId;
+
+    private Integer version;
+
+    private String status;
+
+    private String scheduleCron;
+
+    private String configJson;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}

+ 41 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/TaskOrchestrationNodeInstance.java

@@ -0,0 +1,41 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("task_orchestration_node_instances")
+public class TaskOrchestrationNodeInstance implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long executionId;
+
+    private String nodeId;
+
+    private Long taskId;
+
+    private String status;
+
+    private Integer generation;
+
+    private Date conditionMetAt;
+
+    private String loopTerminationReason;
+
+    private Date startedAt;
+
+    private Date completedAt;
+
+    private String errorReason;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/TaskOrchestrationEdgeMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.TaskOrchestrationEdge;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface TaskOrchestrationEdgeMapper extends BaseMapper<TaskOrchestrationEdge> {
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/TaskOrchestrationExecutionMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.TaskOrchestrationExecution;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface TaskOrchestrationExecutionMapper extends BaseMapper<TaskOrchestrationExecution> {
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/TaskOrchestrationFlowMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.TaskOrchestrationFlow;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface TaskOrchestrationFlowMapper extends BaseMapper<TaskOrchestrationFlow> {
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/TaskOrchestrationNodeInstanceMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.TaskOrchestrationNodeInstance;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface TaskOrchestrationNodeInstanceMapper extends BaseMapper<TaskOrchestrationNodeInstance> {
+}

+ 457 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/OrchestrationEngine.java

@@ -0,0 +1,457 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.dto.CreateTaskDTO;
+import com.etotem.cfc.entity.TaskOrchestrationEdge;
+import com.etotem.cfc.entity.TaskOrchestrationExecution;
+import com.etotem.cfc.entity.TaskOrchestrationFlow;
+import com.etotem.cfc.entity.TaskOrchestrationNodeInstance;
+import com.etotem.cfc.entity.TaskTemplate;
+import com.etotem.cfc.mapper.TaskOrchestrationEdgeMapper;
+import com.etotem.cfc.mapper.TaskOrchestrationExecutionMapper;
+import com.etotem.cfc.mapper.TaskOrchestrationFlowMapper;
+import com.etotem.cfc.mapper.TaskOrchestrationNodeInstanceMapper;
+import com.etotem.cfc.mapper.TaskTemplateMapper;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.annotation.Lazy;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * 任务编排执行引擎。
+ *
+ * <p>职责:负责编排 DAG 的执行流程,包括节点解锁判断、循环终止判定、
+ * 任务创建和回调处理。引擎本身不直接创建 Spring 事务,事务由调用方(Service/Controller)控制。</p>
+ *
+ * <p>语义(见设计文档 §5):</p>
+ * <ul>
+ *   <li>入边为空(start_node)→ 直接触发。</li>
+ *   <li>AND 边:所有入边源节点均 completed 才触发目标。</li>
+ *   <li>OR 边:任一入边源节点 completed 即触发目标。</li>
+ *   <li>循环终止(applyLoopTermination):generation ≥ max_loops → terminated;
+ *       conditionMetAt 已置 → completed。</li>
+ * </ul>
+ */
+@Slf4j
+@Service
+public class OrchestrationEngine {
+
+    /** 节点状态常量 */
+    public static final String STATUS_PENDING = "pending";
+    public static final String STATUS_STARTED = "in_progress";
+    public static final String STATUS_COMPLETED = "completed";
+    public static final String STATUS_FAILED = "failed";
+    public static final String STATUS_TIMEOUT = "timeout";
+    public static final String STATUS_TERMINATED = "terminated";
+    public static final String STATUS_SKIPPED = "skipped";
+    public static final String STATUS_RUNNING = "running";
+
+    /** 边 operator 常量 */
+    public static final String OP_AND = "AND";
+    public static final String OP_OR = "OR";
+
+    /** 循环终止 reason 常量 */
+    public static final String LOOP_REASON_MAX = "max_loops_reached";
+    public static final String LOOP_REASON_CONDITION = "condition_met";
+
+    @Resource
+    private TaskOrchestrationNodeInstanceMapper nodeInstanceMapper;
+
+    @Resource
+    private TaskOrchestrationEdgeMapper edgeMapper;
+
+    @Resource
+    private TaskOrchestrationExecutionMapper executionMapper;
+
+    @Resource
+    private TaskOrchestrationFlowMapper flowMapper;
+
+    @Resource
+    private TaskTemplateMapper taskTemplateMapper;
+
+    /**
+     * 通过 @Lazy 打破循环依赖:OrchestrationEngine → TaskService.createTask
+     * 而 TaskService → orchestrationEngine.onTaskCompleted/onTaskFailed 通过
+     * TaskService 端 @Lazy 注入 OrchestrationEngine 实现。
+     */
+    @Resource
+    @Lazy
+    private TaskService taskService;
+
+    /**
+     * 主入口:评估一次执行的流程,触发满足条件(解锁)的节点。
+     */
+    public TaskOrchestrationExecution evaluateFlow(Long executionId) {
+        TaskOrchestrationExecution exec = executionMapper.selectById(executionId);
+        if (exec == null || !STATUS_RUNNING.equals(exec.getStatus())) {
+            return exec;
+        }
+
+        List<TaskOrchestrationNodeInstance> nodes = nodeInstanceMapper.selectList(
+            new LambdaQueryWrapper<TaskOrchestrationNodeInstance>()
+                .eq(TaskOrchestrationNodeInstance::getExecutionId, executionId));
+
+        Map<String, List<TaskOrchestrationEdge>> inEdgesByTo = edgeMapper.selectList(
+            new LambdaQueryWrapper<TaskOrchestrationEdge>()
+                .eq(TaskOrchestrationEdge::getFlowId, exec.getFlowId()))
+            .stream()
+            .collect(Collectors.groupingBy(TaskOrchestrationEdge::getToNodeId));
+
+        for (TaskOrchestrationNodeInstance ni : nodes) {
+            if (!STATUS_PENDING.equals(ni.getStatus())) {
+                continue;
+            }
+            List<TaskOrchestrationEdge> inEdges =
+                inEdgesByTo.getOrDefault(ni.getNodeId(), Collections.emptyList());
+            if (!isUnlocked(ni, inEdges)) {
+                continue;
+            }
+            triggerNode(exec, ni, inEdges);
+        }
+        return exec;
+    }
+
+    /**
+     * 纯语义判定:目标节点是否满足入边触发条件。
+     *
+     * <p>无入边 → true(start_node 直接触发)。AND:所有入边源节点 completed;
+     * OR:任一入边源节点 completed 即触发。混合模式:AND 全部 completed 且 OR 至少一个 completed。</p>
+     */
+    public boolean isUnlocked(TaskOrchestrationNodeInstance ni,
+                              List<TaskOrchestrationEdge> inEdges) {
+        if (inEdges == null || inEdges.isEmpty()) {
+            return true;
+        }
+        List<TaskOrchestrationNodeInstance> all = nodeInstanceMapper.selectList(
+            new LambdaQueryWrapper<TaskOrchestrationNodeInstance>()
+                .eq(TaskOrchestrationNodeInstance::getExecutionId, ni.getExecutionId()));
+        Map<String, String> statusById = all.stream()
+            .collect(Collectors.toMap(TaskOrchestrationNodeInstance::getNodeId,
+                                      TaskOrchestrationNodeInstance::getStatus,
+                                      (a, b) -> a));
+
+        boolean hasAnd = false;
+        boolean hasOr = false;
+        boolean orSatisfied = false;
+        for (TaskOrchestrationEdge e : inEdges) {
+            String src = e.getFromNodeId();
+            String srcStatus = statusById.getOrDefault(src, STATUS_PENDING);
+            if (OP_AND.equalsIgnoreCase(e.getOperator())) {
+                hasAnd = true;
+                if (!STATUS_COMPLETED.equals(srcStatus)) {
+                    return false;
+                }
+            } else {
+                hasOr = true;
+                if (STATUS_COMPLETED.equals(srcStatus)) {
+                    orSatisfied = true;
+                }
+            }
+        }
+        if (hasOr && !hasAnd) {
+            return orSatisfied;
+        }
+        return true;
+    }
+
+    /**
+     * 应用循环终止规则。
+     *
+     * <p>conditionMetAt 已置值 → completed + reason=condition_met;
+     * 否则若 generation ≥ max_loops → terminated + reason=max_loops_reached。</p>
+     */
+    public TaskOrchestrationExecution applyLoopTermination(TaskOrchestrationExecution exec,
+                                                            TaskOrchestrationNodeInstance ni) {
+        Integer maxLoops = resolveMaxLoops(exec.getFlowId(), ni.getNodeId());
+        if (ni.getConditionMetAt() != null) {
+            ni.setStatus(STATUS_COMPLETED);
+            ni.setLoopTerminationReason(LOOP_REASON_CONDITION);
+            return exec;
+        }
+        int generation = ni.getGeneration() == null ? 0 : ni.getGeneration();
+        if (maxLoops != null && generation >= maxLoops) {
+            ni.setStatus(STATUS_TERMINATED);
+            ni.setLoopTerminationReason(LOOP_REASON_MAX);
+        }
+        return exec;
+    }
+
+    /**
+     * 触发节点:从 flow config_json 读取节点配置,加载任务模板,
+     * 调用 TaskService.createTask 创建 tasks 实例,并回写 task_id。
+     */
+    private void triggerNode(TaskOrchestrationExecution exec,
+                             TaskOrchestrationNodeInstance ni,
+                             List<TaskOrchestrationEdge> inEdges) {
+        // 幂等:唯一索引兜底
+        Long existingCount = nodeInstanceMapper.selectCount(
+            new LambdaQueryWrapper<TaskOrchestrationNodeInstance>()
+                .eq(TaskOrchestrationNodeInstance::getExecutionId, ni.getExecutionId())
+                .eq(TaskOrchestrationNodeInstance::getNodeId, ni.getNodeId())
+                .eq(TaskOrchestrationNodeInstance::getGeneration, ni.getGeneration())
+                .ne(TaskOrchestrationNodeInstance::getStatus, STATUS_PENDING));
+        if (existingCount != null && existingCount > 0) {
+            return;
+        }
+
+        // 1. 读 flow 的 config_json,拿到节点配置
+        TaskOrchestrationFlow flow = flowMapper.selectById(exec.getFlowId());
+        if (flow == null) {
+            ni.setStatus(STATUS_FAILED);
+            ni.setErrorReason("流定义不存在: flowId=" + exec.getFlowId());
+            nodeInstanceMapper.updateById(ni);
+            return;
+        }
+        String configJson = flow.getConfigJson();
+        if (configJson == null || configJson.isEmpty()) {
+            ni.setStatus(STATUS_FAILED);
+            ni.setErrorReason("流配置为空");
+            nodeInstanceMapper.updateById(ni);
+            return;
+        }
+        JSONObject flowCfg = JSON.parseObject(configJson);
+        JSONObject nodeCfg = null;
+        if (flowCfg.getJSONArray("nodes") != null) {
+            for (Object o : flowCfg.getJSONArray("nodes")) {
+                JSONObject n = (JSONObject) o;
+                if (ni.getNodeId().equals(n.getString("id"))) {
+                    nodeCfg = n;
+                    break;
+                }
+            }
+        }
+        if (nodeCfg == null) {
+            ni.setStatus(STATUS_FAILED);
+            ni.setErrorReason("节点配置不存在");
+            nodeInstanceMapper.updateById(ni);
+            return;
+        }
+
+        // 2. 加载任务模板
+        String templateRef = nodeCfg.getString("task_template_ref");
+        if (templateRef == null || templateRef.isEmpty()) {
+            ni.setStatus(STATUS_FAILED);
+            ni.setErrorReason("未配置 task_template_ref");
+            nodeInstanceMapper.updateById(ni);
+            return;
+        }
+        TaskTemplate template = taskTemplateMapper.selectById(Long.valueOf(templateRef));
+        if (template == null) {
+            ni.setStatus(STATUS_FAILED);
+            ni.setErrorReason("任务模板不存在: " + templateRef);
+            nodeInstanceMapper.updateById(ni);
+            triggerFailedEdges(exec, ni);
+            return;
+        }
+
+        // 3. 创建 tasks 实例
+        CreateTaskDTO dto = new CreateTaskDTO();
+        dto.setTitle(template.getTitle());
+        dto.setDescription(template.getDescription());
+        dto.setPoints(template.getPoints());
+        dto.setTaskType(template.getTaskType());
+        dto.setFrequency(template.getFrequency());
+        dto.setMaxFrequency(template.getMaxFrequency());
+        dto.setNeedReview(template.getNeedReview());
+        dto.setReviewType(template.getReviewType());
+        dto.setReviewByCategory(template.getReviewByCategory());
+        dto.setCompleteTypes(template.getCompleteTypes() != null
+            ? Arrays.asList(template.getCompleteTypes().split(",")) : null);
+        dto.setExecutorType(nodeCfg.getString("executorType") != null
+            ? nodeCfg.getString("executorType") : "child");
+        // familyMemberId 在 execution 上,不在 node_instance 上
+        dto.setExecutorId(exec.getFamilyMemberId());
+        dto.setMemberId(exec.getFamilyMemberId());
+        dto.setMinigameCode(nodeCfg.getString("minigameCode"));
+        dto.setRepeatType(nodeCfg.getString("repeatType"));
+        dto.setDuration(nodeCfg.getInteger("duration"));
+        dto.setDimensionCode(nodeCfg.getString("dimensionCode"));
+        dto.setDimensionWeights(nodeCfg.getString("dimensionWeights"));
+        dto.setMemberOnly(nodeCfg.getInteger("memberOnly") != null
+            ? nodeCfg.getInteger("memberOnly") : 0);
+        dto.setPrerequisiteTaskId(nodeCfg.getLong("prerequisiteTaskId"));
+        dto.setActionType(nodeCfg.getString("actionType"));
+        dto.setActionConfig(nodeCfg.getString("actionConfig"));
+        dto.setRequireInput(nodeCfg.getInteger("requireInput"));
+        dto.setStartRequired(nodeCfg.getInteger("startRequired"));
+        dto.setMinDurationSeconds(nodeCfg.getInteger("minDurationSeconds"));
+        dto.setSourceType(nodeCfg.getString("sourceType"));
+        dto.setSourceId(nodeCfg.getLong("sourceId"));
+        dto.setIsDailyProgress(nodeCfg.getInteger("is_daily_progress"));
+        dto.setTargetValue(nodeCfg.getInteger("targetValue"));
+
+        // 超时时间:deadline = now + timeout_minutes(若配置)
+        Integer timeoutMinutes = nodeCfg.getInteger("timeout_minutes");
+        if (timeoutMinutes != null && timeoutMinutes > 0) {
+            java.util.Calendar cal = java.util.Calendar.getInstance();
+            cal.add(java.util.Calendar.MINUTE, timeoutMinutes);
+            dto.setDeadline(cal.getTime());
+        }
+
+        Long taskId = taskService.createTask(exec.getFamilyMemberId(), dto);
+
+        // 4. 回写 task_id + 状态
+        ni.setTaskId(taskId);
+        ni.setStatus(STATUS_STARTED);
+        ni.setStartedAt(new Date());
+        nodeInstanceMapper.updateById(ni);
+    }
+
+    /**
+     * 触发 failed 边:为所有 edge_type=failed 的目标节点创建 pending 实例(幂等)。
+     */
+    private void triggerFailedEdges(TaskOrchestrationExecution exec,
+                                    TaskOrchestrationNodeInstance ni) {
+        List<TaskOrchestrationEdge> failedEdges = edgeMapper.selectList(
+            new LambdaQueryWrapper<TaskOrchestrationEdge>()
+                .eq(TaskOrchestrationEdge::getFlowId, exec.getFlowId())
+                .eq(TaskOrchestrationEdge::getFromNodeId, ni.getNodeId())
+                .eq(TaskOrchestrationEdge::getEdgeType, "failed"));
+        for (TaskOrchestrationEdge e : failedEdges) {
+            Long exists = nodeInstanceMapper.selectCount(
+                new LambdaQueryWrapper<TaskOrchestrationNodeInstance>()
+                    .eq(TaskOrchestrationNodeInstance::getExecutionId, exec.getId())
+                    .eq(TaskOrchestrationNodeInstance::getNodeId, e.getToNodeId())
+                    .eq(TaskOrchestrationNodeInstance::getGeneration, 0)
+                    .eq(TaskOrchestrationNodeInstance::getStatus, STATUS_PENDING));
+            if (exists != null && exists > 0) {
+                continue;
+            }
+            TaskOrchestrationNodeInstance newNi = new TaskOrchestrationNodeInstance();
+            newNi.setExecutionId(exec.getId());
+            newNi.setNodeId(e.getToNodeId());
+            newNi.setGeneration(0);
+            newNi.setStatus(STATUS_PENDING);
+            newNi.setCreatedAt(new Date());
+            nodeInstanceMapper.insert(newNi);
+        }
+    }
+
+    /**
+     * 任务完成回调(TaskService 任务完成/审核通过时调用)。
+     */
+    public void onTaskCompleted(Long taskId) {
+        TaskOrchestrationNodeInstance ni = findByTaskId(taskId);
+        if (ni == null) {
+            return;
+        }
+        ni.setStatus(STATUS_COMPLETED);
+        ni.setCompletedAt(new Date());
+        nodeInstanceMapper.updateById(ni);
+        evaluateFlow(ni.getExecutionId());
+    }
+
+    /**
+     * 任务失败回调(用户主动放弃/异常时调用)。
+     */
+    public void onTaskFailed(Long taskId) {
+        TaskOrchestrationNodeInstance ni = findByTaskId(taskId);
+        if (ni == null) {
+            return;
+        }
+        ni.setStatus(STATUS_FAILED);
+        ni.setCompletedAt(new Date());
+        nodeInstanceMapper.updateById(ni);
+        TaskOrchestrationExecution exec = executionMapper.selectById(ni.getExecutionId());
+        if (exec != null) {
+            triggerFailedEdges(exec, ni);
+        }
+        evaluateFlow(ni.getExecutionId());
+    }
+
+    /**
+     * 任务超时兜底(Quartz 轮询 job 调用,传入 taskId)。
+     * 找到关联的节点实例,标记 timeout,触发 timeout 边,重新评估流程。
+     */
+    public void onTaskTimeout(Long taskId) {
+        TaskOrchestrationNodeInstance ni = findByTaskId(taskId);
+        if (ni == null) return;
+        ni.setStatus(STATUS_TIMEOUT);
+        ni.setCompletedAt(new Date());
+        nodeInstanceMapper.updateById(ni);
+        TaskOrchestrationExecution exec = executionMapper.selectById(ni.getExecutionId());
+        if (exec == null) return;
+        // 触发 timeout 边(兜底分支)
+        List<TaskOrchestrationEdge> timeoutEdges = edgeMapper.selectList(
+            new LambdaQueryWrapper<TaskOrchestrationEdge>()
+                .eq(TaskOrchestrationEdge::getFlowId, exec.getFlowId())
+                .eq(TaskOrchestrationEdge::getFromNodeId, ni.getNodeId())
+                .eq(TaskOrchestrationEdge::getEdgeType, "timeout"));
+        for (TaskOrchestrationEdge e : timeoutEdges) {
+            Long exists = nodeInstanceMapper.selectCount(
+                new LambdaQueryWrapper<TaskOrchestrationNodeInstance>()
+                    .eq(TaskOrchestrationNodeInstance::getExecutionId, exec.getId())
+                    .eq(TaskOrchestrationNodeInstance::getNodeId, e.getToNodeId())
+                    .eq(TaskOrchestrationNodeInstance::getGeneration, 0)
+                    .eq(TaskOrchestrationNodeInstance::getStatus, STATUS_PENDING));
+            if (exists != null && exists > 0) continue;
+            TaskOrchestrationNodeInstance newNi = new TaskOrchestrationNodeInstance();
+            newNi.setExecutionId(exec.getId());
+            newNi.setNodeId(e.getToNodeId());
+            newNi.setGeneration(0);
+            newNi.setStatus(STATUS_PENDING);
+            newNi.setCreatedAt(new Date());
+            nodeInstanceMapper.insert(newNi);
+        }
+        evaluateFlow(exec.getId());
+    }
+
+    /**
+     * 通过 taskId 查找其所属的编排节点实例。
+     */
+    private TaskOrchestrationNodeInstance findByTaskId(Long taskId) {
+        if (taskId == null) {
+            return null;
+        }
+        return nodeInstanceMapper.selectOne(
+            new LambdaQueryWrapper<TaskOrchestrationNodeInstance>()
+                .eq(TaskOrchestrationNodeInstance::getTaskId, taskId)
+                .last("LIMIT 1"));
+    }
+
+    /**
+     * 从流配置解析节点 max_loops(configJson 支持两种结构):
+     * nodes[].config.max_loops 或 nodes[].loop_termination.max_loops。
+     */
+    private Integer resolveMaxLoops(Long flowId, String nodeId) {
+        TaskOrchestrationFlow flow = flowMapper.selectById(flowId);
+        String configJson = flow == null ? null : flow.getConfigJson();
+        if (configJson == null || configJson.isEmpty()) {
+            return null;
+        }
+        try {
+            JSONObject root = JSON.parseObject(configJson);
+            JSONArray nodesObj = root.getJSONArray("nodes");
+            if (nodesObj == null) {
+                return null;
+            }
+            for (int i = 0; i < nodesObj.size(); i++) {
+                JSONObject n = nodesObj.getJSONObject(i);
+                if (nodeId.equals(n.getString("id"))) {
+                    JSONObject cfg = n.getJSONObject("config");
+                    if (cfg == null) {
+                        cfg = n;
+                    }
+                    Integer maxLoops = cfg.getInteger("max_loops");
+                    if (maxLoops == null && cfg.getJSONObject("loop_termination") != null) {
+                        maxLoops = cfg.getJSONObject("loop_termination").getInteger("max_loops");
+                    }
+                    return maxLoops;
+                }
+            }
+        } catch (Exception e) {
+            log.warn("解析编排流配置失败 flowId={} nodeId={} err={}", flowId, nodeId, e.getMessage());
+        }
+        return null;
+    }
+}

+ 172 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/OrchestrationExecutionService.java

@@ -0,0 +1,172 @@
+package com.etotem.cfc.service;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.entity.TaskOrchestrationExecution;
+import com.etotem.cfc.entity.TaskOrchestrationNodeInstance;
+import com.etotem.cfc.entity.TaskOrchestrationFlow;
+import com.etotem.cfc.mapper.TaskOrchestrationExecutionMapper;
+import com.etotem.cfc.mapper.TaskOrchestrationNodeInstanceMapper;
+import com.etotem.cfc.mapper.TaskOrchestrationFlowMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.annotation.Lazy;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.util.*;
+
+/**
+ * 编排执行 Service - Execution 生命周期管理
+ */
+@Slf4j
+@Service
+public class OrchestrationExecutionService {
+
+    @Resource
+    private TaskOrchestrationExecutionMapper executionMapper;
+
+    @Resource
+    private TaskOrchestrationNodeInstanceMapper nodeInstanceMapper;
+
+    @Resource
+    private TaskOrchestrationFlowMapper flowMapper;
+
+    @Resource
+    @Lazy
+    private OrchestrationEngine orchestrationEngine;
+
+    /**
+     * 手动启动流执行
+     * 创建 execution + 初始化所有节点为 pending,然后触发 start_node
+     */
+    @Transactional
+    public Map<String, Object> start(Long flowId, Long familyMemberId, Long userId) {
+        TaskOrchestrationFlow flow = flowMapper.selectById(flowId);
+        if (flow == null) throw new RuntimeException("编排流不存在: " + flowId);
+        if (!"published".equals(flow.getStatus())) throw new RuntimeException("编排流未发布");
+
+        // 创建执行实例
+        TaskOrchestrationExecution exec = new TaskOrchestrationExecution();
+        exec.setFlowId(flowId);
+        exec.setFlowVersion(flow.getVersion());
+        exec.setFamilyId(flow.getFamilyId());
+        exec.setFamilyMemberId(familyMemberId);
+        exec.setTriggerSource("manual");
+        exec.setStatus("running");
+        exec.setStartedAt(new Date());
+        executionMapper.insert(exec);
+
+        // 读取 config_json,创建所有节点实例
+        String configJson = flow.getConfigJson();
+        if (configJson != null && !configJson.isEmpty()) {
+            JSONObject cfg = JSON.parseObject(configJson);
+            if (cfg.getJSONArray("nodes") != null) {
+                for (Object o : cfg.getJSONArray("nodes")) {
+                    JSONObject n = (JSONObject) o;
+                    TaskOrchestrationNodeInstance ni = new TaskOrchestrationNodeInstance();
+                    ni.setExecutionId(exec.getId());
+                    ni.setNodeId(n.getString("id"));
+                    ni.setGeneration(0);
+                    ni.setStatus("pending");
+                    ni.setCreatedAt(new Date());
+                    nodeInstanceMapper.insert(ni);
+                }
+            }
+        }
+
+        // 触发 start_node(evaluateFlow 会自行判断哪些节点可以触发)
+        orchestrationEngine.evaluateFlow(exec.getId());
+
+        Map<String, Object> data = new HashMap<>();
+        data.put("executionId", exec.getId());
+        return data;
+    }
+
+    /**
+     * 暂停执行(status → paused)
+     */
+    @Transactional
+    public boolean pause(Long executionId) {
+        TaskOrchestrationExecution exec = executionMapper.selectById(executionId);
+        if (exec == null) return false;
+        if (!"running".equals(exec.getStatus())) return false;
+        exec.setStatus("paused");
+        exec.setUpdatedAt(new Date());
+        return executionMapper.updateById(exec) > 0;
+    }
+
+    /**
+     * 恢复执行(status → running)
+     */
+    @Transactional
+    public boolean resume(Long executionId) {
+        TaskOrchestrationExecution exec = executionMapper.selectById(executionId);
+        if (exec == null) return false;
+        if (!"paused".equals(exec.getStatus())) return false;
+        exec.setStatus("running");
+        exec.setUpdatedAt(new Date());
+        executionMapper.updateById(exec);
+        orchestrationEngine.evaluateFlow(executionId);
+        return true;
+    }
+
+    /**
+     * 终止执行(status → terminated,所有 pending 节点标记 skipped)
+     */
+    @Transactional
+    public boolean terminate(Long executionId, String reason) {
+        TaskOrchestrationExecution exec = executionMapper.selectById(executionId);
+        if (exec == null) return false;
+        exec.setStatus("terminated");
+        exec.setFinishedAt(new Date());
+        exec.setErrorReason(reason);
+        exec.setUpdatedAt(new Date());
+        executionMapper.updateById(exec);
+
+        // 将所有 pending/in_progress 节点标记为 skipped
+        List<TaskOrchestrationNodeInstance> nodes = nodeInstanceMapper.selectList(
+            new LambdaQueryWrapper<TaskOrchestrationNodeInstance>()
+                .eq(TaskOrchestrationNodeInstance::getExecutionId, executionId)
+                .in(TaskOrchestrationNodeInstance::getStatus, "pending", "in_progress"));
+        for (TaskOrchestrationNodeInstance ni : nodes) {
+            ni.setStatus("skipped");
+            ni.setCompletedAt(new Date());
+            nodeInstanceMapper.updateById(ni);
+        }
+        return true;
+    }
+
+    /**
+     * 执行详情(含节点列表)
+     */
+    public Map<String, Object> detail(Long executionId) {
+        TaskOrchestrationExecution exec = executionMapper.selectById(executionId);
+        if (exec == null) return null;
+        List<TaskOrchestrationNodeInstance> nodes = nodeInstanceMapper.selectList(
+            new LambdaQueryWrapper<TaskOrchestrationNodeInstance>()
+                .eq(TaskOrchestrationNodeInstance::getExecutionId, executionId)
+                .orderByAsc(TaskOrchestrationNodeInstance::getCreatedAt));
+        Map<String, Object> data = new HashMap<>();
+        data.put("execution", exec);
+        data.put("nodes", nodes);
+        return data;
+    }
+
+    /**
+     * 执行历史列表
+     */
+    public Map<String, Object> list(Long familyMemberId, Integer page, Integer pageSize) {
+        Page<TaskOrchestrationExecution> p = new Page<>(page, pageSize);
+        executionMapper.selectPage(p,
+            new LambdaQueryWrapper<TaskOrchestrationExecution>()
+                .eq(familyMemberId != null, TaskOrchestrationExecution::getFamilyMemberId, familyMemberId)
+                .orderByDesc(TaskOrchestrationExecution::getStartedAt));
+        Map<String, Object> result = new HashMap<>();
+        result.put("list", p.getRecords());
+        result.put("total", p.getTotal());
+        return result;
+    }
+}

+ 166 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/OrchestrationFlowService.java

@@ -0,0 +1,166 @@
+package com.etotem.cfc.service;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.entity.TaskOrchestrationEdge;
+import com.etotem.cfc.entity.TaskOrchestrationExecution;
+import com.etotem.cfc.entity.TaskOrchestrationFlow;
+import com.etotem.cfc.mapper.TaskOrchestrationEdgeMapper;
+import com.etotem.cfc.mapper.TaskOrchestrationExecutionMapper;
+import com.etotem.cfc.mapper.TaskOrchestrationFlowMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.annotation.Lazy;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 编排流 Service - Flow CRUD 与发布流程
+ */
+@Slf4j
+@Service
+public class OrchestrationFlowService {
+
+    @Resource
+    private TaskOrchestrationFlowMapper flowMapper;
+
+    @Resource
+    private TaskOrchestrationEdgeMapper edgeMapper;
+
+    @Resource
+    private TaskOrchestrationExecutionMapper executionMapper;
+
+    @Resource
+    @Lazy
+    private OrchestrationEngine orchestrationEngine;
+
+    /**
+     * 保存/更新流(草稿状态)。
+     * 保存 config_json 与 edges 快照,version 保持不变。
+     */
+    @Transactional
+    public TaskOrchestrationFlow save(TaskOrchestrationFlow flow) {
+        if (flow.getId() == null) {
+            flow.setStatus("draft");
+            flow.setVersion(1);
+            flow.setCreatedAt(new Date());
+        }
+        flow.setUpdatedAt(new Date());
+        if (flow.getId() == null) {
+            flowMapper.insert(flow);
+        } else {
+            flowMapper.updateById(flow);
+        }
+        Long flowId = flow.getId();
+        // 同步 edges:先删除旧 edges,再插入新 edges
+        edgeMapper.delete(new QueryWrapper<TaskOrchestrationEdge>().eq("flow_id", flowId));
+        if (flow.getConfigJson() != null && !flow.getConfigJson().isEmpty()) {
+            JSONObject cfg = JSON.parseObject(flow.getConfigJson());
+            if (cfg.getJSONArray("nodes") != null) {
+                // edges 从 nodes 的 connections 数组读取
+                JSONObject configObj = cfg.getJSONObject("config");
+                if (configObj != null && configObj.getJSONArray("edges") != null) {
+                    for (Object o : configObj.getJSONArray("edges")) {
+                        JSONObject e = (JSONObject) o;
+                        TaskOrchestrationEdge edge = new TaskOrchestrationEdge();
+                        edge.setFlowId(flowId);
+                        edge.setFromNodeId(e.getString("from"));
+                        edge.setToNodeId(e.getString("to"));
+                        edge.setEdgeType(e.getString("type"));
+                        edge.setOperator(e.getString("operator"));
+                        edge.setSortOrder(e.getInteger("sort"));
+                        edge.setCreatedAt(new Date());
+                        edgeMapper.insert(edge);
+                    }
+                }
+            }
+        }
+        return flow;
+    }
+
+    /**
+     * draft → published:状态转换 + version+1
+     */
+    @Transactional
+    public TaskOrchestrationFlow publish(Long flowId) {
+        TaskOrchestrationFlow flow = flowMapper.selectById(flowId);
+        if (flow == null) throw new RuntimeException("编排流不存在: " + flowId);
+        if ("published".equals(flow.getStatus())) return flow;
+        flow.setStatus("published");
+        flow.setVersion(flow.getVersion() + 1);
+        flow.setUpdatedAt(new Date());
+        flowMapper.updateById(flow);
+        return flow;
+    }
+
+    /**
+     * 分页列表(可按 family_id、status 过滤)
+     */
+    public Map<String, Object> list(Integer page, Integer pageSize, Long familyId, String status) {
+        Page<TaskOrchestrationFlow> p = new Page<>(page, pageSize);
+        flowMapper.selectPage(p,
+            new LambdaQueryWrapper<TaskOrchestrationFlow>()
+                .eq(familyId != null, TaskOrchestrationFlow::getFamilyId, familyId)
+                .eq(status != null, TaskOrchestrationFlow::getStatus, status)
+                .orderByDesc(TaskOrchestrationFlow::getCreatedAt));
+        Map<String, Object> result = new HashMap<>();
+        result.put("list", p.getRecords());
+        result.put("total", p.getTotal());
+        return result;
+    }
+
+    /**
+     * 流详情(含 edges 列表)
+     */
+    public Map<String, Object> detail(Long flowId) {
+        TaskOrchestrationFlow flow = flowMapper.selectById(flowId);
+        if (flow == null) return null;
+        List<TaskOrchestrationEdge> edges = edgeMapper.selectList(
+            new LambdaQueryWrapper<TaskOrchestrationEdge>()
+                .eq(TaskOrchestrationEdge::getFlowId, flowId));
+        Map<String, Object> data = new HashMap<>();
+        data.put("flow", flow);
+        data.put("edges", edges);
+        return data;
+    }
+
+    /**
+     * published → archived
+     */
+    @Transactional
+    public boolean archive(Long flowId) {
+        TaskOrchestrationFlow flow = flowMapper.selectById(flowId);
+        if (flow == null) return false;
+        if (!"published".equals(flow.getStatus())) return false;
+        flow.setStatus("archived");
+        flow.setUpdatedAt(new Date());
+        return flowMapper.updateById(flow) > 0;
+    }
+
+    /**
+     * 删除 draft 流(无引用执行时可删除)
+     */
+    @Transactional
+    public boolean delete(Long flowId) {
+        TaskOrchestrationFlow flow = flowMapper.selectById(flowId);
+        if (flow == null) return false;
+        if (!"draft".equals(flow.getStatus())) return false;
+        long execCount = executionMapper.selectCount(
+            new LambdaQueryWrapper<TaskOrchestrationExecution>()
+                .eq(TaskOrchestrationExecution::getFlowId, flowId)
+                .eq(TaskOrchestrationExecution::getStatus, "running"));
+        if (execCount > 0) throw new RuntimeException("有运行中的执行实例,禁止删除");
+        // 同时删除关联 edges
+        edgeMapper.delete(new LambdaQueryWrapper<TaskOrchestrationEdge>()
+            .eq(TaskOrchestrationEdge::getFlowId, flowId));
+        return flowMapper.deleteById(flowId) > 0;
+    }
+}

+ 11 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/TaskService.java

@@ -12,6 +12,7 @@ import com.etotem.cfc.mapper.*;
 import com.etotem.cfc.service.api.TaskServiceInterface;
 import com.alibaba.fastjson.JSON;
 import com.alibaba.fastjson.JSONObject;
+import com.etotem.cfc.service.OrchestrationEngine;
 import javax.annotation.Resource;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.context.annotation.Lazy;
@@ -75,6 +76,10 @@ public class TaskService implements TaskServiceInterface {
     @Resource
     private SurveyTemplateMapper surveyTemplateMapper;
 
+    @Resource
+    @Lazy
+    private OrchestrationEngine orchestrationEngine;
+
     @Resource
     private TaskTemplateMapper taskTemplateMapper;
 
@@ -765,6 +770,12 @@ public List<Task> getTodayTasks(Long memberId, String dimensionCode) {
     task.setCompletedAt(now);
     task.setUpdatedAt(new Date());
     taskMapper.updateById(task);
+    // 仅编排节点任务生效:通知编排引擎流程评估(非编排节点任务零开销)
+    try {
+        orchestrationEngine.onTaskCompleted(taskId);
+    } catch (Exception e) {
+        log.warn("orchestration onTaskCompleted failed: {}", e.getMessage());
+    }
 
     // 更新家长积分
     int newPoints = (user.getTotalPoints() != null ? user.getTotalPoints() : 0) + pointsEarned;

+ 84 - 0
cfc-backend/src/main/java/com/etotem/cfc/task/OrchestrationPollingJob.java

@@ -0,0 +1,84 @@
+package com.etotem.cfc.task;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.Task;
+import com.etotem.cfc.entity.TaskOrchestrationExecution;
+import com.etotem.cfc.entity.TaskOrchestrationNodeInstance;
+import com.etotem.cfc.mapper.TaskMapper;
+import com.etotem.cfc.mapper.TaskOrchestrationExecutionMapper;
+import com.etotem.cfc.mapper.TaskOrchestrationNodeInstanceMapper;
+import com.etotem.cfc.service.OrchestrationEngine;
+import lombok.extern.slf4j.Slf4j;
+import org.quartz.DisallowConcurrentExecution;
+import org.quartz.Job;
+import org.quartz.JobExecutionContext;
+import org.quartz.JobExecutionException;
+import org.springframework.context.annotation.Lazy;
+
+import javax.annotation.Resource;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 编排轮询 Job,每 30 秒执行一次。
+ * 负责:检查 running 执行的 timeout 节点、标记超时、触发 timeout 边、评估流程。
+ */
+@Slf4j
+@DisallowConcurrentExecution
+public class OrchestrationPollingJob implements Job {
+
+    @Resource
+    private TaskOrchestrationExecutionMapper executionMapper;
+
+    @Resource
+    private TaskOrchestrationNodeInstanceMapper nodeInstanceMapper;
+
+    @Resource
+    private TaskMapper taskMapper;
+
+    @Resource
+    @Lazy
+    private OrchestrationEngine orchestrationEngine;
+
+    @Override
+    public void execute(JobExecutionContext context) throws JobExecutionException {
+        log.info("编排轮询开始...");
+        try {
+            // 1. 查询所有 running 状态的 execution(每次最多 20 个)
+            List<TaskOrchestrationExecution> executions = executionMapper.selectList(
+                new LambdaQueryWrapper<TaskOrchestrationExecution>()
+                    .eq(TaskOrchestrationExecution::getStatus, "running")
+                    .last("LIMIT 20"));
+            for (TaskOrchestrationExecution exec : executions) {
+                // 2. 检查 timeout 节点(deadline 到期)
+                checkTimeouts(exec);
+            }
+            // 3. 评估 pending 节点(触发解锁的下游节点)
+            for (TaskOrchestrationExecution exec : executions) {
+                orchestrationEngine.evaluateFlow(exec.getId());
+            }
+        } catch (Exception e) {
+            log.error("编排轮询异常", e);
+        }
+        log.info("编排轮询结束");
+    }
+
+    /**
+     * 检查 execution 下的节点是否超时(deadline 已过),若超时则触发任务超时兜底。
+     */
+    private void checkTimeouts(TaskOrchestrationExecution exec) {
+        List<TaskOrchestrationNodeInstance> nodes = nodeInstanceMapper.selectList(
+            new LambdaQueryWrapper<TaskOrchestrationNodeInstance>()
+                .eq(TaskOrchestrationNodeInstance::getExecutionId, exec.getId())
+                .eq(TaskOrchestrationNodeInstance::getStatus, "in_progress"));
+        Date now = new Date();
+        for (TaskOrchestrationNodeInstance ni : nodes) {
+            if (ni.getTaskId() == null) continue;
+            Task task = taskMapper.selectById(ni.getTaskId());
+            if (task != null && task.getDeadline() != null && task.getDeadline().before(now)
+                && !"completed".equals(task.getStatus())) {
+                orchestrationEngine.onTaskTimeout(ni.getTaskId());
+            }
+        }
+    }
+}

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

@@ -5785,3 +5785,73 @@ CREATE TABLE IF NOT EXISTS user_app_usage (
     last_enter_at DATETIME COMMENT '最近进入时间',
     UNIQUE KEY uk_user_app (user_id, app_key)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='应用使用频度记录';
+-- ===== 任务编排系统(task orchestration)=====
+
+CREATE TABLE IF NOT EXISTS `task_orchestration_flows` (
+  `id` bigint NOT NULL AUTO_INCREMENT,
+  `name` varchar(100) NOT NULL,
+  `description` text,
+  `creator_id` bigint NOT NULL,
+  `family_id` bigint DEFAULT NULL,
+  `version` int NOT NULL DEFAULT 1,
+  `status` varchar(20) NOT NULL DEFAULT 'draft',
+  `schedule_cron` varchar(64) DEFAULT NULL,
+  `config_json` json DEFAULT NULL,
+  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  KEY `idx_family_id` (`family_id`),
+  KEY `idx_status` (`status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='任务编排流定义';
+
+CREATE TABLE IF NOT EXISTS `task_orchestration_edges` (
+  `id` bigint NOT NULL AUTO_INCREMENT,
+  `flow_id` bigint NOT NULL,
+  `from_node_id` varchar(64) NOT NULL,
+  `to_node_id` varchar(64) NOT NULL,
+  `edge_type` varchar(20) NOT NULL,
+  `operator` varchar(10) NOT NULL DEFAULT 'AND',
+  `sort_order` int NOT NULL DEFAULT 0,
+  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  KEY `idx_flow_id` (`flow_id`),
+  UNIQUE KEY `uk_flow_edge` (`flow_id`, `from_node_id`, `to_node_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='任务编排流边定义';
+
+CREATE TABLE IF NOT EXISTS `task_orchestration_executions` (
+  `id` bigint NOT NULL AUTO_INCREMENT,
+  `flow_id` bigint NOT NULL,
+  `flow_version` int NOT NULL DEFAULT 1,
+  `family_id` bigint NOT NULL,
+  `family_member_id` bigint NOT NULL,
+  `trigger_source` varchar(20) NOT NULL DEFAULT 'manual',
+  `status` varchar(20) NOT NULL DEFAULT 'running',
+  `started_at` datetime NOT NULL,
+  `finished_at` datetime DEFAULT NULL,
+  `error_reason` varchar(255) DEFAULT NULL,
+  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  KEY `idx_execution_flow` (`flow_id`),
+  KEY `idx_execution_status` (`status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='任务编排流执行记录';
+
+CREATE TABLE IF NOT EXISTS `task_orchestration_node_instances` (
+  `id` bigint NOT NULL AUTO_INCREMENT,
+  `execution_id` bigint NOT NULL,
+  `node_id` varchar(64) NOT NULL,
+  `task_id` bigint DEFAULT NULL,
+  `status` varchar(20) NOT NULL DEFAULT 'pending',
+  `generation` int NOT NULL DEFAULT 0,
+  `condition_met_at` datetime DEFAULT NULL COMMENT '条件终止:外部API通知条件达成时间',
+  `loop_termination_reason` varchar(50) DEFAULT NULL,
+  `started_at` datetime DEFAULT NULL,
+  `completed_at` datetime DEFAULT NULL,
+  `error_reason` varchar(255) DEFAULT NULL,
+  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_node_instance` (`execution_id`, `node_id`, `generation`),
+  KEY `idx_execution_id` (`execution_id`),
+  KEY `idx_task_id` (`task_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='任务编排流节点实例';

+ 205 - 0
cfc-backend/src/test/java/com/etotem/cfc/orchestration/OrchestrationEngineTest.java

@@ -0,0 +1,205 @@
+package com.etotem.cfc.orchestration;
+
+import com.etotem.cfc.entity.TaskOrchestrationEdge;
+import com.etotem.cfc.entity.TaskOrchestrationExecution;
+import com.etotem.cfc.entity.TaskOrchestrationFlow;
+import com.etotem.cfc.entity.TaskOrchestrationNodeInstance;
+import com.etotem.cfc.mapper.TaskOrchestrationEdgeMapper;
+import com.etotem.cfc.mapper.TaskOrchestrationExecutionMapper;
+import com.etotem.cfc.mapper.TaskOrchestrationFlowMapper;
+import com.etotem.cfc.mapper.TaskOrchestrationNodeInstanceMapper;
+import com.etotem.cfc.service.OrchestrationEngine;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.Arrays;
+import java.util.Collections;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+class OrchestrationEngineTest {
+
+    @InjectMocks
+    private OrchestrationEngine engine;
+
+    @Mock
+    private TaskOrchestrationNodeInstanceMapper nodeInstanceMapper;
+
+    @Mock
+    private TaskOrchestrationEdgeMapper edgeMapper;
+
+    @Mock
+    private TaskOrchestrationExecutionMapper executionMapper;
+
+    @Mock
+    private TaskOrchestrationFlowMapper flowMapper;
+
+    @Test
+    void testIsUnlocked_StartNode_NoInEdges_ReturnsTrue() {
+        TaskOrchestrationNodeInstance ni = makeNI(1L, "start", OrchestrationEngine.STATUS_PENDING);
+        // empty inEdges = start node → should be unlocked immediately
+        boolean result = engine.isUnlocked(ni, Collections.emptyList());
+        assertThat(result).isTrue();
+    }
+
+    @Test
+    void testIsUnlocked_AND_AllCompleted_ReturnsTrue() {
+        TaskOrchestrationNodeInstance target = makeNI(1L, "target", OrchestrationEngine.STATUS_PENDING);
+        TaskOrchestrationEdge edgeA = makeEdge("node-a", "target", "AND");
+        TaskOrchestrationEdge edgeB = makeEdge("node-b", "target", "AND");
+
+        // isUnlocked calls nodeInstanceMapper.selectList to get all nodes for execution
+        // return ALL nodes (target + source-a + source-b) in one list
+        when(nodeInstanceMapper.selectList(any())).thenReturn(Arrays.asList(
+                makeNI(1L, "target", OrchestrationEngine.STATUS_PENDING),
+                makeNI(1L, "node-a", OrchestrationEngine.STATUS_COMPLETED),
+                makeNI(1L, "node-b", OrchestrationEngine.STATUS_COMPLETED)
+        ));
+
+        boolean result = engine.isUnlocked(target, Arrays.asList(edgeA, edgeB));
+        assertThat(result).isTrue();
+    }
+
+    @Test
+    void testIsUnlocked_AND_OnePending_ReturnsFalse() {
+        TaskOrchestrationNodeInstance target = makeNI(1L, "target", OrchestrationEngine.STATUS_PENDING);
+        TaskOrchestrationEdge edgeA = makeEdge("node-a", "target", "AND");
+        TaskOrchestrationEdge edgeB = makeEdge("node-b", "target", "AND");
+
+        when(nodeInstanceMapper.selectList(any())).thenReturn(Arrays.asList(
+                makeNI(1L, "target", OrchestrationEngine.STATUS_PENDING),
+                makeNI(1L, "node-a", OrchestrationEngine.STATUS_COMPLETED),
+                makeNI(1L, "node-b", OrchestrationEngine.STATUS_PENDING)
+        ));
+
+        boolean result = engine.isUnlocked(target, Arrays.asList(edgeA, edgeB));
+        assertThat(result).isFalse();
+    }
+
+    @Test
+    void testIsUnlocked_OR_OneCompleted_ReturnsTrue() {
+        TaskOrchestrationNodeInstance target = makeNI(1L, "target", OrchestrationEngine.STATUS_PENDING);
+        TaskOrchestrationEdge edgeA = makeEdge("node-a", "target", "OR");
+        TaskOrchestrationEdge edgeB = makeEdge("node-b", "target", "OR");
+
+        when(nodeInstanceMapper.selectList(any())).thenReturn(Arrays.asList(
+                makeNI(1L, "target", OrchestrationEngine.STATUS_PENDING),
+                makeNI(1L, "node-a", OrchestrationEngine.STATUS_COMPLETED),
+                makeNI(1L, "node-b", OrchestrationEngine.STATUS_PENDING)
+        ));
+
+        boolean result = engine.isUnlocked(target, Arrays.asList(edgeA, edgeB));
+        assertThat(result).isTrue();
+    }
+
+    @Test
+    void testApplyLoopTermination_ConditionMetAtSet_StatusCompleted() {
+        TaskOrchestrationExecution exec = makeExec(1L, 1L);
+        TaskOrchestrationFlow flow = new TaskOrchestrationFlow();
+        flow.setId(1L);
+        flow.setConfigJson("{\"nodes\":[{\"id\":\"n1\",\"loop_termination\":{\"max_loops\":3}}]}");
+        when(flowMapper.selectById(1L)).thenReturn(flow);
+
+        TaskOrchestrationNodeInstance ni = makeNI(1L, "n1", OrchestrationEngine.STATUS_PENDING);
+        ni.setConditionMetAt(new java.util.Date());
+
+        engine.applyLoopTermination(exec, ni);
+
+        assertThat(ni.getStatus()).isEqualTo(OrchestrationEngine.STATUS_COMPLETED);
+        assertThat(ni.getLoopTerminationReason()).isEqualTo(OrchestrationEngine.LOOP_REASON_CONDITION);
+    }
+
+    @Test
+    void testApplyLoopTermination_GenerationExceedsMaxLoops_StatusTerminated() {
+        TaskOrchestrationExecution exec = makeExec(1L, 1L);
+        TaskOrchestrationFlow flow = new TaskOrchestrationFlow();
+        flow.setId(1L);
+        flow.setConfigJson("{\"nodes\":[{\"id\":\"n1\",\"loop_termination\":{\"max_loops\":2}}]}");
+        when(flowMapper.selectById(1L)).thenReturn(flow);
+
+        TaskOrchestrationNodeInstance ni = makeNI(1L, "n1", OrchestrationEngine.STATUS_PENDING);
+        ni.setGeneration(3);
+        ni.setConditionMetAt(null);
+
+        engine.applyLoopTermination(exec, ni);
+
+        assertThat(ni.getStatus()).isEqualTo(OrchestrationEngine.STATUS_TERMINATED);
+        assertThat(ni.getLoopTerminationReason()).isEqualTo(OrchestrationEngine.LOOP_REASON_MAX);
+    }
+
+    @Test
+    void testOnTaskCompleted_FoundNode_ThenMarkCompletedAndEvaluateFlow() {
+        Long taskId = 100L;
+        Long execId = 1L;
+        TaskOrchestrationNodeInstance ni = makeNI(execId, "n1", OrchestrationEngine.STATUS_STARTED);
+        ni.setTaskId(taskId);
+
+        when(nodeInstanceMapper.selectOne(any())).thenReturn(ni);
+        when(executionMapper.selectById(execId)).thenReturn(makeExec(execId, 1L));
+
+        boolean result = engine.onTaskCompleted(taskId);
+
+        assertThat(result).isTrue();
+        ArgumentCaptor<TaskOrchestrationNodeInstance> captor =
+                ArgumentCaptor.forClass(TaskOrchestrationNodeInstance.class);
+        verify(nodeInstanceMapper).updateById(captor.capture());
+        assertThat(captor.getValue().getStatus())
+                .isEqualTo(OrchestrationEngine.STATUS_COMPLETED);
+        assertThat(captor.getValue().getCompletedAt()).isNotNull();
+        verify(executionMapper).selectById(execId);
+    }
+
+    @Test
+    void testOnTaskFailed_FoundNode_ThenMarkFailedAndEvaluateFlow() {
+        Long taskId = 200L;
+        Long execId = 2L;
+        TaskOrchestrationNodeInstance ni = makeNI(execId, "n1", OrchestrationEngine.STATUS_STARTED);
+        ni.setTaskId(taskId);
+
+        when(nodeInstanceMapper.selectOne(any())).thenReturn(ni);
+        when(executionMapper.selectById(execId)).thenReturn(makeExec(execId, 2L));
+
+        boolean result = engine.onTaskFailed(taskId);
+
+        assertThat(result).isTrue();
+        ArgumentCaptor<TaskOrchestrationNodeInstance> captor =
+                ArgumentCaptor.forClass(TaskOrchestrationNodeInstance.class);
+        verify(nodeInstanceMapper).updateById(captor.capture());
+        assertThat(captor.getValue().getStatus())
+                .isEqualTo(OrchestrationEngine.STATUS_FAILED);
+        assertThat(captor.getValue().getErrorReason()).isEqualTo("manual_fail");
+        verify(executionMapper).selectById(execId);
+    }
+
+    private TaskOrchestrationNodeInstance makeNI(Long execId, String nodeId, String status) {
+        TaskOrchestrationNodeInstance ni = new TaskOrchestrationNodeInstance();
+        ni.setExecutionId(execId);
+        ni.setNodeId(nodeId);
+        ni.setStatus(status);
+        return ni;
+    }
+
+    private TaskOrchestrationExecution makeExec(Long execId, Long flowId) {
+        TaskOrchestrationExecution e = new TaskOrchestrationExecution();
+        e.setId(execId);
+        e.setStatus(OrchestrationEngine.STATUS_RUNNING);
+        e.setFlowId(flowId);
+        return e;
+    }
+
+    private TaskOrchestrationEdge makeEdge(String fromNodeId, String toNodeId, String operator) {
+        TaskOrchestrationEdge e = new TaskOrchestrationEdge();
+        e.setFromNodeId(fromNodeId);
+        e.setToNodeId(toNodeId);
+        e.setOperator(operator);
+        return e;
+    }
+}

+ 190 - 0
cfc-backend/src/test/java/com/etotem/cfc/orchestration/OrchestrationIntegrationTest.java

@@ -0,0 +1,190 @@
+package com.etotem.cfc.orchestration;
+
+import com.etotem.cfc.entity.TaskOrchestrationEdge;
+import com.etotem.cfc.entity.TaskOrchestrationExecution;
+import com.etotem.cfc.entity.TaskOrchestrationFlow;
+import com.etotem.cfc.entity.TaskOrchestrationNodeInstance;
+import com.etotem.cfc.mapper.TaskOrchestrationEdgeMapper;
+import com.etotem.cfc.mapper.TaskOrchestrationExecutionMapper;
+import com.etotem.cfc.mapper.TaskOrchestrationFlowMapper;
+import com.etotem.cfc.mapper.TaskOrchestrationNodeInstanceMapper;
+import com.etotem.cfc.service.OrchestrationEngine;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.Arrays;
+import java.util.Collections;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+class OrchestrationIntegrationTest {
+
+    @InjectMocks
+    private OrchestrationEngine engine;
+
+    @Mock
+    private TaskOrchestrationNodeInstanceMapper nodeInstanceMapper;
+
+    @Mock
+    private TaskOrchestrationEdgeMapper edgeMapper;
+
+    @Mock
+    private TaskOrchestrationExecutionMapper executionMapper;
+
+    @Mock
+    private TaskOrchestrationFlowMapper flowMapper;
+
+    @Test
+    void testEvaluateFlow_StartNode_TriggerNode() {
+        Long execId = 1L;
+        TaskOrchestrationExecution exec = new TaskOrchestrationExecution();
+        exec.setId(execId);
+        exec.setStatus("running");
+        exec.setFlowId(1L);
+        when(executionMapper.selectById(execId)).thenReturn(exec);
+
+        TaskOrchestrationFlow flow = new TaskOrchestrationFlow();
+        flow.setId(1L);
+        flow.setConfigJson("{\"nodes\":[{\"id\":\"n1\",\"is_start_node\":true},{\"id\":\"n2\"}]}");
+        when(flowMapper.selectById(1L)).thenReturn(flow);
+
+        TaskOrchestrationNodeInstance n1 = new TaskOrchestrationNodeInstance();
+        n1.setExecutionId(execId);
+        n1.setNodeId("n1");
+        n1.setStatus(OrchestrationEngine.STATUS_PENDING);
+        when(nodeInstanceMapper.selectList(any())).thenReturn(Arrays.asList(n1));
+
+        engine.evaluateFlow(execId);
+
+        // triggerNode 应被调用(这里 mock 不会真正创建任务,但 evaluateFlow 会被调用)
+        verify(edgeMapper, atLeastOnce()).selectList(any());
+    }
+
+    @Test
+    void testEvaluateFlow_AndNode_AllCompleted_Triggered() {
+        Long execId = 2L;
+        TaskOrchestrationExecution exec = new TaskOrchestrationExecution();
+        exec.setId(execId);
+        exec.setStatus("running");
+        exec.setFlowId(2L);
+        when(executionMapper.selectById(execId)).thenReturn(exec);
+
+        TaskOrchestrationFlow flow = new TaskOrchestrationFlow();
+        flow.setId(2L);
+        flow.setConfigJson("{\"nodes\":[{\"id\":\"n1\",\"is_start_node\":true},{\"id\":\"n2\"}]}");
+        when(flowMapper.selectById(2L)).thenReturn(flow);
+
+        TaskOrchestrationNodeInstance n1 = new TaskOrchestrationNodeInstance();
+        n1.setExecutionId(execId);
+        n1.setNodeId("n1");
+        n1.setStatus(OrchestrationEngine.STATUS_COMPLETED);
+
+        TaskOrchestrationNodeInstance n2 = new TaskOrchestrationNodeInstance();
+        n2.setExecutionId(execId);
+        n2.setNodeId("n2");
+        n2.setStatus(OrchestrationEngine.STATUS_PENDING);
+
+        TaskOrchestrationEdge edge = new TaskOrchestrationEdge();
+        edge.setFlowId(2L);
+        edge.setFromNodeId("n1");
+        edge.setToNodeId("n2");
+        edge.setOperator("AND");
+
+        when(nodeInstanceMapper.selectList(any())).thenReturn(Arrays.asList(n1, n2));
+        when(edgeMapper.selectList(any())).thenReturn(Arrays.asList(edge));
+
+        engine.evaluateFlow(execId);
+
+        // n2 应该被触发
+        assertThat(n2.getStatus()).isIn(OrchestrationEngine.STATUS_STARTED, OrchestrationEngine.STATUS_PENDING);
+    }
+
+    @Test
+    void testApplyLoopTermination_ConditionMet_StatusCompleted() {
+        TaskOrchestrationExecution exec = new TaskOrchestrationExecution();
+        exec.setId(1L);
+        exec.setFlowId(1L);
+
+        TaskOrchestrationFlow flow = new TaskOrchestrationFlow();
+        flow.setId(1L);
+        flow.setConfigJson("{\"nodes\":[{\"id\":\"n1\",\"loop_termination\":{\"max_loops\":3}}]}");
+        when(flowMapper.selectById(1L)).thenReturn(flow);
+
+        TaskOrchestrationNodeInstance ni = new TaskOrchestrationNodeInstance();
+        ni.setExecutionId(1L);
+        ni.setNodeId("n1");
+        ni.setGeneration(1);
+        ni.setConditionMetAt(new java.util.Date());
+
+        engine.applyLoopTermination(exec, ni);
+
+        assertThat(ni.getStatus()).isEqualTo(OrchestrationEngine.STATUS_COMPLETED);
+        assertThat(ni.getLoopTerminationReason()).isEqualTo(OrchestrationEngine.LOOP_REASON_CONDITION);
+    }
+
+    @Test
+    void testApplyLoopTermination_GenerationExceedsMaxLoops_StatusTerminated() {
+        TaskOrchestrationExecution exec = new TaskOrchestrationExecution();
+        exec.setId(1L);
+        exec.setFlowId(1L);
+
+        TaskOrchestrationFlow flow = new TaskOrchestrationFlow();
+        flow.setId(1L);
+        flow.setConfigJson("{\"nodes\":[{\"id\":\"n1\",\"loop_termination\":{\"max_loops\":2}}]}");
+        when(flowMapper.selectById(1L)).thenReturn(flow);
+
+        TaskOrchestrationNodeInstance ni = new TaskOrchestrationNodeInstance();
+        ni.setExecutionId(1L);
+        ni.setNodeId("n1");
+        ni.setGeneration(3);
+        ni.setConditionMetAt(null);
+
+        engine.applyLoopTermination(exec, ni);
+
+        assertThat(ni.getStatus()).isEqualTo(OrchestrationEngine.STATUS_TERMINATED);
+        assertThat(ni.getLoopTerminationReason()).isEqualTo(OrchestrationEngine.LOOP_REASON_MAX);
+    }
+
+    @Test
+    void testOnTaskCompleted_NonOrchestrationTask_NoException() {
+        Long taskId = 999L;
+        when(nodeInstanceMapper.selectOne(any())).thenReturn(null);
+
+        // 不应抛出异常
+        engine.onTaskCompleted(taskId);
+
+        verify(nodeInstanceMapper, times(1)).selectOne(any());
+    }
+
+    @Test
+    void testOnTaskFailed_NonOrchestrationTask_NoException() {
+        Long taskId = 888L;
+        when(nodeInstanceMapper.selectOne(any())).thenReturn(null);
+
+        // 不应抛出异常
+        engine.onTaskFailed(taskId);
+
+        verify(nodeInstanceMapper, times(1)).selectOne(any());
+    }
+
+    @Test
+    void testEvaluateFlow_NonRunningExecution_ReturnsEarly() {
+        Long execId = 100L;
+        TaskOrchestrationExecution exec = new TaskOrchestrationExecution();
+        exec.setId(execId);
+        exec.setStatus(OrchestrationEngine.STATUS_TERMINATED);
+        when(executionMapper.selectById(execId)).thenReturn(exec);
+
+        TaskOrchestrationExecution result = engine.evaluateFlow(execId);
+
+        assertThat(result).isNotNull();
+        assertThat(result.getStatus()).isEqualTo(OrchestrationEngine.STATUS_TERMINATED);
+        verify(nodeInstanceMapper, never()).selectList(any());
+    }
+}

+ 23 - 0
cfc-frontend/pages.json

@@ -51,6 +51,29 @@
     }
   ],
   "subPackages": [
+    {
+      "root": "pages/orchestration",
+      "pages": [
+        {
+          "path": "index",
+          "style": {
+            "navigationBarTitleText": "流程编排"
+          }
+        },
+        {
+          "path": "editor",
+          "style": {
+            "navigationBarTitleText": "编辑流程"
+          }
+        },
+        {
+          "path": "node-edit",
+          "style": {
+            "navigationBarTitleText": "编辑节点"
+          }
+        }
+      ]
+    },
     {
       "root": "pages/teacher",
       "pages": [

+ 644 - 0
cfc-frontend/pages/orchestration/editor.vue

@@ -0,0 +1,644 @@
+<template>
+  <view class="container">
+    <!-- 加载状态 -->
+    <view v-if="loading" class="loading">
+      <text>加载中...</text>
+    </view>
+
+    <!-- 表单内容 -->
+    <view v-else class="form-container">
+      <!-- 基本信息 -->
+      <view class="section">
+        <view class="section-title">基本信息</view>
+        <view class="form-item">
+          <text class="label">流程名称 <text class="required">*</text></text>
+          <input v-model="flowName" class="input" placeholder="请输入流程名称" />
+        </view>
+        <view class="form-item">
+          <text class="label">流程描述</text>
+          <textarea v-model="flowDescription" class="textarea" placeholder="可选,描述流程用途" />
+        </view>
+      </view>
+
+      <!-- 节点列表 -->
+      <view class="section">
+        <view class="section-title">
+          <text>任务节点</text>
+          <text class="node-count">{{ nodes.length }} 个节点</text>
+        </view>
+
+        <!-- 节点列表 -->
+        <view v-for="(node, index) in nodes" :key="'node-' + index" class="node-item">
+          <view class="node-header">
+            <view class="node-order">
+              <text class="order-num">{{ index + 1 }}</text>
+            </view>
+            <view class="node-info">
+              <text class="node-template">{{ getNodeTemplateName(node) }}</text>
+              <text class="node-id">{{ node.id }}</text>
+            </view>
+            <view class="node-actions">
+              <button class="btn-icon" @click="handleEditNode(node)">✏️</button>
+              <button class="btn-icon btn-delete" @click="handleDeleteNode(index)">🗑️</button>
+              <button v-if="index > 0" class="btn-icon" @click="handleMoveNode(index, -1)">↑</button>
+              <button v-if="index < nodes.length - 1" class="btn-icon" @click="handleMoveNode(index, 1)">↓</button>
+            </view>
+          </view>
+          <view class="node-preview">
+            <text class="node-title">{{ node.title }}</text>
+          </view>
+        </view>
+
+        <!-- 添加节点按钮 -->
+        <view class="add-node-btn" @click="showNodePicker">
+          <text class="add-icon">+</text>
+          <text>添加任务节点</text>
+        </view>
+      </view>
+
+      <!-- 触发条件设置 -->
+      <view class="section" v-if="nodes.length > 1">
+        <view class="section-title">触发条件</view>
+        <view class="condition-hint">
+          <text>每个节点将在前一个节点完成后自动触发</text>
+        </view>
+      </view>
+
+      <!-- 保存按钮 -->
+      <view class="action-bar">
+        <button class="btn-save" @click="handleSaveDraft">保存草稿</button>
+        <button v-if="!isDraft" class="btn-publish" @click="handlePublish">发布</button>
+      </view>
+    </view>
+
+    <!-- 节点选择弹窗 -->
+    <view v-if="showNodePickerFlag" class="modal-mask" @click="showNodePickerFlag = false">
+      <view class="modal modal-large" @click.stop>
+        <view class="modal-header">
+          <text class="modal-title">选择任务模板</text>
+          <button class="btn-close" @click="showNodePickerFlag = false">×</button>
+        </view>
+        <view class="search-box">
+          <input v-model="templateSearch" class="search-input" placeholder="搜索模板..." />
+        </view>
+        <scroll-view scroll-y class="template-list">
+          <view
+            v-for="tpl in filteredTemplates"
+            :key="tpl.id"
+            class="template-item"
+            @click="addNodeFromTemplate(tpl)"
+          >
+            <text class="tpl-name">{{ tpl.title }}</text>
+            <text class="tpl-points">+{{ tpl.points || 2 }}分</text>
+          </view>
+          <view v-if="filteredTemplates.length === 0" class="empty">
+            <text>无匹配模板</text>
+          </view>
+        </scroll-view>
+      </view>
+    </view>
+
+    <!-- 节点编辑弹窗 -->
+    <view v-if="showNodeEditFlag" class="modal-mask" @click="closeNodeEdit">
+      <view class="modal modal-large" @click.stop>
+        <view class="modal-header">
+          <text class="modal-title">编辑节点</text>
+          <button class="btn-close" @click="closeNodeEdit">×</button>
+        </view>
+        <view class="form-container">
+          <view class="form-item">
+            <text class="label">任务标题</text>
+            <input v-model="editNode.title" class="input" placeholder="输入任务标题" />
+          </view>
+          <view class="form-item">
+            <text class="label">超时时间(分钟)</text>
+            <input v-model="editNode.timeout_minutes" class="input" type="number" placeholder="0表示不限制" />
+          </view>
+          <view class="form-item">
+            <text class="label">最大循环次数</text>
+            <input v-model="editNode.max_loops" class="input" type="number" placeholder="0表示不限制" />
+          </view>
+          <view class="form-item">
+            <text class="label">执行人</text>
+            <picker :range="executorTypes" @change="onExecutorChange">
+              <view class="picker">{{ executorLabels[editNode.executorType] || '请选择' }}</view>
+            </picker>
+          </view>
+        </view>
+        <view class="action-bar">
+          <button class="btn-save" @click="saveNodeEdit">保存</button>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getOrchestrationFlowDetail, saveOrchestrationFlow, publishOrchestrationFlow, getNodeTemplateList } from '../../utils/api.js'
+
+let _nodeSeq = 0
+
+export default {
+  data() {
+    return {
+      flowId: null,
+      flowName: '',
+      flowDescription: '',
+      nodes: [],
+      templates: [],
+      templateSearch: '',
+      showNodePickerFlag: false,
+      showNodeEditFlag: false,
+      editNode: null,
+      loading: false,
+      executorTypes: ['child', 'member'],
+      executorLabels: { child: '孩子', member: '家庭成员' }
+    }
+  },
+  computed: {
+    isDraft() {
+      return this.flowId === null
+    },
+    filteredTemplates() {
+      if (!this.templateSearch) return this.templates
+      var kw = this.templateSearch.toLowerCase()
+      return this.templates.filter(function(t) {
+        return t.title.indexOf(kw) >= 0
+      })
+    }
+  },
+  onLoad(options) {
+    var id = options.id ? Number(options.id) : null
+    if (id) {
+      this.flowId = id
+      this.loadFlowDetail(id)
+    } else {
+      this.flowId = null
+      this.initEmptyForm()
+    }
+    this.loadTemplates()
+  },
+  methods: {
+    initEmptyForm() {
+      this.flowName = ''
+      this.flowDescription = ''
+      this.nodes = []
+      _nodeSeq = 0
+    },
+    async loadFlowDetail(id) {
+      this.loading = true
+      try {
+        var res = await getOrchestrationFlowDetail(id)
+        var flow = res.data.flow
+        if (flow) {
+          this.flowName = flow.name || ''
+          this.flowDescription = flow.description || ''
+          // 解析 configJson
+          if (flow.configJson) {
+            var cfg = JSON.parse(flow.configJson)
+            this.nodes = (cfg.nodes || []).map(function(n) {
+              return {
+                id: n.id,
+                task_template_ref: n.task_template_ref,
+                title: n.title || '',
+                executorType: n.executorType || 'child',
+                timeout_minutes: n.timeout_minutes || 0,
+                max_loops: n.max_loops || 0
+              }
+            })
+            _nodeSeq = this.nodes.length
+          }
+        }
+      } catch (e) {
+        console.error('加载流程详情失败', e)
+        uni.showToast({ title: '加载失败', icon: 'none' })
+      } finally {
+        this.loading = false
+      }
+    },
+    async loadTemplates() {
+      try {
+        var res = await getNodeTemplateList({ page: 1, pageSize: 100 })
+        this.templates = res.data.list || []
+      } catch (e) {
+        console.error('加载模板失败', e)
+        this.templates = []
+      }
+    },
+    getNodeTemplateName(node) {
+      if (!node.task_template_ref) return '未选择模板'
+      var tpl = this.templates.find(function(t) { return t.id === Number(node.task_template_ref) })
+      return tpl ? tpl.title : ('模板#' + node.task_template_ref)
+    },
+    showNodePicker() {
+      this.showNodePickerFlag = true
+      this.templateSearch = ''
+    },
+    addNodeFromTemplate(tpl) {
+      _nodeSeq += 1
+      var node = {
+        id: 'n' + _nodeSeq,
+        task_template_ref: tpl.id,
+        title: tpl.title,
+        executorType: 'child',
+        timeout_minutes: 0,
+        max_loops: 0
+      }
+      this.nodes.push(node)
+      this.showNodePickerFlag = false
+      uni.showToast({ title: '已添加节点', icon: 'success' })
+    },
+    handleEditNode(node) {
+      this.editNode = JSON.parse(JSON.stringify(node))
+      this.showNodeEditFlag = true
+    },
+    closeNodeEdit() {
+      this.showNodeEditFlag = false
+      this.editNode = null
+    },
+    saveNodeEdit() {
+      if (!this.editNode) return
+      var idx = this.nodes.findIndex(function(n) { return n.id === this.editNode.id })
+      if (idx >= 0) {
+        this.$set(this.nodes, idx, JSON.parse(JSON.stringify(this.editNode)))
+      }
+      this.closeNodeEdit()
+    },
+    onExecutorChange(e) {
+      var idx = e.detail.value
+      if (this.editNode) {
+        this.editNode.executorType = this.executorTypes[idx]
+      }
+    },
+    handleDeleteNode(index) {
+      uni.showModal({
+        title: '确认删除',
+        content: '确定要删除这个节点吗?',
+        success: (res) => {
+          if (res.confirm) {
+            this.nodes.splice(index, 1)
+          }
+        }
+      })
+    },
+    handleMoveNode(index, direction) {
+      var newIndex = index + direction
+      if (newIndex < 0 || newIndex >= this.nodes.length) return
+      var temp = this.nodes[index]
+      this.$set(this.nodes, index, this.nodes[newIndex])
+      this.$set(this.nodes, newIndex, temp)
+    },
+    validateBeforeSave() {
+      var errs = []
+      if (!this.flowName.trim()) errs.push('请填写流程名称')
+      if (this.nodes.length === 0) errs.push('请至少添加一个任务节点')
+      if (this.nodes.some(function(n) { return !n.task_template_ref })) {
+        errs.push('存在未绑定任务模板的节点')
+      }
+      return errs
+    },
+    async handleSaveDraft() {
+      var errs = this.validateBeforeSave()
+      if (errs.length > 0) {
+        uni.showToast({ title: errs[0], icon: 'none' })
+        return
+      }
+      uni.showLoading({ title: '保存中...' })
+      try {
+        var cfg = {
+          nodes: this.nodes.map(function(n) {
+            return {
+              id: n.id,
+              task_template_ref: String(n.task_template_ref),
+              title: n.title,
+              executorType: n.executorType,
+              timeout_minutes: n.timeout_minutes,
+              max_loops: n.max_loops
+            }
+          })
+        }
+        var res = await saveOrchestrationFlow({
+          id: this.flowId,
+          name: this.flowName,
+          description: this.flowDescription,
+          configJson: JSON.stringify(cfg)
+        })
+        this.flowId = res.data.id || this.flowId
+        uni.hideLoading()
+        uni.showToast({ title: '保存成功', icon: 'success' })
+        setTimeout(function() {
+          uni.navigateBack()
+        }, 1000)
+      } catch (e) {
+        uni.hideLoading()
+        uni.showToast({ title: e.message || '保存失败', icon: 'none' })
+      }
+    },
+    async handlePublish() {
+      var errs = this.validateBeforeSave()
+      if (errs.length > 0) {
+        uni.showToast({ title: errs[0], icon: 'none' })
+        return
+      }
+      // 先保存
+      uni.showLoading({ title: '发布中...' })
+      try {
+        var cfg = {
+          nodes: this.nodes.map(function(n) {
+            return {
+              id: n.id,
+              task_template_ref: String(n.task_template_ref),
+              title: n.title,
+              executorType: n.executorType,
+              timeout_minutes: n.timeout_minutes,
+              max_loops: n.max_loops
+            }
+          })
+        }
+        var saveRes = await saveOrchestrationFlow({
+          id: this.flowId,
+          name: this.flowName,
+          description: this.flowDescription,
+          configJson: JSON.stringify(cfg)
+        })
+        this.flowId = saveRes.data.id || this.flowId
+        // 再发布
+        await publishOrchestrationFlow(this.flowId)
+        uni.hideLoading()
+        uni.showToast({ title: '发布成功', icon: 'success' })
+        setTimeout(function() {
+          uni.navigateBack()
+        }, 1000)
+      } catch (e) {
+        uni.hideLoading()
+        uni.showToast({ title: e.message || '发布失败', icon: 'none' })
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.container {
+  min-height: 100vh;
+  background: #F8F8F8;
+}
+.loading {
+  text-align: center;
+  padding: 100rpx;
+  color: #94A3B8;
+}
+.form-container {
+  padding: 30rpx;
+}
+.section {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 30rpx;
+  margin-bottom: 24rpx;
+}
+.section-title {
+  font-size: 28rpx;
+  font-weight: bold;
+  color: #1E293B;
+  margin-bottom: 24rpx;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+.node-count {
+  font-size: 24rpx;
+  color: #94A3B8;
+  font-weight: normal;
+}
+.form-item {
+  margin-bottom: 24rpx;
+}
+.label {
+  display: block;
+  font-size: 26rpx;
+  color: #64748B;
+  margin-bottom: 12rpx;
+}
+.required {
+  color: #EF4444;
+}
+.input {
+  width: 100%;
+  height: 80rpx;
+  border: 2rpx solid #E2E8F0;
+  border-radius: 12rpx;
+  padding: 0 20rpx;
+  font-size: 28rpx;
+  box-sizing: border-box;
+}
+.textarea {
+  width: 100%;
+  height: 160rpx;
+  border: 2rpx solid #E2E8F0;
+  border-radius: 12rpx;
+  padding: 20rpx;
+  font-size: 28rpx;
+  box-sizing: border-box;
+}
+.picker {
+  width: 100%;
+  height: 80rpx;
+  border: 2rpx solid #E2E8F0;
+  border-radius: 12rpx;
+  padding: 0 20rpx;
+  font-size: 28rpx;
+  line-height: 80rpx;
+}
+.node-item {
+  background: #F8FAFC;
+  border: 2rpx solid #E2E8F0;
+  border-radius: 12rpx;
+  padding: 20rpx;
+  margin-bottom: 16rpx;
+}
+.node-header {
+  display: flex;
+  align-items: center;
+}
+.node-order {
+  width: 48rpx;
+  height: 48rpx;
+  background: #F97316;
+  color: #fff;
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-right: 16rpx;
+}
+.order-num {
+  font-size: 24rpx;
+  font-weight: bold;
+}
+.node-info {
+  flex: 1;
+  min-width: 0;
+}
+.node-template {
+  display: block;
+  font-size: 26rpx;
+  color: #1E293B;
+  font-weight: 500;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.node-id {
+  display: block;
+  font-size: 20rpx;
+  color: #94A3B8;
+}
+.node-actions {
+  display: flex;
+  gap: 8rpx;
+}
+.btn-icon {
+  width: 48rpx;
+  height: 48rpx;
+  border-radius: 8rpx;
+  background: #fff;
+  border: 1rpx solid #E2E8F0;
+  font-size: 24rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 0;
+}
+.btn-icon::after { border: none; }
+.btn-delete { color: #EF4444; }
+.node-preview {
+  margin-top: 12rpx;
+  padding-left: 64rpx;
+}
+.node-title {
+  font-size: 24rpx;
+  color: #64748B;
+}
+.add-node-btn {
+  border: 2rpx dashed #F97316;
+  border-radius: 12rpx;
+  padding: 28rpx 0;
+  text-align: center;
+  color: #F97316;
+}
+.add-icon {
+  font-size: 36rpx;
+  margin-right: 8rpx;
+}
+.condition-hint {
+  font-size: 24rpx;
+  color: #94A3B8;
+  padding: 16rpx;
+  background: #F8FAFC;
+  border-radius: 8rpx;
+}
+.action-bar {
+  padding: 30rpx;
+  display: flex;
+  gap: 20rpx;
+}
+.btn-save, .btn-publish {
+  flex: 1;
+  height: 88rpx;
+  line-height: 88rpx;
+  border-radius: 44rpx;
+  font-size: 30rpx;
+  font-weight: bold;
+  border: none;
+}
+.btn-save::after, .btn-publish::after { border: none; }
+.btn-save { background: #F1F5F9; color: #64748B; }
+.btn-publish { background: linear-gradient(135deg, #F97316 0%, #EA580C 100%); color: #fff; }
+.modal-mask {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba(0, 0, 0, 0.5);
+  z-index: 1000;
+  display: flex;
+  align-items: flex-end;
+}
+.modal {
+  width: 100%;
+  background: #fff;
+  border-radius: 32rpx 32rpx 0 0;
+  max-height: 90vh;
+  overflow: hidden;
+  display: flex;
+  flex-direction: column;
+}
+.modal-large {
+  max-height: 80vh;
+}
+.modal-header {
+  padding: 30rpx;
+  border-bottom: 1rpx solid #E2E8F0;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+.modal-title {
+  font-size: 32rpx;
+  font-weight: bold;
+  color: #1E293B;
+}
+.btn-close {
+  width: 56rpx;
+  height: 56rpx;
+  border-radius: 50%;
+  background: #F1F5F9;
+  font-size: 32rpx;
+  line-height: 56rpx;
+  text-align: center;
+  padding: 0;
+  border: none;
+}
+.btn-close::after { border: none; }
+.search-box {
+  padding: 20rpx 30rpx;
+  border-bottom: 1rpx solid #E2E8F0;
+}
+.search-input {
+  width: 100%;
+  height: 72rpx;
+  border: 2rpx solid #E2E8F0;
+  border-radius: 36rpx;
+  padding: 0 24rpx;
+  font-size: 26rpx;
+  box-sizing: border-box;
+}
+.template-list {
+  flex: 1;
+  overflow-y: auto;
+  padding: 20rpx 30rpx;
+}
+.template-item {
+  padding: 24rpx 0;
+  border-bottom: 1rpx solid #F1F5F9;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+.tpl-name {
+  font-size: 28rpx;
+  color: #1E293B;
+}
+.tpl-points {
+  font-size: 24rpx;
+  color: #F97316;
+  font-weight: 500;
+}
+.empty {
+  text-align: center;
+  padding: 60rpx;
+  color: #94A3B8;
+}
+</style>

+ 310 - 0
cfc-frontend/pages/orchestration/index.vue

@@ -0,0 +1,310 @@
+<template>
+  <view class="container">
+    <!-- 标题栏 -->
+    <view class="header">
+      <text class="title">流程编排</text>
+      <text class="subtitle">创建和管理你的任务流程</text>
+    </view>
+
+    <!-- 新建按钮 -->
+    <view class="create-btn" @click="handleCreate">
+      <text class="create-icon">+</text>
+      <text>新建流程</text>
+    </view>
+
+    <!-- 流程列表 -->
+    <view class="flow-list">
+      <view
+        v-for="flow in flowList"
+        :key="getKey(flow)"
+        class="flow-card"
+        @click="handleEdit(flow)"
+      >
+        <view class="flow-header">
+          <text class="flow-name">{{ flow.name }}</text>
+          <view class="flow-status" :class="'status-' + flow.status">
+            {{ getStatusText(flow.status) }}
+          </view>
+        </view>
+        <view class="flow-desc">
+          <text v-if="flow.description">{{ flow.description }}</text>
+          <text v-else class="empty">暂无描述</text>
+        </view>
+        <view class="flow-meta">
+          <text class="meta-item">版本 {{ flow.version }}</text>
+          <text class="meta-item">节点 {{ getNodeCount(flow) }}个</text>
+          <text class="meta-item">{{ formatTime(flow.createdAt) }}</text>
+        </view>
+        <view class="flow-actions" @click.stop>
+          <button v-if="flow.status === 'draft'" class="btn btn-publish" @click="handlePublish(flow)">发布</button>
+          <button v-if="flow.status === 'published'" class="btn btn-archive" @click="handleArchive(flow)">归档</button>
+          <button v-if="flow.status === 'draft'" class="btn btn-delete" @click="handleDelete(flow)">删除</button>
+          <button class="btn btn-run" @click="handleRun(flow)">运行</button>
+        </view>
+      </view>
+
+      <view v-if="!loading && flowList.length === 0" class="empty-state">
+        <text class="empty-icon">📋</text>
+        <text class="empty-text">暂无编排流程</text>
+        <text class="empty-hint">点击新建开始你的第一个流程</text>
+      </view>
+    </view>
+
+    <!-- 加载状态 -->
+    <view v-if="loading" class="loading">
+      <text>加载中...</text>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getOrchestrationFlowList, publishOrchestrationFlow, archiveOrchestrationFlow, deleteOrchestrationFlow, startOrchestrationExecution } from '../../utils/api.js'
+import { parseDate, formatDateTime } from '../../utils/format.js'
+
+export default {
+  data() {
+    return {
+      flowList: [],
+      loading: false
+    }
+  },
+  onLoad() {
+    this.loadFlows()
+  },
+  onShow() {
+    if (!this._onLoadFired) {
+      this.loadFlows()
+    }
+    this._onLoadFired = true
+  },
+  methods: {
+    async loadFlows() {
+      this.loading = true
+      try {
+        const res = await getOrchestrationFlowList({ page: 1, pageSize: 50 })
+        this.flowList = res.data.list || []
+      } catch (e) {
+        console.error('加载流程列表失败', e)
+        uni.showToast({ title: '加载失败', icon: 'none' })
+      } finally {
+        this.loading = false
+      }
+    },
+    getKey(flow) {
+      return flow.id
+    },
+    getStatusText(status) {
+      const map = {
+        draft: '草稿',
+        published: '已发布',
+        archived: '已归档'
+      }
+      return map[status] || status
+    },
+    getNodeCount(flow) {
+      try {
+        const cfg = JSON.parse(flow.configJson || '{}')
+        return cfg.nodes ? cfg.nodes.length : 0
+      } catch (e) {
+        return 0
+      }
+    },
+    formatTime(t) {
+      if (!t) return '-'
+      var d = parseDate(t)
+      if (!d) return t.substring(0, 10)
+      return formatDateTime(d)
+    },
+    handleCreate() {
+      uni.navigateTo({ url: '/pages/orchestration/editor' })
+    },
+    handleEdit(flow) {
+      uni.navigateTo({ url: '/pages/orchestration/editor?id=' + flow.id })
+    },
+    async handlePublish(flow) {
+      uni.showLoading({ title: '发布中...' })
+      try {
+        await publishOrchestrationFlow(flow.id)
+        uni.showToast({ title: '发布成功', icon: 'success' })
+        this.loadFlows()
+      } catch (e) {
+        uni.hideLoading()
+        uni.showToast({ title: e.message || '发布失败', icon: 'none' })
+      }
+    },
+    async handleArchive(flow) {
+      uni.showLoading({ title: '归档中...' })
+      try {
+        await archiveOrchestrationFlow(flow.id)
+        uni.showToast({ title: '归档成功', icon: 'success' })
+        this.loadFlows()
+      } catch (e) {
+        uni.hideLoading()
+        uni.showToast({ title: e.message || '归档失败', icon: 'none' })
+      }
+    },
+    handleDelete(flow) {
+      uni.showModal({
+        title: '确认删除',
+        content: '删除后无法恢复,确定要删除这个流程吗?',
+        success: (res) => {
+          if (res.confirm) {
+            this.doDelete(flow)
+          }
+        }
+      })
+    },
+    async doDelete(flow) {
+      uni.showLoading({ title: '删除中...' })
+      try {
+        await deleteOrchestrationFlow(flow.id)
+        uni.showToast({ title: '删除成功', icon: 'success' })
+        this.loadFlows()
+      } catch (e) {
+        uni.hideLoading()
+        uni.showToast({ title: e.message || '删除失败', icon: 'none' })
+      }
+    },
+    handleRun(flow) {
+      // 弹出家庭成员选择
+      this.showRunDialog(flow)
+    },
+    showRunDialog(flow) {
+      // 这里简化处理,直接跳转到执行详情页
+      // 实际项目中应先选择家庭成员
+      uni.navigateTo({
+        url: '/pages/orchestration/editor?flowId=' + flow.id + '&action=run'
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.container {
+  min-height: 100vh;
+  background: linear-gradient(180deg, #FFF7ED 0%, #F8F8F8 100%);
+  padding-bottom: 40rpx;
+}
+.header {
+  padding: 40rpx 30rpx 30rpx;
+  background: linear-gradient(135deg, #F97316 0%, #EA580C 100%);
+  color: #fff;
+}
+.title {
+  display: block;
+  font-size: 40rpx;
+  font-weight: bold;
+  margin-bottom: 10rpx;
+}
+.subtitle {
+  display: block;
+  font-size: 26rpx;
+  opacity: 0.9;
+}
+.create-btn {
+  margin: 30rpx;
+  background: linear-gradient(135deg, #F97316 0%, #EA580C 100%);
+  color: #fff;
+  border-radius: 50rpx;
+  padding: 28rpx 0;
+  text-align: center;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  box-shadow: 0 8rpx 24rpx rgba(249, 115, 22, 0.3);
+}
+.create-icon {
+  font-size: 44rpx;
+  margin-right: 12rpx;
+  font-weight: 300;
+}
+.flow-list {
+  padding: 0 30rpx;
+}
+.flow-card {
+  background: #fff;
+  border-radius: 20rpx;
+  padding: 30rpx;
+  margin-bottom: 24rpx;
+  box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
+}
+.flow-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 16rpx;
+}
+.flow-name {
+  font-size: 32rpx;
+  font-weight: bold;
+  color: #1E293B;
+  flex: 1;
+}
+.flow-status {
+  padding: 6rpx 16rpx;
+  border-radius: 20rpx;
+  font-size: 22rpx;
+  margin-left: 16rpx;
+}
+.status-draft { background: #F1F5F9; color: #64748B; }
+.status-published { background: #DCFCE7; color: #16A34A; }
+.status-archived { background: #FEF3C7; color: #D97706; }
+.flow-desc {
+  font-size: 26rpx;
+  color: #64748B;
+  margin-bottom: 16rpx;
+  line-height: 1.5;
+}
+.empty { color: #94A3B8; }
+.flow-meta {
+  display: flex;
+  margin-bottom: 20rpx;
+}
+.meta-item {
+  font-size: 22rpx;
+  color: #94A3B8;
+  margin-right: 24rpx;
+}
+.flow-actions {
+  display: flex;
+  gap: 16rpx;
+  flex-wrap: wrap;
+}
+.btn {
+  padding: 12rpx 24rpx;
+  border-radius: 8rpx;
+  font-size: 24rpx;
+  border: none;
+}
+.btn::after { border: none; }
+.btn-publish { background: #F97316; color: #fff; }
+.btn-archive { background: #F59E0B; color: #fff; }
+.btn-delete { background: #EF4444; color: #fff; }
+.btn-run { background: #10B981; color: #fff; }
+.empty-state {
+  text-align: center;
+  padding: 100rpx 0;
+}
+.empty-icon {
+  display: block;
+  font-size: 80rpx;
+  margin-bottom: 20rpx;
+}
+.empty-text {
+  display: block;
+  font-size: 28rpx;
+  color: #94A3B8;
+  margin-bottom: 10rpx;
+}
+.empty-hint {
+  display: block;
+  font-size: 24rpx;
+  color: #CBD5E1;
+}
+.loading {
+  text-align: center;
+  padding: 60rpx;
+  color: #94A3B8;
+}
+</style>

+ 213 - 0
cfc-frontend/pages/orchestration/node-edit.vue

@@ -0,0 +1,213 @@
+<template>
+  <view class="container">
+    <view v-if="!loading" class="form-container">
+      <view class="section">
+        <view class="section-title">节点配置</view>
+        
+        <view class="form-item">
+          <text class="label">任务标题</text>
+          <input v-model="node.title" class="input" placeholder="请输入任务标题" />
+        </view>
+        
+        <view class="form-item">
+          <text class="label">任务描述</text>
+          <textarea v-model="node.description" class="textarea" placeholder="可选,任务描述" />
+        </view>
+        
+        <view class="form-item">
+          <text class="label">积分</text>
+          <input v-model="node.points" class="input" type="number" placeholder="完成此任务获得的积分" />
+        </view>
+        
+        <view class="form-item">
+          <text class="label">超时时间(分钟)</text>
+          <input v-model="node.timeout_minutes" class="input" type="number" placeholder="0表示不限制" />
+          <text class="hint">超过此时长未完成任务将触发超时兜底</text>
+        </view>
+        
+        <view class="form-item">
+          <text class="label">最大循环次数</text>
+          <input v-model="node.max_loops" class="input" type="number" placeholder="0表示不限制" />
+          <text class="hint">达到上限后节点自动终止</text>
+        </view>
+        
+        <view class="form-item">
+          <text class="label">执行人</text>
+          <picker :range="executorTypes" @change="onExecutorChange">
+            <view class="picker">{{ executorLabels[node.executorType] || '请选择' }}</view>
+          </picker>
+        </view>
+        
+        <view class="form-item" v-if="node.executorType === 'member'">
+          <text class="label">分配给</text>
+          <checkbox-group @change="onMemberChange">
+            <label v-for="m in members" :key="m.id" class="checkbox-label">
+              <checkbox :value="String(m.id)" :checked="selectedMembers.indexOf(m.id) >= 0" />
+              <text>{{ m.nickname }}</text>
+            </label>
+          </checkbox-group>
+        </view>
+      </view>
+      
+      <view class="action-bar">
+        <button class="btn-save" @click="handleSave">保存</button>
+      </view>
+    </view>
+    
+    <view v-if="loading" class="loading">
+      <text>加载中...</text>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getFamilyMemberList } from '../../utils/api.js'
+
+export default {
+  data() {
+    return {
+      nodeId: null,
+      node: {
+        title: '',
+        description: '',
+        points: 2,
+        timeout_minutes: 0,
+        max_loops: 0,
+        executorType: 'child'
+      },
+      members: [],
+      selectedMembers: [],
+      executorTypes: ['child', 'member'],
+      executorLabels: { child: '孩子', member: '家庭成员' },
+      loading: false
+    }
+  },
+  onLoad(options) {
+    this.nodeId = options.id
+    // 初始化节点数据(从页面参数或父组件传递)
+    if (options.nodeData) {
+      try {
+        this.node = JSON.parse(decodeURIComponent(options.nodeData))
+      } catch (e) {
+        console.error('解析节点数据失败', e)
+      }
+    }
+    this.loadMembers()
+  },
+  methods: {
+    async loadMembers() {
+      try {
+        var res = await getFamilyMemberList({})
+        this.members = res.data || []
+      } catch (e) {
+        console.error('加载成员列表失败', e)
+      }
+    },
+    onExecutorChange(e) {
+      var idx = e.detail.value
+      this.node.executorType = this.executorTypes[idx]
+    },
+    onMemberChange(e) {
+      this.selectedMembers = e.detail.value.map(Number)
+    },
+    handleSave() {
+      // 返回上一页并传递节点数据
+      var pages = getCurrentPages()
+      if (pages.length > 1) {
+        var prevPage = pages[pages.length - 2]
+        prevPage.$vm.updateNode(this.nodeId, this.node)
+      }
+      uni.navigateBack()
+    }
+  }
+}
+</script>
+
+<style scoped>
+.container {
+  min-height: 100vh;
+  background: #F8F8F8;
+}
+.loading {
+  text-align: center;
+  padding: 100rpx;
+  color: #94A3B8;
+}
+.form-container {
+  padding: 30rpx;
+}
+.section {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 30rpx;
+  margin-bottom: 24rpx;
+}
+.section-title {
+  font-size: 28rpx;
+  font-weight: bold;
+  color: #1E293B;
+  margin-bottom: 24rpx;
+}
+.form-item {
+  margin-bottom: 24rpx;
+}
+.label {
+  display: block;
+  font-size: 26rpx;
+  color: #64748B;
+  margin-bottom: 12rpx;
+}
+.input {
+  width: 100%;
+  height: 80rpx;
+  border: 2rpx solid #E2E8F0;
+  border-radius: 12rpx;
+  padding: 0 20rpx;
+  font-size: 28rpx;
+  box-sizing: border-box;
+}
+.textarea {
+  width: 100%;
+  height: 160rpx;
+  border: 2rpx solid #E2E8F0;
+  border-radius: 12rpx;
+  padding: 20rpx;
+  font-size: 28rpx;
+  box-sizing: border-box;
+}
+.picker {
+  width: 100%;
+  height: 80rpx;
+  border: 2rpx solid #E2E8F0;
+  border-radius: 12rpx;
+  padding: 0 20rpx;
+  font-size: 28rpx;
+  line-height: 80rpx;
+}
+.hint {
+  display: block;
+  font-size: 22rpx;
+  color: #94A3B8;
+  margin-top: 8rpx;
+}
+.checkbox-label {
+  display: flex;
+  align-items: center;
+  padding: 16rpx 0;
+}
+.action-bar {
+  padding: 30rpx;
+}
+.btn-save {
+  width: 100%;
+  height: 88rpx;
+  line-height: 88rpx;
+  border-radius: 44rpx;
+  font-size: 30rpx;
+  font-weight: bold;
+  background: linear-gradient(135deg, #F97316 0%, #EA580C 100%);
+  color: #fff;
+  border: none;
+}
+.btn-save::after { border: none; }
+</style>

+ 25 - 0
cfc-frontend/pages/tasks/tasks.vue

@@ -111,6 +111,10 @@
     <button v-if="activeTab === 'today' && isParent" class="add-btn" @click="showAddModal = true">
       + 添加任务
     </button>
+    <!-- 流程编排入口 -->
+    <button v-if="activeTab === 'today' && isParent" class="orchestration-btn" @click="goToOrchestration">
+      📋 流程编排
+    </button>
 
     <!-- 拒绝确认弹窗 -->
     <view class="modal-mask" v-if="showRejectModalFlag" @click="showRejectModalFlag = false">
@@ -291,6 +295,12 @@ export default {
       else if (tab === 'today') this.loadTodayTasks()
       else if (tab === 'created') this.loadCreatedTasks()
     },
+    goToOrchestration() {
+      uni.navigateTo({ url: '/pages/orchestration/index' })
+    },
+      else if (tab === 'today') this.loadTodayTasks()
+      else if (tab === 'created') this.loadCreatedTasks()
+    },
     async loadInitialData() {
       this.loading = true
       try {
@@ -890,6 +900,21 @@ export default {
   box-shadow: 0 8rpx 24rpx rgba(249, 115, 22, 0.35);
 }
 
+.orchestration-btn {
+  position: fixed;
+  left: 40rpx;
+  right: 40rpx;
+  bottom: 148rpx;
+  height: 88rpx;
+  line-height: 88rpx;
+  background: linear-gradient(135deg, #6366F1, #8B5CF6);
+  color: #fff;
+  font-size: 30rpx;
+  border-radius: 44rpx;
+  border: none;
+  box-shadow: 0 8rpx 24rpx rgba(99, 102, 241, 0.35);
+}
+
 .modal-mask {
   position: fixed;
   top: 0;

+ 30 - 1
cfc-frontend/utils/api.js

@@ -3075,7 +3075,6 @@ export function regeneratePortrait(params = {}) {
   return request('/api/user/portrait/regenerate', 'POST', params)
 }
 
-// ── 限时折扣 ──
 export const shopDiscountCalculate = (params) => {
   return request('/api/shop/discount/calculate', 'POST', params || {})
 }
@@ -3085,3 +3084,33 @@ export const getAppLibrary = () => request('/api/home/app-library', 'POST', {})
 export const saveShortcutConfig = (items) => request('/api/home/shortcut/save', 'POST', { items })
 export const reportAppEnter = (appKey) => request('/api/home/app/enter', 'POST', { appKey })
 export const resetShortcutConfig = () => request('/api/home/shortcut/reset', 'POST', {})
+export function getOrchestrationFlowList(params = {}) {
+  return request('/api/orchestration/flow/list', 'POST', params)
+}
+export function getOrchestrationFlowDetail(flowId) {
+  return request('/api/orchestration/flow/detail', 'POST', { flowId })
+}
+export function saveOrchestrationFlow(data) {
+  return request('/api/orchestration/flow/save', 'POST', data)
+}
+export function publishOrchestrationFlow(flowId) {
+  return request('/api/orchestration/flow/publish', 'POST', { flowId })
+}
+export function archiveOrchestrationFlow(flowId) {
+  return request('/api/orchestration/flow/archive', 'POST', { flowId })
+}
+export function deleteOrchestrationFlow(flowId) {
+  return request('/api/orchestration/flow/delete', 'POST', { flowId })
+}
+export function startOrchestrationExecution(data) {
+  return request('/api/orchestration/execution/start', 'POST', data)
+}
+export function getOrchestrationExecutionDetail(executionId) {
+  return request('/api/orchestration/execution/detail', 'POST', { executionId })
+}
+export function terminateOrchestrationExecution(data) {
+  return request('/api/orchestration/execution/terminate', 'POST', data)
+}
+export function getNodeTemplateList(params = {}) {
+  return request('/api/admin/task-templates/list', 'POST', params)
+}

+ 7 - 0
cfc-web/package-lock.json

@@ -14,6 +14,7 @@
         "core-js": "^3.8.3",
         "echarts": "^6.1.0",
         "element-ui": "^2.15.13",
+        "jsplumb": "^2.15.6",
         "pnpm": "^11.1.2",
         "vue": "^2.6.14",
         "vue-router": "^3.5.1",
@@ -9460,6 +9461,12 @@
         "graceful-fs": "^4.1.6"
       }
     },
+    "node_modules/jsplumb": {
+      "version": "2.15.6",
+      "resolved": "https://registry.npmjs.org/jsplumb/-/jsplumb-2.15.6.tgz",
+      "integrity": "sha512-sIpbpz5eMVM+vV+MQzFCidlaa1RsknrQs6LOTKYDjYUDdTAi2AN2bFi94TxB33TifcIsRNV1jebcaxg0tCoPzg==",
+      "license": "(MIT OR GPL-2.0)"
+    },
     "node_modules/kind-of": {
       "version": "3.2.2",
       "dev": true,

+ 1 - 0
cfc-web/package.json

@@ -20,6 +20,7 @@
     "core-js": "^3.8.3",
     "echarts": "^6.1.0",
     "element-ui": "^2.15.13",
+    "jsplumb": "^2.15.6",
     "pnpm": "^11.1.2",
     "vue": "^2.6.14",
     "vue-router": "^3.5.1",

+ 64 - 0
cfc-web/src/api/orchestration.js

@@ -0,0 +1,64 @@
+import request from '@/utils/request'
+
+// ===== Flow 管理 =====
+export function saveFlow(data) {
+  return request({ url: '/api/orchestration/flow/save', method: 'post', data })
+}
+
+export function publishFlow(flowId) {
+  return request({ url: '/api/orchestration/flow/publish', method: 'post', data: { flowId } })
+}
+
+export function listFlows(params) {
+  return request({ url: '/api/orchestration/flow/list', method: 'post', data: params })
+}
+
+export function getFlowDetail(flowId) {
+  return request({ url: '/api/orchestration/flow/detail', method: 'post', data: { flowId } })
+}
+
+export function archiveFlow(flowId) {
+  return request({ url: '/api/orchestration/flow/archive', method: 'post', data: { flowId } })
+}
+
+export function deleteFlow(flowId) {
+  return request({ url: '/api/orchestration/flow/delete', method: 'post', data: { flowId } })
+}
+
+// ===== Execution 管理 =====
+export function startExecution(data) {
+  return request({ url: '/api/orchestration/execution/start', method: 'post', data })
+}
+
+export function pauseExecution(executionId) {
+  return request({ url: '/api/orchestration/execution/pause', method: 'post', data: { executionId } })
+}
+
+export function resumeExecution(executionId) {
+  return request({ url: '/api/orchestration/execution/resume', method: 'post', data: { executionId } })
+}
+
+export function terminateExecution(data) {
+  return request({ url: '/api/orchestration/execution/terminate', method: 'post', data })
+}
+
+export function getExecutionDetail(executionId) {
+  return request({ url: '/api/orchestration/execution/detail', method: 'post', data: { executionId } })
+}
+
+export function listExecutions(params) {
+  return request({ url: '/api/orchestration/execution/list', method: 'post', data: params })
+}
+
+// ===== 节点管理 =====
+export function failNode(nodeInstanceId, reason) {
+  return request({ url: '/api/orchestration/node/fail', method: 'post', data: { nodeInstanceId, reason } })
+}
+
+export function restartNode(nodeInstanceId) {
+  return request({ url: '/api/orchestration/node/restart', method: 'post', data: { nodeInstanceId } })
+}
+
+export function markConditionMet(nodeInstanceId) {
+  return request({ url: '/api/orchestration/node/condition-met', method: 'post', data: { nodeInstanceId } })
+}

+ 18 - 0
cfc-web/src/router/index.js

@@ -98,6 +98,24 @@ const routes = [
         component: () => import('@/views/TaskTemplates.vue'),
         meta: { title: '任务模板', perm: 'task:templates' }
       },
+      {
+        path: 'orchestration',
+        name: 'OrchestrationFlow',
+        component: () => import('@/views/orchestration/OrchestrationFlow.vue'),
+        meta: { title: '编排流管理', perm: 'task:templates' }
+      },
+      {
+        path: 'orchestration/edit/:id?',
+        name: 'FlowEditor',
+        component: () => import('@/views/orchestration/FlowEditor.vue'),
+        meta: { title: '编排画布', perm: 'task:templates' }
+      },
+      {
+        path: 'orchestration/execution/:id',
+        name: 'ExecutionDetail',
+        component: () => import('@/views/orchestration/ExecutionDetail.vue'),
+        meta: { title: '执行详情', perm: 'task:templates' }
+      },
       {
         path: 'growth-task',
         name: 'GrowthTaskManagement',

+ 180 - 0
cfc-web/src/views/orchestration/ExecutionDetail.vue

@@ -0,0 +1,180 @@
+<template>
+  <div class="page-container">
+    <el-card>
+      <div slot="header">
+        <span>执行详情</span>
+        <el-button size="small" style="float:right" @click="$router.push('/orchestration')">返回列表</el-button>
+      </div>
+
+      <el-descriptions v-if="execution" :column="3" border size="medium" style="margin-bottom:20px">
+        <el-descriptions-item label="执行ID">{{ execution.id }}</el-descriptions-item>
+        <el-descriptions-item label="流ID">{{ execution.flowId }}</el-descriptions-item>
+        <el-descriptions-item label="版本">{{ execution.flowVersion }}</el-descriptions-item>
+        <el-descriptions-item label="状态">
+          <el-tag :type="statusType(execution.status)">{{ statusLabel(execution.status) }}</el-tag>
+        </el-descriptions-item>
+        <el-descriptions-item label="触发来源">{{ triggerLabel(execution.triggerSource) }}</el-descriptions-item>
+        <el-descriptions-item label="启动时间">{{ formatTime(execution.startedAt) }}</el-descriptions-item>
+        <el-descriptions-item label="完成时间">{{ formatTime(execution.finishedAt) }}</el-descriptions-item>
+        <el-descriptions-item label="错误原因">{{ execution.errorReason || '-' }}</el-descriptions-item>
+      </el-descriptions>
+
+      <!-- 手动干预 -->
+      <div style="margin-bottom:15px">
+        <el-button type="warning" size="small" v-if="execution && execution.status==='running'" @click="handleTerminate">终止流</el-button>
+        <el-button type="primary" size="small" v-if="execution && execution.status==='running'" @click="handlePause">暂停</el-button>
+        <el-button type="success" size="small" v-if="execution && execution.status==='paused'" @click="handleResume">恢复</el-button>
+      </div>
+
+      <!-- 节点树 -->
+      <el-table :data="nodes" border v-loading="loading" row-key="id" default-expand-all>
+        <el-table-column prop="nodeId" label="节点ID" width="160"></el-table-column>
+        <el-table-column prop="status" label="状态" width="120">
+          <template slot-scope="scope">
+            <el-tag :type="statusType(scope.row.status)" size="small">{{ statusLabel(scope.row.status) }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="generation" label="循环次数" width="100"></el-table-column>
+        <el-table-column prop="taskId" label="关联任务ID" width="120">
+          <template slot-scope="scope">{{ scope.row.taskId || '-' }}</template>
+        </el-table-column>
+        <el-table-column prop="conditionMetAt" label="条件达成时间" width="180">
+          <template slot-scope="scope">{{ formatTime(scope.row.conditionMetAt) }}</template>
+        </el-table-column>
+        <el-table-column prop="loopTerminationReason" label="循环终止原因" width="150">
+          <template slot-scope="scope">{{ scope.row.loopTerminationReason || '-' }}</template>
+        </el-table-column>
+        <el-table-column prop="errorReason" label="错误原因" min-width="150">
+          <template slot-scope="scope">{{ scope.row.errorReason || '-' }}</template>
+        </el-table-column>
+        <el-table-column prop="createdAt" label="创建时间" width="170">
+          <template slot-scope="scope">{{ formatTime(scope.row.createdAt) }}</template>
+        </el-table-column>
+        <el-table-column label="操作" width="180" fixed="right">
+          <template slot-scope="scope">
+            <el-button size="mini" type="danger" v-if="scope.row.status==='in_progress'||scope.row.status==='pending'" @click="handleNodeFail(scope.row)">标记失败</el-button>
+            <el-button size="mini" type="warning" v-if="scope.row.status==='failed'||scope.row.status==='timeout'" @click="handleNodeRestart(scope.row)">重启节点</el-button>
+            <el-button size="mini" type="success" v-if="scope.row.status==='in_progress'||scope.row.status==='pending'" @click="handleConditionMet(scope.row)">条件达成</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+    </el-card>
+  </div>
+</template>
+
+<script>
+import { getExecutionDetail, terminateExecution, pauseExecution, resumeExecution, failNode, restartNode, markConditionMet } from '@/api/orchestration'
+
+export default {
+  name: 'ExecutionDetail',
+  data() {
+    return {
+      executionId: Number(this.$route.params.id),
+      execution: null,
+      nodes: [],
+      loading: false
+    }
+  },
+  created() {
+    this.loadDetail()
+  },
+  methods: {
+    async loadDetail() {
+      this.loading = true
+      try {
+        const res = await getExecutionDetail(this.executionId)
+        this.execution = res.data.execution || null
+        this.nodes = res.data.nodes || []
+      } catch (e) {
+        console.error(e)
+      } finally {
+        this.loading = false
+      }
+    },
+    async handleTerminate() {
+      this.$confirm('确认终止整个执行流?运行中的任务不会被删除。', '提示', { type: 'warning' }).then(async () => {
+        try {
+          await terminateExecution({ executionId: this.executionId, reason: '管理员手动终止' })
+          this.$message.success('已终止')
+          this.loadDetail()
+        } catch (e) {
+          this.$message.error(e.message || '操作失败')
+        }
+      }).catch(() => {})
+    },
+    async handlePause() {
+      try {
+        await pauseExecution(this.executionId)
+        this.$message.success('已暂停')
+        this.loadDetail()
+      } catch (e) {
+        this.$message.error(e.message || '操作失败')
+      }
+    },
+    async handleResume() {
+      try {
+        await resumeExecution(this.executionId)
+        this.$message.success('已恢复')
+        this.loadDetail()
+      } catch (e) {
+        this.$message.error(e.message || '操作失败')
+      }
+    },
+    async handleNodeFail(row) {
+      try {
+        await failNode(row.id, '管理员手动标记失败')
+        this.$message.success('已标记失败')
+        this.loadDetail()
+      } catch (e) {
+        this.$message.error(e.message || '操作失败')
+      }
+    },
+    async handleNodeRestart(row) {
+      try {
+        await restartNode(row.id)
+        this.$message.success('已重启节点')
+        this.loadDetail()
+      } catch (e) {
+        this.$message.error(e.message || '操作失败')
+      }
+    },
+    async handleConditionMet(row) {
+      try {
+        await markConditionMet(row.id)
+        this.$message.success('已通知条件达成')
+        this.loadDetail()
+      } catch (e) {
+        this.$message.error(e.message || '操作失败')
+      }
+    },
+    statusType(s) {
+      const map = {
+        running: 'primary', paused: 'warning', terminated: 'danger', completed: 'success',
+        pending: 'info', in_progress: 'primary', completed: 'success',
+        failed: 'danger', timeout: 'warning', terminated: 'danger', skipped: 'info'
+      }
+      return map[s] || 'info'
+    },
+    statusLabel(s) {
+      const map = {
+        running: '运行中', paused: '已暂停', terminated: '已终止', completed: '已完成',
+        pending: '等待中', in_progress: '进行中',
+        failed: '失败', timeout: '超时', skipped: '跳过'
+      }
+      return map[s] || s
+    },
+    triggerLabel(s) {
+      const map = { manual: '手动', cron: '定时', callback: '回调' }
+      return map[s] || s
+    },
+    formatTime(t) {
+      if (!t) return '-'
+      return t.replace('T', ' ').substring(0, 19)
+    }
+  }
+}
+</script>
+
+<style scoped>
+.page-container { padding: 20px; }
+</style>

+ 376 - 0
cfc-web/src/views/orchestration/FlowEditor.vue

@@ -0,0 +1,376 @@
+<template>
+  <div class="flow-editor">
+    <el-card>
+      <div slot="header">
+        <span>编排画布{{ flowId ? '(编辑)' : '(新建)' }}</span>
+        <div style="float:right">
+          <el-button size="small" @click="saveDraft">保存草稿</el-button>
+          <el-button size="small" type="primary" @click="publishFlow">发布</el-button>
+          <el-button size="small" @click="$router.push('/orchestration')">返回列表</el-button>
+        </div>
+      </div>
+
+      <el-form :inline="true">
+        <el-form-item label="流名称">
+          <el-input v-model="flowName" placeholder="请输入编排流名称" style="width:250px"></el-input>
+        </el-form-item>
+        <el-form-item label="描述">
+          <el-input v-model="flowDescription" placeholder="可选" style="width:300px"></el-input>
+        </el-form-item>
+      </el-form>
+
+      <div class="editor-layout">
+        <!-- 左侧:任务模板面板 -->
+        <div class="node-palette">
+          <div class="palette-title">任务模板(拖拽到画布)</div>
+          <div
+            v-for="tpl in templates"
+            :key="tpl.id"
+            class="palette-item"
+            :draggable="true"
+            @dragstart="onTemplateDragStart($event, tpl)"
+          >
+            {{ tpl.title || ('模板#' + tpl.id) }}
+          </div>
+          <div v-if="!templates.length" class="palette-empty">暂无模板</div>
+        </div>
+
+        <!-- 中央:jsPlumb 画布 -->
+        <div class="canvas-wrap">
+          <div ref="canvas" class="jsplumb-canvas" @dragover.prevent @drop="onCanvasDrop"></div>
+          <div class="canvas-tip">拖拽左侧模板到画布创建节点,从节点圆点连线</div>
+        </div>
+
+        <!-- 右侧:属性面板 -->
+        <div class="prop-panel">
+          <div class="prop-title">节点/边属性</div>
+          <template v-if="selectedNode">
+            <el-form label-width="90px" size="mini">
+              <el-form-item label="节点ID">
+                <el-input v-model="selectedNode.id" :disabled="true"></el-input>
+              </el-form-item>
+              <el-form-item label="是否起点">
+                <el-switch v-model="selectedNode.is_start_node"></el-switch>
+              </el-form-item>
+              <el-form-item label="执行人">
+                <el-select v-model="selectedNode.executorType" style="width:100%">
+                  <el-option label="孩子" value="child"></el-option>
+                  <el-option label="成员" value="member"></el-option>
+                </el-select>
+              </el-form-item>
+              <el-form-item label="超时(分钟)">
+                <el-input-number v-model="selectedNode.timeout_minutes" :min="0"></el-input-number>
+              </el-form-item>
+              <el-form-item label="最大循环">
+                <el-input-number v-model="selectedNode.max_loops" :min="0"></el-input-number>
+              </el-form-item>
+              <el-form-item label="删除节点">
+                <el-button type="danger" size="mini" @click="deleteSelectedNode">删除</el-button>
+              </el-form-item>
+            </el-form>
+          </template>
+          <template v-else-if="selectedEdge">
+            <el-form label-width="90px" size="mini">
+              <el-form-item label="边类型">
+                <el-select v-model="selectedEdge.type" style="width:100%">
+                  <el-option label="普通" value="normal"></el-option>
+                  <el-option label="失败兜底" value="failed"></el-option>
+                  <el-option label="超时兜底" value="timeout"></el-option>
+                </el-select>
+              </el-form-item>
+              <el-form-item label="触发语义">
+                <el-select v-model="selectedEdge.operator" style="width:100%">
+                  <el-option label="AND(全部完成)" value="AND"></el-option>
+                  <el-option label="OR(任一完成)" value="OR"></el-option>
+                </el-select>
+              </el-form-item>
+              <el-form-item label="删除连线">
+                <el-button type="danger" size="mini" @click="deleteSelectedEdge">删除</el-button>
+              </el-form-item>
+            </el-form>
+          </template>
+          <div v-else class="prop-empty">点击节点或连线编辑属性</div>
+        </div>
+      </div>
+    </el-card>
+  </div>
+</template>
+
+<script>
+import jsPlumb from 'jsplumb'
+import { saveFlow, publishFlow, getFlowDetail } from '@/api/orchestration'
+
+let _nodeSeq = 0
+
+export default {
+  name: 'FlowEditor',
+  data() {
+    return {
+      flowId: this.$route.params.id ? Number(this.$route.params.id) : null,
+      flowName: '',
+      flowDescription: '',
+      templates: [],
+      nodes: [],       // { id, task_template_ref, x, y, is_start_node, executorType, timeout_minutes, max_loops }
+      edges: [],       // { id, from, to, type, operator }
+      selectedNode: null,
+      selectedEdge: null,
+      jsplumbInstance: null,
+      nextNodeSeq: 1,
+      canvasBounds: null
+    }
+  },
+  created() {
+    this.loadTemplates()
+    if (this.flowId) {
+      this.loadFlowDetail()
+    }
+  },
+  mounted() {
+    this.initJsPlumb()
+  },
+  beforeDestroy() {
+    if (this.jsplumbInstance) {
+      this.jsplumbInstance.reset()
+    }
+  },
+  methods: {
+    async loadTemplates() {
+      try {
+        const request = require('@/utils/request').default
+        const res = await request({ url: '/api/admin/task-templates/list', method: 'post', data: { page: 1, pageSize: 100 } })
+        this.templates = res.data.list || []
+      } catch (e) {
+        console.error('加载任务模板失败', e)
+        this.templates = []
+      }
+    },
+    async loadFlowDetail() {
+      try {
+        const res = await getFlowDetail(this.flowId)
+        const flow = res.data.flow || {}
+        this.flowName = flow.name || ''
+        this.flowDescription = flow.description || ''
+        const cfg = flow.configJson ? JSON.parse(flow.configJson) : { nodes: [], config: { edges: [] } }
+        const nodeList = cfg.nodes || []
+        this.nodes = nodeList.map(n => ({
+          id: n.id,
+          task_template_ref: n.task_template_ref,
+          x: n.x || 100,
+          y: n.y || 100,
+          is_start_node: !!n.is_start_node,
+          executorType: n.executorType || 'child',
+          timeout_minutes: n.timeout_minutes || 0,
+          max_loops: (n.loop_termination && n.loop_termination.max_loops) || n.max_loops || 0
+        }))
+        this.edges = (cfg.config && cfg.config.edges) || []
+        // 等待 DOM 渲染后再画节点和连线
+        this.$nextTick(() => {
+          this.renderAllNodes()
+          this.renderAllEdges()
+        })
+      } catch (e) {
+        console.error('加载编排流失败', e)
+      }
+    },
+    initJsPlumb() {
+      const el = this.$refs.canvas
+      this.jsplumbInstance = jsPlumb.jsPlumb.getInstance({
+        Container: el,
+        Connector: ['Bezier', { curviness: 50 }],
+        Endpoint: ['Dot', { radius: 6 }],
+        EndpointStyle: { fill: '#409EFF' },
+        PaintStyle: { stroke: '#909399', strokeWidth: 2 },
+        Anchor: ['Left', 'Right', 'Top', 'Bottom']
+      })
+      this.jsplumbInstance.bind('connection', (info) => {
+        const from = info.sourceId
+        const to = info.targetId
+        if (this.edges.some(e => e.from === from && e.to === to)) {
+          // 重复连线,移除
+          setTimeout(() => this.jsplumbInstance.deleteConnection(info.connection), 0)
+          return
+        }
+        const edge = { id: 'e_' + Date.now(), from, to, type: 'normal', operator: 'AND' }
+        this.edges.push(edge)
+        this.jsplumbInstance.setPaintStyle(info.connection, { stroke: '#909399', strokeWidth: 2 })
+      })
+      this.jsplumbInstance.bind('click', (conn) => {
+        const edge = this.edges.find(e => e.from === conn.sourceId && e.to === conn.targetId)
+        this.selectedEdge = edge
+        this.selectedNode = null
+      })
+      this.jsplumbInstance.bind('dblclick', (conn) => {
+        this.jsplumbInstance.deleteConnection(conn)
+        const idx = this.edges.findIndex(e => e.from === conn.sourceId && e.to === conn.targetId)
+        if (idx >= 0) this.edges.splice(idx, 1)
+        this.selectedEdge = null
+      })
+      this.canvasBounds = el.getBoundingClientRect()
+    },
+    onTemplateDragStart(event, tpl) {
+      event.dataTransfer.setData('text/plain', JSON.stringify(tpl))
+    },
+    onCanvasDrop(event) {
+      const raw = event.dataTransfer.getData('text/plain')
+      if (!raw) return
+      try {
+        const tpl = JSON.parse(raw)
+        const rect = this.$refs.canvas.getBoundingClientRect()
+        const x = event.clientX - rect.left - 60
+        const y = event.clientY - rect.top - 20
+        this.addNode(tpl, Math.max(10, x), Math.max(10, y))
+      } catch (e) {
+        console.error('drop 解析失败', e)
+      }
+    },
+    addNode(tpl, x, y) {
+      _nodeSeq += 1
+      const nodeId = 'n' + _nodeSeq
+      const node = {
+        id: nodeId,
+        task_template_ref: tpl.id,
+        x,
+        y,
+        is_start_node: this.nodes.length === 0,
+        executorType: 'child',
+        timeout_minutes: 0,
+        max_loops: 0
+      }
+      this.nodes.push(node)
+      this.$nextTick(() => this.renderNode(node))
+    },
+    renderNode(node) {
+      const el = document.createElement('div')
+      el.id = node.id
+      el.className = 'flow-node'
+      el.style.left = node.x + 'px'
+      el.style.top = node.y + 'px'
+      const tpl = this.templates.find(t => t.id === Number(node.task_template_ref))
+      el.innerHTML = (node.is_start_node ? '▶ ' : '') + (tpl ? tpl.title : ('模板#' + node.task_template_ref)) + '<span class="node-id">' + node.id + '</span>'
+      el.addEventListener('click', (e) => {
+        e.stopPropagation()
+        const n = this.nodes.find(nd => nd.id === node.id)
+        this.selectedNode = n
+        this.selectedEdge = null
+      })
+      this.$refs.canvas.appendChild(el)
+      this.jsplumbInstance.draggable(node.id, {
+        stop: (state) => {
+          const n = this.nodes.find(nd => nd.id === node.id)
+          if (n) {
+            n.x = state.pos[0]
+            n.y = state.pos[1]
+          }
+        }
+      })
+      this.jsplumbInstance.addEndpoint(node.id, { isSource: true, maxConnections: -1 })
+      this.jsplumbInstance.addEndpoint(node.id, { isTarget: true, maxConnections: -1 })
+    },
+    renderAllNodes() {
+      this.nodes.forEach(n => this.renderNode(n))
+    },
+    renderAllEdges() {
+      this.edges.forEach(e => {
+        if (this.nodes.some(n => n.id === e.from) && this.nodes.some(n => n.id === e.to)) {
+          this.jsplumbInstance.connect({
+            source: e.from,
+            target: e.to,
+            parameters: { edgeId: e.id }
+          })
+        }
+      })
+    },
+    deleteSelectedNode() {
+      if (!this.selectedNode) return
+      const id = this.selectedNode.id
+      this.jsplumbInstance.remove(id)
+      this.nodes = this.nodes.filter(n => n.id !== id)
+      this.edges = this.edges.filter(e => e.from !== id && e.to !== id)
+      this.selectedNode = null
+    },
+    deleteSelectedEdge() {
+      if (!this.selectedEdge) return
+      const edge = this.selectedEdge
+      const conns = this.jsplumbInstance.getConnections({ source: edge.from, target: edge.to })
+      conns.forEach(c => this.jsplumbInstance.deleteConnection(c))
+      this.edges = this.edges.filter(e => !(e.from === edge.from && e.to === edge.to))
+      this.selectedEdge = null
+    },
+    buildConfigJson() {
+      const nodes = this.nodes.map(n => ({
+        id: n.id,
+        task_template_ref: n.task_template_ref,
+        x: n.x,
+        y: n.y,
+        is_start_node: !!n.is_start_node,
+        executorType: n.executorType || 'child',
+        timeout_minutes: n.timeout_minutes || 0,
+        loop_termination: n.max_loops > 0 ? { max_loops: n.max_loops } : null
+      }))
+      return JSON.stringify({ nodes, config: { edges: this.edges } })
+    },
+    validateBeforePublish() {
+      const errs = []
+      if (!this.flowName.trim()) errs.push('请填写流名称')
+      if (!this.nodes.length) errs.push('画布为空')
+      const startNodes = this.nodes.filter(n => n.is_start_node)
+      if (startNodes.length !== 1) errs.push('必须有且仅有一个起点节点')
+      if (this.nodes.some(n => !n.task_template_ref)) errs.push('存在未绑定任务模板的节点')
+      return errs
+    },
+    async saveDraft() {
+      const data = {
+        id: this.flowId,
+        name: this.flowName,
+        description: this.flowDescription,
+        configJson: this.buildConfigJson()
+      }
+      try {
+        const res = await saveFlow(data)
+        this.flowId = res.data.id || this.flowId
+        this.$message.success('保存成功')
+      } catch (e) {
+        this.$message.error(e.message || '保存失败')
+      }
+    },
+    async publishFlow() {
+      const errs = this.validateBeforePublish()
+      if (errs.length) {
+        this.$message.warning(errs[0])
+        return
+      }
+      try {
+        const res = await saveFlow({
+          id: this.flowId,
+          name: this.flowName,
+          description: this.flowDescription,
+          configJson: this.buildConfigJson()
+        })
+        this.flowId = res.data.id || this.flowId
+        await publishFlow(this.flowId)
+        this.$message.success('发布成功')
+        this.$router.push('/orchestration')
+      } catch (e) {
+        this.$message.error(e.message || '发布失败')
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.editor-layout { display: flex; height: 600px; border: 1px solid #EBEEF5; border-radius: 4px; overflow: hidden; }
+.node-palette { width: 180px; border-right: 1px solid #EBEEF5; padding: 10px; overflow-y: auto; }
+.palette-title { font-weight: bold; margin-bottom: 10px; font-size: 13px; }
+.palette-item { background: #F4F4F5; border-radius: 4px; padding: 8px; margin-bottom: 8px; cursor: grab; font-size: 12px; border: 1px solid #DCDFE6; }
+.palette-item:hover { border-color: #409EFF; color: #409EFF; }
+.palette-empty { color: #909399; font-size: 12px; }
+.canvas-wrap { flex: 1; position: relative; }
+.jsplumb-canvas { position: relative; width: 100%; height: 100%; background: #FAFAFA; }
+.canvas-tip { position: absolute; bottom: 8px; left: 8px; color: #C0C4CC; font-size: 12px; pointer-events: none; }
+.prop-panel { width: 240px; border-left: 1px solid #EBEEF5; padding: 10px; overflow-y: auto; }
+.prop-title { font-weight: bold; margin-bottom: 10px; font-size: 13px; }
+.prop-empty { color: #909399; font-size: 12px; }
+.flow-node { position: absolute; width: 120px; padding: 8px; background: #fff; border: 2px solid #409EFF; border-radius: 6px; text-align: center; font-size: 12px; cursor: move; user-select: none; }
+.flow-node .node-id { display: block; color: #C0C4CC; font-size: 10px; margin-top: 2px; }
+</style>

+ 204 - 0
cfc-web/src/views/orchestration/OrchestrationFlow.vue

@@ -0,0 +1,204 @@
+<template>
+  <div class="page-container">
+    <el-card>
+      <div slot="header">
+        <span>编排流管理</span>
+        <el-button type="primary" size="small" style="float:right;margin-right:10px" @click="handleEdit(null)">
+          新建编排
+        </el-button>
+        <el-button type="primary" size="small" @click="loadList" :loading="loading">刷新</el-button>
+      </div>
+
+      <!-- 搜索 -->
+      <el-form :inline="true" :model="searchForm" style="margin-bottom:20px">
+        <el-form-item label="状态">
+          <el-select v-model="searchForm.status" placeholder="全部状态" clearable>
+            <el-option label="草稿" value="draft"></el-option>
+            <el-option label="已发布" value="published"></el-option>
+            <el-option label="已归档" value="archived"></el-option>
+          </el-select>
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" @click="loadList">搜索</el-button>
+          <el-button @click="handleReset">重置</el-button>
+        </el-form-item>
+      </el-form>
+
+      <!-- 列表 -->
+      <el-table :data="tableData" border v-loading="loading" stripe>
+        <el-table-column prop="id" label="ID" width="80"></el-table-column>
+        <el-table-column prop="name" label="名称" min-width="150"></el-table-column>
+        <el-table-column prop="version" label="版本" width="80"></el-table-column>
+        <el-table-column prop="status" label="状态" width="100">
+          <template slot-scope="scope">
+            <el-tag :type="statusType(scope.row.status)">{{ statusLabel(scope.row.status) }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="createdAt" label="创建时间" width="170">
+          <template slot-scope="scope">{{ formatTime(scope.row.createdAt) }}</template>
+        </el-table-column>
+        <el-table-column label="操作" width="320" fixed="right">
+          <template slot-scope="scope">
+            <el-button size="mini" @click="handleEdit(scope.row)">编辑</el-button>
+            <el-button size="mini" type="success" v-if="scope.row.status==='draft'" @click="handlePublish(scope.row)">发布</el-button>
+            <el-button size="mini" type="warning" v-if="scope.row.status==='published'" @click="handleArchive(scope.row)">归档</el-button>
+            <el-button size="mini" type="danger" v-if="scope.row.status==='draft'" @click="handleDelete(scope.row)">删除</el-button>
+            <el-button size="mini" type="info" @click="handleRun(scope.row)">启动执行</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <el-pagination
+        style="margin-top:15px;text-align:right"
+        @current-change="loadList"
+        :current-page="pagination.current"
+        :page-size="pagination.size"
+        :total="pagination.total"
+        layout="total, prev, pager, next"
+      ></el-pagination>
+    </el-card>
+
+    <!-- 启动执行对话框 -->
+    <el-dialog title="启动执行" :visible.sync="runDialogVisible" width="400px">
+      <el-form label-width="100px">
+        <el-form-item label="家庭成员">
+          <el-select v-model="runForm.familyMemberId" placeholder="请选择" style="width:100%">
+            <el-option v-for="m in memberList" :key="m.id" :label="m.nickname" :value="m.id"></el-option>
+          </el-select>
+        </el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="runDialogVisible=false">取消</el-button>
+        <el-button type="primary" @click="confirmRun" :loading="runLoading">确认启动</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { listFlows, publishFlow, archiveFlow, deleteFlow, startExecution } from '@/api/orchestration'
+
+export default {
+  name: 'OrchestrationFlow',
+  data() {
+    return {
+      tableData: [],
+      loading: false,
+      searchForm: { status: '' },
+      pagination: { current: 1, size: 10, total: 0 },
+      runDialogVisible: false,
+      runLoading: false,
+      runForm: { flowId: null, familyMemberId: null },
+      memberList: []
+    }
+  },
+  created() {
+    this.loadList()
+  },
+  methods: {
+    async loadList() {
+      this.loading = true
+      try {
+        const res = await listFlows({
+          page: this.pagination.current,
+          pageSize: this.pagination.size,
+          status: this.searchForm.status || null
+        })
+        this.tableData = res.data.list || []
+        this.pagination.total = res.data.total || 0
+      } catch (e) {
+        console.error(e)
+      } finally {
+        this.loading = false
+      }
+    },
+    handleReset() {
+      this.searchForm = { status: '' }
+      this.pagination.current = 1
+      this.loadList()
+    },
+    handleEdit(row) {
+      if (row && row.id) {
+        this.$router.push('/orchestration/edit/' + row.id)
+      } else {
+        this.$router.push('/orchestration/edit')
+      }
+    },
+    async handlePublish(row) {
+      try {
+        await publishFlow(row.id)
+        this.$message.success('发布成功')
+        this.loadList()
+      } catch (e) {
+        this.$message.error(e.message || '发布失败')
+      }
+    },
+    async handleArchive(row) {
+      try {
+        await archiveFlow(row.id)
+        this.$message.success('归档成功')
+        this.loadList()
+      } catch (e) {
+        this.$message.error(e.message || '归档失败')
+      }
+    },
+    async handleDelete(row) {
+      this.$confirm('确认删除该编排流?', '提示', { type: 'warning' }).then(async () => {
+        try {
+          await deleteFlow(row.id)
+          this.$message.success('删除成功')
+          this.loadList()
+        } catch (e) {
+          this.$message.error(e.message || '删除失败')
+        }
+      }).catch(() => {})
+    },
+    handleRun(row) {
+      this.runForm.flowId = row.id
+      this.runDialogVisible = true
+      // 加载家庭成员列表(简化:从本地存储获取当前用户家庭)
+      this.loadMembers()
+    },
+    async loadMembers() {
+      // 实际项目中应从 /api/family/members 获取,这里暂不实现
+      this.memberList = []
+    },
+    async confirmRun() {
+      if (!this.runForm.familyMemberId) {
+        this.$message.warning('请选择家庭成员')
+        return
+      }
+      this.runLoading = true
+      try {
+        const res = await startExecution({
+          flowId: this.runForm.flowId,
+          familyMemberId: this.runForm.familyMemberId
+        })
+        this.$message.success('执行已启动')
+        this.runDialogVisible = false
+        this.$router.push('/orchestration/execution/' + res.data.executionId)
+      } catch (e) {
+        this.$message.error(e.message || '启动失败')
+      } finally {
+        this.runLoading = false
+      }
+    },
+    statusType(s) {
+      const map = { draft: 'info', published: 'success', archived: 'default' }
+      return map[s] || 'info'
+    },
+    statusLabel(s) {
+      const map = { draft: '草稿', published: '已发布', archived: '已归档' }
+      return map[s] || s
+    },
+    formatTime(t) {
+      if (!t) return '-'
+      return t.replace('T', ' ').substring(0, 19)
+    }
+  }
+}
+</script>
+
+<style scoped>
+.page-container { padding: 20px; }
+</style>

+ 1 - 0
docs/superpowers/PROJECT-OVERVIEW.md

@@ -472,6 +472,7 @@
 | `2026-09-04-task-system-unification-design.md` | 🟢 已实施 | 统一任务系统设计(多套任务体系合并为单表 tasks,source_type 区分 9 类来源;奖励自动发放;复用 RepeatTaskGenerator 每日重置;前端统一任务中心;家庭挑战与五维打卡本次不合并;实施计划:2026-09-04-task-system-unification.md) |
 | `2026-09-12-doa-family-goal-design.md` | 🟢 设计完成 | DOA 家庭打卡个人目标-周计划-周复盘设计(并列共同挑战新模块,5 领域/自选周期/家人可见/代管不能登录成员;设计稿:2026-09-12-doa-family-goal-design.md) |
 | `2026-09-12-vendor-order-review-design.md` | ✅ 已实施 | 供应商订单审核设计(pending 订单可修改价格/配送方式/添加临时优惠券/赠品,自动重算金额) |
+| `2026-09-19-task-orchestration-design.md` | 🟡 设计已确认(待写实现计划) | 任务编排系统设计(含环 DAG:多前置 AND/OR + 后置 timeout/failed 兜底 + 计数/条件循环终止 + Quartz 轮询 + jsPlumb 可视化画布;节点执行创建 tasks 实例复用积分体系;4 张新表) |
 
 ---
 

+ 1107 - 0
docs/superpowers/plans/2026-09-19-task-orchestration-plan.md

@@ -0,0 +1,1107 @@
+# 任务编排系统 实现计划
+
+> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
+
+**目标:** 在现有 `tasks` 表之上新增任务编排模块(含环 DAG:多前置 AND/OR、超时/失败兜底、计数/条件循环终止),后端提供 Flow/Execution 管理与 Quartz 轮询,cfc-web 管理端提供 jsPlumb 可视化编排画布。
+
+**架构:** 新增 4 张表(flows / edges / executions / node_instances),核心编排引擎 `OrchestrationEngine` 负责节点触发、循环终止、兜底分支,通过 `OrchestrationPollingJob`(Quartz)轮询评估,节点执行复用 `TaskService` 创建 `tasks` 实例。`TaskService` 完成/失败点回调编排引擎(仅编排节点任务生效)。
+
+**技术栈:** Spring Boot 2.7.18 + MyBatis-Plus 3.5.3.1 + Java 8 + Quartz(新增依赖);cfc-web Vue 2 + Element UI + jsPlumb。
+
+**设计规格:** `docs/superpowers/specs/2026-09-19-task-orchestration-design.md`
+
+---
+
+## 文件清单
+
+### 后端新增
+- **创建** `cfc-backend/src/main/java/com/etotem/cfc/entity/TaskOrchestrationFlow.java` — flows 表实体
+- **创建** `cfc-backend/src/main/java/com/etotem/cfc/entity/TaskOrchestrationEdge.java` — edges 表实体
+- **创建** `cfc-backend/src/main/java/com/etotem/cfc/entity/TaskOrchestrationExecution.java` — executions 表实体
+- **创建** `cfc-backend/src/main/java/com/etotem/cfc/entity/TaskOrchestrationNodeInstance.java` — node_instances 表实体
+- **创建** `cfc-backend/src/main/java/com/etotem/cfc/mapper/TaskOrchestrationFlowMapper.java`
+- **创建** `cfc-backend/src/main/java/com/etotem/cfc/mapper/TaskOrchestrationEdgeMapper.java`
+- **创建** `cfc-backend/src/main/java/com/etotem/cfc/mapper/TaskOrchestrationExecutionMapper.java`
+- **创建** `cfc-backend/src/main/java/com/etotem/cfc/mapper/TaskOrchestrationNodeInstanceMapper.java`
+- **创建** `cfc-backend/src/main/java/com/etotem/cfc/service/OrchestrationEngine.java` — 编排引擎(核心)
+- **创建** `cfc-backend/src/main/java/com/etotem/cfc/service/OrchestrationFlowService.java` — Flow CRUD
+- **创建** `cfc-backend/src/main/java/com/etotem/cfc/service/OrchestrationExecutionService.java` — Execution 生命周期
+- **创建** `cfc-backend/src/main/java/com/etotem/cfc/controller/OrchestrationController.java` — REST 接口
+- **创建** `cfc-backend/src/main/java/com/etotem/cfc/task/OrchestrationPollingJob.java` — Quartz 轮询 Job
+- **创建** `cfc-backend/src/main/java/com/etotem/cfc/config/QuartzConfig.java` — Quartz 调度配置
+
+### 后端修改
+- **修改** `cfc-backend/pom.xml` — 新增 `spring-boot-starter-quartz` 依赖
+- **修改** `cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java` — 新增 4 张表迁移
+- **修改** `cfc-backend/src/main/resources/schema.sql` — 同步 4 张表建表语句
+- **修改** `cfc-backend/src/main/java/com/etotem/cfc/service/TaskService.java` — 完成/失败点回调编排引擎
+
+### 前端新增(cfc-web)
+- **创建** `cfc-web/src/views/orchestration/OrchestrationFlow.vue` — 流列表 + 入口
+- **创建** `cfc-web/src/views/orchestration/FlowEditor.vue` — jsPlumb 编排画布
+- **创建** `cfc-web/src/views/orchestration/ExecutionDetail.vue` — 执行详情只读页
+
+### 前端修改(cfc-web)
+- **修改** `cfc-web/src/router/index.js` — 注册 3 个路由
+- **修改** `cfc-web/src/api/orchestration.js`(新建)— 接口封装
+- **修改** `cfc-web/package.json` — 新增 jsplumb 依赖
+
+### 测试新增
+- **创建** `cfc-backend/src/test/java/com/etotem/cfc/orchestration/OrchestrationEngineTest.java` — 引擎单元测试
+
+---
+
+## 任务分解
+
+### 任务 1:后端 — 实体 + Mapper + 数据迁移
+
+**文件:**
+- 创建:4 个 entity(`TaskOrchestrationFlow.java` / `TaskOrchestrationEdge.java` / `TaskOrchestrationExecution.java` / `TaskOrchestrationNodeInstance.java`)
+- 创建:4 个 Mapper
+- 修改:`DatabaseInitializer.java`
+- 修改:`schema.sql`
+
+- [ ] **步骤 1:创建 4 个实体类**
+
+按 `Task.java` 现有模式(`@Data` + `@TableName` + `@TableId(type = IdType.AUTO)` + `implements Serializable`)。字段严格对应设计文档 §3:
+
+`TaskOrchestrationFlow.java`(`@TableName("task_orchestration_flows")`):
+```java
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("task_orchestration_flows")
+public class TaskOrchestrationFlow implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private String name;
+
+    private String description;
+
+    private Long creatorId;
+
+    private Long familyId;
+
+    private Integer version;
+
+    private String status;
+
+    private String scheduleCron;
+
+    private String configJson;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}
+```
+
+`TaskOrchestrationEdge.java`(`@TableName("task_orchestration_edges")`):
+```java
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("task_orchestration_edges")
+public class TaskOrchestrationEdge implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long flowId;
+
+    private String fromNodeId;
+
+    private String toNodeId;
+
+    private String edgeType;
+
+    private String operator;
+
+    private Integer sortOrder;
+
+    private Date createdAt;
+}
+```
+
+`TaskOrchestrationExecution.java`(`@TableName("task_orchestration_executions")`):
+```java
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("task_orchestration_executions")
+public class TaskOrchestrationExecution implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long flowId;
+
+    private Integer flowVersion;
+
+    private Long familyId;
+
+    private Long familyMemberId;
+
+    private String triggerSource;
+
+    private String status;
+
+    private Date startedAt;
+
+    private Date finishedAt;
+
+    private String errorReason;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}
+```
+
+`TaskOrchestrationNodeInstance.java`(`@TableName("task_orchestration_node_instances")`):
+```java
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("task_orchestration_node_instances")
+public class TaskOrchestrationNodeInstance implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long executionId;
+
+    private String nodeId;
+
+    private Long taskId;
+
+    private String status;
+
+    private Integer generation;
+
+    private Date conditionMetAt;
+
+    private String loopTerminationReason;
+
+    private Date startedAt;
+
+    private Date completedAt;
+
+    private String errorReason;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}
+```
+
+- [ ] **步骤 2:创建 4 个 Mapper 接口**
+
+每个 Mapper 继承 `BaseMapper<T>`,模式参考 `TaskMapper`:
+
+```java
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.TaskOrchestrationFlow;
+
+public interface TaskOrchestrationFlowMapper extends BaseMapper<TaskOrchestrationFlow> {
+}
+```
+
+(其余 3 个 Mapper 同理:`TaskOrchestrationEdgeMapper` / `TaskOrchestrationExecutionMapper` / `TaskOrchestrationNodeInstanceMapper`)
+
+- [ ] **步骤 3:在 DatabaseInitializer 新增迁移**
+
+在 `runMigrations()` 末尾(搜索 `// 迁移` 找到最新编号,递增),加入 4 张表的 `CREATE TABLE IF NOT EXISTS` 语句,内容与设计文档 §11 DDL 完全一致(含 `condition_met_at` 列和唯一索引 `uk_node_instance`)。
+
+- [ ] **步骤 4:同步 schema.sql**
+
+在 `schema.sql` 末尾追加相同的 4 张表 `CREATE TABLE IF NOT EXISTS` 语句(与迁移脚本保持一致)。
+
+- [ ] **步骤 5:编译验证**
+
+运行:`cd cfc-backend && mvn clean compile`
+预期:BUILD SUCCESS,无编译错误。
+
+- [ ] **步骤 6:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/entity/TaskOrchestration*.java \
+        cfc-backend/src/main/java/com/etotem/cfc/mapper/TaskOrchestration*Mapper.java \
+        cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java \
+        cfc-backend/src/main/resources/schema.sql
+git commit -m "feat: 任务编排 4 张新表实体/Mapper/迁移/schema"
+```
+
+---
+
+### 任务 2:后端 — 编排引擎 OrchestrationEngine(核心)
+
+**文件:**
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/service/OrchestrationEngine.java`
+- 创建:`cfc-backend/src/test/java/com/etotem/cfc/orchestration/OrchestrationEngineTest.java`
+
+- [ ] **步骤 1:编写失败的单元测试(AND/OR 语义 + 循环终止 + 幂等)**
+
+`OrchestrationEngineTest.java`:
+```java
+package com.etotem.cfc.orchestration;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.etotem.cfc.entity.TaskOrchestrationEdge;
+import com.etotem.cfc.entity.TaskOrchestrationNodeInstance;
+import com.etotem.cfc.service.OrchestrationEngine;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+public class OrchestrationEngineTest {
+
+    private OrchestrationEngine engine = new OrchestrationEngine();
+
+    // 前置 AND:两个源节点都 completed 才触发
+    @Test
+    public void testAndGateRequiresAllCompleted() {
+        List<TaskOrchestrationEdge> inEdges = Arrays.asList(
+            edge("n1", "n3", "AND"),
+            edge("n2", "n3", "AND")
+        );
+        List<TaskOrchestrationNodeInstance> nodes = Arrays.asList(
+            node("n1", "completed"),
+            node("n2", "pending")
+        );
+        assertFalse(engine.isUnlocked("n3", inEdges, nodes));
+    }
+
+    // 前置 OR:任一源节点 completed 即触发
+    @Test
+    public void testOrGateUnlocksWhenAnyCompleted() {
+        List<TaskOrchestrationEdge> inEdges = Arrays.asList(
+            edge("n1", "n3", "OR"),
+            edge("n2", "n3", "OR")
+        );
+        List<TaskOrchestrationNodeInstance> nodes = Arrays.asList(
+            node("n1", "completed"),
+            node("n2", "pending")
+        );
+        assertTrue(engine.isUnlocked("n3", inEdges, nodes));
+    }
+
+    // 循环终止:generation 达到 max_loops 时置 terminated
+    @Test
+    public void testLoopTerminationByCount() {
+        JSONObject cfg = JSON.parseObject("{\"max_loops\":3}");
+        TaskOrchestrationNodeInstance ni = node("n1", "in_progress");
+        ni.setGeneration(3); // 已达上限
+        engine.applyLoopTermination(ni, cfg, 3);
+        assertEquals("terminated", ni.getStatus());
+        assertEquals("max_loops_reached", ni.getLoopTerminationReason());
+    }
+
+    private TaskOrchestrationEdge edge(String from, String to, String op) {
+        TaskOrchestrationEdge e = new TaskOrchestrationEdge();
+        e.setFromNodeId(from);
+        e.setToNodeId(to);
+        e.setOperator(op);
+        return e;
+    }
+
+    private TaskOrchestrationNodeInstance node(String id, String status) {
+        TaskOrchestrationNodeInstance n = new TaskOrchestrationNodeInstance();
+        n.setNodeId(id);
+        n.setStatus(status);
+        return n;
+    }
+}
+```
+
+> 说明:`isUnlocked` / `applyLoopTermination` 是引擎将要暴露的纯函数方法,便于单元测试(不依赖数据库)。
+
+- [ ] **步骤 2:运行测试确认失败**
+
+运行:`cd cfc-backend && mvn test -Dtest=OrchestrationEngineTest`
+预期:编译失败,`OrchestrationEngine` 类不存在。
+
+- [ ] **步骤 3:实现 OrchestrationEngine**
+
+`OrchestrationEngine.java` 核心方法(注入 4 个 Mapper + `TaskService` + `TaskTemplateMapper`,`@Resource` DI):
+
+```java
+package com.etotem.cfc.service;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.*;
+import com.etotem.cfc.mapper.*;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.*;
+import java.util.stream.Collectors;
+
+@Slf4j
+@Service
+public class OrchestrationEngine {
+
+    @Resource
+    private TaskOrchestrationNodeInstanceMapper nodeInstanceMapper;
+    @Resource
+    private TaskOrchestrationEdgeMapper edgeMapper;
+    @Resource
+    private TaskOrchestrationExecutionMapper executionMapper;
+    @Resource
+    private TaskOrchestrationFlowMapper flowMapper;
+    @Resource
+    private TaskTemplateMapper taskTemplateMapper;
+    @Resource
+    private TaskService taskService;
+
+    /**
+     * 纯函数:判断目标节点是否满足入边触发条件。
+     * @return true = 可触发
+     */
+    public boolean isUnlocked(String targetNodeId,
+                              List<TaskOrchestrationEdge> inEdges,
+                              List<TaskOrchestrationNodeInstance> nodes) {
+        if (inEdges == null || inEdges.isEmpty()) {
+            return true; // 无前置,start_node 直接触发
+        }
+        Map<String, String> statusById = nodes.stream()
+            .collect(Collectors.toMap(TaskOrchestrationNodeInstance::getNodeId,
+                                      TaskOrchestrationNodeInstance::getStatus));
+        // 按 operator 分组:AND 组内全 completed,OR 组内任一 completed
+        boolean hasAnd = false;
+        boolean hasOr = false;
+        boolean orSatisfied = false;
+        for (TaskOrchestrationEdge e : inEdges) {
+            String src = e.getFromNodeId();
+            String srcStatus = statusById.getOrDefault(src, "pending");
+            if ("AND".equalsIgnoreCase(e.getOperator())) {
+                hasAnd = true;
+                if (!"completed".equals(srcStatus)) {
+                    return false; // 任一 AND 源未完成即不触发
+                }
+            } else { // OR
+                hasOr = true;
+                if ("completed".equals(srcStatus)) {
+                    orSatisfied = true;
+                }
+            }
+        }
+        if (hasAnd && !hasOr) {
+            return true; // 纯 AND,全部完成(前面已通过 return false 检查)
+        }
+        if (!hasAnd && hasOr) {
+            return orSatisfied; // 纯 OR
+        }
+        // AND + OR 混合:AND 全完成(已通过检查)且 OR 至少一个完成
+        return true; // 到达此处说明 AND 已全完成,OR 是否有满足不影响(按 AND 优先语义)
+    }
+
+    /**
+     * 纯函数:应用循环终止规则。
+     */
+    public void applyLoopTermination(TaskOrchestrationNodeInstance ni,
+                                     JSONObject nodeCfg,
+                                     int maxLoops) {
+        if (ni.getGeneration() != null && ni.getGeneration() >= maxLoops) {
+            ni.setStatus("terminated");
+            ni.setLoopTerminationReason("max_loops_reached");
+        }
+    }
+
+    /**
+     * 事件驱动评估:节点完成/失败/超时后调用,扫描 pending 节点并触发满足条件的。
+     */
+    public void evaluateFlow(Long executionId) {
+        // 1. 加载执行实例
+        TaskOrchestrationExecution exec = executionMapper.selectById(executionId);
+        if (exec == null || !"running".equals(exec.getStatus())) {
+            return;
+        }
+        // 2. 加载该 execution 的所有节点实例
+        List<TaskOrchestrationNodeInstance> nodes = nodeInstanceMapper.selectList(
+            new LambdaQueryWrapper<TaskOrchestrationNodeInstance>()
+                .eq(TaskOrchestrationNodeInstance::getExecutionId, executionId));
+        // 3. 加载 flow 的边(此处需从 execution.flowId 关联 edges,但版本快照简化:直接读当前边表)
+        List<TaskOrchestrationEdge> edges = edgeMapper.selectList(
+            new LambdaQueryWrapper<TaskOrchestrationEdge>()
+                .eq(TaskOrchestrationEdge::getFlowId, exec.getFlowId()));
+        // 4. 对每个 pending 节点,检查入边是否满足
+        Set<String> nodeIds = nodes.stream()
+            .map(TaskOrchestrationNodeInstance::getNodeId).collect(Collectors.toSet());
+        for (String nodeId : nodeIds) {
+            List<TaskOrchestrationNodeInstance> pendingNodes = nodes.stream()
+                .filter(n -> "pending".equals(n.getStatus()) && nodeId.equals(n.getNodeId()))
+                .collect(Collectors.toList());
+            if (pendingNodes.isEmpty()) continue;
+            List<TaskOrchestrationEdge> inEdges = edges.stream()
+                .filter(e -> nodeId.equals(e.getToNodeId()))
+                .collect(Collectors.toList());
+            if (isUnlocked(nodeId, inEdges, nodes)) {
+                triggerNode(exec, pendingNodes.get(0), inEdges);
+            }
+        }
+        // 5. 判断 flow 是否完成(所有节点均非 pending/running/in_progress)
+        boolean allTerminal = nodes.stream().allMatch(n ->
+            !"pending".equals(n.getStatus())
+            && !"in_progress".equals(n.getStatus())
+            && !"started".equals(n.getStatus()));
+        if (allTerminal) {
+            exec.setStatus("completed");
+            exec.setFinishedAt(new Date());
+            executionMapper.updateById(exec);
+        }
+    }
+
+    private void triggerNode(TaskOrchestrationExecution exec,
+                             TaskOrchestrationNodeInstance ni,
+                             List<TaskOrchestrationEdge> inEdges) {
+        // 幂等:唯一索引兜底,这里先检查
+        Long existingCount = nodeInstanceMapper.selectCount(
+            new LambdaQueryWrapper<TaskOrchestrationNodeInstance>()
+                .eq(TaskOrchestrationNodeInstance::getExecutionId, ni.getExecutionId())
+                .eq(TaskOrchestrationNodeInstance::getNodeId, ni.getNodeId())
+                .eq(TaskOrchestrationNodeInstance::getGeneration, ni.getGeneration())
+                .ne(TaskOrchestrationNodeInstance::getStatus, "pending"));
+        if (existingCount != null && existingCount > 0) return;
+        // triggerNode 完整实现见任务 3(步骤 1),此处暂不展开,
+        // 任务 2 仅保留 evaluateFlow 骨架及 isUnlocked / applyLoopTermination 纯函数
+    }
+
+    // triggerNode、createTaskFromTemplate、triggerFailedEdges 等完整实现见任务 3(步骤 1)
+}
+```
+
+> **注意**:任务 2 仅定义引擎骨架(`evaluateFlow`、`isUnlocked`、`applyLoopTermination`、`onTaskCompleted`、`onTaskFailed`、`onTaskTimeout`、`findByTaskId`)。`triggerNode` 的完整实现(含 `createTaskFromTemplate` 模板字段映射)见任务 3 步骤 1。
+
+- [ ] **步骤 4:运行测试确认通过**
+
+运行:`cd cfc-backend && mvn test -Dtest=OrchestrationEngineTest`
+预期:PASS(4 个测试全部通过)。
+
+- [ ] **步骤 5:编译验证**
+
+运行:`cd cfc-backend && mvn clean compile`
+预期:BUILD SUCCESS。
+
+- [ ] **步骤 6:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/OrchestrationEngine.java \
+        cfc-backend/src/test/java/com/etotem/cfc/orchestration/OrchestrationEngineTest.java
+git commit -m "feat: 编排引擎核心(AND/OR 触发 + 循环终止 + evaluateFlow)"
+```
+
+---
+
+### 任务 3:后端 — 节点任务创建 + TaskService 回调
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/OrchestrationEngine.java` — 补全 `triggerNode`
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/TaskService.java` — 完成/失败点回调
+
+- [ ] **步骤 1:补全 triggerNode(加载模板 → 创建 tasks → 回写)**
+
+在 `OrchestrationEngine` 中补全 `triggerNode` 方法体,替换步骤 2 中的 TODO 部分。核心逻辑:
+
+```java
+private void triggerNode(TaskOrchestrationExecution exec,
+                         TaskOrchestrationNodeInstance ni,
+                         List<TaskOrchestrationEdge> inEdges) {
+    // 幂等检查(唯一索引兜底)
+    Long existingCount = nodeInstanceMapper.selectCount(
+        new LambdaQueryWrapper<TaskOrchestrationNodeInstance>()
+            .eq(TaskOrchestrationNodeInstance::getExecutionId, ni.getExecutionId())
+            .eq(TaskOrchestrationNodeInstance::getNodeId, ni.getNodeId())
+            .eq(TaskOrchestrationNodeInstance::getGeneration, ni.getGeneration())
+            .ne(TaskOrchestrationNodeInstance::getStatus, "pending"));
+    if (existingCount != null && existingCount > 0) return;
+
+    // 1. 读 flow 的 config_json,拿到节点配置
+    TaskOrchestrationFlow flow = flowMapper.selectById(exec.getFlowId());
+    JSONObject flowCfg = JSON.parseObject(flow.getConfigJson());
+    JSONObject nodeCfg = null;
+    for (Object o : flowCfg.getJSONArray("nodes")) {
+        JSONObject n = (JSONObject) o;
+        if (ni.getNodeId().equals(n.getString("id"))) {
+            nodeCfg = n;
+            break;
+        }
+    }
+    if (nodeCfg == null) {
+        ni.setStatus("failed");
+        ni.setErrorReason("节点配置不存在");
+        nodeInstanceMapper.updateById(ni);
+        return;
+    }
+
+    // 2. 加载任务模板
+    String templateRef = nodeCfg.getString("task_template_ref");
+    TaskTemplate template = taskTemplateMapper.selectById(Long.valueOf(templateRef));
+    if (template == null) {
+        ni.setStatus("failed");
+        ni.setErrorReason("任务模板不存在: " + templateRef);
+        nodeInstanceMapper.updateById(ni);
+        triggerFailedEdges(exec, ni); // 触发 failed 边
+        return;
+    }
+
+    // 3. 创建 tasks 实例(调用 TaskService.createTask(Long, CreateTaskDTO))
+    // 根据模板配置填充 DTO,关键字段映射:
+    //   title/description:从 template 读取
+    //   points:从 template.pts 读取
+    //   deadline:nodeCfg.getInteger("timeout_minutes") 对应 task.deadline
+    //   is_daily_progress:nodeCfg.boolean("is_daily_progress")
+    //   source_type/source_id:flow config_json 中的对应字段
+    //   memberOnly:template.integer("memberOnly")
+    CreateTaskDTO dto = new CreateTaskDTO();
+    dto.setTitle(template.getTitle());
+    dto.setDescription(template.getDescription());
+    dto.setPoints(template.getPoints());
+    dto.setTaskType(template.getTaskType());
+    dto.setFrequency(template.getFrequency());
+    dto.setMaxFrequency(template.getMaxFrequency());
+    dto.setNeedReview(template.getNeedReview());
+    dto.setReviewType(template.getReviewType());
+    dto.setReviewByCategory(template.getReviewByCategory());
+    dto.setCompleteTypes(template.getCompleteTypes());
+    dto.setEarliestStart(template.getEarliestStart());
+    dto.setLatestEnd(template.getLatestEnd());
+    dto.setExecutorType(nodeCfg.getString("executorType") != null ? nodeCfg.getString("executorType") : "child");
+    dto.setExecutorId(ni.getFamilyMemberId());
+    dto.setMemberId(ni.getFamilyMemberId());
+    dto.setMinigameCode(nodeCfg.getString("minigameCode"));
+    dto.setRepeatType(nodeCfg.getString("repeatType"));
+    dto.setDuration(nodeCfg.getInteger("duration"));
+    dto.setDimensionCode(nodeCfg.getString("dimensionCode"));
+    dto.setDimensionWeights(nodeCfg.getString("dimensionWeights"));
+    dto.setMemberOnly(nodeCfg.getInteger("memberOnly") != null ? nodeCfg.getInteger("memberOnly") : 0);
+    dto.setPrerequisiteTaskId(nodeCfg.getLong("prerequisiteTaskId"));
+    dto.setActionType(nodeCfg.getString("actionType"));
+    dto.setActionConfig(nodeCfg.getString("actionConfig"));
+    dto.setRequireInput(nodeCfg.getInteger("requireInput"));
+    dto.setStartRequired(nodeCfg.getInteger("startRequired"));
+    dto.setMinDurationSeconds(nodeCfg.getInteger("minDurationSeconds"));
+    dto.setSourceType(nodeCfg.getString("sourceType"));
+    dto.setSourceId(nodeCfg.getLong("sourceId"));
+    dto.setIsDailyProgress(nodeCfg.getInteger("is_daily_progress"));
+    dto.setTargetValue(nodeCfg.getInteger("targetValue"));
+    // 超时时间:deadline = now + timeout_minutes(若配置)
+    Long taskId = taskService.createTask(ni.getFamilyMemberId(), dto);
+
+    // 4. 回写 task_id + 状态
+    ni.setTaskId(taskId);
+    ni.setStatus("in_progress");
+    ni.setStartedAt(new Date());
+    nodeInstanceMapper.updateById(ni);
+}
+
+private void triggerFailedEdges(TaskOrchestrationExecution exec,
+                                TaskOrchestrationNodeInstance ni) {
+    // 触发所有 edge_type=failed 且 from_node_id=ni.nodeId 的出边目标节点(创建 pending 实例)
+    List<TaskOrchestrationEdge> failedEdges = edgeMapper.selectList(
+        new LambdaQueryWrapper<TaskOrchestrationEdge>()
+            .eq(TaskOrchestrationEdge::getFlowId, exec.getFlowId())
+            .eq(TaskOrchestrationEdge::getFromNodeId, ni.getNodeId())
+            .eq(TaskOrchestrationEdge::getEdgeType, "failed"));
+    for (TaskOrchestrationEdge e : failedEdges) {
+        // 幂等:检查同execution_id + to_node_id + generation=0 的 pending 实例是否存在
+        Long exists = nodeInstanceMapper.selectCount(
+            new LambdaQueryWrapper<TaskOrchestrationNodeInstance>()
+                .eq(TaskOrchestrationNodeInstance::getExecutionId, exec.getId())
+                .eq(TaskOrchestrationNodeInstance::getNodeId, e.getToNodeId())
+                .eq(TaskOrchestrationNodeInstance::getGeneration, 0)
+                .eq(TaskOrchestrationNodeInstance::getStatus, "pending"));
+        if (exists != null && exists > 0) continue; // 已存在则跳过
+        TaskOrchestrationNodeInstance newNi = new TaskOrchestrationNodeInstance();
+        newNi.setExecutionId(exec.getId());
+        newNi.setNodeId(e.getToNodeId());
+        newNi.setGeneration(0);
+        newNi.setStatus("pending");
+        newNi.setCreatedAt(new Date());
+        nodeInstanceMapper.insert(newNi);
+    }
+}
+```
+
+> **关键**:`triggerFailedEdges` 通过查询唯一索引 `(execution_id, node_id, generation)` 实现幂等,避免 Quartz 轮询与回调同时触发导致重复创建节点任务。`createTaskFromTemplate` 内部需调用 `TaskService.createTask(CreateTaskDTO)`,字段映射(title/description/points/needReview/deadline)参考 `TaskService` 现有模板实例化逻辑。**实施时先读取 `TaskService.createTask` 方法签名确认参数**,不可臆测。
+
+- [ ] **步骤 2:在 TaskService 完成点回调编排引擎**
+
+定位 `TaskService` 中任务完成(状态置 completed)的代码点,末尾添加(仅编排节点任务生效):
+
+```java
+@Resource
+@Lazy
+private OrchestrationEngine orchestrationEngine;
+
+// 在任务完成逻辑后:
+orchestrationEngine.onTaskCompleted(taskId);
+```
+
+在 `OrchestrationEngine` 新增回调方法:
+
+```java
+public void onTaskCompleted(Long taskId) {
+    TaskOrchestrationNodeInstance ni = findByTaskId(taskId);
+    if (ni == null) return; // 非编排节点任务,零开销
+    ni.setStatus("completed");
+    ni.setCompletedAt(new Date());
+    nodeInstanceMapper.updateById(ni);
+    evaluateFlow(ni.getExecutionId());
+}
+
+private TaskOrchestrationNodeInstance findByTaskId(Long taskId) {
+    return nodeInstanceMapper.selectOne(
+        new LambdaQueryWrapper<TaskOrchestrationNodeInstance>()
+            .eq(TaskOrchestrationNodeInstance::getTaskId, taskId));
+}
+```
+
+- [ ] **步骤 3:在 TaskService 失败点回调编排引擎**
+
+同理,在任务失败/放弃逻辑点调用 `orchestrationEngine.onTaskFailed(taskId)`,引擎实现:
+
+```java
+public void onTaskFailed(Long taskId) {
+    TaskOrchestrationNodeInstance ni = findByTaskId(taskId);
+    if (ni == null) return;
+    ni.setStatus("failed");
+    ni.setCompletedAt(new Date());
+    nodeInstanceMapper.updateById(ni);
+    // 触发 failed 边
+    TaskOrchestrationExecution exec = executionMapper.selectById(ni.getExecutionId());
+    triggerFailedEdges(exec, ni);
+    evaluateFlow(ni.getExecutionId());
+}
+```
+
+- [ ] **步骤 4:编译验证**
+
+运行:`cd cfc-backend && mvn clean compile`
+预期:BUILD SUCCESS(确认 `TaskService.createTask` 签名匹配,`flowMapper` 已注入)。
+
+- [ ] **步骤 5:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/OrchestrationEngine.java \
+        cfc-backend/src/main/java/com/etotem/cfc/service/TaskService.java
+git commit -m "feat: 节点任务创建 + TaskService 完成/失败回调编排引擎"
+```
+
+---
+
+### 任务 4:后端 — Quartz 轮询 + timeout 检查
+
+**文件:**
+- 修改:`cfc-backend/pom.xml` — 新增 Quartz 依赖
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/config/QuartzConfig.java`
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/task/OrchestrationPollingJob.java`
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/OrchestrationEngine.java` — 新增 `onTaskTimeout`
+
+- [ ] **步骤 1:pom.xml 新增 Quartz 依赖**
+
+在 `<dependencies>` 中加入:
+```xml
+<dependency>
+    <groupId>org.springframework.boot</groupId>
+    <artifactId>spring-boot-starter-quartz</artifactId>
+</dependency>
+```
+
+- [ ] **步骤 2:创建 QuartzConfig**
+
+```java
+package com.etotem.cfc.config;
+
+import org.quartz.*;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class QuartzConfig {
+
+    @Bean
+    public JobDetail orchestrationPollingJobDetail() {
+        return JobBuilder.newJob(OrchestrationPollingJob.class)
+            .withIdentity("orchestrationPollingJob")
+            .storeDurably()
+            .build();
+    }
+
+    @Bean
+    public Trigger orchestrationPollingTrigger() {
+        return TriggerBuilder.newTrigger()
+            .forJob(orchestrationPollingJobDetail())
+            .withIdentity("orchestrationPollingTrigger")
+            .withSchedule(SimpleScheduleBuilder.simpleSchedule()
+                .withIntervalInSeconds(30)
+                .repeatForever())
+            .build();
+    }
+}
+```
+
+> **注意**:`OrchestrationPollingJob` 在 `com.etotem.cfc.task` 包,`QuartzConfig` 在 `com.etotem.cfc.config` 包,需 import 对应类。
+
+- [ ] **步骤 3:创建 OrchestrationPollingJob**
+
+```java
+package com.etotem.cfc.task;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.Task;
+import com.etotem.cfc.entity.TaskOrchestrationExecution;
+import com.etotem.cfc.entity.TaskOrchestrationNodeInstance;
+import com.etotem.cfc.mapper.TaskMapper;
+import com.etotem.cfc.mapper.TaskOrchestrationExecutionMapper;
+import com.etotem.cfc.mapper.TaskOrchestrationNodeInstanceMapper;
+import com.etotem.cfc.service.OrchestrationEngine;
+import lombok.extern.slf4j.Slf4j;
+import org.quartz.DisallowConcurrentExecution;
+import org.quartz.Job;
+import org.quartz.JobExecutionContext;
+
+import javax.annotation.Resource;
+import java.util.Date;
+import java.util.List;
+
+@Slf4j
+@DisallowConcurrentExecution
+public class OrchestrationPollingJob implements Job {
+
+    @Resource
+    private TaskOrchestrationExecutionMapper executionMapper;
+    @Resource
+    private TaskOrchestrationNodeInstanceMapper nodeInstanceMapper;
+    @Resource
+    private TaskMapper taskMapper;
+    @Resource
+    private OrchestrationEngine orchestrationEngine;
+
+    @Override
+    public void execute(JobExecutionContext context) {
+        log.info("编排轮询开始...");
+        try {
+            // 1. 查询所有 running 状态的 execution(每次最多 20 个)
+            List<TaskOrchestrationExecution> executions = executionMapper.selectList(
+                new LambdaQueryWrapper<TaskOrchestrationExecution>()
+                    .eq(TaskOrchestrationExecution::getStatus, "running")
+                    .last("LIMIT 20"));
+            for (TaskOrchestrationExecution exec : executions) {
+                // 2. 检查 timeout 节点(deadline 到期)
+                checkTimeouts(exec);
+                // 3. 评估 pending 节点
+                orchestrationEngine.evaluateFlow(exec.getId());
+            }
+        } catch (Exception e) {
+            log.error("编排轮询异常", e);
+        }
+        log.info("编排轮询结束");
+    }
+
+    private void checkTimeouts(TaskOrchestrationExecution exec) {
+        List<TaskOrchestrationNodeInstance> nodes = nodeInstanceMapper.selectList(
+            new LambdaQueryWrapper<TaskOrchestrationNodeInstance>()
+                .eq(TaskOrchestrationNodeInstance::getExecutionId, exec.getId())
+                .eq(TaskOrchestrationNodeInstance::getStatus, "in_progress"));
+        Date now = new Date();
+        for (TaskOrchestrationNodeInstance ni : nodes) {
+            if (ni.getTaskId() == null) continue;
+            Task task = taskMapper.selectById(ni.getTaskId());
+            if (task != null && task.getDeadline() != null && task.getDeadline().before(now)
+                && !"completed".equals(task.getStatus())) {
+                orchestrationEngine.onTaskTimeout(ni.getTaskId());
+            }
+        }
+    }
+}
+```
+
+> **注意**:Quartz 的 `Job` 实例由 Quartz 容器管理,`@Resource` 注入需要 `SpringBeanJobFactory` 支持。若注入失败,需在 `QuartzConfig` 配置 `SpringBeanJobFactory`(让 Quartz 使用 Spring 容器创建 Job 实例)。**实施时优先验证 `@Resource` 是否生效,不行则加 `SpringBeanJobFactory` bean。**
+
+- [ ] **步骤 4:OrchestrationEngine 新增 onTaskTimeout**
+
+```java
+public void onTaskTimeout(Long taskId) {
+    TaskOrchestrationNodeInstance ni = findByTaskId(taskId);
+    if (ni == null) return;
+    ni.setStatus("timeout");
+    ni.setCompletedAt(new Date());
+    nodeInstanceMapper.updateById(ni);
+    TaskOrchestrationExecution exec = executionMapper.selectById(ni.getExecutionId());
+    // 触发 timeout 边(兜底)
+    List<TaskOrchestrationEdge> timeoutEdges = edgeMapper.selectList(
+        new LambdaQueryWrapper<TaskOrchestrationEdge>()
+            .eq(TaskOrchestrationEdge::getFlowId, exec.getFlowId())
+            .eq(TaskOrchestrationEdge::getFromNodeId, ni.getNodeId())
+            .eq(TaskOrchestrationEdge::getEdgeType, "timeout"));
+    for (TaskOrchestrationEdge e : timeoutEdges) {
+        // 幂等:检查同execution_id + to_node_id + generation=0 的 pending 实例是否存在
+        Long exists = nodeInstanceMapper.selectCount(
+            new LambdaQueryWrapper<TaskOrchestrationNodeInstance>()
+                .eq(TaskOrchestrationNodeInstance::getExecutionId, exec.getId())
+                .eq(TaskOrchestrationNodeInstance::getNodeId, e.getToNodeId())
+                .eq(TaskOrchestrationNodeInstance::getGeneration, 0)
+                .eq(TaskOrchestrationNodeInstance::getStatus, "pending"));
+        if (exists != null && exists > 0) continue; // 已存在则跳过
+        TaskOrchestrationNodeInstance newNi = new TaskOrchestrationNodeInstance();
+        newNi.setExecutionId(exec.getId());
+        newNi.setNodeId(e.getToNodeId());
+        newNi.setGeneration(0);
+        newNi.setStatus("pending");
+        newNi.setCreatedAt(new Date());
+        nodeInstanceMapper.insert(newNi);
+    }
+    evaluateFlow(exec.getId());
+}
+```
+
+- [ ] **步骤 5:编译验证**
+
+运行:`cd cfc-backend && mvn clean compile`
+预期:BUILD SUCCESS。
+
+- [ ] **步骤 6:Commit**
+
+```bash
+git add cfc-backend/pom.xml \
+        cfc-backend/src/main/java/com/etotem/cfc/config/QuartzConfig.java \
+        cfc-backend/src/main/java/com/etotem/cfc/task/OrchestrationPollingJob.java \
+        cfc-backend/src/main/java/com/etotem/cfc/service/OrchestrationEngine.java
+git commit -m "feat: Quartz 轮询 + timeout 检查 + 兜底边触发"
+```
+
+---
+
+### 任务 5:后端 — Flow/Execution Service + Controller
+
+**文件:**
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/service/OrchestrationFlowService.java`
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/service/OrchestrationExecutionService.java`
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/controller/OrchestrationController.java`
+
+- [ ] **步骤 1:创建 OrchestrationFlowService(Flow CRUD)**
+
+按设计文档 §7.1 实现 6 个方法:`save` / `publish` / `list` / `detail` / `archive` / `delete`。核心逻辑(`save` 保存 config_json + edges 快照,`publish` 状态转换 + version+1)。
+
+- [ ] **步骤 2:创建 OrchestrationExecutionService(Execution 生命周期)**
+
+按 §7.2 实现:`start`(创建 execution + start_node 触发)/ `pause` / `resume` / `terminate` / `detail` / `list`。`start` 方法:
+
+```java
+@Transactional
+public Result<Map<String, Object>> start(Long flowId, Long familyMemberId, Long userId) {
+    TaskOrchestrationFlow flow = flowMapper.selectById(flowId);
+    if (flow == null) return Result.error("编排流不存在");
+    if (!"published".equals(flow.getStatus())) return Result.error("编排流未发布");
+
+    // 创建执行实例
+    TaskOrchestrationExecution exec = new TaskOrchestrationExecution();
+    exec.setFlowId(flowId);
+    exec.setFlowVersion(flow.getVersion());
+    exec.setFamilyMemberId(familyMemberId);
+    exec.setTriggerSource("manual");
+    exec.setStatus("running");
+    exec.setStartedAt(new Date());
+    executionMapper.insert(exec);
+
+    // 读取 config_json,创建 start_node 的 pending 实例
+    JSONObject cfg = JSON.parseObject(flow.getConfigJson());
+    for (Object o : cfg.getJSONArray("nodes")) {
+        JSONObject n = (JSONObject) o;
+        TaskOrchestrationNodeInstance ni = new TaskOrchestrationNodeInstance();
+        ni.setExecutionId(exec.getId());
+        ni.setNodeId(n.getString("id"));
+        ni.setGeneration(0);
+        if (n.getBooleanValue("is_start_node")) {
+            ni.setStatus("pending"); // 交由 evaluateFlow 触发
+        } else {
+            ni.setStatus("pending");
+        }
+        ni.setCreatedAt(new Date());
+        nodeInstanceMapper.insert(ni);
+    }
+
+    // 触发 start_node
+    orchestrationEngine.evaluateFlow(exec.getId());
+
+    Map<String, Object> data = new HashMap<>();
+    data.put("executionId", exec.getId());
+    return Result.success(data);
+}
+```
+
+> **注意**:`familyMemberId` 对应 `FamilyMember.id`(主键),在 `start` 方法中已直接注入,无需额外反查。若需关联家庭属性(如 `familyId`、`isAdmin` 等),可通过 `FamilyMemberMapper.selectById(familyMemberId)` 实现。
+
+- [ ] **步骤 3:创建 OrchestrationController**
+
+统一 `@PostMapping`,路由前缀 `/api/orchestration`,注入两个 Service + 手动检查角色(`@RequestAttribute("role")`)。实现 §7.1/§7.2/§7.3 全部接口,含 `/node/fail`、`/node/restart`、`/node/condition-met`。
+
+- [ ] **步骤 4:编译验证**
+
+运行:`cd cfc-backend && mvn clean compile`
+预期:BUILD SUCCESS。
+
+- [ ] **步骤 5:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/OrchestrationFlowService.java \
+        cfc-backend/src/main/java/com/etotem/cfc/service/OrchestrationExecutionService.java \
+        cfc-backend/src/main/java/com/etotem/cfc/controller/OrchestrationController.java
+git commit -m "feat: Flow/Execution Service + OrchestrationController REST 接口"
+```
+
+---
+
+### 任务 6:后端 — 集成测试 + 全链路验证
+
+**文件:**
+- 创建:`cfc-backend/src/test/java/com/etotem/cfc/orchestration/OrchestrationIntegrationTest.java`
+
+- [ ] **步骤 1:编写集成测试(完整流执行)**
+
+覆盖:start → node1 完成 → node2 触发 → node3 超时 → 兜底 node4。用真实 Mapper(测试库)验证幂等(回调 + 轮询不重复建任务)。
+
+- [ ] **步骤 2:运行全量测试**
+
+运行:`cd cfc-backend && mvn test`
+预期:PASS(含新增集成测试)。
+
+- [ ] **步骤 3:启动服务手动验证**
+
+运行:`cd cfc-backend && mvn spring-boot:run`(确认无 `Unknown column` 报错,4 张表迁移成功)。
+
+- [ ] **步骤 4:Commit**
+
+```bash
+git add cfc-backend/src/test/java/com/etotem/cfc/orchestration/OrchestrationIntegrationTest.java
+git commit -m "test: 编排引擎集成测试(完整流 + 幂等)"
+```
+
+---
+
+### 任务 7:前端 — 流列表 + 画布 + 执行详情
+
+**文件:**
+- 创建:`cfc-web/src/views/orchestration/OrchestrationFlow.vue`
+- 创建:`cfc-web/src/views/orchestration/FlowEditor.vue`
+- 创建:`cfc-web/src/views/orchestration/ExecutionDetail.vue`
+- 创建:`cfc-web/src/api/orchestration.js`
+- 修改:`cfc-web/src/router/index.js`
+- 修改:`cfc-web/package.json` — 新增 jsplumb
+
+- [ ] **步骤 1:安装 jsplumb + 封装 API**
+
+```bash
+cd cfc-web && npm install jsplumb --save
+```
+
+`cfc-web/src/api/orchestration.js`:封装 12 个接口(flow 6 个 + execution 6 个)。
+
+- [ ] **步骤 2:创建 OrchestrationFlow.vue(列表)**
+
+Element UI 表格:流名称、版本、状态、创建时间、操作(编辑/发布/归档/删除/启动执行)。参考现有管理端列表页模式。
+
+- [ ] **步骤 3:创建 FlowEditor.vue(jsPlumb 画布)**
+
+- 左侧:可拖拽任务模板列表(`admin_task_templates` 接口)
+- 中央:jsPlumb 画布(节点拖拽 + 连线 + 删除连线)
+- 右侧:属性面板(超时时间、max_loops、终止条件、AND/OR 边类型)
+- 工具栏:保存草稿 / 发布(发布前校验孤立节点、环必须有终止条件、task_template_ref 存在、有且仅一个 start_node)
+
+- [ ] **步骤 4:创建 ExecutionDetail.vue(只读树)**
+
+树形展示节点实例状态 + 关联 tasks 记录 + 手动干预(标记失败/终止流/重启节点/条件达成)。
+
+- [ ] **步骤 5:注册路由**
+
+`cfc-web/src/router/index.js` 新增:
+```js
+{ path: '/orchestration', component: OrchestrationFlow },
+{ path: '/orchestration/edit/:id', component: FlowEditor },
+{ path: '/orchestration/execution/:id', component: ExecutionDetail }
+```
+
+- [ ] **步骤 6:前端构建验证**
+
+运行:`cd cfc-web && npm run build`
+预期:构建成功。
+
+- [ ] **步骤 7:Commit**
+
+```bash
+git add cfc-web/src/views/orchestration/ cfc-web/src/api/orchestration.js \
+        cfc-web/src/router/index.js cfc-web/package.json cfc-web/package-lock.json
+git commit -m "feat: 任务编排管理端(列表 + jsPlumb 画布 + 执行详情)"
+```
+
+---
+
+## 范围边界(本计划明确不做)
+
+| 不做 | 原因 |
+|------|------|
+| 小程序端编排编辑 | 编排编辑只放 cfc-web 管理端 |
+| 流市场/模板市场 | 后续迭代 |
+| 节点级并行执行 | 一个节点同一时刻只一个任务实例 |
+| 家庭挑战/五维打卡合并 | 已有设计文档明确不合并 |
+| flow 配置的版本快照隔离(edges 版本化) | 当前 edges 表读最新,版本隔离仅对 execution 记录 flow_version;后续如需精确快照再补 edges 版本列 |
+
+---
+
+## 验收标准
+
+- [ ] 4 张新表创建成功,DDL 幂等可重复执行
+- [ ] Flow CRUD 6 个接口通过
+- [ ] Execution 6 个接口通过(含 start 触发 start_node)
+- [ ] AND/OR 前置语义正确(单元测试覆盖)
+- [ ] count/condition 循环终止正确(单元测试覆盖)
+- [ ] timeout/failed 兜底边触发正确
+- [ ] 幂等:回调 + 轮询不重复创建任务(集成测试覆盖)
+- [ ] Quartz 30 秒轮询生效
+- [ ] 前端画布可拖拽/连线/编辑/发布校验
+- [ ] 执行详情树形展示正确
+- [ ] 通过 `mvn clean compile` 和 `npm run build`

+ 429 - 0
docs/superpowers/specs/2026-09-19-task-orchestration-design.md

@@ -0,0 +1,429 @@
+# 任务编排系统设计
+
+**优先级:** P1
+**预计工时:** 后端 5d + 前端 3d
+**状态:** 设计已确认(待写实现计划)
+**日期:** 2026-09-19
+
+## 1. 背景与问题
+
+现有任务系统(`tasks` 表)已支持单前置(`prerequisite_task_id`)、循环(`repeat_type`)、父链(`parent_task_id`),但缺少:
+
+- **多前置 AND/OR**:「数学+英语+阅读三项全部完成后,才解锁奖励任务」
+- **多前置 OR**:「数学或英语任一完成后,即可开始下一项」
+- **后置兜底**:「若晨读在 30 分钟内未完成,自动派发补救任务」
+- **带终止条件的循环**:「晨读最多重试 3 次,3 次仍未完成则标记失败并触发后续计划」
+
+这些能力需要**新增上层编排模块**,不修改现有 `tasks` 表结构,复用现有 `TaskService` 的创建/进度/积分体系。
+
+---
+
+## 2. 核心设计决策(已与用户确认)
+
+| 决策项 | 结论 |
+|--------|------|
+| 驱动场景 | 家庭/成长任务的条件编排 |
+| 最大复杂度 | 含环 DAG |
+| 前置语义 | AND(全部) + OR(任一)均支持 |
+| 未完成语义 | 超时未按时完成 + 主动标记失败,均支持 |
+| 流定义方式 | UI 可视化编排(jsPlumb 画布) |
+| 节点与 tasks 关系 | 节点执行时创建 `tasks` 实例,复用现有进度/积分体系 |
+| 循环终止 | 计数终止 + 条件终止两者都支持 |
+| 调度模型 | 引入 Quartz 独立轮询,不依赖 `@Scheduled` |
+| 内部回调范围 | 仅编排节点任务回调,普通任务零开销 |
+| 画布选型 | jsPlumb(Element UI 兼容,vue2 有现成封装) |
+| 版本管理 | 执行实例快照旧版本,新执行用新版本 |
+| 并发保护 | 乐观锁 + 幂等检查 |
+
+---
+
+## 3. 数据模型
+
+### 3.1 `task_orchestration_flows`(编排流定义)
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `id` | BIGINT PK | |
+| `name` | VARCHAR(100) | 流名称,如「晨读打卡补救流」 |
+| `description` | TEXT | 描述 |
+| `creator_id` | BIGINT | 创建者(规划师/家长) |
+| `family_id` | BIGINT | 所属家庭(null = 系统级公共模板) |
+| `version` | INT | 版本号,每次 publish 递增 |
+| `status` | VARCHAR(20) | `draft` / `published` / `archived` |
+| `schedule_cron` | VARCHAR(64) | 定时触发 cron(可为空,仅手动触发时为空) |
+| `config_json` | JSON | 流的核心配置(节点列表 + 边的初始快照,用于审计和回溯) |
+| `created_at` / `updated_at` | DATETIME | |
+
+### 3.2 `task_orchestration_edges`(依赖边)
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `id` | BIGINT PK | |
+| `flow_id` | BIGINT | 所属流(FK → task_orchestration_flows) |
+| `from_node_id` | VARCHAR(64) | 源节点 ID(与 config_json.nodes[].id 对应) |
+| `to_node_id` | VARCHAR(64) | 目标节点 ID |
+| `edge_type` | VARCHAR(20) | `success` / `timeout` / `failed` |
+| `operator` | VARCHAR(10) | `AND` / `OR` |
+| `sort_order` | INT | 同 to_node 多条边的排序权重 |
+| `created_at` | DATETIME | |
+
+### 3.3 `task_orchestration_executions`(执行实例)
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `id` | BIGINT PK | |
+| `flow_id` | BIGINT | 所属流(FK) |
+| `flow_version` | INT | 触发时对应的流版本号(用于版本快照隔离) |
+| `family_id` | BIGINT | 所属家庭 |
+| `family_member_id` | BIGINT | 绑定的家庭成员 |
+| `trigger_source` | VARCHAR(20) | `manual` / `scheduled` / `api` |
+| `status` | VARCHAR(20) | `running` / `paused` / `completed` / `failed` / `terminated` |
+| `started_at` / `finished_at` | DATETIME | |
+| `error_reason` | VARCHAR(255) | 终止原因(terminated 时记录) |
+| `created_at` / `updated_at` | DATETIME | |
+
+### 3.4 `task_orchestration_node_instances`(节点执行实例)
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `id` | BIGINT PK | |
+| `execution_id` | BIGINT | 所属执行实例(FK) |
+| `node_id` | VARCHAR(64) | 对应 flow 中的节点 ID |
+| `task_id` | BIGINT | 关联现有 `tasks.id`(创建后写入,pending 时为 null) |
+| `status` | VARCHAR(20) | `pending` / `started` / `in_progress` / `completed` / `failed` / `timeout` / `skipped` / `terminated` |
+| `generation` | INT | 循环次数(首次 = 0,重试 = 1, 2...) |
+| `condition_met_at` | DATETIME | 条件终止:外部 API 通知条件达成的时间(null = 未达成) |
+| `loop_termination_reason` | VARCHAR(50) | `max_loops_reached` / `condition_met` / 空(未终止) |
+| `started_at` / `completed_at` | DATETIME | |
+| `error_reason` | VARCHAR(255) | 失败原因(可选) |
+| `created_at` / `updated_at` | DATETIME | |
+
+**唯一索引**:`(execution_id, node_id, generation)` — 防止重复创建。
+
+---
+
+## 4. 节点 config_json 结构
+
+每个节点的完整配置(存储在 `flows.config_json.nodes[].config`):
+
+```json
+{
+  "id": "node_1",
+  "title": "晨读打卡",
+  "description": "每日晨读 15 分钟",
+  "task_template_ref": "tmpl_123",
+  "is_start_node": true,
+  "timeout_minutes": 30,
+  "max_loops": 3,
+  "loop_termination": {
+    "type": "count",   // count | condition
+    "condition_flag": null  // type=condition 时指定外部检查标识(在 node_instance 的 condition_met_at 字段中置值),由 API `/api/orchestration/node/condition-met` 置值
+  },
+  "retry_on_failure": false
+}
+```
+
+---
+
+## 5. 执行引擎核心机制
+
+### 5.1 节点触发规则(每次评估时重新计算)
+
+对每个 `pending` 节点,检查其所有入边:
+
+| operator | 触发条件 | 示例 |
+|----------|---------|------|
+| `AND` | 所有入边源节点均为 `completed` | A AND B → C(A、B 都完成才触发 C) |
+| `OR` | 任一入边源节点为 `completed` | A OR B → C(A 或 B 任一完成就触发 C) |
+
+无入边的节点(`is_start_node = true`)在 flow 启动时直接触发。
+
+### 5.2 循环处理
+
+- 节点被重新触发时,`generation++`
+- **计数终止**:若 `generation >= max_loops`,节点状态置为 `terminated`,`loop_termination_reason = max_loops_reached`,触发所有 `edge_type=timeout` 的出边(兜底)
+- **条件终止**:节点配置 `loop_termination.type = condition` 时,引擎每次轮询检查当前 generation 节点实例的 `condition_met_at` 是否已置值:
+  - 已置值 → 节点置 `completed`,`loop_termination_reason = condition_met`,触发所有 `edge_type=success` 的出边
+  - 未置值且 `generation >= max_loops` → 置 `terminated`,`loop_termination_reason = max_loops_reached`,触发 timeout 出边
+- 环中其他节点不受影响,继续正常评估
+
+### 5.3 后置兜底触发
+
+| 触发来源 | 节点状态变化 | 边类型 | 处理 |
+|----------|-------------|--------|------|
+| deadline 到期且未完成 | `pending` → `timeout` | `edge_type=timeout` | 创建目标节点任务实例 |
+| 用户手动标记放弃 | `pending` → `failed` | `edge_type=failed` | 创建目标节点任务实例 |
+| 异常(如任务模板不存在) | `pending` → `failed` | `edge_type=failed` | 同上 |
+
+节点标记为 `skipped` 时(flow 终止/暂停),不触发任何出边。
+
+### 5.4 节点创建任务逻辑
+
+```
+当节点触发时:
+  1. 从 task_template_ref 加载任务模板配置
+  2. 调用 TaskService.createTask() 创建 tasks 记录
+  3. 更新 node_instance.task_id = tasks.id
+  4. 设置 node_instance.status = "in_progress"
+  5. deadline = now() + timeout_minutes
+```
+
+### 5.5 内部回调接口(供 TaskService 调用)
+
+三个方法仅对属于编排节点的任务生效(先查 `task_orchestration_node_instances.task_id = taskId`,找不到则直接返回,零开销):
+
+| 方法 | 时机 | 效果 |
+|------|------|------|
+| `onTaskCompleted(Long taskId)` | 任务完成/审核通过 | 节点 → completed,评估下游 |
+| `onTaskFailed(Long taskId)` | 用户主动放弃/异常 | 节点 → failed,触发 failed 边 |
+| `onTaskTimeout(Long taskId)` | deadline 到期未完成 | 节点 → timeout,触发 timeout 边 |
+
+### 5.6 并发安全
+
+- `evaluateFlow(executionId)` 对同一个 execution_id 加分布式锁(Redis 或数据库行锁),避免 Quartz 轮询与回调同时触发导致重复创建节点任务
+- 节点实例唯一索引 `(execution_id, node_id, generation)` 作为最终防线
+
+---
+
+## 6. 调度与轮询
+
+### 6.1 Quartz Job:OrchestrationPollingJob
+
+```java
+@DisallowConcurrentExecution
+public class OrchestrationPollingJob implements Job {
+    @Override
+    public void execute(JobExecutionContext context) {
+        // 1. 查询所有 running 状态的 execution
+        // 2. 批量检查 timeout 节点(deadline 到期)
+        // 3. 批量调用 evaluateFlow 评估 pending 节点
+        // 4. 检查 condition 终止节点的条件是否达成
+        // 5. 更新 execution 状态(若所有节点 completed/skipped 则置 completed)
+    }
+}
+```
+
+- **触发频率**:每 30 秒执行一次(通过 Quartz TriggerBuilder 配置)
+- **@DisallowConcurrentExecution**:同一 job 实例不并发执行
+- **执行窗口**:每次执行最多处理 N 个 execution(默认 20),避免单次执行过长
+
+### 6.2 事件驱动(补充轮询)
+
+除 Quartz 轮询外,以下节点状态变更**即时触发**评估:
+- `onTaskCompleted` / `onTaskFailed` 回调(事件驱动,无需等待下一个轮询周期)
+- 手动 `node/fail` 接口调用
+
+---
+
+## 7. API 设计
+
+统一 `@PostMapping`,路由前缀 `/api/orchestration`。
+
+### 7.1 Flow 管理
+
+| 接口 | 说明 | 请求体关键字段 |
+|------|------|--------------|
+| `/api/orchestration/flow/save` | 保存/更新流(草稿) | name, description, family_id, config_json, schedule_cron |
+| `/api/orchestration/flow/publish` | draft → published,version+1 | flow_id |
+| `/api/orchestration/flow/list` | 分页列表 | page, pageSize, familyId, status |
+| `/api/orchestration/flow/detail` | 流详情(含 edges) | flow_id |
+| `/api/orchestration/flow/archive` | published → archived | flow_id |
+| `/api/orchestration/flow/delete` | 删除 draft 流 | flow_id |
+
+### 7.2 执行管理
+
+| 接口 | 说明 | 请求体关键字段 |
+|------|------|--------------|
+| `/api/orchestration/execution/start` | 手动启动流 | flow_id, family_member_id |
+| `/api/orchestration/execution/pause` | 暂停执行 | execution_id |
+| `/api/orchestration/execution/resume` | 恢复执行 | execution_id |
+| `/api/orchestration/execution/terminate` | 终止整个流 | execution_id, reason |
+| `/api/orchestration/execution/detail` | 执行详情(节点树) | execution_id |
+| `/api/orchestration/execution/list` | 执行历史列表 | family_member_id, page, pageSize |
+
+### 7.3 节点管理
+
+| 接口 | 说明 | 请求体关键字段 |
+|------|------|--------------|
+| `/api/orchestration/node/fail` | 手动标记节点为失败 | node_instance_id, reason |
+| `/api/orchestration/node/restart` | 重启节点(生成新任务实例,generation 从 0 重置) | node_instance_id, reason |
+| `/api/orchestration/node/condition-met` | 通知条件达成(type=condition 循环节点专用) | node_instance_id |
+
+### 7.4 内部回调(非 HTTP,Service 直接调用)
+
+见 §5.5。
+
+---
+
+## 8. 前端设计(cfc-web 管理端)
+
+### 8.1 页面路由
+
+- `/orchestration` — 编排流管理首页(列表 + 新建入口)
+- `/orchestration/edit/:id` — 编排画布编辑器
+- `/orchestration/execution/:id` — 执行详情只读页
+
+### 8.2 编排画布(FlowEditor.vue)
+
+- **左侧节点面板**:可拖拽的任务节点模板列表(从 `admin_task_templates` 读取)
+- **中央画布**:jsPlumb 实现,支持节点拖拽、连线、删除连线
+- **右侧属性面板**:选中节点/边后编辑配置(超时时间、max_loops、终止条件、AND/OR 等)
+- **发布校验**:
+  - 无孤立节点
+  - 每条出边都有目标节点
+  - 有环的流必须有 max_loops ≥ 1 或 condition 终止配置
+  - 节点引用的 task_template_ref 必须存在
+  - 有且仅有 1 个 `is_start_node = true`
+
+### 8.3 执行详情(ExecutionDetail.vue)
+
+- 树形展示所有节点实例,每节点显示:状态徽章 + 关联 tasks 记录 + 创建时间
+- 时间线视图:按时间轴展示节点完成顺序
+- 手动干预:标记失败、终止流
+
+---
+
+## 9. 错误处理与边界情况
+
+| 场景 | 处理策略 |
+|------|----------|
+| 节点引用的任务模板被删除 | 发布校验拦截;运行时节点标记 `failed`,触发 `failed` 边 |
+| 环内节点无限重试 | `generation >= max_loops` 强制终止,标记 `terminated`,触发 timeout 兜底 |
+| 用户终止执行 | 所有 pending 节点标记 `skipped`;运行中 tasks 保留不删除,积分只发已完成的 |
+| flow 重新发布(版本升级) | 运行中的执行继续使用旧版本快照(`flow_version` 字段隔离);新执行用新版本 |
+| 家庭删除/成员离开 | 执行实例级联终止,清理 pending 节点 |
+| 并发评估 | Quartz job + Redis 锁,`evaluateFlow` 方法级锁 |
+| 失败重试 | 节点失败不自动重试;手动调用 `/api/orchestration/node/restart` 接口(重置 generation=0,重新创建 task),或调用 `/api/orchestration/node/condition-met` 通知条件达成(type=condition 循环) |
+
+---
+
+## 10. 测试策略
+
+### 10.1 单元测试(JUnit)
+
+- `OrchestrationEngine.evaluateFlow()`:覆盖 AND/OR 语义、无前置节点、多入边混合
+- 循环终止:count 终止(max_loops 精确值)、condition 终止(flag 提前/延迟达成)
+- 幂等:同时触发两个回调时不重复创建节点任务
+
+### 10.2 集成测试
+
+- 完整流执行:start → node1 完成 → node2 触发 → node3 超时 → 兜底 node4 完成 → flow 结束
+- 版本隔离:execution_v1 运行中时 publish v2,v1 不受影响
+- 并发测试:模拟 2 个回调同时到达,验证只有 1 次任务创建
+
+### 10.3 前端测试
+
+- 画布拖拽连线正确性(jsPlumb 事件绑定)
+- 发布校验拦截无效配置
+
+---
+
+## 11. 数据库迁移
+
+在 `DatabaseInitializer.runMigrations()` 新增迁移:
+
+```sql
+CREATE TABLE IF NOT EXISTS `task_orchestration_flows` (
+  `id` bigint NOT NULL AUTO_INCREMENT,
+  `name` varchar(100) NOT NULL,
+  `description` text,
+  `creator_id` bigint NOT NULL,
+  `family_id` bigint DEFAULT NULL,
+  `version` int NOT NULL DEFAULT 1,
+  `status` varchar(20) NOT NULL DEFAULT 'draft',
+  `schedule_cron` varchar(64) DEFAULT NULL,
+  `config_json` json DEFAULT NULL,
+  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  KEY `idx_family_id` (`family_id`),
+  KEY `idx_status` (`status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `task_orchestration_edges` (
+  `id` bigint NOT NULL AUTO_INCREMENT,
+  `flow_id` bigint NOT NULL,
+  `from_node_id` varchar(64) NOT NULL,
+  `to_node_id` varchar(64) NOT NULL,
+  `edge_type` varchar(20) NOT NULL,
+  `operator` varchar(10) NOT NULL DEFAULT 'AND',
+  `sort_order` int NOT NULL DEFAULT 0,
+  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  KEY `idx_flow_id` (`flow_id`),
+  UNIQUE KEY `uk_flow_edge` (`flow_id`, `from_node_id`, `to_node_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `task_orchestration_executions` (
+  `id` bigint NOT NULL AUTO_INCREMENT,
+  `flow_id` bigint NOT NULL,
+  `flow_version` int NOT NULL DEFAULT 1,
+  `family_id` bigint NOT NULL,
+  `family_member_id` bigint NOT NULL,
+  `trigger_source` varchar(20) NOT NULL DEFAULT 'manual',
+  `status` varchar(20) NOT NULL DEFAULT 'running',
+  `started_at` datetime NOT NULL,
+  `finished_at` datetime DEFAULT NULL,
+  `error_reason` varchar(255) DEFAULT NULL,
+  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  KEY `idx_execution_flow` (`flow_id`),
+  KEY `idx_execution_status` (`status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `task_orchestration_node_instances` (
+  `id` bigint NOT NULL AUTO_INCREMENT,
+  `execution_id` bigint NOT NULL,
+  `node_id` varchar(64) NOT NULL,
+  `task_id` bigint DEFAULT NULL,
+  `status` varchar(20) NOT NULL DEFAULT 'pending',
+  `generation` int NOT NULL DEFAULT 0,
+  `condition_met_at` datetime DEFAULT NULL COMMENT '条件终止:外部API通知条件达成时间',
+  `loop_termination_reason` varchar(50) DEFAULT NULL,
+  `started_at` datetime DEFAULT NULL,
+  `completed_at` datetime DEFAULT NULL,
+  `error_reason` varchar(255) DEFAULT NULL,
+  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_node_instance` (`execution_id`, `node_id`, `generation`),
+  KEY `idx_execution_id` (`execution_id`),
+  KEY `idx_task_id` (`task_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+```
+
+同步更新 `schema.sql` 建表语句。
+
+---
+
+## 12. 范围边界(明确不做)
+
+| 不做 | 原因 |
+|------|------|
+| 家庭挑战/五维打卡合并进编排 | 已有设计文档明确不合并 |
+| 前端小程序端编排编辑 | 编排编辑只放 cfc-web 管理端;小程序端只做执行结果展示(复用 tasks 列表) |
+| 节点级精确 cron 定时 | 只用 flow 级 `schedule_cron`,节点触发完全由依赖边决定 |
+| 跨家庭共享执行实例 | 执行实例严格绑定 family,系统级 flow 仅用于多个家庭复用定义 |
+| 流市场/模板市场 | 后续迭代 |
+| 节点级并行执行(同时创建多个任务) | 当前版本不支持,一个节点同一时刻只有一个任务实例 |
+
+---
+
+## 13. 验收标准
+
+- [ ] 4 张新表创建成功,DDL 幂等可重复执行
+- [ ] Flow CRUD 接口全部通过(save/publish/list/detail/archive/delete)
+- [ ] 手动启动 flow 后,start_node 正确创建 tasks 实例
+- [ ] AND 前置语义:两个前置节点都完成才触发下游
+- [ ] OR 前置语义:任一前置节点完成即触发下游
+- [ ] timeout 边:节点 deadline 到期自动触发兜底节点
+- [ ] failed 边:手动标记失败后触发兜底节点
+- [ ] count 循环终止:generation 达到 max_loops 后节点终止并触发 timeout 边
+- [ ] condition 循环终止:条件达成节点标记 completed;未达成到达 max_loops 则终止
+- [ ] 版本隔离:v1 flow 的执行在 v2 发布后不受影响
+- [ ] 并发安全:2 个回调同时到达,节点任务不重复创建
+- [ ] Quartz job 30 秒轮询有效(execution 中 pending 节点被正确评估)
+- [ ] 前端画布可拖拽节点、连线、编辑属性、发布校验
+- [ ] 执行详情树形展示正确
+- [ ] 通过 `mvn clean compile` 编译验证