# 05 · 后端架构设计 (Java + Spring AI Alibaba)

## 1. 技术栈

| 层级 | 组件 | 版本 | 说明 |
|------|------|------|------|
| 语言 | Java | 17 LTS | |
| 框架 | Spring Boot | 3.3.x | |
| AI | **Spring AI Alibaba** | 1.0.0+ | 通义千问/万相/百炼原生集成 |
| ORM | **MyBatis-Plus** | 3.5.x | 内置分页/多租户/乐观锁/逻辑删除插件 |
| 迁移 | Flyway | 10.x | |
| DB | MySQL | 8.0 | 主存储 |
| 缓存 + 向量 | **Redis Stack** | 7.2+ | Spring Cache + 分布式锁 + RediSearch 向量检索(Agent 记忆) |
| MQ | RabbitMQ | 3.13 | 任务队列 |
| 对象存储 | **阿里云 OSS / 火山引擎 TOS**(生产) | - | 媒体资源,签名 URL 给第三方 AI 拉取参考图 |
| 对象存储(备选) | MinIO | - | 本地开发 / 私有化部署,S3 兼容 |
| 搜索 | ~~Elasticsearch~~(M8 后按需引入) | - | MVP 阶段用 MySQL FULLTEXT 顶住 |
| 安全 | Sa-Token (Redis 会话) | 1.39+ | 登录/权限/踢人/多账号体系开箱即用,未来可切 JWT 风格 |
| API | Spring WebFlux + SSE | 6.x | 异步推送 |
| 监控 | Actuator + Prometheus + Grafana | - | |
| 追踪 | SkyWalking / OpenTelemetry | - | |
| 容器 | Docker + Docker Compose | - | 开发态 |
| 编排 | Kubernetes | 1.28+ | 生产态(可选) |

## 2. 模块划分(多模块 Maven 项目)

```
yaoai-comic-studio/
├── yaoai-common/              # 通用工具、异常、常量、DTO 基类
├── yaoai-domain/              # 领域模型(MyBatis-Plus Entity + Mapper + Service)
├── yaoai-security/            # 认证授权(Sa-Token + Redis 会话 + 多租户 Filter)
├── yaoai-billing/             # 计费: 余额冻结、交易流水、订阅、套餐
├── yaoai-storage/             # 对象存储抽象(StorageProvider 接口: OSS / TOS / MinIO / S3)
├── yaoai-ai-core/             # AI 抽象层(Provider 接口、模型路由、Skill 加载)
├── yaoai-ai-providers/        # 具体 Provider 实现(通义/豆包/Kling/OpenAI/Gemini)
├── yaoai-agent/               # 三层 Agent 体系(Script/Production/Decision/Executor/Supervisor)
├── yaoai-pipeline/            # 业务流水线(剧本解析/资产生成/分镜/视频/合成)
├── yaoai-task/                # 任务系统(RabbitMQ Worker + 状态机 + 重试 + 心跳)
├── yaoai-media/               # FFmpeg 合成、字幕、转场、BGM
├── yaoai-api/                 # REST Controller + SSE + OpenAPI
├── yaoai-admin/               # 管理后台 API(员工/小组/用量/审计)
├── yaoai-worker/              # 可独立部署的 Worker 进程
├── yaoai-bootstrap/            # 主启动模块(组合 api+worker 或分开启动)
└── yaoai-test/                # 集成测试、系统测试、Testcontainers
```

**部署形态**:
- **api-server**: 只跑 `yaoai-api` + `yaoai-admin`(无状态,水平扩展)
- **worker**: 只跑 `yaoai-worker`(按队列类型分组,image/video/text/voice 独立扩缩)
- **scheduler**: 单实例跑定时任务(冻结清理、统计聚合)

## 3. 分层架构

```
┌──────────────────────────────────────────┐
│ API Layer (Controller + DTO + OpenAPI)   │
├──────────────────────────────────────────┤
│ Application Layer (Service + UseCase)    │
│   - 事务边界、权限校验、租户注入         │
├──────────────────────────────────────────┤
│ Domain Layer                             │
│   - Entity / VO / Mapper / IService      │
│   - DomainEvent (Spring Event)           │
├──────────────────────────────────────────┤
│ Infrastructure Layer                     │
│   - AI Provider / Storage / MQ / Redis   │
│   - ExternalAPI(支付/短信/OAuth)         │
└──────────────────────────────────────────┘
```

