Phase 7 Task 4: 协作与共享模块
- 创建 collaboration_manager.py 协作管理模块 - CollaborationManager: 协作管理主类 - 项目分享链接管理 - 支持只读/评论/编辑/管理员权限 - 评论和批注系统 - 支持实体、关系、转录文本评论 - 变更历史追踪 - 记录所有数据操作变更 - 团队成员管理 - 支持多角色权限控制 - 更新 schema.sql 添加协作相关数据库表 - project_shares: 项目分享表 - comments: 评论表 - change_history: 变更历史表 - team_members: 团队成员表 - 更新 main.py 添加协作相关 API 端点 - 项目分享相关端点 - 评论和批注相关端点 - 变更历史相关端点 - 团队成员管理端点 - 更新 README.md 和 STATUS.md
This commit is contained in:
914
backend/collaboration_manager.py
Normal file
914
backend/collaboration_manager.py
Normal file
@@ -0,0 +1,914 @@
|
||||
"""
|
||||
InsightFlow - 协作与共享模块 (Phase 7 Task 4)
|
||||
支持项目分享、评论批注、变更历史、团队空间
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import uuid
|
||||
import hashlib
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Optional, Dict, Any
|
||||
from dataclasses import dataclass, asdict
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class SharePermission(Enum):
|
||||
"""分享权限级别"""
|
||||
READ_ONLY = "read_only" # 只读
|
||||
COMMENT = "comment" # 可评论
|
||||
EDIT = "edit" # 可编辑
|
||||
ADMIN = "admin" # 管理员
|
||||
|
||||
|
||||
class CommentTargetType(Enum):
|
||||
"""评论目标类型"""
|
||||
ENTITY = "entity" # 实体评论
|
||||
RELATION = "relation" # 关系评论
|
||||
TRANSCRIPT = "transcript" # 转录文本评论
|
||||
PROJECT = "project" # 项目级评论
|
||||
|
||||
|
||||
class ChangeType(Enum):
|
||||
"""变更类型"""
|
||||
CREATE = "create" # 创建
|
||||
UPDATE = "update" # 更新
|
||||
DELETE = "delete" # 删除
|
||||
MERGE = "merge" # 合并
|
||||
SPLIT = "split" # 拆分
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProjectShare:
|
||||
"""项目分享链接"""
|
||||
id: str
|
||||
project_id: str
|
||||
token: str # 分享令牌
|
||||
permission: str # 权限级别
|
||||
created_by: str # 创建者
|
||||
created_at: str
|
||||
expires_at: Optional[str] # 过期时间
|
||||
max_uses: Optional[int] # 最大使用次数
|
||||
use_count: int # 已使用次数
|
||||
password_hash: Optional[str] # 密码保护
|
||||
is_active: bool # 是否激活
|
||||
allow_download: bool # 允许下载
|
||||
allow_export: bool # 允许导出
|
||||
|
||||
|
||||
@dataclass
|
||||
class Comment:
|
||||
"""评论/批注"""
|
||||
id: str
|
||||
project_id: str
|
||||
target_type: str # 评论目标类型
|
||||
target_id: str # 目标ID
|
||||
parent_id: Optional[str] # 父评论ID(支持回复)
|
||||
author: str # 作者
|
||||
author_name: str # 作者显示名
|
||||
content: str # 评论内容
|
||||
created_at: str
|
||||
updated_at: str
|
||||
resolved: bool # 是否已解决
|
||||
resolved_by: Optional[str] # 解决者
|
||||
resolved_at: Optional[str] # 解决时间
|
||||
mentions: List[str] # 提及的用户
|
||||
attachments: List[Dict] # 附件
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChangeRecord:
|
||||
"""变更记录"""
|
||||
id: str
|
||||
project_id: str
|
||||
change_type: str # 变更类型
|
||||
entity_type: str # 实体类型 (entity/relation/transcript/project)
|
||||
entity_id: str # 实体ID
|
||||
entity_name: str # 实体名称(用于显示)
|
||||
changed_by: str # 变更者
|
||||
changed_by_name: str # 变更者显示名
|
||||
changed_at: str
|
||||
old_value: Optional[Dict] # 旧值
|
||||
new_value: Optional[Dict] # 新值
|
||||
description: str # 变更描述
|
||||
session_id: Optional[str] # 会话ID(批量变更关联)
|
||||
reverted: bool # 是否已回滚
|
||||
reverted_at: Optional[str] # 回滚时间
|
||||
reverted_by: Optional[str] # 回滚者
|
||||
|
||||
|
||||
@dataclass
|
||||
class TeamMember:
|
||||
"""团队成员"""
|
||||
id: str
|
||||
project_id: str
|
||||
user_id: str # 用户ID
|
||||
user_name: str # 用户名
|
||||
user_email: str # 用户邮箱
|
||||
role: str # 角色 (owner/admin/editor/viewer)
|
||||
joined_at: str
|
||||
invited_by: str # 邀请者
|
||||
last_active_at: Optional[str] # 最后活跃时间
|
||||
permissions: List[str] # 具体权限列表
|
||||
|
||||
|
||||
@dataclass
|
||||
class TeamSpace:
|
||||
"""团队空间"""
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
created_by: str
|
||||
created_at: str
|
||||
updated_at: str
|
||||
member_count: int
|
||||
project_count: int
|
||||
settings: Dict[str, Any] # 团队设置
|
||||
|
||||
|
||||
class CollaborationManager:
|
||||
"""协作管理主类"""
|
||||
|
||||
def __init__(self, db_manager=None):
|
||||
self.db = db_manager
|
||||
self._shares_cache: Dict[str, ProjectShare] = {}
|
||||
self._comments_cache: Dict[str, List[Comment]] = {}
|
||||
|
||||
# ============ 项目分享 ============
|
||||
|
||||
def create_share_link(
|
||||
self,
|
||||
project_id: str,
|
||||
created_by: str,
|
||||
permission: str = "read_only",
|
||||
expires_in_days: Optional[int] = None,
|
||||
max_uses: Optional[int] = None,
|
||||
password: Optional[str] = None,
|
||||
allow_download: bool = False,
|
||||
allow_export: bool = False
|
||||
) -> ProjectShare:
|
||||
"""创建项目分享链接"""
|
||||
share_id = str(uuid.uuid4())
|
||||
token = self._generate_share_token(project_id)
|
||||
|
||||
now = datetime.now().isoformat()
|
||||
expires_at = None
|
||||
if expires_in_days:
|
||||
expires_at = (datetime.now() + timedelta(days=expires_in_days)).isoformat()
|
||||
|
||||
password_hash = None
|
||||
if password:
|
||||
password_hash = hashlib.sha256(password.encode()).hexdigest()
|
||||
|
||||
share = ProjectShare(
|
||||
id=share_id,
|
||||
project_id=project_id,
|
||||
token=token,
|
||||
permission=permission,
|
||||
created_by=created_by,
|
||||
created_at=now,
|
||||
expires_at=expires_at,
|
||||
max_uses=max_uses,
|
||||
use_count=0,
|
||||
password_hash=password_hash,
|
||||
is_active=True,
|
||||
allow_download=allow_download,
|
||||
allow_export=allow_export
|
||||
)
|
||||
|
||||
# 保存到数据库
|
||||
if self.db:
|
||||
self._save_share_to_db(share)
|
||||
|
||||
self._shares_cache[token] = share
|
||||
return share
|
||||
|
||||
def _generate_share_token(self, project_id: str) -> str:
|
||||
"""生成分享令牌"""
|
||||
data = f"{project_id}:{datetime.now().timestamp()}:{uuid.uuid4()}"
|
||||
return hashlib.sha256(data.encode()).hexdigest()[:32]
|
||||
|
||||
def _save_share_to_db(self, share: ProjectShare):
|
||||
"""保存分享记录到数据库"""
|
||||
cursor = self.db.conn.cursor()
|
||||
cursor.execute("""
|
||||
INSERT INTO project_shares
|
||||
(id, project_id, token, permission, created_by, created_at,
|
||||
expires_at, max_uses, use_count, password_hash, is_active,
|
||||
allow_download, allow_export)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
share.id, share.project_id, share.token, share.permission,
|
||||
share.created_by, share.created_at, share.expires_at,
|
||||
share.max_uses, share.use_count, share.password_hash,
|
||||
share.is_active, share.allow_download, share.allow_export
|
||||
))
|
||||
self.db.conn.commit()
|
||||
|
||||
def validate_share_token(
|
||||
self,
|
||||
token: str,
|
||||
password: Optional[str] = None
|
||||
) -> Optional[ProjectShare]:
|
||||
"""验证分享令牌"""
|
||||
# 从缓存或数据库获取
|
||||
share = self._shares_cache.get(token)
|
||||
if not share and self.db:
|
||||
share = self._get_share_from_db(token)
|
||||
|
||||
if not share:
|
||||
return None
|
||||
|
||||
# 检查是否激活
|
||||
if not share.is_active:
|
||||
return None
|
||||
|
||||
# 检查是否过期
|
||||
if share.expires_at and datetime.now().isoformat() > share.expires_at:
|
||||
return None
|
||||
|
||||
# 检查使用次数
|
||||
if share.max_uses and share.use_count >= share.max_uses:
|
||||
return None
|
||||
|
||||
# 验证密码
|
||||
if share.password_hash:
|
||||
if not password:
|
||||
return None
|
||||
password_hash = hashlib.sha256(password.encode()).hexdigest()
|
||||
if password_hash != share.password_hash:
|
||||
return None
|
||||
|
||||
return share
|
||||
|
||||
def _get_share_from_db(self, token: str) -> Optional[ProjectShare]:
|
||||
"""从数据库获取分享记录"""
|
||||
cursor = self.db.conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT * FROM project_shares WHERE token = ?
|
||||
""", (token,))
|
||||
row = cursor.fetchone()
|
||||
|
||||
if not row:
|
||||
return None
|
||||
|
||||
return ProjectShare(
|
||||
id=row[0],
|
||||
project_id=row[1],
|
||||
token=row[2],
|
||||
permission=row[3],
|
||||
created_by=row[4],
|
||||
created_at=row[5],
|
||||
expires_at=row[6],
|
||||
max_uses=row[7],
|
||||
use_count=row[8],
|
||||
password_hash=row[9],
|
||||
is_active=bool(row[10]),
|
||||
allow_download=bool(row[11]),
|
||||
allow_export=bool(row[12])
|
||||
)
|
||||
|
||||
def increment_share_usage(self, token: str):
|
||||
"""增加分享链接使用次数"""
|
||||
share = self._shares_cache.get(token)
|
||||
if share:
|
||||
share.use_count += 1
|
||||
|
||||
if self.db:
|
||||
cursor = self.db.conn.cursor()
|
||||
cursor.execute("""
|
||||
UPDATE project_shares
|
||||
SET use_count = use_count + 1
|
||||
WHERE token = ?
|
||||
""", (token,))
|
||||
self.db.conn.commit()
|
||||
|
||||
def revoke_share_link(self, share_id: str, revoked_by: str) -> bool:
|
||||
"""撤销分享链接"""
|
||||
if self.db:
|
||||
cursor = self.db.conn.cursor()
|
||||
cursor.execute("""
|
||||
UPDATE project_shares
|
||||
SET is_active = 0
|
||||
WHERE id = ?
|
||||
""", (share_id,))
|
||||
self.db.conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
return False
|
||||
|
||||
def list_project_shares(self, project_id: str) -> List[ProjectShare]:
|
||||
"""列出项目的所有分享链接"""
|
||||
if not self.db:
|
||||
return []
|
||||
|
||||
cursor = self.db.conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT * FROM project_shares
|
||||
WHERE project_id = ?
|
||||
ORDER BY created_at DESC
|
||||
""", (project_id,))
|
||||
|
||||
shares = []
|
||||
for row in cursor.fetchall():
|
||||
shares.append(ProjectShare(
|
||||
id=row[0],
|
||||
project_id=row[1],
|
||||
token=row[2],
|
||||
permission=row[3],
|
||||
created_by=row[4],
|
||||
created_at=row[5],
|
||||
expires_at=row[6],
|
||||
max_uses=row[7],
|
||||
use_count=row[8],
|
||||
password_hash=row[9],
|
||||
is_active=bool(row[10]),
|
||||
allow_download=bool(row[11]),
|
||||
allow_export=bool(row[12])
|
||||
))
|
||||
return shares
|
||||
|
||||
# ============ 评论和批注 ============
|
||||
|
||||
def add_comment(
|
||||
self,
|
||||
project_id: str,
|
||||
target_type: str,
|
||||
target_id: str,
|
||||
author: str,
|
||||
author_name: str,
|
||||
content: str,
|
||||
parent_id: Optional[str] = None,
|
||||
mentions: Optional[List[str]] = None,
|
||||
attachments: Optional[List[Dict]] = None
|
||||
) -> Comment:
|
||||
"""添加评论"""
|
||||
comment_id = str(uuid.uuid4())
|
||||
now = datetime.now().isoformat()
|
||||
|
||||
comment = Comment(
|
||||
id=comment_id,
|
||||
project_id=project_id,
|
||||
target_type=target_type,
|
||||
target_id=target_id,
|
||||
parent_id=parent_id,
|
||||
author=author,
|
||||
author_name=author_name,
|
||||
content=content,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
resolved=False,
|
||||
resolved_by=None,
|
||||
resolved_at=None,
|
||||
mentions=mentions or [],
|
||||
attachments=attachments or []
|
||||
)
|
||||
|
||||
if self.db:
|
||||
self._save_comment_to_db(comment)
|
||||
|
||||
# 更新缓存
|
||||
key = f"{target_type}:{target_id}"
|
||||
if key not in self._comments_cache:
|
||||
self._comments_cache[key] = []
|
||||
self._comments_cache[key].append(comment)
|
||||
|
||||
return comment
|
||||
|
||||
def _save_comment_to_db(self, comment: Comment):
|
||||
"""保存评论到数据库"""
|
||||
cursor = self.db.conn.cursor()
|
||||
cursor.execute("""
|
||||
INSERT INTO comments
|
||||
(id, project_id, target_type, target_id, parent_id, author, author_name,
|
||||
content, created_at, updated_at, resolved, resolved_by, resolved_at,
|
||||
mentions, attachments)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
comment.id, comment.project_id, comment.target_type, comment.target_id,
|
||||
comment.parent_id, comment.author, comment.author_name, comment.content,
|
||||
comment.created_at, comment.updated_at, comment.resolved,
|
||||
comment.resolved_by, comment.resolved_at,
|
||||
json.dumps(comment.mentions), json.dumps(comment.attachments)
|
||||
))
|
||||
self.db.conn.commit()
|
||||
|
||||
def get_comments(
|
||||
self,
|
||||
target_type: str,
|
||||
target_id: str,
|
||||
include_resolved: bool = True
|
||||
) -> List[Comment]:
|
||||
"""获取评论列表"""
|
||||
if not self.db:
|
||||
return []
|
||||
|
||||
cursor = self.db.conn.cursor()
|
||||
if include_resolved:
|
||||
cursor.execute("""
|
||||
SELECT * FROM comments
|
||||
WHERE target_type = ? AND target_id = ?
|
||||
ORDER BY created_at ASC
|
||||
""", (target_type, target_id))
|
||||
else:
|
||||
cursor.execute("""
|
||||
SELECT * FROM comments
|
||||
WHERE target_type = ? AND target_id = ? AND resolved = 0
|
||||
ORDER BY created_at ASC
|
||||
""", (target_type, target_id))
|
||||
|
||||
comments = []
|
||||
for row in cursor.fetchall():
|
||||
comments.append(self._row_to_comment(row))
|
||||
return comments
|
||||
|
||||
def _row_to_comment(self, row) -> Comment:
|
||||
"""将数据库行转换为Comment对象"""
|
||||
return Comment(
|
||||
id=row[0],
|
||||
project_id=row[1],
|
||||
target_type=row[2],
|
||||
target_id=row[3],
|
||||
parent_id=row[4],
|
||||
author=row[5],
|
||||
author_name=row[6],
|
||||
content=row[7],
|
||||
created_at=row[8],
|
||||
updated_at=row[9],
|
||||
resolved=bool(row[10]),
|
||||
resolved_by=row[11],
|
||||
resolved_at=row[12],
|
||||
mentions=json.loads(row[13]) if row[13] else [],
|
||||
attachments=json.loads(row[14]) if row[14] else []
|
||||
)
|
||||
|
||||
def update_comment(
|
||||
self,
|
||||
comment_id: str,
|
||||
content: str,
|
||||
updated_by: str
|
||||
) -> Optional[Comment]:
|
||||
"""更新评论"""
|
||||
if not self.db:
|
||||
return None
|
||||
|
||||
now = datetime.now().isoformat()
|
||||
cursor = self.db.conn.cursor()
|
||||
cursor.execute("""
|
||||
UPDATE comments
|
||||
SET content = ?, updated_at = ?
|
||||
WHERE id = ? AND author = ?
|
||||
""", (content, now, comment_id, updated_by))
|
||||
self.db.conn.commit()
|
||||
|
||||
if cursor.rowcount > 0:
|
||||
return self._get_comment_by_id(comment_id)
|
||||
return None
|
||||
|
||||
def _get_comment_by_id(self, comment_id: str) -> Optional[Comment]:
|
||||
"""根据ID获取评论"""
|
||||
cursor = self.db.conn.cursor()
|
||||
cursor.execute("SELECT * FROM comments WHERE id = ?", (comment_id,))
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
return self._row_to_comment(row)
|
||||
return None
|
||||
|
||||
def resolve_comment(
|
||||
self,
|
||||
comment_id: str,
|
||||
resolved_by: str
|
||||
) -> bool:
|
||||
"""标记评论为已解决"""
|
||||
if not self.db:
|
||||
return False
|
||||
|
||||
now = datetime.now().isoformat()
|
||||
cursor = self.db.conn.cursor()
|
||||
cursor.execute("""
|
||||
UPDATE comments
|
||||
SET resolved = 1, resolved_by = ?, resolved_at = ?
|
||||
WHERE id = ?
|
||||
""", (resolved_by, now, comment_id))
|
||||
self.db.conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def delete_comment(self, comment_id: str, deleted_by: str) -> bool:
|
||||
"""删除评论"""
|
||||
if not self.db:
|
||||
return False
|
||||
|
||||
cursor = self.db.conn.cursor()
|
||||
# 只允许作者或管理员删除
|
||||
cursor.execute("""
|
||||
DELETE FROM comments
|
||||
WHERE id = ? AND (author = ? OR ? IN (
|
||||
SELECT created_by FROM projects WHERE id = comments.project_id
|
||||
))
|
||||
""", (comment_id, deleted_by, deleted_by))
|
||||
self.db.conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def get_project_comments(
|
||||
self,
|
||||
project_id: str,
|
||||
limit: int = 50,
|
||||
offset: int = 0
|
||||
) -> List[Comment]:
|
||||
"""获取项目下的所有评论"""
|
||||
if not self.db:
|
||||
return []
|
||||
|
||||
cursor = self.db.conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT * FROM comments
|
||||
WHERE project_id = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
""", (project_id, limit, offset))
|
||||
|
||||
comments = []
|
||||
for row in cursor.fetchall():
|
||||
comments.append(self._row_to_comment(row))
|
||||
return comments
|
||||
|
||||
# ============ 变更历史 ============
|
||||
|
||||
def record_change(
|
||||
self,
|
||||
project_id: str,
|
||||
change_type: str,
|
||||
entity_type: str,
|
||||
entity_id: str,
|
||||
entity_name: str,
|
||||
changed_by: str,
|
||||
changed_by_name: str,
|
||||
old_value: Optional[Dict] = None,
|
||||
new_value: Optional[Dict] = None,
|
||||
description: str = "",
|
||||
session_id: Optional[str] = None
|
||||
) -> ChangeRecord:
|
||||
"""记录变更"""
|
||||
record_id = str(uuid.uuid4())
|
||||
now = datetime.now().isoformat()
|
||||
|
||||
record = ChangeRecord(
|
||||
id=record_id,
|
||||
project_id=project_id,
|
||||
change_type=change_type,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
entity_name=entity_name,
|
||||
changed_by=changed_by,
|
||||
changed_by_name=changed_by_name,
|
||||
changed_at=now,
|
||||
old_value=old_value,
|
||||
new_value=new_value,
|
||||
description=description,
|
||||
session_id=session_id,
|
||||
reverted=False,
|
||||
reverted_at=None,
|
||||
reverted_by=None
|
||||
)
|
||||
|
||||
if self.db:
|
||||
self._save_change_to_db(record)
|
||||
|
||||
return record
|
||||
|
||||
def _save_change_to_db(self, record: ChangeRecord):
|
||||
"""保存变更记录到数据库"""
|
||||
cursor = self.db.conn.cursor()
|
||||
cursor.execute("""
|
||||
INSERT INTO change_history
|
||||
(id, project_id, change_type, entity_type, entity_id, entity_name,
|
||||
changed_by, changed_by_name, changed_at, old_value, new_value,
|
||||
description, session_id, reverted, reverted_at, reverted_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
record.id, record.project_id, record.change_type, record.entity_type,
|
||||
record.entity_id, record.entity_name, record.changed_by, record.changed_by_name,
|
||||
record.changed_at, json.dumps(record.old_value) if record.old_value else None,
|
||||
json.dumps(record.new_value) if record.new_value else None,
|
||||
record.description, record.session_id, record.reverted,
|
||||
record.reverted_at, record.reverted_by
|
||||
))
|
||||
self.db.conn.commit()
|
||||
|
||||
def get_change_history(
|
||||
self,
|
||||
project_id: str,
|
||||
entity_type: Optional[str] = None,
|
||||
entity_id: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0
|
||||
) -> List[ChangeRecord]:
|
||||
"""获取变更历史"""
|
||||
if not self.db:
|
||||
return []
|
||||
|
||||
cursor = self.db.conn.cursor()
|
||||
|
||||
if entity_type and entity_id:
|
||||
cursor.execute("""
|
||||
SELECT * FROM change_history
|
||||
WHERE project_id = ? AND entity_type = ? AND entity_id = ?
|
||||
ORDER BY changed_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
""", (project_id, entity_type, entity_id, limit, offset))
|
||||
elif entity_type:
|
||||
cursor.execute("""
|
||||
SELECT * FROM change_history
|
||||
WHERE project_id = ? AND entity_type = ?
|
||||
ORDER BY changed_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
""", (project_id, entity_type, limit, offset))
|
||||
else:
|
||||
cursor.execute("""
|
||||
SELECT * FROM change_history
|
||||
WHERE project_id = ?
|
||||
ORDER BY changed_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
""", (project_id, limit, offset))
|
||||
|
||||
records = []
|
||||
for row in cursor.fetchall():
|
||||
records.append(self._row_to_change_record(row))
|
||||
return records
|
||||
|
||||
def _row_to_change_record(self, row) -> ChangeRecord:
|
||||
"""将数据库行转换为ChangeRecord对象"""
|
||||
return ChangeRecord(
|
||||
id=row[0],
|
||||
project_id=row[1],
|
||||
change_type=row[2],
|
||||
entity_type=row[3],
|
||||
entity_id=row[4],
|
||||
entity_name=row[5],
|
||||
changed_by=row[6],
|
||||
changed_by_name=row[7],
|
||||
changed_at=row[8],
|
||||
old_value=json.loads(row[9]) if row[9] else None,
|
||||
new_value=json.loads(row[10]) if row[10] else None,
|
||||
description=row[11],
|
||||
session_id=row[12],
|
||||
reverted=bool(row[13]),
|
||||
reverted_at=row[14],
|
||||
reverted_by=row[15]
|
||||
)
|
||||
|
||||
def get_entity_version_history(
|
||||
self,
|
||||
entity_type: str,
|
||||
entity_id: str
|
||||
) -> List[ChangeRecord]:
|
||||
"""获取实体的版本历史(用于版本对比)"""
|
||||
if not self.db:
|
||||
return []
|
||||
|
||||
cursor = self.db.conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT * FROM change_history
|
||||
WHERE entity_type = ? AND entity_id = ?
|
||||
ORDER BY changed_at ASC
|
||||
""", (entity_type, entity_id))
|
||||
|
||||
records = []
|
||||
for row in cursor.fetchall():
|
||||
records.append(self._row_to_change_record(row))
|
||||
return records
|
||||
|
||||
def revert_change(self, record_id: str, reverted_by: str) -> bool:
|
||||
"""回滚变更"""
|
||||
if not self.db:
|
||||
return False
|
||||
|
||||
now = datetime.now().isoformat()
|
||||
cursor = self.db.conn.cursor()
|
||||
cursor.execute("""
|
||||
UPDATE change_history
|
||||
SET reverted = 1, reverted_at = ?, reverted_by = ?
|
||||
WHERE id = ? AND reverted = 0
|
||||
""", (now, reverted_by, record_id))
|
||||
self.db.conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def get_change_stats(self, project_id: str) -> Dict[str, Any]:
|
||||
"""获取变更统计"""
|
||||
if not self.db:
|
||||
return {}
|
||||
|
||||
cursor = self.db.conn.cursor()
|
||||
|
||||
# 总变更数
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*) FROM change_history WHERE project_id = ?
|
||||
""", (project_id,))
|
||||
total_changes = cursor.fetchone()[0]
|
||||
|
||||
# 按类型统计
|
||||
cursor.execute("""
|
||||
SELECT change_type, COUNT(*) FROM change_history
|
||||
WHERE project_id = ? GROUP BY change_type
|
||||
""", (project_id,))
|
||||
type_counts = {row[0]: row[1] for row in cursor.fetchall()}
|
||||
|
||||
# 按实体类型统计
|
||||
cursor.execute("""
|
||||
SELECT entity_type, COUNT(*) FROM change_history
|
||||
WHERE project_id = ? GROUP BY entity_type
|
||||
""", (project_id,))
|
||||
entity_type_counts = {row[0]: row[1] for row in cursor.fetchall()}
|
||||
|
||||
# 最近活跃的用户
|
||||
cursor.execute("""
|
||||
SELECT changed_by_name, COUNT(*) as count FROM change_history
|
||||
WHERE project_id = ?
|
||||
GROUP BY changed_by_name
|
||||
ORDER BY count DESC
|
||||
LIMIT 5
|
||||
""", (project_id,))
|
||||
top_contributors = [
|
||||
{"name": row[0], "changes": row[1]}
|
||||
for row in cursor.fetchall()
|
||||
]
|
||||
|
||||
return {
|
||||
"total_changes": total_changes,
|
||||
"by_type": type_counts,
|
||||
"by_entity_type": entity_type_counts,
|
||||
"top_contributors": top_contributors
|
||||
}
|
||||
|
||||
# ============ 团队成员管理 ============
|
||||
|
||||
def add_team_member(
|
||||
self,
|
||||
project_id: str,
|
||||
user_id: str,
|
||||
user_name: str,
|
||||
user_email: str,
|
||||
role: str,
|
||||
invited_by: str,
|
||||
permissions: Optional[List[str]] = None
|
||||
) -> TeamMember:
|
||||
"""添加团队成员"""
|
||||
member_id = str(uuid.uuid4())
|
||||
now = datetime.now().isoformat()
|
||||
|
||||
# 根据角色设置默认权限
|
||||
if permissions is None:
|
||||
permissions = self._get_default_permissions(role)
|
||||
|
||||
member = TeamMember(
|
||||
id=member_id,
|
||||
project_id=project_id,
|
||||
user_id=user_id,
|
||||
user_name=user_name,
|
||||
user_email=user_email,
|
||||
role=role,
|
||||
joined_at=now,
|
||||
invited_by=invited_by,
|
||||
last_active_at=None,
|
||||
permissions=permissions
|
||||
)
|
||||
|
||||
if self.db:
|
||||
self._save_member_to_db(member)
|
||||
|
||||
return member
|
||||
|
||||
def _get_default_permissions(self, role: str) -> List[str]:
|
||||
"""获取角色的默认权限"""
|
||||
permissions_map = {
|
||||
"owner": ["read", "write", "delete", "share", "admin", "export"],
|
||||
"admin": ["read", "write", "delete", "share", "export"],
|
||||
"editor": ["read", "write", "export"],
|
||||
"viewer": ["read"],
|
||||
"commenter": ["read", "comment"]
|
||||
}
|
||||
return permissions_map.get(role, ["read"])
|
||||
|
||||
def _save_member_to_db(self, member: TeamMember):
|
||||
"""保存成员到数据库"""
|
||||
cursor = self.db.conn.cursor()
|
||||
cursor.execute("""
|
||||
INSERT INTO team_members
|
||||
(id, project_id, user_id, user_name, user_email, role, joined_at,
|
||||
invited_by, last_active_at, permissions)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
member.id, member.project_id, member.user_id, member.user_name,
|
||||
member.user_email, member.role, member.joined_at, member.invited_by,
|
||||
member.last_active_at, json.dumps(member.permissions)
|
||||
))
|
||||
self.db.conn.commit()
|
||||
|
||||
def get_team_members(self, project_id: str) -> List[TeamMember]:
|
||||
"""获取团队成员列表"""
|
||||
if not self.db:
|
||||
return []
|
||||
|
||||
cursor = self.db.conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT * FROM team_members WHERE project_id = ?
|
||||
ORDER BY joined_at ASC
|
||||
""", (project_id,))
|
||||
|
||||
members = []
|
||||
for row in cursor.fetchall():
|
||||
members.append(self._row_to_team_member(row))
|
||||
return members
|
||||
|
||||
def _row_to_team_member(self, row) -> TeamMember:
|
||||
"""将数据库行转换为TeamMember对象"""
|
||||
return TeamMember(
|
||||
id=row[0],
|
||||
project_id=row[1],
|
||||
user_id=row[2],
|
||||
user_name=row[3],
|
||||
user_email=row[4],
|
||||
role=row[5],
|
||||
joined_at=row[6],
|
||||
invited_by=row[7],
|
||||
last_active_at=row[8],
|
||||
permissions=json.loads(row[9]) if row[9] else []
|
||||
)
|
||||
|
||||
def update_member_role(
|
||||
self,
|
||||
member_id: str,
|
||||
new_role: str,
|
||||
updated_by: str
|
||||
) -> bool:
|
||||
"""更新成员角色"""
|
||||
if not self.db:
|
||||
return False
|
||||
|
||||
permissions = self._get_default_permissions(new_role)
|
||||
cursor = self.db.conn.cursor()
|
||||
cursor.execute("""
|
||||
UPDATE team_members
|
||||
SET role = ?, permissions = ?
|
||||
WHERE id = ?
|
||||
""", (new_role, json.dumps(permissions), member_id))
|
||||
self.db.conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def remove_team_member(self, member_id: str, removed_by: str) -> bool:
|
||||
"""移除团队成员"""
|
||||
if not self.db:
|
||||
return False
|
||||
|
||||
cursor = self.db.conn.cursor()
|
||||
cursor.execute("DELETE FROM team_members WHERE id = ?", (member_id,))
|
||||
self.db.conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def check_permission(
|
||||
self,
|
||||
project_id: str,
|
||||
user_id: str,
|
||||
permission: str
|
||||
) -> bool:
|
||||
"""检查用户权限"""
|
||||
if not self.db:
|
||||
return False
|
||||
|
||||
cursor = self.db.conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT permissions FROM team_members
|
||||
WHERE project_id = ? AND user_id = ?
|
||||
""", (project_id, user_id))
|
||||
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
return False
|
||||
|
||||
permissions = json.loads(row[0]) if row[0] else []
|
||||
return permission in permissions or "admin" in permissions
|
||||
|
||||
def update_last_active(self, project_id: str, user_id: str):
|
||||
"""更新用户最后活跃时间"""
|
||||
if not self.db:
|
||||
return
|
||||
|
||||
now = datetime.now().isoformat()
|
||||
cursor = self.db.conn.cursor()
|
||||
cursor.execute("""
|
||||
UPDATE team_members
|
||||
SET last_active_at = ?
|
||||
WHERE project_id = ? AND user_id = ?
|
||||
""", (now, project_id, user_id))
|
||||
self.db.conn.commit()
|
||||
|
||||
|
||||
# 全局协作管理器实例
|
||||
_collaboration_manager = None
|
||||
|
||||
|
||||
def get_collaboration_manager(db_manager=None):
|
||||
"""获取协作管理器单例"""
|
||||
global _collaboration_manager
|
||||
if _collaboration_manager is None:
|
||||
_collaboration_manager = CollaborationManager(db_manager)
|
||||
return _collaboration_manager
|
||||
520
backend/main.py
520
backend/main.py
@@ -79,6 +79,13 @@ try:
|
||||
except ImportError:
|
||||
NEO4J_AVAILABLE = False
|
||||
|
||||
try:
|
||||
from collaboration_manager import get_collaboration_manager, CollaborationManager
|
||||
COLLABORATION_AVAILABLE = True
|
||||
except ImportError as e:
|
||||
print(f"Collaboration import error: {e}")
|
||||
COLLABORATION_AVAILABLE = False
|
||||
|
||||
app = FastAPI(title="InsightFlow", version="0.3.0")
|
||||
|
||||
app.add_middleware(
|
||||
@@ -145,6 +152,41 @@ class GlossaryTermCreate(BaseModel):
|
||||
term: str
|
||||
pronunciation: Optional[str] = ""
|
||||
|
||||
# Phase 7: 协作与共享 - 请求模型
|
||||
class ShareLinkCreate(BaseModel):
|
||||
permission: str = "read_only" # read_only, comment, edit, admin
|
||||
expires_in_days: Optional[int] = None
|
||||
max_uses: Optional[int] = None
|
||||
password: Optional[str] = None
|
||||
allow_download: bool = False
|
||||
allow_export: bool = False
|
||||
|
||||
class ShareLinkVerify(BaseModel):
|
||||
token: str
|
||||
password: Optional[str] = None
|
||||
|
||||
class CommentCreate(BaseModel):
|
||||
target_type: str # entity, relation, transcript, project
|
||||
target_id: str
|
||||
parent_id: Optional[str] = None
|
||||
content: str
|
||||
mentions: Optional[List[str]] = None
|
||||
|
||||
class CommentUpdate(BaseModel):
|
||||
content: str
|
||||
|
||||
class CommentResolve(BaseModel):
|
||||
resolved: bool
|
||||
|
||||
class TeamMemberInvite(BaseModel):
|
||||
user_id: str
|
||||
user_name: str
|
||||
user_email: str
|
||||
role: str = "viewer" # owner, admin, editor, viewer, commenter
|
||||
|
||||
class TeamMemberRoleUpdate(BaseModel):
|
||||
role: str
|
||||
|
||||
# API Keys
|
||||
KIMI_API_KEY = os.getenv("KIMI_API_KEY", "")
|
||||
KIMI_BASE_URL = os.getenv("KIMI_BASE_URL", "https://api.kimi.com/coding")
|
||||
@@ -165,6 +207,15 @@ def get_doc_processor():
|
||||
_doc_processor = DocumentProcessor()
|
||||
return _doc_processor
|
||||
|
||||
# Phase 7: Collaboration Manager singleton
|
||||
_collaboration_manager = None
|
||||
def get_collab_manager():
|
||||
global _collaboration_manager
|
||||
if _collaboration_manager is None and COLLABORATION_AVAILABLE:
|
||||
db = get_db_manager() if DB_AVAILABLE else None
|
||||
_collaboration_manager = get_collaboration_manager(db)
|
||||
return _collaboration_manager
|
||||
|
||||
# Phase 2: Entity Edit API
|
||||
@app.put("/api/v1/entities/{entity_id}")
|
||||
async def update_entity(entity_id: str, update: EntityUpdate):
|
||||
@@ -2605,6 +2656,475 @@ async def get_subgraph(request: GraphQueryRequest):
|
||||
return subgraph
|
||||
|
||||
|
||||
# ==========================================
|
||||
# Phase 7: 协作与共享 API
|
||||
# ==========================================
|
||||
|
||||
# ----- 项目分享 -----
|
||||
|
||||
@app.post("/api/v1/projects/{project_id}/shares")
|
||||
async def create_share_link(project_id: str, request: ShareLinkCreate, created_by: str = "current_user"):
|
||||
"""创建项目分享链接"""
|
||||
if not COLLABORATION_AVAILABLE:
|
||||
raise HTTPException(status_code=503, detail="Collaboration module not available")
|
||||
|
||||
manager = get_collab_manager()
|
||||
share = manager.create_share_link(
|
||||
project_id=project_id,
|
||||
created_by=created_by,
|
||||
permission=request.permission,
|
||||
expires_in_days=request.expires_in_days,
|
||||
max_uses=request.max_uses,
|
||||
password=request.password,
|
||||
allow_download=request.allow_download,
|
||||
allow_export=request.allow_export
|
||||
)
|
||||
|
||||
return {
|
||||
"id": share.id,
|
||||
"token": share.token,
|
||||
"permission": share.permission,
|
||||
"created_at": share.created_at,
|
||||
"expires_at": share.expires_at,
|
||||
"max_uses": share.max_uses,
|
||||
"share_url": f"/share/{share.token}"
|
||||
}
|
||||
|
||||
@app.get("/api/v1/projects/{project_id}/shares")
|
||||
async def list_project_shares(project_id: str):
|
||||
"""列出项目的所有分享链接"""
|
||||
if not COLLABORATION_AVAILABLE:
|
||||
raise HTTPException(status_code=503, detail="Collaboration module not available")
|
||||
|
||||
manager = get_collab_manager()
|
||||
shares = manager.list_project_shares(project_id)
|
||||
|
||||
return {
|
||||
"shares": [
|
||||
{
|
||||
"id": s.id,
|
||||
"token": s.token,
|
||||
"permission": s.permission,
|
||||
"created_at": s.created_at,
|
||||
"expires_at": s.expires_at,
|
||||
"use_count": s.use_count,
|
||||
"max_uses": s.max_uses,
|
||||
"is_active": s.is_active,
|
||||
"has_password": s.password_hash is not None,
|
||||
"allow_download": s.allow_download,
|
||||
"allow_export": s.allow_export
|
||||
}
|
||||
for s in shares
|
||||
]
|
||||
}
|
||||
|
||||
@app.post("/api/v1/shares/verify")
|
||||
async def verify_share_link(request: ShareLinkVerify):
|
||||
"""验证分享链接"""
|
||||
if not COLLABORATION_AVAILABLE:
|
||||
raise HTTPException(status_code=503, detail="Collaboration module not available")
|
||||
|
||||
manager = get_collab_manager()
|
||||
share = manager.validate_share_token(request.token, request.password)
|
||||
|
||||
if not share:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired share link")
|
||||
|
||||
# 增加使用次数
|
||||
manager.increment_share_usage(request.token)
|
||||
|
||||
return {
|
||||
"valid": True,
|
||||
"project_id": share.project_id,
|
||||
"permission": share.permission,
|
||||
"allow_download": share.allow_download,
|
||||
"allow_export": share.allow_export
|
||||
}
|
||||
|
||||
@app.get("/api/v1/shares/{token}/access")
|
||||
async def access_shared_project(token: str, password: Optional[str] = None):
|
||||
"""通过分享链接访问项目"""
|
||||
if not COLLABORATION_AVAILABLE:
|
||||
raise HTTPException(status_code=503, detail="Collaboration module not available")
|
||||
|
||||
manager = get_collab_manager()
|
||||
share = manager.validate_share_token(token, password)
|
||||
|
||||
if not share:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired share link")
|
||||
|
||||
# 增加使用次数
|
||||
manager.increment_share_usage(token)
|
||||
|
||||
# 获取项目信息
|
||||
if not DB_AVAILABLE:
|
||||
raise HTTPException(status_code=503, detail="Database not available")
|
||||
|
||||
db = get_db_manager()
|
||||
project = db.get_project(share.project_id)
|
||||
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
return {
|
||||
"project": {
|
||||
"id": project.id,
|
||||
"name": project.name,
|
||||
"description": project.description,
|
||||
"created_at": project.created_at
|
||||
},
|
||||
"permission": share.permission,
|
||||
"allow_download": share.allow_download,
|
||||
"allow_export": share.allow_export
|
||||
}
|
||||
|
||||
@app.delete("/api/v1/shares/{share_id}")
|
||||
async def revoke_share_link(share_id: str, revoked_by: str = "current_user"):
|
||||
"""撤销分享链接"""
|
||||
if not COLLABORATION_AVAILABLE:
|
||||
raise HTTPException(status_code=503, detail="Collaboration module not available")
|
||||
|
||||
manager = get_collab_manager()
|
||||
success = manager.revoke_share_link(share_id, revoked_by)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Share link not found")
|
||||
|
||||
return {"success": True, "message": "Share link revoked"}
|
||||
|
||||
# ----- 评论和批注 -----
|
||||
|
||||
@app.post("/api/v1/projects/{project_id}/comments")
|
||||
async def add_comment(project_id: str, request: CommentCreate, author: str = "current_user", author_name: str = "User"):
|
||||
"""添加评论"""
|
||||
if not COLLABORATION_AVAILABLE:
|
||||
raise HTTPException(status_code=503, detail="Collaboration module not available")
|
||||
|
||||
manager = get_collab_manager()
|
||||
comment = manager.add_comment(
|
||||
project_id=project_id,
|
||||
target_type=request.target_type,
|
||||
target_id=request.target_id,
|
||||
author=author,
|
||||
author_name=author_name,
|
||||
content=request.content,
|
||||
parent_id=request.parent_id,
|
||||
mentions=request.mentions
|
||||
)
|
||||
|
||||
return {
|
||||
"id": comment.id,
|
||||
"target_type": comment.target_type,
|
||||
"target_id": comment.target_id,
|
||||
"parent_id": comment.parent_id,
|
||||
"author": comment.author,
|
||||
"author_name": comment.author_name,
|
||||
"content": comment.content,
|
||||
"created_at": comment.created_at,
|
||||
"resolved": comment.resolved
|
||||
}
|
||||
|
||||
@app.get("/api/v1/{target_type}/{target_id}/comments")
|
||||
async def get_comments(target_type: str, target_id: str, include_resolved: bool = True):
|
||||
"""获取评论列表"""
|
||||
if not COLLABORATION_AVAILABLE:
|
||||
raise HTTPException(status_code=503, detail="Collaboration module not available")
|
||||
|
||||
manager = get_collab_manager()
|
||||
comments = manager.get_comments(target_type, target_id, include_resolved)
|
||||
|
||||
return {
|
||||
"count": len(comments),
|
||||
"comments": [
|
||||
{
|
||||
"id": c.id,
|
||||
"parent_id": c.parent_id,
|
||||
"author": c.author,
|
||||
"author_name": c.author_name,
|
||||
"content": c.content,
|
||||
"created_at": c.created_at,
|
||||
"updated_at": c.updated_at,
|
||||
"resolved": c.resolved,
|
||||
"resolved_by": c.resolved_by,
|
||||
"resolved_at": c.resolved_at
|
||||
}
|
||||
for c in comments
|
||||
]
|
||||
}
|
||||
|
||||
@app.get("/api/v1/projects/{project_id}/comments")
|
||||
async def get_project_comments(project_id: str, limit: int = 50, offset: int = 0):
|
||||
"""获取项目下的所有评论"""
|
||||
if not COLLABORATION_AVAILABLE:
|
||||
raise HTTPException(status_code=503, detail="Collaboration module not available")
|
||||
|
||||
manager = get_collab_manager()
|
||||
comments = manager.get_project_comments(project_id, limit, offset)
|
||||
|
||||
return {
|
||||
"count": len(comments),
|
||||
"comments": [
|
||||
{
|
||||
"id": c.id,
|
||||
"target_type": c.target_type,
|
||||
"target_id": c.target_id,
|
||||
"parent_id": c.parent_id,
|
||||
"author": c.author,
|
||||
"author_name": c.author_name,
|
||||
"content": c.content,
|
||||
"created_at": c.created_at,
|
||||
"resolved": c.resolved
|
||||
}
|
||||
for c in comments
|
||||
]
|
||||
}
|
||||
|
||||
@app.put("/api/v1/comments/{comment_id}")
|
||||
async def update_comment(comment_id: str, request: CommentUpdate, updated_by: str = "current_user"):
|
||||
"""更新评论"""
|
||||
if not COLLABORATION_AVAILABLE:
|
||||
raise HTTPException(status_code=503, detail="Collaboration module not available")
|
||||
|
||||
manager = get_collab_manager()
|
||||
comment = manager.update_comment(comment_id, request.content, updated_by)
|
||||
|
||||
if not comment:
|
||||
raise HTTPException(status_code=404, detail="Comment not found or not authorized")
|
||||
|
||||
return {
|
||||
"id": comment.id,
|
||||
"content": comment.content,
|
||||
"updated_at": comment.updated_at
|
||||
}
|
||||
|
||||
@app.post("/api/v1/comments/{comment_id}/resolve")
|
||||
async def resolve_comment(comment_id: str, resolved_by: str = "current_user"):
|
||||
"""标记评论为已解决"""
|
||||
if not COLLABORATION_AVAILABLE:
|
||||
raise HTTPException(status_code=503, detail="Collaboration module not available")
|
||||
|
||||
manager = get_collab_manager()
|
||||
success = manager.resolve_comment(comment_id, resolved_by)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Comment not found")
|
||||
|
||||
return {"success": True, "message": "Comment resolved"}
|
||||
|
||||
@app.delete("/api/v1/comments/{comment_id}")
|
||||
async def delete_comment(comment_id: str, deleted_by: str = "current_user"):
|
||||
"""删除评论"""
|
||||
if not COLLABORATION_AVAILABLE:
|
||||
raise HTTPException(status_code=503, detail="Collaboration module not available")
|
||||
|
||||
manager = get_collab_manager()
|
||||
success = manager.delete_comment(comment_id, deleted_by)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Comment not found or not authorized")
|
||||
|
||||
return {"success": True, "message": "Comment deleted"}
|
||||
|
||||
# ----- 变更历史 -----
|
||||
|
||||
@app.get("/api/v1/projects/{project_id}/history")
|
||||
async def get_change_history(
|
||||
project_id: str,
|
||||
entity_type: Optional[str] = None,
|
||||
entity_id: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0
|
||||
):
|
||||
"""获取变更历史"""
|
||||
if not COLLABORATION_AVAILABLE:
|
||||
raise HTTPException(status_code=503, detail="Collaboration module not available")
|
||||
|
||||
manager = get_collab_manager()
|
||||
records = manager.get_change_history(project_id, entity_type, entity_id, limit, offset)
|
||||
|
||||
return {
|
||||
"count": len(records),
|
||||
"history": [
|
||||
{
|
||||
"id": r.id,
|
||||
"change_type": r.change_type,
|
||||
"entity_type": r.entity_type,
|
||||
"entity_id": r.entity_id,
|
||||
"entity_name": r.entity_name,
|
||||
"changed_by": r.changed_by,
|
||||
"changed_by_name": r.changed_by_name,
|
||||
"changed_at": r.changed_at,
|
||||
"old_value": r.old_value,
|
||||
"new_value": r.new_value,
|
||||
"description": r.description,
|
||||
"reverted": r.reverted
|
||||
}
|
||||
for r in records
|
||||
]
|
||||
}
|
||||
|
||||
@app.get("/api/v1/projects/{project_id}/history/stats")
|
||||
async def get_change_history_stats(project_id: str):
|
||||
"""获取变更统计"""
|
||||
if not COLLABORATION_AVAILABLE:
|
||||
raise HTTPException(status_code=503, detail="Collaboration module not available")
|
||||
|
||||
manager = get_collab_manager()
|
||||
stats = manager.get_change_stats(project_id)
|
||||
|
||||
return stats
|
||||
|
||||
@app.get("/api/v1/{entity_type}/{entity_id}/versions")
|
||||
async def get_entity_versions(entity_type: str, entity_id: str):
|
||||
"""获取实体版本历史"""
|
||||
if not COLLABORATION_AVAILABLE:
|
||||
raise HTTPException(status_code=503, detail="Collaboration module not available")
|
||||
|
||||
manager = get_collab_manager()
|
||||
records = manager.get_entity_version_history(entity_type, entity_id)
|
||||
|
||||
return {
|
||||
"count": len(records),
|
||||
"versions": [
|
||||
{
|
||||
"id": r.id,
|
||||
"change_type": r.change_type,
|
||||
"changed_by": r.changed_by,
|
||||
"changed_by_name": r.changed_by_name,
|
||||
"changed_at": r.changed_at,
|
||||
"old_value": r.old_value,
|
||||
"new_value": r.new_value,
|
||||
"description": r.description
|
||||
}
|
||||
for r in records
|
||||
]
|
||||
}
|
||||
|
||||
@app.post("/api/v1/history/{record_id}/revert")
|
||||
async def revert_change(record_id: str, reverted_by: str = "current_user"):
|
||||
"""回滚变更"""
|
||||
if not COLLABORATION_AVAILABLE:
|
||||
raise HTTPException(status_code=503, detail="Collaboration module not available")
|
||||
|
||||
manager = get_collab_manager()
|
||||
success = manager.revert_change(record_id, reverted_by)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Change record not found or already reverted")
|
||||
|
||||
return {"success": True, "message": "Change reverted"}
|
||||
|
||||
# ----- 团队成员 -----
|
||||
|
||||
@app.post("/api/v1/projects/{project_id}/members")
|
||||
async def invite_team_member(project_id: str, request: TeamMemberInvite, invited_by: str = "current_user"):
|
||||
"""邀请团队成员"""
|
||||
if not COLLABORATION_AVAILABLE:
|
||||
raise HTTPException(status_code=503, detail="Collaboration module not available")
|
||||
|
||||
manager = get_collab_manager()
|
||||
member = manager.add_team_member(
|
||||
project_id=project_id,
|
||||
user_id=request.user_id,
|
||||
user_name=request.user_name,
|
||||
user_email=request.user_email,
|
||||
role=request.role,
|
||||
invited_by=invited_by
|
||||
)
|
||||
|
||||
return {
|
||||
"id": member.id,
|
||||
"user_id": member.user_id,
|
||||
"user_name": member.user_name,
|
||||
"user_email": member.user_email,
|
||||
"role": member.role,
|
||||
"joined_at": member.joined_at,
|
||||
"permissions": member.permissions
|
||||
}
|
||||
|
||||
@app.get("/api/v1/projects/{project_id}/members")
|
||||
async def list_team_members(project_id: str):
|
||||
"""列出团队成员"""
|
||||
if not COLLABORATION_AVAILABLE:
|
||||
raise HTTPException(status_code=503, detail="Collaboration module not available")
|
||||
|
||||
manager = get_collab_manager()
|
||||
members = manager.get_team_members(project_id)
|
||||
|
||||
return {
|
||||
"count": len(members),
|
||||
"members": [
|
||||
{
|
||||
"id": m.id,
|
||||
"user_id": m.user_id,
|
||||
"user_name": m.user_name,
|
||||
"user_email": m.user_email,
|
||||
"role": m.role,
|
||||
"joined_at": m.joined_at,
|
||||
"last_active_at": m.last_active_at,
|
||||
"permissions": m.permissions
|
||||
}
|
||||
for m in members
|
||||
]
|
||||
}
|
||||
|
||||
@app.put("/api/v1/members/{member_id}/role")
|
||||
async def update_member_role(member_id: str, request: TeamMemberRoleUpdate, updated_by: str = "current_user"):
|
||||
"""更新成员角色"""
|
||||
if not COLLABORATION_AVAILABLE:
|
||||
raise HTTPException(status_code=503, detail="Collaboration module not available")
|
||||
|
||||
manager = get_collab_manager()
|
||||
success = manager.update_member_role(member_id, request.role, updated_by)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Member not found")
|
||||
|
||||
return {"success": True, "message": "Member role updated"}
|
||||
|
||||
@app.delete("/api/v1/members/{member_id}")
|
||||
async def remove_team_member(member_id: str, removed_by: str = "current_user"):
|
||||
"""移除团队成员"""
|
||||
if not COLLABORATION_AVAILABLE:
|
||||
raise HTTPException(status_code=503, detail="Collaboration module not available")
|
||||
|
||||
manager = get_collab_manager()
|
||||
success = manager.remove_team_member(member_id, removed_by)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Member not found")
|
||||
|
||||
return {"success": True, "message": "Member removed"}
|
||||
|
||||
@app.get("/api/v1/projects/{project_id}/permissions")
|
||||
async def check_project_permissions(project_id: str, user_id: str = "current_user"):
|
||||
"""检查用户权限"""
|
||||
if not COLLABORATION_AVAILABLE:
|
||||
raise HTTPException(status_code=503, detail="Collaboration module not available")
|
||||
|
||||
manager = get_collab_manager()
|
||||
members = manager.get_team_members(project_id)
|
||||
|
||||
user_member = None
|
||||
for m in members:
|
||||
if m.user_id == user_id:
|
||||
user_member = m
|
||||
break
|
||||
|
||||
if not user_member:
|
||||
return {
|
||||
"has_access": False,
|
||||
"role": None,
|
||||
"permissions": []
|
||||
}
|
||||
|
||||
return {
|
||||
"has_access": True,
|
||||
"role": user_member.role,
|
||||
"permissions": user_member.permissions
|
||||
}
|
||||
|
||||
|
||||
# Serve frontend - MUST be last to not override API routes
|
||||
app.mount("/", StaticFiles(directory="frontend", html=True), name="frontend")
|
||||
|
||||
|
||||
@@ -178,8 +178,90 @@ CREATE INDEX IF NOT EXISTS idx_entity_attributes_entity ON entity_attributes(ent
|
||||
CREATE INDEX IF NOT EXISTS idx_entity_attributes_template ON entity_attributes(template_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_attr_history_entity ON attribute_history(entity_id);
|
||||
|
||||
-- Phase 5: 属性相关索引
|
||||
CREATE INDEX IF NOT EXISTS idx_attr_templates_project ON attribute_templates(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_entity_attributes_entity ON entity_attributes(entity_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_entity_attributes_template ON entity_attributes(template_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_attr_history_entity ON attribute_history(entity_id);
|
||||
-- Phase 7: 协作与共享 - 项目分享表
|
||||
CREATE TABLE IF NOT EXISTS project_shares (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
token TEXT NOT NULL UNIQUE, -- 分享令牌
|
||||
permission TEXT DEFAULT 'read_only', -- 权限级别: read_only, comment, edit, admin
|
||||
created_by TEXT NOT NULL, -- 创建者
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP, -- 过期时间
|
||||
max_uses INTEGER, -- 最大使用次数
|
||||
use_count INTEGER DEFAULT 0, -- 已使用次数
|
||||
password_hash TEXT, -- 密码保护(哈希)
|
||||
is_active BOOLEAN DEFAULT 1, -- 是否激活
|
||||
allow_download BOOLEAN DEFAULT 0, -- 允许下载
|
||||
allow_export BOOLEAN DEFAULT 0, -- 允许导出
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Phase 7: 协作与共享 - 评论表
|
||||
CREATE TABLE IF NOT EXISTS comments (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
target_type TEXT NOT NULL, -- 目标类型: entity, relation, transcript, project
|
||||
target_id TEXT NOT NULL, -- 目标ID
|
||||
parent_id TEXT, -- 父评论ID(支持回复)
|
||||
author TEXT NOT NULL, -- 作者ID
|
||||
author_name TEXT, -- 作者显示名
|
||||
content TEXT NOT NULL, -- 评论内容
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
resolved BOOLEAN DEFAULT 0, -- 是否已解决
|
||||
resolved_by TEXT, -- 解决者
|
||||
resolved_at TIMESTAMP, -- 解决时间
|
||||
mentions TEXT, -- JSON数组: 提及的用户
|
||||
attachments TEXT, -- JSON数组: 附件
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (parent_id) REFERENCES comments(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Phase 7: 协作与共享 - 变更历史表
|
||||
CREATE TABLE IF NOT EXISTS change_history (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
change_type TEXT NOT NULL, -- 变更类型: create, update, delete, merge, split
|
||||
entity_type TEXT NOT NULL, -- 实体类型: entity, relation, transcript, project
|
||||
entity_id TEXT NOT NULL, -- 实体ID
|
||||
entity_name TEXT, -- 实体名称(用于显示)
|
||||
changed_by TEXT NOT NULL, -- 变更者ID
|
||||
changed_by_name TEXT, -- 变更者显示名
|
||||
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
old_value TEXT, -- JSON: 旧值
|
||||
new_value TEXT, -- JSON: 新值
|
||||
description TEXT, -- 变更描述
|
||||
session_id TEXT, -- 会话ID(批量变更关联)
|
||||
reverted BOOLEAN DEFAULT 0, -- 是否已回滚
|
||||
reverted_at TIMESTAMP, -- 回滚时间
|
||||
reverted_by TEXT, -- 回滚者
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Phase 7: 协作与共享 - 团队成员表
|
||||
CREATE TABLE IF NOT EXISTS team_members (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL, -- 用户ID
|
||||
user_name TEXT, -- 用户名
|
||||
user_email TEXT, -- 用户邮箱
|
||||
role TEXT DEFAULT 'viewer', -- 角色: owner, admin, editor, viewer, commenter
|
||||
joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
invited_by TEXT, -- 邀请者
|
||||
last_active_at TIMESTAMP, -- 最后活跃时间
|
||||
permissions TEXT, -- JSON数组: 具体权限列表
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
|
||||
UNIQUE(project_id, user_id) -- 每个项目每个用户只能有一条记录
|
||||
);
|
||||
|
||||
-- Phase 7: 协作与共享索引
|
||||
CREATE INDEX IF NOT EXISTS idx_shares_project ON project_shares(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_shares_token ON project_shares(token);
|
||||
CREATE INDEX IF NOT EXISTS idx_comments_project ON comments(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_comments_target ON comments(target_type, target_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_comments_parent ON comments(parent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_change_history_project ON change_history(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_change_history_entity ON change_history(entity_type, entity_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_change_history_session ON change_history(session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_team_members_project ON team_members(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_team_members_user ON team_members(user_id);
|
||||
|
||||
Reference in New Issue
Block a user