You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 

42 KiB

ADR 05: 分镜方案表设计

状态: 已接受 已废弃
日期: 2026-02-12
决策者: 开发团队
废弃日期: 2026-02-12
废弃原因: 业务需求变更,不再需要多方案管理功能


⚠️ 废弃声明

此 ADR 已于 2026-02-12 废弃。storyboard_plans 表及相关功能已完全移除。

详见:Changelog: 移除 storyboard_plans 功能


上下文

当前分镜系统需要支持多套表现方案的管理:

  1. 业务需求

    • 每个分镜需要支持多套 AI 生成的图片方案(如"方案一"、"方案二")
    • 每个分镜需要支持多个参考视频
    • 用户需要能够选择一个默认方案用于预览和导出
    • 方案需要支持排序和元数据扩展
  2. 现有问题

    • 缺少统一的方案管理表
    • 无法灵活支持多种方案类型(图片/视频)
    • 缺少默认方案的概念
  3. 技术约束

    • 遵循 Jointo 项目架构规范(无物理外键、SMALLINT 枚举、UUID v7)
    • 需要支持高性能查询和扩展
    • 必须保证数据一致性

决策

我们决定新增 storyboard_plans 表,并在 storyboards 表中添加 default_plan_id 字段。

核心设计原则

  1. 统一方案管理:使用单表存储所有类型的方案(图片/视频)
  2. 类型枚举:使用 SMALLINT 存储方案类型(1=图片, 2=视频)
  3. 元数据扩展:使用 JSONB 存储方案的具体属性(url、尺寸、时长等)
  4. 应用层约束:不使用物理外键,在应用层保证引用完整性
  5. 默认方案:支持为每个分镜设置默认方案,删除时自动处理

表结构设计

1. storyboard_plans 表

CREATE TABLE "public"."storyboard_plans" (
    "plan_id" UUID NOT NULL,
    "storyboard_id" UUID NOT NULL,
    "plan_type" SMALLINT NOT NULL,
    "plan_name" TEXT NOT NULL,
    "description" TEXT,
    "display_order" INTEGER NOT NULL DEFAULT 0,
    "meta_data" JSONB NOT NULL DEFAULT '{}'::jsonb,
    "created_at" TIMESTAMPTZ NOT NULL DEFAULT now(),
    "updated_at" TIMESTAMPTZ NOT NULL DEFAULT now(),
    
    CONSTRAINT "storyboard_plans_pkey" PRIMARY KEY ("plan_id"),
    CONSTRAINT "storyboard_plans_name_unique" UNIQUE ("storyboard_id", "plan_name"),
    CONSTRAINT "storyboard_plans_type_check" CHECK ("plan_type" IN (1, 2))
);

-- 索引设计
CREATE INDEX "idx_storyboard_plans_storyboard" 
    ON "public"."storyboard_plans" ("storyboard_id");
CREATE INDEX "idx_storyboard_plans_type" 
    ON "public"."storyboard_plans" ("plan_type");
CREATE INDEX "idx_storyboard_plans_storyboard_order" 
    ON "public"."storyboard_plans" ("storyboard_id", "display_order");

-- 触发器:自动更新 updated_at
CREATE TRIGGER "update_storyboard_plans_updated_at"
    BEFORE UPDATE ON "public"."storyboard_plans"
    FOR EACH ROW
    EXECUTE PROCEDURE "public"."update_updated_at_column"();

-- 字段注释 COMMENT ON TABLE "public"."storyboard_plans" IS '分镜方案表:存储分镜的具体表现方案(如多套AI生成图、参考视频等)'; COMMENT ON COLUMN "public"."storyboard_plans"."plan_id" IS '方案ID(UUID v7,应用层生成)'; COMMENT ON COLUMN "public"."storyboard_plans"."storyboard_id" IS '所属分镜ID(逻辑外键关联 storyboards 表,应用层验证)'; COMMENT ON COLUMN "public"."storyboard_plans"."plan_type" IS '方案类型:1=图片(image), 2=视频(video)'; COMMENT ON COLUMN "public"."storyboard_plans"."plan_name" IS '方案名称(如"方案一"、"AI生成版")'; COMMENT ON COLUMN "public"."storyboard_plans"."description" IS '方案备注描述'; COMMENT ON COLUMN "public"."storyboard_plans"."display_order" IS '显示顺序(用于前端排序)'; COMMENT ON COLUMN "public"."storyboard_plans"."meta_data" IS '扩展元数据(JSONB,用于存储 url, width, height, duration, prompt 等)'; COMMENT ON COLUMN "public"."storyboard_plans"."created_at" IS '创建时间(UTC,TIMESTAMPTZ 自动记录时区)'; COMMENT ON COLUMN "public"."storyboard_plans"."updated_at" IS '更新时间(UTC,TIMESTAMPTZ 自动记录时区)';


#### 字段说明

| 字段 | 类型 | 说明 |
|------|------|------|
| `plan_id` | UUID | 主键,应用层生成 UUID v7 |
| `storyboard_id` | UUID | 所属分镜ID,逻辑外键(应用层验证) |
| `plan_type` | SMALLINT | 方案类型:1=图片, 2=视频 |
| `plan_name` | TEXT | 方案名称,同一分镜下唯一 |
| `description` | TEXT | 方案描述(可选) |
| `display_order` | INTEGER | 显示顺序,默认 0 |
| `meta_data` | JSONB | 扩展元数据 |
| `created_at` | TIMESTAMPTZ | 创建时间(UTC) |
| `updated_at` | TIMESTAMPTZ | 更新时间(UTC) |

