From 7a2dc5f810ba98f31642053006effe672960f7f6 Mon Sep 17 00:00:00 2001 From: OpenClaw Bot Date: Tue, 24 Feb 2026 00:17:13 +0800 Subject: [PATCH] Phase 7 Task 4: Add collaboration API endpoints and schema - Add collaboration_manager import and COLLABORATION_AVAILABLE flag - Add get_collab_manager() singleton function - Add collaboration API endpoints: - Project share links (create, list, verify, access, revoke) - Comments (add, get, update, resolve, delete) - Change history (get, stats, versions, revert) - Team members (invite, list, update role, remove, check permissions) - Add collaboration tables to schema.sql: - project_shares, comments, change_history, team_members - Related indexes for performance --- backend/main.py | 523 +++++++++++++++++++++++++++++++++++++++++++++ backend/schema.sql | 92 ++++++++ 2 files changed, 615 insertions(+) diff --git a/backend/main.py b/backend/main.py index 2d45278..7b09287 100644 --- a/backend/main.py +++ b/backend/main.py @@ -167,6 +167,14 @@ except ImportError as e: print(f"Security Manager import error: {e}") SECURITY_MANAGER_AVAILABLE = False +# Phase 7 Task 4: Collaboration Manager +try: + from collaboration_manager import get_collaboration_manager, CollaborationManager + COLLABORATION_AVAILABLE = True +except ImportError as e: + print(f"Collaboration Manager import error: {e}") + COLLABORATION_AVAILABLE = False + # FastAPI app with enhanced metadata for Swagger app = FastAPI( title="InsightFlow API", @@ -718,6 +726,15 @@ def get_doc_processor(): _doc_processor = DocumentProcessor() return _doc_processor +# Phase 7 Task 4: 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}", tags=["Entities"]) async def update_entity(entity_id: str, update: EntityUpdate, _=Depends(verify_api_key)): @@ -7011,6 +7028,512 @@ async def reject_access_request( ) +# ========================================== +# Phase 7 Task 4: 协作与共享 API +# ========================================== + +# ----- 请求模型 ----- + +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 + + +# ----- 项目分享 ----- + +@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") diff --git a/backend/schema.sql b/backend/schema.sql index 98d8d34..549c66e 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -633,3 +633,95 @@ CREATE INDEX IF NOT EXISTS idx_masking_project ON masking_rules(project_id); CREATE INDEX IF NOT EXISTS idx_access_policy_project ON data_access_policies(project_id); CREATE INDEX IF NOT EXISTS idx_access_requests_policy ON access_requests(policy_id); CREATE INDEX IF NOT EXISTS idx_access_requests_user ON access_requests(user_id); + +-- ============================================ +-- Phase 7 Task 4: 协作与共享 +-- ============================================ + +-- 项目分享表 +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 +); + +-- 评论表 +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 +); + +-- 变更历史表 +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 +); + +-- 团队成员表 +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) -- 每个项目每个用户只能有一条记录 +); + +-- 协作与共享相关索引 +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);