from __future__ import annotations import json import os import tempfile import threading import time import uuid from datetime import datetime from typing import Any from sqlalchemy import and_, delete, func, or_, select from server.auth.context import IdentityContext, get_identity from server.db.database import get_session from server.db.models import ( AuditEventRecord, ChatMessageRecord, ChatSessionRecord, ProjectFileRecord, ProjectMember, UserWorkspace, WorkspaceProject, ) from server.timeutil import fmt_dt PERSONAL_PROJECT_ID = "__personal__" WRITE_ROLES = {"owner", "editor"} MEMBER_ROLES = {"owner", "editor", "viewer"} # The desktop can persist the same conversation snapshot twice in quick succession # (for example React effects during initial hydration). Serialize replacement # transactions in this process so SQLite never observes two writers rebuilding the # same unique (tenant, session, seq) slots at once. _message_replace_lock = threading.RLock() def _now() -> str: return fmt_dt(datetime.now()) def _uid(prefix: str) -> str: return f"{prefix}_{uuid.uuid4().hex[:12]}" def _deleted_mark() -> int: return int(time.time() * 1000) def _legacy_seed() -> dict[str, Any]: personal = { "id": _uid("sess"), "projectId": PERSONAL_PROJECT_ID, "title": "新话题", "status": "running", "updatedAt": _now(), } return { "projects": [], "sessions": [personal], "files": [], "messages": {}, "activeProjectId": PERSONAL_PROJECT_ID, "activeSessionId": personal["id"], "worldKey": "default", } class _LegacyJsonProjectStore: """Read/write compatibility for explicit legacy JSON paths and migration tests only.""" def __init__(self, path: str) -> None: self.path = str(path) self._lock = threading.Lock() self.data = self._load() def _load(self) -> dict[str, Any]: try: with open(self.path, "r", encoding="utf-8") as handle: data = json.load(handle) return data if isinstance(data, dict) and data.get("sessions") else _legacy_seed() except (FileNotFoundError, json.JSONDecodeError, TypeError): data = _legacy_seed() self._write(data) return data def _write(self, data: dict[str, Any]) -> None: os.makedirs(os.path.dirname(self.path) or ".", exist_ok=True) fd, tmp = tempfile.mkstemp(dir=os.path.dirname(self.path) or ".", suffix=".tmp") try: with os.fdopen(fd, "w", encoding="utf-8") as handle: json.dump(data, handle, ensure_ascii=False, indent=1) os.replace(tmp, self.path) except BaseException: if os.path.exists(tmp): os.unlink(tmp) raise def snapshot(self, *, include_messages: bool = True) -> dict[str, Any]: with self._lock: output = {key: self.data.get(key) for key in ( "projects", "sessions", "files", "activeProjectId", "activeSessionId", "worldKey", )} if include_messages: output["messages"] = self.data.get("messages") or {} return output def replace_workspace(self, payload: dict[str, Any]) -> dict[str, Any]: with self._lock: self.data = { "projects": list(payload.get("projects") or []), "sessions": list(payload.get("sessions") or []), "files": list(payload.get("files") or []), "messages": dict(payload.get("messages") or {}), "activeProjectId": payload.get("activeProjectId") or PERSONAL_PROJECT_ID, "activeSessionId": payload.get("activeSessionId"), "worldKey": payload.get("worldKey") or "default", } if not self.data["sessions"]: self.data = _legacy_seed() self._write(self.data) return self.snapshot(include_messages=True) def create_project(self, name: str, *, scope_label: str = "未设定作用域", work_dir: str | None = None, project_id: str | None = None) -> dict[str, Any]: with self._lock: project = { "id": project_id or _uid("proj"), "name": name.strip() or "未命名项目", "scopeLabel": scope_label, "createdAt": _now(), "worldKey": "default", } if work_dir: project["workDir"] = work_dir chat = { "id": _uid("sess"), "projectId": project["id"], "title": "新话题", "status": "running", "updatedAt": _now(), } self.data["projects"] = [project, *(self.data.get("projects") or [])] self.data["sessions"] = [chat, *(self.data.get("sessions") or [])] self.data["activeProjectId"] = project["id"] self.data["activeSessionId"] = chat["id"] self._write(self.data) return {"project": project, "session": chat} def delete_project(self, project_id: str) -> dict[str, Any]: if project_id == PERSONAL_PROJECT_ID: raise ValueError("个人话题作用域不可删除") with self._lock: if not any(row.get("id") == project_id for row in self.data.get("projects") or []): raise ValueError("项目不存在") session_ids = {row["id"] for row in self.data.get("sessions") or [] if row.get("projectId") == project_id} self.data["projects"] = [row for row in self.data.get("projects") or [] if row.get("id") != project_id] self.data["sessions"] = [row for row in self.data.get("sessions") or [] if row.get("id") not in session_ids] self.data["files"] = [row for row in self.data.get("files") or [] if row.get("projectId") != project_id] self.data["messages"] = {key: value for key, value in (self.data.get("messages") or {}).items() if key not in session_ids} self._write(self.data) return {"deletedProjectId": project_id, "deletedSessionIds": sorted(session_ids), "worldUntouched": True} def replace_messages(self, session_id: str, messages: list[Any]) -> dict[str, Any]: with self._lock: self.data.setdefault("messages", {})[session_id] = list(messages or []) self._write(self.data) return {"sessionId": session_id, "count": len(messages or [])} def get_file(self, file_id: str) -> dict[str, Any]: for row in self.data.get("files") or []: if row.get("id") == file_id: return dict(row) raise ValueError("文件不存在") def get_messages(self, session_id: str) -> list[Any]: return list((self.data.get("messages") or {}).get(session_id) or []) class ProjectStore: """Tenant-aware collaborative workspace backed by SQLAlchemy.""" def __new__(cls, identity: IdentityContext | str | None = None, path: str | None = None): legacy_path = identity if isinstance(identity, (str, os.PathLike)) else path if cls is ProjectStore and legacy_path is not None: return _LegacyJsonProjectStore(str(legacy_path)) return super().__new__(cls) def __init__(self, identity: IdentityContext | None = None, path: str | None = None) -> None: self.identity = identity or get_identity() self.path = path or "database" @property def tenant_uuid(self) -> str: return self.identity.tenant_uuid @property def user_id(self) -> int: return self.identity.user_id def _maybe_seed_web_demo(self) -> None: """Web 端首次打开工作区且无项目时,懒播种一套演示项目(桌面端/测试不受影响)。""" try: from server.state.web_demo import maybe_seed_demo_workspace maybe_seed_demo_workspace(self.identity) except Exception: pass # 演示播种失败绝不影响工作区主流程 def _workspace(self, session) -> UserWorkspace: row = session.execute(select(UserWorkspace).where( UserWorkspace.tenant_uuid == self.tenant_uuid, UserWorkspace.user_id == self.user_id, UserWorkspace.deleted == 0, )).scalar_one_or_none() if row is None: row = UserWorkspace( tenant_uuid=self.tenant_uuid, user_id=self.user_id, creator_id=self.user_id or None, active_project_id=PERSONAL_PROJECT_ID, created_at=_now(), ) session.add(row) session.flush() return row def _member(self, session, project_id: str, *, write: bool = False, owner: bool = False) -> ProjectMember: member = session.execute(select(ProjectMember).join( WorkspaceProject, and_( WorkspaceProject.id == ProjectMember.project_id, WorkspaceProject.tenant_uuid == ProjectMember.tenant_uuid, ), ).where( ProjectMember.tenant_uuid == self.tenant_uuid, ProjectMember.project_id == project_id, ProjectMember.user_id == self.user_id, ProjectMember.status == "active", ProjectMember.deleted == 0, WorkspaceProject.deleted == 0, )).scalar_one_or_none() if member is None: raise PermissionError("项目不存在或无权访问") if owner and member.role != "owner": raise PermissionError("只有项目所有者可以执行此操作") if write and member.role not in WRITE_ROLES: raise PermissionError("当前项目角色只有查看权限") return member def _session(self, session, session_id: str, *, write: bool = False) -> ChatSessionRecord: row = session.execute(select(ChatSessionRecord).where( ChatSessionRecord.tenant_uuid == self.tenant_uuid, ChatSessionRecord.id == session_id, ChatSessionRecord.deleted == 0, )).scalar_one_or_none() if row is None: raise ValueError("会话不存在") if row.scope == "personal": if row.owner_user_id != self.user_id: raise PermissionError("会话不存在或无权访问") elif row.project_id: self._member(session, row.project_id, write=write) else: raise PermissionError("会话归属无效") return row def _audit(self, session, action: str, resource_type: str, resource_id: str, project_id: str | None = None, detail: dict[str, Any] | None = None) -> None: session.add(AuditEventRecord( tenant_uuid=self.tenant_uuid, actor_user_id=self.user_id, creator_id=self.user_id or None, project_id=project_id, action=action, resource_type=resource_type, resource_id=resource_id, detail=detail or {}, created_at=_now(), )) def _ensure_personal_session(self, session) -> ChatSessionRecord: row = session.execute(select(ChatSessionRecord).where( ChatSessionRecord.tenant_uuid == self.tenant_uuid, ChatSessionRecord.owner_user_id == self.user_id, ChatSessionRecord.scope == "personal", ChatSessionRecord.deleted == 0, ).order_by(ChatSessionRecord.updated_at.desc(), ChatSessionRecord.created_at.desc())).scalars().first() if row is None: row = ChatSessionRecord( id=_uid("sess"), tenant_uuid=self.tenant_uuid, owner_user_id=self.user_id, creator_id=self.user_id or None, project_id=None, scope="personal", title="新话题", status="running", created_at=_now(), updated_at=_now(), ) session.add(row) session.flush() return row def _accessible_projects(self, session) -> list[tuple[WorkspaceProject, ProjectMember]]: return list(session.execute(select(WorkspaceProject, ProjectMember).join( ProjectMember, and_( ProjectMember.tenant_uuid == WorkspaceProject.tenant_uuid, ProjectMember.project_id == WorkspaceProject.id, ), ).where( WorkspaceProject.tenant_uuid == self.tenant_uuid, WorkspaceProject.deleted == 0, ProjectMember.user_id == self.user_id, ProjectMember.status == "active", ProjectMember.deleted == 0, ).order_by(WorkspaceProject.created_at.desc())).all()) @staticmethod def _project_dict(project: WorkspaceProject, role: str, member_count: int) -> dict[str, Any]: return { "id": project.id, "name": project.name, "scopeLabel": project.scope_label, "createdAt": project.created_at, "archived": project.archived, "worldKey": project.id, "workDir": project.work_dir or None, "ownerUserId": project.owner_user_id, "role": role, "memberCount": member_count, "dataVersion": project.data_version, } @staticmethod def _session_dict(row: ChatSessionRecord) -> dict[str, Any]: return { "id": row.id, "projectId": row.project_id or PERSONAL_PROJECT_ID, "title": row.title, "status": row.status, "updatedAt": row.updated_at or row.created_at, "scope": row.scope, "ownerUserId": row.owner_user_id, } @staticmethod def _file_dict(row: ProjectFileRecord) -> dict[str, Any]: return { "id": row.id, "projectId": row.project_id, "name": row.name, "kind": row.kind, "note": row.note or None, "createdAt": row.created_at, } def snapshot(self, *, include_messages: bool = True) -> dict[str, Any]: self._maybe_seed_web_demo() with get_session() as session: personal = self._ensure_personal_session(session) workspace = self._workspace(session) project_rows = self._accessible_projects(session) project_ids = [project.id for project, _ in project_rows] member_counts = dict(session.execute(select( ProjectMember.project_id, func.count(ProjectMember.id), ).where( ProjectMember.tenant_uuid == self.tenant_uuid, ProjectMember.project_id.in_(project_ids or ["__none__"]), ProjectMember.status == "active", ProjectMember.deleted == 0, ).group_by(ProjectMember.project_id)).all()) projects = [ self._project_dict(project, member.role, int(member_counts.get(project.id, 1))) for project, member in project_rows ] session_filter = or_( and_(ChatSessionRecord.scope == "personal", ChatSessionRecord.owner_user_id == self.user_id), and_(ChatSessionRecord.scope == "project", ChatSessionRecord.project_id.in_(project_ids or ["__none__"])), ) sessions = list(session.execute(select(ChatSessionRecord).where( ChatSessionRecord.tenant_uuid == self.tenant_uuid, ChatSessionRecord.deleted == 0, session_filter, ).order_by(ChatSessionRecord.updated_at.desc(), ChatSessionRecord.created_at.desc())).scalars()) if personal not in sessions: sessions.append(personal) files = list(session.execute(select(ProjectFileRecord).where( ProjectFileRecord.tenant_uuid == self.tenant_uuid, ProjectFileRecord.project_id.in_(project_ids or ["__none__"]), ProjectFileRecord.deleted == 0, ).order_by(ProjectFileRecord.created_at.desc())).scalars()) visible_session_ids = {row.id for row in sessions} active_project_id = workspace.active_project_id or PERSONAL_PROJECT_ID if active_project_id != PERSONAL_PROJECT_ID and active_project_id not in project_ids: active_project_id = PERSONAL_PROJECT_ID active_session_id = workspace.active_session_id if active_session_id not in visible_session_ids: candidate = next((row for row in sessions if (row.project_id or PERSONAL_PROJECT_ID) == active_project_id), personal) active_session_id = candidate.id workspace.active_project_id = active_project_id workspace.active_session_id = active_session_id workspace.version += 1 session.commit() output = { "projects": projects, "sessions": [self._session_dict(row) for row in sessions], "files": [self._file_dict(row) for row in files], "activeProjectId": active_project_id, "activeSessionId": active_session_id, "worldKey": "default" if active_project_id == PERSONAL_PROJECT_ID else active_project_id, "workspaceVersion": workspace.version, } if include_messages: messages: dict[str, list[Any]] = {sid: [] for sid in visible_session_ids} rows = session.execute(select(ChatMessageRecord).where( ChatMessageRecord.tenant_uuid == self.tenant_uuid, ChatMessageRecord.session_id.in_(visible_session_ids or ["__none__"]), ChatMessageRecord.deleted == 0, ).order_by(ChatMessageRecord.session_id, ChatMessageRecord.seq)).scalars() for message in rows: messages.setdefault(message.session_id, []).append(message.payload) output["messages"] = messages return output def active_world_key(self) -> str: with get_session() as session: workspace = self._workspace(session) key = workspace.active_project_id or PERSONAL_PROJECT_ID if key != PERSONAL_PROJECT_ID: try: self._member(session, key) except PermissionError: key = PERSONAL_PROJECT_ID workspace.active_project_id = key session.commit() return "default" if key == PERSONAL_PROJECT_ID else key def require_active_write(self) -> None: """Reject viewer writes and repair stale active-project selections.""" with get_session() as session: workspace = self._workspace(session) project_id = workspace.active_project_id or PERSONAL_PROJECT_ID if project_id == PERSONAL_PROJECT_ID: return try: self._member(session, project_id) except PermissionError: workspace.active_project_id = PERSONAL_PROJECT_ID session.commit() return self._member(session, project_id, write=True) def replace_workspace(self, payload: dict[str, Any]) -> dict[str, Any]: """Only selection and authorized message bodies are accepted from the client.""" with get_session() as session: workspace = self._workspace(session) project_id = payload.get("activeProjectId") or PERSONAL_PROJECT_ID if project_id != PERSONAL_PROJECT_ID: self._member(session, str(project_id)) session_id = payload.get("activeSessionId") if session_id: selected = self._session(session, str(session_id)) selected_project = selected.project_id or PERSONAL_PROJECT_ID if selected_project != project_id: project_id = selected_project workspace.active_project_id = str(project_id) workspace.active_session_id = str(session_id) if session_id else workspace.active_session_id workspace.updater_id = self.user_id or None workspace.updated_at = _now() workspace.version += 1 messages = payload.get("messages") if isinstance(messages, dict): for sid, rows in messages.items(): if isinstance(rows, list): self._replace_messages(session, str(sid), rows) session.commit() return self.snapshot(include_messages=True) def create_project(self, name: str, *, scope_label: str = "未设定作用域", work_dir: str | None = None, project_id: str | None = None) -> dict[str, Any]: with get_session() as session: pid = (project_id or _uid("proj"))[:64] exists = session.execute(select(WorkspaceProject.id).where(WorkspaceProject.id == pid)).first() if exists: raise ValueError("项目标识已存在") project = WorkspaceProject( id=pid, tenant_uuid=self.tenant_uuid, owner_user_id=self.user_id, creator_id=self.user_id or None, name=(name or "").strip() or "未命名项目", scope_label=scope_label or "未设定作用域", work_dir=(work_dir or "").strip(), created_at=_now(), ) member = ProjectMember( tenant_uuid=self.tenant_uuid, project_id=pid, user_id=self.user_id, role="owner", status="active", invited_by=self.user_id, creator_id=self.user_id or None, created_at=_now(), ) session.add_all([project, member]) session.flush() created_session = self._create_session(session, pid, "新话题") workspace = self._workspace(session) workspace.active_project_id = pid workspace.active_session_id = created_session.id self._audit(session, "project.create", "project", pid, pid) session.commit() return {"project": self._project_dict(project, "owner", 1), "session": self._session_dict(created_session)} def rename_project(self, project_id: str, name: str) -> dict[str, Any]: with get_session() as session: self._member(session, project_id, write=True) project = session.execute(select(WorkspaceProject).where( WorkspaceProject.tenant_uuid == self.tenant_uuid, WorkspaceProject.id == project_id, WorkspaceProject.deleted == 0, )).scalar_one() project.name = name.strip() or project.name project.updater_id = self.user_id project.updated_at = _now() project.data_version += 1 self._audit(session, "project.rename", "project", project_id, project_id) session.commit() return {"projectId": project_id, "name": project.name, "dataVersion": project.data_version} def set_work_dir(self, project_id: str, work_dir: str) -> dict[str, Any]: """设置项目工程数据目录(写权限项目)。浏览器上传本地文件后自动落位。""" with get_session() as session: self._member(session, project_id, write=True) project = session.execute(select(WorkspaceProject).where( WorkspaceProject.tenant_uuid == self.tenant_uuid, WorkspaceProject.id == project_id, WorkspaceProject.deleted == 0, )).scalar_one() project.work_dir = (work_dir or "").strip() project.updater_id = self.user_id project.updated_at = _now() project.data_version += 1 self._audit(session, "project.workdir.set", "project", project_id, project_id, detail={"workDir": project.work_dir}) session.commit() return {"projectId": project_id, "workDir": project.work_dir, "dataVersion": project.data_version} def delete_project(self, project_id: str) -> dict[str, Any]: if project_id == PERSONAL_PROJECT_ID: raise ValueError("个人话题作用域不可删除") with get_session() as session: self._member(session, project_id, owner=True) mark = _deleted_mark() project = session.execute(select(WorkspaceProject).where( WorkspaceProject.tenant_uuid == self.tenant_uuid, WorkspaceProject.id == project_id, WorkspaceProject.deleted == 0, )).scalar_one() project.deleted = mark project.updater_id = self.user_id project.updated_at = _now() sessions = list(session.execute(select(ChatSessionRecord).where( ChatSessionRecord.tenant_uuid == self.tenant_uuid, ChatSessionRecord.project_id == project_id, ChatSessionRecord.deleted == 0, )).scalars()) drop_sessions = {row.id for row in sessions} for row in sessions: row.deleted = mark for model in (ProjectMember, ProjectFileRecord): for row in session.execute(select(model).where( model.tenant_uuid == self.tenant_uuid, model.project_id == project_id, model.deleted == 0, )).scalars(): row.deleted = mark for row in session.execute(select(ChatMessageRecord).where( ChatMessageRecord.tenant_uuid == self.tenant_uuid, ChatMessageRecord.session_id.in_(drop_sessions or ["__none__"]), ChatMessageRecord.deleted == 0, )).scalars(): row.deleted = mark self._audit(session, "project.delete", "project", project_id, project_id) session.commit() return {"deletedProjectId": project_id, "deletedSessionIds": sorted(drop_sessions), "worldUntouched": True} def _create_session(self, session, project_id: str, title: str, session_id: str | None = None) -> ChatSessionRecord: scope = "personal" if project_id == PERSONAL_PROJECT_ID else "project" if scope == "project": self._member(session, project_id, write=True) row = ChatSessionRecord( id=(session_id or _uid("sess"))[:64], tenant_uuid=self.tenant_uuid, owner_user_id=self.user_id, creator_id=self.user_id or None, project_id=None if scope == "personal" else project_id, scope=scope, title=(title or "").strip() or "新话题", status="running", created_at=_now(), updated_at=_now(), ) session.add(row) session.flush() return row def create_session(self, project_id: str, title: str = "新话题", session_id: str | None = None) -> dict[str, Any]: with get_session() as session: row = self._create_session(session, project_id, title, session_id) workspace = self._workspace(session) workspace.active_project_id = project_id workspace.active_session_id = row.id self._audit(session, "session.create", "session", row.id, row.project_id) session.commit() return {"session": self._session_dict(row)} def rename_session(self, session_id: str, title: str) -> dict[str, Any]: with get_session() as session: row = self._session(session, session_id, write=True) row.title = title.strip() or row.title row.updated_at = _now() row.updater_id = self.user_id self._audit(session, "session.rename", "session", row.id, row.project_id) session.commit() return {"sessionId": row.id, "title": row.title} def delete_session(self, session_id: str) -> dict[str, Any]: with get_session() as session: row = self._session(session, session_id, write=True) mark = _deleted_mark() row.deleted = mark for message in session.execute(select(ChatMessageRecord).where( ChatMessageRecord.tenant_uuid == self.tenant_uuid, ChatMessageRecord.session_id == session_id, ChatMessageRecord.deleted == 0, )).scalars(): message.deleted = mark self._audit(session, "session.delete", "session", row.id, row.project_id) session.commit() return {"deletedSessionId": session_id} def _replace_messages(self, session, session_id: str, messages: list[Any]) -> dict[str, Any]: chat = self._session(session, session_id, write=True) # Message slots are unique by (tenant_uuid, session_id, seq); soft-deleting # the previous rows and inserting seq=0..N again still violates that key. # A full snapshot replacement therefore removes the old slots physically # before rebuilding them. Session deletion keeps its separate soft-delete # semantics. session.execute(delete(ChatMessageRecord).where( ChatMessageRecord.tenant_uuid == self.tenant_uuid, ChatMessageRecord.session_id == session_id, )) for seq, payload in enumerate(messages or []): session.add(ChatMessageRecord( tenant_uuid=self.tenant_uuid, session_id=session_id, sender_user_id=self.user_id if isinstance(payload, dict) and payload.get("role") == "user" else None, creator_id=self.user_id or None, seq=seq, payload=payload if isinstance(payload, dict) else {"value": payload}, created_at=_now(), )) chat.updated_at = _now() chat.updater_id = self.user_id return {"sessionId": session_id, "count": len(messages or [])} def replace_messages(self, session_id: str, messages: list[Any]) -> dict[str, Any]: with _message_replace_lock: with get_session() as session: result = self._replace_messages(session, session_id, messages) session.commit() return result def append_message(self, session_id: str, message: dict[str, Any]) -> dict[str, Any]: with get_session() as session: chat = self._session(session, session_id, write=True) max_seq = session.execute(select(func.max(ChatMessageRecord.seq)).where( ChatMessageRecord.tenant_uuid == self.tenant_uuid, ChatMessageRecord.session_id == session_id, ChatMessageRecord.deleted == 0, )).scalar_one_or_none() seq = int(max_seq if max_seq is not None else -1) + 1 session.add(ChatMessageRecord( tenant_uuid=self.tenant_uuid, session_id=session_id, sender_user_id=self.user_id if message.get("role") == "user" else None, creator_id=self.user_id or None, seq=seq, payload=message, created_at=_now(), )) chat.updated_at = _now() session.commit() return {"sessionId": session_id, "count": seq + 1} def get_messages(self, session_id: str) -> list[Any]: with get_session() as session: self._session(session, session_id) rows = session.execute(select(ChatMessageRecord).where( ChatMessageRecord.tenant_uuid == self.tenant_uuid, ChatMessageRecord.session_id == session_id, ChatMessageRecord.deleted == 0, ).order_by(ChatMessageRecord.seq)).scalars() return [row.payload for row in rows] def ensure_session( self, session_id: str, *, project_id: str | None = None, title: str = "新话题", ) -> dict[str, Any]: """对话入口兜底:会话已存在则校验权限;前端本地新建但未同步成功时自动建库。""" with get_session() as session: try: row = self._session(session, session_id) except ValueError: row = None workspace = self._workspace(session) if row is not None: current_pid = row.project_id or PERSONAL_PROJECT_ID requested_pid = (project_id or "").strip() target_pid = requested_pid or current_pid rebound = target_pid != current_pid if rebound: if row.owner_user_id != self.user_id: raise PermissionError("只有会话创建者可以调整会话归属") if row.project_id: self._member(session, row.project_id, write=True) if target_pid != PERSONAL_PROJECT_ID: self._member(session, target_pid, write=True) row.scope = "personal" if target_pid == PERSONAL_PROJECT_ID else "project" row.project_id = None if target_pid == PERSONAL_PROJECT_ID else target_pid row.updater_id = self.user_id row.updated_at = _now() self._audit( session, "session.rebind", "session", row.id, row.project_id, detail={"fromProjectId": current_pid, "toProjectId": target_pid}, ) workspace_changed = ( workspace.active_project_id != target_pid or workspace.active_session_id != row.id ) if workspace_changed: workspace.active_project_id = target_pid workspace.active_session_id = row.id workspace.updater_id = self.user_id workspace.updated_at = _now() if rebound or workspace_changed: session.commit() return self._session_dict(row) # 会话不存在 → 挂到指定项目 / 当前活动项目 / 个人话题 pid = (project_id or workspace.active_project_id or PERSONAL_PROJECT_ID).strip() if pid and pid != PERSONAL_PROJECT_ID: try: self._member(session, pid, write=True) except PermissionError: if project_id: raise pid = PERSONAL_PROJECT_ID row = self._create_session(session, pid, title, session_id=session_id) workspace.active_project_id = pid workspace.active_session_id = row.id self._audit(session, "session.ensure", "session", row.id, row.project_id) session.commit() return self._session_dict(row) def create_file(self, project_id: str, name: str, kind: str = "other", note: str = "", file_id: str | None = None) -> dict[str, Any]: with get_session() as session: self._member(session, project_id, write=True) row = ProjectFileRecord( id=(file_id or _uid("file"))[:64], tenant_uuid=self.tenant_uuid, project_id=project_id, creator_id=self.user_id or None, name=name.strip(), kind=kind if kind in {"schedule", "report", "import", "other"} else "other", note=note, created_at=_now(), ) session.add(row) self._audit(session, "file.create", "project_file", row.id, project_id) session.commit() return {"file": self._file_dict(row)} def get_file(self, file_id: str) -> dict[str, Any]: """读取单个项目文件记录(校验项目读权限)。""" with get_session() as session: row = session.execute(select(ProjectFileRecord).where( ProjectFileRecord.tenant_uuid == self.tenant_uuid, ProjectFileRecord.id == file_id, ProjectFileRecord.deleted == 0, )).scalar_one_or_none() if row is None: raise ValueError("文件不存在") self._member(session, row.project_id) return self._file_dict(row) def delete_file(self, file_id: str) -> dict[str, Any]: with get_session() as session: row = session.execute(select(ProjectFileRecord).where( ProjectFileRecord.tenant_uuid == self.tenant_uuid, ProjectFileRecord.id == file_id, ProjectFileRecord.deleted == 0, )).scalar_one_or_none() if row is None: raise ValueError("文件不存在") self._member(session, row.project_id, write=True) row.deleted = _deleted_mark() row.updater_id = self.user_id self._audit(session, "file.delete", "project_file", file_id, row.project_id) session.commit() return {"deletedFileId": file_id} def list_members(self, project_id: str) -> list[dict[str, Any]]: with get_session() as session: self._member(session, project_id) rows = session.execute(select(ProjectMember).where( ProjectMember.tenant_uuid == self.tenant_uuid, ProjectMember.project_id == project_id, ProjectMember.status == "active", ProjectMember.deleted == 0, ).order_by(ProjectMember.created_at)).scalars() return [{ "userId": row.user_id, "role": row.role, "status": row.status, "invitedBy": row.invited_by, "createdAt": row.created_at, "isCurrentUser": row.user_id == self.user_id, } for row in rows] def add_member(self, project_id: str, user_id: int, role: str) -> dict[str, Any]: if role not in {"editor", "viewer"}: raise ValueError("成员角色必须是 editor 或 viewer") with get_session() as session: self._member(session, project_id, owner=True) row = session.execute(select(ProjectMember).where( ProjectMember.tenant_uuid == self.tenant_uuid, ProjectMember.project_id == project_id, ProjectMember.user_id == user_id, ProjectMember.deleted == 0, )).scalar_one_or_none() if row is None: row = ProjectMember( tenant_uuid=self.tenant_uuid, project_id=project_id, user_id=user_id, role=role, status="active", invited_by=self.user_id, creator_id=self.user_id or None, created_at=_now(), ) session.add(row) else: if row.role == "owner": raise ValueError("不能修改项目所有者") row.role = role row.status = "active" row.updater_id = self.user_id row.updated_at = _now() self._audit(session, "project.member.add", "project_member", str(user_id), project_id, {"role": role}) session.commit() return {"projectId": project_id, "userId": user_id, "role": role} def update_member(self, project_id: str, user_id: int, role: str) -> dict[str, Any]: return self.add_member(project_id, user_id, role) def remove_member(self, project_id: str, user_id: int) -> dict[str, Any]: with get_session() as session: self._member(session, project_id, owner=True) row = session.execute(select(ProjectMember).where( ProjectMember.tenant_uuid == self.tenant_uuid, ProjectMember.project_id == project_id, ProjectMember.user_id == user_id, ProjectMember.deleted == 0, )).scalar_one_or_none() if row is None: raise ValueError("项目成员不存在") if row.role == "owner": raise ValueError("不能移除项目所有者") row.status = "revoked" row.deleted = _deleted_mark() row.updater_id = self.user_id row.updated_at = _now() self._audit(session, "project.member.remove", "project_member", str(user_id), project_id) session.commit() return {"projectId": project_id, "removedUserId": user_id} def get_project_store() -> ProjectStore: return ProjectStore(get_identity())