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.
 

18 KiB

分镜-项目素材关联服务

文档版本:v1.0
最后更新:2026-02-02
符合规范:jointo-tech-stack v1.0


目录

  1. 服务概述
  2. 核心功能
  3. 数据库设计
  4. 服务实现
  5. API 接口

服务概述

分镜-项目素材关联服务负责管理分镜与项目素材(角色、场景、道具、实拍)的关联关系。该服务是 StoryboardService 的一部分,专门处理素材关联逻辑。

职责

  • 将项目素材添加到分镜
  • 从分镜移除项目素材
  • 查询分镜使用的素材
  • 维护素材的引用计数(usage_count

设计原则

  • 引用计数维护:添加/删除关联时自动更新 project_resources.usage_count
  • 事务安全:关联操作和计数更新在同一事务中完成
  • 删除保护usage_count > 0 的素材禁止删除(除非强制删除)
  • 软删除分镜不计入:只统计 deleted_at IS NULL 的分镜

核心功能

1. 添加素材到分镜

  • 创建 storyboard_resources 关联记录
  • 自动增加 project_resources.usage_count +1
  • 事务安全

2. 从分镜移除素材

  • 删除 storyboard_resources 关联记录
  • 自动减少 project_resources.usage_count -1
  • 事务安全

3. 查询分镜素材

  • 按类型查询(角色/场景/道具/实拍)
  • 按标签查询
  • 返回完整素材信息

4. 批量操作

  • 批量添加素材
  • 批量移除素材
  • 批量更新引用计数

数据库设计

storyboard_resources 关联表

CREATE TABLE storyboard_resources (
    storyboard_resource_id UUID PRIMARY KEY,  -- 应用层生成 UUID v7
    storyboard_id UUID NOT NULL,  -- 无物理外键,应用层校验
    project_resource_id UUID NOT NULL,  -- 无物理外键,应用层校验
    resource_type SMALLINT NOT NULL,  -- 1=character, 2=scene, 3=prop, 4=footage
    display_order INTEGER NOT NULL DEFAULT 0,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    CONSTRAINT storyboard_resources_unique UNIQUE (storyboard_id, project_resource_id) NULLS NOT DISTINCT
);

-- 索引(手动创建,确保引用完整性查询性能)
CREATE INDEX idx_storyboard_resources_storyboard_id ON storyboard_resources (storyboard_id);
CREATE INDEX idx_storyboard_resources_project_resource_id ON storyboard_resources (project_resource_id);
CREATE INDEX idx_storyboard_resources_type ON storyboard_resources (resource_type);

project_resources 表(引用计数字段)

-- 在 project_resources 表中添加 usage_count 字段
ALTER TABLE project_resources ADD COLUMN usage_count INTEGER NOT NULL DEFAULT 0;

-- 添加 CHECK 约束
ALTER TABLE project_resources ADD CONSTRAINT project_resources_usage_count_check CHECK (usage_count >= 0);

-- 添加索引
CREATE INDEX idx_project_resources_usage_count ON project_resources (usage_count) WHERE deleted_at IS NULL;

-- 添加注释
COMMENT ON COLUMN project_resources.usage_count IS '引用计数(被多少个分镜使用,由 StoryboardService 维护)';

服务实现

StoryboardService 类(素材关联部分)

# app/services/storyboard_service.py
from typing import List, Optional, Dict, Any
from uuid import UUID as PG_UUID
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update, delete
from app.models.storyboard import Storyboard
from app.models.project_resource import ProjectResource
from app.models.storyboard_resource import StoryboardResource
from app.repositories.storyboard_repository import StoryboardRepository
from app.repositories.project_resource_repository import ProjectResourceRepository
from app.core.exceptions import NotFoundError, ValidationError, PermissionError
from app.core.database import generate_uuid_v7
import logging

logger = logging.getLogger(__name__)

class StoryboardService:
    def __init__(
        self,
        db: AsyncSession,
        storyboard_repo: StoryboardRepository,
        project_resource_repo: ProjectResourceRepository
    ):
        self.db = db
        self.storyboard_repo = storyboard_repo
        self.project_resource_repo = project_resource_repo

    # ==================== 素材关联管理 ====================

    async def add_resource_to_storyboard(
        self,
        user_id: uuid.UUID,
        storyboard_id: uuid.UUID,
        project_resource_id: uuid.UUID,
        display_order: int = 0
    ) -> StoryboardResource:
        """
        将项目素材添加到分镜
        
        自动维护 project_resources.usage_count +1
        """
        logger.info(
            "添加素材到分镜: user_id=%s, storyboard_id=%s, project_resource_id=%s",
            user_id,
            storyboard_id,
            project_resource_id
        )
        
        # 检查分镜权限
        storyboard = await self.storyboard_repo.get_by_id(storyboard_id)
        if not storyboard:
            raise NotFoundError("分镜不存在")
        
        await self._check_project_permission(user_id, storyboard.project_id, 'editor')
        
        # 检查素材存在性和归属
        resource = await self.project_resource_repo.get_by_id(project_resource_id)
        if not resource:
            raise NotFoundError("素材不存在")
        
        if resource.project_id != storyboard.project_id:
            raise ValidationError("素材不属于当前项目")
        
        # 检查是否已关联
        existing = await self.db.execute(
            select(StoryboardResource).where(
                StoryboardResource.storyboard_id == storyboard_id,
                StoryboardResource.project_resource_id == project_resource_id
            )
        )
        if existing.scalar_one_or_none():
            raise ValidationError("素材已关联到该分镜")
        
        # 开始事务:创建关联 + 更新引用计数
        try:
            # 1. 创建关联记录
            association = StoryboardResource(
                storyboard_resource_id=generate_uuid_v7(),
                storyboard_id=storyboard_id,
                project_resource_id=project_resource_id,
                resource_type=resource.type,
                display_order=display_order
            )
            self.db.add(association)
            
            # 2. 增加引用计数
            await self.db.execute(
                update(ProjectResource)
                .where(ProjectResource.project_resource_id == project_resource_id)
                .values(usage_count=ProjectResource.usage_count + 1)
            )
            
            # 3. 提交事务
            await self.db.commit()
            await self.db.refresh(association)
            
            logger.info(
                "素材关联成功: storyboard_resource_id=%s, usage_count=%d",
                association.storyboard_resource_id,
                resource.usage_count + 1
            )
            
            return association
            
        except Exception as e:
            await self.db.rollback()
            logger.error(
                "添加素材到分镜失败: storyboard_id=%s, project_resource_id=%s",
                storyboard_id,
                project_resource_id,
                exc_info=True
            )
            raise

    async def remove_resource_from_storyboard(
        self,
        user_id: uuid.UUID,
        storyboard_id: uuid.UUID,
        project_resource_id: uuid.UUID
    ) -> None:
        """
        从分镜移除项目素材
        
        自动维护 project_resources.usage_count -1
        """
        logger.info(
            "从分镜移除素材: user_id=%s, storyboard_id=%s, project_resource_id=%s",
            user_id,
            storyboard_id,
            project_resource_id
        )
        
        # 检查分镜权限
        storyboard = await self.storyboard_repo.get_by_id(storyboard_id)
        if not storyboard:
            raise NotFoundError("分镜不存在")
        
        await self._check_project_permission(user_id, storyboard.project_id, 'editor')
        
        # 检查关联是否存在
        result = await self.db.execute(
            select(StoryboardResource).where(
                StoryboardResource.storyboard_id == storyboard_id,
                StoryboardResource.project_resource_id == project_resource_id
            )
        )
        association = result.scalar_one_or_none()
        if not association:
            raise NotFoundError("素材未关联到该分镜")
        
        # 开始事务:删除关联 + 更新引用计数
        try:
            # 1. 删除关联记录
            await self.db.execute(
                delete(StoryboardResource).where(
                    StoryboardResource.storyboard_resource_id == association.storyboard_resource_id
                )
            )
            
            # 2. 减少引用计数
            await self.db.execute(
                update(ProjectResource)
                .where(ProjectResource.project_resource_id == project_resource_id)
                .values(usage_count=ProjectResource.usage_count - 1)
            )
            
            # 3. 提交事务
            await self.db.commit()
            
            logger.info(
                "素材移除成功: storyboard_resource_id=%s",
                association.storyboard_resource_id
            )
            
        except Exception as e:
            await self.db.rollback()
            logger.error(
                "从分镜移除素材失败: storyboard_id=%s, project_resource_id=%s",
                storyboard_id,
                project_resource_id,
                exc_info=True
            )
            raise

    async def get_storyboard_resources(
        self,
        user_id: uuid.UUID,
        storyboard_id: uuid.UUID,
        resource_type: Optional[int] = None
    ) -> List[Dict[str, Any]]:
        """
        获取分镜使用的素材列表
        
        Args:
            user_id: 用户ID
            storyboard_id: 分镜ID
            resource_type: 素材类型过滤(可选)
        
        Returns:
            素材列表(包含完整素材信息)
        """
        # 检查分镜权限
        storyboard = await self.storyboard_repo.get_by_id(storyboard_id)
        if not storyboard:
            raise NotFoundError("分镜不存在")
        
        await self._check_project_permission(user_id, storyboard.project_id, 'viewer')
        
        # 查询关联素材
        query = (
            select(StoryboardResource, ProjectResource)
            .join(
                ProjectResource,
                StoryboardResource.project_resource_id == ProjectResource.project_resource_id
            )
            .where(StoryboardResource.storyboard_id == storyboard_id)
            .where(ProjectResource.deleted_at.is_(None))
        )
        
        if resource_type is not None:
            query = query.where(StoryboardResource.resource_type == resource_type)
        
        query = query.order_by(StoryboardResource.display_order)
        
        result = await self.db.execute(query)
        rows = result.all()
        
        resources = []
        for association, resource in rows:
            resources.append({
                'storyboard_resource_id': str(association.storyboard_resource_id),
                'project_resource_id': str(resource.project_resource_id),
                'name': resource.name,
                'type': resource.type,
                'file_url': resource.file_url,
                'thumbnail_url': resource.thumbnail_url,
                'element_tag_id': str(resource.element_tag_id) if resource.element_tag_id else None,
                'element_name': resource.element_name,
                'tag_label': resource.tag_label,
                'display_order': association.display_order,
                'created_at': association.created_at
            })
        
        return resources

    async def batch_add_resources(
        self,
        user_id: uuid.UUID,
        storyboard_id: uuid.UUID,
        resource_ids: List[uuid.UUID]
    ) -> List[StoryboardResource]:
        """批量添加素材到分镜"""
        associations = []
        for resource_id in resource_ids:
            association = await self.add_resource_to_storyboard(
                user_id, storyboard_id, resource_id
            )
            associations.append(association)
        return associations

    async def batch_remove_resources(
        self,
        user_id: uuid.UUID,
        storyboard_id: uuid.UUID,
        resource_ids: List[uuid.UUID]
    ) -> None:
        """批量从分镜移除素材"""
        for resource_id in resource_ids:
            await self.remove_resource_from_storyboard(
                user_id, storyboard_id, resource_id
            )

    # ==================== 辅助方法 ====================

    async def _check_project_permission(
        self,
        user_id: uuid.UUID,
        project_id: uuid.UUID,
        required_role: str = 'viewer'
    ) -> None:
        """检查项目权限(应用层引用完整性校验)"""
        from app.services.project_service import ProjectService
        from app.repositories.project_repository import ProjectRepository
        
        project_repo = ProjectRepository(self.db)
        project_service = ProjectService(self.db, project_repo)
        
        has_permission = await project_service.check_user_permission(
            user_id, project_id, required_role
        )
        if not has_permission:
            raise PermissionError("没有权限访问此项目")

API 接口

1. 添加素材到分镜

POST /api/v1/storyboards/{storyboard_id}/resources

请求体

{
  "project_resource_id": "019d1234-5678-7abc-def0-123456789abc",
  "display_order": 0
}

响应

{
  "success": true,
  "code": 200,
  "message": "Success",
  "data": {
    "storyboard_resource_id": "019d1234-5678-7abc-def0-999999999999",
    "storyboard_id": "019d1234-5678-7abc-def0-111111111111",
    "project_resource_id": "019d1234-5678-7abc-def0-123456789abc",
    "resource_type": 1,
    "display_order": 0,
    "created_at": "2026-02-02T10:00:00+00:00"
  },
  "timestamp": "2026-02-03T10:00:00Z"
}

2. 从分镜移除素材

DELETE /api/v1/storyboards/{storyboard_id}/resources/{project_resource_id}

响应

{
  "success": true,
  "code": 200,
  "message": "素材已移除",
  "data": null,
  "timestamp": "2026-02-03T10:00:00Z"
}

3. 获取分镜素材列表

GET /api/v1/storyboards/{storyboard_id}/resources?type=1

查询参数

  • type: 素材类型过滤(可选:1=角色, 2=场景, 3=道具, 4=实拍)

响应

{
  "success": true,
  "code": 200,
  "message": "Success",
  "data": [
    {
      "storyboard_resource_id": "019d1234-5678-7abc-def0-999999999999",
      "project_resource_id": "019d1234-5678-7abc-def0-123456789abc",
      "name": "主角-张三-少年形象",
      "type": 1,
      "file_url": "https://storage.jointo.ai/project_resource/character/1/abc123.png",
      "thumbnail_url": "https://storage.jointo.ai/thumbnail/character/1/thumb_abc123.png",
      "element_tag_id": "019d1234-5678-7abc-def0-666666666666",
      "element_name": "张三",
      "tag_label": "少年",
      "display_order": 0,
      "created_at": "2026-02-02T10:00:00+00:00"
    }
  ],
  "timestamp": "2026-02-03T10:00:00Z"
}

4. 批量添加素材

POST /api/v1/storyboards/{storyboard_id}/resources/batch

请求体

{
  "resource_ids": [
    "019d1234-5678-7abc-def0-123456789abc",
    "019d1234-5678-7abc-def0-123456789abd"
  ]
}

5. 批量移除素材

DELETE /api/v1/storyboards/{storyboard_id}/resources/batch

请求体

{
  "resource_ids": [
    "019d1234-5678-7abc-def0-123456789abc",
    "019d1234-5678-7abc-def0-123456789abd"
  ]
}

引用计数维护机制

1. 计数规则

  • 添加关联usage_count +1
  • 删除关联usage_count -1
  • 软删除分镜:不影响 usage_count(分镜恢复后关联仍有效)
  • 硬删除分镜:批量减少所有关联素材的 usage_count

2. 事务安全

所有关联操作和计数更新在同一事务中完成:

try:
    # 1. 创建/删除关联
    # 2. 更新引用计数
    await self.db.commit()
except Exception:
    await self.db.rollback()
    raise

3. 删除保护

ProjectResourceService.delete_resource() 中检查 usage_count

if resource.usage_count > 0 and not force:
    raise ValidationError(
        f"该素材正在被 {resource.usage_count} 个分镜使用,无法删除。"
        "请先从分镜中移除该素材,或使用强制删除。"
    )

4. 数据一致性

  • 初始化:新素材 usage_count 默认为 0
  • 约束:CHECK 约束确保 usage_count >= 0
  • 索引idx_project_resources_usage_count 加速查询
  • 审计:日志记录所有计数变更

相关文档


变更记录

v1.1 (2026-02-03)

  • 修复 UUID v7 生成方式(移除数据库 DEFAULT,改为应用层生成)
  • 修复日志格式化(统一使用 %-formatting)
  • 修复异常日志(添加 exc_info=True)
  • 修复 API 响应格式(统一使用 ApiResponse 格式)
  • 移除数据库注释(将在迁移脚本中添加)
  • 符合 jointo-tech-stack v1.0 规范

v1.0 (2026-02-02)

  • 初始版本
  • 定义分镜-项目素材关联服务
  • 实现引用计数维护机制
  • 添加事务安全保证
  • 添加批量操作支持

文档版本:v1.0
最后更新:2026-02-02