#### meta_data 结构示例

```json
// 图片方案 (plan_type = 1)
{
  "url": "https://storage.example.com/images/xxx.jpg",
  "width": 1920,
  "height": 1080,
  "format": "jpg",
  "size": 2048576,
  "prompt": "A beautiful sunset over the ocean",
  "ai_model": "stable-diffusion-xl",
  "generation_params": {
    "steps": 50,
    "cfg_scale": 7.5
  }
}

// 视频方案 (plan_type = 2)
{
  "url": "https://storage.example.com/videos/xxx.mp4",
  "width": 1920,
  "height": 1080,
  "duration": 5.5,
  "format": "mp4",
  "size": 10485760,
  "thumbnail_url": "https://storage.example.com/thumbnails/xxx.jpg",
  "prompt": "Camera panning across a cityscape",
  "ai_model": "runway-gen2"
}

2. storyboards 表新增字段

-- 添加 default_plan_id 字段
ALTER TABLE "public"."storyboards" 
    ADD COLUMN "default_plan_id" UUID;

-- 创建索引
CREATE INDEX "idx_storyboards_default_plan" 
    ON "public"."storyboards" ("default_plan_id") 
    WHERE "default_plan_id" IS NOT NULL;

-- 字段注释
COMMENT ON COLUMN "public"."storyboards"."default_plan_id" IS '默认方案ID(逻辑外键关联 storyboard_plans 表,应用层验证,允许为 NULL)';

字段说明

字段 类型 说明
default_plan_id UUID (nullable) 默认方案ID,指向 storyboard_plans.plan_id

设计决策

  • 允许 NULL:分镜可以没有默认方案
  • 应用层验证:不使用物理外键,在 Service 层验证引用完整性
  • 删除处理:删除默认方案时,自动选择 display_order 最小的其他方案

数据一致性保证

应用层验证策略

遵循 Jointo 项目架构规范,不使用数据库物理外键,在应用层保证引用完整性:

1. Repository 层 - 基础验证

# repositories/storyboard_plan_repository.py
from typing import Optional, List
from uuid import UUID
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession

from app.models.storyboard_plan import StoryboardPlan
from app.repositories.base_repository import BaseRepository


class StoryboardPlanRepository(BaseRepository[StoryboardPlan]):
    def __init__(self, db: AsyncSession):
        super().__init__(db, StoryboardPlan)
    
    async def get_by_storyboard(
        self, 
        storyboard_id: UUID
    ) -> List[StoryboardPlan]:
        """获取分镜的所有方案"""
        query = select(StoryboardPlan).where(
            StoryboardPlan.storyboard_id == storyboard_id
        ).order_by(StoryboardPlan.display_order)
        
        result = await self.db.execute(query)
        return list(result.scalars().all())
    
    async def get_by_id(self, plan_id: UUID) -> Optional[StoryboardPlan]:
        """根据 ID 获取方案"""
        query = select(StoryboardPlan).where(
            StoryboardPlan.plan_id == plan_id
        )
        result = await self.db.execute(query)
        return result.scalar_one_or_none()
    
    async def exists(self, plan_id: UUID) -> bool:
        """检查方案是否存在"""
        query = select(StoryboardPlan.plan_id).where(
            StoryboardPlan.plan_id == plan_id
        ).limit(1)
        result = await self.db.execute(query)
        return result.scalar_one_or_none() is not None
    
    async def check_name_exists(
        self, 
        storyboard_id: UUID, 
        plan_name: str,
        exclude_plan_id: Optional[UUID] = None
    ) -> bool:
        """检查方案名称是否已存在"""
        query = select(StoryboardPlan.plan_id).where(
            StoryboardPlan.storyboard_id == storyboard_id,
            StoryboardPlan.plan_name == plan_name
        )
        
        if exclude_plan_id:
            query = query.where(StoryboardPlan.plan_id != exclude_plan_id)
        
        result = await self.db.execute(query)
        return result.scalar_one_or_none() is not None
    
    async def get_first_by_order(
        self, 
        storyboard_id: UUID
    ) -> Optional[StoryboardPlan]:
        """获取 display_order 最小的方案"""
        query = select(StoryboardPlan).where(
            StoryboardPlan.storyboard_id == storyboard_id
        ).order_by(StoryboardPlan.display_order).limit(1)
        
        result = await self.db.execute(query)
        return result.scalar_one_or_none()
    
    async def delete_by_storyboard(self, storyboard_id: UUID) -> int:
        """删除分镜的所有方案"""
        plans = await self.get_by_storyboard(storyboard_id)
        for plan in plans:
            await self.db.delete(plan)
        await self.db.flush()
        return len(plans)

2. Service 层 - 业务验证

# services/storyboard_plan_service.py
from typing import List, Optional
from uuid import UUID
from sqlmodel.ext.asyncio.session import AsyncSession

from app.core.exceptions import NotFoundError, ValidationError
from app.core.logging import logger
from app.models.storyboard_plan import StoryboardPlan, PlanType
from app.repositories.storyboard_repository import StoryboardRepository
from app.repositories.storyboard_plan_repository import StoryboardPlanRepository
from app.schemas.storyboard_plan import (
    StoryboardPlanCreate,
    StoryboardPlanUpdate,
    StoryboardPlanResponse
)


class StoryboardPlanService:
    def __init__(self, db: AsyncSession):
        self.db = db
        self.plan_repo = StoryboardPlanRepository(db)
        self.storyboard_repo = StoryboardRepository(db)
    
    async def create_plan(
        self,
        user_id: UUID,
        storyboard_id: UUID,
        data: StoryboardPlanCreate
    ) -> StoryboardPlan:
        """创建方案 - 验证分镜存在"""
        
        # 1. 验证分镜是否存在
        storyboard = await self.storyboard_repo.get_by_id(storyboard_id)
        if not storyboard:
            raise NotFoundError("分镜不存在")
        
        # 2. 验证用户权限(通过项目权限)
        has_permission = await self.storyboard_repo.check_user_permission(
            user_id, storyboard_id
        )
        if not has_permission:
            raise ValidationError("无权限操作该分镜")
        
        # 3. 验证方案名称唯一性
        name_exists = await self.plan_repo.check_name_exists(
            storyboard_id, data.plan_name
        )
        if name_exists:
            raise ValidationError(f"方案名称 '{data.plan_name}' 已存在")
        
        # 4. 创建方案
        plan = StoryboardPlan(
            storyboard_id=storyboard_id,
            plan_type=data.plan_type,
            plan_name=data.plan_name,
            description=data.description,
            display_order=data.display_order,
            meta_data=data.meta_data or {}
        )
        
        self.db.add(plan)
        await self.db.flush()
        await self.db.refresh(plan)
        
        logger.info(
            "创建分镜方案成功: plan_id=%s, storyboard_id=%s, type=%s",
            plan.plan_id, storyboard_id, data.plan_type
        )
        
        # 5. 如果是第一个方案,自动设置为默认方案
        if not storyboard.default_plan_id:
            await self.storyboard_repo.update(
                storyboard_id,
                {"default_plan_id": plan.plan_id}
            )
            logger.info(
                "自动设置为默认方案: storyboard_id=%s, plan_id=%s",
                storyboard_id, plan.plan_id
            )
        
        await self.db.commit()
        return plan
    
    async def update_plan(
        self,
        user_id: UUID,
        plan_id: UUID,
        data: StoryboardPlanUpdate
    ) -> StoryboardPlan:
        """更新方案"""
        
        # 1. 验证方案是否存在
        plan = await self.plan_repo.get_by_id(plan_id)
        if not plan:
            raise NotFoundError("方案不存在")
        
        # 2. 验证用户权限
        has_permission = await self.storyboard_repo.check_user_permission(
            user_id, plan.storyboard_id
        )
        if not has_permission:
            raise ValidationError("无权限操作该方案")
        
        # 3. 如果更新名称,验证唯一性
        if data.plan_name and data.plan_name != plan.plan_name:
            name_exists = await self.plan_repo.check_name_exists(
                plan.storyboard_id,
                data.plan_name,
                exclude_plan_id=plan_id
            )
            if name_exists:
                raise ValidationError(f"方案名称 '{data.plan_name}' 已存在")
        
        # 4. 更新字段(排除 set_as_default)
        update_data = data.dict(exclude_unset=True, exclude={'set_as_default'})
        for key, value in update_data.items():
            setattr(plan, key, value)
        
        await self.db.flush()
        await self.db.refresh(plan)
        
        # 5. 如果需要设置为默认方案
        if data.set_as_default:
            await self.storyboard_repo.update(
                plan.storyboard_id,
                {"default_plan_id": plan_id}
            )
            logger.info(
                "更新方案并设置为默认: plan_id=%s, storyboard_id=%s",
                plan_id, plan.storyboard_id
            )
        
        await self.db.commit()
        
        logger.info("更新分镜方案成功: plan_id=%s", plan_id)
        return plan
    
    async def delete_plan(
        self,
        user_id: UUID,
        plan_id: UUID
    ) -> None:
        """删除方案 - 处理默认方案逻辑"""
        
        # 1. 验证方案是否存在
        plan = await self.plan_repo.get_by_id(plan_id)
        if not plan:
            raise NotFoundError("方案不存在")
        
        # 2. 验证用户权限
        has_permission = await self.storyboard_repo.check_user_permission(
            user_id, plan.storyboard_id
        )
        if not has_permission:
            raise ValidationError("无权限删除该方案")
        
        # 3. 获取分镜信息
        storyboard = await self.storyboard_repo.get_by_id(plan.storyboard_id)
        if not storyboard:
            raise NotFoundError("分镜不存在")
        
        # 4. 如果删除的是默认方案,自动选择新的默认方案
        if storyboard.default_plan_id == plan_id:
            # 获取其他方案
            other_plan = await self.plan_repo.get_first_by_order(
                plan.storyboard_id
            )
            
            # 如果有其他方案,选择 display_order 最小的
            if other_plan and other_plan.plan_id != plan_id:
                await self.storyboard_repo.update(
                    plan.storyboard_id,
                    {"default_plan_id": other_plan.plan_id}
                )
                logger.info(
                    "自动切换默认方案: storyboard_id=%s, new_plan_id=%s",
                    plan.storyboard_id, other_plan.plan_id
                )
            else:
                # 没有其他方案,设置为 NULL
                await self.storyboard_repo.update(
                    plan.storyboard_id,
                    {"default_plan_id": None}
                )
                logger.info(
                    "清空默认方案: storyboard_id=%s",
                    plan.storyboard_id
                )
        
        # 5. 删除方案
        await self.db.delete(plan)
        await self.db.commit()
        
        logger.info("删除分镜方案成功: plan_id=%s", plan_id)
    
    async def set_default_plan(
        self,
        user_id: UUID,
        storyboard_id: UUID,
        plan_id: UUID
    ) -> None:
        """设置默认方案"""
        
        # 1. 验证分镜是否存在
        storyboard = await self.storyboard_repo.get_by_id(storyboard_id)
        if not storyboard:
            raise NotFoundError("分镜不存在")
        
        # 2. 验证用户权限
        has_permission = await self.storyboard_repo.check_user_permission(
            user_id, storyboard_id
        )
        if not has_permission:
            raise ValidationError("无权限操作该分镜")
        
        # 3. 验证方案是否存在且属于该分镜
        plan = await self.plan_repo.get_by_id(plan_id)
        if not plan:
            raise NotFoundError("方案不存在")
        
        if plan.storyboard_id != storyboard_id:
            raise ValidationError("方案不属于该分镜")
        
        # 4. 更新默认方案
        await self.storyboard_repo.update(
            storyboard_id,
            {"default_plan_id": plan_id}
        )
        await self.db.commit()
        
        logger.info(
            "设置默认方案成功: storyboard_id=%s, plan_id=%s",
            storyboard_id, plan_id
        )
    
    async def get_plans_by_storyboard(
        self,
        user_id: UUID,
        storyboard_id: UUID
    ) -> List[StoryboardPlan]:
        """获取分镜的所有方案"""
        
        # 1. 验证分镜是否存在
        storyboard = await self.storyboard_repo.get_by_id(storyboard_id)
        if not storyboard:
            raise NotFoundError("分镜不存在")
        
        # 2. 验证用户权限
        has_permission = await self.storyboard_repo.check_user_permission(
            user_id, storyboard_id
        )
        if not has_permission:
            raise ValidationError("无权限查看该分镜")
        
        # 3. 获取所有方案
        plans = await self.plan_repo.get_by_storyboard(storyboard_id)
        return plans

3. 级联删除处理

# services/storyboard_service.py (部分代码)
async def delete_storyboard(
    self,
    user_id: UUID,
    storyboard_id: UUID
) -> None:
    """删除分镜 - 级联删除所有方案"""
    
    # 验证权限...
    
    # 使用事务处理级联删除
    async with self.db.begin():
        # 1. 删除所有方案
        await self.plan_repo.delete_by_storyboard(storyboard_id)
        
        # 2. 删除分镜本身
        await self.storyboard_repo.delete(storyboard_id)
        
        logger.info("删除分镜及其方案成功: storyboard_id=%s", storyboard_id)

Model 层实现

# models/storyboard_plan.py
"""
分镜方案模型

包含:
- StoryboardPlan: 分镜方案表
- PlanType: 方案类型枚举
"""
from datetime import datetime
from enum import IntEnum
from typing import Optional, Dict, Any
from uuid import UUID

from sqlalchemy import Column, Text, Integer, SmallInteger, CheckConstraint, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID as PG_UUID, JSONB, TIMESTAMP
from sqlmodel import SQLModel, Field

from app.utils.id_generator import generate_uuid


class PlanType(IntEnum):
    """方案类型枚举"""
    IMAGE = 1  # 图片
    VIDEO = 2  # 视频
    
    @classmethod
    def from_string(cls, value: str) -> Optional[int]:
        """字符串转数字"""
        mapping = {
            'image': cls.IMAGE,
            'video': cls.VIDEO
        }
        return mapping.get(value.lower())
    
    @classmethod
    def to_string(cls, value: int) -> Optional[str]:
        """数字转字符串"""
        mapping = {
            cls.IMAGE: 'image',
            cls.VIDEO: 'video'
        }
        return mapping.get(value)
    
    @classmethod
    def get_display_name(cls, value: int) -> str:
        """获取显示名称"""
        names = {
            cls.IMAGE: "图片",
            cls.VIDEO: "视频"
        }
        return names.get(value, "未知类型")


class StoryboardPlan(SQLModel, table=True):
    """分镜方案表"""
    __tablename__ = "storyboard_plans"
    
    plan_id: UUID = Field(
        sa_column=Column(
            PG_UUID(as_uuid=True),
            primary_key=True,
            default=generate_uuid
        )
    )
    storyboard_id: UUID = Field(
        sa_column=Column(PG_UUID(as_uuid=True), nullable=False, index=True)
    )
    
    # 方案类型
    plan_type: int = Field(
        sa_column=Column(SmallInteger, nullable=False)
    )
    
    # 基本信息
    plan_name: str = Field(
        sa_column=Column(Text, nullable=False)
    )
    description: Optional[str] = Field(
        default=None,
        sa_column=Column(Text, nullable=True)
    )
    
    # 排序
    display_order: int = Field(
        default=0,
        sa_column=Column(Integer, nullable=False)
    )
    
    # 扩展元数据
    meta_data: Dict[str, Any] = Field(
        default_factory=dict,
        sa_column=Column("meta_data", JSONB, nullable=False, server_default='{}')
    )
    
    # 审计字段
    created_at: datetime = Field(
        sa_column=Column(
            TIMESTAMP(timezone=True),
            nullable=False,
            server_default="now()"
        )
    )
    updated_at: datetime = Field(
        sa_column=Column(
            TIMESTAMP(timezone=True),
            nullable=False,
            server_default="now()"
        )
    )
    
    __table_args__ = (
        UniqueConstraint(
            'storyboard_id', 'plan_name',
            name='storyboard_plans_name_unique'
        ),
        CheckConstraint(
            'plan_type IN (1, 2)',
            name='storyboard_plans_type_check'
        ),
    )

Schema 层实现

# schemas/storyboard_plan.py
"""
分镜方案 Schema

包含:
- StoryboardPlanCreate: 创建方案请求
- StoryboardPlanUpdate: 更新方案请求
- StoryboardPlanResponse: 方案响应
"""
from typing import Optional, Dict, Any
from uuid import UUID
from pydantic import BaseModel, Field, field_validator, field_serializer
from enum import Enum

from app.models.storyboard_plan import PlanType


class PlanTypeEnum(str, Enum):
    """API 层方案类型枚举(字符串)"""
    IMAGE = "image"
    VIDEO = "video"


class StoryboardPlanCreate(BaseModel):
    """创建方案请求"""
    plan_type: int = Field(..., description="方案类型:1=图片, 2=视频")
    plan_name: str = Field(..., min_length=1, max_length=255, description="方案名称")
    description: Optional[str] = Field(None, description="方案描述")
    display_order: int = Field(default=0, description="显示顺序")
    meta_data: Optional[Dict[str, Any]] = Field(default=None, description="扩展元数据")
    
    @field_validator('plan_type')
    @classmethod
    def validate_plan_type(cls, v: int) -> int:
        """验证方案类型"""
        if v not in [PlanType.IMAGE, PlanType.VIDEO]:
            raise ValueError(f"无效的方案类型: {v}")
        return v
    
    @field_validator('plan_name')
    @classmethod
    def validate_plan_name(cls, v: str) -> str:
        """验证方案名称"""
        v = v.strip()
        if not v:
            raise ValueError("方案名称不能为空")
        return v


class StoryboardPlanUpdate(BaseModel):
    """更新方案请求"""
    plan_name: Optional[str] = Field(None, min_length=1, max_length=255)
    description: Optional[str] = None
    display_order: Optional[int] = None
    meta_data: Optional[Dict[str, Any]] = None
    set_as_default: Optional[bool] = Field(
        None, 
        alias="setAsDefault",
        description="是否设置为默认方案"
    )
    
    @field_validator('plan_name')
    @classmethod
    def validate_plan_name(cls, v: Optional[str]) -> Optional[str]:
        """验证方案名称"""
        if v is not None:
            v = v.strip()
            if not v:
                raise ValueError("方案名称不能为空")
        return v
    
    class Config:
        populate_by_name = True


class StoryboardPlanResponse(BaseModel):
    """方案响应"""
    plan_id: UUID = Field(..., alias="planId")
    storyboard_id: UUID = Field(..., alias="storyboardId")
    plan_type: str = Field(..., alias="planType")
    plan_name: str = Field(..., alias="planName")
    description: Optional[str] = None
    display_order: int = Field(..., alias="displayOrder")
    meta_data: Dict[str, Any] = Field(..., alias="metaData")
    created_at: str = Field(..., alias="createdAt")
    updated_at: str = Field(..., alias="updatedAt")
    
    @field_serializer('plan_type')
    def serialize_plan_type(self, value: int) -> str:
        """API 输出:整数 → 字符串"""
        return PlanType.to_string(value) or "unknown"
    
    @field_serializer('created_at', 'updated_at')
    def serialize_datetime(self, value) -> str:
        """序列化时间为 ISO 8601 格式"""
        if hasattr(value, 'isoformat'):
            return value.isoformat()
        return str(value)
    
    class Config:
        populate_by_name = True
        from_attributes = True


class SetDefaultPlanRequest(BaseModel):
    """设置默认方案请求"""
    plan_id: UUID = Field(..., alias="planId", description="方案ID")
    
    class Config:
        populate_by_name = True

API 层实现示例

# api/v1/storyboard_plans.py
"""
分镜方案 API

提供分镜方案的 CRUD 操作和默认方案设置
"""
from typing import List
from uuid import UUID
from fastapi import APIRouter, Depends, status
from sqlmodel.ext.asyncio.session import AsyncSession

from app.core.database import get_db
from app.core.dependencies import get_current_user
from app.core.response import success_response, ApiResponse
from app.models.user import User
from app.schemas.storyboard_plan import (
    StoryboardPlanCreate,
    StoryboardPlanUpdate,
    StoryboardPlanResponse,
    SetDefaultPlanRequest
)
from app.services.storyboard_plan_service import StoryboardPlanService

router = APIRouter(prefix="/storyboards", tags=["storyboard-plans"])


@router.post(
    "/{storyboard_id}/plans",
    response_model=ApiResponse[StoryboardPlanResponse],
    status_code=status.HTTP_201_CREATED,
    summary="创建分镜方案"
)
async def create_plan(
    storyboard_id: UUID,
    data: StoryboardPlanCreate,
    current_user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db)
):
    """
    创建分镜方案
    
    - **storyboard_id**: 分镜ID
    - **plan_type**: 方案类型(1=图片, 2=视频)
    - **plan_name**: 方案名称(同一分镜下唯一)
    - **description**: 方案描述(可选)
    - **display_order**: 显示顺序(默认0)
    - **meta_data**: 扩展元数据(可选)
    """
    service = StoryboardPlanService(db)
    plan = await service.create_plan(
        user_id=current_user.user_id,
        storyboard_id=storyboard_id,
        data=data
    )
    return success_response(
        data=StoryboardPlanResponse.model_validate(plan),
        message="创建方案成功"
    )


@router.get(
    "/{storyboard_id}/plans",
    response_model=ApiResponse[List[StoryboardPlanResponse]],
    summary="获取分镜的所有方案"
)
async def get_plans(
    storyboard_id: UUID,
    current_user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db)
):
    """
    获取分镜的所有方案
    
    - **storyboard_id**: 分镜ID
    
    返回按 display_order 排序的方案列表
    """
    service = StoryboardPlanService(db)
    plans = await service.get_plans_by_storyboard(
        user_id=current_user.user_id,
        storyboard_id=storyboard_id
    )
    return success_response(
        data=[StoryboardPlanResponse.model_validate(p) for p in plans]
    )


@router.patch(
    "/{storyboard_id}/plans/{plan_id}",
    response_model=ApiResponse[StoryboardPlanResponse],
    summary="更新分镜方案"
)
async def update_plan(
    storyboard_id: UUID,
    plan_id: UUID,
    data: StoryboardPlanUpdate,
    current_user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db)
):
    """
    更新分镜方案
    
    - **storyboard_id**: 分镜ID
    - **plan_id**: 方案ID
    - **plan_name**: 方案名称(可选)
    - **description**: 方案描述(可选)
    - **display_order**: 显示顺序(可选)
    - **meta_data**: 扩展元数据(可选)
    - **set_as_default**: 是否设置为默认方案(可选,true 时同步更新 storyboards.default_plan_id)
    """
    service = StoryboardPlanService(db)
    plan = await service.update_plan(
        user_id=current_user.user_id,
        plan_id=plan_id,
        data=data
    )
    return success_response(
        data=StoryboardPlanResponse.model_validate(plan),
        message="更新方案成功"
    )


@router.delete(
    "/{storyboard_id}/plans/{plan_id}",
    response_model=ApiResponse[None],
    status_code=status.HTTP_204_NO_CONTENT,
    summary="删除分镜方案"
)
async def delete_plan(
    storyboard_id: UUID,
    plan_id: UUID,
    current_user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db)
):
    """
    删除分镜方案
    
    - **storyboard_id**: 分镜ID
    - **plan_id**: 方案ID
    
    如果删除的是默认方案,会自动选择 display_order 最小的其他方案作为新的默认方案
    """
    service = StoryboardPlanService(db)
    await service.delete_plan(
        user_id=current_user.user_id,
        plan_id=plan_id
    )
    return success_response(message="删除方案成功")


@router.post(
    "/{storyboard_id}/default-plan",
    response_model=ApiResponse[None],
    summary="设置默认方案"
)
async def set_default_plan(
    storyboard_id: UUID,
    data: SetDefaultPlanRequest,
    current_user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db)
):
    """
    设置默认方案
    
    - **storyboard_id**: 分镜ID
    - **plan_id**: 方案ID
    """
    service = StoryboardPlanService(db)
    await service.set_default_plan(
        user_id=current_user.user_id,
        storyboard_id=storyboard_id,
        plan_id=data.plan_id
    )
    return success_response(message="设置默认方案成功")

Alembic 迁移脚本

"""创建 storyboard_plans 表并为 storyboards 表添加 default_plan_id 字段

Revision ID: xxx_create_storyboard_plans
Revises: previous_revision
Create Date: 2026-02-12
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql


# revision identifiers, used by Alembic.
revision = 'xxx_create_storyboard_plans'
down_revision = 'previous_revision'
branch_labels = None
depends_on = None


def upgrade() -> None:
    """升级"""
    
    # 1. 创建 storyboard_plans 表
    op.create_table(
        'storyboard_plans',
        sa.Column('plan_id', postgresql.UUID(as_uuid=True), nullable=False),
        sa.Column('storyboard_id', postgresql.UUID(as_uuid=True), nullable=False),
        sa.Column('plan_type', sa.SmallInteger(), nullable=False),
        sa.Column('plan_name', sa.Text(), nullable=False),
        sa.Column('description', sa.Text(), nullable=True),
        sa.Column('display_order', sa.Integer(), nullable=False, server_default='0'),
        sa.Column('meta_data', postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default='{}'),
        sa.Column(
            'created_at',
            postgresql.TIMESTAMP(timezone=True),
            nullable=False,
            server_default=sa.text('now()')
        ),
        sa.Column(
            'updated_at',
            postgresql.TIMESTAMP(timezone=True),
            nullable=False,
            server_default=sa.text('now()')
        ),
        sa.PrimaryKeyConstraint('plan_id', name='storyboard_plans_pkey'),
        sa.UniqueConstraint('storyboard_id', 'plan_name', name='storyboard_plans_name_unique'),
        sa.CheckConstraint('plan_type IN (1, 2)', name='storyboard_plans_type_check')
    )
    
    # 2. 创建索引
    op.create_index(
        'idx_storyboard_plans_storyboard',
        'storyboard_plans',
        ['storyboard_id']
    )
    op.create_index(
        'idx_storyboard_plans_type',
        'storyboard_plans',
        ['plan_type']
    )
    op.create_index(
        'idx_storyboard_plans_storyboard_order',
        'storyboard_plans',
        ['storyboard_id', 'display_order']
    )
    
    # 3. 添加表注释
    op.execute("""
        COMMENT ON TABLE storyboard_plans IS '分镜方案表:存储分镜的具体表现方案(如多套AI生成图、参考视频等)'
    """)
    
    # 4. 添加列注释
    op.execute("""
        COMMENT ON COLUMN storyboard_plans.plan_id IS '方案ID(UUID v7,应用层生成)';
        COMMENT ON COLUMN storyboard_plans.storyboard_id IS '所属分镜ID(逻辑外键关联 storyboards 表,应用层验证)';
        COMMENT ON COLUMN storyboard_plans.plan_type IS '方案类型:1=图片(image), 2=视频(video)';
        COMMENT ON COLUMN storyboard_plans.plan_name IS '方案名称(如"方案一"、"AI生成版")';
        COMMENT ON COLUMN storyboard_plans.description IS '方案备注描述';
        COMMENT ON COLUMN storyboard_plans.display_order IS '显示顺序(用于前端排序)';
        COMMENT ON COLUMN storyboard_plans.meta_data IS '扩展元数据(JSONB,用于存储 url, width, height, duration, prompt 等)';
        COMMENT ON COLUMN storyboard_plans.created_at IS '创建时间(UTC,TIMESTAMPTZ 自动记录时区)';
        COMMENT ON COLUMN storyboard_plans.updated_at IS '更新时间(UTC,TIMESTAMPTZ 自动记录时区)';
    """)
    
    # 5. 创建触发器(自动更新 updated_at)
    op.execute("""
        CREATE TRIGGER update_storyboard_plans_updated_at
            BEFORE UPDATE ON storyboard_plans
            FOR EACH ROW
            EXECUTE PROCEDURE update_updated_at_column();
    """)
    
    # 6. 为 storyboards 表添加 default_plan_id 字段
    op.add_column(
        'storyboards',
        sa.Column('default_plan_id', postgresql.UUID(as_uuid=True), nullable=True)
    )
    
    # 7. 创建条件索引(仅索引非 NULL 值)
    op.create_index(
        'idx_storyboards_default_plan',
        'storyboards',
        ['default_plan_id'],
        postgresql_where=sa.text('default_plan_id IS NOT NULL')
    )
    
    # 8. 添加列注释
    op.execute("""
        COMMENT ON COLUMN storyboards.default_plan_id IS '默认方案ID(逻辑外键关联 storyboard_plans 表,应用层验证,允许为 NULL)';
    """)


def downgrade() -> None:
    """降级"""
    
    # 1. 删除 storyboards 表的 default_plan_id 字段
    op.drop_index('idx_storyboards_default_plan', table_name='storyboards')
    op.drop_column('storyboards', 'default_plan_id')
    
    # 2. 删除触发器
    op.execute('DROP TRIGGER IF EXISTS update_storyboard_plans_updated_at ON storyboard_plans')
    
    # 3. 删除索引
    op.drop_index('idx_storyboard_plans_storyboard_order', table_name='storyboard_plans')
    op.drop_index('idx_storyboard_plans_type', table_name='storyboard_plans')
    op.drop_index('idx_storyboard_plans_storyboard', table_name='storyboard_plans')
    
    # 4. 删除表
    op.drop_table('storyboard_plans')

理由

1. 统一方案管理

优点

  • 单表存储所有类型的方案,简化数据模型
  • 使用 JSONB 存储不同类型方案的特定属性,灵活扩展
  • 统一的 CRUD 接口,降低开发和维护成本

对比多表方案

  • 多表方案:需要 storyboard_imagesstoryboard_videos 两张表
  • 单表方案:一张 storyboard_plans 表,通过 plan_type 区分

2. SMALLINT 枚举

优点

  • 查询性能优于字符串枚举(整数比较)
  • 索引大小更小(2 字节 vs 字符串长度)
  • 添加新类型无需 ALTER TYPE(避免锁表)
  • 跨语言支持更好(整数是通用类型)

实现

  • 数据库层:SMALLINT + CHECK 约束
  • Model 层:Python IntEnum
  • Schema 层:字符串枚举(API 兼容性)

3. 应用层引用完整性

优点

  • 写入性能提升 15-30%(无外键检查开销)
  • 避免锁竞争(无表级锁)
  • 支持分库分表扩展
  • 业务逻辑更灵活(完整编程能力)

保证机制

  • Repository 层:基础验证(exists、batch_exists)
  • Service 层:业务验证(权限、唯一性、级联处理)
  • 后台任务:定期数据完整性检查(可选)

4. 默认方案设计

优点

  • 允许 NULL:分镜可以没有默认方案(灵活)
  • 自动选择:删除默认方案时自动选择新方案(用户体验好)
  • 应用层验证:不使用物理外键(性能优势)

删除逻辑

删除方案 → 是否为默认方案?
  ├─ 是 → 是否有其他方案?
  │   ├─ 是 → 选择 display_order 最小的方案
  │   └─ 否 → 设置为 NULL
  └─ 否 → 直接删除

5. JSONB 元数据

优点

  • 灵活存储不同类型方案的特定属性
  • 支持 JSON 查询和索引(PostgreSQL 原生支持)
  • 无需频繁修改表结构

使用场景

  • 图片方案:url, width, height, format, size, prompt, ai_model
  • 视频方案:url, width, height, duration, format, size, thumbnail_url, prompt

6. 索引策略

核心索引

  • idx_storyboard_plans_storyboard:根据分镜ID查询所有方案(最频繁)
  • idx_storyboard_plans_type:按类型统计和筛选
  • idx_storyboard_plans_storyboard_order:组合索引,优化排序查询

条件索引

  • idx_storyboards_default_plan:仅索引非 NULL 的 default_plan_id(节省空间)

后果

正面影响

  1. 功能完整

    • 支持多套方案管理(图片/视频)
    • 支持默认方案选择
    • 支持方案排序和元数据扩展
  2. 性能优化

    • 无物理外键,写入性能提升 15-30%
    • SMALLINT 枚举,查询性能优于字符串
    • 合理的索引设计,查询效率高
  3. 扩展性强

    • JSONB 元数据支持灵活扩展
    • 添加新方案类型无需修改表结构
    • 支持未来的分库分表需求
  4. 开发友好

    • 统一的 CRUD 接口
    • 清晰的分层架构(Repository/Service/API)
    • 完整的错误处理和日志记录

负面影响

  1. 应用层复杂度

    • 需要在 Service 层实现引用完整性验证
    • 需要处理默认方案的自动选择逻辑
    • 需要实现级联删除逻辑

    缓解措施

    • 提供完整的 Repository 基础方法
    • 在 Service 层封装复杂逻辑
    • 编写完整的单元测试和集成测试
  2. 数据一致性风险

    • 无物理外键,可能出现孤儿记录
    • 需要定期检查数据完整性

    缓解措施

    • Service 层严格验证引用完整性
    • 使用事务保证原子性
    • 后台任务定期检查和修复(可选)
  3. 学习成本

    • 开发者需要理解应用层验证机制
    • 需要遵循严格的开发规范

    缓解措施

    • 提供完整的文档和代码示例
    • Code Review 确保规范执行
    • 提供 Repository/Service 基类简化开发

相关决策

实施步骤

  • 设计表结构和索引
  • 编写 Alembic 迁移脚本
  • 实现 Model 层(StoryboardPlan、PlanType)
  • 实现 Schema 层(Create/Update/Response)
  • 实现 Repository 层(基础 CRUD)
  • 实现 Service 层(业务逻辑、引用验证、级联处理)
  • 实现 API 层(REST 接口)
  • 执行数据库迁移
  • 注册路由和导出 Model
  • 编写单元测试(Repository 层)- 9/9 通过
  • 编写集成测试(API 层)- 19/19 通过
  • 更新 API 文档
  • 前端集成

测试结果

Repository 单元测试(9/9 通过)

测试文件:server/tests/unit/repositories/test_storyboard_plan_repository.py

  • test_create_plan_success - 创建方案成功
  • test_get_by_id_success - 根据ID获取方案
  • test_get_by_storyboard_success - 获取分镜的所有方案
  • test_get_by_storyboard_ordered - 验证按 display_order 排序
  • test_check_name_exists_true - 检查名称存在
  • test_check_name_exists_false - 检查名称不存在
  • test_check_name_exists_exclude - 排除指定方案ID
  • test_get_first_by_order - 获取 display_order 最小的方案
  • test_delete_by_storyboard - 删除分镜的所有方案

API 集成测试(19/19 通过)

测试文件:server/tests/integration/test_storyboard_plans_api.py

创建方案(6个测试)

  • test_create_plan_success - 创建图片方案成功
  • test_create_plan_video_type - 创建视频方案成功
  • test_create_plan_duplicate_name - 重复名称返回 400
  • test_create_plan_invalid_type - 无效类型返回 422
  • test_create_plan_unauthorized - 未授权返回 403
  • test_create_plan_nonexistent_storyboard - 不存在的分镜返回 404

获取方案列表(3个测试)

  • test_get_plans_success - 获取方案列表成功
  • test_get_plans_empty - 空列表返回成功
  • test_get_plans_unauthorized - 未授权返回 403

更新方案(3个测试)

  • test_update_plan_success - 更新方案成功
  • test_update_plan_set_as_default - 更新并设置为默认
  • test_update_plan_duplicate_name - 重复名称返回 400

删除方案(3个测试)

  • test_delete_plan_success - 删除方案成功
  • test_delete_default_plan_auto_switch - 删除默认方案自动切换
  • test_delete_plan_unauthorized - 未授权返回 403

设置默认方案(3个测试)

  • test_set_default_plan_success - 设置默认方案成功
  • test_set_default_plan_nonexistent - 不存在的方案返回 404
  • test_set_default_plan_wrong_storyboard - 其他分镜的方案返回 404

响应格式(1个测试)

  • test_response_camelcase_format - 验证 camelCase 响应格式

变更记录

  • 2026-02-12: 初始版本,完成表结构设计和代码实现
  • 2026-02-12: 完成数据库迁移和路由注册,后端功能已就绪
  • 2026-02-12: 完成单元测试(9/9 通过)和集成测试(19/19 通过),测试覆盖率达标