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

Merge branch 'cfclub' of http://git.iwintrue.com/liaoxg/cfc into cfclub

E2E Test Bot 1 день назад
Родитель
Сommit
915136596b
61 измененных файлов с 7034 добавлено и 31 удалено
  1. 6 0
      cfc-backend/pom.xml
  2. 164 1
      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. 6 1
      cfc-backend/src/main/java/com/etotem/cfc/controller/HealthExerciseController.java
  5. 7 1
      cfc-backend/src/main/java/com/etotem/cfc/controller/HealthMealController.java
  6. 51 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/HomeController.java
  7. 199 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/OrchestrationController.java
  8. 58 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminGuideConfigController.java
  9. 46 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/config/GuideConfigController.java
  10. 6 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/HealthExerciseRecord.java
  11. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/HealthMealRecord.java
  12. 31 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/TaskOrchestrationEdge.java
  13. 39 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/TaskOrchestrationExecution.java
  14. 37 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/TaskOrchestrationFlow.java
  15. 41 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/TaskOrchestrationNodeInstance.java
  16. 25 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/UserAppUsage.java
  17. 29 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/UserShortcutConfig.java
  18. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/TaskOrchestrationEdgeMapper.java
  19. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/TaskOrchestrationExecutionMapper.java
  20. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/TaskOrchestrationFlowMapper.java
  21. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/TaskOrchestrationNodeInstanceMapper.java
  22. 12 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/UserAppUsageMapper.java
  23. 13 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/UserShortcutConfigMapper.java
  24. 98 0
      cfc-backend/src/main/java/com/etotem/cfc/service/HomeShortcutService.java
  25. 457 0
      cfc-backend/src/main/java/com/etotem/cfc/service/OrchestrationEngine.java
  26. 172 0
      cfc-backend/src/main/java/com/etotem/cfc/service/OrchestrationExecutionService.java
  27. 166 0
      cfc-backend/src/main/java/com/etotem/cfc/service/OrchestrationFlowService.java
  28. 11 0
      cfc-backend/src/main/java/com/etotem/cfc/service/TaskService.java
  29. 84 0
      cfc-backend/src/main/java/com/etotem/cfc/task/OrchestrationPollingJob.java
  30. 98 0
      cfc-backend/src/main/resources/schema.sql
  31. 205 0
      cfc-backend/src/test/java/com/etotem/cfc/orchestration/OrchestrationEngineTest.java
  32. 190 0
      cfc-backend/src/test/java/com/etotem/cfc/orchestration/OrchestrationIntegrationTest.java
  33. 23 3
      cfc-frontend/components/OnboardingGuide.vue
  34. 23 0
      cfc-frontend/pages.json
  35. 21 2
      cfc-frontend/pages/growth/diet-checkin.vue
  36. 22 3
      cfc-frontend/pages/growth/exercise-checkin.vue
  37. 44 6
      cfc-frontend/pages/home-pages/child-index.vue
  38. 57 8
      cfc-frontend/pages/index-home/index.vue
  39. 644 0
      cfc-frontend/pages/orchestration/editor.vue
  40. 310 0
      cfc-frontend/pages/orchestration/index.vue
  41. 213 0
      cfc-frontend/pages/orchestration/node-edit.vue
  42. 25 0
      cfc-frontend/pages/tasks/tasks.vue
  43. 41 1
      cfc-frontend/utils/api.js
  44. 202 0
      cfc-frontend/utils/app-library.js
  45. 1 1
      cfc-web/.last_build_commit
  46. 9 2
      cfc-web/package-lock.json
  47. 2 1
      cfc-web/package.json
  48. 41 0
      cfc-web/public/CHANGELOG-v1.0.md
  49. 42 1
      cfc-web/public/CHANGELOG.md
  50. 17 0
      cfc-web/src/api/admin.js
  51. 64 0
      cfc-web/src/api/orchestration.js
  52. 24 0
      cfc-web/src/router/index.js
  53. 1 0
      cfc-web/src/views/Layout.vue
  54. 264 0
      cfc-web/src/views/admin/GuideConfig.vue
  55. 180 0
      cfc-web/src/views/orchestration/ExecutionDetail.vue
  56. 376 0
      cfc-web/src/views/orchestration/FlowEditor.vue
  57. 204 0
      cfc-web/src/views/orchestration/OrchestrationFlow.vue
  58. 1 0
      docs/superpowers/PROJECT-OVERVIEW.md
  59. 1107 0
      docs/superpowers/plans/2026-09-19-task-orchestration-plan.md
  60. 429 0
      docs/superpowers/specs/2026-09-19-task-orchestration-design.md
  61. 297 0
      docs/superpowers/specs/2026-09-21-homepage-shortcut-application-library-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>

+ 164 - 1
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -10889,6 +10889,8 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         }
         seedDanA2Fingerprints();
         seedDiscountRulesMenu();
+        createUserShortcutConfigTable();
+        createUserAppUsageTable();
 
         // 迁移313: 修复 meal_configs 历史双编码 participant_member_ids(共餐配置前端曾 JSON.stringify 二次编码,存成了 "\"[1,2]\"",
         //          导致再次进入页面时无法识别选中成员)。仅处理以双引号包裹的值,不触碰正常 "[]..." 格式