## 4. 核心组件设计

### 4.1 AI Provider 抽象

```java
public interface AiProvider {
    String getId();
    Set<Capability> getCapabilities(); // TEXT, IMAGE, VIDEO, TTS, EMBEDDING
    List<ModelInfo> listModels();
}

public interface TextProvider extends AiProvider {
    Flux<String> streamChat(ChatRequest req);
    ChatResponse chat(ChatRequest req);
}

public interface ImageProvider extends AiProvider {
    ImageResult generate(ImageRequest req); // 支持参考图 List<ReferenceImage>
}

public interface VideoProvider extends AiProvider {
    VideoTask submit(VideoRequest req);     // 异步提交
    VideoTaskStatus poll(String externalId); // 轮询
}

public interface TtsProvider extends AiProvider {
    AudioResult synthesize(TtsRequest req);
}
```

**实现**(Spring `@Component + @ConditionalOnProperty`):
- `QwenTextProvider` - 通义千问 Max/Plus/Turbo
- `WanxImageProvider` - 通义万相文生图(支持参考图)
- `WanxVideoProvider` - 通义万相视频
- `SeedanceProvider` - 豆包 Seedance
- `KlingProvider` - 快手 Kling
- `OpenAIProvider` - OpenAI 兼容(可配置 baseUrl)

**路由器** `AiModelRouter`: 根据项目配置 > 全局默认 > fallback 选择 Provider。

### 4.2 Skill(提示词)管理

借鉴 Toonflow Markdown 外化 + AIComicBuilder 插槽化:

```java
@Service
public class SkillLoader {
    // skills/
    //   ├── script_agent_decision.md
    //   ├── script_execution_skeleton.md
    //   ├── art_skills/2d_anime/art_character.md
    //   └── ...

    public String load(String key);
    public String loadWithSlots(String key, Map<String, String> slots, Long tenantId, Long projectId);
    // 优先级: project > tenant > global > default
}

@Data
@TableName("prompt_templates")
public class PromptTemplate {
    @TableId(type = IdType.ASSIGN_ID)
    Long id;
    String promptKey;     // script_generate, shot_split, character_image...
    String slotKey;       // role_definition, output_format... (nullable = full)
    Scope scope;          // GLOBAL / TENANT / PROJECT
    Long tenantId;
    Long projectId;
    String content;
    @Version
    int version;
}
```

热更新: 文件变更监听 + Redis pub/sub 通知所有节点失效缓存。

### 4.3 三层 Agent 落地

```java
@Service
public class ScriptDecisionAgent {
    @Autowired ChatClient chatClient;
    @Autowired SkillLoader skills;
    @Autowired AgentMemoryService memory;

    @Tool("run_skeleton_executor")
    public String runSkeleton(String projectId, String context) {
        return skeletonExecutor.execute(projectId, context);
    }
    @Tool("run_adaptation_executor") ...
    @Tool("run_script_executor") ...

    public Flux<String> handle(String projectId, String userInput) {
        String sys = skills.load("script_agent_decision.md");
        MemoryContext ctx = memory.retrieve(projectId, userInput);
        return chatClient.prompt()
            .system(sys + ctx.asPromptSuffix())
            .user(userInput)
            .tools(this) // Spring AI Alibaba @Tool
            .stream()
            .content();
    }
}

@Service
public class SkeletonExecutor {
    public String execute(String projectId, String ctx) {
        String prompt = skills.load("script_execution_skeleton.md");
        // ...调用 chatClient,返回 <storySkeleton> XML
        // 监督层审核
        var review = supervisionAgent.review("skeleton", result);
        if (!review.passed()) return execute(...); // 最多重试 N 次
        return result;
    }
}
```

### 4.4 Agent Memory (Redis Stack / RediSearch)

用 Redis Stack 的 RediSearch 模块做向量检索,和业务缓存共用 Redis 集群(**生产环境向量实例与缓存实例分开部署**,避免大向量拖慢热 key)。Spring AI 官方提供 `RedisVectorStore`,和 `VectorStore` 其他实现接口完全一致,未来要迁 Milvus 零改动。

