feat: virtual company module, team projects, PWA dishes app, and startup scripts overhaul
- Add company module (3-tier org, CEO planning, parallel departments) - Add company orchestrator, knowledge extractor, presets, scheduler - Add company API endpoints, models, and frontend views - Add 今天吃啥 PWA app (69 dishes, real images, offline support) - Add team_projects output directory structure - Add unified manage.ps1 for service lifecycle - Add Windows startup guide v1.0 - Add TTS troubleshooting doc - Update frontend (AgentChat UX overhaul, new views) - Update backend (voice engine fix, multi-tenant, RBAC) - Remove deprecated startup scripts and old docs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -136,8 +136,10 @@ class ChatRequest(BaseModel):
|
||||
message: str
|
||||
session_id: Optional[str] = None
|
||||
model: Optional[str] = None
|
||||
model_config_id: Optional[str] = Field(default=None, description="使用已保存的模型配置(含 API Key)")
|
||||
temperature: Optional[float] = None
|
||||
max_iterations: Optional[int] = None
|
||||
max_tokens: Optional[int] = Field(default=None, description="单次回复最大 token 数")
|
||||
streamlined: bool = Field(default=False, description="启用工具结果流式美化")
|
||||
prompt_sections_enabled: bool = Field(default=True, description="启用系统提示词分层装配")
|
||||
system_prompt_override: Optional[str] = Field(default=None, description="覆盖 Agent 的 System Prompt")
|
||||
@@ -355,17 +357,20 @@ async def chat_bare(
|
||||
"""无需 Agent 配置,使用默认设置直接对话。"""
|
||||
uid = current_user.id
|
||||
bare_scope = f"{uid}:__bare__" if uid else "__bare__"
|
||||
mc = _resolve_model_config(db, req.model_config_id, uid)
|
||||
llm_kwargs = _apply_model_config(dict(
|
||||
model=req.model or (
|
||||
"gpt-4o-mini" if settings.OPENAI_API_KEY and settings.OPENAI_API_KEY != "your-openai-api-key"
|
||||
else "deepseek-v4-flash"
|
||||
),
|
||||
temperature=req.temperature or 0.7,
|
||||
max_iterations=req.max_iterations or 10,
|
||||
max_tokens=req.max_tokens,
|
||||
), mc)
|
||||
config = AgentConfig(
|
||||
name="bare_agent",
|
||||
system_prompt="你是一个有用的AI助手。请使用可用工具来帮助用户完成任务。",
|
||||
llm=AgentLLMConfig(
|
||||
model=req.model or (
|
||||
"gpt-4o-mini" if settings.OPENAI_API_KEY and settings.OPENAI_API_KEY != "your-openai-api-key"
|
||||
else "deepseek-v4-flash"
|
||||
),
|
||||
temperature=req.temperature or 0.7,
|
||||
max_iterations=req.max_iterations or 10,
|
||||
),
|
||||
llm=AgentLLMConfig(**llm_kwargs),
|
||||
user_id=uid,
|
||||
memory_scope_id=bare_scope,
|
||||
memory=AgentMemoryConfig(
|
||||
@@ -421,17 +426,20 @@ async def chat_bare_stream(
|
||||
"""无需 Agent 配置,使用默认设置直接对话(流式 SSE)。"""
|
||||
uid = current_user.id
|
||||
bare_scope = f"{uid}:__bare__" if uid else "__bare__"
|
||||
mc = _resolve_model_config(db, req.model_config_id, uid)
|
||||
llm_kwargs = _apply_model_config(dict(
|
||||
model=req.model or (
|
||||
"gpt-4o-mini" if settings.OPENAI_API_KEY and settings.OPENAI_API_KEY != "your-openai-api-key"
|
||||
else "deepseek-v4-flash"
|
||||
),
|
||||
temperature=req.temperature or 0.7,
|
||||
max_iterations=req.max_iterations or 10,
|
||||
max_tokens=req.max_tokens,
|
||||
), mc)
|
||||
config = AgentConfig(
|
||||
name="bare_agent",
|
||||
system_prompt="你是一个有用的AI助手。请使用可用工具来帮助用户完成任务。",
|
||||
llm=AgentLLMConfig(
|
||||
model=req.model or (
|
||||
"gpt-4o-mini" if settings.OPENAI_API_KEY and settings.OPENAI_API_KEY != "your-openai-api-key"
|
||||
else "deepseek-v4-flash"
|
||||
),
|
||||
temperature=req.temperature or 0.7,
|
||||
max_iterations=req.max_iterations or 10,
|
||||
),
|
||||
llm=AgentLLMConfig(**llm_kwargs),
|
||||
user_id=uid,
|
||||
memory_scope_id=bare_scope,
|
||||
memory=AgentMemoryConfig(
|
||||
@@ -476,7 +484,7 @@ async def chat_with_agent(
|
||||
agent = db.query(Agent).filter(Agent.id == agent_id).first()
|
||||
if not agent:
|
||||
raise HTTPException(status_code=404, detail="Agent 不存在")
|
||||
if agent.user_id and agent.user_id != current_user.id and current_user.role != "admin" and not agent.is_public:
|
||||
if agent.user_id and agent.user_id != current_user.id and not current_user.has_permission("agent:chat_any") and not agent.is_public:
|
||||
raise HTTPException(status_code=403, detail="无权访问该 Agent")
|
||||
|
||||
# 从 Agent 配置构建 Runtime
|
||||
@@ -506,18 +514,20 @@ async def chat_with_agent(
|
||||
memory_cfg = _build_memory_config_from_node(agent_node_cfg)
|
||||
if getattr(agent, "parent_agent_id", None):
|
||||
memory_cfg.parent_agent_id = agent.parent_agent_id
|
||||
mc = _resolve_model_config(db, req.model_config_id, uid)
|
||||
llm_kwargs = _apply_model_config(dict(
|
||||
provider=agent_node_cfg.get("provider", "openai"),
|
||||
model=req.model or agent_node_cfg.get("model", "gpt-4o-mini"),
|
||||
temperature=req.temperature or float(agent_node_cfg.get("temperature", 0.7)),
|
||||
max_iterations=req.max_iterations or int(agent_node_cfg.get("max_iterations", 10)),
|
||||
max_tokens=req.max_tokens or agent_node_cfg.get("max_tokens"),
|
||||
plan_mode_enabled=bool(agent_node_cfg.get("plan_mode_enabled", False)),
|
||||
plan_approval_required=bool(agent_node_cfg.get("plan_approval_required", True)),
|
||||
), mc)
|
||||
config = AgentConfig(
|
||||
name=agent.name,
|
||||
system_prompt=system_prompt,
|
||||
llm=AgentLLMConfig(
|
||||
provider=agent_node_cfg.get("provider", "openai"),
|
||||
model=req.model or agent_node_cfg.get("model", "gpt-4o-mini"),
|
||||
temperature=req.temperature or float(agent_node_cfg.get("temperature", 0.7)),
|
||||
max_iterations=req.max_iterations or int(agent_node_cfg.get("max_iterations", 10)),
|
||||
# 计划模式 (P2)
|
||||
plan_mode_enabled=bool(agent_node_cfg.get("plan_mode_enabled", False)),
|
||||
plan_approval_required=bool(agent_node_cfg.get("plan_approval_required", True)),
|
||||
),
|
||||
llm=AgentLLMConfig(**llm_kwargs),
|
||||
tools=AgentToolConfig(
|
||||
include_tools=agent_node_cfg.get("tools", []),
|
||||
exclude_tools=agent_node_cfg.get("exclude_tools", []),
|
||||
@@ -577,7 +587,7 @@ async def chat_with_agent_stream(
|
||||
agent = db.query(Agent).filter(Agent.id == agent_id).first()
|
||||
if not agent:
|
||||
raise HTTPException(status_code=404, detail="Agent 不存在")
|
||||
if agent.user_id and agent.user_id != current_user.id and current_user.role != "admin" and not agent.is_public:
|
||||
if agent.user_id and agent.user_id != current_user.id and not current_user.has_permission("agent:chat_any") and not agent.is_public:
|
||||
raise HTTPException(status_code=403, detail="无权访问该 Agent")
|
||||
|
||||
wc = agent.workflow_config or {}
|
||||
@@ -603,18 +613,20 @@ async def chat_with_agent_stream(
|
||||
memory_cfg = _build_memory_config_from_node(agent_node_cfg)
|
||||
if getattr(agent, "parent_agent_id", None):
|
||||
memory_cfg.parent_agent_id = agent.parent_agent_id
|
||||
mc = _resolve_model_config(db, req.model_config_id, uid)
|
||||
llm_kwargs = _apply_model_config(dict(
|
||||
provider=agent_node_cfg.get("provider", "openai"),
|
||||
model=req.model or agent_node_cfg.get("model", "gpt-4o-mini"),
|
||||
temperature=req.temperature or float(agent_node_cfg.get("temperature", 0.7)),
|
||||
max_iterations=req.max_iterations or int(agent_node_cfg.get("max_iterations", 10)),
|
||||
max_tokens=req.max_tokens or agent_node_cfg.get("max_tokens"),
|
||||
plan_mode_enabled=bool(agent_node_cfg.get("plan_mode_enabled", False)),
|
||||
plan_approval_required=bool(agent_node_cfg.get("plan_approval_required", True)),
|
||||
), mc)
|
||||
config = AgentConfig(
|
||||
name=agent.name,
|
||||
system_prompt=system_prompt,
|
||||
llm=AgentLLMConfig(
|
||||
provider=agent_node_cfg.get("provider", "openai"),
|
||||
model=req.model or agent_node_cfg.get("model", "gpt-4o-mini"),
|
||||
temperature=req.temperature or float(agent_node_cfg.get("temperature", 0.7)),
|
||||
max_iterations=req.max_iterations or int(agent_node_cfg.get("max_iterations", 10)),
|
||||
# 计划模式 (P2)
|
||||
plan_mode_enabled=bool(agent_node_cfg.get("plan_mode_enabled", False)),
|
||||
plan_approval_required=bool(agent_node_cfg.get("plan_approval_required", True)),
|
||||
),
|
||||
llm=AgentLLMConfig(**llm_kwargs),
|
||||
tools=AgentToolConfig(
|
||||
include_tools=agent_node_cfg.get("tools", []),
|
||||
exclude_tools=agent_node_cfg.get("exclude_tools", []),
|
||||
@@ -901,7 +913,38 @@ async def search_messages(
|
||||
return SearchMessagesResponse(messages=result, total=total)
|
||||
|
||||
|
||||
def _find_agent_node_config(nodes: list) -> Dict[str, Any]:
|
||||
def _resolve_model_config(db: Session, model_config_id: Optional[str], user_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""从数据库加载模型配置,返回 {provider, model, api_key, base_url}。"""
|
||||
if not model_config_id:
|
||||
return None
|
||||
from app.models.model_config import ModelConfig
|
||||
mc = db.query(ModelConfig).filter(
|
||||
ModelConfig.id == model_config_id,
|
||||
ModelConfig.user_id == user_id,
|
||||
).first()
|
||||
if not mc:
|
||||
raise HTTPException(status_code=404, detail="模型配置不存在")
|
||||
return {
|
||||
"provider": mc.provider,
|
||||
"model": mc.model_name,
|
||||
"api_key": mc.api_key,
|
||||
"base_url": mc.base_url,
|
||||
}
|
||||
|
||||
|
||||
def _apply_model_config(llm_kwargs: Dict[str, Any], mc: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""将模型配置应用到 LLM 参数中。"""
|
||||
if not mc:
|
||||
return llm_kwargs
|
||||
if mc.get("provider"):
|
||||
llm_kwargs["provider"] = mc["provider"]
|
||||
if mc.get("model"):
|
||||
llm_kwargs["model"] = mc["model"]
|
||||
if mc.get("api_key"):
|
||||
llm_kwargs["api_key"] = mc["api_key"]
|
||||
if mc.get("base_url"):
|
||||
llm_kwargs["base_url"] = mc["base_url"]
|
||||
return llm_kwargs
|
||||
"""从工作流节点列表中查找第一个 agent 类型或 llm 类型的节点配置。"""
|
||||
if not nodes:
|
||||
return {}
|
||||
|
||||
@@ -250,7 +250,7 @@ async def publish_to_market(
|
||||
agent = db.query(Agent).filter(Agent.id == agent_id).first()
|
||||
if not agent:
|
||||
raise NotFoundError("Agent", agent_id)
|
||||
if agent.user_id != current_user.id and current_user.role != "admin":
|
||||
if agent.user_id != current_user.id and not current_user.has_permission("agent:manage_market"):
|
||||
raise HTTPException(status_code=403, detail="无权发布此 Agent")
|
||||
|
||||
agent.is_public = 1
|
||||
@@ -279,7 +279,7 @@ async def unpublish_from_market(
|
||||
agent = db.query(Agent).filter(Agent.id == agent_id).first()
|
||||
if not agent:
|
||||
raise NotFoundError("Agent", agent_id)
|
||||
if agent.user_id != current_user.id and current_user.role != "admin":
|
||||
if agent.user_id != current_user.id and not current_user.has_permission("agent:manage_market"):
|
||||
raise HTTPException(status_code=403, detail="无权下架此 Agent")
|
||||
|
||||
agent.is_public = 0
|
||||
|
||||
@@ -38,7 +38,7 @@ def _check_agent(db: Session, agent_id: str, user: User):
|
||||
agent = db.query(Agent).filter(Agent.id == agent_id).first()
|
||||
if not agent:
|
||||
raise HTTPException(status_code=404, detail="Agent 不存在")
|
||||
if agent.user_id and agent.user_id != user.id and user.role != "admin":
|
||||
if agent.user_id and agent.user_id != user.id and not user.has_permission("agent:manage"):
|
||||
raise HTTPException(status_code=403, detail="无权访问该 Agent")
|
||||
return agent
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ async def get_agent_overview(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Agent 概览统计:Agent 数、对话次数、LLM 调用次数、Token 用量、工具调用次数。"""
|
||||
user_id = None if current_user.role == "admin" else current_user.id
|
||||
user_id = None if current_user.has_permission("monitoring:view_all") else current_user.id
|
||||
return AgentMonitoringService.get_overview(db, user_id)
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ async def get_llm_calls(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""最近 LLM 调用记录列表。"""
|
||||
user_id = None if current_user.role == "admin" else current_user.id
|
||||
user_id = None if current_user.has_permission("monitoring:view_all") else current_user.id
|
||||
return AgentMonitoringService.get_llm_calls(db, user_id, days, limit)
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ async def get_agent_stats(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""各 Agent 用量统计(按 Agent 分组)。"""
|
||||
user_id = None if current_user.role == "admin" else current_user.id
|
||||
user_id = None if current_user.has_permission("monitoring:view_all") else current_user.id
|
||||
return AgentMonitoringService.get_agent_stats(db, user_id, days)
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ async def get_tool_usage(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""工具调用频次统计。"""
|
||||
user_id = None if current_user.role == "admin" else current_user.id
|
||||
user_id = None if current_user.has_permission("monitoring:view_all") else current_user.id
|
||||
return AgentMonitoringService.get_tool_usage(db, user_id, days)
|
||||
|
||||
|
||||
@@ -70,5 +70,5 @@ async def get_daily_trend(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""每日 LLM 调用趋势。"""
|
||||
user_id = None if current_user.role == "admin" else current_user.id
|
||||
user_id = None if current_user.has_permission("monitoring:view_all") else current_user.id
|
||||
return AgentMonitoringService.get_daily_trend(db, user_id, days)
|
||||
|
||||
@@ -74,7 +74,7 @@ async def list_schedules(
|
||||
):
|
||||
"""获取当前用户的所有定时任务。"""
|
||||
query = db.query(AgentSchedule)
|
||||
if current_user.role != "admin":
|
||||
if not current_user.has_permission("agent:schedule_view_all"):
|
||||
query = query.filter(AgentSchedule.user_id == current_user.id)
|
||||
schedules = query.order_by(AgentSchedule.created_at.desc()).all()
|
||||
return schedules
|
||||
@@ -91,7 +91,7 @@ async def create_schedule(
|
||||
agent = db.query(Agent).filter(Agent.id == data.agent_id).first()
|
||||
if not agent:
|
||||
raise HTTPException(status_code=404, detail="Agent 不存在")
|
||||
if agent.user_id and agent.user_id != current_user.id and current_user.role != "admin":
|
||||
if agent.user_id and agent.user_id != current_user.id and not current_user.has_permission("agent:schedule"):
|
||||
raise HTTPException(status_code=403, detail="无权使用该 Agent")
|
||||
|
||||
# 验证 cron 表达式
|
||||
@@ -132,7 +132,7 @@ async def update_schedule(
|
||||
schedule = db.query(AgentSchedule).filter(AgentSchedule.id == schedule_id).first()
|
||||
if not schedule:
|
||||
raise HTTPException(status_code=404, detail="定时任务不存在")
|
||||
if schedule.user_id != current_user.id and current_user.role != "admin":
|
||||
if schedule.user_id != current_user.id and not current_user.has_permission("agent:schedule"):
|
||||
raise HTTPException(status_code=403, detail="无权修改该定时任务")
|
||||
|
||||
if data.name is not None:
|
||||
@@ -169,7 +169,7 @@ async def delete_schedule(
|
||||
schedule = db.query(AgentSchedule).filter(AgentSchedule.id == schedule_id).first()
|
||||
if not schedule:
|
||||
raise HTTPException(status_code=404, detail="定时任务不存在")
|
||||
if schedule.user_id != current_user.id and current_user.role != "admin":
|
||||
if schedule.user_id != current_user.id and not current_user.has_permission("agent:schedule"):
|
||||
raise HTTPException(status_code=403, detail="无权删除该定时任务")
|
||||
|
||||
db.delete(schedule)
|
||||
@@ -188,7 +188,7 @@ async def trigger_schedule(
|
||||
schedule = db.query(AgentSchedule).filter(AgentSchedule.id == schedule_id).first()
|
||||
if not schedule:
|
||||
raise HTTPException(status_code=404, detail="定时任务不存在")
|
||||
if schedule.user_id != current_user.id and current_user.role != "admin":
|
||||
if schedule.user_id != current_user.id and not current_user.has_permission("agent:schedule"):
|
||||
raise HTTPException(status_code=403, detail="无权触发该定时任务")
|
||||
|
||||
execution_id = create_execution_for_schedule(db, schedule)
|
||||
|
||||
@@ -127,7 +127,7 @@ async def get_agents(
|
||||
支持分页、搜索、状态筛选、工作区筛选
|
||||
"""
|
||||
# 管理员可以看到所有Agent,普通用户只能看到自己拥有的或有read权限的
|
||||
if current_user.role == "admin":
|
||||
if current_user.has_permission("agent:view_all"):
|
||||
query = db.query(Agent)
|
||||
else:
|
||||
# 获取用户拥有或有read权限的Agent
|
||||
@@ -397,8 +397,8 @@ async def delete_agent(
|
||||
if not agent:
|
||||
raise NotFoundError(f"Agent不存在: {agent_id}")
|
||||
|
||||
# 只有Agent所有者可以删除
|
||||
if agent.user_id != current_user.id and current_user.role != "admin":
|
||||
# 只有Agent所有者或管理员可以删除
|
||||
if agent.user_id != current_user.id and not current_user.has_permission("agent:delete"):
|
||||
raise HTTPException(status_code=403, detail="无权删除此Agent")
|
||||
|
||||
agent_name = agent.name
|
||||
|
||||
212
backend/app/api/api_keys.py
Normal file
212
backend/app/api/api_keys.py
Normal file
@@ -0,0 +1,212 @@
|
||||
"""
|
||||
API Key 管理 API — 创建、列表、撤销 API 密钥
|
||||
"""
|
||||
import hashlib
|
||||
import secrets
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
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 require_admin, get_current_workspace_id
|
||||
from app.models.user import User
|
||||
from app.models.api_key import ApiKey, KEY_PREFIX, verify_api_key
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/api-keys", tags=["api-keys"])
|
||||
|
||||
|
||||
def _generate_api_key() -> tuple[str, str, str]:
|
||||
"""生成 API Key。返回 (完整key, 前缀, SHA256哈希)。"""
|
||||
raw = secrets.token_hex(24) # 48 hex chars
|
||||
full_key = f"{KEY_PREFIX}{raw}"
|
||||
key_hash = hashlib.sha256(full_key.encode()).hexdigest()
|
||||
return full_key, KEY_PREFIX, key_hash
|
||||
|
||||
|
||||
class ApiKeyCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100, description="密钥名称")
|
||||
expires_in_days: Optional[int] = Field(None, ge=1, description="过期天数(null=永不过期)")
|
||||
permissions: Optional[List[str]] = Field(None, description="权限范围(null=全部权限)")
|
||||
|
||||
|
||||
class ApiKeyResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
key_prefix: str
|
||||
permissions: Optional[List[str]] = None
|
||||
last_used_at: Optional[str] = None
|
||||
expires_at: Optional[str] = None
|
||||
status: str
|
||||
created_at: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ApiKeyCreatedResponse(BaseModel):
|
||||
"""创建成功后返回,包含明文密钥(仅此一次)"""
|
||||
id: str
|
||||
name: str
|
||||
api_key: str = Field(..., description="完整的 API Key(仅创建时返回,请立即保存)")
|
||||
key_prefix: str
|
||||
expires_at: Optional[str] = None
|
||||
message: str = "请立即保存此密钥,关闭后无法再次查看"
|
||||
|
||||
|
||||
@router.post("", response_model=ApiKeyCreatedResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_api_key(
|
||||
req: ApiKeyCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
workspace_id: str = Depends(get_current_workspace_id),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""创建新的 API Key。返回完整密钥(仅此一次)。"""
|
||||
full_key, prefix, key_hash = _generate_api_key()
|
||||
|
||||
expires_at = None
|
||||
if req.expires_in_days:
|
||||
expires_at = datetime.utcnow() + timedelta(days=req.expires_in_days)
|
||||
|
||||
api_key = ApiKey(
|
||||
user_id=current_user.id,
|
||||
workspace_id=workspace_id,
|
||||
name=req.name,
|
||||
key_prefix=prefix,
|
||||
key_hash=key_hash,
|
||||
permissions=req.permissions,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
db.add(api_key)
|
||||
db.commit()
|
||||
db.refresh(api_key)
|
||||
|
||||
logger.info("用户 %s 创建了 API Key: %s", current_user.username, api_key.name)
|
||||
|
||||
return ApiKeyCreatedResponse(
|
||||
id=api_key.id,
|
||||
name=api_key.name,
|
||||
api_key=full_key,
|
||||
key_prefix=prefix,
|
||||
expires_at=api_key.expires_at.isoformat() if api_key.expires_at else None,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=List[ApiKeyResponse])
|
||||
async def list_api_keys(
|
||||
current_user: User = Depends(get_current_user),
|
||||
workspace_id: str = Depends(get_current_workspace_id),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""列出当前用户的所有 API Key(不返回密钥明文)。"""
|
||||
keys = (
|
||||
db.query(ApiKey)
|
||||
.filter(
|
||||
ApiKey.user_id == current_user.id,
|
||||
ApiKey.workspace_id == workspace_id,
|
||||
)
|
||||
.order_by(ApiKey.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
ApiKeyResponse(
|
||||
id=k.id,
|
||||
name=k.name,
|
||||
key_prefix=k.key_prefix,
|
||||
permissions=k.permissions,
|
||||
last_used_at=k.last_used_at.isoformat() if k.last_used_at else None,
|
||||
expires_at=k.expires_at.isoformat() if k.expires_at else None,
|
||||
status=k.status,
|
||||
created_at=k.created_at.isoformat() if k.created_at else None,
|
||||
)
|
||||
for k in keys
|
||||
]
|
||||
|
||||
|
||||
@router.delete("/{key_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def revoke_api_key(
|
||||
key_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""撤销 API Key(设置为 revoked 状态)。"""
|
||||
api_key = db.query(ApiKey).filter(
|
||||
ApiKey.id == key_id,
|
||||
ApiKey.user_id == current_user.id,
|
||||
).first()
|
||||
|
||||
if not api_key:
|
||||
raise HTTPException(status_code=404, detail="API Key 不存在")
|
||||
|
||||
if api_key.status == "revoked":
|
||||
raise HTTPException(status_code=400, detail="该密钥已被撤销")
|
||||
|
||||
api_key.status = "revoked"
|
||||
db.commit()
|
||||
|
||||
logger.info("用户 %s 撤销了 API Key: %s", current_user.username, api_key.name)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# 管理员端点
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
|
||||
@router.get("/admin/all", response_model=List[dict])
|
||||
async def admin_list_all_keys(
|
||||
_admin: User = Depends(require_admin()),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""管理员查看所有用户的 API Key(不含密钥明文)。"""
|
||||
keys = (
|
||||
db.query(ApiKey)
|
||||
.order_by(ApiKey.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
results = []
|
||||
for k in keys:
|
||||
owner = k.user
|
||||
results.append({
|
||||
"id": k.id,
|
||||
"name": k.name,
|
||||
"key_prefix": k.key_prefix,
|
||||
"permissions": k.permissions,
|
||||
"last_used_at": k.last_used_at.isoformat() if k.last_used_at else None,
|
||||
"expires_at": k.expires_at.isoformat() if k.expires_at else None,
|
||||
"status": k.status,
|
||||
"created_at": k.created_at.isoformat() if k.created_at else None,
|
||||
"owner": {
|
||||
"id": owner.id,
|
||||
"username": owner.username,
|
||||
"email": owner.email,
|
||||
} if owner else None,
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
|
||||
@router.delete("/admin/{key_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def admin_revoke_key(
|
||||
key_id: str,
|
||||
_admin: User = Depends(require_admin()),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""管理员撤销任意 API Key。"""
|
||||
api_key = db.query(ApiKey).filter(ApiKey.id == key_id).first()
|
||||
if not api_key:
|
||||
raise HTTPException(status_code=404, detail="API Key 不存在")
|
||||
if api_key.status == "revoked":
|
||||
raise HTTPException(status_code=400, detail="该密钥已被撤销")
|
||||
|
||||
api_key.status = "revoked"
|
||||
db.commit()
|
||||
|
||||
logger.info("管理员 %s 撤销了 %s 的 API Key: %s",
|
||||
current_user.username, api_key.user.username if api_key.user else "?", api_key.name)
|
||||
@@ -11,8 +11,8 @@ from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, Form
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.security import decode_access_token
|
||||
from app.core.database import get_db
|
||||
from app.api.deps import require_admin
|
||||
from sqlalchemy.orm import Session
|
||||
from app.models.user import User
|
||||
|
||||
@@ -112,19 +112,10 @@ async def check_update(
|
||||
|
||||
# ─── 管理端版本列表 ──────────────────────────────────────────
|
||||
|
||||
async def _get_admin_user(token: str, db: Session) -> User:
|
||||
"""验证管理员身份。"""
|
||||
payload = decode_access_token(token)
|
||||
if not payload:
|
||||
raise HTTPException(status_code=401, detail="未登录")
|
||||
user = db.query(User).filter(User.id == payload.get("sub")).first()
|
||||
if not user or user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="仅管理员可操作")
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/versions")
|
||||
async def list_versions():
|
||||
async def list_versions(
|
||||
_admin: User = Depends(require_admin()),
|
||||
):
|
||||
"""管理端:获取所有版本历史。"""
|
||||
data = _load_versions()
|
||||
return {
|
||||
@@ -140,6 +131,7 @@ async def create_version(
|
||||
platform: str = Form("android"),
|
||||
force_update_min: str = Form(""),
|
||||
release_notes: str = Form(""),
|
||||
_admin: User = Depends(require_admin()),
|
||||
):
|
||||
"""管理端:上传 APK 并创建新版本。"""
|
||||
_ensure_dirs()
|
||||
@@ -189,7 +181,10 @@ async def create_version(
|
||||
|
||||
|
||||
@router.delete("/versions/{version_str}")
|
||||
async def delete_version(version_str: str):
|
||||
async def delete_version(
|
||||
version_str: str,
|
||||
_admin: User = Depends(require_admin()),
|
||||
):
|
||||
"""管理端:删除版本记录(不删除 APK 文件)。"""
|
||||
data = _load_versions()
|
||||
versions = data.get("versions", [])
|
||||
|
||||
@@ -12,6 +12,7 @@ from pydantic import BaseModel
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.api.auth import get_current_user
|
||||
from app.api.deps import require_admin
|
||||
from app.models.user import User
|
||||
from app.models.audit_log import AuditLog
|
||||
|
||||
@@ -52,20 +53,13 @@ class AuditLogStats(BaseModel):
|
||||
by_resource: dict # {"agent": 8, "workflow": 7, ...}
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
def _check_admin(current_user: User):
|
||||
if getattr(current_user, "role", None) != "admin":
|
||||
from app.core.exceptions import ForbiddenError
|
||||
raise ForbiddenError("仅管理员可访问审计日志")
|
||||
|
||||
|
||||
# ── Endpoints ────────────────────────────────────────────────────
|
||||
|
||||
@router.get("", response_model=List[AuditLogItem])
|
||||
async def get_audit_logs(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_admin: User = Depends(require_admin()),
|
||||
user_id: Optional[str] = Query(None, description="按用户ID过滤"),
|
||||
action: Optional[str] = Query(None, description="操作类型: CREATE/UPDATE/DELETE/EXECUTE/LOGIN"),
|
||||
resource_type: Optional[str] = Query(None, description="资源类型: agent/workflow/user"),
|
||||
@@ -77,8 +71,6 @@ async def get_audit_logs(
|
||||
limit: int = Query(50, ge=1, le=1000),
|
||||
):
|
||||
"""查询操作审计日志(仅管理员)"""
|
||||
_check_admin(current_user)
|
||||
|
||||
query = db.query(AuditLog)
|
||||
|
||||
if user_id:
|
||||
@@ -106,10 +98,9 @@ async def get_audit_logs(
|
||||
async def get_audit_logs_stats(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_admin: User = Depends(require_admin()),
|
||||
):
|
||||
"""获取审计日志统计(仅管理员)"""
|
||||
_check_admin(current_user)
|
||||
|
||||
total = db.query(func.count(AuditLog.id)).scalar() or 0
|
||||
|
||||
action_rows = db.query(
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
"""
|
||||
认证相关API
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status, Form
|
||||
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel, field_validator
|
||||
import re
|
||||
import secrets
|
||||
import uuid
|
||||
import io
|
||||
import base64
|
||||
import random
|
||||
import string
|
||||
import logging
|
||||
from app.core.database import get_db
|
||||
from app.core.security import (
|
||||
@@ -155,25 +160,162 @@ def _get_user_default_workspace_id(db: Session, user: User) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
# ─── 图形验证码 & 登录失败计数 ───────────────────────────────
|
||||
CAPTCHA_TTL_SEC = 120 # 验证码 2 分钟有效
|
||||
LOGIN_FAIL_TTL_SEC = 15 * 60 # 失败计数窗口 15 分钟
|
||||
LOGIN_CAPTCHA_THRESHOLD = 3 # 失败达到此次数后要求验证码
|
||||
REMEMBER_ME_EXPIRE_MINUTES = 30 * 24 * 60 # 记住我:30 天
|
||||
|
||||
|
||||
def _gen_captcha_text(length: int = 4) -> str:
|
||||
# 去除易混淆字符 0/O/1/I/L
|
||||
alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
|
||||
return "".join(random.choice(alphabet) for _ in range(length))
|
||||
|
||||
|
||||
def _render_captcha_image(text: str) -> str:
|
||||
"""用 Pillow 生成验证码 PNG,返回 data:image/png;base64,... 字符串。"""
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
width, height = 120, 40
|
||||
img = Image.new("RGB", (width, height), (245, 247, 250))
|
||||
draw = ImageDraw.Draw(img)
|
||||
try:
|
||||
font = ImageFont.truetype("arial.ttf", 28)
|
||||
except Exception:
|
||||
font = ImageFont.load_default()
|
||||
# 干扰线
|
||||
for _ in range(5):
|
||||
draw.line(
|
||||
[(random.randint(0, width), random.randint(0, height)),
|
||||
(random.randint(0, width), random.randint(0, height))],
|
||||
fill=tuple(random.randint(150, 200) for _ in range(3)),
|
||||
width=1,
|
||||
)
|
||||
# 逐字绘制,带随机颜色与位置抖动
|
||||
for i, ch in enumerate(text):
|
||||
draw.text(
|
||||
(10 + i * 26 + random.randint(-2, 2), random.randint(2, 8)),
|
||||
ch,
|
||||
font=font,
|
||||
fill=(random.randint(20, 90), random.randint(20, 90), random.randint(90, 160)),
|
||||
)
|
||||
# 干扰点
|
||||
for _ in range(60):
|
||||
draw.point((random.randint(0, width), random.randint(0, height)),
|
||||
fill=tuple(random.randint(120, 200) for _ in range(3)))
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
|
||||
|
||||
|
||||
def _verify_captcha(captcha_id: str, code: str) -> bool:
|
||||
"""校验图形验证码(一次性,校验后立即删除)。"""
|
||||
if not captcha_id or not code:
|
||||
return False
|
||||
r = get_redis_client()
|
||||
if not r:
|
||||
return False
|
||||
key = f"captcha:{captcha_id}"
|
||||
stored = r.get(key)
|
||||
if stored is None:
|
||||
return False
|
||||
r.delete(key) # 一次性,无论对错都作废
|
||||
if isinstance(stored, bytes):
|
||||
stored = stored.decode()
|
||||
return str(stored).upper() == code.strip().upper()
|
||||
|
||||
|
||||
def _fail_key(username: str) -> str:
|
||||
return f"login_fail:{(username or '').strip().lower()}"
|
||||
|
||||
|
||||
def _get_login_fail_count(username: str) -> int:
|
||||
r = get_redis_client()
|
||||
if not r:
|
||||
return 0
|
||||
v = r.get(_fail_key(username))
|
||||
try:
|
||||
return int(v) if v is not None else 0
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def _incr_login_fail(username: str) -> int:
|
||||
r = get_redis_client()
|
||||
if not r:
|
||||
return 0
|
||||
key = _fail_key(username)
|
||||
count = r.incr(key)
|
||||
if count == 1:
|
||||
r.expire(key, LOGIN_FAIL_TTL_SEC)
|
||||
return int(count)
|
||||
|
||||
|
||||
def _reset_login_fail(username: str):
|
||||
r = get_redis_client()
|
||||
if r:
|
||||
r.delete(_fail_key(username))
|
||||
|
||||
|
||||
@router.get("/captcha")
|
||||
async def get_captcha():
|
||||
"""生成图形验证码,返回 {captcha_id, image(data URL)}。"""
|
||||
text = _gen_captcha_text()
|
||||
captcha_id = str(uuid.uuid4())
|
||||
r = get_redis_client()
|
||||
if r:
|
||||
r.setex(f"captcha:{captcha_id}", CAPTCHA_TTL_SEC, text)
|
||||
return {"captcha_id": captcha_id, "image": _render_captcha_image(text)}
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login(
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
remember_me: bool = Form(False),
|
||||
captcha_id: str = Form(""),
|
||||
captcha: str = Form(""),
|
||||
db: Session = Depends(get_db),
|
||||
client_type: str = "web"
|
||||
):
|
||||
"""用户登录。client_type=android/ios 时签发 7 天 token,web 默认 30 分钟。"""
|
||||
user = db.query(User).filter(User.username == form_data.username).first()
|
||||
"""用户登录。
|
||||
|
||||
- client_type=android/ios 时签发 7 天 token;
|
||||
- web 端默认 30 分钟,勾选「记住我」(remember_me) 则签发 30 天;
|
||||
- 同一用户名连续失败 >= 3 次后,必须携带图形验证码 (captcha_id + captcha)。
|
||||
"""
|
||||
username = form_data.username
|
||||
fail_count = _get_login_fail_count(username)
|
||||
|
||||
# 失败次数达到阈值:强制校验图形验证码
|
||||
if fail_count >= LOGIN_CAPTCHA_THRESHOLD:
|
||||
if not _verify_captcha(captcha_id, captcha):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={"message": "验证码错误或已过期", "captcha_required": True},
|
||||
)
|
||||
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
|
||||
if not user or not verify_password(form_data.password, user.password_hash):
|
||||
logger.warning(f"登录失败: 用户名 {form_data.username}, user_found={user is not None}, pwd_len={len(form_data.password)}")
|
||||
if user:
|
||||
logger.warning(f" DB hash prefix: {user.password_hash[:30]}...")
|
||||
raise UnauthorizedError("用户名或密码错误")
|
||||
new_count = _incr_login_fail(username)
|
||||
logger.warning(f"登录失败: 用户名 {username}, user_found={user is not None}, 失败次数={new_count}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={
|
||||
"message": "用户名或密码错误",
|
||||
"captcha_required": new_count >= LOGIN_CAPTCHA_THRESHOLD,
|
||||
},
|
||||
)
|
||||
|
||||
# 登录成功:清零失败计数
|
||||
_reset_login_fail(username)
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
if client_type in ("android", "ios"):
|
||||
expires = timedelta(minutes=settings.JWT_MOBILE_TOKEN_EXPIRE_MINUTES)
|
||||
elif remember_me:
|
||||
expires = timedelta(minutes=REMEMBER_ME_EXPIRE_MINUTES)
|
||||
else:
|
||||
expires = timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
|
||||
@@ -198,27 +340,36 @@ async def login(
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: Session = Depends(get_db)
|
||||
token: str = Depends(oauth2_scheme_optional),
|
||||
db: Session = Depends(get_db),
|
||||
request: Request = None,
|
||||
) -> User:
|
||||
"""FastAPI 依赖 — 从 JWT 提取当前用户,返回 User 模型。"""
|
||||
"""FastAPI 依赖 — 从 JWT 或 X-API-Key 提取当前用户,返回 User 模型。
|
||||
|
||||
优先级:JWT Bearer Token > X-API-Key Header
|
||||
"""
|
||||
from app.core.security import decode_access_token
|
||||
from app.models.api_key import verify_api_key as _verify_api_key
|
||||
|
||||
payload = decode_access_token(token)
|
||||
if payload is None:
|
||||
raise UnauthorizedError("无效的访问令牌")
|
||||
# 尝试 JWT 认证
|
||||
if token and token not in ("undefined", "null"):
|
||||
payload = decode_access_token(token)
|
||||
if payload:
|
||||
user_id = payload.get("sub")
|
||||
if user_id:
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if user and user.status != "deleted":
|
||||
return user
|
||||
|
||||
user_id = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise UnauthorizedError("无效的访问令牌")
|
||||
# 尝试 API Key 认证
|
||||
if request:
|
||||
api_key_str = request.headers.get("X-API-Key", "").strip()
|
||||
if api_key_str:
|
||||
api_key = _verify_api_key(api_key_str, db)
|
||||
if api_key:
|
||||
return api_key.user
|
||||
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if user is None:
|
||||
raise NotFoundError("用户", user_id)
|
||||
if user.status == "deleted":
|
||||
raise UnauthorizedError("账号已注销")
|
||||
|
||||
return user
|
||||
raise UnauthorizedError("未提供有效的认证令牌")
|
||||
|
||||
|
||||
@router.get("/me", response_model=MeResponse)
|
||||
|
||||
801
backend/app/api/companies.py
Normal file
801
backend/app/api/companies.py
Normal file
@@ -0,0 +1,801 @@
|
||||
"""
|
||||
虚拟公司 API — 创建 / 管理 / 执行
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.auth import get_current_user
|
||||
from app.core.database import get_db
|
||||
from app.models.user import User
|
||||
from app.models.agent import Agent
|
||||
from app.models.company import Company, CompanyProject
|
||||
from app.models.team import Team, TeamMember
|
||||
from app.models.company_schedule import CompanySchedule
|
||||
from app.models.company_knowledge import CompanyKnowledge
|
||||
from app.services.company_orchestrator import CompanyOrchestrator
|
||||
from app.services.company_presets import COMPANY_PRESETS
|
||||
from app.services.company_knowledge_extractor import extract_from_project
|
||||
from app.services.insight_analyzer import analyze_company
|
||||
from app.services.scheduler import register_schedule, unregister_schedule
|
||||
from app.services.project_supervisor import ProjectSupervisor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/v1/companies",
|
||||
tags=["companies"],
|
||||
responses={401: {"description": "未授权"}, 404: {"description": "资源不存在"}},
|
||||
)
|
||||
|
||||
# ─── Schemas ───
|
||||
|
||||
|
||||
class CompanyCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
description: Optional[str] = None
|
||||
industry: Optional[str] = None
|
||||
workspace_id: Optional[str] = None
|
||||
ceo_agent_id: Optional[str] = None
|
||||
|
||||
|
||||
class CompanyUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
description: Optional[str] = None
|
||||
industry: Optional[str] = None
|
||||
|
||||
|
||||
class DepartmentAdd(BaseModel):
|
||||
team_id: str = Field(..., description="已有Team ID")
|
||||
|
||||
|
||||
class ExecuteRequest(BaseModel):
|
||||
project_description: str = Field(..., min_length=1, description="公司级项目目标")
|
||||
|
||||
|
||||
# ─── Endpoints ───
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_companies(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""列出用户的所有虚拟公司。"""
|
||||
companies = (
|
||||
db.query(Company)
|
||||
.filter(Company.user_id == current_user.id, Company.status != "archived")
|
||||
.order_by(Company.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
result = []
|
||||
for c in companies:
|
||||
dept_count = db.query(Team).filter(Team.parent_company_id == c.id).count()
|
||||
d = c.to_dict()
|
||||
d["department_count"] = dept_count
|
||||
result.append(d)
|
||||
return {"companies": result, "total": len(result)}
|
||||
|
||||
|
||||
@router.get("/{company_id}")
|
||||
def get_company(
|
||||
company_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""获取公司详情(含部门列表)。"""
|
||||
company = db.query(Company).filter(Company.id == company_id).first()
|
||||
if not company:
|
||||
raise HTTPException(status_code=404, detail="公司不存在")
|
||||
departments = db.query(Team).filter(Team.parent_company_id == company_id).all()
|
||||
return {
|
||||
**company.to_dict(),
|
||||
"departments": [d.to_dict(include_members=True) for d in departments],
|
||||
}
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
def create_company(
|
||||
body: CompanyCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""手动创建虚拟公司。"""
|
||||
company = Company(
|
||||
id=str(uuid.uuid4()),
|
||||
name=body.name,
|
||||
description=body.description,
|
||||
industry=body.industry,
|
||||
workspace_id=body.workspace_id,
|
||||
user_id=current_user.id,
|
||||
ceo_agent_id=body.ceo_agent_id,
|
||||
config={},
|
||||
)
|
||||
db.add(company)
|
||||
db.commit()
|
||||
return company.to_dict()
|
||||
|
||||
|
||||
@router.post("/template/{preset_type}", status_code=201)
|
||||
def create_company_from_preset(
|
||||
preset_type: str,
|
||||
workspace_id: Optional[str] = Query(None),
|
||||
company_name: Optional[str] = Query(None, description="自定义公司名称,覆盖预设默认名"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""从行业预设一键创建虚拟公司(含部门 + 团队)。
|
||||
|
||||
基于 company_presets.py 的角色定义,自动创建公司 + 多个部门 + Agent + 填充成员。
|
||||
可通过 company_name 自定义公司名称。
|
||||
"""
|
||||
preset = COMPANY_PRESETS.get(preset_type)
|
||||
if not preset:
|
||||
raise HTTPException(status_code=400, detail=f"未知预设类型: {preset_type}")
|
||||
|
||||
# 1. 创建公司 (支持自定义名称)
|
||||
company_name_final = company_name.strip() if company_name else preset["name"]
|
||||
company = Company(
|
||||
id=str(uuid.uuid4()),
|
||||
name=company_name_final,
|
||||
description=preset["description"],
|
||||
industry=preset["id"],
|
||||
workspace_id=workspace_id,
|
||||
user_id=current_user.id,
|
||||
config={"preset_type": preset_type},
|
||||
)
|
||||
db.add(company)
|
||||
db.flush()
|
||||
|
||||
# 2. 按分组创建部门和 Agent
|
||||
groups: Dict[str, List[Dict]] = {}
|
||||
for role in preset["roles"]:
|
||||
g = role["group"]
|
||||
if g not in groups:
|
||||
groups[g] = []
|
||||
groups[g].append(role)
|
||||
|
||||
created_departments = []
|
||||
|
||||
for group_name, roles in groups.items():
|
||||
# 找到 leader(第一个角色)
|
||||
leader = roles[0]
|
||||
|
||||
# 创建部门的 Agent(复用或新建)
|
||||
dept_agents = []
|
||||
for rc in roles:
|
||||
existing = (
|
||||
db.query(Agent)
|
||||
.filter(Agent.name == rc["name"], Agent.user_id == current_user.id)
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
dept_agents.append(existing)
|
||||
continue
|
||||
|
||||
agent = Agent(
|
||||
id=str(uuid.uuid4()),
|
||||
name=rc["name"],
|
||||
description=rc["description"],
|
||||
agent_type="specialist",
|
||||
user_id=current_user.id,
|
||||
workspace_id=workspace_id,
|
||||
workflow_config={
|
||||
"nodes": [
|
||||
{"id": "start-1", "type": "start", "position": {"x": 80, "y": 120}, "data": {}},
|
||||
{
|
||||
"id": "llm-1",
|
||||
"type": "llm",
|
||||
"position": {"x": 320, "y": 120},
|
||||
"data": {
|
||||
"prompt": rc["system_prompt"],
|
||||
"temperature": rc["temperature"],
|
||||
"model": rc["model"],
|
||||
"provider": "deepseek",
|
||||
"enable_tools": True,
|
||||
"tools": rc["tools"],
|
||||
"selected_tools": rc["tools"],
|
||||
"max_iterations": rc["max_iterations"],
|
||||
},
|
||||
},
|
||||
{"id": "end-1", "type": "end", "position": {"x": 560, "y": 120}, "data": {}},
|
||||
],
|
||||
"edges": [
|
||||
{"id": "e1", "source": "start-1", "target": "llm-1", "sourceHandle": "right", "targetHandle": "left"},
|
||||
{"id": "e2", "source": "llm-1", "target": "end-1", "sourceHandle": "right", "targetHandle": "left"},
|
||||
],
|
||||
},
|
||||
status="published",
|
||||
category="company_department",
|
||||
tags=[rc["role"], preset_type, "company_department"],
|
||||
)
|
||||
db.add(agent)
|
||||
db.flush()
|
||||
dept_agents.append(agent)
|
||||
|
||||
# 创建部门(Team)
|
||||
dept = Team(
|
||||
id=str(uuid.uuid4()),
|
||||
name=f"{company_name_final}-{group_name}",
|
||||
description=f"{company_name_final} {group_name}部门",
|
||||
user_id=current_user.id,
|
||||
workspace_id=workspace_id,
|
||||
department_type=_map_group_to_dept_type(group_name),
|
||||
parent_company_id=company.id,
|
||||
config={"workflow": preset_type, "department": True},
|
||||
)
|
||||
db.add(dept)
|
||||
db.flush()
|
||||
|
||||
# 添加成员
|
||||
for i, ag in enumerate(dept_agents):
|
||||
member = TeamMember(
|
||||
id=str(uuid.uuid4()),
|
||||
team_id=dept.id,
|
||||
agent_id=ag.id,
|
||||
role=roles[i]["role"] if i < len(roles) else "member",
|
||||
position=i,
|
||||
is_lead=(i == 0),
|
||||
)
|
||||
db.add(member)
|
||||
|
||||
created_departments.append({
|
||||
"id": dept.id,
|
||||
"name": dept.name,
|
||||
"department_type": dept.department_type,
|
||||
"members": [{"agent_id": ag.id, "name": ag.name, "role": roles[j]["role"] if j < len(roles) else "member", "is_lead": j == 0} for j, ag in enumerate(dept_agents)],
|
||||
})
|
||||
|
||||
# 3. 设置 CEO(第一个部门的 leader 作为CEO,或查已有CEO Agent)
|
||||
ceo_candidate = (
|
||||
db.query(Agent)
|
||||
.filter(Agent.name.like("%CEO%"), Agent.user_id == current_user.id)
|
||||
.first()
|
||||
)
|
||||
if not ceo_candidate and created_departments and created_departments[0]["members"]:
|
||||
ceo_candidate = db.query(Agent).filter(Agent.id == created_departments[0]["members"][0]["agent_id"]).first()
|
||||
if ceo_candidate:
|
||||
company.ceo_agent_id = ceo_candidate.id
|
||||
|
||||
db.commit()
|
||||
db.refresh(company)
|
||||
|
||||
logger.info(
|
||||
"从预设创建虚拟公司: preset=%s company=%s departments=%d",
|
||||
preset_type, company.id, len(created_departments),
|
||||
)
|
||||
|
||||
return {
|
||||
**company.to_dict(),
|
||||
"departments": created_departments,
|
||||
"total_agents": sum(len(d["members"]) for d in created_departments),
|
||||
}
|
||||
|
||||
|
||||
@router.put("/{company_id}")
|
||||
def update_company(
|
||||
company_id: str,
|
||||
body: CompanyUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""更新公司信息(名称、描述、行业)。"""
|
||||
company = db.query(Company).filter(Company.id == company_id).first()
|
||||
if not company:
|
||||
raise HTTPException(status_code=404, detail="公司不存在")
|
||||
if company.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="无权操作")
|
||||
if body.name is not None:
|
||||
company.name = body.name
|
||||
if body.description is not None:
|
||||
company.description = body.description
|
||||
if body.industry is not None:
|
||||
company.industry = body.industry
|
||||
db.commit()
|
||||
db.refresh(company)
|
||||
return company.to_dict()
|
||||
|
||||
|
||||
@router.delete("/{company_id}")
|
||||
def delete_company(
|
||||
company_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""归档公司(软删除)。"""
|
||||
company = db.query(Company).filter(Company.id == company_id).first()
|
||||
if not company:
|
||||
raise HTTPException(status_code=404, detail="公司不存在")
|
||||
if company.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="无权操作")
|
||||
company.status = "archived"
|
||||
db.commit()
|
||||
return {"message": "公司已归档"}
|
||||
|
||||
|
||||
# ─── 部门管理 ───
|
||||
|
||||
|
||||
@router.post("/{company_id}/departments", status_code=201)
|
||||
def add_department(
|
||||
company_id: str,
|
||||
body: DepartmentAdd,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""将已有团队添加为公司部门。"""
|
||||
company = db.query(Company).filter(Company.id == company_id).first()
|
||||
if not company:
|
||||
raise HTTPException(status_code=404, detail="公司不存在")
|
||||
team = db.query(Team).filter(Team.id == body.team_id, Team.user_id == current_user.id).first()
|
||||
if not team:
|
||||
raise HTTPException(status_code=404, detail="团队不存在")
|
||||
if team.parent_company_id:
|
||||
raise HTTPException(status_code=400, detail="该团队已属于其他公司")
|
||||
team.parent_company_id = company_id
|
||||
db.commit()
|
||||
return team.to_dict(include_members=True)
|
||||
|
||||
|
||||
@router.delete("/{company_id}/departments/{department_id}")
|
||||
def remove_department(
|
||||
company_id: str,
|
||||
department_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""从公司移除部门(不解散团队)。"""
|
||||
team = db.query(Team).filter(Team.id == department_id, Team.parent_company_id == company_id).first()
|
||||
if not team:
|
||||
raise HTTPException(status_code=404, detail="部门不存在")
|
||||
team.parent_company_id = None
|
||||
db.commit()
|
||||
return {"message": "部门已移除"}
|
||||
|
||||
|
||||
# ─── 项目执行 ───
|
||||
|
||||
|
||||
@router.post("/{company_id}/execute")
|
||||
async def execute_company(
|
||||
company_id: str,
|
||||
body: ExecuteRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""执行公司级项目(非流式)。"""
|
||||
company = db.query(Company).filter(Company.id == company_id).first()
|
||||
if not company:
|
||||
raise HTTPException(status_code=404, detail="公司不存在")
|
||||
orchestrator = CompanyOrchestrator(db, company_id, current_user.id)
|
||||
try:
|
||||
result = await orchestrator.execute(body.project_description)
|
||||
return result
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{company_id}/execute/stream")
|
||||
async def execute_company_stream(
|
||||
company_id: str,
|
||||
body: ExecuteRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""执行公司级项目(SSE 流式)。"""
|
||||
company = db.query(Company).filter(Company.id == company_id).first()
|
||||
if not company:
|
||||
raise HTTPException(status_code=404, detail="公司不存在")
|
||||
|
||||
orchestrator = CompanyOrchestrator(db, company_id, current_user.id)
|
||||
|
||||
async def event_stream():
|
||||
try:
|
||||
async for event in orchestrator.execute_stream(body.project_description):
|
||||
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
|
||||
except Exception as e:
|
||||
yield f"data: {json.dumps({'type': 'error', 'message': str(e)}, ensure_ascii=False)}\n\n"
|
||||
|
||||
return StreamingResponse(event_stream(), media_type="text/event-stream")
|
||||
|
||||
|
||||
# ─── 统计 ───
|
||||
|
||||
|
||||
@router.get("/{company_id}/stats")
|
||||
def get_company_stats(
|
||||
company_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""获取公司统计信息(项目数、成功率、最近活跃时间)。"""
|
||||
projects = (
|
||||
db.query(CompanyProject)
|
||||
.filter(CompanyProject.company_id == company_id)
|
||||
.order_by(CompanyProject.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
total = len(projects)
|
||||
completed = sum(1 for p in projects if p.status == "completed")
|
||||
success_rate = round(completed / total * 100, 1) if total > 0 else 0
|
||||
last_active = projects[0].created_at.isoformat() if projects else None
|
||||
return {
|
||||
"total_projects": total,
|
||||
"completed_projects": completed,
|
||||
"success_rate": success_rate,
|
||||
"last_active_at": last_active,
|
||||
}
|
||||
|
||||
|
||||
# ─── 导出 ───
|
||||
|
||||
|
||||
@router.get("/{company_id}/export")
|
||||
def export_company(
|
||||
company_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""导出公司架构 + 所有项目结果为 Markdown 文件。"""
|
||||
from fastapi.responses import Response
|
||||
|
||||
company = db.query(Company).filter(Company.id == company_id).first()
|
||||
if not company:
|
||||
raise HTTPException(status_code=404, detail="公司不存在")
|
||||
|
||||
departments = db.query(Team).filter(Team.parent_company_id == company_id).all()
|
||||
projects = (
|
||||
db.query(CompanyProject)
|
||||
.filter(CompanyProject.company_id == company_id)
|
||||
.order_by(CompanyProject.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
lines = [
|
||||
f"# {company.name}",
|
||||
"",
|
||||
f"- **行业**: {company.industry or '通用'}",
|
||||
f"- **描述**: {company.description or '无'}",
|
||||
f"- **创建时间**: {company.created_at.isoformat() if company.created_at else '未知'}",
|
||||
f"- **状态**: {company.status}",
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
"## 组织架构",
|
||||
"",
|
||||
]
|
||||
|
||||
for i, dept in enumerate(departments, 1):
|
||||
lines.append(f"### {i}. {dept.name}")
|
||||
lines.append(f"- **类型**: {dept.department_type or '通用'}")
|
||||
if dept.members:
|
||||
lines.append("- **成员**:")
|
||||
for m in dept.members:
|
||||
agent_name = m.agent.name if hasattr(m, 'agent') and m.agent else m.agent_id
|
||||
lines.append(f" - {agent_name} ({m.role})" + (" [Leader]" if m.is_lead else ""))
|
||||
lines.append("")
|
||||
|
||||
lines.extend([
|
||||
"---",
|
||||
"",
|
||||
f"## 项目历史 ({len(projects)} 个项目)",
|
||||
"",
|
||||
])
|
||||
|
||||
for i, proj in enumerate(projects, 1):
|
||||
lines.append(f"### {i}. {proj.name}")
|
||||
lines.append(f"- **状态**: {proj.status}")
|
||||
lines.append(f"- **创建时间**: {proj.created_at.isoformat() if proj.created_at else '未知'}")
|
||||
if proj.completed_at:
|
||||
lines.append(f"- **完成时间**: {proj.completed_at.isoformat()}")
|
||||
if proj.ceo_plan:
|
||||
lines.append(f"- **CEO 规划**:")
|
||||
analysis = proj.ceo_plan.get("analysis", "") if isinstance(proj.ceo_plan, dict) else ""
|
||||
if analysis:
|
||||
lines.append(f" {analysis}")
|
||||
dept_plans = proj.ceo_plan.get("departments", []) if isinstance(proj.ceo_plan, dict) else []
|
||||
if dept_plans:
|
||||
lines.append(" - **部门任务**:")
|
||||
for dp in dept_plans:
|
||||
lines.append(f" - {dp.get('department_name', '未知')}: {dp.get('goal', '')}")
|
||||
lines.append("")
|
||||
|
||||
markdown = "\n".join(lines)
|
||||
return Response(
|
||||
content=markdown,
|
||||
media_type="text/markdown; charset=utf-8",
|
||||
headers={"Content-Disposition": f'attachment; filename="{company.name}_export.md"'},
|
||||
)
|
||||
|
||||
|
||||
# ─── 项目历史 ───
|
||||
|
||||
|
||||
@router.get("/{company_id}/projects")
|
||||
def list_projects(
|
||||
company_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""列出公司的项目执行历史。"""
|
||||
projects = (
|
||||
db.query(CompanyProject)
|
||||
.filter(CompanyProject.company_id == company_id)
|
||||
.order_by(CompanyProject.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
return {"projects": [p.to_dict() for p in projects]}
|
||||
|
||||
|
||||
def _map_group_to_dept_type(group_name: str) -> str:
|
||||
"""将角色分组名映射到部门类型。"""
|
||||
mapping = {
|
||||
"战略层": "executive",
|
||||
"产品层": "product",
|
||||
"交付层": "engineering",
|
||||
"运营层": "operations",
|
||||
"服务层": "sales",
|
||||
"业务层": "marketing",
|
||||
"供应链": "operations",
|
||||
"支持层": "hr",
|
||||
"执行层": "marketing",
|
||||
"创意层": "creative",
|
||||
"制作层": "production",
|
||||
}
|
||||
return mapping.get(group_name, "operations")
|
||||
|
||||
|
||||
# ─── 定时调度 ───
|
||||
|
||||
|
||||
class ScheduleCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=200)
|
||||
project_goal: str = Field(..., min_length=1)
|
||||
cron_expression: Optional[str] = None
|
||||
interval_minutes: Optional[str] = None
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class ScheduleUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
project_goal: Optional[str] = None
|
||||
cron_expression: Optional[str] = None
|
||||
interval_minutes: Optional[str] = None
|
||||
enabled: Optional[bool] = None
|
||||
|
||||
|
||||
@router.get("/{company_id}/schedules")
|
||||
def list_schedules(company_id: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""列出公司的定时调度。"""
|
||||
schedules = db.query(CompanySchedule).filter(CompanySchedule.company_id == company_id).order_by(CompanySchedule.created_at.desc()).all()
|
||||
return {"schedules": [s.to_dict() for s in schedules]}
|
||||
|
||||
|
||||
@router.post("/{company_id}/schedules", status_code=201)
|
||||
def create_schedule(company_id: str, body: ScheduleCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""创建定时调度。"""
|
||||
import uuid
|
||||
cs = CompanySchedule(
|
||||
id=str(uuid.uuid4()),
|
||||
company_id=company_id,
|
||||
name=body.name,
|
||||
project_goal=body.project_goal,
|
||||
cron_expression=body.cron_expression,
|
||||
interval_minutes=body.interval_minutes,
|
||||
enabled=body.enabled,
|
||||
)
|
||||
db.add(cs)
|
||||
db.commit()
|
||||
if cs.enabled and cs.cron_expression:
|
||||
try:
|
||||
register_schedule(cs)
|
||||
db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
return cs.to_dict()
|
||||
|
||||
|
||||
@router.put("/schedules/{schedule_id}")
|
||||
def update_schedule(schedule_id: str, body: ScheduleUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""更新调度。"""
|
||||
cs = db.query(CompanySchedule).filter(CompanySchedule.id == schedule_id).first()
|
||||
if not cs:
|
||||
raise HTTPException(status_code=404, detail="调度不存在")
|
||||
if body.name is not None:
|
||||
cs.name = body.name
|
||||
if body.project_goal is not None:
|
||||
cs.project_goal = body.project_goal
|
||||
if body.cron_expression is not None:
|
||||
cs.cron_expression = body.cron_expression
|
||||
if body.interval_minutes is not None:
|
||||
cs.interval_minutes = body.interval_minutes
|
||||
if body.enabled is not None:
|
||||
cs.enabled = body.enabled
|
||||
db.commit()
|
||||
if cs.enabled and cs.cron_expression:
|
||||
try:
|
||||
register_schedule(cs)
|
||||
db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
unregister_schedule(schedule_id)
|
||||
return cs.to_dict()
|
||||
|
||||
|
||||
@router.delete("/schedules/{schedule_id}")
|
||||
def delete_schedule(schedule_id: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""删除调度。"""
|
||||
cs = db.query(CompanySchedule).filter(CompanySchedule.id == schedule_id).first()
|
||||
if not cs:
|
||||
raise HTTPException(status_code=404, detail="调度不存在")
|
||||
unregister_schedule(schedule_id)
|
||||
db.delete(cs)
|
||||
db.commit()
|
||||
return {"message": "调度已删除"}
|
||||
|
||||
|
||||
@router.post("/schedules/{schedule_id}/trigger")
|
||||
async def trigger_schedule(schedule_id: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""手动触发调度。"""
|
||||
from app.services.scheduler import _execute_scheduled_project
|
||||
cs = db.query(CompanySchedule).filter(CompanySchedule.id == schedule_id).first()
|
||||
if not cs:
|
||||
raise HTTPException(status_code=404, detail="调度不存在")
|
||||
try:
|
||||
await _execute_scheduled_project(schedule_id)
|
||||
return {"message": "调度已触发"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ─── 知识库 ───
|
||||
|
||||
|
||||
@router.post("/{company_id}/knowledge/generate", status_code=201)
|
||||
def generate_knowledge(company_id: str, project_id: str = Query(...), db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""从指定项目生成知识条目。"""
|
||||
project = db.query(CompanyProject).filter(CompanyProject.id == project_id, CompanyProject.company_id == company_id).first()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="项目不存在")
|
||||
entries = extract_from_project(project, db)
|
||||
db.commit()
|
||||
return {"message": f"已生成 {len(entries)} 条知识", "entries": [e.to_dict() for e in entries]}
|
||||
|
||||
|
||||
@router.get("/{company_id}/knowledge")
|
||||
def list_knowledge(company_id: str, search: Optional[str] = Query(None), category: Optional[str] = Query(None), db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""列出公司知识库。"""
|
||||
q = db.query(CompanyKnowledge).filter(CompanyKnowledge.company_id == company_id)
|
||||
if search:
|
||||
q = q.filter(CompanyKnowledge.title.contains(search) | CompanyKnowledge.content.contains(search))
|
||||
if category:
|
||||
q = q.filter(CompanyKnowledge.category == category)
|
||||
entries = q.order_by(CompanyKnowledge.created_at.desc()).all()
|
||||
return {"knowledge": [e.to_dict() for e in entries], "total": len(entries)}
|
||||
|
||||
|
||||
@router.get("/{company_id}/knowledge/{knowledge_id}")
|
||||
def get_knowledge(company_id: str, knowledge_id: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
entry = db.query(CompanyKnowledge).filter(CompanyKnowledge.id == knowledge_id, CompanyKnowledge.company_id == company_id).first()
|
||||
if not entry:
|
||||
raise HTTPException(status_code=404, detail="知识条目不存在")
|
||||
return entry.to_dict()
|
||||
|
||||
|
||||
@router.delete("/{company_id}/knowledge/{knowledge_id}")
|
||||
def delete_knowledge(company_id: str, knowledge_id: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
entry = db.query(CompanyKnowledge).filter(CompanyKnowledge.id == knowledge_id, CompanyKnowledge.company_id == company_id).first()
|
||||
if not entry:
|
||||
raise HTTPException(status_code=404, detail="知识条目不存在")
|
||||
db.delete(entry)
|
||||
db.commit()
|
||||
return {"message": "知识条目已删除"}
|
||||
|
||||
|
||||
# ─── 洞察分析 ───
|
||||
|
||||
|
||||
@router.get("/{company_id}/insights")
|
||||
def get_insights(company_id: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""获取公司跨项目洞察分析。"""
|
||||
result = analyze_company(company_id, db)
|
||||
return result
|
||||
|
||||
|
||||
# ─── 项目模板市场 ───
|
||||
|
||||
|
||||
@router.get("/templates/list")
|
||||
def list_public_templates(search: Optional[str] = Query(None), db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""列出所有已发布的公开项目模板。"""
|
||||
projects = db.query(CompanyProject).filter(CompanyProject.status == "completed").all()
|
||||
templates = []
|
||||
for p in projects:
|
||||
cfg = p.config or {}
|
||||
if cfg.get("is_public"):
|
||||
if search and search not in (p.name or "") and search not in (p.description or ""):
|
||||
continue
|
||||
templates.append({
|
||||
"id": p.id,
|
||||
"company_id": p.company_id,
|
||||
"name": p.name,
|
||||
"description": p.description,
|
||||
"ceo_plan_structure": {
|
||||
"departments": [{"department_name": d.get("department_name", ""), "department_type": d.get("department_type", ""), "goal": d.get("goal", "")} for d in (p.ceo_plan or {}).get("departments", [])]
|
||||
},
|
||||
"created_at": p.created_at.isoformat() if p.created_at else None,
|
||||
})
|
||||
return {"templates": templates, "total": len(templates)}
|
||||
|
||||
|
||||
@router.post("/{company_id}/projects/{project_id}/publish")
|
||||
def publish_project(company_id: str, project_id: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""将项目发布为公开模板。"""
|
||||
project = db.query(CompanyProject).filter(CompanyProject.id == project_id, CompanyProject.company_id == company_id).first()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="项目不存在")
|
||||
cfg = project.config or {}
|
||||
cfg["is_public"] = True
|
||||
project.config = cfg
|
||||
db.commit()
|
||||
return {"message": "项目已发布为公开模板"}
|
||||
|
||||
|
||||
@router.post("/{company_id}/projects/{project_id}/unpublish")
|
||||
def unpublish_project(company_id: str, project_id: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""取消公开模板发布。"""
|
||||
project = db.query(CompanyProject).filter(CompanyProject.id == project_id, CompanyProject.company_id == company_id).first()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="项目不存在")
|
||||
cfg = project.config or {}
|
||||
cfg["is_public"] = False
|
||||
project.config = cfg
|
||||
db.commit()
|
||||
return {"message": "已取消公开"}
|
||||
|
||||
|
||||
# ─── Project Supervisor / 项目监管 API ───
|
||||
|
||||
@router.get("/supervisor/zombies")
|
||||
def get_zombie_projects(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""获取所有僵尸项目(跨所有公司)。"""
|
||||
sup = ProjectSupervisor(db)
|
||||
return sup.get_zombie_summary()
|
||||
|
||||
|
||||
@router.get("/supervisor/in-progress")
|
||||
def get_in_progress_projects(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""获取所有运行中的项目及其风险状态。"""
|
||||
sup = ProjectSupervisor(db)
|
||||
return {"projects": sup.get_all_in_progress()}
|
||||
|
||||
|
||||
@router.post("/supervisor/scan")
|
||||
def trigger_supervisor_scan(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""手动触发一次僵尸项目扫描。"""
|
||||
sup = ProjectSupervisor(db)
|
||||
result = sup.auto_detect_and_mark()
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/{company_id}/projects/{project_id}/recover")
|
||||
def recover_project(company_id: str, project_id: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""恢复僵尸项目(标记为 failed 以便重新执行)。"""
|
||||
project = db.query(CompanyProject).filter(CompanyProject.id == project_id, CompanyProject.company_id == company_id).first()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="项目不存在")
|
||||
sup = ProjectSupervisor(db)
|
||||
result = sup.recover_project(project_id)
|
||||
if not result["success"]:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
return result
|
||||
87
backend/app/api/company_presets.py
Normal file
87
backend/app/api/company_presets.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""
|
||||
公司组织班底预设包 API — 一键创建全套 AI 数字员工
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.auth import get_current_user
|
||||
from app.core.database import get_db
|
||||
from app.models.user import User
|
||||
from app.services.company_presets import (
|
||||
list_company_presets,
|
||||
get_company_preset,
|
||||
create_company_preset,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/v1/company-presets",
|
||||
tags=["company-presets"],
|
||||
responses={
|
||||
401: {"description": "未授权"},
|
||||
400: {"description": "请求参数错误"},
|
||||
404: {"description": "预设包不存在"},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class CreateCompanyPresetRequest(BaseModel):
|
||||
workspace_id: Optional[str] = Field(None, description="工作区 ID")
|
||||
selected_roles: Optional[List[str]] = Field(None, description="要创建的角色 role key 列表,不传则创建全部")
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_presets(
|
||||
_current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""获取所有可用行业预设包列表(含角色预览,不含完整 prompt)。"""
|
||||
presets = list_company_presets()
|
||||
return {"presets": presets, "total": len(presets)}
|
||||
|
||||
|
||||
@router.get("/{preset_type}")
|
||||
def get_preset(
|
||||
preset_type: str,
|
||||
_current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""获取单个行业预设包的详细信息(含全部角色配置)。"""
|
||||
preset = get_company_preset(preset_type)
|
||||
if not preset:
|
||||
raise HTTPException(status_code=404, detail=f"预设包 '{preset_type}' 不存在")
|
||||
return preset
|
||||
|
||||
|
||||
@router.post("/{preset_type}", status_code=201)
|
||||
def create_preset(
|
||||
preset_type: str,
|
||||
body: CreateCompanyPresetRequest = CreateCompanyPresetRequest(),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""一键创建全套公司 AI 数字员工。
|
||||
|
||||
支持通过 `selected_roles` 字段筛选要创建的角色,不传则创建预设包的全部角色。
|
||||
"""
|
||||
try:
|
||||
result = create_company_preset(
|
||||
db=db,
|
||||
preset_type=preset_type,
|
||||
user_id=current_user.id,
|
||||
workspace_id=body.workspace_id,
|
||||
selected_roles=body.selected_roles,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
logger.info(
|
||||
"用户 %s 创建公司预设包 %s: 新建=%d 复用=%d",
|
||||
current_user.id, preset_type, result["created"], result["reused"],
|
||||
)
|
||||
return result
|
||||
@@ -10,7 +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.api.deps import require_workspace_admin, WorkspaceContext, get_current_workspace_id
|
||||
from app.models.user import User
|
||||
from app.core.exceptions import NotFoundError, ValidationError
|
||||
from app.services.data_source_connector import DataSourceConnector
|
||||
@@ -60,11 +60,13 @@ async def get_data_sources(
|
||||
type: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
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(DataSource).filter(
|
||||
DataSource.user_id == current_user.id
|
||||
DataSource.user_id == current_user.id,
|
||||
DataSource.workspace_id == workspace_id,
|
||||
)
|
||||
|
||||
if type:
|
||||
@@ -121,7 +123,8 @@ async def get_data_source(
|
||||
"""获取数据源详情"""
|
||||
data_source = db.query(DataSource).filter(
|
||||
DataSource.id == data_source_id,
|
||||
DataSource.user_id == current_user.id
|
||||
DataSource.user_id == current_user.id,
|
||||
DataSource.workspace_id == workspace_id,
|
||||
).first()
|
||||
|
||||
if not data_source:
|
||||
|
||||
@@ -195,3 +195,87 @@ def _user_has_permission(db: Session, user_id: str, permission_code: str) -> boo
|
||||
.first()
|
||||
)
|
||||
return result is not None
|
||||
|
||||
|
||||
# ─── 统一权限检查依赖 (v2.0) ───
|
||||
|
||||
class require_permission:
|
||||
"""统一权限检查依赖 — 替换散落的 ``role == "admin"`` 判断。
|
||||
|
||||
用法::
|
||||
|
||||
@router.get("/agents")
|
||||
async def list_agents(
|
||||
ctx: WorkspaceContext = Depends(require_permission("agent:read")),
|
||||
):
|
||||
...
|
||||
|
||||
检查顺序:
|
||||
1. 平台管理员(user.role == "admin")→ 直接通过
|
||||
2. Workspace 管理员 → 在该 workspace 内通过
|
||||
3. 系统 RBAC → 检查用户角色是否拥有该权限码
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
if _user_has_permission(db, ctx.user.id, self.permission_code):
|
||||
return ctx
|
||||
|
||||
raise HTTPException(status_code=403, detail=f"缺少权限: {self.permission_code}")
|
||||
|
||||
|
||||
class require_admin:
|
||||
"""平台管理员权限检查 — 仅平台管理员可访问。
|
||||
|
||||
用法::
|
||||
|
||||
@router.delete("/users/{id}")
|
||||
async def delete_user(
|
||||
current_user: User = Depends(require_admin()),
|
||||
):
|
||||
...
|
||||
"""
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> User:
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if not auth_header.startswith("Bearer "):
|
||||
raise HTTPException(status_code=401, detail="未提供有效的认证令牌")
|
||||
token = auth_header[7:]
|
||||
payload = decode_access_token(token)
|
||||
if payload is None:
|
||||
raise HTTPException(status_code=401, detail="无效的访问令牌")
|
||||
user_id = payload.get("sub")
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="无效的访问令牌")
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="用户不存在")
|
||||
if user.status == "deleted":
|
||||
raise HTTPException(status_code=403, detail="账号已注销")
|
||||
if user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="仅平台管理员可执行此操作")
|
||||
return user
|
||||
|
||||
@@ -39,7 +39,7 @@ class ExecutionLogResponse(BaseModel):
|
||||
|
||||
|
||||
def _has_execution_permission(db: Session, execution: Execution, current_user: User) -> bool:
|
||||
if getattr(current_user, "role", None) == "admin":
|
||||
if current_user.has_permission("execution:view_all"):
|
||||
return True
|
||||
if execution.workflow_id:
|
||||
workflow = db.query(Workflow).filter(Workflow.id == execution.workflow_id).first()
|
||||
|
||||
@@ -12,6 +12,7 @@ from app.models.execution import Execution
|
||||
from app.models.workflow import Workflow
|
||||
from app.models.agent import Agent
|
||||
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.tasks.workflow_tasks import execute_workflow_task, resume_workflow_task
|
||||
from app.services.agent_workspace_chat_log import ensure_agent_dialogue_logged_from_db_execution
|
||||
@@ -124,7 +125,7 @@ class ResumeExecutionBody(BaseModel):
|
||||
def _can_view_execution(
|
||||
db: Session, current_user: User, execution: Execution
|
||||
) -> bool:
|
||||
if getattr(current_user, "role", None) == "admin":
|
||||
if current_user.has_permission("execution:view_all"):
|
||||
return True
|
||||
if execution.workflow_id:
|
||||
wf = db.query(Workflow).filter(Workflow.id == execution.workflow_id).first()
|
||||
@@ -241,11 +242,12 @@ async def get_executions(
|
||||
status: Optional[str] = None,
|
||||
search: Optional[str] = None,
|
||||
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),
|
||||
):
|
||||
"""
|
||||
获取执行记录列表(支持分页、筛选、搜索)
|
||||
|
||||
|
||||
Args:
|
||||
skip: 跳过记录数(分页)
|
||||
limit: 每页记录数(分页,最大100)
|
||||
@@ -257,7 +259,7 @@ async def get_executions(
|
||||
limit = min(limit, 100)
|
||||
|
||||
# 管理员可看全部;普通用户:自己拥有的工作流或 Agent 上的执行记录(含纯 Agent 执行)
|
||||
if getattr(current_user, "role", None) == "admin":
|
||||
if current_user.has_permission("execution:view_all"):
|
||||
query = db.query(Execution)
|
||||
else:
|
||||
query = (
|
||||
@@ -265,6 +267,7 @@ async def get_executions(
|
||||
.outerjoin(Workflow, Execution.workflow_id == Workflow.id)
|
||||
.outerjoin(Agent, Execution.agent_id == Agent.id)
|
||||
.filter(
|
||||
Execution.workspace_id == workspace_id,
|
||||
or_(
|
||||
Workflow.user_id == current_user.id,
|
||||
Agent.user_id == current_user.id,
|
||||
|
||||
@@ -242,7 +242,7 @@ async def set_default_agent(
|
||||
agent = db.query(Agent).filter(Agent.id == data.agent_id).first()
|
||||
if not agent:
|
||||
raise HTTPException(status_code=404, detail="Agent 不存在")
|
||||
if agent.user_id and agent.user_id != current_user.id and current_user.role != "admin":
|
||||
if agent.user_id and agent.user_id != current_user.id and not current_user.has_permission("agent:manage"):
|
||||
raise HTTPException(status_code=403, detail="无权使用该 Agent")
|
||||
|
||||
current_user.feishu_default_agent_id = data.agent_id
|
||||
|
||||
@@ -14,6 +14,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.services.knowledge_service import (
|
||||
create_knowledge_base,
|
||||
@@ -103,6 +104,7 @@ async def api_create_kb(
|
||||
req: KBCreateRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
workspace_id: str = Depends(get_current_workspace_id),
|
||||
):
|
||||
"""创建知识库。"""
|
||||
kb = create_knowledge_base(
|
||||
@@ -112,6 +114,7 @@ async def api_create_kb(
|
||||
description=req.description,
|
||||
chunk_size=req.chunk_size,
|
||||
chunk_overlap=req.chunk_overlap,
|
||||
workspace_id=workspace_id,
|
||||
)
|
||||
return KBResponse(**kb.to_dict())
|
||||
|
||||
@@ -120,9 +123,10 @@ async def api_create_kb(
|
||||
async def api_list_kb(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
workspace_id: str = Depends(get_current_workspace_id),
|
||||
):
|
||||
"""列出知识库。"""
|
||||
kbs = list_knowledge_bases(db, user_id=current_user.id)
|
||||
kbs = list_knowledge_bases(db, user_id=current_user.id, workspace_id=workspace_id)
|
||||
return [KBResponse(**kb.to_dict()) for kb in kbs]
|
||||
|
||||
|
||||
|
||||
@@ -10,7 +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.api.deps import require_workspace_admin, WorkspaceContext, get_current_workspace_id
|
||||
from app.models.user import User
|
||||
from app.core.exceptions import NotFoundError, ValidationError, ConflictError
|
||||
from app.services.encryption_service import EncryptionService
|
||||
@@ -68,14 +68,18 @@ async def get_model_configs(
|
||||
limit: int = Query(100, ge=1, le=100, description="每页记录数"),
|
||||
provider: Optional[str] = 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(ModelConfig).filter(ModelConfig.user_id == current_user.id)
|
||||
query = db.query(ModelConfig).filter(
|
||||
ModelConfig.user_id == current_user.id,
|
||||
ModelConfig.workspace_id == workspace_id,
|
||||
)
|
||||
|
||||
# 筛选:按提供商筛选
|
||||
if provider:
|
||||
@@ -90,26 +94,28 @@ async def get_model_configs(
|
||||
async def create_model_config(
|
||||
config_data: ModelConfigCreate,
|
||||
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),
|
||||
):
|
||||
"""
|
||||
创建模型配置
|
||||
|
||||
|
||||
注意:API密钥会加密存储
|
||||
"""
|
||||
# 验证提供商
|
||||
valid_providers = ['openai', 'deepseek', 'anthropic', 'local']
|
||||
if config_data.provider not in valid_providers:
|
||||
raise ValidationError(f"不支持的提供商: {config_data.provider}")
|
||||
|
||||
|
||||
# 检查名称是否重复
|
||||
existing_config = db.query(ModelConfig).filter(
|
||||
ModelConfig.name == config_data.name,
|
||||
ModelConfig.user_id == current_user.id
|
||||
ModelConfig.user_id == current_user.id,
|
||||
ModelConfig.workspace_id == workspace_id,
|
||||
).first()
|
||||
if existing_config:
|
||||
raise ConflictError(f"模型配置名称 '{config_data.name}' 已存在")
|
||||
|
||||
|
||||
# 创建模型配置
|
||||
# API密钥加密存储
|
||||
encrypted_api_key = EncryptionService.encrypt(config_data.api_key)
|
||||
@@ -119,7 +125,8 @@ async def create_model_config(
|
||||
model_name=config_data.model_name,
|
||||
api_key=encrypted_api_key,
|
||||
base_url=config_data.base_url,
|
||||
user_id=current_user.id
|
||||
user_id=current_user.id,
|
||||
workspace_id=workspace_id,
|
||||
)
|
||||
db.add(model_config)
|
||||
db.commit()
|
||||
|
||||
@@ -32,7 +32,7 @@ async def get_system_overview(
|
||||
返回工作流、Agent、执行记录等数量统计
|
||||
"""
|
||||
# 普通用户只能查看自己的数据,管理员可以查看全部
|
||||
user_id = None if current_user.role == "admin" else current_user.id
|
||||
user_id = None if current_user.has_permission("monitoring:view_all") else current_user.id
|
||||
|
||||
overview = MonitoringService.get_system_overview(db, user_id)
|
||||
return overview
|
||||
@@ -50,7 +50,7 @@ async def get_execution_statistics(
|
||||
返回执行数量、成功率、平均执行时间、执行趋势等
|
||||
"""
|
||||
# 普通用户只能查看自己的数据,管理员可以查看全部
|
||||
user_id = None if current_user.role == "admin" else current_user.id
|
||||
user_id = None if current_user.has_permission("monitoring:view_all") else current_user.id
|
||||
|
||||
statistics = MonitoringService.get_execution_statistics(db, user_id, days)
|
||||
return statistics
|
||||
@@ -68,7 +68,7 @@ async def get_node_type_statistics(
|
||||
返回各节点类型的执行次数、平均耗时、错误率等
|
||||
"""
|
||||
# 普通用户只能查看自己的数据,管理员可以查看全部
|
||||
user_id = None if current_user.role == "admin" else current_user.id
|
||||
user_id = None if current_user.has_permission("monitoring:view_all") else current_user.id
|
||||
|
||||
statistics = MonitoringService.get_node_type_statistics(db, user_id, days)
|
||||
return statistics
|
||||
@@ -86,7 +86,7 @@ async def get_recent_activities(
|
||||
返回最近的执行记录等
|
||||
"""
|
||||
# 普通用户只能查看自己的数据,管理员可以查看全部
|
||||
user_id = None if current_user.role == "admin" else current_user.id
|
||||
user_id = None if current_user.has_permission("monitoring:view_all") else current_user.id
|
||||
|
||||
activities = MonitoringService.get_recent_activities(db, user_id, limit)
|
||||
return activities
|
||||
|
||||
@@ -14,6 +14,7 @@ from app.models.user import User
|
||||
from app.models.workflow import Workflow
|
||||
from app.models.agent import Agent
|
||||
from app.api.auth import get_current_user
|
||||
from app.api.deps import require_admin
|
||||
from app.core.exceptions import NotFoundError, ConflictError, ValidationError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -60,9 +61,9 @@ async def get_roles(
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""获取角色列表(仅管理员)"""
|
||||
if current_user.role != "admin":
|
||||
if not current_user.has_permission("permission:manage"):
|
||||
raise HTTPException(status_code=403, detail="仅管理员可访问")
|
||||
|
||||
|
||||
roles = db.query(Role).offset(skip).limit(limit).all()
|
||||
|
||||
result = []
|
||||
@@ -88,7 +89,7 @@ async def create_role(
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""创建角色(仅管理员)"""
|
||||
if current_user.role != "admin":
|
||||
if not current_user.has_permission("permission:manage"):
|
||||
raise HTTPException(status_code=403, detail="仅管理员可创建角色")
|
||||
|
||||
# 检查角色名称是否已存在
|
||||
@@ -131,7 +132,7 @@ async def update_role(
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""更新角色(仅管理员)"""
|
||||
if current_user.role != "admin":
|
||||
if not current_user.has_permission("permission:manage"):
|
||||
raise HTTPException(status_code=403, detail="仅管理员可更新角色")
|
||||
|
||||
role = db.query(Role).filter(Role.id == role_id).first()
|
||||
@@ -179,7 +180,7 @@ async def delete_role(
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""删除角色(仅管理员)"""
|
||||
if current_user.role != "admin":
|
||||
if not current_user.has_permission("permission:manage"):
|
||||
raise HTTPException(status_code=403, detail="仅管理员可删除角色")
|
||||
|
||||
role = db.query(Role).filter(Role.id == role_id).first()
|
||||
@@ -258,7 +259,7 @@ async def assign_user_roles(
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""为用户分配角色(仅管理员)"""
|
||||
if current_user.role != "admin":
|
||||
if not current_user.has_permission("permission:manage"):
|
||||
raise HTTPException(status_code=403, detail="仅管理员可分配角色")
|
||||
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
@@ -286,7 +287,7 @@ async def get_users(
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""获取用户列表(仅管理员)"""
|
||||
if current_user.role != "admin":
|
||||
if not current_user.has_permission("permission:manage"):
|
||||
raise HTTPException(status_code=403, detail="仅管理员可访问")
|
||||
|
||||
users = db.query(User).offset(skip).limit(limit).all()
|
||||
@@ -316,7 +317,7 @@ async def get_user_roles(
|
||||
):
|
||||
"""获取用户的角色列表"""
|
||||
# 用户只能查看自己的角色,管理员可以查看所有用户的角色
|
||||
if current_user.id != user_id and current_user.role != "admin":
|
||||
if current_user.id != user_id and not current_user.has_permission("permission:manage"):
|
||||
raise HTTPException(status_code=403, detail="无权访问")
|
||||
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
@@ -354,7 +355,7 @@ async def grant_workflow_permission(
|
||||
raise NotFoundError("工作流", workflow_id)
|
||||
|
||||
# 只有工作流所有者或管理员可以授权
|
||||
if workflow.user_id != current_user.id and current_user.role != "admin":
|
||||
if workflow.user_id != current_user.id and not current_user.has_permission("workflow:share"):
|
||||
raise HTTPException(status_code=403, detail="无权授权此工作流")
|
||||
|
||||
# 验证权限类型
|
||||
@@ -410,7 +411,7 @@ async def get_workflow_permissions(
|
||||
raise NotFoundError("工作流", workflow_id)
|
||||
|
||||
# 只有工作流所有者或管理员可以查看权限
|
||||
if workflow.user_id != current_user.id and current_user.role != "admin":
|
||||
if workflow.user_id != current_user.id and not current_user.has_permission("workflow:share"):
|
||||
raise HTTPException(status_code=403, detail="无权查看此工作流的权限")
|
||||
|
||||
permissions = db.query(WorkflowPermission).filter(
|
||||
@@ -451,8 +452,8 @@ async def revoke_workflow_permission(
|
||||
raise NotFoundError("权限", permission_id)
|
||||
|
||||
# 只有工作流所有者、管理员或授权人可以撤销
|
||||
if (workflow.user_id != current_user.id and
|
||||
current_user.role != "admin" and
|
||||
if (workflow.user_id != current_user.id and
|
||||
not current_user.has_permission("workflow:share") and
|
||||
permission.granted_by != current_user.id):
|
||||
raise HTTPException(status_code=403, detail="无权撤销此权限")
|
||||
|
||||
@@ -483,7 +484,7 @@ async def grant_agent_permission(
|
||||
raise NotFoundError("Agent", agent_id)
|
||||
|
||||
# 只有Agent所有者或管理员可以授权
|
||||
if agent.user_id != current_user.id and current_user.role != "admin":
|
||||
if agent.user_id != current_user.id and not current_user.has_permission("agent:deploy"):
|
||||
raise HTTPException(status_code=403, detail="无权授权此Agent")
|
||||
|
||||
# 验证权限类型
|
||||
@@ -539,7 +540,7 @@ async def get_agent_permissions(
|
||||
raise NotFoundError("Agent", agent_id)
|
||||
|
||||
# 只有Agent所有者或管理员可以查看权限
|
||||
if agent.user_id != current_user.id and current_user.role != "admin":
|
||||
if agent.user_id != current_user.id and not current_user.has_permission("agent:deploy"):
|
||||
raise HTTPException(status_code=403, detail="无权查看此Agent的权限")
|
||||
|
||||
permissions = db.query(AgentPermission).filter(
|
||||
@@ -580,8 +581,8 @@ async def revoke_agent_permission(
|
||||
raise NotFoundError("权限", permission_id)
|
||||
|
||||
# 只有Agent所有者、管理员或授权人可以撤销
|
||||
if (agent.user_id != current_user.id and
|
||||
current_user.role != "admin" and
|
||||
if (agent.user_id != current_user.id and
|
||||
not current_user.has_permission("agent:deploy") and
|
||||
permission.granted_by != current_user.id):
|
||||
raise HTTPException(status_code=403, detail="无权撤销此权限")
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ from pydantic import BaseModel
|
||||
from app.core.database import get_db
|
||||
from app.core.config import settings
|
||||
from app.api.auth import get_current_user
|
||||
from app.api.deps import require_admin
|
||||
from app.models.user import User
|
||||
from app.models.execution_log import ExecutionLog
|
||||
from app.models.agent_execution_log import AgentExecutionLog
|
||||
@@ -172,18 +173,12 @@ def _build_union_query(
|
||||
return queries
|
||||
|
||||
|
||||
def _check_admin(current_user: User):
|
||||
if getattr(current_user, "role", None) != "admin":
|
||||
from app.core.exceptions import ForbiddenError
|
||||
raise ForbiddenError("仅管理员可访问系统日志")
|
||||
|
||||
|
||||
# ── Endpoints ────────────────────────────────────────────────────
|
||||
|
||||
@router.get("", response_model=List[UnifiedLogItem])
|
||||
async def get_system_logs(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_admin: User = Depends(require_admin()),
|
||||
source: Optional[str] = Query(None, description="日志来源: execution/agent/llm/all"),
|
||||
level: Optional[str] = Query(None, description="日志级别: INFO/WARN/ERROR"),
|
||||
keyword: Optional[str] = Query(None, description="关键词搜索"),
|
||||
@@ -192,21 +187,10 @@ async def get_system_logs(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=1000),
|
||||
):
|
||||
"""
|
||||
统一日志查询:跨 execution_logs / agent_execution_logs / agent_llm_logs 联合查询。
|
||||
管理员可查全部,普通用户只能查看自己相关的 Agent 执行日志和 LLM 日志。
|
||||
"""
|
||||
_check_admin(current_user)
|
||||
|
||||
# 解析时间
|
||||
"""统一日志查询(仅管理员)。"""
|
||||
sd = datetime.fromisoformat(start_date) if start_date else None
|
||||
ed = datetime.fromisoformat(end_date) if end_date else None
|
||||
|
||||
# 非 admin 限制用户范围
|
||||
user_id = None
|
||||
if getattr(current_user, "role", None) != "admin":
|
||||
user_id = current_user.id
|
||||
|
||||
results: list[UnifiedLogItem] = []
|
||||
|
||||
# 1) 工作流执行日志
|
||||
@@ -241,8 +225,6 @@ async def get_system_logs(
|
||||
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)
|
||||
@@ -297,11 +279,9 @@ async def get_system_logs(
|
||||
@router.get("/stats", response_model=LogStatsResponse)
|
||||
async def get_system_logs_stats(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_admin: User = Depends(require_admin()),
|
||||
):
|
||||
"""获取系统日志统计:各级别计数、各来源计数、24小时趋势"""
|
||||
_check_admin(current_user)
|
||||
|
||||
now = datetime.utcnow()
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
hours_24 = now - timedelta(hours=24)
|
||||
@@ -382,13 +362,11 @@ async def get_system_logs_stats(
|
||||
|
||||
@router.get("/app-logs", response_model=List[AppLogItem])
|
||||
async def get_app_logs(
|
||||
current_user: User = Depends(get_current_user),
|
||||
_admin: User = Depends(require_admin()),
|
||||
lines: int = Query(200, ge=10, le=2000, description="读取行数"),
|
||||
level: Optional[str] = Query(None, description="按级别过滤: INFO/WARNING/ERROR"),
|
||||
):
|
||||
"""读取应用程序文件日志的尾部行"""
|
||||
_check_admin(current_user)
|
||||
|
||||
log_file = Path(settings.LOG_DIR) / "app.log"
|
||||
if not log_file.exists():
|
||||
return []
|
||||
|
||||
@@ -14,6 +14,7 @@ from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
|
||||
from app.api.auth import get_current_user
|
||||
from app.api.deps import require_admin
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_password_hash
|
||||
from app.models.user import User
|
||||
@@ -24,12 +25,6 @@ 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):
|
||||
@@ -90,7 +85,7 @@ def list_users(
|
||||
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),
|
||||
_admin: User = Depends(require_admin()),
|
||||
):
|
||||
"""获取用户列表(分页、搜索、过滤)"""
|
||||
q = db.query(User)
|
||||
@@ -137,7 +132,7 @@ def list_users(
|
||||
def create_user(
|
||||
data: UserCreateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
_admin: User = Depends(_require_admin),
|
||||
_admin: User = Depends(require_admin()),
|
||||
):
|
||||
"""管理员创建新用户"""
|
||||
if db.query(User).filter(User.username == data.username).first():
|
||||
@@ -166,7 +161,7 @@ def create_user(
|
||||
def get_user(
|
||||
user_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
_admin: User = Depends(_require_admin),
|
||||
_admin: User = Depends(require_admin()),
|
||||
):
|
||||
"""获取用户详情"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
@@ -187,7 +182,7 @@ def update_user(
|
||||
user_id: str,
|
||||
data: UserUpdateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
_admin: User = Depends(_require_admin),
|
||||
_admin: User = Depends(require_admin()),
|
||||
):
|
||||
"""编辑用户"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
@@ -229,7 +224,7 @@ def update_user(
|
||||
def delete_user(
|
||||
user_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
_admin: User = Depends(_require_admin),
|
||||
_admin: User = Depends(require_admin()),
|
||||
):
|
||||
"""软删除用户(标记为 deleted)"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
@@ -250,7 +245,7 @@ def reset_password(
|
||||
user_id: str,
|
||||
data: ResetPasswordRequest,
|
||||
db: Session = Depends(get_db),
|
||||
_admin: User = Depends(_require_admin),
|
||||
_admin: User = Depends(require_admin()),
|
||||
):
|
||||
"""重置用户密码"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
|
||||
@@ -274,40 +274,15 @@ async def text_to_voice(
|
||||
async def _edge_tts_synthesize(
|
||||
text: str, voice: str, output_path: str, speed: float = 1.0,
|
||||
) -> None:
|
||||
"""使用 edge-tts 命令行工具合成语音。"""
|
||||
import shutil
|
||||
|
||||
exe = shutil.which("edge-tts") or shutil.which("edge-tts", path=(
|
||||
os.environ.get("PATH", "") + os.pathsep +
|
||||
os.path.join(os.path.dirname(sys.executable), "Scripts") + os.pathsep +
|
||||
os.path.join(os.path.dirname(sys.executable), "..", "Scripts")
|
||||
))
|
||||
if not exe:
|
||||
exe = "edge-tts"
|
||||
"""使用 edge-tts Python 库直接合成语音(无需 CLI 子进程)。"""
|
||||
import edge_tts
|
||||
|
||||
rate_str = f"{int((speed - 1.0) * 100):+d}%"
|
||||
|
||||
logger.debug("edge-tts exe: %s text_len=%d rate=%s", exe, len(text), rate_str)
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
exe,
|
||||
"--text", text,
|
||||
"--voice", voice,
|
||||
f"--rate={rate_str}",
|
||||
"--write-media", output_path,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
out_str = stdout.decode(errors="replace") if stdout else ""
|
||||
err_str = stderr.decode(errors="replace") if stderr else ""
|
||||
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"edge-tts CLI 失败 (exit={proc.returncode}): {err_str or out_str}"
|
||||
)
|
||||
communicate = edge_tts.Communicate(text, voice, rate=rate_str)
|
||||
await communicate.save(output_path)
|
||||
|
||||
if not Path(output_path).is_file():
|
||||
raise RuntimeError(f"edge-tts 未生成输出文件: {out_str} {err_str}")
|
||||
raise RuntimeError("edge-tts 未生成输出文件")
|
||||
|
||||
logger.info("edge-tts CLI 成功: %d bytes", Path(output_path).stat().st_size)
|
||||
|
||||
|
||||
@@ -151,7 +151,7 @@ async def get_workflows(
|
||||
):
|
||||
"""获取工作流列表(支持搜索、筛选、排序、工作区筛选)"""
|
||||
# 管理员可以看到所有工作流,普通用户只能看到自己拥有的或有read权限的
|
||||
if current_user.role == "admin":
|
||||
if current_user.has_permission("workflow:view_all"):
|
||||
query = db.query(Workflow)
|
||||
else:
|
||||
# 获取用户拥有或有read权限的工作流
|
||||
@@ -350,8 +350,8 @@ async def delete_workflow(
|
||||
if not workflow:
|
||||
raise NotFoundError("工作流", workflow_id)
|
||||
|
||||
# 只有工作流所有者可以删除
|
||||
if workflow.user_id != current_user.id and current_user.role != "admin":
|
||||
# 只有工作流所有者或管理员可以删除
|
||||
if workflow.user_id != current_user.id and not current_user.has_permission("workflow:delete"):
|
||||
raise HTTPException(status_code=403, detail="无权删除此工作流")
|
||||
|
||||
db.delete(workflow)
|
||||
|
||||
@@ -11,7 +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.api.deps 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
|
||||
@@ -435,7 +435,7 @@ def admin_list_workspaces(
|
||||
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),
|
||||
_admin: User = Depends(require_admin()),
|
||||
):
|
||||
"""平台管理员查看所有工作区(分页)。"""
|
||||
q = db.query(Workspace)
|
||||
@@ -482,7 +482,7 @@ def admin_list_workspaces(
|
||||
def admin_get_workspace(
|
||||
workspace_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
_admin: User = Depends(_require_admin),
|
||||
_admin: User = Depends(require_admin()),
|
||||
):
|
||||
"""平台管理员查看任意工作区详情。"""
|
||||
ws = db.query(Workspace).filter(Workspace.id == workspace_id).first()
|
||||
@@ -516,7 +516,7 @@ def admin_update_workspace(
|
||||
workspace_id: str,
|
||||
data: WorkspaceUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
_admin: User = Depends(_require_admin),
|
||||
_admin: User = Depends(require_admin()),
|
||||
):
|
||||
"""平台管理员编辑任意工作区。"""
|
||||
ws = db.query(Workspace).filter(Workspace.id == workspace_id).first()
|
||||
@@ -541,7 +541,7 @@ def admin_update_workspace(
|
||||
def admin_delete_workspace(
|
||||
workspace_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
_admin: User = Depends(_require_admin),
|
||||
_admin: User = Depends(require_admin()),
|
||||
):
|
||||
"""平台管理员强制删除工作区(软删除)。"""
|
||||
ws = db.query(Workspace).filter(Workspace.id == workspace_id).first()
|
||||
|
||||
Reference in New Issue
Block a user