# 08 · 异步任务与 AI 工作流

## 1. 总体策略

**核心原则(借鉴 waoowaoo 代码卫士)**:
1. **所有 AI 调用必须异步**,禁止在 HTTP 请求线程内直接调 LLM/图/视频模型
2. **所有任务必须幂等**,用 `idempotencyKey` 去重
3. **所有任务必须可追溯**,记录 payload/result/error/耗时/成本
4. **所有任务必须可取消**,支持用户主动终止
5. **所有任务必须有心跳**,防止 Worker 假死

## 2. 任务队列拓扑

```
Producer(API Server)
   │  publish
   ▼
RabbitMQ
├── exchange: yaoai.tasks (topic)
├── queue: text.generate     (concurrency=50, prefetch=5)
├── queue: image.generate    (concurrency=20, prefetch=2)
├── queue: video.generate    (concurrency=10, prefetch=1)
├── queue: voice.tts         (concurrency=30, prefetch=5)
├── queue: video.assemble    (concurrency=5,  prefetch=1)
├── queue: lipsync           (concurrency=5,  prefetch=1)
├── queue: script.parse      (concurrency=10, prefetch=2)
└── dlx: yaoai.tasks.dlx     (死信,人工介入)
   │
   ▼ consume
Worker Pods(K8s HPA 按 queue 深度扩缩)
```

## 3. 任务状态机

```
     submit
        │
        ▼
    [QUEUED] ──── cancel ────► [CANCELLED]
        │
        │ pickup
        ▼
  [PROCESSING] ──── cancel ────► [CANCELLED](尽力)
        │
    success │ fail
        │   │
        ▼   ▼
 [COMPLETED][FAILED]
              │
              │ retry < max
              ▼
           [QUEUED]
              │
              │ retry exceeded
              ▼
           [DEAD](进 DLX,管理员介入)
```

## 4. 任务提交(幂等)

```java
@Service
public class TaskService {
    public Task submit(TaskType type, Long projectId, Object payload, String idempotencyKey) {
        // 1. 幂等检查
        var existing = taskRepo.findByIdempotencyKey(idempotencyKey);
        if (existing.isPresent()) return existing.get();

        // 2. 计费预检
        BigDecimal quoted = costEstimator.estimate(type, payload);
        BalanceFreeze freeze = billingService.freeze(userId, quoted, idempotencyKey, taskId);

        // 3. 持久化
        Task task = Task.builder()
            .taskId(UlidCreator.getUlid().toString())
            .type(type).projectId(projectId)
            .payload(json(payload))
            .idempotencyKey(idempotencyKey)
            .freezeId(freeze.getId())
            .status(QUEUED).attempt(0).maxAttempts(3)
            .build();
        taskRepo.save(task);

        // 4. 发布到 MQ
        rabbitTemplate.convertAndSend("yaoai.tasks", type.routingKey(), task.getTaskId());

        return task;
    }
}
```

## 5. Worker 处理模板

