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.
 

21 KiB

评论协作服务

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


目录

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

服务概述

评论协作服务负责处理项目中的评论功能,支持多态评论(项目、分镜、视频等)、回复、@ 提及、评论解决等协作功能。

职责

  • 评论 CRUD 操作
  • 多态评论(支持评论项目、分镜、视频、音效、字幕、配音、分镜看板项)
  • 回复功能(父子评论)
  • @ 提及功能
  • 评论解决状态管理
  • 分镜看板位置标记
  • 评论通知

核心功能

1. 多态评论

支持对以下对象进行评论:

  • 项目级评论:整体反馈
  • 分镜评论:针对特定分镜
  • 视频评论:针对视频片段
  • 音效评论:针对音效
  • 字幕评论:针对字幕
  • 配音评论:针对配音
  • 分镜看板项评论:针对分镜看板上的项

2. 回复功能

  • 支持多级回复
  • 查询评论的所有回复
  • 回复通知

3. @ 提及功能

  • 在评论中 @ 提及用户
  • 提及用户收到通知
  • 查询提及我的评论

4. 评论解决

  • 标记评论为已解决
  • 记录解决人和解决时间
  • 筛选未解决的评论

5. 分镜看板位置

  • 评论可以标记分镜看板位置
  • 按时间位置查询评论

服务实现

CommentService 类

# 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)

响应

{
  "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

请求体

{
  "storyboard_id": "storyboard-789",
  "content": "这个分镜的构图很棒!@user-001",
  "time_position": 5.5
}

3. 回复评论

POST /api/v1/projects/{project_id}/comments

请求体

{
  "parent_comment_id": "comment-123",
  "content": "谢谢!@user-002"
}

4. 更新评论

PUT /api/v1/comments/{comment_id}

请求体

{
  "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 模型

# 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

# 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

相关文档


文档版本: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)

  • 初始版本