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:

View File

@@ -10,6 +10,7 @@ from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.api.auth import get_current_user
from app.api.deps import require_workspace_admin, WorkspaceContext
from app.core.database import get_db
from app.models.agent import Agent
from app.models.agent_schedule import AgentSchedule
@@ -162,8 +163,9 @@ async def delete_schedule(
schedule_id: str,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
_ws_admin: WorkspaceContext = Depends(require_workspace_admin),
):
"""删除定时任务。"""
"""删除定时任务(仅工作区管理员和平台管理员可操作)"""
schedule = db.query(AgentSchedule).filter(AgentSchedule.id == schedule_id).first()
if not schedule:
raise HTTPException(status_code=404, detail="定时任务不存在")
@@ -180,8 +182,9 @@ async def trigger_schedule(
schedule_id: str,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
_ws_admin: WorkspaceContext = Depends(require_workspace_admin),
):
"""手动触发一次定时任务。"""
"""手动触发一次定时任务(仅工作区管理员和平台管理员可操作)"""
schedule = db.query(AgentSchedule).filter(AgentSchedule.id == schedule_id).first()
if not schedule:
raise HTTPException(status_code=404, detail="定时任务不存在")

View File

@@ -0,0 +1,180 @@
"""
Agent 会话管理 API
提供会话重命名、置顶、删除、导出功能
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import PlainTextResponse
from pydantic import BaseModel, Field
from typing import Optional
from sqlalchemy.orm import Session
import json
from app.core.database import get_db
from app.api.auth import get_current_user
from app.api.deps import get_current_workspace_id
from app.models.user import User
from app.models.agent import Agent
from app.models.agent_session import AgentSession
from app.models.chat_message import ChatMessage
router = APIRouter(prefix="/api/v1/agent-chat", tags=["agent-sessions"])
class UpdateSessionRequest(BaseModel):
title: Optional[str] = Field(default=None, description="会话标题")
is_pinned: Optional[bool] = Field(default=None, description="是否置顶")
class SessionResponse(BaseModel):
id: str
title: Optional[str] = None
is_pinned: bool = False
created_at: Optional[str] = None
updated_at: Optional[str] = None
@router.patch("/{agent_id}/sessions/{session_id}", response_model=SessionResponse)
async def update_session(
agent_id: str,
session_id: str,
req: UpdateSessionRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
workspace_id: str = Depends(get_current_workspace_id),
):
"""更新会话信息(重命名 / 置顶)"""
agent = db.query(Agent).filter(Agent.id == agent_id).first()
if not agent:
raise HTTPException(status_code=404, detail="Agent 不存在")
session = db.query(AgentSession).filter(
AgentSession.id == session_id,
AgentSession.agent_id == agent_id,
).first()
if not session:
session = AgentSession(
id=session_id,
user_id=current_user.id,
agent_id=agent_id,
workspace_id=workspace_id or None,
)
db.add(session)
if req.title is not None:
session.title = req.title
if req.is_pinned is not None:
session.is_pinned = req.is_pinned
db.commit()
db.refresh(session)
return SessionResponse(
id=session.id,
title=session.title,
is_pinned=session.is_pinned,
created_at=session.created_at.isoformat() if session.created_at else None,
updated_at=session.updated_at.isoformat() if session.updated_at else None,
)
@router.delete("/{agent_id}/sessions/{session_id}")
async def delete_session(
agent_id: str,
session_id: str,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
workspace_id: str = Depends(get_current_workspace_id),
):
"""删除会话及所有关联消息"""
agent = db.query(Agent).filter(Agent.id == agent_id).first()
if not agent:
raise HTTPException(status_code=404, detail="Agent 不存在")
session_filter = [
AgentSession.id == session_id,
AgentSession.agent_id == agent_id,
]
if workspace_id:
session_filter.append(AgentSession.workspace_id == workspace_id)
db.query(AgentSession).filter(*session_filter).delete()
msg_filter = [
ChatMessage.agent_id == agent_id,
ChatMessage.session_id == session_id,
]
if workspace_id:
msg_filter.append(ChatMessage.workspace_id == workspace_id)
deleted_count = db.query(ChatMessage).filter(*msg_filter).delete()
db.commit()
return {"deleted": True, "messages_deleted": deleted_count}
@router.get("/{agent_id}/sessions/{session_id}/export")
async def export_session(
agent_id: str,
session_id: str,
format: str = Query("json", pattern="^(json|markdown)$"),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
workspace_id: str = Depends(get_current_workspace_id),
):
"""导出会话为 JSON 或 Markdown 格式"""
agent = db.query(Agent).filter(Agent.id == agent_id).first()
if not agent:
raise HTTPException(status_code=404, detail="Agent 不存在")
msg_query = db.query(ChatMessage).filter(
ChatMessage.agent_id == agent_id,
ChatMessage.session_id == session_id,
)
if workspace_id:
msg_query = msg_query.filter(ChatMessage.workspace_id == workspace_id)
messages = msg_query.order_by(ChatMessage.created_at.asc()).all()
if not messages:
raise HTTPException(status_code=404, detail="会话无消息")
# 获取会话标题
session = db.query(AgentSession).filter(AgentSession.id == session_id).first()
title = session.title if session and session.title else "对话"
if format == "markdown":
lines = [f"# {title}", "", f"**Agent**: {agent.name}", f"**时间**: {messages[0].created_at.isoformat() if messages[0].created_at else 'N/A'}", "", "---", ""]
for m in messages:
role_label = "用户" if m.role == "user" else "AI 助手" if m.role == "assistant" else m.role
lines.append(f"### {role_label}")
if m.content:
lines.append(m.content)
if m.tool_name:
lines.append(f"\n> 工具调用: `{m.tool_name}`")
if m.tool_output:
lines.append(f"\n<details><summary>工具输出</summary>\n\n```\n{m.tool_output[:500]}\n```\n</details>")
lines.append("")
return PlainTextResponse("\n".join(lines), media_type="text/markdown")
else:
# JSON
export_data = {
"title": title,
"agent_name": agent.name,
"agent_id": agent_id,
"session_id": session_id,
"exported_at": messages[0].created_at.isoformat() if messages[0].created_at else None,
"messages": [
{
"role": m.role,
"content": m.content,
"tool_name": m.tool_name,
"tool_input": m.tool_input,
"tool_output": m.tool_output,
"created_at": m.created_at.isoformat() if m.created_at else None,
}
for m in messages
],
}
return export_data

View File

@@ -11,6 +11,7 @@ import logging
from app.core.database import get_db
from app.models.agent import Agent
from app.api.auth import get_current_user
from app.api.deps import require_workspace_admin, WorkspaceContext
from app.models.user import User
from app.core.exceptions import NotFoundError, ValidationError, ConflictError
from app.services.permission_service import check_agent_permission
@@ -385,10 +386,11 @@ async def update_agent(
async def delete_agent(
agent_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
current_user: User = Depends(get_current_user),
_ws_admin: WorkspaceContext = Depends(require_workspace_admin),
):
"""
删除Agent只有所有者可以删除
删除Agent仅工作区管理员和平台管理员可操作
"""
agent = db.query(Agent).filter(Agent.id == agent_id).first()
@@ -435,11 +437,12 @@ async def delete_agent(
async def deploy_agent(
agent_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
current_user: User = Depends(get_current_user),
_ws_admin: WorkspaceContext = Depends(require_workspace_admin),
):
"""
部署Agent
部署Agent(仅工作区管理员和平台管理员可操作)
将Agent状态设置为published
"""
agent = db.query(Agent).filter(Agent.id == agent_id).first()
@@ -463,11 +466,12 @@ async def deploy_agent(
async def stop_agent(
agent_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
current_user: User = Depends(get_current_user),
_ws_admin: WorkspaceContext = Depends(require_workspace_admin),
):
"""
停止Agent
停止Agent(仅工作区管理员和平台管理员可操作)
将Agent状态设置为stopped
"""
agent = db.query(Agent).filter(Agent.id == agent_id).first()
@@ -601,8 +605,9 @@ async def create_main_agent(
agent_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_ws_admin: WorkspaceContext = Depends(require_workspace_admin),
):
"""一键将当前 Agent 升级为 Main Agent预置专用工具 + 系统提示词"""
"""一键将当前 Agent 升级为 Main Agent仅工作区管理员和平台管理员可操作"""
agent = db.query(Agent).filter(Agent.id == agent_id).first()
if not agent:
raise NotFoundError(f"Agent不存在: {agent_id}")

View File

@@ -10,6 +10,7 @@ import logging
from app.core.database import get_db
from app.models.alert_rule import AlertRule, AlertLog
from app.api.auth import get_current_user
from app.api.deps import get_current_workspace_id
from app.models.user import User
from app.core.exceptions import NotFoundError, ValidationError, ConflictError
from app.services.alert_service import AlertService
@@ -102,14 +103,17 @@ async def get_alert_rules(
alert_type: Optional[str] = Query(None, description="告警类型筛选"),
enabled: Optional[bool] = Query(None, description="是否启用筛选"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
current_user: User = Depends(get_current_user),
workspace_id: str = Depends(get_current_workspace_id),
):
"""
获取告警规则列表
支持分页和筛选
"""
query = db.query(AlertRule).filter(AlertRule.user_id == current_user.id)
if workspace_id:
query = query.filter(AlertRule.workspace_id == workspace_id)
# 筛选
if alert_type:
@@ -126,7 +130,8 @@ async def get_alert_rules(
async def create_alert_rule(
rule_data: AlertRuleCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
current_user: User = Depends(get_current_user),
workspace_id: str = Depends(get_current_workspace_id),
):
"""
创建告警规则
@@ -165,7 +170,8 @@ async def create_alert_rule(
notification_type=rule_data.notification_type,
notification_config=rule_data.notification_config,
enabled=rule_data.enabled,
user_id=current_user.id
user_id=current_user.id,
workspace_id=workspace_id or None,
)
db.add(alert_rule)
db.commit()