```java
@Component
@RabbitListener(queues = "video.generate")
public class VideoGenerateWorker {

    public void onMessage(String taskId) {
        Task task = taskService.lockAndMarkProcessing(taskId);
        ScheduledFuture<?> heartbeat = scheduler.scheduleAtFixedRate(
            () -> taskService.heartbeat(taskId),
            0, 10, TimeUnit.SECONDS
        );
        try {
            var payload = parse(task.getPayload());

            // 1. 为参考图(首尾帧)生成预签名 URL,第三方视频模型据此拉图
            //    Bucket 默认私有,TTL 30 分钟覆盖任务最大执行时长(10~15 分钟)
            String firstFrameUrl = storageProvider.getSignedUrl(
                payload.firstFrameKey(), Duration.ofMinutes(30));
            String lastFrameUrl = storageProvider.getSignedUrl(
                payload.lastFrameKey(), Duration.ofMinutes(30));
            List<String> charRefUrls = payload.characterRefKeys().stream()
                .map(k -> storageProvider.getSignedUrl(k, Duration.ofMinutes(30)))
                .toList();

            // 2. 调用 Provider(异步提交,返回外部任务 id)
            VideoTask vt = videoProvider.submit(VideoRequest.builder()
                .prompt(payload.videoScript())
                .firstFrameUrl(firstFrameUrl)
                .lastFrameUrl(lastFrameUrl)
                .referenceUrls(charRefUrls)
                .duration(payload.duration())
                .resolution(payload.resolution())
                .build());

            task.setExternalId(vt.externalId());
            taskRepo.save(task);

            // 3. 轮询 (5s × 120 次 = 10 分钟)
            VideoResult result = pollUntilDone(vt, Duration.ofMinutes(10));

            // 4. ⚠️ 关键: 从模型厂商临时 URL 拉回视频,转存到自有 OSS/TOS
            //    模型厂的返回 URL 通常 24 小时过期,绝不能直接存入数据库
            String targetKey = String.format("shots/%d/%d/video-v%d.mp4",
                payload.projectId(), payload.storyboardId(), payload.version());
            try (InputStream in = httpClient.stream(result.videoUrl())) {
                storageProvider.upload(targetKey, in, "video/mp4", result.sizeBytes());
            }
            Long mediaId = mediaService.createMediaObject(
                targetKey, "video/mp4", result.sizeBytes(),
                result.width(), result.height(), result.durationMs());

            // 5. 写 shot_assets(fileUrl 永远存 object key,不存 URL)
            assetService.createShotAsset(payload.storyboardId(), KEYFRAME_VIDEO, mediaId, task);

            // 6. 计费结算
            BigDecimal actualCost = costCalculator.actualCost(task, result);
            billingService.settle(task.getFreezeId(), actualCost);

            // 7. 标记完成 + 发事件(前端拿到后用 GET /api/media/{mediaId}/url 换签名 URL 播放)
            taskService.markCompleted(task, Map.of("mediaId", mediaId));
            sseService.publish(task.getProjectId(), "task.completed", task);

        } catch (CancelledException e) {
            billingService.rollback(task.getFreezeId());
            taskService.markCancelled(task);
        } catch (Exception e) {
            if (task.getAttempt() < task.getMaxAttempts() && isRetryable(e)) {
                taskService.requeueWithBackoff(task);
            } else {
                billingService.rollback(task.getFreezeId());
                taskService.markFailed(task, e);
                sseService.publish(task.getProjectId(), "task.failed", task);
            }
        } finally {
            heartbeat.cancel(false);
        }
    }
}
```

## 6. 三层 Agent 执行引擎

### 6.1 运行记录(GraphRun / GraphStep)

每次触发一个 Agent 流程(如"生成全集分镜")都创建 `GraphRun`,每个执行层/监督层步骤记录 `GraphStep`,支持:
- 断点续跑(从失败的 step 重启)
- 可视化回放(时间线)
- 审计追溯

### 6.2 执行伪码

```java
@Service
public class ProductionAgentOrchestrator {

    public GraphRun start(Long projectId, String userGoal) {
        GraphRun run = graphRunRepo.create(projectId, "PRODUCTION", userGoal);
        try {
            // Step 1: 决策层(选择执行路径)
            var decision = step(run, "decision", DECISION, () ->
                decisionAgent.decide(projectId, userGoal));

            for (String execName : decision.pipeline()) {
                // Step N: 执行层
                var result = step(run, execName, EXECUTION, () ->
                    executors.get(execName).execute(projectId, run));

                // 监督层
                if (needsSupervision(execName)) {
                    var review = step(run, execName + ".review", SUPERVISION, () ->
                        supervisionAgent.review(execName, result));
                    if (!review.passed()) {
                        if (review.retry()) result = retry(execName, review.feedback());
                        else break; // escalate to user
                    }
                }
            }
            run.markCompleted();
        } catch (Exception e) {
            run.markFailed(e);
        }
        return run;
    }
}
```

### 6.3 执行层 Executor 清单