@@ -11296,6 +11298,131 @@ 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 {
+            String defaultConfig = "{"
+                + "\"enabled\":true,"
+                + "\"version\":\"1.0\","
+                + "\"positions\":[\"home\",\"child\"],"
+                + "\"parent\":["
+                    + "{\"icon\":\"🔍\",\"title\":\"你真的了解孩子的状态吗?\",\"desc\":\"情绪低落没察觉、习惯变化没注意、能力短板没发现——很多问题等爆发了才看见。浠艾福帮你在问题发生前,看清孩子真实的身心状态。\",\"isIntro\":true},"
+                    + "{\"icon\":\"🌏\",\"title\":\"五维沙盘,看见全家\",\"desc\":\"身·心·智·行·富,一张全景图看清每个维度。孩子身体好不好、情绪怎么样、能力到哪了——不用猜,看得见.\"},"
+                    + "{\"icon\":\"📋\",\"title\":\"小任务,大改变\",\"desc\":\"布置每日小任务,完成就能攒星星。每天一点点积累,好习惯自然养成.\"},"
+                    + "{\"icon\":\"🎁\",\"title\":\"心愿墙,看得见努力\",\"desc\":\"孩子攒够星星就能兑换心愿。努力有回报,目标感和坚持力一起培养.\"}"
+                + "],"
+                + "\"child\":["
+                    + "{\"icon\":\"🔍\",\"title\":\"你的每一点成长,都被看见\",\"desc\":\"今天完成了几个任务?比昨天进步了多少?浠艾福帮你记录每一点努力,让成长看得见。\",\"isIntro\":true},"
+                    + "{\"icon\":\"🌟\",\"title\":\"赚星星\",\"desc\":\"完成爸爸妈妈布置的任务,就能获得星星积分,越努力越多!\"},"
+                    + "{\"icon\":\"🎮\",\"title\":\"玩中学\",\"desc\":\"完成任务后可以玩小游戏、兑换心愿,学习和快乐两不误!\"},"
+                    + "{\"icon\":\"🏆\",\"title\":\"升级挑战\",\"desc\":\"从星星宝宝到宇宙小英雄,一步步升级,看看你能走多远!\"}"
+                + "]"
+                + "}";
+            jdbcTemplate.update(
+                "INSERT IGNORE INTO sys_config (config_key, config_value, description) VALUES ('onboarding_guide_config', ?, '首页引导浮层配置')",
+                defaultConfig
+            );
+            log.info("迁移318: 已预置onboarding_guide_config默认配置");
+        } catch (Exception e) {
+            log.warn("预置onboarding_guide_config可能已存在: {}", e.getMessage());
+        }
+        // 迁移328: 饮食/运动打卡表补充照片与语音字段(前端已上传 media URL,补上持久化列)
+        try {
+            ensureColumn("health_meal_record", "photo_urls", "TEXT COMMENT '照片URL列表(逗号分隔)'");
+            ensureColumn("health_meal_record", "voice_url", "VARCHAR(500) COMMENT '语音URL'");
+            ensureColumn("health_meal_record", "voice_text", "TEXT COMMENT '语音转写文字'");
+            ensureColumn("exercise_record", "photo_urls", "TEXT COMMENT '照片URL列表(逗号分隔)'");
+            ensureColumn("exercise_record", "voice_url", "VARCHAR(500) COMMENT '语音URL'");
+            ensureColumn("exercise_record", "voice_text", "TEXT COMMENT '语音转写文字'");
+            log.info("迁移328: 已为health_meal_record/exercise_record补充照片语音字段");
+        } catch (Exception e) {
+            log.warn("迁移328: 补充照片语音字段失败: {}", e.getMessage());
+        }
     }
 
     /**
@@ -11335,7 +11462,7 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
     // 迁移326: 商城营销菜单新增「限时折扣管理」(幂等)
     private void seedDiscountRulesMenu() {
         try {
-            int count = jdbcTemplate.queryForObject(
+            Integer count = jdbcTemplate.queryForObject(
                     "SELECT COUNT(*) FROM menu WHERE path = '/discount-rules'", Integer.class);
             if (count != null && count > 0) {
                 log.info("迁移326: 限时折扣管理菜单已存在,跳过");
@@ -11349,4 +11476,40 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
             log.warn("迁移326: 添加限时折扣管理菜单失败: {}", e.getMessage());
         }
     }
+
+    // 迁移327: 创建 user_shortcut_config 表(首页快捷方式自定义配置,每应用一条记录,locked=置顶)
+    private void createUserShortcutConfigTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS user_shortcut_config (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                    "user_id BIGINT NOT NULL COMMENT '用户ID', " +
+                    "app_key VARCHAR(64) NOT NULL COMMENT '应用key', " +
+                    "locked TINYINT DEFAULT 0 COMMENT '是否锁定置顶(1=锁定,0=普通)', " +
+                    "sort_order INT DEFAULT 0 COMMENT '手动排序序号', " +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+                    "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
+                    "UNIQUE KEY uk_user_app (user_id, app_key)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='首页快捷方式自定义配置'");
+            log.info("迁移327: 已创建 user_shortcut_config 表");
+        } catch (Exception e) {
+            log.warn("迁移327: 创建 user_shortcut_config 表失败: {}", e.getMessage());
+        }
+    }
+
+    // 迁移327: 创建 user_app_usage 表(应用使用频度记录)
+    private void createUserAppUsageTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS user_app_usage (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                    "user_id BIGINT NOT NULL COMMENT '用户ID', " +
+                    "app_key VARCHAR(64) NOT NULL COMMENT '应用key', " +
+                    "enter_count INT DEFAULT 0 COMMENT '累计进入次数', " +
+                    "last_enter_at DATETIME COMMENT '最近进入时间', " +
+                    "UNIQUE KEY uk_user_app (user_id, app_key)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='应用使用频度记录'");
+            log.info("迁移327: 已创建 user_app_usage 表");
+        } catch (Exception e) {
+            log.warn("迁移327: 创建 user_app_usage 表失败: {}", e.getMessage());
+        }
+    }
 }

+ 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();
+    }
+}

+ 6 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthExerciseController.java

@@ -64,7 +64,12 @@ public class HealthExerciseController {
         }
         if (record.getMemberId() == null) return Result.error("memberId不能为空");
         if (record.getExerciseType() == null || record.getExerciseType().trim().isEmpty()) return Result.error("请选择运动类型");
-        if (record.getDurationMinutes() == null || record.getDurationMinutes() <= 0) return Result.error("运动时长需大于0分钟");
+        // 内容校验:运动时长、图片、语音 至少有一项即可
+        boolean hasContent = (record.getDurationMinutes() != null && record.getDurationMinutes() > 0)
+                || (record.getPhotoUrls() != null && !record.getPhotoUrls().trim().isEmpty())
+                || (record.getVoiceUrl() != null && !record.getVoiceUrl().trim().isEmpty())
+                || (record.getVoiceText() != null && !record.getVoiceText().trim().isEmpty());
+        if (!hasContent) return Result.error("请填写运动时长或添加图片/语音");
         record.setCreatedAt(new Date());
         healthExerciseRecordMapper.insert(record);
                 // 触发画像重算

+ 7 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthMealController.java

@@ -209,7 +209,13 @@ public class HealthMealController {
         }
         if (record.getMemberId() == null) return Result.error("memberId不能为空");
         if (record.getMealType() == null || record.getMealType().trim().isEmpty()) return Result.error("请选择餐次");
-        if (record.getFoodItems() == null || record.getFoodItems().trim().isEmpty()) return Result.error("请填写食物清单");
+        // 内容校验:文字(食物清单/备注)、图片、语音 至少有一项即可
+        boolean hasContent = (record.getFoodItems() != null && !record.getFoodItems().trim().isEmpty())
+                || (record.getRemark() != null && !record.getRemark().trim().isEmpty())
+                || (record.getPhotoUrls() != null && !record.getPhotoUrls().trim().isEmpty())
+                || (record.getVoiceUrl() != null && !record.getVoiceUrl().trim().isEmpty())
+                || (record.getVoiceText() != null && !record.getVoiceText().trim().isEmpty());
+        if (!hasContent) return Result.error("请填写内容或添加图片/语音");
 
         // 补打逻辑
         boolean isBackfill = record.getIsBackfill() != null && record.getIsBackfill() == 1;

+ 51 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/HomeController.java

@@ -0,0 +1,51 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.service.HomeShortcutService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+@Tag(name = "首页快捷方式", description = "应用库、快捷配置、使用频度")
+@RestController
+@RequestMapping("/api/home")
+public class HomeController {
+
+    @Resource
+    private HomeShortcutService homeShortcutService;
+
+    @Operation(summary = "获取应用库配置 + 用户快捷 + 频度")
+    @PostMapping("/app-library")
+    public Result<?> getAppLibrary(@RequestAttribute("userId") Long userId) {
+        return homeShortcutService.getAppLibrary(userId);
+    }
+
+    @Operation(summary = "保存快捷方式配置")
+    @PostMapping("/shortcut/save")
+    public Result<Void> saveShortcut(@RequestBody Map<String, Object> body,
+                                     @RequestAttribute("userId") Long userId) {
+        @SuppressWarnings("unchecked")
+        List<Map<String, Object>> items = (List<Map<String, Object>>) body.get("items");
+        if (items == null) items = java.util.Collections.emptyList();
+        return homeShortcutService.saveShortcutConfig(userId, items);
+    }
+
+    @Operation(summary = "记录应用进入次数")
+    @PostMapping("/app/enter")
+    public Result<Void> reportEnter(@RequestBody Map<String, String> body,
+                                    @RequestAttribute("userId") Long userId) {
+        String appKey = body.get("appKey");
+        return homeShortcutService.reportEnter(userId, appKey);
+    }
+
+    @Operation(summary = "重置为默认配置(清除所有锁定)")
+    @PostMapping("/shortcut/reset")
+    public Result<Void> resetShortcut(@RequestAttribute("userId") Long userId) {
+        return homeShortcutService.resetToDefault(userId);
+    }
+}

+ 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);
+    }
+}

+ 58 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminGuideConfigController.java

@@ -0,0 +1,58 @@
+package com.etotem.cfc.controller.admin;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.service.SysConfigService;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.Map;
+
+/**
+ * 首页引导浮层配置管理(管理端)
+ * 存储于 sys_config 表,key = onboarding_guide_config,value 为 JSON 字符串
+ */
+@RestController
+@RequestMapping("/api/admin/guide-config")
+public class AdminGuideConfigController {
+
+    private static final String CONFIG_KEY = "onboarding_guide_config";
+
+    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+    @Resource
+    private SysConfigService sysConfigService;
+
+    /**
+     * 获取引导配置(key 不存在时返回 null,前端使用默认值)
+     */
+    @PostMapping("/get")
+    public Result<Map<String, Object>> get() {
+        String value = sysConfigService.getValue(CONFIG_KEY);
+        if (value == null || value.isEmpty()) {
+            return Result.success(null);
+        }
+        try {
+            Map<String, Object> config = OBJECT_MAPPER.readValue(value, new TypeReference<Map<String, Object>>() {});
+            return Result.success(config);
+        } catch (Exception e) {
+            return Result.error("首页引导配置解析失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 保存引导配置
+     */
+    @PostMapping("/save")
+    public Result<String> save(@RequestBody Map<String, String> body) {
+        String configValue = body == null ? null : body.get("configValue");
+        if (configValue == null || configValue.isEmpty()) {
+            return Result.error("configValue 不能为空");
+        }
+        return sysConfigService.update(CONFIG_KEY, configValue, "首页引导浮层配置");
+    }
+}

+ 46 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/config/GuideConfigController.java

@@ -0,0 +1,46 @@
+package com.etotem.cfc.controller.config;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.service.SysConfigService;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.Map;
+
+/**
+ * 首页引导浮层配置读取(小程序端)
+ * 存储于 sys_config 表,key = onboarding_guide_config,value 为 JSON 字符串
+ */
+@RestController
+@RequestMapping("/api/guide-config")
+public class GuideConfigController {
+
+    private static final String CONFIG_KEY = "onboarding_guide_config";
+
+    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+    @Resource
+    private SysConfigService sysConfigService;
+
+    /**
+     * 获取引导配置(需登录;key 不存在时 data 为 null,前端使用默认值)
+     */
+    @PostMapping("/current")
+    public Result<Map<String, Object>> current(@RequestAttribute("userId") Long userId) {
+        String value = sysConfigService.getValue(CONFIG_KEY);
+        if (value == null || value.isEmpty()) {
+            return Result.success(null);
+        }
+        try {
+            Map<String, Object> config = OBJECT_MAPPER.readValue(value, new TypeReference<Map<String, Object>>() {});
+            return Result.success(config);
+        } catch (Exception e) {
+            return Result.error("首页引导配置解析失败: " + e.getMessage());
+        }
+    }
+}

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

@@ -24,5 +24,11 @@ public class HealthExerciseRecord implements Serializable {
     private Date startTime;
     private Date endTime;
     private String remark;
+    /** 照片URL列表(逗号分隔) */
+    private String photoUrls;
+    /** 语音URL */
+    private String voiceUrl;
+    /** 语音转写文字 */
+    private String voiceText;
     private Date createdAt;
 }

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/HealthMealRecord.java

@@ -36,6 +36,15 @@ public class HealthMealRecord {
 
     private String remark;
 
+    /** 照片URL列表(逗号分隔) */
+    private String photoUrls;
+
+    /** 语音URL */
+    private String voiceUrl;
+
+    /** 语音转写文字 */
+    private String voiceText;
+
     /** 用餐日期(补打时为实际用餐日,null=等于创建日) */
     private java.sql.Date recordDate;
 

+ 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;
+}

+ 25 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/UserAppUsage.java

@@ -0,0 +1,25 @@
+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("user_app_usage")
+public class UserAppUsage implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long userId;
+
+    private String appKey;
+
+    private Integer enterCount;
+
+    private Date lastEnterAt;
+}

+ 29 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/UserShortcutConfig.java

@@ -0,0 +1,29 @@
+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("user_shortcut_config")
+public class UserShortcutConfig implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long userId;
+
+    private String appKey;
+
+    private Integer locked;
+
+    private Integer sortOrder;
+
+    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> {
+}

+ 12 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/UserAppUsageMapper.java

@@ -0,0 +1,12 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.UserAppUsage;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+public interface UserAppUsageMapper extends BaseMapper<UserAppUsage> {
+
+    @Select("SELECT * FROM user_app_usage WHERE user_id = #{userId} AND app_key = #{appKey} LIMIT 1")
+    UserAppUsage selectByUserIdAndAppKey(@Param("userId") Long userId, @Param("appKey") String appKey);
+}

+ 13 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/UserShortcutConfigMapper.java

@@ -0,0 +1,13 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.UserShortcutConfig;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+import java.util.List;
+
+public interface UserShortcutConfigMapper extends BaseMapper<UserShortcutConfig> {
+
+    @Select("SELECT * FROM user_shortcut_config WHERE user_id = #{userId} ORDER BY locked DESC, sort_order ASC")
+    List<UserShortcutConfig> selectByUserId(@Param("userId") Long userId);
+}

+ 98 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/HomeShortcutService.java

@@ -0,0 +1,98 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.UserShortcutConfig;
+import com.etotem.cfc.entity.UserAppUsage;
+import com.etotem.cfc.mapper.UserShortcutConfigMapper;
+import com.etotem.cfc.mapper.UserAppUsageMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.util.*;
+import java.util.stream.Collectors;
+
+@Slf4j
+@Service
+public class HomeShortcutService {
+
+    @Resource
+    private UserShortcutConfigMapper userShortcutConfigMapper;
+
+    @Resource
+    private UserAppUsageMapper userAppUsageMapper;
+
+    /**
+     * 返回用户快捷配置 + 频度映射。
+     * 应用库本身是前端常量,后端只需返回用户侧数据。
+     */
+    public Result<Map<String, Object>> getAppLibrary(Long userId) {
+        List<UserShortcutConfig> configs = userShortcutConfigMapper.selectByUserId(userId);
+
+        List<Map<String, Object>> myShortcuts = configs.stream().map(c -> {
+            Map<String, Object> m = new HashMap<>();
+            m.put("appKey", c.getAppKey());
+            m.put("locked", c.getLocked());
+            m.put("sortOrder", c.getSortOrder());
+            return m;
+        }).collect(Collectors.toList());
+
+        List<UserAppUsage> usages = userAppUsageMapper.selectList(null);
+        Map<String, Integer> usageMap = new HashMap<>();
+        for (UserAppUsage u : usages) {
+            usageMap.put(u.getAppKey(), u.getEnterCount());
+        }
+
+        Map<String, Object> resp = new HashMap<>();
+        resp.put("myShortcuts", myShortcuts);
+        resp.put("usage", usageMap);
+        return Result.success(resp);
+    }
+
+    @Transactional
+    public Result<Void> saveShortcutConfig(Long userId, List<Map<String, Object>> items) {
+        userShortcutConfigMapper.delete(
+                new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<UserShortcutConfig>()
+                        .eq("user_id", userId));
+        for (Map<String, Object> item : items) {
+            String appKey = (String) item.get("appKey");
+            if (appKey == null || appKey.isEmpty()) continue;
+            UserShortcutConfig c = new UserShortcutConfig();
+            c.setUserId(userId);
+            c.setAppKey(appKey);
+            c.setLocked(item.get("locked") != null ? ((Number) item.get("locked")).intValue() : 0);
+            c.setSortOrder(item.get("sortOrder") != null ? ((Number) item.get("sortOrder")).intValue() : 0);
+            userShortcutConfigMapper.insert(c);
+        }
+        return Result.success(null);
+    }
+
+    public Result<Void> reportEnter(Long userId, String appKey) {
+        if (userId == null || appKey == null || appKey.isEmpty()) {
+            return Result.error("参数缺失");
+        }
+        UserAppUsage u = userAppUsageMapper.selectByUserIdAndAppKey(userId, appKey);
+        if (u == null) {
+            u = new UserAppUsage();
+            u.setUserId(userId);
+            u.setAppKey(appKey);
+            u.setEnterCount(1);
+            u.setLastEnterAt(new Date());
+            userAppUsageMapper.insert(u);
+        } else {
+            u.setEnterCount(u.getEnterCount() + 1);
+            u.setLastEnterAt(new Date());
+            userAppUsageMapper.updateById(u);
+        }
+        return Result.success(null);
+    }
+
+    @Transactional
+    public Result<Void> resetToDefault(Long userId) {
+        userShortcutConfigMapper.delete(
+                new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<UserShortcutConfig>()
+                        .eq("user_id", userId));
+        return Result.success(null);
+    }
+}

+ 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());
+            }
+        }
+    }
+}

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

