# 评论协作服务 > **文档版本**:v2.0 > **最后更新**:2026-02-03 > **合规状态**:✅ 符合 jointo-tech-stack 规范 --- ## 目录 1. [服务概述](#服务概述) 2. [核心功能](#核心功能) 3. [服务实现](#服务实现) 4. [API 接口](#api-接口) 5. [数据模型](#数据模型) --- ## 服务概述 评论协作服务负责处理项目中的评论功能,支持多态评论(项目、分镜、视频等)、回复、@ 提及、评论解决等协作功能。 ### 职责 - 评论 CRUD 操作 - 多态评论(支持评论项目、分镜、视频、音效、字幕、配音、分镜看板项) - 回复功能(父子评论) - @ 提及功能 - 评论解决状态管理 - 分镜看板位置标记 - 评论通知 --- ## 核心功能 ### 1. 多态评论 支持对以下对象进行评论: - **项目级评论**:整体反馈 - **分镜评论**:针对特定分镜 - **视频评论**:针对视频片段 - **音效评论**:针对音效 - **字幕评论**:针对字幕 - **配音评论**:针对配音 - **分镜看板项评论**:针对分镜看板上的项 ### 2. 回复功能 - 支持多级回复 - 查询评论的所有回复 - 回复通知 ### 3. @ 提及功能 - 在评论中 @ 提及用户 - 提及用户收到通知 - 查询提及我的评论 ### 4. 评论解决 - 标记评论为已解决 - 记录解决人和解决时间 - 筛选未解决的评论 ### 5. 分镜看板位置 - 评论可以标记分镜看板位置 - 按时间位置查询评论 --- ## 服务实现 ### CommentService 类 ```python # app/services/comment_service.py from typing import List, Optional, Dict, Any from sqlalchemy.ext.asyncio import AsyncSession from app.models.comment import Comment from app.repositories.comment_repository import CommentRepository from app.repositories.project_repository import ProjectRepository from app.repositories.user_repository import UserRepository from app.schemas.comment import CommentCreate, CommentUpdate, CommentFilter from app.core.exceptions import NotFoundError, ValidationError, PermissionError from app.services.notification_service import NotificationService from app.core.logging import get_logger logger = get_logger(__name__) class CommentService: def __init__(self, async_session: AsyncSession): self.repository = CommentRepository(async_session) self.project_repository = ProjectRepository(async_session) self.user_repository = UserRepository(async_session) self.notification_service = NotificationService(async_session) self.async_session = async_session async def get_comments( self, user_id: str, comment_filter: CommentFilter, page: int = 1, page_size: int = 50 ) -> Dict[str, Any]: """获取评论列表""" # 检查项目权限 has_permission = await self.project_repository.check_user_permission( user_id, comment_filter.project_id, 'viewer' ) if not has_permission: raise PermissionError("没有权限访问此项目") comments = await self.repository.get_by_filter( project_id=comment_filter.project_id, storyboard_id=comment_filter.storyboard_id, video_id=comment_filter.video_id, sound_effect_id=comment_filter.sound_effect_id, subtitle_id=comment_filter.subtitle_id, voiceover_id=comment_filter.voiceover_id, timeline_item_id=comment_filter.timeline_item_id, is_resolved=comment_filter.is_resolved, page=page, page_size=page_size ) total = await self.repository.count_by_filter( project_id=comment_filter.project_id, storyboard_id=comment_filter.storyboard_id, video_id=comment_filter.video_id, is_resolved=comment_filter.is_resolved ) return { 'items': comments, 'total': total, 'page': page, 'page_size': page_size, 'total_pages': (total + page_size - 1) // page_size } async def get_comment( self, user_id: str, comment_id: str ) -> Comment: """获取评论详情""" comment = await self.repository.get_by_id(comment_id) if not comment: raise NotFoundError("评论不存在") # 检查项目权限 has_permission = await self.project_repository.check_user_permission( user_id, comment.project_id, 'viewer' ) if not has_permission: raise PermissionError("没有权限访问此评论") return comment async def create_comment( self, user_id: str, project_id: str, comment_data: CommentCreate ) -> Comment: """创建评论""" # 检查项目权限 has_permission = await self.project_repository.check_user_permission( user_id, project_id, 'viewer' ) if not has_permission: raise PermissionError("没有权限在此项目中评论") # 验证关联对象(至少有一个,或者是项目级评论) has_target = any([ comment_data.storyboard_id, comment_data.video_id, comment_data.sound_effect_id, comment_data.subtitle_id, comment_data.voiceover_id, comment_data.timeline_item_id ]) # 如果是回复,验证父评论存在 if comment_data.parent_comment_id: parent_comment = await self.repository.get_by_id(comment_data.parent_comment_id) if not parent_comment: raise NotFoundError("父评论不存在") if parent_comment.project_id != project_id: raise ValidationError("父评论不属于此项目") # 提取 @ 提及的用户 mentioned_user_ids = self._extract_mentions(comment_data.content) # 验证提及的用户存在 if mentioned_user_ids: for mentioned_user_id in mentioned_user_ids: user = await self.user_repository.get_by_id(mentioned_user_id) if not user: raise ValidationError(f"用户 {mentioned_user_id} 不存在") comment = Comment( project_id=project_id, storyboard_id=comment_data.storyboard_id, video_id=comment_data.video_id, sound_effect_id=comment_data.sound_effect_id, subtitle_id=comment_data.subtitle_id, voiceover_id=comment_data.voiceover_id, timeline_item_id=comment_data.timeline_item_id, content=comment_data.content, parent_comment_id=comment_data.parent_comment_id, mentioned_user_ids=mentioned_user_ids, author_id=user_id, time_position=comment_data.time_position ) created_comment = await self.repository.create(comment) # 发送通知 await self._send_comment_notifications( comment=created_comment, mentioned_user_ids=mentioned_user_ids, parent_comment=parent_comment if comment_data.parent_comment_id else None ) return created_comment async def update_comment( self, user_id: str, comment_id: str, comment_data: CommentUpdate ) -> Comment: """更新评论""" comment = await self.repository.get_by_id(comment_id) if not comment: raise NotFoundError("评论不存在") # 检查权限(只有作者可以更新) if comment.author_id != user_id: raise PermissionError("只有评论作者可以更新评论") # 如果更新内容,重新提取 @ 提及 if comment_data.content: mentioned_user_ids = self._extract_mentions(comment_data.content) comment_data.mentioned_user_ids = mentioned_user_ids update_data = comment_data.dict(exclude_unset=True) return await self.repository.update(comment_id, update_data) async def delete_comment( self, user_id: str, comment_id: str ) -> None: """删除评论(软删除)""" comment = await self.repository.get_by_id(comment_id) if not comment: raise NotFoundError("评论不存在") # 检查权限(作者或项目所有者可以删除) is_author = comment.author_id == user_id is_project_owner = await self.project_repository.check_user_permission( user_id, comment.project_id, 'owner' ) if not (is_author or is_project_owner): raise PermissionError("没有权限删除此评论") await self.repository.soft_delete(comment_id) async def resolve_comment( self, user_id: str, comment_id: str ) -> Comment: """标记评论为已解决""" comment = await self.repository.get_by_id(comment_id) if not comment: raise NotFoundError("评论不存在") # 检查权限 has_permission = await self.project_repository.check_user_permission( user_id, comment.project_id, 'editor' ) if not has_permission: raise PermissionError("没有权限解决此评论") return await self.repository.update(comment_id, { 'is_resolved': True, 'resolved_by': user_id, 'resolved_at': datetime.now(timezone.utc) }) async def unresolve_comment( self, user_id: str, comment_id: str ) -> Comment: """取消评论解决状态""" comment = await self.repository.get_by_id(comment_id) if not comment: raise NotFoundError("评论不存在") # 检查权限 has_permission = await self.project_repository.check_user_permission( user_id, comment.project_id, 'editor' ) if not has_permission: raise PermissionError("没有权限取消解决此评论") return await self.repository.update(comment_id, { 'is_resolved': False, 'resolved_by': None, 'resolved_at': None }) async def get_replies( self, user_id: str, comment_id: str ) -> List[Comment]: """获取评论的所有回复""" comment = await self.repository.get_by_id(comment_id) if not comment: raise NotFoundError("评论不存在") # 检查权限 has_permission = await self.project_repository.check_user_permission( user_id, comment.project_id, 'viewer' ) if not has_permission: raise PermissionError("没有权限访问此评论") return await self.repository.get_replies(comment_id) async def get_mentions( self, user_id: str, project_id: Optional[str] = None, page: int = 1, page_size: int = 20 ) -> Dict[str, Any]: """获取提及我的评论""" comments = await self.repository.get_by_mentioned_user( user_id=user_id, project_id=project_id, page=page, page_size=page_size ) total = await self.repository.count_by_mentioned_user( user_id=user_id, project_id=project_id ) return { 'items': comments, 'total': total, 'page': page, 'page_size': page_size, 'total_pages': (total + page_size - 1) // page_size } def _extract_mentions(self, content: str) -> List[str]: """从评论内容中提取 @ 提及的用户 ID""" import re # 匹配 @user-123 格式 pattern = r'@(user-[a-zA-Z0-9-]+)' matches = re.findall(pattern, content) return list(set(matches)) # 去重 async def _send_comment_notifications( self, comment: Comment, mentioned_user_ids: List[str], parent_comment: Optional[Comment] = None ) -> None: """发送评论通知""" # 通知被提及的用户 for user_id in mentioned_user_ids: await self.notification_service.create_notification( user_id=user_id, notification_type='mention', title='有人在评论中提及了你', content=f'{comment.author_id} 在评论中提及了你', related_data={ 'comment_id': comment.id, 'project_id': comment.project_id } ) # 如果是回复,通知父评论作者 if parent_comment and parent_comment.author_id != comment.author_id: await self.notification_service.create_notification( user_id=parent_comment.author_id, notification_type='comment', title='有人回复了你的评论', content=f'{comment.author_id} 回复了你的评论', related_data={ 'comment_id': comment.id, 'parent_comment_id': parent_comment.id, 'project_id': comment.project_id } ) ``` --- ## API 接口 ### 1. 获取评论列表 ``` GET /api/v1/projects/{project_id}/comments ``` **查询参数**: - `storyboard_id`: 分镜 ID(可选) - `video_id`: 视频 ID(可选) - `is_resolved`: 是否已解决(可选) - `page`: 页码(默认 1) - `page_size`: 每页数量(默认 50) **响应**: ```json { "items": [ { "id": "comment-123", "project_id": "project-456", "storyboard_id": "storyboard-789", "content": "这个分镜的构图很棒!@user-001", "author_id": "user-002", "mentioned_user_ids": ["user-001"], "is_resolved": false, "created_at": "2025-01-27T10:00:00Z" } ], "total": 50, "page": 1, "page_size": 50, "total_pages": 1 } ``` ### 2. 创建评论 ``` POST /api/v1/projects/{project_id}/comments ``` **请求体**: ```json { "storyboard_id": "storyboard-789", "content": "这个分镜的构图很棒!@user-001", "time_position": 5.5 } ``` ### 3. 回复评论 ``` POST /api/v1/projects/{project_id}/comments ``` **请求体**: ```json { "parent_comment_id": "comment-123", "content": "谢谢!@user-002" } ``` ### 4. 更新评论 ``` PUT /api/v1/comments/{comment_id} ``` **请求体**: ```json { "content": "这个分镜的构图很棒!(已修改)" } ``` ### 5. 删除评论 ``` DELETE /api/v1/comments/{comment_id} ``` ### 6. 标记评论为已解决 ``` POST /api/v1/comments/{comment_id}/resolve ``` ### 7. 取消评论解决状态 ``` POST /api/v1/comments/{comment_id}/unresolve ``` ### 8. 获取评论的回复 ``` GET /api/v1/comments/{comment_id}/replies ``` ### 9. 获取提及我的评论 ``` GET /api/v1/comments/mentions ``` **查询参数**: - `project_id`: 项目 ID(可选) - `page`: 页码(默认 1) - `page_size`: 每页数量(默认 20) --- ## 数据模型 ### Comment 模型 ```python # app/models/comment.py from sqlmodel import SQLModel, Field, Column from sqlalchemy import Text, SmallInteger, TIMESTAMP, text, ARRAY from sqlalchemy.dialects.postgresql import UUID, NUMERIC from datetime import datetime from typing import Optional, List from app.utils.id_generator import generate_uuid class Comment(SQLModel, table=True): __tablename__ = "comments" __table_args__ = {"comment": "评论表"} # 主键(UUID v7 - 应用层生成) comment_id: str = Field( sa_column=Column(UUID(as_uuid=False), primary_key=True, default=generate_uuid), description="评论 ID (UUID v7)" ) # 关联字段(无外键约束,应用层验证) project_id: str = Field( sa_column=Column(UUID(as_uuid=False), nullable=False, index=True), description="项目 ID - 应用层验证" ) storyboard_id: Optional[str] = Field( default=None, sa_column=Column(UUID(as_uuid=False), nullable=True, index=True), description="分镜 ID - 应用层验证" ) video_id: Optional[str] = Field( default=None, sa_column=Column(UUID(as_uuid=False), nullable=True), description="视频 ID - 应用层验证" ) sound_effect_id: Optional[str] = Field( default=None, sa_column=Column(UUID(as_uuid=False), nullable=True), description="音效 ID - 应用层验证" ) subtitle_id: Optional[str] = Field( default=None, sa_column=Column(UUID(as_uuid=False), nullable=True), description="字幕 ID - 应用层验证" ) voiceover_id: Optional[str] = Field( default=None, sa_column=Column(UUID(as_uuid=False), nullable=True), description="配音 ID - 应用层验证" ) timeline_item_id: Optional[str] = Field( default=None, sa_column=Column(UUID(as_uuid=False), nullable=True), description="时间轴项 ID - 应用层验证" ) # 评论内容 content: str = Field( sa_column=Column(Text, nullable=False), description="评论内容" ) # 回复和提及 parent_comment_id: Optional[str] = Field( default=None, sa_column=Column(UUID(as_uuid=False), nullable=True, index=True), description="父评论 ID - 应用层验证" ) mentioned_user_ids: Optional[List[str]] = Field( default=None, sa_column=Column(ARRAY(UUID(as_uuid=False))), description="提及的用户 ID 列表" ) # 作者 author_id: str = Field( sa_column=Column(UUID(as_uuid=False), nullable=False, index=True), description="作者 ID - 应用层验证" ) # 时间位置(用于视频评论) time_position: Optional[float] = Field( default=None, sa_column=Column(NUMERIC(10, 3)), description="时间位置(秒)" ) # 解决状态 is_resolved: bool = Field(default=False, description="是否已解决") resolved_by: Optional[str] = Field( default=None, sa_column=Column(UUID(as_uuid=False), nullable=True), description="解决人 ID - 应用层验证" ) resolved_at: Optional[datetime] = Field( default=None, sa_column=Column(TIMESTAMP(timezone=True)), description="解决时间" ) # 时间戳(TIMESTAMPTZ) created_at: datetime = Field( sa_column=Column( TIMESTAMP(timezone=True), nullable=False, server_default=text("CURRENT_TIMESTAMP") ), description="创建时间" ) updated_at: datetime = Field( sa_column=Column( TIMESTAMP(timezone=True), nullable=False, server_default=text("CURRENT_TIMESTAMP") ), description="更新时间" ) deleted_at: Optional[datetime] = Field( default=None, sa_column=Column(TIMESTAMP(timezone=True)), description="删除时间(软删除)" ) ``` ### CommentCreate Schema ```python # app/schemas/comment.py from pydantic import BaseModel, Field from typing import Optional, List class CommentCreate(BaseModel): storyboard_id: Optional[str] = None video_id: Optional[str] = None sound_effect_id: Optional[str] = None subtitle_id: Optional[str] = None voiceover_id: Optional[str] = None timeline_item_id: Optional[str] = None content: str = Field(..., min_length=1, max_length=5000) parent_comment_id: Optional[str] = None time_position: Optional[float] = Field(None, ge=0) class CommentUpdate(BaseModel): content: Optional[str] = Field(None, min_length=1, max_length=5000) class CommentFilter(BaseModel): project_id: str storyboard_id: Optional[str] = None video_id: Optional[str] = None sound_effect_id: Optional[str] = None subtitle_id: Optional[str] = None voiceover_id: Optional[str] = None timeline_item_id: Optional[str] = None is_resolved: Optional[bool] = None class CommentResponse(BaseModel): id: str project_id: str storyboard_id: Optional[str] video_id: Optional[str] content: str author_id: str parent_comment_id: Optional[str] mentioned_user_ids: Optional[List[str]] time_position: Optional[float] is_resolved: bool resolved_by: Optional[str] resolved_at: Optional[str] created_at: str updated_at: str class Config: from_attributes = True ``` --- ## 相关文档 - [通知服务](./notification-service.md) - [项目管理服务](./project-service.md) - [用户管理服务](./user-service.md) - [数据库设计](../04-database-design.md) --- **文档版本**:v2.0 **最后更新**:2026-02-03 **合规状态**:✅ 符合 jointo-tech-stack 规范 --- ## 变更记录 **v2.0 (2026-02-03)** - ✅ **符合 jointo-tech-stack 规范**: - 移除数据库物理外键约束,改为应用层验证 - Model 改用 SQLModel,UUID v7 主键(应用层生成) - 时间戳使用 TIMESTAMPTZ - Service 层改为异步模式(AsyncSession + async/await) - 添加日志记录(get_logger) - 字段添加完整注释和描述 **v1.0 (2025-01-27)** - 初始版本