```java
@Configuration
public class VectorStoreConfig {
    @Bean
    public VectorStore agentMemoryStore(
            @Qualifier("vectorJedis") JedisPooled jedis,
            EmbeddingModel embeddingModel) {       // 通义 text-embedding-v3, dim=1024
        return RedisVectorStore.builder(jedis, embeddingModel)
            .indexName("agent_memory")
            .prefix("memory:")
            .metadataFields(
                MetadataField.tag("project_id"),
                MetadataField.tag("agent_type"),
                MetadataField.tag("role"),
                MetadataField.numeric("created_at")
            )
            .initializeSchema(true)
            .build();
    }
}

@Service
public class AgentMemoryService {
    @Autowired VectorStore vectorStore;

    public void add(String projectId, String role, String content) {
        vectorStore.add(List.of(new Document(content, Map.of(
            "project_id", projectId,
            "agent_type", "script",
            "role", role,
            "created_at", System.currentTimeMillis()
        ))));
        // 定期摘要压缩: 超过 100 条触发,旧消息合并成 summary 文档
    }

    public MemoryContext retrieve(String projectId, String query) {
        var results = vectorStore.similaritySearch(
            SearchRequest.query(query).withTopK(3)
                .withFilterExpression("project_id == '" + projectId + "'")
        );
        return new MemoryContext(results, shortTerm, summaries);
    }
}
```

**索引参数**: HNSW, `M=16, EF_CONSTRUCTION=200, DISTANCE_METRIC=COSINE`。
**持久化**: AOF `everysec` + RDB 每小时,防重启丢记忆。
**规模上限**: 单节点 64GB 内存可容纳约 2~3 万个活跃项目的记忆,超出时再迁 Milvus(接口层零改动)。

### 4.5 对象存储抽象 (StorageProvider)

生产默认 **阿里云 OSS**(同厂商通义系列内网免流量),也可切火山引擎 TOS(如果主力视频模型选豆包 Seedance)。本地开发可选 MinIO(S3 兼容)。

```java
public interface StorageProvider {
    String getProviderId();
    void upload(String key, InputStream data, String contentType, long size);
    InputStream download(String key);
    /** 给第三方 AI 服务拉参考图用,TTL 覆盖任务最大执行时长 */
    String getSignedUrl(String key, Duration ttl);
    /** 给用户下载用,可强制附件下载 */
    String getSignedDownloadUrl(String key, Duration ttl, String filename);
    void delete(String key);
    boolean exists(String key);
    ObjectMeta stat(String key);
}

@Component
@ConditionalOnProperty(name = "yaoai.storage.provider", havingValue = "oss")
public class AliyunOssProvider implements StorageProvider { /* aliyun-sdk-oss */ }

@Component
@ConditionalOnProperty(name = "yaoai.storage.provider", havingValue = "tos")
public class VolcengineTosProvider implements StorageProvider { /* ve-tos-java-sdk */ }

// TODO: MinIO 备选实现,本地开发和私有化部署用
// @Component
// @ConditionalOnProperty(name = "yaoai.storage.provider", havingValue = "minio")
// public class MinioProvider implements StorageProvider { ... }
```

**Bucket 规划**:
```
yaoai-media-{env}/
├── scripts/{tenantId}/{projectId}/original.pdf          # 剧本原文件(永远私有, TTL 5min)
├── characters/{projectId}/{charId}/turnaround-v{n}.png  # 角色四视图
├── scenes/{projectId}/{sceneId}/v{n}.jpg
├── shots/{projectId}/{shotId}/first-v{n}.jpg            # 首帧
├── shots/{projectId}/{shotId}/last-v{n}.jpg             # 尾帧
├── shots/{projectId}/{shotId}/video-v{n}.mp4            # 分镜视频
├── audio/{projectId}/{lineId}/voice-v{n}.mp3            # TTS
└── finals/{projectId}/{episodeId}/final.mp4             # 成片
```

**ACL**: Bucket 默认 **private**,第三方拉图**必须**通过预签名 URL(TTL 30 分钟,覆盖视频任务 10~15 分钟执行窗口)。

