feat: multi-tenant workspace isolation, RBAC, sidebar nav, billing, and Android enhancements

- Backend: workspace_id isolation for 14 model tables + safe migration/backfill
- Backend: RBAC system with 4 roles and 23 permissions, seeded on startup
- Backend: workspace admin endpoints (list/manage all workspaces)
- Backend: admin user management API (CRUD, reset password)
- Backend: billing API with subscription plans, usage tracking, rate limiting
- Backend: fix system_logs.py UNION query and wrong column references
- Backend: WebSocket JWT auth and workspace enforcement
- Frontend: sidebar navigation replacing top dropdown menu
- Frontend: user management page (Users.vue) for admins
- Frontend: enhanced Workspaces.vue with admin table view
- Frontend: workspace RBAC computed properties in user store
- Android: agent marketplace, billing/subscription UI, onboarding wizard
- Android: phone login, analytics tracker, crash handler, network diagnostics
- Android: splash screen, encrypted token storage, app update enhancements
- Docs: multi-tenant RBAC guide with 8 sections and role-permission matrix

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 01:00:22 +08:00
parent 51eafb23f3
commit 876789fac1
93 changed files with 8803 additions and 669 deletions

View File

@@ -10,13 +10,14 @@ from __future__ import annotations
import logging
import json
from typing import Any, AsyncGenerator, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Depends, HTTPException, Request, Query
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from app.core.database import get_db
from sqlalchemy.orm import Session
from app.api.auth import get_current_user
from app.api.deps import get_current_workspace_id, get_workspace_membership, WorkspaceContext
from app.models.user import User
from app.models.agent import Agent
from app.models.agent_llm_log import AgentLLMLog
@@ -43,6 +44,7 @@ def _make_llm_logger(
db: Session,
agent_id: Optional[str] = None,
user_id: Optional[str] = None,
workspace_id: Optional[str] = None,
):
"""创建 LLM 调用日志回调,写入 AgentLLMLog 表。"""
def _log(metrics: dict):
@@ -51,6 +53,7 @@ def _make_llm_logger(
agent_id=agent_id,
session_id=metrics.get("session_id"),
user_id=user_id,
workspace_id=workspace_id,
model=metrics.get("model", ""),
provider=metrics.get("provider"),
prompt_tokens=metrics.get("prompt_tokens", 0),
@@ -74,6 +77,7 @@ def _make_message_saver(
db: Session,
agent_id: Optional[str] = None,
user_id: Optional[str] = None,
workspace_id: Optional[str] = None,
):
"""创建消息持久化回调,将每条消息写入 chat_messages 表。"""
def _save(msg: dict):
@@ -82,6 +86,7 @@ def _make_message_saver(
session_id=msg.get("session_id"),
agent_id=agent_id,
user_id=user_id,
workspace_id=workspace_id,
role=msg.get("role", "user"),
content=msg.get("content"),
tool_name=msg.get("tool_name"),
@@ -90,6 +95,29 @@ def _make_message_saver(
iteration=msg.get("iteration", 0),
)
db.add(record)
# 自动创建 / 更新 AgentSession 记录
session_id = msg.get("session_id")
if session_id and agent_id:
from app.models.agent_session import AgentSession
existing = db.query(AgentSession).filter(
AgentSession.id == session_id
).first()
if not existing:
title = None
if msg.get("role") == "user" and msg.get("content"):
title = msg["content"][:100]
session = AgentSession(
id=session_id,
user_id=user_id,
agent_id=agent_id,
workspace_id=workspace_id,
title=title,
)
db.add(session)
elif existing.title is None and msg.get("role") == "user" and msg.get("content"):
existing.title = msg["content"][:100]
db.commit()
except Exception as e:
logger.warning("写入 ChatMessage 失败: %s", e)
@@ -155,6 +183,7 @@ class SessionItem(BaseModel):
title: Optional[str] = None
last_message: Optional[str] = None
message_count: int = 0
is_pinned: bool = False
created_at: Optional[str] = None
updated_at: Optional[str] = None
@@ -164,6 +193,23 @@ class SessionListResponse(BaseModel):
sessions: List[SessionItem]
class SearchMessageItem(BaseModel):
"""跨会话搜索消息条目"""
id: str
session_id: str
agent_id: Optional[str] = None
role: str
content: Optional[str] = None
session_title: Optional[str] = None
created_at: Optional[str] = None
class SearchMessagesResponse(BaseModel):
"""消息搜索响应"""
messages: List[SearchMessageItem]
total: int
class OrchestrateAgentItem(BaseModel):
"""编排中单个 Agent 的定义"""
id: str
@@ -208,6 +254,7 @@ class OrchestrateResponse(BaseModel):
async def orchestrate_agents(
req: OrchestrateRequest,
current_user: User = Depends(get_current_user),
workspace_id: str = Depends(get_current_workspace_id),
db: Session = Depends(get_db),
):
"""多 Agent 编排:支持 route / sequential / debate 三种模式。"""
@@ -225,7 +272,7 @@ async def orchestrate_agents(
for a in req.agents
]
on_llm_call = _make_llm_logger(db, agent_id=None, user_id=current_user.id)
on_llm_call = _make_llm_logger(db, agent_id=None, user_id=current_user.id, workspace_id=workspace_id)
orchestrator = AgentOrchestrator(
default_llm_config=AgentLLMConfig(
model=req.model or "deepseek-v4-flash",
@@ -265,10 +312,11 @@ class GraphOrchestrateRequest(BaseModel):
async def orchestrate_graph(
req: GraphOrchestrateRequest,
current_user: User = Depends(get_current_user),
workspace_id: str = Depends(get_current_workspace_id),
db: Session = Depends(get_db),
):
"""图编排模式:按 DAG 拓扑顺序执行 Agent 和条件节点。"""
on_llm_call = _make_llm_logger(db, agent_id=None, user_id=current_user.id)
on_llm_call = _make_llm_logger(db, agent_id=None, user_id=current_user.id, workspace_id=workspace_id)
orchestrator = AgentOrchestrator(
default_llm_config=AgentLLMConfig(
model=req.model or "deepseek-v4-flash",
@@ -301,6 +349,7 @@ async def orchestrate_graph(
async def chat_bare(
req: ChatRequest,
current_user: User = Depends(get_current_user),
workspace_id: str = Depends(get_current_workspace_id),
db: Session = Depends(get_db),
):
"""无需 Agent 配置,使用默认设置直接对话。"""
@@ -334,8 +383,8 @@ async def chat_bare(
config.prompt_sections.enabled = False
if req.system_prompt_override:
config.system_prompt = req.system_prompt_override
on_llm_call = _make_llm_logger(db, agent_id=None, user_id=current_user.id)
on_message = _make_message_saver(db, agent_id=None, user_id=current_user.id)
on_llm_call = _make_llm_logger(db, agent_id=None, user_id=current_user.id, workspace_id=workspace_id)
on_message = _make_message_saver(db, agent_id=None, user_id=current_user.id, workspace_id=workspace_id)
context = AgentContext(session_id=req.session_id)
runtime = AgentRuntime(config=config, context=context, on_llm_call=on_llm_call, on_message=on_message, streamlined=req.streamlined)
result = await runtime.run(req.message)
@@ -366,6 +415,7 @@ async def chat_bare(
async def chat_bare_stream(
req: ChatRequest,
current_user: User = Depends(get_current_user),
workspace_id: str = Depends(get_current_workspace_id),
db: Session = Depends(get_db),
):
"""无需 Agent 配置,使用默认设置直接对话(流式 SSE"""
@@ -399,8 +449,8 @@ async def chat_bare_stream(
config.prompt_sections.enabled = False
if req.system_prompt_override:
config.system_prompt = req.system_prompt_override
on_llm_call = _make_llm_logger(db, agent_id=None, user_id=current_user.id)
on_message = _make_message_saver(db, agent_id=None, user_id=current_user.id)
on_llm_call = _make_llm_logger(db, agent_id=None, user_id=current_user.id, workspace_id=workspace_id)
on_message = _make_message_saver(db, agent_id=None, user_id=current_user.id, workspace_id=workspace_id)
context = AgentContext(session_id=req.session_id)
runtime = AgentRuntime(config=config, context=context, on_llm_call=on_llm_call, on_message=on_message, streamlined=req.streamlined)
return StreamingResponse(
@@ -419,6 +469,7 @@ async def chat_with_agent(
agent_id: str,
req: ChatRequest,
current_user: User = Depends(get_current_user),
workspace_id: str = Depends(get_current_workspace_id),
db: Session = Depends(get_db),
):
"""与指定的 Agent 对话。Agent 的工作流配置会用于构建 Runtime。"""
@@ -485,8 +536,8 @@ async def chat_with_agent(
if req.system_prompt_override:
config.system_prompt = req.system_prompt_override
on_llm_call = _make_llm_logger(db, agent_id=agent_id, user_id=current_user.id)
on_message = _make_message_saver(db, agent_id=agent_id, user_id=current_user.id)
on_llm_call = _make_llm_logger(db, agent_id=agent_id, user_id=current_user.id, workspace_id=workspace_id)
on_message = _make_message_saver(db, agent_id=agent_id, user_id=current_user.id, workspace_id=workspace_id)
context = AgentContext(session_id=req.session_id)
runtime = AgentRuntime(config=config, context=context, on_llm_call=on_llm_call, on_message=on_message, streamlined=req.streamlined)
result = await runtime.run(req.message)
@@ -519,6 +570,7 @@ async def chat_with_agent_stream(
agent_id: str,
req: ChatRequest,
current_user: User = Depends(get_current_user),
workspace_id: str = Depends(get_current_workspace_id),
db: Session = Depends(get_db),
):
"""与指定的 Agent 对话(流式 SSE"""
@@ -581,8 +633,8 @@ async def chat_with_agent_stream(
if req.system_prompt_override:
config.system_prompt = req.system_prompt_override
on_llm_call = _make_llm_logger(db, agent_id=agent_id, user_id=current_user.id)
on_message = _make_message_saver(db, agent_id=agent_id, user_id=current_user.id)
on_llm_call = _make_llm_logger(db, agent_id=agent_id, user_id=current_user.id, workspace_id=workspace_id)
on_message = _make_message_saver(db, agent_id=agent_id, user_id=current_user.id, workspace_id=workspace_id)
context = AgentContext(session_id=req.session_id)
runtime = AgentRuntime(config=config, context=context, on_llm_call=on_llm_call, on_message=on_message, streamlined=req.streamlined)
return StreamingResponse(
@@ -600,18 +652,20 @@ async def chat_with_agent_stream(
async def list_agent_sessions(
agent_id: str,
limit: int = 50,
search: Optional[str] = None,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""获取 Agent 的会话列表,按最近活跃时间排序。"""
"""获取 Agent 的会话列表,置顶优先,再按最近活跃时间排序。支持 search 参数按标题/内容搜索。"""
from sqlalchemy import func as sa_func, desc, or_
from app.models.agent_session import AgentSession
# 验证 agent 存在或有权限
agent = db.query(Agent).filter(Agent.id == agent_id).first()
if not agent:
raise HTTPException(status_code=404, detail="Agent 不存在")
rows = (
rows_query = (
db.query(
ChatMessage.session_id,
sa_func.min(ChatMessage.created_at).label("created_at"),
@@ -620,23 +674,65 @@ async def list_agent_sessions(
)
.filter(ChatMessage.agent_id == agent_id)
.group_by(ChatMessage.session_id)
.order_by(desc("updated_at"))
.limit(limit)
.all()
)
# 搜索过滤按会话标题AgentSession.title或消息内容
if search and search.strip():
pattern = f"%{search.strip()}%"
# 子查询:在标题或消息内容中匹配的 session_id
matching_session_ids = set()
# 匹配 AgentSession.title
title_matches = db.query(AgentSession.id).filter(
AgentSession.title.like(pattern)
).all()
matching_session_ids.update(r[0] for r in title_matches)
# 匹配消息内容
content_matches = db.query(ChatMessage.session_id).filter(
ChatMessage.agent_id == agent_id,
ChatMessage.content.like(pattern),
).distinct().all()
matching_session_ids.update(r[0] for r in content_matches)
if matching_session_ids:
rows_query = rows_query.filter(
ChatMessage.session_id.in_(list(matching_session_ids))
)
else:
# 无匹配,返回空
return SessionListResponse(sessions=[])
rows = rows_query.order_by(desc("updated_at")).limit(limit).all()
# 批量查询 AgentSession 记录
session_ids = [row.session_id for row in rows]
agent_sessions_map = {}
if session_ids:
records = db.query(AgentSession).filter(
AgentSession.id.in_(session_ids)
).all()
agent_sessions_map = {s.id: s for s in records}
sessions = []
for row in rows:
# 取第一条 user 消息作为标题
first_user_msg = (
db.query(ChatMessage)
.filter(
ChatMessage.session_id == row.session_id,
ChatMessage.role == "user",
asession = agent_sessions_map.get(row.session_id)
# 标题优先级: AgentSession.title > 第一条 user 消息
title = asession.title if asession and asession.title else None
if not title:
first_user_msg = (
db.query(ChatMessage)
.filter(
ChatMessage.session_id == row.session_id,
ChatMessage.role == "user",
)
.order_by(ChatMessage.created_at.asc())
.first()
)
.order_by(ChatMessage.created_at.asc())
.first()
)
if first_user_msg and first_user_msg.content:
title = first_user_msg.content[:100]
# 取最后一条消息作为预览
last_msg = (
db.query(ChatMessage)
@@ -646,13 +742,21 @@ async def list_agent_sessions(
)
sessions.append(SessionItem(
session_id=row.session_id,
title=first_user_msg.content[:100] if first_user_msg and first_user_msg.content else None,
title=title,
last_message=last_msg.content[:200] if last_msg and last_msg.content else None,
message_count=row.message_count,
is_pinned=asession.is_pinned if asession else False,
created_at=row.created_at.isoformat() if row.created_at else None,
updated_at=row.updated_at.isoformat() if row.updated_at else None,
))
# 排序:置顶优先,再按更新时间降序
pinned = [s for s in sessions if s.is_pinned]
unpinned = [s for s in sessions if not s.is_pinned]
pinned.sort(key=lambda s: s.updated_at or "", reverse=True)
unpinned.sort(key=lambda s: s.updated_at or "", reverse=True)
sessions = pinned + unpinned
return SessionListResponse(sessions=sessions)
@@ -662,10 +766,11 @@ async def get_session_messages(
session_id: str,
before_id: Optional[str] = None,
limit: int = 50,
search: Optional[str] = None,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""获取会话的消息历史(分页),从旧到新排序。"""
"""获取会话的消息历史(分页),从旧到新排序。支持 search 参数按内容搜索。"""
from sqlalchemy import or_
# limit 限制
@@ -676,6 +781,11 @@ async def get_session_messages(
ChatMessage.session_id == session_id,
)
# 消息内容搜索
if search and search.strip():
pattern = f"%{search.strip()}%"
base_q = base_q.filter(ChatMessage.content.like(pattern))
# 游标分页before_id 之前的老消息(使用复合键避免 created_at 相同导致遗漏)
if before_id:
cursor_msg = db.query(ChatMessage).filter(ChatMessage.id == before_id).first()
@@ -725,6 +835,72 @@ async def get_session_messages(
return MessageHistoryResponse(messages=messages, has_more=has_more, total=total)
@router.get("/{agent_id}/search-messages", response_model=SearchMessagesResponse)
async def search_messages(
agent_id: str,
q: str = Query(..., min_length=1, description="搜索关键词"),
limit: int = Query(50, ge=1, le=100),
skip: int = Query(0, ge=0),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""跨会话搜索消息内容,返回匹配的消息及所属会话信息。"""
from app.models.agent_session import AgentSession
# 验证 agent 存在
agent = db.query(Agent).filter(Agent.id == agent_id).first()
if not agent:
raise HTTPException(status_code=404, detail="Agent 不存在")
pattern = f"%{q.strip()}%"
# 搜索匹配的消息
total = (
db.query(ChatMessage)
.filter(
ChatMessage.agent_id == agent_id,
ChatMessage.content.like(pattern),
)
.count()
)
messages = (
db.query(ChatMessage)
.filter(
ChatMessage.agent_id == agent_id,
ChatMessage.content.like(pattern),
)
.order_by(ChatMessage.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
# 批量获取会话标题
session_ids = list(set(m.session_id for m in messages))
session_titles = {}
if session_ids:
records = db.query(AgentSession).filter(
AgentSession.id.in_(session_ids)
).all()
session_titles = {s.id: s.title for s in records if s.title}
result = [
SearchMessageItem(
id=m.id,
session_id=m.session_id,
agent_id=m.agent_id,
role=m.role,
content=m.content,
session_title=session_titles.get(m.session_id),
created_at=m.created_at.isoformat() if m.created_at else None,
)
for m in messages
]
return SearchMessagesResponse(messages=result, total=total)
def _find_agent_node_config(nodes: list) -> Dict[str, Any]:
"""从工作流节点列表中查找第一个 agent 类型或 llm 类型的节点配置。"""
if not nodes: