aps-agent/server/state/projects.py

839 lines
37 KiB
Python
Raw Normal View History

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_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 _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]:
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 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 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())