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:
@@ -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:
|
||||
|
||||
@@ -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="定时任务不存在")
|
||||
|
||||
180
backend/app/api/agent_sessions.py
Normal file
180
backend/app/api/agent_sessions.py
Normal 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
|
||||
@@ -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}")
|
||||
|
||||
@@ -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()
|
||||
|
||||
229
backend/app/api/analytics.py
Normal file
229
backend/app/api/analytics.py
Normal 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}
|
||||
@@ -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
303
backend/app/api/billing.py
Normal 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"}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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="插件无代码")
|
||||
|
||||
|
||||
@@ -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 调用日志统计
|
||||
|
||||
@@ -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
264
backend/app/api/users.py
Normal 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": "密码已重置"}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": "工作区已删除"}
|
||||
|
||||
Reference in New Issue
Block a user