**CDN**: 生产配自定义域名 `https://media.yaoai.com` + CDN 回源 OSS,签名参数透传,前端播放视频走 CDN 加速。

### 4.6 任务系统

```java
@Data
@TableName("tasks")
public class Task {
    @TableId(type = IdType.ASSIGN_ID)
    Long id;
    String taskId;        // ULID
    Long tenantId;
    Long userId;
    Long projectId;
    TaskType type;        // SCRIPT_PARSE, IMAGE_GEN, VIDEO_GEN, TTS, ASSEMBLE...
    TaskStatus status;    // QUEUED, PROCESSING, COMPLETED, FAILED, CANCELLED
    @TableField(typeHandler = JacksonTypeHandler.class)
    String payload;       // JSON
    @TableField(typeHandler = JacksonTypeHandler.class)
    String result;        // JSON
    String error;
    int attempt;
    int maxAttempts;
    Instant heartbeatAt;  // 心跳防假死
    Instant createdAt;
    Instant startedAt;
    Instant finishedAt;
    String externalId;    // 第三方 AI 服务的任务 ID
    String idempotencyKey;
    Long freezeId;        // 关联的计费冻结
}

public interface TaskHandler<P, R> {
    TaskType supports();
    R handle(Task task, P payload) throws Exception;
}

@Component
@RabbitListener(queues = "video.generate")
public class VideoGenerateConsumer {
    public void onMessage(String taskId) {
        Task task = taskService.markProcessing(taskId);
        try {
            BillingContext billing = billingService.prepare(task);
            Result r = handler.handle(task, task.getPayload());
            taskService.markCompleted(task, r);
            billingService.settle(billing, r.actualCost);
        } catch (Exception e) {
            taskService.markFailed(task, e);
            billingService.rollback(billing);
        }
    }
}
```

## 5. 安全与多租户

### 5.1 认证(Sa-Token)

- **Sa-Token 1.39+**(`sa-token-spring-boot3-starter` + `sa-token-redis-jackson`)
- Token 默认 `uuid` 风格存 Redis,会话/权限/踢人/封号全部开箱即用
- 多账号体系:`StpUtil`(用户端) / `StpAdminUtil`(运营后台),互不干扰
- 注解鉴权:`@SaCheckLogin` / `@SaCheckPermission("project:edit")` / `@SaCheckRole("admin")`
- 登录后在 Session 存 `tenantId` / `userId` / `roles`,`TenantContextFilter` 从 `StpUtil.getSession()` 取
- 第三方登录:Sa-Token-OAuth2 支持 Google / GitHub / 微信 / 钉钉
- 注册 IP 限流(Bucket4j + Redis),密码 BCrypt salt=12
- **未来开放 API 给第三方开发者** 时,引入 `sa-token-jwt` 模块切 JWT 风格,代码零改动

```java
// 登录
StpUtil.login(user.getId());
StpUtil.getSession().set("tenantId", user.getTenantId());

// 权限校验(走 StpInterface 注入数据库查询)
@SaCheckPermission("project:edit")
@PutMapping("/projects/{id}")
public ApiResponse<Project> update(...) { ... }

// 踢人下线(封号、订阅到期、风控触发)
StpUtil.kickout(userId);
```

### 5.2 多租户隔离(MyBatis-Plus TenantLineInnerInterceptor)