View File

@@ -0,0 +1,229 @@
"""
数据分析 API — 从 user_behavior_logs 表聚合
提供概览、日活趋势、Agent 排行榜
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from typing import Optional, List, Any, Dict
from sqlalchemy.orm import Session
from sqlalchemy import func as sa_func, text, desc
from datetime import datetime, timedelta
from app.core.database import get_db
from app.api.auth import get_current_user
from app.models.user import User
from app.models.user_behavior import UserBehaviorLog
from app.models.chat_message import ChatMessage
router = APIRouter(prefix="/api/v1/analytics", tags=["analytics"])
# ─────────── Response Models ───────────
class OverviewResponse(BaseModel):
total_users: int = 0
active_users_today: int = 0
messages_today: int = 0
sessions_today: int = 0
class DailyStatItem(BaseModel):
date: str
active_users: int = 0
messages: int = 0
new_registrations: int = 0
class DailyStatsResponse(BaseModel):
stats: List[DailyStatItem]
class TopAgentItem(BaseModel):
agent_id: str
message_count: int = 0
session_count: int = 0
class TopAgentsResponse(BaseModel):
agents: List[TopAgentItem]
# ─────────── Endpoints ───────────
@router.get("/overview", response_model=OverviewResponse)
async def get_overview(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""运营概览:总用户数、今日活跃、今日消息数/会话数"""
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
total_users = db.query(sa_func.count(User.id)).scalar() or 0
active_users_today = db.query(sa_func.count(sa_func.distinct(UserBehaviorLog.user_id))).filter(
UserBehaviorLog.created_at >= today
).scalar() or 0
messages_today = db.query(sa_func.count(ChatMessage.id)).filter(
ChatMessage.created_at >= today
).scalar() or 0
sessions_today = db.query(sa_func.count(sa_func.distinct(ChatMessage.session_id))).filter(
ChatMessage.created_at >= today
).scalar() or 0
return OverviewResponse(
total_users=total_users,
active_users_today=active_users_today,
messages_today=messages_today,
sessions_today=sessions_today,
)
@router.get("/daily-stats", response_model=DailyStatsResponse)
async def get_daily_stats(
days: int = Query(default=30, ge=1, le=365),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""每日趋势UV、消息数、新注册数"""
since = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=days - 1)
# 每日活跃用户
uv_rows = (
db.query(
sa_func.date(UserBehaviorLog.created_at).label("date"),
sa_func.count(sa_func.distinct(UserBehaviorLog.user_id)).label("uv"),
)
.filter(UserBehaviorLog.created_at >= since)
.group_by(text("date"))
.order_by("date")
.all()
)
uv_map = {str(row.date): row.uv for row in uv_rows}
# 每日消息
msg_rows = (
db.query(
sa_func.date(ChatMessage.created_at).label("date"),
sa_func.count(ChatMessage.id).label("cnt"),
)
.filter(ChatMessage.created_at >= since)
.group_by(text("date"))
.order_by("date")
.all()
)
msg_map = {str(row.date): row.cnt for row in msg_rows}
# 每日新注册
reg_rows = (
db.query(
sa_func.date(User.created_at).label("date"),
sa_func.count(User.id).label("cnt"),
)
.filter(User.created_at >= since)
.group_by(text("date"))
.order_by("date")
.all()
)
reg_map = {str(row.date): row.cnt for row in reg_rows}
# 构建日期序列
stats = []
for i in range(days):
d = (since + timedelta(days=i)).strftime("%Y-%m-%d")
stats.append(DailyStatItem(
date=d,
active_users=uv_map.get(d, 0),
messages=msg_map.get(d, 0),
new_registrations=reg_map.get(d, 0),
))
return DailyStatsResponse(stats=stats)
@router.get("/top-agents", response_model=TopAgentsResponse)
async def get_top_agents(
days: int = Query(default=30, ge=1, le=365),
limit: int = Query(default=10, ge=1, le=100),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""最活跃 Agent 排行"""
since = datetime.now() - timedelta(days=days)
rows = (
db.query(
ChatMessage.agent_id,
sa_func.count(ChatMessage.id).label("message_count"),
sa_func.count(sa_func.distinct(ChatMessage.session_id)).label("session_count"),
)
.filter(
ChatMessage.created_at >= since,
ChatMessage.agent_id.isnot(None),
)
.group_by(ChatMessage.agent_id)
.order_by(desc("message_count"))
.limit(limit)
.all()
)
agents = [
TopAgentItem(
agent_id=row.agent_id,
message_count=row.message_count,
session_count=row.session_count,
)
for row in rows
]
return TopAgentsResponse(agents=agents)
# ─────────── Mobile Events (v1.1.0) ───────────
class AnalyticsEventItem(BaseModel):
event_type: str = Field(..., description="事件类型: screen_view / user_action")
screen_name: Optional[str] = None
category: Optional[str] = None
action: Optional[str] = None
label: Optional[str] = None
agent_id: Optional[str] = None
timestamp: Optional[str] = None
class AnalyticsEventsRequest(BaseModel):
events: List[AnalyticsEventItem]
@router.post("/events")
async def receive_events(
req: AnalyticsEventsRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""接收移动端批量埋点事件"""
count = 0
for evt in req.events:
try:
t = datetime.fromisoformat(evt.timestamp.replace("Z", "+00:00")) if evt.timestamp else datetime.now()
except Exception:
t = datetime.now()
log = UserBehaviorLog(
user_id=current_user.id,
category=evt.category or "mobile",
action=evt.action or evt.event_type,
context={
"screen": evt.screen_name,
"label": evt.label,
"agent_id": evt.agent_id,
},
source="android",
created_at=t,
)
db.add(log)
count += 1
db.commit()
return {"received": count}

View File

@@ -585,19 +585,256 @@ async def bind_phone(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""绑定或修改手机号。"""
"""绑定或修改手机号(需验证码校验)"""
redis = get_redis_client()
# 必须提供验证码
if not body.code:
raise HTTPException(status_code=400, detail="请提供短信验证码")
# 校验验证码
if not _verify_sms_code(redis, body.phone, body.code):
raise HTTPException(status_code=400, detail="验证码错误或已过期")
# 检查手机号是否已被其他用户绑定
existing = db.query(User).filter(User.phone == body.phone, User.id != current_user.id).first()
if existing:
raise ConflictError("该手机号已被其他用户绑定")
current_user.phone = body.phone
current_user.phone_verified = True
current_user.phone_verified_at = datetime.utcnow()
db.commit()
logger.info("用户 %s 绑定手机号成功", current_user.username)
return {"message": "手机号绑定成功", "phone": body.phone}
# ─── 手机验证码v1.1.0)────────────────────────────────────
SMS_CODE_TTL_SEC = 600 # 验证码 10 分钟有效
SMS_RATE_LIMIT_SEC = 60 # 同一手机号 60 秒内只能发一次
class SendSmsCodeRequest(BaseModel):
phone: str
@field_validator("phone")
@classmethod
def phone_format(cls, v: str) -> str:
if not re.match(r"^1[3-9]\d{9}$", v):
raise ValueError("手机号格式无效")
return v
class VerifyPhoneRequest(BaseModel):
phone: str
code: str
@field_validator("phone")
@classmethod
def phone_format(cls, v: str) -> str:
if not re.match(r"^1[3-9]\d{9}$", v):
raise ValueError("手机号格式无效")
return v
class PhoneLoginRequest(BaseModel):
phone: str
code: str
@field_validator("phone")
@classmethod
def phone_format(cls, v: str) -> str:
if not re.match(r"^1[3-9]\d{9}$", v):
raise ValueError("手机号格式无效")
return v
def _gen_sms_code() -> str:
"""生成 6 位数字验证码"""
return str(secrets.randbelow(900000) + 100000)
async def _send_sms_code(phone: str, code: str) -> bool:
"""发送短信验证码。返回 True 表示发送成功。"""
from app.core.sms_service import get_sms_provider
provider = get_sms_provider()
try:
return await provider.send(phone, code)
except Exception as e:
logger.warning("SMS 发送异常: %s", e)
return False
@router.post("/phone/send-code")
async def send_phone_code(body: SendSmsCodeRequest):
"""发送手机验证码。"""
redis = get_redis_client()
# 频率限制
rate_key = f"sms_rate:{body.phone}"
if redis:
if redis.exists(rate_key):
ttl = redis.ttl(rate_key)
raise HTTPException(
status_code=429,
detail=f"操作过于频繁,请 {ttl} 秒后重试"
)
code = _gen_sms_code()
# 存储到 Redis
code_key = f"sms_code:{body.phone}"
if redis:
redis.setex(code_key, SMS_CODE_TTL_SEC, code)
redis.setex(rate_key, SMS_RATE_LIMIT_SEC, "1")
else:
# 无 Redis 时回退内存存储
if not hasattr(send_phone_code, "_memory_store"):
send_phone_code._memory_store = {}
send_phone_code._memory_rate = {}
send_phone_code._memory_store[body.phone] = {
"code": code,
"expires_at": datetime.utcnow() + timedelta(seconds=SMS_CODE_TTL_SEC),
}
send_phone_code._memory_rate[body.phone] = \
datetime.utcnow() + timedelta(seconds=SMS_RATE_LIMIT_SEC)
# 发送验证码
sent = await _send_sms_code(body.phone, code)
if not sent:
# Mock 模式或发送失败时返回验证码(仅开发环境)
logger.info("开发模式:%s 的短信验证码为 %s", body.phone, code)
return {
"message": "验证码已生成Mock 模式)",
"dev_code": code,
}
return {"message": "验证码已发送"}
def _verify_sms_code(redis, phone: str, code: str) -> bool:
"""校验短信验证码(从 Redis 或内存存储中读取)。"""
code_key = f"sms_code:{phone}"
stored_code = None
if redis:
stored_code_raw = redis.get(code_key)
stored_code = stored_code_raw.decode() if isinstance(stored_code_raw, bytes) else stored_code_raw
elif hasattr(send_phone_code, "_memory_store"):
entry = send_phone_code._memory_store.get(phone, {})
if entry and entry.get("expires_at", datetime.min) > datetime.utcnow():
stored_code = entry.get("code")
if not stored_code:
return False
if stored_code != code.strip():
return False
# 验证通过后清除
if redis:
redis.delete(code_key)
elif hasattr(send_phone_code, "_memory_store"):
send_phone_code._memory_store.pop(phone, None)
return True
@router.post("/phone/verify")
async def verify_phone(
body: VerifyPhoneRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""验证手机号(绑定后验证)。"""
redis = get_redis_client()
if not _verify_sms_code(redis, body.phone, body.code):
raise HTTPException(status_code=400, detail="验证码错误或已过期")
# 检查手机号是否已被其他用户绑定
existing = db.query(User).filter(
User.phone == body.phone, User.id != current_user.id
).first()
if existing:
raise HTTPException(status_code=400, detail="该手机号已被其他用户绑定")
current_user.phone = body.phone
current_user.phone_verified = True
current_user.phone_verified_at = datetime.utcnow()
db.commit()
logger.info("用户 %s 手机号 %s 验证成功", current_user.username, body.phone)
return {"message": "手机号验证成功", "phone": body.phone}
@router.post("/login/phone", response_model=Token)
async def phone_login(
body: PhoneLoginRequest,
db: Session = Depends(get_db),
):
"""手机号+验证码登录。用户不存在则自动注册。"""
redis = get_redis_client()
if not _verify_sms_code(redis, body.phone, body.code):
raise HTTPException(status_code=400, detail="验证码错误或已过期")
user = db.query(User).filter(User.phone == body.phone).first()
if not user:
# 自动注册:用手机号生成用户名
username_base = f"user_{body.phone[-6:]}"
username = username_base
counter = 1
while db.query(User).filter(User.username == username).first():
username = f"{username_base}_{counter}"
counter += 1
user = User(
username=username,
email=f"{body.phone}@phone.local",
password_hash=get_password_hash(secrets.token_urlsafe(16)),
phone=body.phone,
phone_verified=True,
phone_verified_at=datetime.utcnow(),
)
db.add(user)
db.commit()
db.refresh(user)
logger.info("手机号自动注册: %s%s", body.phone, username)
else:
# 如果手机号还没标记为已验证,更新
if not user.phone_verified:
user.phone_verified = True
user.phone_verified_at = datetime.utcnow()
db.commit()
if user.status == "deleted":
raise HTTPException(status_code=403, detail="该账号已注销")
ws_id = _get_user_default_workspace_id(db, user)
access_token = create_access_token(
data={"sub": user.id, "username": user.username, "ws": ws_id or ""},
expires_delta=timedelta(minutes=settings.JWT_MOBILE_TOKEN_EXPIRE_MINUTES),
)
refresh_token = create_refresh_token(
user_id=user.id,
username=user.username,
workspace_id=ws_id or "",
)
return {
"access_token": access_token,
"token_type": "bearer",
"refresh_token": refresh_token,
}
async def get_optional_user(
token: str | None = Depends(oauth2_scheme_optional),
db: Session = Depends(get_db)

303
backend/app/api/billing.py Normal file
View File

@@ -0,0 +1,303 @@
"""
付费体系 API套餐、订阅、订单、用量
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from typing import Optional, List
from sqlalchemy.orm import Session
from datetime import date, datetime, timedelta
from app.core.database import get_db
from app.api.auth import get_current_user
from app.models.user import User
from app.models.billing import BillingPlan, BillingOrder, UserSubscription, UsageRecord
router = APIRouter(prefix="/api/v1/billing", tags=["billing"])
# ─── Pydantic Models ───
class PlanResponse(BaseModel):
id: str
name: str
tier: str
price_monthly: float
price_yearly: float
daily_quota: int
agent_limit: int
knowledge_limit: int
file_upload_mb: int = 5
models: Optional[list] = None
features: Optional[list] = None
is_active: bool = True
sort_order: int = 0
class Config:
from_attributes = True
class SubscriptionResponse(BaseModel):
tier: str = "free"
status: str = "active"
plan_name: Optional[str] = None
started_at: Optional[str] = None
expires_at: Optional[str] = None
auto_renew: bool = False
daily_quota: int = 50
agent_limit: int = 3
knowledge_limit: int = 10
file_upload_mb: int = 5
models: Optional[list] = None
features: Optional[list] = None
class UsageResponse(BaseModel):
daily_used: int = 0
daily_limit: int = 50
daily_remaining: int = 50
is_exhausted: bool = False
class CreateOrderRequest(BaseModel):
plan_id: str = Field(..., description="套餐ID")
period: str = Field(default="monthly", description="周期: monthly/yearly")
payment_method: str = Field(default="mock", description="支付方式: mock/wechat/alipay")
class OrderResponse(BaseModel):
id: str
plan_id: Optional[str] = None
amount: float
period: str
status: str
payment_method: Optional[str] = None
payment_ref: Optional[str] = None
created_at: Optional[str] = None
paid_at: Optional[str] = None
class Config:
from_attributes = True
# ─── Helpers ───
QUOTA_DEFAULTS = {
"free": {"daily_quota": 50, "agent_limit": 3, "knowledge_limit": 10, "file_upload_mb": 5},
"pro": {"daily_quota": 500, "agent_limit": 20, "knowledge_limit": 500, "file_upload_mb": 50},
"enterprise": {"daily_quota": 0, "agent_limit": 0, "knowledge_limit": 0, "file_upload_mb": 200},
}
def _get_user_subscription_info(db: Session, user: User) -> SubscriptionResponse:
"""获取用户当前订阅信息(套餐+权益)"""
sub = db.query(UserSubscription).filter(UserSubscription.user_id == user.id).first()
tier = sub.tier if sub else (user.subscription_tier or "free")
plan = db.query(BillingPlan).filter(BillingPlan.id == sub.plan_id).first() if (sub and sub.plan_id) else None
quota = QUOTA_DEFAULTS.get(tier, QUOTA_DEFAULTS["free"])
return SubscriptionResponse(
tier=tier,
status=sub.status if sub else "active",
plan_name=plan.name if plan else (sub.tier if sub else "免费版"),
started_at=sub.started_at.isoformat() if (sub and sub.started_at) else None,
expires_at=sub.expires_at.isoformat() if (sub and sub.expires_at) else None,
auto_renew=sub.auto_renew if sub else False,
daily_quota=plan.daily_quota if plan else quota["daily_quota"],
agent_limit=plan.agent_limit if plan else quota["agent_limit"],
knowledge_limit=plan.knowledge_limit if plan else quota["knowledge_limit"],
file_upload_mb=plan.file_upload_mb if plan else quota["file_upload_mb"],
models=plan.models if plan else None,
features=plan.features if plan else None,
)
def _get_daily_limit(tier: str) -> int:
plan = QUOTA_DEFAULTS.get(tier)
return plan["daily_quota"] if plan else 50
# ─── Endpoints ───
@router.get("/plans", response_model=List[PlanResponse])
async def list_plans(
db: Session = Depends(get_db),
):
"""获取所有可用套餐"""
return db.query(BillingPlan).filter(BillingPlan.is_active == True).order_by(BillingPlan.sort_order.asc()).all()
@router.get("/user/subscription", response_model=SubscriptionResponse)
async def get_user_subscription(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""获取当前用户订阅状态与权益"""
return _get_user_subscription_info(db, current_user)
@router.get("/user/usage", response_model=UsageResponse)
async def get_user_usage(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""获取当前用户今日用量"""
today = date.today()
sub = db.query(UserSubscription).filter(UserSubscription.user_id == current_user.id).first()
tier = sub.tier if sub else (current_user.subscription_tier or "free")
daily_limit = _get_daily_limit(tier)
# 如果日期变了,重置计数
if current_user.daily_usage_date and current_user.daily_usage_date < today:
current_user.daily_usage_count = 0
current_user.daily_usage_date = today
db.commit()
used = current_user.daily_usage_count or 0
remaining = max(0, daily_limit - used) if daily_limit > 0 else 999999
return UsageResponse(
daily_used=used,
daily_limit=daily_limit,
daily_remaining=remaining,
is_exhausted=daily_limit > 0 and used >= daily_limit,
)
@router.post("/orders", response_model=OrderResponse)
async def create_order(
req: CreateOrderRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""创建订单"""
plan = db.query(BillingPlan).filter(BillingPlan.id == req.plan_id, BillingPlan.is_active == True).first()
if not plan:
raise HTTPException(status_code=404, detail="套餐不存在")
if plan.tier == "free":
raise HTTPException(status_code=400, detail="免费套餐无需购买")
amount = plan.price_yearly if req.period == "yearly" else plan.price_monthly
if amount <= 0:
raise HTTPException(status_code=400, detail="套餐价格无效")
order = BillingOrder(
user_id=current_user.id,
plan_id=plan.id,
amount=amount,
period=req.period,
status="pending",
payment_method=req.payment_method,
)
db.add(order)
db.commit()
db.refresh(order)
return OrderResponse(
id=order.id,
plan_id=order.plan_id,
amount=order.amount,
period=order.period,
status=order.status,
payment_method=order.payment_method,
payment_ref=order.payment_ref,
created_at=order.created_at.isoformat() if order.created_at else None,
paid_at=order.paid_at.isoformat() if order.paid_at else None,
)
@router.get("/orders/{order_id}", response_model=OrderResponse)
async def get_order(
order_id: str,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""查询订单状态"""
order = db.query(BillingOrder).filter(
BillingOrder.id == order_id,
BillingOrder.user_id == current_user.id,
).first()
if not order:
raise HTTPException(status_code=404, detail="订单不存在")
return OrderResponse(
id=order.id,
plan_id=order.plan_id,
amount=order.amount,
period=order.period,
status=order.status,
payment_method=order.payment_method,
payment_ref=order.payment_ref,
created_at=order.created_at.isoformat() if order.created_at else None,
paid_at=order.paid_at.isoformat() if order.paid_at else None,
)
@router.post("/orders/{order_id}/pay")
async def pay_order(
order_id: str,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""模拟支付Phase 4 替换为真实支付)"""
order = db.query(BillingOrder).filter(
BillingOrder.id == order_id,
BillingOrder.user_id == current_user.id,
).first()
if not order:
raise HTTPException(status_code=404, detail="订单不存在")
if order.status != "pending":
raise HTTPException(status_code=400, detail="订单状态不允许支付")
plan = db.query(BillingPlan).filter(BillingPlan.id == order.plan_id).first()
if not plan:
raise HTTPException(status_code=404, detail="套餐不存在")
# 计算到期时间
days = 365 if order.period == "yearly" else 30
now = datetime.utcnow()
expires_at = now + timedelta(days=days)
# 更新订单状态
order.status = "paid"
order.paid_at = now
order.payment_ref = f"mock_{order.id}"
# 创建或更新订阅
sub = db.query(UserSubscription).filter(UserSubscription.user_id == current_user.id).first()
if not sub:
sub = UserSubscription(user_id=current_user.id)
db.add(sub)
sub.plan_id = plan.id
sub.tier = plan.tier
sub.status = "active"
sub.started_at = now
sub.expires_at = expires_at
# 更新用户模型
current_user.subscription_tier = plan.tier
current_user.subscription_expires_at = expires_at
current_user.daily_usage_count = 0
current_user.daily_usage_date = date.today()
db.commit()
return {
"success": True,
"tier": plan.tier,
"expires_at": expires_at.isoformat(),
"message": f"已升级到 {plan.name},有效期至 {expires_at.strftime('%Y-%m-%d')}",
}
@router.post("/callback/wechat")
async def wechat_callback():
"""微信支付回调Phase 4 实现)"""
return {"code": "SUCCESS", "message": "not implemented yet"}
@router.post("/callback/alipay")
async def alipay_callback():
"""支付宝回调Phase 4 实现)"""
return {"code": "SUCCESS", "message": "not implemented yet"}

View File

@@ -6,6 +6,7 @@ from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends, HTTPExce
from sqlalchemy.orm import Session
from app.core.database import get_db, SessionLocal
from app.models.workflow import Workflow
from app.models.workspace import WorkspaceMembership
from app.api.auth import get_current_user
from app.models.user import User
from app.websocket.collaboration_manager import collaboration_manager
@@ -79,6 +80,25 @@ async def websocket_collaboration(
await websocket.close(code=status.WS_1008_POLICY_VIOLATION, reason="工作流不存在")
return
# 检查 workspace 成员资格
ws_id = payload.get("ws", "")
if ws_id and user.role != "admin":
membership = (
db.query(WorkspaceMembership)
.filter(
WorkspaceMembership.workspace_id == ws_id,
WorkspaceMembership.user_id == user.id,
)
.first()
)
if not membership:
await websocket.close(code=status.WS_1008_POLICY_VIOLATION, reason="无权访问此工作区")
return
# 验证 workflow 属于该 workspace
if workflow.workspace_id and workflow.workspace_id != ws_id:
await websocket.close(code=status.WS_1008_POLICY_VIOLATION, reason="无权编辑此工作流")
return
# 检查权限(只有工作流所有者可以协作编辑,或者未来可以扩展权限)
# 暂时只允许所有者编辑
if workflow.user_id != user.id:

View File

@@ -10,6 +10,7 @@ import logging
from app.core.database import get_db
from app.models.data_source import DataSource
from app.api.auth import get_current_user
from app.api.deps import require_workspace_admin, WorkspaceContext
from app.models.user import User
from app.core.exceptions import NotFoundError, ValidationError
from app.services.data_source_connector import DataSourceConnector
@@ -163,9 +164,10 @@ async def update_data_source(
async def delete_data_source(
data_source_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
current_user: User = Depends(get_current_user),
_ws_admin: WorkspaceContext = Depends(require_workspace_admin),
):
"""删除数据源"""
"""删除数据源(仅工作区管理员和平台管理员可操作)"""
data_source = db.query(DataSource).filter(
DataSource.id == data_source_id,
DataSource.user_id == current_user.id

View File

@@ -1,7 +1,8 @@
"""
FastAPI 依赖注入 — Workspace 上下文、权限检查
FastAPI 依赖注入 — Workspace 上下文、RBAC 权限检查
"""
from dataclasses import dataclass
import logging
from typing import Optional
from fastapi import Depends, HTTPException, Request
@@ -10,6 +11,9 @@ from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.security import decode_access_token
from app.models.user import User
from app.models.workspace import WorkspaceMembership
logger = logging.getLogger(__name__)
@dataclass
@@ -41,7 +45,12 @@ def get_workspace_context(
request: Request,
db: Session = Depends(get_db),
) -> WorkspaceContext:
"""获取完整的 Workspace 上下文(用户 + 工作区),用于需要 workspace 过滤的接口。"""
"""获取完整的 Workspace 上下文(用户 + 工作区)
注意:此函数仅认证用户并提取 workspace_id不验证 workspace 成员资格。
用于跨 workspace 或 admin 端点。需要强制成员资格验证的端点请使用
get_workspace_membership()。
"""
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
raise HTTPException(status_code=401, detail="未提供有效的认证令牌")
@@ -61,4 +70,128 @@ def get_workspace_context(
if not user:
raise HTTPException(status_code=401, detail="用户不存在")
if user.status == "deleted":
raise HTTPException(status_code=403, detail="账号已注销")
return WorkspaceContext(user=user, workspace_id=ws_id or "")
# ─── 多租户 RBAC 依赖 (v1.3) ───
def get_workspace_membership(
request: Request,
db: Session = Depends(get_db),
) -> WorkspaceContext:
"""认证用户 + 验证 workspace 成员资格。
平台管理员user.role == 'admin')绕过 workspace 成员资格检查。
普通用户必须属于 JWT 中 `ws` 指定的 workspace。
"""
ctx = get_workspace_context(request, db)
# 平台管理员可访问所有 workspace
if ctx.user.role == "admin":
return ctx
if not ctx.workspace_id:
raise HTTPException(status_code=400, detail="未选择工作区")
membership = (
db.query(WorkspaceMembership)
.filter(
WorkspaceMembership.workspace_id == ctx.workspace_id,
WorkspaceMembership.user_id == ctx.user.id,
)
.first()
)
if not membership:
logger.warning("用户 %s 尝试访问未加入的工作区 %s", ctx.user.id, ctx.workspace_id)
raise HTTPException(status_code=403, detail="无权访问此工作区")
return ctx
async def require_workspace_admin(
ctx: WorkspaceContext = Depends(get_workspace_membership),
db: Session = Depends(get_db),
) -> WorkspaceContext:
"""要求 workspace 管理员或平台管理员权限。
用于删除/发布/修改工作区设置等敏感操作。
"""
# 平台管理员
if ctx.user.role == "admin":
return ctx
# workspace 管理员
membership = (
db.query(WorkspaceMembership)
.filter(
WorkspaceMembership.workspace_id == ctx.workspace_id,
WorkspaceMembership.user_id == ctx.user.id,
)
.first()
)
if not membership or membership.role != "admin":
raise HTTPException(status_code=403, detail="需要工作区管理员权限")
return ctx
class WorkspacePermission:
"""细粒度权限检查依赖 — 桥接系统 RBAC 与 workspace 上下文。
用法:
@router.delete("/agents/{id}")
async def delete_agent(
...,
ctx: WorkspaceContext = Depends(WorkspacePermission("agent:delete")),
):
"""
def __init__(self, permission_code: str):
self.permission_code = permission_code
async def __call__(
self,
ctx: WorkspaceContext = Depends(get_workspace_membership),
db: Session = Depends(get_db),
) -> WorkspaceContext:
# 平台管理员总是通过
if ctx.user.role == "admin":
return ctx
# workspace 管理员拥有该 workspace 内所有权限
membership = (
db.query(WorkspaceMembership)
.filter(
WorkspaceMembership.workspace_id == ctx.workspace_id,
WorkspaceMembership.user_id == ctx.user.id,
)
.first()
)
if membership and membership.role == "admin":
return ctx
# 检查系统级 RBAC 权限
if _user_has_permission(db, ctx.user.id, self.permission_code):
return ctx
raise HTTPException(status_code=403, detail=f"缺少权限: {self.permission_code}")
def _user_has_permission(db: Session, user_id: str, permission_code: str) -> bool:
"""检查用户是否拥有指定权限(通过系统级 RBAC"""
from app.models.permission import Role, Permission
from app.models.user import User
result = (
db.query(Permission)
.join(Permission.roles)
.join(Role.users)
.filter(
Permission.code == permission_code,
User.id == user_id,
)
.first()
)
return result is not None

View File

@@ -13,6 +13,7 @@ from sqlalchemy.orm import Session
from app.core.database import get_db
from app.api.auth import get_current_user
from app.api.deps import get_current_workspace_id
from app.models.user import User
from app.models.knowledge_entry import KnowledgeEntry
from app.services.bottleneck_detector import bottleneck_detector
@@ -53,19 +54,17 @@ def get_knowledge_entries(
limit: int = Query(50, ge=1, le=200, description="返回条数"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
workspace_id: str = Depends(get_current_workspace_id),
):
"""获取知识条目列表,按创建时间倒序。"""
"""获取当前工作区知识条目列表,按创建时间倒序。"""
since = datetime.now() - timedelta(days=days)
entries = (
db.query(KnowledgeEntry)
.filter(
KnowledgeEntry.created_at >= since,
KnowledgeEntry.is_active == True,
)
.order_by(KnowledgeEntry.created_at.desc())
.limit(limit)
.all()
q = db.query(KnowledgeEntry).filter(
KnowledgeEntry.created_at >= since,
KnowledgeEntry.is_active == True,
)
if workspace_id:
q = q.filter(KnowledgeEntry.workspace_id == workspace_id)
entries = q.order_by(KnowledgeEntry.created_at.desc()).limit(limit).all()
return [e.to_dict() for e in entries]
@@ -74,24 +73,24 @@ def get_knowledge_trend(
days: int = Query(7, ge=1, le=365, description="统计天数"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
workspace_id: str = Depends(get_current_workspace_id),
):
"""知识条目增长趋势 — 按天统计新增数量。"""
"""知识条目增长趋势(当前工作区)— 按天统计新增数量。"""
since = datetime.now() - timedelta(days=days)
# GROUP BY date(created_at)
rows = (
db.query(
func.date(KnowledgeEntry.created_at).label("date"),
func.count(KnowledgeEntry.id).label("count"),
)
.filter(
KnowledgeEntry.created_at >= since,
KnowledgeEntry.is_active == True,
)
.group_by(func.date(KnowledgeEntry.created_at))
.order_by(func.date(KnowledgeEntry.created_at).asc())
.all()
q = db.query(
func.date(KnowledgeEntry.created_at).label("date"),
func.count(KnowledgeEntry.id).label("count"),
).filter(
KnowledgeEntry.created_at >= since,
KnowledgeEntry.is_active == True,
)
if workspace_id:
q = q.filter(KnowledgeEntry.workspace_id == workspace_id)
rows = q.group_by(func.date(KnowledgeEntry.created_at)).order_by(
func.date(KnowledgeEntry.created_at).asc()
).all()
# Fill in missing dates with 0 count
trend = []

View File

@@ -10,6 +10,7 @@ import logging
from app.core.database import get_db
from app.models.model_config import ModelConfig
from app.api.auth import get_current_user
from app.api.deps import require_workspace_admin, WorkspaceContext
from app.models.user import User
from app.core.exceptions import NotFoundError, ValidationError, ConflictError
from app.services.encryption_service import EncryptionService
@@ -207,10 +208,11 @@ async def update_model_config(
async def delete_model_config(
config_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
current_user: User = Depends(get_current_user),
_ws_admin: WorkspaceContext = Depends(require_workspace_admin),
):
"""
删除模型配置
删除模型配置(仅工作区管理员和平台管理员可操作)
"""
config = db.query(ModelConfig).filter(
ModelConfig.id == config_id,

View File

@@ -11,6 +11,7 @@ import logging
from app.core.database import get_db
from app.api.auth import get_current_user
from app.api.deps import get_current_workspace_id
from app.models.user import User
from app.models.plugin import NodePlugin
from app.services.plugin_loader import (
@@ -202,11 +203,14 @@ async def create_plugin(
async def get_plugin(
plugin_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""获取插件详情。"""
"""获取插件详情(仅所有者或公开插件可查看)"""
plugin = db.query(NodePlugin).filter(NodePlugin.id == plugin_id).first()
if not plugin:
raise HTTPException(status_code=404, detail="插件不存在")
if not plugin.is_public and plugin.user_id and plugin.user_id != current_user.id:
raise HTTPException(status_code=403, detail="无权查看此插件")
return plugin
@@ -222,7 +226,7 @@ async def update_plugin(
plugin = db.query(NodePlugin).filter(NodePlugin.id == plugin_id).first()
if not plugin:
raise HTTPException(status_code=404, detail="插件不存在")
if plugin.user_id and plugin.user_id != current_user.id:
if plugin.user_id != current_user.id:
raise HTTPException(status_code=403, detail="无权修改此插件")
for field, value in body.dict(exclude_unset=True).items():
@@ -251,7 +255,7 @@ async def delete_plugin(
plugin = db.query(NodePlugin).filter(NodePlugin.id == plugin_id).first()
if not plugin:
raise HTTPException(status_code=404, detail="插件不存在")
if plugin.user_id and plugin.user_id != current_user.id:
if plugin.user_id != current_user.id:
raise HTTPException(status_code=403, detail="无权删除此插件")
unload_plugin_code(plugin.id)
@@ -271,6 +275,8 @@ async def toggle_plugin(
plugin = db.query(NodePlugin).filter(NodePlugin.id == plugin_id).first()
if not plugin:
raise HTTPException(status_code=404, detail="插件不存在")
if plugin.user_id != current_user.id:
raise HTTPException(status_code=403, detail="无权操作此插件")
plugin.enabled = not plugin.enabled
if plugin.enabled:
@@ -310,11 +316,14 @@ async def test_plugin(
plugin_id: str,
body: PluginExecuteRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""在沙箱中测试执行插件。"""
"""在沙箱中测试执行插件(仅所有者可测试)"""
plugin = db.query(NodePlugin).filter(NodePlugin.id == plugin_id).first()
if not plugin:
raise HTTPException(status_code=404, detail="插件不存在")
if plugin.user_id != current_user.id:
raise HTTPException(status_code=403, detail="无权测试此插件")
if not plugin.code:
raise HTTPException(status_code=400, detail="插件无代码")

View File

@@ -110,26 +110,25 @@ def _build_union_query(
AgentExecutionLog.id.label("id"),
text("'agent'").label("source"),
case(
(AgentExecutionLog.status == "failed", "ERROR"),
(AgentExecutionLog.status == "completed", "INFO"),
(AgentExecutionLog.success == False, "ERROR"),
else_="INFO"
).label("level"),
AgentExecutionLog.user_message.label("message"),
AgentExecutionLog.input_text.label("message"),
AgentExecutionLog.created_at.label("timestamp"),
text("'agent_chat'").label("resource_type"),
AgentExecutionLog.agent_id.label("resource_id"),
AgentExecutionLog.total_latency_ms.label("duration_ms"),
AgentExecutionLog.latency_ms.label("duration_ms"),
text("NULL").label("username"),
)
if level:
if level.upper() == "ERROR":
q = q.filter(AgentExecutionLog.status == "failed")
q = q.filter(AgentExecutionLog.success == False)
elif level.upper() == "WARN":
q = q.filter(AgentExecutionLog.status == "failed")
q = q.filter(AgentExecutionLog.success == False)
else:
q = q.filter(AgentExecutionLog.status != "failed")
q = q.filter(AgentExecutionLog.success == True)
if keyword:
q = q.filter(AgentExecutionLog.user_message.contains(keyword))
q = q.filter(AgentExecutionLog.input_text.contains(keyword))
if start_date:
q = q.filter(AgentExecutionLog.created_at >= start_date)
if end_date:
@@ -195,7 +194,6 @@ async def get_system_logs(
):
"""
统一日志查询:跨 execution_logs / agent_execution_logs / agent_llm_logs 联合查询。
管理员可查全部,普通用户只能查看自己相关的 Agent 执行日志和 LLM 日志。
"""
_check_admin(current_user)
@@ -209,22 +207,91 @@ async def get_system_logs(
if getattr(current_user, "role", None) != "admin":
user_id = current_user.id
source_val = source if source else "all"
queries = _build_union_query(db, source_val, level, keyword, sd, ed, user_id)
results: list[UnifiedLogItem] = []
if not queries:
return []
# 1) 工作流执行日志
if source in (None, "all", "execution"):
q = db.query(ExecutionLog)
if level:
q = q.filter(ExecutionLog.level == level.upper())
if keyword:
q = q.filter(ExecutionLog.message.contains(keyword))
if sd:
q = q.filter(ExecutionLog.timestamp >= sd)
if ed:
q = q.filter(ExecutionLog.timestamp <= ed)
for row in q.order_by(ExecutionLog.timestamp.desc()).offset(skip).limit(limit).all():
results.append(UnifiedLogItem(
id=row.id,
source="execution",
level=row.level,
message=row.message or "",
timestamp=row.timestamp,
resource_type=row.node_type,
resource_id=row.execution_id,
duration_ms=row.duration,
))
# UNION ALL
union = queries[0]
for q in queries[1:]:
union = union.union_all(q)
# 2) Agent 执行日志
if source in (None, "all", "agent"):
q = db.query(AgentExecutionLog)
if keyword:
q = q.filter(AgentExecutionLog.input_text.contains(keyword))
if sd:
q = q.filter(AgentExecutionLog.created_at >= sd)
if ed:
q = q.filter(AgentExecutionLog.created_at <= ed)
if user_id:
q = q.filter(AgentExecutionLog.user_id == user_id)
if level:
if level.upper() == "ERROR":
q = q.filter(AgentExecutionLog.success == False)
elif level.upper() != "WARN":
q = q.filter(AgentExecutionLog.success == True)
for row in q.order_by(AgentExecutionLog.created_at.desc()).offset(skip).limit(limit).all():
results.append(UnifiedLogItem(
id=row.id,
source="agent",
level="ERROR" if not row.success else "INFO",
message=row.input_text or "",
timestamp=row.created_at,
resource_type="agent_chat",
resource_id=row.agent_id,
duration_ms=row.latency_ms,
))
# 排序 + 分页
total = union.count() if hasattr(union, 'count') else 0
rows = union.order_by(text("timestamp DESC")).offset(skip).limit(limit).all()
# 3) LLM 调用日志
if source in (None, "all", "llm"):
q = db.query(AgentLLMLog)
if keyword:
q = q.filter(AgentLLMLog.error_message.contains(keyword))
if sd:
q = q.filter(AgentLLMLog.created_at >= sd)
if ed:
q = q.filter(AgentLLMLog.created_at <= ed)
if level:
if level.upper() == "ERROR":
q = q.filter(AgentLLMLog.status == "error")
elif level.upper() == "WARN":
q = q.filter(AgentLLMLog.status == "rate_limited")
else:
q = q.filter(AgentLLMLog.status == "success")
for row in q.order_by(AgentLLMLog.created_at.desc()).offset(skip).limit(limit).all():
msg = row.error_message or f"LLM call: {row.model or 'unknown'} ({row.step_type or '?'})"
results.append(UnifiedLogItem(
id=row.id,
source="llm",
level="ERROR" if row.status == "error" else ("WARN" if row.status == "rate_limited" else "INFO"),
message=msg,
timestamp=row.created_at,
resource_type="llm_call",
resource_id=row.agent_id,
duration_ms=row.latency_ms,
))
return rows
# 按时间排序 + 分页
results.sort(key=lambda x: x.timestamp or datetime.min, reverse=True)
return results[skip:skip + limit]
@router.get("/stats", response_model=LogStatsResponse)
@@ -258,7 +325,7 @@ async def get_system_logs_stats(
).scalar() or 0
agent_errors = db.query(func.count(AgentExecutionLog.id)).filter(
AgentExecutionLog.created_at >= today_start,
AgentExecutionLog.status == "failed"
AgentExecutionLog.success == False
).scalar() or 0
# LLM 调用日志统计

View File

@@ -13,6 +13,7 @@ from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.api.auth import get_current_user
from app.api.deps import require_workspace_admin, WorkspaceContext
from app.core.database import get_db
from app.models.tool import Tool
from app.models.user import User
@@ -355,8 +356,12 @@ async def delete_tool(
@router.post("/reload")
async def reload_tools(db: Session = Depends(get_db)):
"""从数据库重新加载所有自定义工具到注册表(热更新)。"""
async def reload_tools(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_ws_admin: WorkspaceContext = Depends(require_workspace_admin),
):
"""从数据库重新加载所有自定义工具到注册表(热更新,仅工作区管理员可操作)。"""
# 清除旧的自定义工具
for name in list(tool_registry._custom_tool_configs.keys()):
if name not in tool_registry._builtin_tools:

264
backend/app/api/users.py Normal file
View File

@@ -0,0 +1,264 @@
"""
Admin 用户管理 API仅平台管理员可访问
"""
from __future__ import annotations
import logging
import uuid
from datetime import datetime
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field, EmailStr
from sqlalchemy.orm import Session
from sqlalchemy import func
from app.api.auth import get_current_user
from app.core.database import get_db
from app.core.security import get_password_hash
from app.models.user import User
from app.models.workspace import WorkspaceMembership
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/admin/users", tags=["admin-users"])
def _require_admin(current_user: User = Depends(get_current_user)) -> User:
if current_user.role != "admin":
raise HTTPException(status_code=403, detail="仅平台管理员可访问")
return current_user
# ── Request / Response schemas ──
class UserCreateRequest(BaseModel):
username: str = Field(..., min_length=2, max_length=50)
email: EmailStr
password: str = Field(..., min_length=6, max_length=128)
role: str = Field(default="user")
phone: Optional[str] = None
status: str = Field(default="active")
class UserUpdateRequest(BaseModel):
username: Optional[str] = Field(None, min_length=2, max_length=50)
email: Optional[EmailStr] = None
role: Optional[str] = None
phone: Optional[str] = None
status: Optional[str] = None
subscription_tier: Optional[str] = None
class ResetPasswordRequest(BaseModel):
new_password: str = Field(..., min_length=6, max_length=128)
class UserItem(BaseModel):
id: str
username: str
email: str
role: str
phone: Optional[str] = None
phone_verified: bool = False
status: str
is_email_verified: bool = False
subscription_tier: str = "free"
daily_usage_count: int = 0
workspace_count: int = 0
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
class UserListResponse(BaseModel):
items: List[UserItem]
total: int
page: int
page_size: int
# ── Endpoints ──
@router.get("", response_model=UserListResponse)
def list_users(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
search: Optional[str] = Query(None, description="搜索用户名或邮箱"),
status: Optional[str] = Query(None, description="过滤状态: active/disabled/deleted"),
role: Optional[str] = Query(None, description="过滤角色"),
db: Session = Depends(get_db),
_admin: User = Depends(_require_admin),
):
"""获取用户列表(分页、搜索、过滤)"""
q = db.query(User)
if search:
like = f"%{search}%"
q = q.filter((User.username.ilike(like)) | (User.email.ilike(like)))
if status:
q = q.filter(User.status == status)
if role:
q = q.filter(User.role == role)
total = q.count()
items = q.order_by(User.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
# 获取每个用户的 workspace 数量
user_ids = [u.id for u in items]
ws_counts = {}
if user_ids:
rows = (
db.query(
WorkspaceMembership.user_id,
func.count(WorkspaceMembership.workspace_id).label("cnt"),
)
.filter(WorkspaceMembership.user_id.in_(user_ids))
.group_by(WorkspaceMembership.user_id)
.all()
)
ws_counts = {r.user_id: r.cnt for r in rows}
return {
"items": [
{**{c.name: getattr(u, c.name) for c in User.__table__.columns},
"workspace_count": ws_counts.get(u.id, 0)}
for u in items
],
"total": total,
"page": page,
"page_size": page_size,
}
@router.post("", response_model=UserItem)
def create_user(
data: UserCreateRequest,
db: Session = Depends(get_db),
_admin: User = Depends(_require_admin),
):
"""管理员创建新用户"""
if db.query(User).filter(User.username == data.username).first():
raise HTTPException(status_code=409, detail="用户名已存在")
if db.query(User).filter(User.email == data.email).first():
raise HTTPException(status_code=409, detail="邮箱已存在")
user = User(
id=str(uuid.uuid4()),
username=data.username,
email=data.email,
password_hash=get_password_hash(data.password),
role=data.role,
phone=data.phone,
status=data.status,
)
db.add(user)
db.commit()
db.refresh(user)
logger.info("管理员创建了用户: %s (%s)", user.username, user.id)
return {**{c.name: getattr(user, c.name) for c in User.__table__.columns}, "workspace_count": 0}
@router.get("/{user_id}", response_model=UserItem)
def get_user(
user_id: str,
db: Session = Depends(get_db),
_admin: User = Depends(_require_admin),
):
"""获取用户详情"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
ws_count = (
db.query(func.count(WorkspaceMembership.workspace_id))
.filter(WorkspaceMembership.user_id == user_id)
.scalar()
) or 0
return {**{c.name: getattr(user, c.name) for c in User.__table__.columns}, "workspace_count": ws_count}
@router.put("/{user_id}", response_model=UserItem)
def update_user(
user_id: str,
data: UserUpdateRequest,
db: Session = Depends(get_db),
_admin: User = Depends(_require_admin),
):
"""编辑用户"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
if data.username is not None and data.username != user.username:
if db.query(User).filter(User.username == data.username, User.id != user_id).first():
raise HTTPException(status_code=409, detail="用户名已存在")
user.username = data.username
if data.email is not None and data.email != user.email:
if db.query(User).filter(User.email == data.email, User.id != user_id).first():
raise HTTPException(status_code=409, detail="邮箱已存在")
user.email = data.email
if data.role is not None:
user.role = data.role
if data.phone is not None:
user.phone = data.phone
if data.status is not None:
if data.status not in ("active", "disabled", "deleted"):
raise HTTPException(status_code=400, detail="无效的状态值")
user.status = data.status
if data.subscription_tier is not None:
user.subscription_tier = data.subscription_tier
db.commit()
db.refresh(user)
logger.info("管理员更新了用户: %s (%s)", user.username, user.id)
ws_count = (
db.query(func.count(WorkspaceMembership.workspace_id))
.filter(WorkspaceMembership.user_id == user_id)
.scalar()
) or 0
return {**{c.name: getattr(user, c.name) for c in User.__table__.columns}, "workspace_count": ws_count}
@router.delete("/{user_id}")
def delete_user(
user_id: str,
db: Session = Depends(get_db),
_admin: User = Depends(_require_admin),
):
"""软删除用户(标记为 deleted"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
if user.role == "admin":
raise HTTPException(status_code=400, detail="不能删除平台管理员")
user.status = "deleted"
db.commit()
logger.info("管理员软删除了用户: %s (%s)", user.username, user.id)
return {"message": "用户已删除"}
@router.post("/{user_id}/reset-password")
def reset_password(
user_id: str,
data: ResetPasswordRequest,
db: Session = Depends(get_db),
_admin: User = Depends(_require_admin),
):
"""重置用户密码"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
user.password_hash = get_password_hash(data.new_password)
db.commit()
logger.info("管理员重置了用户 %s 的密码", user.username)
return {"message": "密码已重置"}

View File

@@ -1,14 +1,19 @@
"""
WebSocket API
WebSocket API — 实时推送执行状态workspace 隔离)
"""
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
from app.websocket.manager import websocket_manager
from app.core.database import SessionLocal
from app.core.security import decode_access_token
from app.models.execution import Execution
from app.models.user import User
from app.models.workspace import WorkspaceMembership
from typing import Optional
import json
import asyncio
import logging
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -27,21 +32,83 @@ def _get_progress_from_redis(execution_id: str) -> Optional[dict]:
return None
def _validate_ws_token(token: Optional[str], execution_id: str) -> dict:
"""验证 WebSocket JWT token返回 {user_id, workspace_id}。
失败时返回包含 error/code 的字典,调用方应据此拒绝连接。
"""
if not token:
return {"error": "缺少认证令牌", "code": 4001}
payload = decode_access_token(token)
if payload is None:
return {"error": "无效的访问令牌", "code": 4001}
user_id = payload.get("sub")
ws_id = payload.get("ws", "")
if not user_id:
return {"error": "无效的令牌载荷", "code": 4001}
# 验证用户存在且未被删除
db = SessionLocal()
try:
user = db.query(User).filter(User.id == user_id).first()
if not user:
return {"error": "用户不存在", "code": 4001}
if user.status == "deleted":
return {"error": "账号已注销", "code": 4003}
# 平台管理员绕过 workspace 检查
if user.role == "admin":
return {"user_id": user_id, "workspace_id": ws_id, "is_admin": True}
# 验证 workspace 成员资格
if ws_id:
membership = (
db.query(WorkspaceMembership)
.filter(
WorkspaceMembership.workspace_id == ws_id,
WorkspaceMembership.user_id == user_id,
)
.first()
)
if not membership:
return {"error": "无权访问此工作区", "code": 4003}
# 验证 execution 属于该 workspace
execution = db.query(Execution).filter(Execution.id == execution_id).first()
if execution and ws_id and execution.workspace_id and execution.workspace_id != ws_id:
return {"error": "无权查看此执行记录", "code": 4003}
return {"user_id": user_id, "workspace_id": ws_id}
finally:
db.close()
@router.websocket("/api/v1/ws/executions/{execution_id}")
async def websocket_execution_status(
websocket: WebSocket,
execution_id: str,
token: Optional[str] = None
token: Optional[str] = Query(None),
):
"""
WebSocket实时推送执行状态
WebSocket 实时推送执行状态workspace 隔离)。
Args:
websocket: WebSocket连接
execution_id: 执行记录ID
token: JWT Token可选通过query参数传递
需要 JWT token 通过 query 参数传递: ?token=xxx
连接建立前验证JWT 有效性 → workspace 成员资格 → execution workspace 归属。
"""
await websocket_manager.connect(websocket, execution_id)
# ── 认证 + 鉴权 ──
auth = _validate_ws_token(token, execution_id)
if "error" in auth:
await websocket.accept()
await websocket.send_json({"type": "error", "message": auth["error"], "code": auth["code"]})
await websocket.close(code=auth["code"])
return
user_id = auth["user_id"]
ws_id = auth.get("workspace_id", "")
await websocket_manager.connect(websocket, execution_id, workspace_id=ws_id)
db = SessionLocal()
@@ -139,7 +206,7 @@ async def websocket_execution_status(
except WebSocketDisconnect:
pass
except Exception as e:
print(f"WebSocket错误: {e}")
logger.error("WebSocket 错误: %s", e)
try:
await websocket_manager.send_personal_message({
"type": "error",

View File

@@ -11,6 +11,7 @@ from app.core.database import get_db
from app.models.workflow import Workflow
from app.models.workflow_version import WorkflowVersion
from app.api.auth import get_current_user, UserResponse
from app.api.deps import require_workspace_admin, WorkspaceContext
from app.models.user import User
from app.core.exceptions import NotFoundError, ValidationError, ConflictError
from app.services.workflow_validator import validate_workflow
@@ -340,9 +341,10 @@ async def update_workflow(
async def delete_workflow(
workflow_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
current_user: User = Depends(get_current_user),
_ws_admin: WorkspaceContext = Depends(require_workspace_admin),
):
"""删除工作流(只有所有者可以删除"""
"""删除工作流(仅工作区管理员和平台管理员可操作"""
workflow = db.query(Workflow).filter(Workflow.id == workflow_id).first()
if not workflow:
@@ -610,9 +612,10 @@ async def rollback_workflow_version(
version: int,
rollback_data: Optional[WorkflowVersionRollback] = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
current_user: User = Depends(get_current_user),
_ws_admin: WorkspaceContext = Depends(require_workspace_admin),
):
"""回滚工作流到指定版本"""
"""回滚工作流到指定版本(仅工作区管理员和平台管理员可操作)"""
# 验证工作流是否存在且属于当前用户
workflow = db.query(Workflow).filter(
Workflow.id == workflow_id,

View File

@@ -11,6 +11,7 @@ import logging
from app.core.database import get_db
from app.api.auth import get_current_user
from app.api.users import _require_admin
from app.models.user import User
from app.models.workspace import Workspace, WorkspaceMembership
from app.services.workspace_service import check_workspace_access, get_user_workspaces
@@ -391,3 +392,165 @@ def remove_member(
db.commit()
return {"message": "成员已移除"}
# ── 平台管理员端点 ──
admin_router = APIRouter(
prefix="/api/v1/admin/workspaces",
tags=["admin-workspaces"],
responses={
401: {"description": "未授权"},
403: {"description": "仅平台管理员可访问"},
404: {"description": "资源不存在"},
}
)
class AdminWorkspaceResponse(BaseModel):
id: str
name: str
description: Optional[str]
is_default: bool
owner_id: str
owner_name: str = ""
max_members: int
member_count: int = 0
status: str
created_at: Optional[str]
updated_at: Optional[str]
class AdminWorkspaceListResponse(BaseModel):
items: List[AdminWorkspaceResponse]
total: int
page: int
page_size: int
@admin_router.get("", response_model=AdminWorkspaceListResponse)
def admin_list_workspaces(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
search: Optional[str] = Query(None, description="搜索工作区名称"),
status: Optional[str] = Query(None, description="过滤状态: active/disabled/deleted"),
db: Session = Depends(get_db),
_admin: User = Depends(_require_admin),
):
"""平台管理员查看所有工作区(分页)。"""
q = db.query(Workspace)
if search:
q = q.filter(Workspace.name.ilike(f"%{search}%"))
if status:
q = q.filter(Workspace.status == status)
total = q.count()
workspaces = q.order_by(Workspace.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
# 批量查 owner 和 member_count
owner_ids = [w.owner_id for w in workspaces]
owners = {}
if owner_ids:
for u in db.query(User).filter(User.id.in_(owner_ids)).all():
owners[u.id] = u.username
items = []
for w in workspaces:
mc = (
db.query(WorkspaceMembership)
.filter(WorkspaceMembership.workspace_id == w.id)
.count()
)
items.append(AdminWorkspaceResponse(
id=w.id,
name=w.name,
description=w.description,
is_default=bool(w.is_default),
owner_id=w.owner_id,
owner_name=owners.get(w.owner_id, ""),
max_members=w.max_members,
member_count=mc,
status=w.status,
created_at=w.created_at.isoformat() if w.created_at else None,
updated_at=w.updated_at.isoformat() if w.updated_at else None,
))
return AdminWorkspaceListResponse(items=items, total=total, page=page, page_size=page_size)
@admin_router.get("/{workspace_id}", response_model=AdminWorkspaceResponse)
def admin_get_workspace(
workspace_id: str,
db: Session = Depends(get_db),
_admin: User = Depends(_require_admin),
):
"""平台管理员查看任意工作区详情。"""
ws = db.query(Workspace).filter(Workspace.id == workspace_id).first()
if not ws:
raise NotFoundError("工作区", workspace_id)
owner = db.query(User).filter(User.id == ws.owner_id).first()
mc = (
db.query(WorkspaceMembership)
.filter(WorkspaceMembership.workspace_id == workspace_id)
.count()
)
return AdminWorkspaceResponse(
id=ws.id,
name=ws.name,
description=ws.description,
is_default=bool(ws.is_default),
owner_id=ws.owner_id,
owner_name=owner.username if owner else "",
max_members=ws.max_members,
member_count=mc,
status=ws.status,
created_at=ws.created_at.isoformat() if ws.created_at else None,
updated_at=ws.updated_at.isoformat() if ws.updated_at else None,
)
@admin_router.put("/{workspace_id}")
def admin_update_workspace(
workspace_id: str,
data: WorkspaceUpdate,
db: Session = Depends(get_db),
_admin: User = Depends(_require_admin),
):
"""平台管理员编辑任意工作区。"""
ws = db.query(Workspace).filter(Workspace.id == workspace_id).first()
if not ws:
raise NotFoundError("工作区", workspace_id)
if data.name is not None:
ws.name = data.name
if data.description is not None:
ws.description = data.description
if data.max_members is not None:
ws.max_members = data.max_members
if data.status is not None:
ws.status = data.status
ws.updated_at = datetime.utcnow()
db.commit()
return {"message": "工作区已更新"}
@admin_router.delete("/{workspace_id}")
def admin_delete_workspace(
workspace_id: str,
db: Session = Depends(get_db),
_admin: User = Depends(_require_admin),
):
"""平台管理员强制删除工作区(软删除)。"""
ws = db.query(Workspace).filter(Workspace.id == workspace_id).first()
if not ws:
raise NotFoundError("工作区", workspace_id)
ws.status = "deleted"
ws.updated_at = datetime.utcnow()
db.commit()
logger.info("平台管理员删除了工作区: %s (%s)", ws.name, ws.id)
return {"message": "工作区已删除"}