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
18 KiB
分镜-项目素材关联服务
文档版本:v1.0
最后更新:2026-02-02
符合规范:jointo-tech-stack v1.0
目录
服务概述
分镜-项目素材关联服务负责管理分镜与项目素材(角色、场景、道具、实拍)的关联关系。该服务是 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