886 lines
40 KiB
Python
886 lines
40 KiB
Python
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_, 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"}
|
|
|
|
|
|
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)
|
|
mark = _deleted_mark()
|
|
for row in session.execute(select(ChatMessageRecord).where(
|
|
ChatMessageRecord.tenant_uuid == self.tenant_uuid,
|
|
ChatMessageRecord.session_id == session_id,
|
|
ChatMessageRecord.deleted == 0,
|
|
)).scalars():
|
|
row.deleted = mark
|
|
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 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())
|