```java
// 从 Sa-Token Session 取 tenantId 存入 ThreadLocal
@Component
public class TenantContextFilter extends OncePerRequestFilter {
    protected void doFilterInternal(...) {
        if (StpUtil.isLogin()) {
            Long tenantId = (Long) StpUtil.getSession().get("tenantId");
            TenantContext.set(tenantId);
        }
        try { chain.doFilter(req, res); } finally { TenantContext.clear(); }
    }
}

// MyBatis-Plus 插件一行搞定,所有 SQL 自动追加 WHERE tenant_id = ?
@Configuration
public class MybatisPlusConfig {
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();

        // 多租户插件(必须最先添加)
        interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(
            new TenantLineHandler() {
                @Override
                public Expression getTenantId() {
                    return new LongValue(TenantContext.get());
                }
                @Override
                public String getTenantIdColumn() {
                    return "tenant_id";
                }
                @Override
                public boolean ignoreTable(String tableName) {
                    // 无需租户隔离的表
                    return Set.of("global_assets", "prompt_presets").contains(tableName);
                }
            }
        ));

        // 分页插件
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));

        // 乐观锁插件(计费扣减等场景)
        interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());

        return interceptor;
    }
}

// Entity 示例 — 无需任何 Filter 注解,插件自动处理
@Data
@TableName("projects")
public class Project {
    @TableId(type = IdType.ASSIGN_ID)
    private Long id;
    private Long tenantId;       // 插件自动追加到 WHERE 条件
    private Long ownerUserId;
    private String title;
    // ...
    @TableLogic
    private Integer deleted;     // 逻辑删除(0=未删, 1=已删)
}

// 跨租户操作(白名单场景): 临时忽略租户插件
@InterceptorIgnore(tenantLine = "true")
@Select("SELECT * FROM global_assets WHERE library = 'MARKET'")
List<GlobalAsset> selectPublicAssets();
```

**三级隔离**:
1. Free/Pro/Team 共享 schema + `tenant_id` 列(MyBatis-Plus TenantLineInnerInterceptor 自动注入)
2. Enterprise 可选独立 schema(MyBatis-Plus `DynamicTableNameInnerInterceptor` + 动态数据源)
3. Private 部署整库独占

### 5.3 RBAC

角色 × 权限矩阵:
- `PROJECT:READ`、`PROJECT:WRITE`、`PROJECT:DELETE`
- `STORYBOARD:EDIT`、`STORYBOARD:GENERATE`、`STORYBOARD:APPROVE`
- `TEAM:INVITE`、`TEAM:REMOVE`
- `BILLING:VIEW`、`BILLING:RECHARGE`
- `ADMIN:*`

项目级角色: owner / admin / editor / viewer(直接采用前端原型的定义)。

## 6. 配置管理

```yaml
yaoai:
  ai:
    providers:
      qwen:
        api-key: ${QWEN_API_KEY}
        base-url: https://dashscope.aliyuncs.com
      wanx:
        api-key: ${QWEN_API_KEY}
    default-models:
      text: qwen-max
      image: wanx2.1-t2i-plus
      video: wanx2.1-video
      tts: qwen-tts
      embedding: text-embedding-v3
  billing:
    mode: STRICT  # OFF | SOFT | STRICT
    free-credits: 100
  storage:
    provider: oss                     # oss | tos | minio(TODO) | s3
    bucket: yaoai-media-prod
    region: cn-hangzhou
    endpoint: oss-cn-hangzhou.aliyuncs.com
    access-key: ${STORAGE_AK}
    secret-key: ${STORAGE_SK}
    cdn-domain: https://media.yaoai.com   # 可选,签名 URL 优先走 CDN
    signed-url-ttl: PT30M                 # 给第三方 AI 拉参考图用
  vector:
    provider: redis                   # redis | milvus(未来)
    redis:
      host: ${VECTOR_REDIS_HOST}      # 与业务缓存 Redis 分实例
      port: 6379
      index-name: agent_memory
  queue:
    concurrency:
      text: 50
      image: 20
      video: 10
      voice: 30
```

## 7. 关键设计决策

| 决策 | 选择 | 原因 |
|------|------|------|
| 响应式 vs 阻塞 | **Servlet + @Async + Reactor 流式**混合 | AI SSE 流式用 Reactor,CRUD 用 Servlet 简单 |
| 同步 vs 异步 AI 调用 | **全部异步**通过 MQ | 防止 HTTP 超时、便于重试、可观测 |
| ORM 选型 | **MyBatis-Plus** | 团队熟悉、多租户/分页/乐观锁插件开箱即用、JSON 字段处理好、代码生成省时间 |
| 单体 vs 微服务 | **单体多模块起步**,Worker 独立部署 | 降低初期复杂度 |
| 本地 vs 分布式事务 | **本地事务 + Saga** | 跨库/跨服务用 Saga(订单-支付-入账) |
| WebSocket vs SSE | **SSE 为主**,WS 留给协作 | SSE 简单、穿透代理、浏览器原生 |
