# 项目管理服务 > **文档版本**:v2.0 > **最后更新**:2026-01-31 --- ## 目录 1. [服务概述](#服务概述) 2. [核心功能](#核心功能) 3. [服务实现](#服务实现) 4. [API 接口](#api-接口) 5. [数据库设计](#数据库设计) 6. [数据模型](#数据模型) **相关文档**: - [流程图与关系图](./project-service-flows.md) - 完整的流程图和数据库关系图 --- ## 服务概述 项目管理服务负责处理项目的创建、查询、更新、删除等核心业务逻辑。 ### 职责 - 项目 CRUD 操作 - 项目权限管理 - 项目成员管理 - 项目移动到文件夹 - 项目搜索和筛选 - 子项目管理(父子项目关系) --- ## 核心功能 ### 1. 项目创建 - 支持两种项目类型:`mine`(个人项目)、`collab`(协作项目) - 可指定所属文件夹 - 自动设置项目所有者 - 初始化项目配置 ### 2. 项目查询 - 按用户查询项目列表 - 按文件夹查询项目 - 按项目类型筛选 - 支持分页 ### 3. 项目更新 - 更新项目基本信息 - 更新项目配置 - 更新项目缩略图 ### 4. 项目删除(回收站机制) - 移至回收站(status=2,设置 `trashed_at`) - 回收站恢复(status 恢复为 active) - 永久删除(status=3,设置 `deleted_at`,用户不可见) - 物理删除(仅管理员,真实删除数据库记录) - 自动清理(30天后自动从回收站转为软删除) ### 5. 项目移动 - 移动项目到指定文件夹 - 移动到根目录(`folder_id = null`) - 权限检查 ### 6. 项目克隆 - 完整复制项目基本信息 - 复制所有关联资源(分镜、视频、附件等) - 自动生成唯一名称(避免重名) - 异步处理,支持进度追踪 ### 7. 项目导出 - 打包项目为 ZIP 文件 - 包含项目配置、分镜、资源文件 - 异步任务处理 - 进度通知和文件下载 ### 8. 项目分享 - 生成公开访问链接 - 可选密码保护 - 邀请特定用户并设置权限 - 管理分享权限(查看/编辑/管理) ### 9. 项目搜索和筛选 - 按名称、描述全文搜索 - 按内容类型筛选 - 按创建时间、更新时间、名称排序 - 支持分页 ### 10. 项目排序 - 自定义项目显示顺序 - 拖拽排序支持 ### 11. 子项目管理 - 支持父子项目关系(项目层级结构) - 子项目关联剧本(一对一) - 子项目继承父项目权限 - 子项目独立管理分镜和资源 - 自动创建子项目(上传剧本时) --- ## 服务实现 > **📊 流程图与关系图** > 详细的表关系图和业务流程图请参考:[项目服务流程图](./project-service-flows.md) > > 包含内容: > - 数据库表关系 ER 图 > - 项目创建流程图 > - 项目克隆流程图 > - 权限检查流程图 > - 项目生命周期状态图 > - 子项目关系图 --- ## 服务实现 ### ProjectService 类 ```python # app/services/project_service.py from typing import List, Optional, Dict, Any from sqlalchemy.ext.asyncio import AsyncSession from app.models.project import Project from app.repositories.project_repository import ProjectRepository from app.repositories.folder_repository import FolderRepository from app.schemas.project import ProjectCreate, ProjectUpdate, ProjectMove from app.core.exceptions import NotFoundError, ValidationError, PermissionError from app.core.logging import get_logger # 获取模块级 logger logger = get_logger(__name__) class ProjectService: def __init__(self, db: AsyncSession): self.repository = ProjectRepository(db) self.folder_repository = FolderRepository(db) self.db = db async def get_projects( self, user_id: str, project_type: Optional[str] = None, folder_id: Optional[str] = None, content_type: Optional[str] = None, search: Optional[str] = None, sort_by: str = 'updated_at', sort_order: str = 'desc', page: int = 1, page_size: int = 20 ) -> Dict[str, Any]: """获取项目列表(支持搜索、筛选、排序)""" # 如果指定了文件夹,检查文件夹权限 if folder_id: has_permission = await self.folder_repository.check_user_permission( user_id, folder_id, 'viewer' ) if not has_permission: raise PermissionError("没有权限访问此文件夹") # 验证排序字段 valid_sort_fields = ['created_at', 'updated_at', 'name', 'display_order'] if sort_by not in valid_sort_fields: raise ValidationError("无效的排序字段: %s" % sort_by) # 验证排序方向 if sort_order not in ['asc', 'desc']: raise ValidationError("无效的排序方向: %s" % sort_order) projects = await self.repository.get_by_user( user_id=user_id, project_type=project_type, folder_id=folder_id, content_type=content_type, search=search, sort_by=sort_by, sort_order=sort_order, page=page, page_size=page_size ) total = await self.repository.count_by_user( user_id=user_id, project_type=project_type, folder_id=folder_id, content_type=content_type, search=search ) return { 'items': projects, 'total': total, 'page': page, 'page_size': page_size, 'total_pages': (total + page_size - 1) // page_size } async def get_project( self, user_id: str, project_id: str ) -> Project: """获取项目详情""" project = await self.repository.get_by_id(project_id) if not project: raise NotFoundError("项目不存在") # 检查权限 if not await self.repository.check_user_permission( user_id, project_id, 'viewer' ): raise PermissionError("没有权限访问此项目") return project async def create_project( self, user_id: str, project_data: ProjectCreate ) -> Project: """创建项目""" # 校验用户存在性 await self._validate_user_exists(user_id) # 如果指定了文件夹,检查存在性和权限 if project_data.folder_id: await self._validate_folder_exists(project_data.folder_id) has_permission = await self.folder_repository.check_user_permission( user_id, project_data.folder_id, 'editor' ) if not has_permission: raise PermissionError("没有权限在此文件夹中创建项目") # 校验封面图片存在性和所属权 if hasattr(project_data, 'cover_image_id') and project_data.cover_image_id: await self._validate_attachment_exists_and_owned( project_data.cover_image_id, user_id ) # 验证项目类型 if project_data.type not in ['mine', 'collab']: raise ValidationError("无效的项目类型") project = Project( name=project_data.name, description=project_data.description, type=project_data.type, owner_id=user_id, folder_id=project_data.folder_id, settings=project_data.settings.dict() if project_data.settings else {} ) created_project = await self.repository.create(project) logger.info( "用户创建项目: user_id=%s, project_id=%s, name=%s", user_id, str(created_project.id), created_project.name ) # 如果是协作项目,添加创建者为成员 if project_data.type == 'collab': await self.repository.add_member( created_project.id, user_id, 'owner' ) return created_project async def update_project( self, user_id: str, project_id: str, project_data: ProjectUpdate ) -> Project: """更新项目""" project = await self.repository.get_by_id(project_id) if not project: raise NotFoundError("项目不存在") # 检查权限 if not await self.repository.check_user_permission( user_id, project_id, 'editor' ): raise PermissionError("没有权限编辑此项目") update_data = project_data.dict(exclude_unset=True) return await self.repository.update(project_id, update_data) async def delete_project( self, user_id: str, project_id: str ) -> None: """删除项目(软删除)""" project = await self.repository.get_by_id(project_id) if not project: raise NotFoundError("项目不存在") # 检查权限(只有 owner 可以删除) if not await self.repository.check_user_permission( user_id, project_id, 'owner' ): raise PermissionError("只有项目所有者可以删除项目") await self.repository.soft_delete(project_id) logger.info("用户删除项目: user_id=%s, project_id=%s", user_id, project_id) async def move_project( self, user_id: str, project_id: str, move_data: ProjectMove ) -> Project: """移动项目到文件夹""" # 检查项目权限 if not await self.repository.check_user_permission( user_id, project_id, 'editor' ): raise PermissionError("没有权限移动此项目") # 检查目标文件夹权限 if move_data.folder_id: has_permission = await self.folder_repository.check_user_permission( user_id, move_data.folder_id, 'editor' ) if not has_permission: raise PermissionError("没有权限访问目标文件夹") return await self.repository.move_to_folder( project_id, move_data.folder_id ) async def get_project_members( self, user_id: str, project_id: str ) -> List[Dict[str, Any]]: """获取项目成员列表""" # 检查权限 if not await self.repository.check_user_permission( user_id, project_id, 'viewer' ): raise PermissionError("没有权限查看项目成员") return await self.repository.get_members(project_id) async def add_project_member( self, user_id: str, project_id: str, member_user_id: str, role: str ) -> None: """添加项目成员""" # 检查权限(只有 owner 可以添加成员) if not await self.repository.check_user_permission( user_id, project_id, 'owner' ): raise PermissionError("只有项目所有者可以添加成员") # 验证角色 if role not in ['owner', 'editor', 'viewer']: raise ValidationError("无效的角色") await self.repository.add_member(project_id, member_user_id, role) async def remove_project_member( self, user_id: str, project_id: str, member_user_id: str ) -> None: """移除项目成员""" # 检查权限(只有 owner 可以移除成员) if not await self.repository.check_user_permission( user_id, project_id, 'owner' ): raise PermissionError("只有项目所有者可以移除成员") # 不能移除自己 if user_id == member_user_id: raise ValidationError("不能移除自己") await self.repository.remove_member(project_id, member_user_id) async def clone_project( self, user_id: str, project_id: str ) -> Dict[str, Any]: """克隆项目(异步任务)""" # 检查源项目权限 source_project = await self.repository.get_by_id(project_id) if not source_project: raise NotFoundError("项目不存在") if not await self.repository.check_user_permission( user_id, project_id, 'viewer' ): raise PermissionError("没有权限克隆此项目") # 生成唯一的副本名称 base_name = f"{source_project.name}-副本" new_name = await self._generate_unique_project_name( base_name, source_project.folder_id, user_id ) # 创建异步任务 task_id = await self._create_clone_task( user_id, project_id, new_name ) return { 'task_id': task_id, 'status': 'pending', 'message': '克隆任务已创建' } async def export_project( self, user_id: str, project_id: str ) -> Dict[str, Any]: """导出项目为 ZIP 文件(异步任务)""" # 检查权限 if not await self.repository.check_user_permission( user_id, project_id, 'viewer' ): raise PermissionError("没有权限导出此项目") # 创建导出任务 task_id = await self._create_export_task(user_id, project_id) return { 'task_id': task_id, 'status': 'pending', 'message': '导出任务已创建' } async def get_export_status( self, user_id: str, project_id: str, task_id: str ) -> Dict[str, Any]: """获取导出任务状态""" # 检查权限 if not await self.repository.check_user_permission( user_id, project_id, 'viewer' ): raise PermissionError("没有权限访问此项目") # 查询任务状态(从 Redis 或任务队列) task_status = await self._get_task_status(task_id) return task_status async def create_share( self, user_id: str, project_id: str, share_data: Dict[str, Any] ) -> Dict[str, Any]: """创建项目分享""" # 检查权限(只有 owner 可以创建分享) if not await self.repository.check_user_permission( user_id, project_id, 'owner' ): raise PermissionError("只有项目所有者可以创建分享") # 生成分享链接 share_token = await self._generate_share_token() share = await self.repository.create_share( project_id=project_id, created_by=user_id, share_token=share_token, password=share_data.get('password'), permission=share_data.get('permission', 'viewer'), expires_at=share_data.get('expires_at') ) return { 'share_id': share.id, 'share_url': f"/share/p/{share_token}", 'permission': share.permission, 'expires_at': share.expires_at } async def get_shares( self, user_id: str, project_id: str ) -> List[Dict[str, Any]]: """获取项目分享列表""" # 检查权限 if not await self.repository.check_user_permission( user_id, project_id, 'viewer' ): raise PermissionError("没有权限查看分享列表") return await self.repository.get_shares(project_id) async def revoke_share( self, user_id: str, project_id: str, share_id: str ) -> None: """撤销项目分享""" # 检查权限(只有 owner 可以撤销分享) if not await self.repository.check_user_permission( user_id, project_id, 'owner' ): raise PermissionError("只有项目所有者可以撤销分享") await self.repository.delete_share(share_id) async def update_project_order( self, user_id: str, project_id: str, new_order: int ) -> Project: """更新项目显示顺序""" # 检查权限 if not await self.repository.check_user_permission( user_id, project_id, 'editor' ): raise PermissionError("没有权限修改项目顺序") return await self.repository.update( project_id, {'display_order': new_order} ) async def create_subproject( self, user_id: str, parent_project_id: str, screenplay_id: str, name: str, description: Optional[str] = None ) -> Project: """创建子项目(由剧本服务调用)""" # 检查父项目权限 parent_project = await self.repository.get_by_id(parent_project_id) if not parent_project: raise NotFoundError("父项目不存在") if not await self.repository.check_user_permission( user_id, parent_project_id, 'editor' ): raise PermissionError("没有权限在此项目中创建子项目") # 创建子项目 subproject = Project( name=name, description=description, type=parent_project.type, # 继承父项目类型 owner_id=parent_project.owner_id, # 继承父项目所有者 owner_type=parent_project.owner_type, folder_id=parent_project.folder_id, # 继承父项目文件夹 parent_project_id=parent_project_id, screenplay_id=screenplay_id, settings=parent_project.settings.copy() if parent_project.settings else {} ) created_subproject = await self.repository.create(subproject) # 如果父项目是协作项目,复制成员权限 if parent_project.type == 'collab': members = await self.repository.get_members(parent_project_id) for member in members: await self.repository.add_member( created_subproject.id, member['user_id'], member['role'] ) return created_subproject async def get_subprojects( self, user_id: str, parent_project_id: str, page: int = 1, page_size: int = 20 ) -> Dict[str, Any]: """获取子项目列表""" # 检查父项目权限 if not await self.repository.check_user_permission( user_id, parent_project_id, 'viewer' ): raise PermissionError("没有权限访问此项目") subprojects = await self.repository.get_subprojects( parent_project_id, page, page_size ) total = await self.repository.count_subprojects(parent_project_id) return { 'items': subprojects, 'total': total, 'page': page, 'page_size': page_size, 'total_pages': (total + page_size - 1) // page_size } async def _generate_unique_project_name( self, base_name: str, folder_id: str, user_id: str ) -> str: """生成唯一的项目名称""" # 获取同文件夹下的所有项目名称 projects = await self.repository.get_by_folder(folder_id, user_id) existing_names = {p.name for p in projects} # 如果基础名称不存在,直接返回 if base_name not in existing_names: return base_name # 否则添加数字后缀 counter = 1 while f"{base_name}{counter}" in existing_names: counter += 1 return f"{base_name}{counter}" async def _create_clone_task( self, user_id: str, project_id: str, new_name: str ) -> str: """创建克隆任务(提交到任务队列)""" # TODO: 实现异步任务队列(Celery/ARQ) pass async def _create_export_task( self, user_id: str, project_id: str ) -> str: """创建导出任务(提交到任务队列)""" # TODO: 实现异步任务队列 pass async def _get_task_status(self, task_id: str) -> Dict[str, Any]: """获取任务状态""" # TODO: 从 Redis 或任务队列查询状态 pass async def _generate_share_token(self) -> str: """生成分享令牌""" import secrets return secrets.token_urlsafe(32) # ==================== 引用完整性校验方法 ==================== async def _validate_user_exists(self, user_id: str) -> None: """校验用户存在性""" from app.repositories.user_repository import UserRepository user_repo = UserRepository(self.db) user = await user_repo.get_by_id(user_id) if not user: raise ValidationError(f"用户不存在: {user_id}") async def _validate_folder_exists(self, folder_id: str) -> None: """校验文件夹存在性""" folder = await self.folder_repository.get_by_id(folder_id) if not folder: raise ValidationError(f"文件夹不存在: {folder_id}") async def _validate_attachment_exists_and_owned( self, attachment_id: str, user_id: str ) -> None: """校验附件存在性和所属权""" from app.repositories.attachment_repository import AttachmentRepository attachment_repo = AttachmentRepository(self.db) attachment = await attachment_repo.get_by_id(attachment_id) if not attachment: raise ValidationError(f"附件不存在: {attachment_id}") if attachment.uploaded_by != user_id: raise PermissionError("无权使用此附件") ``` --- ## 仓储层实现 ### ProjectRepository 类 ```python # app/repositories/project_repository.py from typing import List, Optional, Dict, Any from sqlalchemy import select, update, delete, func, or_, and_ from sqlalchemy.ext.asyncio import AsyncSession from app.models.project import Project, ProjectStatus, ProjectType from app.utils.uuid import generate_uuid_v7 from app.core.logging import get_logger from datetime import datetime, UTC logger = get_logger(__name__) class ProjectRepository: def __init__(self, db: AsyncSession): self.db = db async def get_by_id(self, project_id: str) -> Optional[Project]: """根据ID获取项目""" stmt = select(Project).where( Project.id == project_id, Project.status.in_([ProjectStatus.ACTIVE, ProjectStatus.ARCHIVED]) ) result = await self.db.execute(stmt) return result.scalar_one_or_none() async def create(self, project: Project) -> Project: """创建项目""" if not project.id: project.id = generate_uuid_v7() self.db.add(project) await self.db.commit() await self.db.refresh(project) return project async def update(self, project_id: str, update_data: Dict[str, Any]) -> Project: """更新项目""" stmt = ( update(Project) .where(Project.id == project_id) .values(**update_data, updated_at=datetime.now(UTC)) .returning(Project) ) result = await self.db.execute(stmt) await self.db.commit() return result.scalar_one() async def soft_delete(self, project_id: str) -> None: """软删除项目(移至回收站)""" stmt = ( update(Project) .where(Project.id == project_id) .values( status=ProjectStatus.TRASHED, trashed_at=datetime.now(UTC), updated_at=datetime.now(UTC) ) ) await self.db.execute(stmt) await self.db.commit() async def get_by_user( self, user_id: str, project_type: Optional[str] = None, folder_id: Optional[str] = None, content_type: Optional[str] = None, search: Optional[str] = None, sort_by: str = 'updated_at', sort_order: str = 'desc', page: int = 1, page_size: int = 20 ) -> List[Project]: """获取用户的项目列表(支持筛选、搜索、排序)""" stmt = select(Project).where( Project.owner_id == user_id, Project.status.in_([ProjectStatus.ACTIVE, ProjectStatus.ARCHIVED]) ) # 项目类型筛选 if project_type: type_value = ProjectType.from_string(project_type) stmt = stmt.where(Project.type == type_value) # 文件夹筛选 if folder_id: stmt = stmt.where(Project.folder_id == folder_id) # 内容类型筛选 if content_type: from app.models.project import ProjectContentType content_type_value = ProjectContentType.from_string(content_type) stmt = stmt.where(Project.content_type == content_type_value) # 全文搜索 if search: search_pattern = f"%{search}%" stmt = stmt.where( or_( Project.name.ilike(search_pattern), Project.description.ilike(search_pattern) ) ) # 排序 sort_column = getattr(Project, sort_by) if sort_order == 'desc': stmt = stmt.order_by(sort_column.desc()) else: stmt = stmt.order_by(sort_column.asc()) # 分页 offset = (page - 1) * page_size stmt = stmt.offset(offset).limit(page_size) result = await self.db.execute(stmt) return list(result.scalars().all()) async def count_by_user( self, user_id: str, project_type: Optional[str] = None, folder_id: Optional[str] = None, content_type: Optional[str] = None, search: Optional[str] = None ) -> int: """统计用户的项目数量""" stmt = select(func.count(Project.id)).where( Project.owner_id == user_id, Project.status.in_([ProjectStatus.ACTIVE, ProjectStatus.ARCHIVED]) ) if project_type: type_value = ProjectType.from_string(project_type) stmt = stmt.where(Project.type == type_value) if folder_id: stmt = stmt.where(Project.folder_id == folder_id) if content_type: from app.models.project import ProjectContentType content_type_value = ProjectContentType.from_string(content_type) stmt = stmt.where(Project.content_type == content_type_value) if search: search_pattern = f"%{search}%" stmt = stmt.where( or_( Project.name.ilike(search_pattern), Project.description.ilike(search_pattern) ) ) result = await self.db.execute(stmt) return result.scalar_one() async def move_to_folder( self, project_id: str, folder_id: Optional[str] ) -> Project: """移动项目到文件夹""" return await self.update(project_id, {'folder_id': folder_id}) async def check_user_permission( self, user_id: str, project_id: str, required_role: str ) -> bool: """检查用户对项目的权限""" from app.models.project import MemberRole # 获取项目 project = await self.get_by_id(project_id) if not project: return False # 检查是否为所有者 if project.owner_id == user_id: return True # 检查成员权限 if project.type == ProjectType.COLLAB: from app.models.project import ProjectMember stmt = select(ProjectMember).where( ProjectMember.project_id == project_id, ProjectMember.user_id == user_id ) result = await self.db.execute(stmt) member = result.scalar_one_or_none() if not member: return False # 权限层级:owner > editor > viewer role_hierarchy = { 'owner': 3, 'editor': 2, 'viewer': 1 } member_role_str = MemberRole.to_string(member.role) return role_hierarchy.get(member_role_str, 0) >= role_hierarchy.get(required_role, 0) return False async def get_members(self, project_id: str) -> List[Dict[str, Any]]: """获取项目成员列表""" from app.models.project import ProjectMember stmt = select(ProjectMember).where(ProjectMember.project_id == project_id) result = await self.db.execute(stmt) members = result.scalars().all() return [ { 'user_id': m.user_id, 'role': MemberRole.to_string(m.role), 'invited_by': m.invited_by, 'created_at': m.created_at.isoformat() } for m in members ] async def add_member( self, project_id: str, user_id: str, role: str ) -> None: """添加项目成员""" from app.models.project import ProjectMember, MemberRole member = ProjectMember( id=generate_uuid_v7(), project_id=project_id, user_id=user_id, role=MemberRole.from_string(role) ) self.db.add(member) await self.db.commit() async def remove_member( self, project_id: str, user_id: str ) -> None: """移除项目成员""" from app.models.project import ProjectMember stmt = delete(ProjectMember).where( ProjectMember.project_id == project_id, ProjectMember.user_id == user_id ) await self.db.execute(stmt) await self.db.commit() async def get_subprojects( self, parent_project_id: str, page: int = 1, page_size: int = 20 ) -> List[Project]: """获取子项目列表""" stmt = select(Project).where( Project.parent_project_id == parent_project_id, Project.status.in_([ProjectStatus.ACTIVE, ProjectStatus.ARCHIVED]) ).order_by(Project.created_at.desc()) offset = (page - 1) * page_size stmt = stmt.offset(offset).limit(page_size) result = await self.db.execute(stmt) return list(result.scalars().all()) async def count_subprojects(self, parent_project_id: str) -> int: """统计子项目数量""" stmt = select(func.count(Project.id)).where( Project.parent_project_id == parent_project_id, Project.status.in_([ProjectStatus.ACTIVE, ProjectStatus.ARCHIVED]) ) result = await self.db.execute(stmt) return result.scalar_one() async def get_by_folder( self, folder_id: Optional[str], user_id: str ) -> List[Project]: """获取文件夹下的所有项目(用于名称唯一性检查)""" stmt = select(Project).where( Project.folder_id == folder_id, Project.owner_id == user_id, Project.status.in_([ProjectStatus.ACTIVE, ProjectStatus.ARCHIVED]) ) result = await self.db.execute(stmt) return list(result.scalars().all()) async def create_share( self, project_id: str, created_by: str, share_token: str, password: Optional[str], permission: str, expires_at: Optional[datetime] ) -> Any: """创建项目分享""" from app.models.project import ProjectShare, SharePermission share = ProjectShare( id=generate_uuid_v7(), project_id=project_id, share_token=share_token, password_hash=password, # TODO: 实际应该哈希处理 permission=SharePermission.from_string(permission), created_by=created_by, expires_at=expires_at ) self.db.add(share) await self.db.commit() await self.db.refresh(share) return share async def get_shares(self, project_id: str) -> List[Dict[str, Any]]: """获取项目分享列表""" from app.models.project import ProjectShare, SharePermission stmt = select(ProjectShare).where(ProjectShare.project_id == project_id) result = await self.db.execute(stmt) shares = result.scalars().all() return [ { 'share_id': s.id, 'share_url': f"/share/p/{s.share_token}", 'permission': SharePermission.to_string(s.permission), 'created_by': s.created_by, 'created_at': s.created_at.isoformat(), 'expires_at': s.expires_at.isoformat() if s.expires_at else None, 'has_password': bool(s.password_hash), 'access_count': s.access_count } for s in shares ] async def delete_share(self, share_id: str) -> None: """删除项目分享""" from app.models.project import ProjectShare stmt = delete(ProjectShare).where(ProjectShare.id == share_id) await self.db.execute(stmt) await self.db.commit() ``` **说明**: - Repository 层负责所有数据库操作 - 使用 SQLAlchemy 2.0 风格的查询语句 - 所有方法都是异步的(`async/await`) - 枚举转换在 Repository 层完成 - 权限检查逻辑封装在 `check_user_permission()` 方法中 --- ## API 接口 ### 1. 获取项目列表 ``` GET /api/v1/projects ``` **查询参数**: - `type`: 项目类型(`mine` | `collab`) - `folder_id`: 文件夹 ID - `content_type`: 内容类型(`ad` | `movie` | `series` | `anime` | `short` | `concept`) - `search`: 搜索关键词(按名称、描述搜索) - `sort_by`: 排序字段(`created_at` | `updated_at` | `name` | `display_order`) - `sort_order`: 排序方向(`asc` | `desc`,默认 `desc`) - `page`: 页码(默认 1) - `page_size`: 每页数量(默认 20) **响应**(符合统一响应格式): ```json { "success": true, "data": { "items": [ { "id": "project-123", "name": "我的项目", "type": "mine", "owner_id": "user-456", "folder_id": "folder-789", "created_at": "2025-01-27T10:00:00Z" } ], "total": 100, "page": 1, "page_size": 20, "total_pages": 5 } } ``` ### 2. 创建项目 ``` POST /api/v1/projects ``` **请求体**: ```json { "name": "我的项目", "description": "项目描述", "type": "mine", "folder_id": "folder-123", "settings": { "resolution": "1920x1080", "fps": 30 } } ``` **响应**(符合统一响应格式): ```json { "success": true, "data": { "id": "project-123", "name": "我的项目", "type": "mine", "owner_id": "user-456", "folder_id": "folder-123", "created_at": "2026-02-02T10:00:00Z", "updated_at": "2026-02-02T10:00:00Z" }, "message": "项目创建成功" } ``` ### 3. 获取项目详情 ``` GET /api/v1/projects/{project_id} ``` ### 4. 更新项目 ``` PUT /api/v1/projects/{project_id} ``` ### 5. 移至回收站 ``` DELETE /api/v1/projects/{project_id} ``` **说明**:将项目移至回收站(status=2),30天内可恢复 ### 6. 从回收站恢复 ``` POST /api/v1/projects/{project_id}/restore ``` **响应**(符合统一响应格式): ```json { "success": true, "data": { "id": "project-123", "name": "我的项目", "status": "active" }, "message": "项目已恢复" } ``` ### 7. 永久删除(从回收站) ``` DELETE /api/v1/projects/{project_id}/permanent ``` **说明**:用户在回收站中立即永久删除项目(status=3),不可恢复 **响应**(符合统一响应格式): ```json { "code": 200, "message": "项目已永久删除", "data": null } ``` ### 8. 查看回收站 ``` GET /api/v1/projects/trash ``` **查询参数**: - `page`: 页码(默认 1) - `page_size`: 每页数量(默认 20) **响应**(符合统一响应格式): ```json { "success": true, "data": { "items": [ { "id": "project-123", "name": "我的项目", "status": "trashed", "trashed_at": "2026-01-21T10:00:00Z", "days_remaining": 25 } ], "total": 10, "page": 1, "page_size": 20 } } ``` ### 9. 移动项目 ``` POST /api/v1/projects/{project_id}/move ``` **请求体**: ```json { "folder_id": "folder-456" } ``` ### 10. 克隆项目 ``` POST /api/v1/projects/{project_id}/clone ``` **响应**(符合统一响应格式): ```json { "success": true, "data": { "task_id": "task-123", "status": "pending" }, "message": "克隆任务已创建" } ``` ### 11. 导出项目 ``` POST /api/v1/projects/{project_id}/export ``` **响应**(符合统一响应格式): ```json { "success": true, "data": { "task_id": "task-456", "status": "pending" }, "message": "导出任务已创建" } ``` ### 12. 查询导出进度 ``` GET /api/v1/projects/{project_id}/export/{task_id} ``` **响应**(符合统一响应格式): ```json { "success": true, "data": { "task_id": "task-456", "status": "completed", "progress": 100, "download_url": "/api/v1/downloads/project-123.zip", "expires_at": "2025-01-28T10:00:00Z" } } ``` ### 13. 创建分享 ``` POST /api/v1/projects/{project_id}/shares ``` **请求体**: ```json { "password": "optional-password", "permission": "viewer", "expires_at": "2025-02-01T00:00:00Z" } ``` **响应**(符合统一响应格式): ```json { "success": true, "data": { "share_id": "share-123", "share_url": "/share/p/abc123token", "permission": "viewer", "expires_at": "2025-02-01T00:00:00Z" } } ``` ### 14. 获取分享列表 ``` GET /api/v1/projects/{project_id}/shares ``` **响应**(符合统一响应格式): ```json { "success": true, "data": { "shares": [ { "share_id": "share-123", "share_url": "/share/p/abc123token", "permission": "viewer", "created_by": "user-456", "created_at": "2025-01-27T10:00:00Z", "expires_at": "2025-02-01T00:00:00Z", "has_password": true } ] } } ``` ### 15. 撤销分享 ``` DELETE /api/v1/projects/{project_id}/shares/{share_id} ``` ### 16. 更新项目顺序 ``` PUT /api/v1/projects/{project_id}/order ``` **请求体**: ```json { "display_order": 10 } ``` ### 17. 获取子项目列表 ``` GET /api/v1/projects/{parent_project_id}/subprojects ``` **查询参数**: - `page`: 页码(默认 1) - `page_size`: 每页数量(默认 20) **响应**(符合统一响应格式): ```json { "success": true, "data": { "items": [ { "project_id": "subproject-123", "name": "第一集剧本", "parent_project_id": "parent-project-456", "screenplay_id": "screenplay-789", "type": "mine", "created_at": "2025-01-27T10:00:00Z" } ], "total": 5, "page": 1, "page_size": 20, "total_pages": 1 } } ``` **说明**: - 子项目通常由上传剧本时自动创建,不需要手动创建 - 此接口用于查询父项目下的所有子项目 - 子项目继承父项目的权限设置 --- ## 数据库设计 ### projects 表结构 ```sql CREATE TABLE projects ( project_id UUID PRIMARY KEY, -- UUID v7 主键,应用层生成 -- 基本信息 name TEXT NOT NULL, -- 项目名称,最大长度 255 type SMALLINT NOT NULL DEFAULT 1, -- 项目类型:1=mine(个人项目), 2=collab(协作项目) description TEXT, -- 项目描述,详细描述项目内容和目标 -- 所有者信息(V1: 仅支持个人用户,V2: 支持企业) owner_type TEXT NOT NULL DEFAULT 'user' CHECK (owner_type IN ('user', 'organization')), -- 所有者类型:user(用户) | organization(组织) owner_id UUID NOT NULL, -- 所有者ID(逻辑外键,无物理约束) -- 文件夹归属 folder_id UUID, -- 所属文件夹ID(逻辑外键,无物理约束),NULL 表示根目录 -- 父子项目关系(支持项目层级结构) parent_project_id UUID, -- 父项目ID(逻辑外键,无物理约束),NULL 表示根项目 screenplay_id UUID, -- 关联的剧本ID(逻辑外键,无物理约束),仅子项目使用 -- 显示顺序 display_order INTEGER NOT NULL DEFAULT 0, -- 自定义排序顺序,数值越小越靠前 -- 封面图片 thumbnail_url TEXT, -- 缩略图 URL(已废弃,保留兼容性) cover_image_id UUID, -- 封面图片ID(逻辑外键,无物理约束) -- 项目预算积分(V1: 暂不使用,V2: 企业为项目充值预算) ai_credits_budget INTEGER NOT NULL DEFAULT 0, -- V1: 保持为 0, V2: 企业为项目充值的专属积分预算 budget_consumed INTEGER NOT NULL DEFAULT 0, -- V1: 保持为 0, V2: 已消耗的项目预算积分 -- 影视项目元数据 content_type SMALLINT, -- 项目内容类型:1=ad(广告片), 2=movie(电影), 3=series(剧集), 4=anime(动画), 5=short(短视频), 6=concept(概念片) aspect_ratio SMALLINT, -- 画幅比例:1=16:9, 2=9:16, 3=4:3, 4=21:9, 5=1:1, 6=2.35:1, 7=2.39:1 planned_duration INTEGER CHECK (planned_duration > 0), -- 计划总时长(秒),用于项目规划 actual_duration INTEGER CHECK (actual_duration > 0), -- 实际总时长(秒),根据分镜/视频自动计算 visual_style TEXT, -- 视觉风格(英文名称,如 "cyberpunk", "minimalist", "anime"),用于 AI 生成时的风格参考 style_and_characters TEXT, -- 风格和角色描述,记录视觉风格、主要角色等信息 -- 项目设置 settings JSONB NOT NULL DEFAULT '{}' CHECK (jsonb_typeof(settings) = 'object'), -- 项目配置(分辨率、帧率等) -- 状态(支持回收站机制) status SMALLINT NOT NULL DEFAULT 0 CHECK (status IN (0, 1, 2, 3)), -- 项目状态:0=active(活跃), 1=archived(归档), 2=trashed(回收站), 3=soft_deleted(软删除) -- 时间戳(所有时间戳使用 TIMESTAMPTZ 存储 UTC 时间) created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 创建时间 updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 最后更新时间 trashed_at TIMESTAMPTZ, -- 进入回收站时间,用于计算30天自动清理期限 deleted_at TIMESTAMPTZ, -- 软删除时间,用户从回收站删除或自动过期后设置 -- 唯一约束 CONSTRAINT projects_name_unique UNIQUE (folder_id, name) NULLS NOT DISTINCT, -- 子项目约束:子项目必须关联剧本 CONSTRAINT projects_subproject_screenplay_check CHECK ( (parent_project_id IS NULL AND screenplay_id IS NULL) OR (parent_project_id IS NOT NULL AND screenplay_id IS NOT NULL) ) ); -- 索引(优化查询性能) CREATE INDEX idx_projects_owner ON projects (owner_type, owner_id) WHERE status IN (0, 1); -- 按所有者查询 CREATE INDEX idx_projects_folder_id ON projects (folder_id) WHERE status IN (0, 1) AND folder_id IS NOT NULL; -- 按文件夹查询 CREATE INDEX idx_projects_type ON projects (type) WHERE status IN (0, 1); -- 按项目类型筛选 CREATE INDEX idx_projects_status ON projects (status); -- 按状态筛选 CREATE INDEX idx_projects_trashed_at ON projects (trashed_at) WHERE status = 2; -- 回收站自动清理 CREATE INDEX idx_projects_created_at ON projects (created_at) WHERE status IN (0, 1); -- 按创建时间排序 CREATE INDEX idx_projects_updated_at ON projects (updated_at) WHERE status IN (0, 1); -- 按更新时间排序 CREATE INDEX idx_projects_display_order ON projects (folder_id, display_order) WHERE status IN (0, 1); -- 自定义排序 CREATE INDEX idx_projects_cover_image_id ON projects (cover_image_id) WHERE cover_image_id IS NOT NULL; -- 封面图片关联 CREATE INDEX idx_projects_settings_gin ON projects USING GIN (settings); -- JSONB 字段查询 CREATE INDEX idx_projects_name_trgm ON projects USING GIN (name gin_trgm_ops) WHERE status IN (0, 1); -- 项目名称全文搜索 CREATE INDEX idx_projects_description_trgm ON projects USING GIN (description gin_trgm_ops) WHERE status IN (0, 1) AND description IS NOT NULL; -- 描述全文搜索 CREATE INDEX idx_projects_content_type ON projects (content_type) WHERE status IN (0, 1) AND content_type IS NOT NULL; -- 按内容类型筛选 CREATE INDEX idx_projects_aspect_ratio ON projects (aspect_ratio) WHERE status IN (0, 1) AND aspect_ratio IS NOT NULL; -- 按画幅比例筛选 CREATE INDEX idx_projects_parent_project_id ON projects (parent_project_id) WHERE parent_project_id IS NOT NULL AND status IN (0, 1); -- 按父项目查询子项目 CREATE INDEX idx_projects_screenplay_id ON projects (screenplay_id) WHERE screenplay_id IS NOT NULL; -- 按剧本查询子项目 -- 表和字段注释 COMMENT ON TABLE projects IS '项目表,存储视频制作项目的基本信息和元数据'; COMMENT ON COLUMN projects.project_id IS '项目ID (UUID v7)'; COMMENT ON COLUMN projects.name IS '项目名称'; COMMENT ON COLUMN projects.type IS '项目类型: 1=mine(个人项目), 2=collab(协作项目)'; COMMENT ON COLUMN projects.description IS '项目描述'; COMMENT ON COLUMN projects.owner_type IS '所有者类型: user(用户) | organization(组织)'; COMMENT ON COLUMN projects.owner_id IS '所有者ID(逻辑外键,无物理约束)'; COMMENT ON COLUMN projects.folder_id IS '所属文件夹ID(逻辑外键,无物理约束)'; COMMENT ON COLUMN projects.parent_project_id IS '父项目ID(逻辑外键,无物理约束)'; COMMENT ON COLUMN projects.screenplay_id IS '关联的剧本ID(逻辑外键,无物理约束)'; COMMENT ON COLUMN projects.display_order IS '自定义排序顺序'; COMMENT ON COLUMN projects.thumbnail_url IS '缩略图 URL(已废弃)'; COMMENT ON COLUMN projects.cover_image_id IS '封面图片ID(逻辑外键,无物理约束)'; COMMENT ON COLUMN projects.ai_credits_budget IS 'V1: 保持为 0, V2: 企业为项目充值的专属积分预算'; COMMENT ON COLUMN projects.budget_consumed IS 'V1: 保持为 0, V2: 已消耗的项目预算积分'; COMMENT ON COLUMN projects.content_type IS '项目内容类型: 1=ad, 2=movie, 3=series, 4=anime, 5=short, 6=concept'; COMMENT ON COLUMN projects.aspect_ratio IS '画幅比例: 1=16:9, 2=9:16, 3=4:3, 4=21:9, 5=1:1, 6=2.35:1, 7=2.39:1'; COMMENT ON COLUMN projects.planned_duration IS '计划总时长(秒)'; COMMENT ON COLUMN projects.actual_duration IS '实际总时长(秒)'; COMMENT ON COLUMN projects.visual_style IS '视觉风格(英文名称,如 cyberpunk, minimalist)'; COMMENT ON COLUMN projects.style_and_characters IS '风格和角色描述'; COMMENT ON COLUMN projects.settings IS '项目配置(JSONB格式)'; COMMENT ON COLUMN projects.status IS '项目状态: 0=active, 1=archived, 2=trashed, 3=soft_deleted'; COMMENT ON COLUMN projects.created_at IS '创建时间(自动记录时区)'; COMMENT ON COLUMN projects.updated_at IS '更新时间(自动记录时区)'; COMMENT ON COLUMN projects.trashed_at IS '进入回收站时间(自动记录时区)'; COMMENT ON COLUMN projects.deleted_at IS '软删除时间(自动记录时区)'; -- 触发器 CREATE TRIGGER update_projects_updated_at BEFORE UPDATE ON projects FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); ``` ### project_shares 表结构 ```sql CREATE TABLE project_shares ( share_id UUID PRIMARY KEY, -- UUID v7 主键 project_id UUID NOT NULL, -- 关联项目ID(逻辑外键,无物理约束) share_token TEXT NOT NULL UNIQUE, -- 分享令牌,用于生成公开访问链接 password_hash TEXT, -- 访问密码的哈希值(可选) permission SMALLINT NOT NULL DEFAULT 1, -- 分享权限:1=viewer(查看), 2=editor(编辑) created_by UUID NOT NULL, -- 创建者用户ID(逻辑外键,无物理约束) created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 创建时间 expires_at TIMESTAMPTZ, -- 过期时间,NULL 表示永不过期 last_accessed_at TIMESTAMPTZ, -- 最后访问时间 access_count INTEGER NOT NULL DEFAULT 0, -- 访问次数统计 CONSTRAINT project_shares_token_unique UNIQUE (share_token) ); -- 索引 CREATE INDEX idx_project_shares_project_id ON project_shares (project_id); -- 按项目查询分享 CREATE INDEX idx_project_shares_token ON project_shares (share_token); -- 按令牌查询 CREATE INDEX idx_project_shares_created_by ON project_shares (created_by); -- 按创建者查询 CREATE INDEX idx_project_shares_expires_at ON project_shares (expires_at) WHERE expires_at IS NOT NULL; -- 过期清理 -- 表和字段注释 COMMENT ON TABLE project_shares IS '项目分享链接表,管理公开分享链接和权限'; COMMENT ON COLUMN project_shares.share_id IS '分享记录ID (UUID v7)'; COMMENT ON COLUMN project_shares.project_id IS '关联项目ID(逻辑外键,无物理约束)'; COMMENT ON COLUMN project_shares.share_token IS '分享令牌'; COMMENT ON COLUMN project_shares.password_hash IS '访问密码哈希值'; COMMENT ON COLUMN project_shares.permission IS '分享权限: 1=viewer, 2=editor'; COMMENT ON COLUMN project_shares.created_by IS '创建者用户ID(逻辑外键,无物理约束)'; COMMENT ON COLUMN project_shares.created_at IS '创建时间(自动记录时区)'; COMMENT ON COLUMN project_shares.expires_at IS '过期时间(自动记录时区)'; COMMENT ON COLUMN project_shares.last_accessed_at IS '最后访问时间(自动记录时区)'; COMMENT ON COLUMN project_shares.access_count IS '访问次数统计'; ``` ### project_members 表结构 ```sql CREATE TABLE project_members ( member_id UUID PRIMARY KEY, -- UUID v7 主键 project_id UUID NOT NULL, -- 关联项目ID(逻辑外键,无物理约束) user_id UUID NOT NULL, -- 成员用户ID(逻辑外键,无物理约束) role SMALLINT NOT NULL DEFAULT 3, -- 成员角色:1=owner(所有者), 2=editor(编辑者), 3=viewer(查看者) invited_by UUID, -- 邀请人用户ID(逻辑外键,无物理约束) created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 创建时间(邀请创建时间) updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 更新时间 CONSTRAINT project_members_unique UNIQUE (project_id, user_id) NULLS NOT DISTINCT ); -- 索引 CREATE INDEX idx_project_members_project_id ON project_members (project_id); -- 按项目查询成员 CREATE INDEX idx_project_members_user_id ON project_members (user_id); -- 按用户查询项目 CREATE INDEX idx_project_members_role ON project_members (role); -- 按角色筛选 -- 表和字段注释 COMMENT ON TABLE project_members IS '项目成员表,管理协作项目的成员和权限'; COMMENT ON COLUMN project_members.member_id IS '成员记录ID (UUID v7)'; COMMENT ON COLUMN project_members.project_id IS '关联项目ID(逻辑外键,无物理约束)'; COMMENT ON COLUMN project_members.user_id IS '成员用户ID(逻辑外键,无物理约束)'; COMMENT ON COLUMN project_members.role IS '成员角色: 1=owner, 2=editor, 3=viewer'; COMMENT ON COLUMN project_members.invited_by IS '邀请人用户ID(逻辑外键,无物理约束)'; COMMENT ON COLUMN project_members.created_at IS '创建时间(邀请创建时间,自动记录时区)'; COMMENT ON COLUMN project_members.updated_at IS '更新时间(自动记录时区)'; -- 触发器 CREATE TRIGGER update_project_members_updated_at BEFORE UPDATE ON project_members FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); ``` ### project_versions 表结构 ```sql CREATE TABLE project_versions ( version_id UUID PRIMARY KEY, -- UUID v7 主键 project_id UUID NOT NULL, -- 关联项目ID(逻辑外键,无物理约束) version_number INTEGER NOT NULL, -- 版本号,从 1 开始递增 snapshot_data JSONB NOT NULL, -- 项目快照数据(完整的项目状态) created_by UUID NOT NULL, -- 创建者用户ID(逻辑外键,无物理约束) note TEXT, -- 版本说明 created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 创建时间 CONSTRAINT project_versions_unique UNIQUE (project_id, version_number) ); -- 索引 CREATE INDEX idx_project_versions_project_id ON project_versions (project_id); -- 按项目查询版本 CREATE INDEX idx_project_versions_created_at ON project_versions (created_at); -- 按时间排序 CREATE INDEX idx_project_versions_snapshot_gin ON project_versions USING GIN (snapshot_data); -- JSONB 查询 -- 表和字段注释 COMMENT ON TABLE project_versions IS '项目版本表,存储项目快照用于版本控制和回滚'; COMMENT ON COLUMN project_versions.version_id IS '版本记录ID (UUID v7)'; COMMENT ON COLUMN project_versions.project_id IS '关联项目ID(逻辑外键,无物理约束)'; COMMENT ON COLUMN project_versions.version_number IS '版本号(从 1 开始递增)'; COMMENT ON COLUMN project_versions.snapshot_data IS '项目快照数据(JSONB格式)'; COMMENT ON COLUMN project_versions.created_by IS '创建者用户ID(逻辑外键,无物理约束)'; COMMENT ON COLUMN project_versions.note IS '版本说明'; COMMENT ON COLUMN project_versions.created_at IS '创建时间(自动记录时区)'; ``` ### 设计说明 1. **UUID v7 主键**:所有表使用 UUID v7 作为主键,应用层生成(`app.utils.uuid.generate_uuid_v7()`) 2. **项目类型**:使用 SMALLINT 存储,后端枚举映射区分个人项目(mine)和协作项目(collab) 3. **父子项目关系**: - `parent_project_id`:父项目 ID,NULL 表示根项目 - `screenplay_id`:关联的剧本 ID(仅子项目使用) - 约束:子项目必须关联剧本(`parent_project_id` 和 `screenplay_id` 要么都为 NULL,要么都不为 NULL) - 子项目继承父项目的权限设置 - 子项目可以独立管理分镜、资源、导出等功能 4. **多态所有者**(V2 预留): - `owner_type`:所有者类型('user' | 'organization') - `owner_id`:所有者 ID,根据 owner_type 指向不同表 - V1 阶段:owner_type 固定为 'user',owner_id 指向 users.user_id - V2 阶段:支持 owner_type='organization',owner_id 指向 organizations.organization_id 5. **项目预算积分**(V2 预留): - `ai_credits_budget`:项目专属积分预算(企业为项目充值) - `budget_consumed`:已消耗的预算积分 - V1 阶段:两个字段保持为 0,积分从 users.ai_credits_balance 扣除 - V2 阶段:启用项目预算功能,优先从项目预算扣除 6. **影视项目元数据**: - `content_type`:项目内容类型(广告片/电影/剧集/动画/短视频/概念片) - `aspect_ratio`:画幅比例(16:9/9:16/4:3/21:9/1:1/2.35:1/2.39:1) - `planned_duration`:计划总时长(秒),用于项目规划 - `actual_duration`:实际总时长(秒),根据分镜/视频自动计算 - `visual_style`:视觉风格(英文名称,如 "cyberpunk", "minimalist", "anime"),用于 AI 生成时的风格参考 - `style_and_characters`:风格和角色描述,记录视觉风格、主要角色等信息 7. **文件夹归属**:folder_id 支持项目分组管理 8. **显示顺序**:display_order 支持自定义排序,默认为 0 9. **封面图片**:cover_image_id 关联 attachments 表(一对一) 10. **项目设置**:settings 使用 JSONB 存储灵活配置(分辨率、帧率等) 11. **名称唯一**:同一文件夹下项目名称唯一 12. **协作管理**:project_members 表管理项目成员和权限 13. **版本控制**:project_versions 表存储项目快照,支持版本回滚 14. **软删除**:使用 deleted_at 字段 15. **全文搜索**:使用 pg_trgm 扩展支持项目名称、描述模糊搜索 16. **分享管理**:project_shares 表管理公开分享链接 - share_token:唯一的分享令牌 - password_hash:可选的访问密码 - permission:分享权限(viewer/editor) - expires_at:过期时间(可选) - access_count:访问统计 17. **扩展性设计**: - V1 实现简单,只处理个人用户场景 - V2 升级时无需修改表结构,只需启用预留字段 - 业务逻辑通过 owner_type 判断,易于扩展 --- ## 测试 ### 单元测试 ```bash # 运行 ProjectService 单元测试 docker exec jointo-server-app pytest tests/unit/services/test_project_service.py -v # 运行特定测试 docker exec jointo-server-app pytest tests/unit/services/test_project_service.py::test_create_project -v ``` ### 集成测试 ```bash # 运行 ProjectService 集成测试 docker exec jointo-server-app pytest tests/integration/services/test_project_service.py -v # 运行权限检查测试 docker exec jointo-server-app pytest tests/integration/services/test_project_service.py::test_project_permissions -v ``` ### 测试覆盖率 ```bash # 生成测试覆盖率报告 docker exec jointo-server-app pytest tests/ --cov=app.services.project_service --cov-report=html ``` --- ## 数据库迁移 ### 创建迁移文件 ```bash # 1. 创建迁移文件 docker exec jointo-server-app alembic revision -m "create_projects_tables" # 2. 编辑迁移文件 # 将本文档中的 SQL 表结构复制到迁移文件的 upgrade() 函数中 # 3. 检查迁移状态 docker exec jointo-server-app alembic current # 4. 执行迁移 docker exec jointo-server-app python scripts/db_migrate.py upgrade # 5. 验证迁移 docker exec jointo-server-app alembic history ``` ### 回滚迁移 ```bash # 回滚到上一个版本 docker exec jointo-server-app python scripts/db_migrate.py downgrade -1 # 回滚到指定版本 docker exec jointo-server-app python scripts/db_migrate.py downgrade ``` --- ## 数据模型 ### 枚举值映射表 | 枚举类型 | 数值 | 字符串值 | 说明 | |---------|------|---------|------| | **project_type** | 1 | mine | 个人项目 | | | 2 | collab | 协作项目 | | **project_status** | 0 | active | 活跃项目 | | | 1 | archived | 用户归档 | | | 2 | trashed | 回收站(30天内可恢复) | | | 3 | soft_deleted | 软删除(用户不可见) | | **content_type** | 1 | ad | 广告片 | | | 2 | movie | 电影 | | | 3 | series | 剧集 | | | 4 | anime | 动画 | | | 5 | short | 短视频 | | | 6 | concept | 概念片 | | **aspect_ratio** | 1 | 16:9 | 标准宽屏 | | | 2 | 9:16 | 竖屏 | | | 3 | 4:3 | 传统电视 | | | 4 | 21:9 | 超宽屏 | | | 5 | 1:1 | 正方形 | | | 6 | 2.35:1 | 电影宽屏 | | | 7 | 2.39:1 | 电影宽屏 | | **share_permission** | 1 | viewer | 查看权限 | | | 2 | editor | 编辑权限 | | **member_role** | 1 | owner | 所有者 | | | 2 | editor | 编辑者 | | | 3 | viewer | 查看者 | ### Project 模型 ```python # app/models/project.py from sqlalchemy import Column, String, Text, ForeignKey, JSONB, Integer, SmallInteger from sqlalchemy.dialects.postgresql import TIMESTAMP from sqlalchemy.orm import relationship from app.core.database import Base from app.utils.uuid import generate_uuid_v7 from datetime import datetime, UTC from enum import IntEnum class ProjectStatus(IntEnum): """项目状态枚举""" ACTIVE = 0 ARCHIVED = 1 TRASHED = 2 SOFT_DELETED = 3 @classmethod def from_string(cls, value: str) -> int: """字符串转数字""" mapping = { 'active': cls.ACTIVE, 'archived': cls.ARCHIVED, 'trashed': cls.TRASHED, 'soft_deleted': cls.SOFT_DELETED } return mapping.get(value.lower()) @classmethod def to_string(cls, value: int) -> str: """数字转字符串""" mapping = { cls.ACTIVE: 'active', cls.ARCHIVED: 'archived', cls.TRASHED: 'trashed', cls.SOFT_DELETED: 'soft_deleted' } return mapping.get(value) class ProjectType(IntEnum): """项目类型枚举""" MINE = 1 COLLAB = 2 @classmethod def from_string(cls, value: str) -> int: """字符串转数字""" mapping = {'mine': cls.MINE, 'collab': cls.COLLAB} return mapping.get(value.lower()) @classmethod def to_string(cls, value: int) -> str: """数字转字符串""" mapping = {cls.MINE: 'mine', cls.COLLAB: 'collab'} return mapping.get(value) class ProjectContentType(IntEnum): """项目内容类型枚举""" AD = 1 MOVIE = 2 SERIES = 3 ANIME = 4 SHORT = 5 CONCEPT = 6 @classmethod def from_string(cls, value: str) -> int: mapping = { 'ad': cls.AD, 'movie': cls.MOVIE, 'series': cls.SERIES, 'anime': cls.ANIME, 'short': cls.SHORT, 'concept': cls.CONCEPT } return mapping.get(value.lower()) @classmethod def to_string(cls, value: int) -> str: mapping = { cls.AD: 'ad', cls.MOVIE: 'movie', cls.SERIES: 'series', cls.ANIME: 'anime', cls.SHORT: 'short', cls.CONCEPT: 'concept' } return mapping.get(value) class AspectRatioType(IntEnum): """画幅比例枚举""" RATIO_16_9 = 1 RATIO_9_16 = 2 RATIO_4_3 = 3 RATIO_21_9 = 4 RATIO_1_1 = 5 RATIO_2_35_1 = 6 RATIO_2_39_1 = 7 @classmethod def from_string(cls, value: str) -> int: mapping = { '16:9': cls.RATIO_16_9, '9:16': cls.RATIO_9_16, '4:3': cls.RATIO_4_3, '21:9': cls.RATIO_21_9, '1:1': cls.RATIO_1_1, '2.35:1': cls.RATIO_2_35_1, '2.39:1': cls.RATIO_2_39_1 } return mapping.get(value) @classmethod def to_string(cls, value: int) -> str: mapping = { cls.RATIO_16_9: '16:9', cls.RATIO_9_16: '9:16', cls.RATIO_4_3: '4:3', cls.RATIO_21_9: '21:9', cls.RATIO_1_1: '1:1', cls.RATIO_2_35_1: '2.35:1', cls.RATIO_2_39_1: '2.39:1' } return mapping.get(value) class SharePermission(IntEnum): """分享权限枚举""" VIEWER = 1 EDITOR = 2 @classmethod def from_string(cls, value: str) -> int: mapping = {'viewer': cls.VIEWER, 'editor': cls.EDITOR} return mapping.get(value.lower()) @classmethod def to_string(cls, value: int) -> str: mapping = {cls.VIEWER: 'viewer', cls.EDITOR: 'editor'} return mapping.get(value) class MemberRole(IntEnum): """成员角色枚举""" OWNER = 1 EDITOR = 2 VIEWER = 3 @classmethod def from_string(cls, value: str) -> int: mapping = {'owner': cls.OWNER, 'editor': cls.EDITOR, 'viewer': cls.VIEWER} return mapping.get(value.lower()) @classmethod def to_string(cls, value: int) -> str: mapping = {cls.OWNER: 'owner', cls.EDITOR: 'editor', cls.VIEWER: 'viewer'} return mapping.get(value) class Project(Base): __tablename__ = "projects" # 使用 UUID v7 主键(应用层生成) id = Column('project_id', String, primary_key=True, default=generate_uuid_v7) def __repr__(self): return f"" name = Column(String(255), nullable=False) description = Column(Text) type = Column(SmallInteger, nullable=False, default=ProjectType.MINE) owner_type = Column(String, nullable=False, default='user') owner_id = Column(String, nullable=False) folder_id = Column(String) parent_project_id = Column(String) # 父项目ID screenplay_id = Column(String) # 关联的剧本ID(仅子项目) display_order = Column(Integer, default=0, nullable=False) thumbnail_url = Column(String(500)) cover_image_id = Column(String) # 项目预算积分(V2 预留) ai_credits_budget = Column(Integer, nullable=False, default=0) budget_consumed = Column(Integer, nullable=False, default=0) # 影视项目元数据 content_type = Column(SmallInteger) aspect_ratio = Column(SmallInteger) planned_duration = Column(Integer) actual_duration = Column(Integer) visual_style = Column(Text) # 视觉风格(英文名称) style_and_characters = Column(Text) # 项目设置 settings = Column(JSONB, default={}) # 状态 status = Column(SmallInteger, nullable=False, default=ProjectStatus.ACTIVE) # 时间戳(使用 TIMESTAMPTZ 存储 UTC 时间) created_at = Column(TIMESTAMP(timezone=True), default=lambda: datetime.now(UTC)) updated_at = Column(TIMESTAMP(timezone=True), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)) trashed_at = Column(TIMESTAMP(timezone=True)) deleted_at = Column(TIMESTAMP(timezone=True)) # 关系(注意:数据库层不使用外键约束,仅应用层维护引用完整性) # owner = relationship("User", back_populates="projects") # folder = relationship("Folder", back_populates="projects") # storyboards = relationship("Storyboard", back_populates="project") # members = relationship("ProjectMember", back_populates="project") @property def type_str(self) -> str: """获取项目类型字符串""" return ProjectType.to_string(self.type) @property def status_str(self) -> str: """获取项目状态字符串""" return ProjectStatus.to_string(self.status) @property def content_type_str(self) -> str: """获取内容类型字符串""" return ProjectContentType.to_string(self.content_type) if self.content_type else None @property def aspect_ratio_str(self) -> str: """获取画幅比例字符串""" return AspectRatioType.to_string(self.aspect_ratio) if self.aspect_ratio else None @property def is_subproject(self) -> bool: """判断是否为子项目""" return self.parent_project_id is not None ``` ### ProjectCreate Schema ```python # app/schemas/project.py from pydantic import BaseModel, Field, field_validator, ConfigDict from typing import Optional, Dict, Any from enum import Enum from app.models.project import ProjectType, ProjectContentType, AspectRatioType, SharePermission, MemberRole class ProjectSettings(BaseModel): resolution: str = "1920x1080" fps: int = 30 duration: Optional[int] = None class ProjectCreate(BaseModel): name: str = Field(..., min_length=1, max_length=255) description: Optional[str] = None type: str = Field(..., pattern="^(mine|collab)$") folder_id: Optional[str] = None content_type: Optional[str] = None # 'ad', 'movie', 'series', 'anime', 'short', 'concept' aspect_ratio: Optional[str] = None # '16:9', '9:16', '4:3', '21:9', '1:1', '2.35:1', '2.39:1' planned_duration: Optional[int] = Field(None, gt=0) visual_style: Optional[str] = None # 视觉风格(英文名称,如 "cyberpunk", "minimalist") style_and_characters: Optional[str] = None settings: Optional[ProjectSettings] = None @field_validator('content_type') @classmethod def validate_content_type(cls, v: Optional[str]) -> Optional[str]: if v is not None: valid_types = ['ad', 'movie', 'series', 'anime', 'short', 'concept'] if v.lower() not in valid_types: raise ValueError(f'content_type must be one of {valid_types}') return v @field_validator('aspect_ratio') @classmethod def validate_aspect_ratio(cls, v: Optional[str]) -> Optional[str]: if v is not None: valid_ratios = ['16:9', '9:16', '4:3', '21:9', '1:1', '2.35:1', '2.39:1'] if v not in valid_ratios: raise ValueError(f'aspect_ratio must be one of {valid_ratios}') return v class ProjectUpdate(BaseModel): name: Optional[str] = Field(None, min_length=1, max_length=255) description: Optional[str] = None thumbnail_url: Optional[str] = None content_type: Optional[str] = None aspect_ratio: Optional[str] = None planned_duration: Optional[int] = Field(None, gt=0) actual_duration: Optional[int] = Field(None, gt=0) visual_style: Optional[str] = None # 视觉风格(英文名称) style_and_characters: Optional[str] = None settings: Optional[ProjectSettings] = None @field_validator('content_type') @classmethod def validate_content_type(cls, v: Optional[str]) -> Optional[str]: if v is not None: valid_types = ['ad', 'movie', 'series', 'anime', 'short', 'concept'] if v.lower() not in valid_types: raise ValueError(f'content_type must be one of {valid_types}') return v @field_validator('aspect_ratio') @classmethod def validate_aspect_ratio(cls, v: Optional[str]) -> Optional[str]: if v is not None: valid_ratios = ['16:9', '9:16', '4:3', '21:9', '1:1', '2.35:1', '2.39:1'] if v not in valid_ratios: raise ValueError(f'aspect_ratio must be one of {valid_ratios}') return v class ProjectMove(BaseModel): folder_id: Optional[str] = None class ProjectClone(BaseModel): """克隆项目请求""" pass # 无需额外参数,使用默认行为 class ProjectExport(BaseModel): """导出项目请求""" pass # 无需额外参数 class ProjectShareCreate(BaseModel): password: Optional[str] = None permission: str = Field(default='viewer', pattern='^(viewer|editor)$') expires_at: Optional[str] = None # ISO 8601 格式 class ProjectShareResponse(BaseModel): share_id: str share_url: str permission: str created_by: str created_at: str expires_at: Optional[str] has_password: bool access_count: int model_config = ConfigDict(from_attributes=True) class ProjectOrderUpdate(BaseModel): display_order: int = Field(..., ge=0) class TaskStatusResponse(BaseModel): task_id: str status: str # 'pending' | 'processing' | 'completed' | 'failed' progress: int = Field(..., ge=0, le=100) message: Optional[str] = None download_url: Optional[str] = None expires_at: Optional[str] = None class ProjectResponse(BaseModel): id: str name: str description: Optional[str] type: str # 返回字符串 'mine' 或 'collab' owner_id: str folder_id: Optional[str] parent_project_id: Optional[str] # 父项目ID screenplay_id: Optional[str] # 关联的剧本ID is_subproject: bool # 是否为子项目 display_order: int thumbnail_url: Optional[str] content_type: Optional[str] # 返回字符串 'ad', 'movie' 等 aspect_ratio: Optional[str] # 返回字符串 '16:9', '9:16' 等 planned_duration: Optional[int] actual_duration: Optional[int] visual_style: Optional[str] # 视觉风格(英文名称) style_and_characters: Optional[str] settings: Dict[str, Any] created_at: str updated_at: str model_config = ConfigDict(from_attributes=True) @classmethod def from_orm(cls, obj): """从 ORM 对象转换,自动处理枚举转换""" return cls( id=obj.id, name=obj.name, description=obj.description, type=obj.type_str, # 使用模型的 property 方法 owner_id=obj.owner_id, folder_id=obj.folder_id, parent_project_id=obj.parent_project_id, screenplay_id=obj.screenplay_id, is_subproject=obj.is_subproject, display_order=obj.display_order, thumbnail_url=obj.thumbnail_url, content_type=obj.content_type_str, aspect_ratio=obj.aspect_ratio_str, planned_duration=obj.planned_duration, actual_duration=obj.actual_duration, visual_style=obj.visual_style, style_and_characters=obj.style_and_characters, settings=obj.settings, created_at=obj.created_at.isoformat(), updated_at=obj.updated_at.isoformat() ) ``` **说明**: - API 层面保持使用字符串('mine', 'collab', 'ad', '16:9' 等) - 数据库层面使用 SMALLINT 存储 - 模型层提供 `from_string()` 和 `to_string()` 方法进行转换 - Schema 层使用 `field_validator` 验证输入 - Response 使用 `from_orm()` 自动转换枚举为字符串 ``` --- ## 相关文档 - [文件夹管理服务](./folder-service.md) - [分镜管理服务](./storyboard-service.md) --- ## 变更记录 **v2.1 (2026-01-31)** - 新增视觉风格字段: - 项目表新增 `visual_style` 字段(TEXT,可选) - 存储视觉风格的英文名称(如 "cyberpunk", "minimalist", "anime") - 用于 AI 生成时的风格参考 - 更新 `ProjectCreate`、`ProjectUpdate`、`ProjectResponse` Schema - 更新 `Project` 模型 **v2.0 (2026-01-31)** - 新增子项目功能: - 项目表新增 `parent_project_id` 和 `screenplay_id` 字段 - 支持父子项目关系(项目层级结构) - 子项目关联剧本(一对一) - 子项目继承父项目权限和设置 - 新增 `create_subproject()` 方法 - 新增 `get_subprojects()` 方法 - 新增 `GET /api/v1/projects/{parent_project_id}/subprojects` 接口 - 更新数据库设计(新增约束和索引) - 更新 `Project` 模型(新增 `is_subproject` 属性) - 更新 `ProjectResponse` Schema(新增子项目相关字段) **v1.0 (2026-01-20)** - 初始版本 --- **文档版本**:v2.1 **最后更新**:2026-01-31 ### ✅ 已完成(2026-01-20) **核心功能**: - 项目 CRUD 操作(创建、查询、更新、删除) - 权限管理(owner/editor/viewer 三级权限) - 成员管理(添加、移除、角色管理) - 搜索和筛选(全文搜索 + 多条件筛选) - 排序功能(时间/名称/自定义顺序) - 分享功能(链接生成、密码保护、权限控制、过期时间) - 项目移动(文件夹间移动) **技术实现**: - 数据模型层:`server/app/models/project.py` - Schema 层:`server/app/schemas/project.py` - 仓储层:`server/app/repositories/project_repository.py` - 服务层:`server/app/services/project_service.py` - API 层:`server/app/api/v1/projects.py` - 数据库迁移:`server/app/migrations/003_project_tables.py` **数据库特性**: - 使用 UUID v7 主键(时间排序) - pg_trgm 扩展支持全文搜索 - JSONB 存储灵活配置 - 软删除机制 - 自动更新时间戳 ### ⚠️ 部分实现 **克隆功能**: - 状态:同步版本已实现 - 限制:大项目可能超时 - 位置:`ProjectService.clone_project()` - TODO:实现异步任务队列后改为异步处理 **导出功能**: - 状态:接口框架已实现 - 限制:返回"开发中"状态 - 位置:`ProjectService.export_project()` - TODO:实现异步任务队列和实际导出逻辑 ### 📋 待实现 **异步任务系统**: - 任务队列(Celery/ARQ) - Redis 任务状态追踪 - 进度通知机制 - 文件下载管理 **版本控制功能**: - 项目快照创建 - 版本回滚 - 版本对比 - 模型已预留:`ProjectVersion` **企业功能(V2)**: - 组织所有者支持(owner_type='organization') - 项目预算积分(ai_credits_budget) - 预算消耗追踪(budget_consumed) **资源关联**: - 克隆时复制分镜 - 克隆时复制视频 - 克隆时复制附件 - 需要等待相关模型实现 ### 🔧 技术债务 1. **全文搜索优化**:当前使用 ILIKE,可升级为 pg_trgm 的 similarity 函数 2. **权限继承**:项目权限可从文件夹继承,需要完善继承逻辑 3. **批量操作**:支持批量移动、批量删除等操作 4. **统计信息**:项目统计(分镜数、视频数、总时长等) --- **实现完成时间**:2026-01-20