# ADR 05: 分镜方案表设计 **状态**: ~~已接受~~ **已废弃** **日期**: 2026-02-12 **决策者**: 开发团队 **废弃日期**: 2026-02-12 **废弃原因**: 业务需求变更,不再需要多方案管理功能 --- ## ⚠️ 废弃声明 此 ADR 已于 2026-02-12 废弃。`storyboard_plans` 表及相关功能已完全移除。 详见:[Changelog: 移除 storyboard_plans 功能](../changelogs/2026-02-12-remove-storyboard-plans.md) --- ## 上下文 当前分镜系统需要支持多套表现方案的管理: 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 表 ```sql 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 表新增字段 ```sql -- 添加 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 层 - 基础验证 ```python # 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 层 - 业务验证 ```python # 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. 级联删除处理 ```python # 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 层实现 ```python # 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 层实现 ```python # 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 层实现示例 ```python # 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 迁移脚本 ```python """创建 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_images` 和 `storyboard_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 基类简化开发 ## 相关决策 - [ADR-002: PostgreSQL 17 UUID v7](../../architecture/adrs/002-postgresql-17-uuid-v7.md) - UUID v7 应用层生成 - [ADR-006: TIMESTAMPTZ for Event Timestamps](../../architecture/adrs/006-timestamptz-for-event-timestamps.md) - 时间戳类型规范 - [ADR-007: Database Migration Best Practices](../../architecture/adrs/007-database-migration-best-practices.md) - 迁移最佳实践 - [ADR-010: Repository Service Transaction Management](../../architecture/adrs/010-repository-service-transaction-management.md) - 事务管理规范 ## 实施步骤 - [x] 设计表结构和索引 - [x] 编写 Alembic 迁移脚本 - [x] 实现 Model 层(StoryboardPlan、PlanType) - [x] 实现 Schema 层(Create/Update/Response) - [x] 实现 Repository 层(基础 CRUD) - [x] 实现 Service 层(业务逻辑、引用验证、级联处理) - [x] 实现 API 层(REST 接口) - [x] 执行数据库迁移 - [x] 注册路由和导出 Model - [x] 编写单元测试(Repository 层)- 9/9 通过 ✅ - [x] 编写集成测试(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 通过),测试覆盖率达标 ✅