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
21 KiB
评论协作服务
文档版本:v2.0
最后更新:2026-02-03
合规状态:✅ 符合 jointo-tech-stack 规范
目录
服务概述
评论协作服务负责处理项目中的评论功能,支持多态评论(项目、分镜、视频等)、回复、@ 提及、评论解决等协作功能。
职责
- 评论 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)
- 初始版本