| Executor | 对应 Toonflow Skill | 任务类型 |
|----------|---------------------|----------|
| `SkeletonExecutor` | script_execution_skeleton | 故事骨架 |
| `AdaptationExecutor` | script_execution_adaptation | 改编策略 |
| `ScriptExecutor` | script_execution_script | 剧本编写 |
| `DeriveAssetExecutor` | production_execution_derive_assets | 衍生资产分析 |
| `AssetImageExecutor` | production_execution_generate_assets | 角色/场景/道具图(异步) |
| `DirectorPlanExecutor` | production_execution_director_plan | 拍摄计划 |
| `StoryboardTableExecutor` | production_execution_storyboard_table | 分镜表 |
| `StoryboardPanelExecutor` | production_execution_storyboard_panel | 分镜面板写入 |
| `FrameGenerateExecutor` | frame_generate_first/last(AICB) | 首尾帧(异步并发) |
| `VideoGenerateExecutor` | video_generate | 视频生成(异步并发) |
| `VoiceTtsExecutor` | - | TTS |
| `AssembleExecutor` | - | FFmpeg 合成 |

## 7. Skill 加载与热更新

```java
@Service
public class SkillLoader {
    private final Map<String, String> cache = new ConcurrentHashMap<>();

    @PostConstruct
    public void init() {
        // 从 classpath:skills/ + 外部 config 目录 加载所有 .md
        reload();
        // 文件监听
        fileWatcher.watch(Paths.get("./skills"), this::reload);
        // Redis pub/sub
        redisTemplate.listenChannel("skill:reload", msg -> reload());
    }

    public String load(String key) { return cache.get(key); }

    public String loadRendered(String key, Long tenantId, Long projectId, Map<String,String> vars) {
        // 1. 查 prompt_templates 表找 scope=PROJECT 覆盖
        // 2. 查 scope=TENANT
        // 3. fallback 到 cache(GLOBAL / 文件)
        // 4. 模板渲染(mustache)
    }
}
```

## 8. 成本估算表

```java
@Component
public class CostEstimator {
    public BigDecimal estimate(TaskType type, Object payload) {
        return switch (type) {
            case SCRIPT_PARSE     -> BigDecimal.valueOf(5);
            case OUTLINE_GEN      -> BigDecimal.valueOf(10);
            case EPISODE_GEN      -> BigDecimal.valueOf(10);
            case SHOT_SPLIT       -> BigDecimal.valueOf(3 * shotCount);
            case CHARACTER_IMAGE  -> BigDecimal.valueOf(20);  // 四视图
            case SCENE_IMAGE      -> BigDecimal.valueOf(10);
            case PROP_IMAGE       -> BigDecimal.valueOf(10);
            case FRAME_GEN        -> BigDecimal.valueOf(15);  // 单张
            case VIDEO_GEN        -> estimateVideo(payload);
            case TTS              -> BigDecimal.valueOf(0.1 * chars);
            case LIPSYNC          -> BigDecimal.valueOf(30);
            case ASSEMBLE         -> BigDecimal.valueOf(5);
        };
    }
}
```

## 9. 超时与降级

| 场景 | 策略 |
|------|------|
| 第三方 AI 超时 | 退避重试 3 次,失败切换 fallback provider |
| 视频模型繁忙(429) | 指数退避 30s/60s/120s |
| Worker 假死(心跳超时 60s) | 定时任务标记 DEAD,重新入队 |
| 整体 MQ 故障 | Redis 降级队列(最多 1000 任务) |
| 余额不足 | 任务直接拒绝,返回 402 |

## 10. 定时任务(单实例 Scheduler)

```
每 1 分钟  : 清理过期 BalanceFreeze → ROLLED_BACK
每 5 分钟  : Worker 心跳扫描 → 超时标 DEAD 重新入队
每 30 分钟 : 统计聚合(usage_records → daily_usage_stats)
每 1 小时  : Agent Memory 摘要压缩(超过 100 条触发)
每天 02:00 : 清理过期通知、归档旧 audit_logs
每天 03:00 : 订阅续费检查、到期停用
```
