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:
2026-07-10 22:37:56 +08:00
parent 3483c6b3be
commit f2e65a8fbb
259 changed files with 39239 additions and 3148 deletions

View File

@@ -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 {}

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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
View 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)

View File

@@ -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", [])

View File

@@ -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(

View File

@@ -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 天 tokenweb 默认 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)

View 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

View 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

View File

@@ -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:

View File

@@ -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

View File

@@ -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()

View File

@@ -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,

View File

@@ -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

View File

@@ -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]

View File

@@ -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()

View File

@@ -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

View File

@@ -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="无权撤销此权限")

View File

@@ -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 []

View File

@@ -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()

View File

@@ -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)

View File

@@ -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)

View File

@@ -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()