@@ -3270,6 +3270,9 @@ CREATE TABLE IF NOT EXISTS health_meal_record (
     fiber_g INT COMMENT '膳食纤维克数',
     meal_time DATETIME COMMENT '用餐时间',
     remark VARCHAR(500) COMMENT '备注',
+    photo_urls TEXT COMMENT '照片URL列表(逗号分隔)',
+    voice_url VARCHAR(500) COMMENT '语音URL',
+    voice_text TEXT COMMENT '语音转写文字',
     record_date DATE COMMENT '用餐日期(补打时为实际用餐日,默认等于创建日)',
     is_backfill TINYINT DEFAULT 0 COMMENT '是否补打: 1=补打(无能量值), 0=当日打卡',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
@@ -3305,6 +3308,9 @@ CREATE TABLE IF NOT EXISTS exercise_record (
     start_time DATETIME COMMENT '开始时间',
     end_time DATETIME COMMENT '结束时间',
     remark TEXT COMMENT '备注',
+    photo_urls TEXT COMMENT '照片URL列表(逗号分隔)',
+    voice_url VARCHAR(500) COMMENT '语音URL',
+    voice_text TEXT COMMENT '语音转写文字',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
     INDEX idx_member_id (member_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='运动打卡记录';
@@ -5757,3 +5763,95 @@ CREATE TABLE IF NOT EXISTS shop_discount_rules (
     INDEX idx_distribution (distribution_system_id),
     INDEX idx_end_time (end_time)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='限时折扣规则表';
+
+-- 首页快捷方式自定义配置(每应用一条记录,locked=置顶)
+CREATE TABLE IF NOT EXISTS user_shortcut_config (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    user_id BIGINT NOT NULL COMMENT '用户ID',
+    app_key VARCHAR(64) NOT NULL COMMENT '应用key',
+    locked TINYINT DEFAULT 0 COMMENT '是否锁定置顶(1=锁定,0=普通)',
+    sort_order INT DEFAULT 0 COMMENT '手动排序序号',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_user_app (user_id, app_key)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='首页快捷方式自定义配置';
+
+-- 应用使用频度记录
+CREATE TABLE IF NOT EXISTS user_app_usage (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    user_id BIGINT NOT NULL COMMENT '用户ID',
+    app_key VARCHAR(64) NOT NULL COMMENT '应用key',
+    enter_count INT DEFAULT 0 COMMENT '累计进入次数',
+    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 - 3
cfc-frontend/components/OnboardingGuide.vue

@@ -35,6 +35,8 @@
 </template>
 
 <script>
+import { getGuideConfig } from '../utils/api.js'
+
 var PARENT_STEPS = [
   {
     icon: '🔍',
@@ -87,15 +89,33 @@ export default {
   name: 'OnboardingGuide',
   props: {
     role: { type: String, default: 'parent' },
-    show: { type: Boolean, default: false }
+    show: { type: Boolean, default: false },
+    position: { type: String, default: 'home' }
   },
   data() {
     return {
-      currentStep: 0
+      currentStep: 0,
+      _configSteps: null,
+      _configVersion: '1.0'
     }
   },
+  created() {
+    var self = this
+    getGuideConfig().then(function(res) {
+      var cfg = (res && res.data) ? res.data : null
+      if (cfg && (cfg.parent || cfg.child)) {
+        self._configSteps = cfg
+        self._configVersion = cfg.version || '1.0'
+      }
+    }).catch(function() {
+      // 拉取配置失败,保持 null 走默认步骤
+    })
+  },
   computed: {
     steps() {
+      if (this._configSteps) {
+        return this.role === 'child' ? (this._configSteps.child || CHILD_STEPS) : (this._configSteps.parent || PARENT_STEPS)
+      }
       if (this.role === 'child') return CHILD_STEPS
       return PARENT_STEPS
     }
@@ -112,7 +132,7 @@ export default {
       if (this.currentStep < this.steps.length - 1) {
         this.currentStep++
       } else {
-        uni.setStorageSync('_onboardingDone', true)
+        uni.setStorageSync('_onboardingDone', JSON.stringify({ version: this._configVersion || '1.0', doneAt: Date.now() }))
         this.$emit('close')
       }
     }

+ 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": [

+ 21 - 2
cfc-frontend/pages/growth/diet-checkin.vue

@@ -144,6 +144,7 @@ export default {
     return {
       memberId: null,
       submitting: false,
+      pendingSubmit: false,
       isRecording: false,
       recordSeconds: 0,
       recordTimer: null,
@@ -205,9 +206,12 @@ export default {
       sttTranscribe(res.tempFilePath).then(function(text) {
         self.form.voiceText = text
         uni.hideLoading()
+        // 保存时停止录音 → 语音已就绪,继续提交
+        if (self.pendingSubmit) { self.pendingSubmit = false; self.doCheckin() }
       }).catch(function(err) {
         uni.hideLoading()
         console.warn('STT 转写失败:', err)
+        if (self.pendingSubmit) { self.pendingSubmit = false; self.doCheckin() }
       })
     })
     this.recorderManager.onError((err) => {
@@ -315,10 +319,25 @@ export default {
         })
       })
     },
-    async doCheckin() {
+    doCheckin: function() {
+      var self = this
+      if (self.submitting) return
+      // 保存时正在录音 → 先停止录音,等待 onStop 完成语音后继续提交
+      if (self.isRecording) {
+        self.pendingSubmit = true
+        self.recorderManager.stop()
+        return
+      }
+      self.submitCheckin()
+    },
+    submitCheckin: async function() {
       var self = this
       if (!self.form.mealType) { uni.showToast({ title: '请选择餐次', icon: 'none' }); return }
-      if (!self.form.foodItems || !self.form.foodItems.trim()) { uni.showToast({ title: '请填写食物清单', icon: 'none' }); return }
+      // 内容校验:文字(食物清单/备注)、图片、语音 至少有一项
+      var hasText = (self.form.foodItems && self.form.foodItems.trim()) || (self.form.remark && self.form.remark.trim())
+      var hasPhoto = self.form.photos && self.form.photos.length > 0
+      var hasVoice = self.form.voicePath || (self.form.voiceText && self.form.voiceText.trim())
+      if (!hasText && !hasPhoto && !hasVoice) { uni.showToast({ title: '请填写内容或添加图片/语音', icon: 'none' }); return }
       if (!self.memberId) { uni.showToast({ title: '孩子ID无效', icon: 'none' }); return }
       self.submitting = true
       try {

+ 22 - 3
cfc-frontend/pages/growth/exercise-checkin.vue

@@ -163,6 +163,7 @@ export default {
       memberId: null,
       isSelfCheckin: false,
       submitting: false,
+      pendingSubmit: false,
       syncingSteps: false,
       syncedStepText: '',
       isRecording: false,
@@ -228,9 +229,12 @@ export default {
       sttTranscribe(res.tempFilePath).then(function(text) {
         self.form.voiceText = text
         uni.hideLoading()
+        // 保存时停止录音 → 语音已就绪,继续提交
+        if (self.pendingSubmit) { self.pendingSubmit = false; self.doCheckin() }
       }).catch(function(err) {
         uni.hideLoading()
         console.warn('STT 转写失败:', err)
+        if (self.pendingSubmit) { self.pendingSubmit = false; self.doCheckin() }
       })
     })
     this.recorderManager.onError(() => {
@@ -401,11 +405,26 @@ export default {
         })
       })
     },
-    async doCheckin() {
+    doCheckin: function() {
+      var self = this
+      if (self.submitting) return
+      // 保存时正在录音 → 先停止录音,等待 onStop 完成语音后继续提交
+      if (self.isRecording) {
+        self.pendingSubmit = true
+        self.recorderManager.stop()
+        return
+      }
+      self.submitCheckin()
+    },
+    submitCheckin: async function() {
       var self = this
       if (!self.form.exerciseType) { uni.showToast({ title: '请选择运动类型', icon: 'none' }); return }
+      // 内容校验:运动时长、图片、语音 至少有一项
       var durationNum = parseInt(self.form.durationMinutes)
-      if (!self.form.durationMinutes || isNaN(durationNum) || durationNum <= 0) { uni.showToast({ title: '请填写有效的运动时长', icon: 'none' }); return }
+      var hasDuration = self.form.durationMinutes && !isNaN(durationNum) && durationNum > 0
+      var hasPhoto = self.form.photos && self.form.photos.length > 0
+      var hasVoice = self.form.voicePath || (self.form.voiceText && self.form.voiceText.trim())
+      if (!hasDuration && !hasPhoto && !hasVoice) { uni.showToast({ title: '请填写运动时长或添加图片/语音', icon: 'none' }); return }
       if (!self.memberId) { uni.showToast({ title: '孩子ID无效', icon: 'none' }); return }
       self.submitting = true
       try {
@@ -427,7 +446,7 @@ export default {
           data: {
             memberId: self.memberId,
             exerciseType: self.form.exerciseType,
-            durationMinutes: parseInt(self.form.durationMinutes),
+            durationMinutes: self.form.durationMinutes ? parseInt(self.form.durationMinutes) : null,
             intensity: self.form.intensity,
             caloriesBurned: self.form.caloriesBurned ? parseInt(self.form.caloriesBurned) : null,
             remark: self.form.remark || '',

+ 44 - 6
cfc-frontend/pages/home-pages/child-index.vue

@@ -9,6 +9,7 @@
     <!-- 首次登录引导浮层 -->
     <OnboardingGuide
       role="child"
+      position="child"
       :show="showOnboarding"
       @close="showOnboarding = false" />
 
@@ -309,7 +310,7 @@ B) 积分卡片 — Claymorphism 暖橙黏土风格
 </template>
 
 <script>
-import { getChildren, getTodayTasks, getEnergyOverview, getEnergyLogs, getActivityList, getProductsByDomain, getFamilyEnergySandbox, getEnergySandbox, getUnlockStatus, getFamilyMemberList } from '../../utils/api.js'
+import { getChildren, getTodayTasks, getEnergyOverview, getEnergyLogs, getActivityList, getProductsByDomain, getFamilyEnergySandbox, getEnergySandbox, getUnlockStatus, getFamilyMemberList, getGuideConfig } from '../../utils/api.js'
 import FamilyEnergyBar from '../../components/FamilyEnergyBar.vue'
 import WuxingSandbox from '../../components/wuxing-sandbox.vue'
 import RecommendedFeed from '../../components/RecommendedFeed.vue'
@@ -506,11 +507,8 @@ export default {
     }
   },
   mounted() {
-    // 首次登录引导
-    var onboardingDone = uni.getStorageSync('_onboardingDone')
-    if (!onboardingDone) {
-      this.showOnboarding = true
-    }
+    // 首次登录引导:异步拉取配置并按版本号判断是否展示
+    this._checkOnboardingShow()
     this.loadData()
   },
   onShow() {
@@ -756,6 +754,46 @@ export default {
         }
       })
     },
+    _checkOnboardingShow() {
+      var self = this
+      getGuideConfig().then(function(res) {
+        var cfg = (res && res.data) ? res.data : null
+        var cfgVersion = cfg ? (cfg.version || '1.0') : '1.0'
+        // 检查启用
+        if (cfg && cfg.enabled === false) return
+        // 检查位置
+        if (cfg && cfg.positions && cfg.positions.indexOf('child') === -1) return
+        // 比较版本
+        var stored = uni.getStorageSync('_onboardingDone')
+        var seenVersion = '0.0'
+        if (stored) {
+          try {
+            var obj = typeof stored === 'string' ? JSON.parse(stored) : stored
+            if (obj && obj.version) seenVersion = obj.version
+          } catch (e) {}
+        }
+        if (self._compareVersion(seenVersion, cfgVersion) >= 0) return
+        self.showOnboarding = true
+      }).catch(function() {
+        // 接口失败按旧逻辑:无配置则 version='1.0',已看过则不显示
+        var stored = uni.getStorageSync('_onboardingDone')
+        if (stored) {
+          try {
+            var obj = typeof stored === 'string' ? JSON.parse(stored) : stored
+            if (obj && obj.version && self._compareVersion(obj.version, '1.0') >= 0) return
+          } catch (e) {}
+        }
+        self.showOnboarding = true
+      })
+    },
+    _compareVersion(v1, v2) {
+      var parts1 = String(v1).split('.').map(function(s) { return parseInt(s || '0', 10) })
+      var parts2 = String(v2).split('.').map(function(s) { return parseInt(s || '0', 10) })
+      for (var i = 0; i < Math.max(parts1.length, parts2.length); i++) {
+        if ((parts1[i] || 0) !== (parts2[i] || 0)) return (parts1[i] || 0) - (parts2[i] || 0)
+      }
+      return 0
+    },
     goToDomain(domain) {
       uni.showToast({ title: '即将上线', icon: 'none' })
     },

+ 57 - 8
cfc-frontend/pages/index-home/index.vue

@@ -244,6 +244,7 @@
     <OnboardingGuide
       :role="currentRole"
       :show="showOnboardingGuide"
+      position="home"
       @close="closeOnboardingGuide" />
   </view>
 
@@ -430,7 +431,7 @@ import DailyTaskCard from '../../components/daily-task-card.vue'
 import ReportUploadCard from '../../components/report-upload-card.vue'
 import OnboardingProgressBar from '../../components/OnboardingProgressBar.vue'
 import OnboardingGuide from '../../components/OnboardingGuide.vue'
-import { acceptParentInvite, getActivityList, getFeaturedArticles, getEnergySandbox, getEnergyOverview, getFamilyMemberList, getChallengeList, switchToFamilyMember, getMyMembership, updateChallengeProgress, respondChallenge, getTodayTasks, completeTask as completeTaskApi, getTodayParentTasks, completeParentTask as completeParentTaskApi, getTaskHistory, getNotices, getNotificationList, getSurveyStatus, getSelfCheckStatus, getReferralCode, extractReferralCode } from '../../utils/api.js'
+import { acceptParentInvite, getActivityList, getFeaturedArticles, getEnergySandbox, getEnergyOverview, getFamilyMemberList, getChallengeList, getGuideConfig, switchToFamilyMember, getMyMembership, updateChallengeProgress, respondChallenge, getTodayTasks, completeTask as completeTaskApi, getTodayParentTasks, completeParentTask as completeParentTaskApi, getTaskHistory, getNotices, getNotificationList, getSurveyStatus, getSelfCheckStatus, getReferralCode, extractReferralCode } from '../../utils/api.js'
 import nav from '../../utils/nav.js'
 import config from '@/config.js'
 import { parseDate } from '../../utils/format.js'
@@ -470,6 +471,7 @@ export default {
       selfCheckScore: null,
       selfCheckLoading: false,
       showOnboardingGuide: false,
+      _onboardingGuideAttempted: false,
 
       dimensionActivities: [],
       indexArticles: [],
@@ -710,6 +712,11 @@ membershipDays: 0,
 
     // 首次加载:onLoad 已触发 _doInit,此处跳过避免重复调用
     // 后续 onShow(切 Tab 返回):刷新易变数据(会员状态、今日任务、挑战列表)
+    // 关键修复:在 onShow 中也检查 onboarding,兼容 switchTab 后未触发 onLoad 的情况
+    if (!this.showOnboardingGuide) {
+      this._maybeShowOnboardingGuide()
+    }
+
     if (this._onLoadFired) {
       this._onLoadFired = false // 清标记,后续 onShow 走刷新路径
     } else {
@@ -1218,16 +1225,47 @@ membershipDays: 0,
         if (newVal) that._tryHideSplash()
       })
     },
-    // 首次登录引导浮层:仅当从未看过(无 _onboardingDone 标记)时展示一次
+    // 首次登录引导浮层:异步拉取配置并按版本号判断是否展示
     _maybeShowOnboardingGuide() {
       if (this.showOnboardingGuide) return
-      if (uni.getStorageSync('_onboardingDone')) return
-      // 延迟到 splash 蒙板关闭后再弹,避免层级冲突
+      if (this._onboardingGuideAttempted) return
+      this._onboardingGuideAttempted = true
       var self = this
-      setTimeout(function() {
-        if (!self.isLoggedIn()) return
-        self.showOnboardingGuide = true
-      }, 600)
+      getGuideConfig().then(function(res) {
+        var cfg = (res && res.data) ? res.data : null
+        var cfgVersion = cfg ? (cfg.version || '1.0') : '1.0'
+        // 检查启用
+        if (cfg && cfg.enabled === false) return
+        // 检查位置(index-home 对应 'home' 位置)
+        if (cfg && cfg.positions && cfg.positions.indexOf('home') === -1) return
+        // 比较版本
+        var stored = uni.getStorageSync('_onboardingDone')
+        var seenVersion = '0.0'
+        if (stored) {
+          try {
+            var obj = typeof stored === 'string' ? JSON.parse(stored) : stored
+            if (obj && obj.version) seenVersion = obj.version
+          } catch (e) {}
+        }
+        if (self._compareVersion(seenVersion, cfgVersion) >= 0) return
+        setTimeout(function() {
+          if (!self.isLoggedIn()) return
+          self.showOnboardingGuide = true
+        }, 600)
+      }).catch(function() {
+        // 接口失败走旧逻辑
+        var stored = uni.getStorageSync('_onboardingDone')
+        if (stored) {
+          try {
+            var obj = typeof stored === 'string' ? JSON.parse(stored) : stored
+            if (obj && obj.version && self._compareVersion(obj.version, '1.0') >= 0) return
+          } catch (e) {}
+        }
+        setTimeout(function() {
+          if (!self.isLoggedIn()) return
+          self.showOnboardingGuide = true
+        }, 600)
+      })
     },
     closeOnboardingGuide() {
       this.showOnboardingGuide = false
@@ -1235,6 +1273,17 @@ membershipDays: 0,
     isLoggedIn() {
       return !!uni.getStorageSync('token')
     },
+    // 版本比较:返回 >0 表示 v1 > v2,0 相等,<0 表示 v1 < v2
+    _compareVersion: function(v1, v2) {
+      var parts1 = String(v1).split('.')
+      var parts2 = String(v2).split('.')
+      for (var i = 0; i < Math.max(parts1.length, parts2.length); i++) {
+        var n1 = parseInt(parts1[i] || '0', 10)
+        var n2 = parseInt(parts2[i] || '0', 10)
+        if (n1 !== n2) return n1 - n2
+      }
+      return 0
+    },
     // ===== 会员状态(沙盘下方会员入口) =====
     checkMemberStatus: function() {
       var that = this

+ 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;

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

@@ -409,6 +409,11 @@ export const getFamilyMemberList = (params) => {
 	return request('/api/family/member/list', 'POST', params || {})
 }
 
+// 首页引导浮层配置(管理端可配置家长/孩子引导步骤,无配置时返回 null data)
+export const getGuideConfig = () => {
+	return request('/api/guide-config/current', 'POST', {})
+}
+
 // 获取以指定成员为中心的所有成员关系(关系类型+信任/亲密度/沟通评分)
 export const getPairwiseRelationships = (centerMemberId) => {
 	return request('/api/family/relationship/pairwise', 'POST', { centerMemberId })
@@ -3070,7 +3075,42 @@ export function regeneratePortrait(params = {}) {
   return request('/api/user/portrait/regenerate', 'POST', params)
 }
 
-// ── 限时折扣 ──
 export const shopDiscountCalculate = (params) => {
   return request('/api/shop/discount/calculate', 'POST', params || {})
 }
+
+// ── 首页快捷方式 ──
+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)
+}

+ 202 - 0
cfc-frontend/utils/app-library.js

@@ -0,0 +1,202 @@
+/**
+ * 应用库常量清单
+ *
+ * 每个应用字段说明:
+ *   key        - 在后端配置/URL 中使用的 key,对应 user_shortcut_config.app_key
+ *   name       - 显示名称(中文)
+ *   icon       - emoji 图标,与现有首页快捷入口一致,零资源成本
+ *   route      - 直接进入的页面路径(uni-app navigationTo URL 写法)
+ *   canShortcut - 是否可以放入首页快捷区(true=可配置,false=已在首页有显著入口,不可配入快捷区)
+ *
+ * 应用库 = 所有“可直接进入的功能页面”,除已在首页有显著入口者外,其余均可在配置页中选择放入快捷区。
+ *
+ * 固定的 6 个快捷入口(默认值,新用户/无配置时):
+ *   task, interaction, tiandi, members, circle, shop
+ *
+ * 已在首页显著位置的功能(canShortcut=false,配置页可见但置灰标注"已在首页"):
+ *   report, article
+ *
+ * 可在快捷区配置的功能(canShortcut=true):
+ *   diet, ai, growth, wish, medal, game, assess, activity, membership, wealth
+ */
+
+export const APP_LIBRARY = [
+  // --- 已在首页有显著入口(不进快捷配置列表,但进应用库,置灰标注) ---
+  {
+    key: 'report',
+    name: '健康报告',
+    icon: '🩺',
+    route: '/pages/health/report-upload',
+    canShortcut: false,
+    description: '体检报告 · 菌群检测 · 认知测评 · 心理测评 · 情绪打卡'
+  },
+  {
+    key: 'article',
+    name: '文章中心',
+    icon: '📖',
+    route: '/pages/article-center/index',
+    canShortcut: false,
+    description: '免费五维测评 · 个性化成长方案 · 全家共管 · 任务与积分激励'
+  },
+
+  // --- 可在快捷区配置 ---
+  {
+    key: 'task',
+    name: '任务',
+    icon: '📋',
+    route: '/pages/tasks/tasks',
+    canShortcut: true,
+    description: '创建/下发、执行(含小游戏)、审核'
+  },
+  {
+    key: 'interaction',
+    name: '互动',
+    icon: '💬',
+    route: '/pages/action-detail/interaction-log',
+    canShortcut: true,
+    description: '记录家庭互动、查看互动历史'
+  },
+  {
+    key: 'tiandi',
+    name: '天盘',
+    icon: '🏠',
+    route: '/pages/mind-detail/family-dashboard',
+    canShortcut: true,
+    description: '家庭天盘、成长计划查看'
+  },
+  {
+    key: 'members',
+    name: '成员',
+    icon: '👨‍👩‍👧‍👦',
+    route: '/pages/profile-extra/family-members',
+    canShortcut: true,
+    description: '家庭成员管理、成员角色切换'
+  },
+  {
+    key: 'circle',
+    name: '圈子',
+    icon: '🤝',
+    route: '/pages/discover/circles',
+    canShortcut: true,
+    description: '圈子动态、创建/加入圈子'
+  },
+  {
+    key: 'shop',
+    name: '商城',
+    icon: '🛒',
+    route: '/pages/shop/index/index',
+    canShortcut: true,
+    description: '商品浏览、购买套餐、服务商品'
+  },
+
+  // --- 可配置的功能 ---
+  {
+    key: 'diet',
+    name: '饮食推荐',
+    icon: '🥗',
+    route: '/pages/diet/index',
+    canShortcut: true,
+    description: ' personalized 饮食建议、食物/产品推荐'
+  },
+  {
+    key: 'ai',
+    name: 'AI管家',
+    icon: '🤖',
+    route: '/pages/ai/chat',
+    canShortcut: true,
+    description: 'AI健康问答、营养分析、挑食改善建议'
+  },
+  {
+    key: 'growth',
+    name: '成长记录',
+    icon: '📈',
+    route: '/pages/growth-main/index',
+    canShortcut: true,
+    description: '查看全家成长档案、里程碑、成长轨迹'
+  },
+  {
+    key: 'wish',
+    name: '心愿',
+    icon: '⭐',
+    route: '/pages/wishes/index',
+    canShortcut: true,
+    description: '创建心愿、浏览他人心愿、兑换'
+  },
+  {
+    key: 'medal',
+    name: '勋章',
+    icon: '🏅',
+    route: '/pages/rewards/rewards',
+    canShortcut: true,
+    description: '查看勋章墙、勋章获取方式'
+  },
+  {
+    key: 'game',
+    name: '小游戏',
+    icon: '🎮',
+    route: '/pages/games/list',
+    canShortcut: true,
+    description: '舒尔特方格、猜数字、数独小游戏'
+  },
+  {
+    key: 'assess',
+    name: '测评',
+    icon: '🧪',
+    route: '/pages/assessment/apply',
+    canShortcut: true,
+    description: '预约测评、查看报告、历史测评'
+  },
+  {
+    key: 'activity',
+    name: '活动',
+    icon: '🎪',
+    route: '/pages/activity/index',
+    canShortcut: true,
+    description: '查看活动、报名参与、活动记录'
+  },
+  {
+    key: 'membership',
+    name: '会员中心',
+    icon: '👑',
+    route: '/pages/membership/index',
+    canShortcut: true,
+    description: '查看会员状态、续费/升级、会员权益'
+  },
+  {
+    key: 'wealth',
+    name: '财富',
+    icon: '💰',
+    route: '/pages/wealth/index',
+    canShortcut: true,
+    description: '查看财富积分、收益明细、理财套餐'
+  }
+]
+
+/**
+ * 获取应用库(所有 key 的只读快照,外部请勿修改原数组)
+ * 用法:import { APP_LIBRARY } from '@/utils/app-library';或 APP_LIBRARY.slice()
+ */
+export const getAppLibrary = () => APP_LIBRARY.slice()
+
+/**
+ * 根据 key 在应用库中查找应用信息
+ * 用法:import { findAppByKey } from '@/utils/app-library'
+ *      const app = findAppByKey('task')
+ */
+export const findAppByKey = (key) => {
+  return APP_LIBRARY.find(app => app.key === key)
+}
+
+/**
+ * 获取所有可以放入首页快捷区的应用(canShortcut=true)
+ * 用法:import { getShortcutableApps } from '@/utils/app-library'
+ *      const shortcutable = getShortcutableApps()
+ */
+export const getShortcutableApps = () => APP_LIBRARY.filter(app => app.canShortcut === true)
+
+/**
+ * 获取所有已在首页有显著入口的应用(canShortcut=false)
+ * 用法:import { getFixedApps } from '@/utils/app-library'
+ *      const fixed = getFixedApps()
+ */
+export const getFixedApps = () => APP_LIBRARY.filter(app => app.canShortcut === false)

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-8a3653176c71aef139ced32fee641d17a8335990
+87a01fab59270effda54c0521450b360e1f6c498

+ 9 - 2
cfc-web/package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1453",
+  "version": "1.0.1455",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "cfc-web",
-      "version": "1.0.1453",
+      "version": "1.0.1455",
       "dependencies": {
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor-for-vue": "^1.0.2",
@@ -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,

+ 2 - 1
cfc-web/package.json

@@ -1,6 +1,6 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1453",
+  "version": "1.0.1456",
   "private": true,
   "scripts": {
     "dev": "vue-cli-service serve",
@@ -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",

+ 41 - 0
cfc-web/public/CHANGELOG-v1.0.md

@@ -4,6 +4,47 @@
 
 ---
 
+## v1.0.1456 (2026-09-21)
+
+### Bug 修复
+- seedDiscountRulesMenu 的 count 改用 Integer
+- 语音/图片打卡无需强制文字内容
+
+### 其他
+- 用 int 接收后再与 null 比较会触发 "不可比较的类型: int和<nulltype>" 编译错误。
+- 改为 Integer 接收,null 判断即成立。
+- 
+
+### 新功能
+- 任务编排引擎与执行服务
+
+
+## v1.0.1455 (2026-09-21)
+
+### 文档
+- 首页快捷方式自定义+应用库+频度追踪设计规格
+
+
+## v1.0.1454 (2026-09-21)
+
+### 新功能
+- 首页引导配置化
+- 食材推荐高频入口前置,合并页统一导航
+
+### 其他
+- (icon/title/desc/isIntro)、展示位置(首页/孩子端)、版本号(version)。
+- 小程序 index-home/child-index 异步拉取配置并按版本比对决定是否展示,
+- 无配置或接口失败时回退到硬编码默认步骤。
+- 后端复用 sys_config 表(key=onboarding_guide_config),新增 AdminGuideConfigController
+- (管理端 get/save)和 GuideConfigController(小程序 current)。
+- DatabaseInitializer 迁移318预置默认配置。
+- 
+- - 成长 Tab 饮食推荐 / 食材戒备卡均改跳合并页 food-query
+- - 菌群报告页戒备入口改跳 food-query(带 reportId/memberId,进入默认戒备模式)
+- - food-query.vue 重写:支持成员切换、排序选择、红绿适合/戒备配色、家庭总览聚合
+- 
+
+
 ## v1.0.1453 (2026-09-21)
 
 ### Bug 修复

+ 42 - 1
cfc-web/public/CHANGELOG.md

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.1453
+> 当前版本: v1.0.1456
 
 ## 历史版本
 
@@ -8,6 +8,47 @@
 
 ---
 
+## v1.0.1456 (2026-09-21)
+
+### Bug 修复
+- seedDiscountRulesMenu 的 count 改用 Integer
+- 语音/图片打卡无需强制文字内容
+
+### 其他
+- 用 int 接收后再与 null 比较会触发 "不可比较的类型: int和<nulltype>" 编译错误。
+- 改为 Integer 接收,null 判断即成立。
+- 
+
+### 新功能
+- 任务编排引擎与执行服务
+
+
+## v1.0.1455 (2026-09-21)
+
+### 文档
+- 首页快捷方式自定义+应用库+频度追踪设计规格
+
+
+## v1.0.1454 (2026-09-21)
+
+### 新功能
+- 首页引导配置化
+- 食材推荐高频入口前置,合并页统一导航
+
+### 其他
+- (icon/title/desc/isIntro)、展示位置(首页/孩子端)、版本号(version)。
+- 小程序 index-home/child-index 异步拉取配置并按版本比对决定是否展示,
+- 无配置或接口失败时回退到硬编码默认步骤。
+- 后端复用 sys_config 表(key=onboarding_guide_config),新增 AdminGuideConfigController
+- (管理端 get/save)和 GuideConfigController(小程序 current)。
+- DatabaseInitializer 迁移318预置默认配置。
+- 
+- - 成长 Tab 饮食推荐 / 食材戒备卡均改跳合并页 food-query
+- - 菌群报告页戒备入口改跳 food-query(带 reportId/memberId,进入默认戒备模式)
+- - food-query.vue 重写:支持成员切换、排序选择、红绿适合/戒备配色、家庭总览聚合
+- 
+
+
 ## v1.0.1453 (2026-09-21)
 
 ### Bug 修复

+ 17 - 0
cfc-web/src/api/admin.js

@@ -2150,3 +2150,20 @@ export function getProfileTrend(memberId, days) {
     data: { memberId, days: days || 30 }
   })
 }
+
+// ========== 引导配置 API ==========
+
+export function getGuideConfig() {
+  return request({
+    url: '/api/admin/guide-config/get',
+    method: 'post'
+  })
+}
+
+export function saveGuideConfig(configValue) {
+  return request({
+    url: '/api/admin/guide-config/save',
+    method: 'post',
+    data: { configValue }
+  })
+}

+ 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 } })
+}

+ 24 - 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',
@@ -325,6 +343,12 @@ const routes = [
       component: () => import('@/views/admin/SysConfig.vue'),
       meta: { title: '系统配置', perm: 'system:base' }
     },
+    {
+      path: 'guide-config',
+      name: 'GuideConfig',
+      component: () => import('@/views/admin/GuideConfig.vue'),
+      meta: { title: '引导配置', perm: 'system:base' }
+    },
     {
       path: 'menu-manage',
       name: 'MenuManage',

+ 1 - 0
cfc-web/src/views/Layout.vue

@@ -294,6 +294,7 @@ export default {
             { title: '基础管理', icon: 'el-icon-s-tools', perm: 'system:base',
               children: [
                 { path: '/sys-config', label: '系统配置', icon: 'el-icon-s-tools', perm: 'system:base' },
+                { path: '/guide-config', label: '引导配置', icon: 'el-icon-setting', perm: 'system:base' },
                 { path: '/users', label: '用户管理', icon: 'el-icon-user', perm: 'system:users' },
                 { path: '/operation-logs', label: '操作日志', icon: 'el-icon-s-order', perm: 'system:logs' },
                 { path: '/membership-center', label: '会员中心', icon: 'el-icon-s-custom', perm: 'config:member' },

+ 264 - 0
cfc-web/src/views/admin/GuideConfig.vue

@@ -0,0 +1,264 @@
+<template>
+  <div class="guide-config admin-page">
+    <div class="header admin-page-header">
+      <h2 class="admin-page-title">引导配置</h2>
+      <div class="admin-page-desc">配置微信小程序引导遮罩的启用状态、展示位置与步骤内容</div>
+    </div>
+
+    <div class="config-body" v-loading="loading">
+      <!-- 基本配置 -->
+      <el-card shadow="never" class="config-block">
+        <div slot="header" class="block-header">
+          <span class="block-title">基本配置</span>
+        </div>
+        <el-form :model="form" label-width="110px">
+          <el-form-item label="启用引导">
+            <el-switch v-model="form.enabled" active-text="启用" inactive-text="停用" />
+          </el-form-item>
+          <el-form-item label="版本号">
+            <el-input v-model="form.version" placeholder="如: 1.0" class="version-input" />
+            <div class="form-tip">升级版本号后,小程序用户会重新看到引导遮罩</div>
+          </el-form-item>
+          <el-form-item label="展示位置">
+            <el-checkbox-group v-model="form.positions">
+              <el-checkbox label="home">首页</el-checkbox>
+              <el-checkbox label="child">孩子端首页</el-checkbox>
+            </el-checkbox-group>
+          </el-form-item>
+        </el-form>
+      </el-card>
+
+      <!-- 步骤配置 -->
+      <el-card shadow="never" class="config-block">
+        <div slot="header" class="block-header">
+          <span class="block-title">步骤配置</span>
+        </div>
+        <el-tabs v-model="activeTab">
+          <el-tab-pane label="家长端" name="parent">
+            <div v-for="(step, index) in stepsFor('parent')" :key="index">
+              <el-card shadow="never" class="step-card">
+                <div class="step-card-head">
+                  <span class="step-title">步骤 {{ index + 1 }}</span>
+                  <div class="step-actions">
+                    <el-button size="mini" type="danger" @click="removeStep('parent', index)">删除</el-button>
+                    <el-button size="mini" :disabled="index === 0" @click="moveUp('parent', index)">↑ 上移</el-button>
+                    <el-button size="mini" :disabled="index === stepsFor('parent').length - 1" @click="moveDown('parent', index)">↓ 下移</el-button>
+                  </div>
+                </div>
+                <el-row :gutter="16">
+                  <el-col :span="8">
+                    <div class="field-label">图标</div>
+                    <el-input v-model="step.icon" placeholder="如: el-icon-star" />
+                  </el-col>
+                  <el-col :span="8">
+                    <div class="field-label">标题</div>
+                    <el-input v-model="step.title" placeholder="步骤标题" />
+                  </el-col>
+                  <el-col :span="8">
+                    <div class="field-label">开场页</div>
+                    <el-switch v-model="step.isIntro" active-text="是" inactive-text="否" />
+                  </el-col>
+                </el-row>
+                <div class="field-label desc-label">描述</div>
+                <el-input v-model="step.desc" type="textarea" :rows="2" placeholder="步骤描述" />
+              </el-card>
+            </div>
+            <el-button size="small" icon="el-icon-plus" @click="addStep('parent')">新增步骤</el-button>
+          </el-tab-pane>
+
+          <el-tab-pane label="孩子端" name="child">
+            <div v-for="(step, index) in stepsFor('child')" :key="index">
+              <el-card shadow="never" class="step-card">
+                <div class="step-card-head">
+                  <span class="step-title">步骤 {{ index + 1 }}</span>
+                  <div class="step-actions">
+                    <el-button size="mini" type="danger" @click="removeStep('child', index)">删除</el-button>
+                    <el-button size="mini" :disabled="index === 0" @click="moveUp('child', index)">↑ 上移</el-button>
+                    <el-button size="mini" :disabled="index === stepsFor('child').length - 1" @click="moveDown('child', index)">↓ 下移</el-button>
+                  </div>
+                </div>
+                <el-row :gutter="16">
+                  <el-col :span="8">
+                    <div class="field-label">图标</div>
+                    <el-input v-model="step.icon" placeholder="如: el-icon-star" />
+                  </el-col>
+                  <el-col :span="8">
+                    <div class="field-label">标题</div>
+                    <el-input v-model="step.title" placeholder="步骤标题" />
+                  </el-col>
+                  <el-col :span="8">
+                    <div class="field-label">开场页</div>
+                    <el-switch v-model="step.isIntro" active-text="是" inactive-text="否" />
+                  </el-col>
+                </el-row>
+                <div class="field-label desc-label">描述</div>
+                <el-input v-model="step.desc" type="textarea" :rows="2" placeholder="步骤描述" />
+              </el-card>
+            </div>
+            <el-button size="small" icon="el-icon-plus" @click="addStep('child')">新增步骤</el-button>
+          </el-tab-pane>
+        </el-tabs>
+      </el-card>
+
+      <!-- 保存 -->
+      <div class="save-wrap">
+        <el-button type="primary" size="medium" :loading="submitting" @click="handleSave">保存</el-button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import { getGuideConfig, saveGuideConfig } from '@/api/admin'
+
+export default {
+  name: 'GuideConfig',
+  data() {
+    return {
+      loading: false,
+      submitting: false,
+      activeTab: 'parent',
+      form: {
+        enabled: true,
+        version: '',
+        positions: [],
+        parent: [],
+        child: []
+      }
+    }
+  },
+  mounted() {
+    this.loadConfig()
+  },
+  methods: {
+    stepsFor(key) {
+      return key === 'child' ? this.form.child : this.form.parent
+    },
+    async loadConfig() {
+      this.loading = true
+      try {
+        const res = await getGuideConfig()
+        if (res.data) {
+          this.form.enabled = typeof res.data.enabled === 'boolean' ? res.data.enabled : true
+          this.form.version = res.data.version || ''
+          this.form.positions = res.data.positions && res.data.positions.length ? res.data.positions : []
+          this.form.parent = res.data.parent && res.data.parent.length ? res.data.parent : []
+          this.form.child = res.data.child && res.data.child.length ? res.data.child : []
+        }
+      } catch (e) {
+        this.$message.error('加载失败')
+      } finally {
+        this.loading = false
+      }
+    },
+    addStep(key) {
+      const list = this.stepsFor(key)
+      const isFirst = !list || list.length === 0
+      list.push({ icon: '', title: '', desc: '', isIntro: isFirst })
+    },
+    removeStep(key, index) {
+      this.stepsFor(key).splice(index, 1)
+    },
+    moveUp(key, index) {
+      const list = this.stepsFor(key)
+      if (index <= 0) return
+      const item = list[index]
+      list.splice(index, 1)
+      list.splice(index - 1, 0, item)
+    },
+    moveDown(key, index) {
+      const list = this.stepsFor(key)
+      if (index >= list.length - 1) return
+      const item = list[index]
+      list.splice(index, 1)
+      list.splice(index + 1, 0, item)
+    },
+    async handleSave() {
+      const version = (this.form.version || '').trim()
+      if (!version) {
+        this.$message.warning('请填写版本号')
+        return
+      }
+      const totalSteps = (this.form.parent ? this.form.parent.length : 0) + (this.form.child ? this.form.child.length : 0)
+      if (totalSteps === 0) {
+        this.$message.warning('请至少添加一个引导步骤')
+        return
+      }
+      const payload = {
+        enabled: this.form.enabled,
+        version: version,
+        positions: this.form.positions,
+        parent: this.form.parent,
+        child: this.form.child
+      }
+      this.submitting = true
+      try {
+        await saveGuideConfig(JSON.stringify(payload))
+        this.$message.success('保存成功')
+      } catch (e) {
+        this.$message.error('保存失败')
+      } finally {
+        this.submitting = false
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.guide-config {
+  padding: 20px;
+}
+.admin-page-desc {
+  margin-top: 4px;
+  font-size: 13px;
+  color: #909399;
+}
+.config-body {
+  max-width: 1200px;
+  margin: 0 auto;
+}
+.config-block {
+  margin-bottom: 20px;
+}
+.block-header {
+  display: flex;
+  align-items: center;
+}
+.block-title {
+  font-weight: 600;
+}
+.version-input {
+  max-width: 320px;
+}
+.form-tip {
+  margin-top: 6px;
+  font-size: 12px;
+  color: #909399;
+  line-height: 1.5;
+}
+.step-card {
+  margin-bottom: 16px;
+}
+.step-card-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 12px;
+}
+.step-title {
+  font-weight: 600;
+}
+.field-label {
+  font-size: 12px;
+  color: #909399;
+  margin-bottom: 6px;
+}
+.desc-label {
+  margin-top: 12px;
+}
+.save-wrap {
+  margin-top: 24px;
+  text-align: center;
+}
+</style>

+ 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` 编译验证

+ 297 - 0
docs/superpowers/specs/2026-09-21-homepage-shortcut-application-library-design.md

@@ -0,0 +1,297 @@
+# 首页快捷方式自定义 + 应用库 + 频度追踪 设计规格
+
+- 日期:2026-09-21
+- 状态:待用户审查
+- 涉及模块:`cfc-backend`(后端)+ `cfc-frontend`(微信小程序)
+
+---
+
+## 1. 背景与目标
+
+首页(`pages/index-home/index.vue`)在"开通会员"卡片下方有一排 **6 个硬编码快捷入口**:任务、互动、天盘、成员、圈子、商城。
+
+当前问题:
+- 6 个入口写死在模板里,用户无法自定义。
+- 平台有大量"可直接进入"的功能页面(健康报告、饮食推荐、AI 管家、成长记录、心愿、测评、小游戏、活动、会员中心、财富等),散落在各分包,用户找不到。
+- 没有记录用户对每个功能的使用频度,无法做个性化推荐。
+
+目标:
+1. 把这 6 个快捷入口改造成**用户可自定义**(从应用库选择放哪些)。
+2. 为每个可直接进入的功能设计**图标**,形成**应用库**。
+3. 记录用户进入每个应用的**次数**。
+4. 根据**使用频度**自动调整首页快捷方式(锁定应用优先 + 频度填充)。
+5. 已在首页有显著入口的功能,**不进快捷配置列表**(避免重复)。
+
+---
+
+## 2. 需求澄清结论(已与用户确认)
+
+| 问题 | 结论 |
+|------|------|
+| 频度调整机制 | **自动排序但可手动覆盖**:系统按频度排序,但用户手动锁定后以锁定为准 |
+| 配置与频度存储 | **服务端存储**(换设备同步、频度累计准确) |
+| 适用人群 | **所有登录用户**(非会员与会员一致) |
+| 首页重复功能 | **已在首页显著位置的功能,从配置列表中排除** |
+| 应用库范围 | **全部候选功能都入库** |
+| 锁定的语义 | **锁定 = 置顶**,不会被点击次数挤掉;但用户仍可手动移除 |
+| 锁定操作入口 | **只在应用库配置页管理**,首页不显示锁图标 |
+| 自动填充逻辑 | **锁定占位 + 频度填满剩余** |
+| 锁定应用内部排序 | **支持**手动调整锁定应用之间的顺序 |
+| 频度计数触发 | **任意入口进入都计数**(含 TabBar 页) |
+
+---
+
+## 3. 架构方案
+
+采用 **混合方案**:
+
+- **应用库 = 前端常量文件** `utils/app-library.js`,图标用 **emoji**(与现有 6 个快捷入口一致,零静态资源成本)。
+- **后端建 2 张表**:`user_shortcut_config`(用户快捷配置)+ `user_app_usage`(使用频度)。
+
+理由:
+- 应用库本质是静态配置(key/名称/图标/路由/是否可快捷),前端维护最灵活,迭代快。
+- 新增应用需发版(可接受,功能迭代本就发版)。
+- 符合 YAGNI:应用库无"运营后台动态增删"需求,不做成后端表。
+
+---
+
+## 4. 数据模型
+
+### 4.1 `user_shortcut_config`(用户快捷配置)
+
+记录"用户把某个应用放到快捷区"的配置,**每应用一条记录**,`locked` 决定是否锁定。
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| id | BIGINT PK AUTO | 主键 |
+| user_id | BIGINT NOT NULL | 用户 ID |
+| app_key | VARCHAR(64) NOT NULL | 应用 key(对应 app-library.js) |
+| locked | TINYINT DEFAULT 0 | 是否锁定(1=锁定/置顶,0=普通) |
+| sort_order | INT DEFAULT 0 | 手动排序序号(锁定的应用之间排序用) |
+| created_at | DATETIME | 创建时间 |
+| updated_at | DATETIME | 更新时间 |
+
+- **唯一索引**:`(user_id, app_key)`
+
+> 语义:`locked=1` 的应用在首页快捷区**始终保留**(不被频度排序挤掉),按 `sort_order` 升序排在最前;`locked=0` 的应用参与频度填充。
+
+### 4.2 `user_app_usage`(应用使用频度)
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| id | BIGINT PK AUTO | 主键 |
+| user_id | BIGINT NOT NULL | 用户 ID |
+| app_key | VARCHAR(64) NOT NULL | 应用 key |
+| enter_count | INT DEFAULT 0 | 累计进入次数 |
+| last_enter_at | DATETIME | 最近进入时间 |
+
+- **唯一索引**:`(user_id, app_key)`
+
+---
+
+## 5. 应用库清单(前端常量 `utils/app-library.js`)
+
+每个应用字段:`key`、`name`、`icon`(emoji)、`route`、`canShortcut`(是否可放首页快捷区)。
+
+| key | 名称 | 图标 | 路由 | canShortcut |
+|-----|------|------|------|:---:|
+| task | 任务 | 📋 | /pages/tasks/tasks | ✅ |
+| interaction | 互动 | 💬 | /pages/action-detail/interaction-log | ✅ |
+| tiandi | 天盘 | 🏠 | /pages/mind-detail/family-dashboard | ✅ |
+| members | 成员 | 👨‍👩‍👧‍👦 | /pages/profile-extra/family-members | ✅ |
+| circle | 圈子 | 🤝 | /pages/discover/circles | ✅ |
+| shop | 商城 | 🛒 | /pages/shop/index/index | ✅ |
+| diet | 饮食推荐 | 🥗 | /pages/diet/index | ✅ |
+| ai | AI管家 | 🤖 | /pages/ai/chat | ✅ |
+| growth | 成长记录 | 📈 | /pages/growth-main/index | ✅ |
+| wish | 心愿 | ⭐ | /pages/wishes/index | ✅ |
+| medal | 勋章 | 🏅 | /pages/rewards/rewards | ✅ |
+| game | 小游戏 | 🎮 | /pages/games/list | ✅ |
+| assess | 测评 | 🧪 | /pages/assessment/apply | ✅ |
+| activity | 活动 | 🎪 | /pages/activity/index | ✅ |
+| membership | 会员中心 | 👑 | /pages/membership/index | ✅ |
+| wealth | 财富 | 💰 | /pages/wealth/index | ✅ |
+| report | 健康报告 | 🩺 | /pages/health/report-upload | ❌ 已在首页 |
+| article | 文章中心 | 📖 | /pages/article-center/index | ❌ 已在首页(推荐阅读) |
+
+> `canShortcut=false` 的应用在应用库页面可见但**置灰 + 标注"已在首页"**,用户无法选入 6 格。
+
+**默认 6 格**(新用户 / 无配置时)= 当前硬编码的 6 个:task、interaction、tiandi、members、circle、shop。
+
+---
+
+## 6. API 设计(统一 `@PostMapping` + `Result<T>`,JWT 认证)
+
+### 6.1 `POST /api/home/app-library`
+返回应用库 + 我的快捷配置 + 频度,**合并为一次请求**(避免 N+1)。
+
+- 请求:无 body(从 JWT 取 user_id)
+- 响应 `data`:
+  ```json
+  {
+    "apps": [
+      { "key": "task", "name": "任务", "icon": "📋", "route": "/pages/tasks/tasks", "canShortcut": true }
+    ],
+    "myShortcuts": [
+      { "appKey": "task", "locked": true, "sortOrder": 0 }
+    ],
+    "usage": { "task": 12, "shop": 8 }
+  }
+  ```
+
+### 6.2 `POST /api/home/shortcut/save`
+保存用户配置(全量覆盖用户对该用户的快捷配置)。
+
+- 请求 body:`{ "items": [ { "appKey": "task", "locked": true, "sortOrder": 0 } ] }`
+- 语义:删除该用户旧的 `user_shortcut_config` 记录,批量插入新的 `items`(每个 item 一条记录)。
+- 响应:`Result<Void>`
+
+### 6.3 `POST /api/home/app/enter`
+记录进入次数。
+
+- 请求 body:`{ "appKey": "task" }`
+- 语义:`user_app_usage` 的 `enter_count` +1,更新 `last_enter_at`(不存在则插入)。
+- 响应:`Result<Void>`
+
+### 6.4 `POST /api/home/shortcut/reset`
+重置为默认(清除锁定,回到系统按频度排序)。
+
+- 请求:无 body
+- 语义:删除该用户的 `user_shortcut_config` 记录。
+- 响应:`Result<Void>`
+
+---
+
+## 7. 前端改造
+
+### 7.1 首页快捷区(`pages/index-home/index.vue`)
+
+- 将 6 个硬编码 `<view class="quick-item">` 改为 `v-for` 动态渲染,数据来自 `POST /api/home/app-library`。
+- 快捷区右上角加"编辑"小图标 → 跳转应用库配置页 `/pages/home/app-library`。
+- 快捷区点击某个应用 → 先 `navTo(route)`,再调用 `POST /api/home/app/enter` 记录频度(注意:频度计数是"任意入口",故记录逻辑放在各应用入口页 onLoad,而非仅首页点击处,见 7.4)。
+
+### 7.2 应用库配置页(新增 `pages/home/app-library`)
+
+- 在 `pages.json` 注册(复用或新建分包 `pages/home`)。
+- 页面结构:
+  1. 顶部:**当前首页快捷区**(6 格预览),支持:
+     - 添加/移除应用(点击应用库里的应用切换选中态)
+     - 拖动/上移下移调整锁定应用顺序(小程序用「长按排序」或「上移/下移」按钮,避免复杂拖拽)
+     - 每个锁定应用显示"🔒 已锁定"标识
+  2. 中部:**应用库网格**,展示所有应用(图标 + 名称 + 进入次数),`canShortcut=false` 的置灰标注"已在首页"。
+  3. 底部按钮:
+     - **保存** → `POST /api/home/shortcut/save`
+     - **一键按频度排序** → 清除锁定,回到系统自动排序(调 `POST /api/home/shortcut/reset`)
+
+### 7.3 前端 API 封装(`utils/api.js`)
+
+按 AGENTS.md 规则 2(同 URL 唯一入口)新增:
+```js
+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', {})
+```
+
+### 7.4 频度计数埋点("任意入口进入都计数")
+
+在每个应用入口页的 `onLoad`(非 onShow,避免 Tab 切换重复计数)中调用 `reportAppEnter(appKey)`。
+
+- 复用 `_onLoadFired` 模式:`onLoad` 只触发一次,天然适合计数。
+- 埋点页面 = 应用库清单中所有 `canShortcut=true` 应用的入口页(约 16 个页面),在各自 `onLoad` 里加一行 `reportAppEnter('xxx')`(静默失败,不阻塞页面)。
+
+---
+
+## 8. 首页快捷区排序算法(核心逻辑)
+
+**输入**:`myShortcuts`(含 locked 标记)+ `usage`(频度)。
+
+**输出**:6 个应用 key(有序)。
+
+```
+1. lockedApps = myShortcuts 中 locked=1 的应用,按 sort_order 升序
+2. if lockedApps.length >= 6:
+       取前 6 个
+   else:
+       remaining = 6 - lockedApps.length
+       candidates = app-library 中 canShortcut=true 且 不在 lockedApps 里的应用
+       fill = candidates 按 usage[appKey] 降序排序,取前 remaining 个
+       若候选不足 remaining,用默认顺序补齐(不足 6 格就少显示)
+   结果 = lockedApps + fill
+```
+
+> 说明:`myShortcuts` 里 `locked=0` 的应用本质上是"用户主动加入但未锁定"的,在填充阶段它们会被纳入 `candidates` 一起按频度排序,因此仍有机会显示(频度高时),也可能被更高频应用挤掉。
+
+---
+
+## 9. 后端实现要点
+
+### 9.1 文件
+
+| 文件 | 说明 |
+|------|------|
+| `entity/UserShortcutConfig.java` | 实体,`@TableName("user_shortcut_config")` |
+| `entity/UserAppUsage.java` | 实体,`@TableName("user_app_usage")` |
+| `mapper/UserShortcutConfigMapper.java` | MyBatis-Plus Mapper |
+| `mapper/UserAppUsageMapper.java` | MyBatis-Plus Mapper |
+| `controller/HomeController.java` | 4 个接口 |
+| `service/HomeShortcutService.java`(或并入 controller) | 业务逻辑 |
+
+### 9.2 迁移(`DatabaseInitializer.runMigrations()`)
+
+新增迁移编号 **327**,建两张表(幂等,`CREATE TABLE IF NOT EXISTS`),并同步 `schema.sql`:
+
+```sql
+CREATE TABLE IF NOT EXISTS user_shortcut_config (
+  id BIGINT AUTO_INCREMENT PRIMARY KEY,
+  user_id BIGINT NOT NULL,
+  app_key VARCHAR(64) NOT NULL,
+  locked TINYINT DEFAULT 0,
+  sort_order INT DEFAULT 0,
+  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  UNIQUE KEY uk_user_app (user_id, app_key)
+);
+
+CREATE TABLE IF NOT EXISTS user_app_usage (
+  id BIGINT AUTO_INCREMENT PRIMARY KEY,
+  user_id BIGINT NOT NULL,
+  app_key VARCHAR(64) NOT NULL,
+  enter_count INT DEFAULT 0,
+  last_enter_at DATETIME,
+  UNIQUE KEY uk_user_app (user_id, app_key)
+);
+```
+
+### 9.3 规范遵循
+
+- `@PostMapping`、`Result<T>`、`@Resource` DI、JWT 认证(拦截 `/api/**`)。
+- Controller 内手动校验 `@RequestAttribute("role")`(所有登录角色可用,无需角色限制)。
+- Bean 命名无冲突(`HomeController` 需确认不与现有类重名)。
+
+---
+
+## 10. 边界情况
+
+1. **锁定数 > 6**:取 `sort_order` 前 6 个锁定应用。
+2. **候选应用不足 6 个**:少显示即可(不报错)。
+3. **频度全为 0**:用默认 6 个顺序。
+4. **并发计数**:`app/enter` 用 `UPDATE ... SET enter_count = enter_count + 1` 原子更新,避免覆盖。
+5. **重复上报**:`onLoad` 只触发一次,天然去重;后端对同 key 同用户做 upsert。
+6. **换设备**:配置与频度都在服务端,登录后同步。
+
+---
+
+## 11. 测试
+
+- **后端单测**:`HomeShortcutService` 排序算法(锁定占位 + 频度填充、锁定>6、候选不足)。
+- **前端**:`utils/app-library.js` 导出结构校验;首页快捷区 `v-for` 渲染(含空态)。
+- **CI 门禁**:`node scripts/audit-duplicate-api-calls.js` 通过(新页面 onLoad 埋点不重复打同一 URL)。
+
+---
+
+## 12. 交付范围
+
+- 后端:2 张表迁移 + 2 实体 + 2 Mapper + 1 Controller + 排序逻辑。
+- 前端:`utils/app-library.js` + `utils/api.js` 4 个封装 + 首页改造 + 应用库配置页 + 16 个入口页埋点。
+- 不涉及:运营后台动态维护应用库(YAGNI,后续有需求再扩展)。