- 添加 skill slug 格式校验,防止路径遍历攻击 - 优化导出文件清理逻辑,添加异常处理 - 前端依赖管理表单添加禁用状态联动 - 补充相关单元测试 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
294 lines
9.7 KiB
Python
294 lines
9.7 KiB
Python
"""Skills 管理路由"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
from fastapi import APIRouter, BackgroundTasks, Depends, File, HTTPException, Query, UploadFile
|
|
from fastapi.responses import FileResponse
|
|
from pydantic import BaseModel, Field
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from server.utils.auth_middleware import get_admin_user, get_db, get_superadmin_user
|
|
from src.services.skill_service import (
|
|
create_skill_node,
|
|
delete_skill,
|
|
delete_skill_node,
|
|
export_skill_zip,
|
|
get_skill_dependency_options,
|
|
get_skill_tree,
|
|
import_skill_zip,
|
|
list_skills,
|
|
read_skill_file,
|
|
update_skill_dependencies,
|
|
update_skill_file,
|
|
)
|
|
from src.storage.postgres.models_business import User
|
|
from src.utils.logging_config import logger
|
|
|
|
skills = APIRouter(prefix="/system/skills", tags=["skills"])
|
|
|
|
|
|
class SkillNodeCreateRequest(BaseModel):
|
|
path: str = Field(..., description="相对 skill 根目录的路径")
|
|
is_dir: bool = Field(False, description="是否创建目录")
|
|
content: str | None = Field("", description="文件内容(仅文件创建时生效)")
|
|
|
|
|
|
class SkillFileUpdateRequest(BaseModel):
|
|
path: str = Field(..., description="相对 skill 根目录的路径")
|
|
content: str = Field(..., description="文件内容")
|
|
|
|
|
|
class SkillDependenciesUpdateRequest(BaseModel):
|
|
tool_dependencies: list[str] = Field(default_factory=list, description="依赖的内置工具列表")
|
|
mcp_dependencies: list[str] = Field(default_factory=list, description="依赖的 MCP 服务列表")
|
|
skill_dependencies: list[str] = Field(default_factory=list, description="依赖的其他 skill slug 列表")
|
|
|
|
|
|
def _raise_from_value_error(e: ValueError) -> None:
|
|
message = str(e)
|
|
status_code = 404 if "不存在" in message else 400
|
|
raise HTTPException(status_code=status_code, detail=message)
|
|
|
|
|
|
def _cleanup_export_file(path: str) -> None:
|
|
try:
|
|
Path(path).unlink(missing_ok=True)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to cleanup exported skill archive '{path}': {e}")
|
|
|
|
|
|
@skills.get("")
|
|
async def list_skills_route(
|
|
_current_user: User = Depends(get_admin_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""获取技能列表(管理员可读)。"""
|
|
try:
|
|
items = await list_skills(db)
|
|
return {"success": True, "data": [item.to_dict() for item in items]}
|
|
except Exception as e:
|
|
logger.error(f"Failed to list skills: {e}")
|
|
raise HTTPException(status_code=500, detail="获取技能列表失败")
|
|
|
|
|
|
@skills.get("/dependency-options")
|
|
async def get_skill_dependency_options_route(
|
|
_current_user: User = Depends(get_superadmin_user),
|
|
):
|
|
"""获取 skill 依赖项可选列表(仅超级管理员)。"""
|
|
try:
|
|
return {"success": True, "data": get_skill_dependency_options()}
|
|
except Exception as e:
|
|
logger.error(f"Failed to get skill dependency options: {e}")
|
|
raise HTTPException(status_code=500, detail="获取 skill 依赖选项失败")
|
|
|
|
|
|
@skills.post("/import")
|
|
async def import_skill_route(
|
|
file: UploadFile = File(...),
|
|
current_user: User = Depends(get_superadmin_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""导入技能压缩包(仅超级管理员)。"""
|
|
try:
|
|
file_bytes = await file.read()
|
|
item = await import_skill_zip(
|
|
db,
|
|
filename=file.filename or "",
|
|
file_bytes=file_bytes,
|
|
created_by=current_user.username,
|
|
)
|
|
return {"success": True, "data": item.to_dict()}
|
|
except ValueError as e:
|
|
_raise_from_value_error(e)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Failed to import skill zip: {e}")
|
|
raise HTTPException(status_code=500, detail="导入技能失败")
|
|
|
|
|
|
@skills.get("/{slug}/tree")
|
|
async def get_skill_tree_route(
|
|
slug: str,
|
|
_current_user: User = Depends(get_superadmin_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""获取技能目录树(仅超级管理员)。"""
|
|
try:
|
|
tree = await get_skill_tree(db, slug)
|
|
return {"success": True, "data": tree}
|
|
except ValueError as e:
|
|
_raise_from_value_error(e)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Failed to get skill tree '{slug}': {e}")
|
|
raise HTTPException(status_code=500, detail="获取技能目录树失败")
|
|
|
|
|
|
@skills.get("/{slug}/file")
|
|
async def get_skill_file_route(
|
|
slug: str,
|
|
path: str = Query(..., description="相对 skill 根目录路径"),
|
|
_current_user: User = Depends(get_superadmin_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""读取技能文本文件(仅超级管理员)。"""
|
|
try:
|
|
data = await read_skill_file(db, slug, path)
|
|
return {"success": True, "data": data}
|
|
except ValueError as e:
|
|
_raise_from_value_error(e)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Failed to read skill file '{slug}/{path}': {e}")
|
|
raise HTTPException(status_code=500, detail="读取技能文件失败")
|
|
|
|
|
|
@skills.post("/{slug}/file")
|
|
async def create_skill_file_route(
|
|
slug: str,
|
|
payload: SkillNodeCreateRequest,
|
|
current_user: User = Depends(get_superadmin_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""创建技能文件或目录(仅超级管理员)。"""
|
|
try:
|
|
await create_skill_node(
|
|
db,
|
|
slug=slug,
|
|
relative_path=payload.path,
|
|
is_dir=payload.is_dir,
|
|
content=payload.content,
|
|
updated_by=current_user.username,
|
|
)
|
|
return {"success": True}
|
|
except ValueError as e:
|
|
_raise_from_value_error(e)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Failed to create skill node '{slug}/{payload.path}': {e}")
|
|
raise HTTPException(status_code=500, detail="创建技能文件失败")
|
|
|
|
|
|
@skills.put("/{slug}/file")
|
|
async def update_skill_file_route(
|
|
slug: str,
|
|
payload: SkillFileUpdateRequest,
|
|
current_user: User = Depends(get_superadmin_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""更新技能文本文件(仅超级管理员)。"""
|
|
try:
|
|
await update_skill_file(
|
|
db,
|
|
slug=slug,
|
|
relative_path=payload.path,
|
|
content=payload.content,
|
|
updated_by=current_user.username,
|
|
)
|
|
return {"success": True}
|
|
except ValueError as e:
|
|
_raise_from_value_error(e)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Failed to update skill file '{slug}/{payload.path}': {e}")
|
|
raise HTTPException(status_code=500, detail="更新技能文件失败")
|
|
|
|
|
|
@skills.put("/{slug}/dependencies")
|
|
async def update_skill_dependencies_route(
|
|
slug: str,
|
|
payload: SkillDependenciesUpdateRequest,
|
|
current_user: User = Depends(get_superadmin_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""更新 skill 依赖(仅超级管理员)。"""
|
|
try:
|
|
item = await update_skill_dependencies(
|
|
db,
|
|
slug=slug,
|
|
tool_dependencies=payload.tool_dependencies,
|
|
mcp_dependencies=payload.mcp_dependencies,
|
|
skill_dependencies=payload.skill_dependencies,
|
|
updated_by=current_user.username,
|
|
)
|
|
return {"success": True, "data": item.to_dict()}
|
|
except ValueError as e:
|
|
_raise_from_value_error(e)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Failed to update skill dependencies '{slug}': {e}")
|
|
raise HTTPException(status_code=500, detail="更新 skill 依赖失败")
|
|
|
|
|
|
@skills.delete("/{slug}/file")
|
|
async def delete_skill_file_route(
|
|
slug: str,
|
|
path: str = Query(..., description="相对 skill 根目录路径"),
|
|
_current_user: User = Depends(get_superadmin_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""删除技能文件或目录(仅超级管理员)。"""
|
|
try:
|
|
await delete_skill_node(db, slug=slug, relative_path=path)
|
|
return {"success": True}
|
|
except ValueError as e:
|
|
_raise_from_value_error(e)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Failed to delete skill file '{slug}/{path}': {e}")
|
|
raise HTTPException(status_code=500, detail="删除技能文件失败")
|
|
|
|
|
|
@skills.get("/{slug}/export")
|
|
async def export_skill_route(
|
|
slug: str,
|
|
background_tasks: BackgroundTasks,
|
|
_current_user: User = Depends(get_superadmin_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""导出技能压缩包(仅超级管理员)。"""
|
|
try:
|
|
export_path, download_name = await export_skill_zip(db, slug)
|
|
background_tasks.add_task(_cleanup_export_file, export_path)
|
|
return FileResponse(
|
|
path=export_path,
|
|
media_type="application/zip",
|
|
filename=download_name,
|
|
)
|
|
except ValueError as e:
|
|
_raise_from_value_error(e)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Failed to export skill '{slug}': {e}")
|
|
raise HTTPException(status_code=500, detail="导出技能失败")
|
|
|
|
|
|
@skills.delete("/{slug}")
|
|
async def delete_skill_route(
|
|
slug: str,
|
|
_current_user: User = Depends(get_superadmin_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""删除技能(目录 + 数据库记录,仅超级管理员)。"""
|
|
try:
|
|
await delete_skill(db, slug=slug)
|
|
return {"success": True}
|
|
except ValueError as e:
|
|
_raise_from_value_error(e)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Failed to delete skill '{slug}': {e}")
|
|
raise HTTPException(status_code=500, detail="删除技能失败")
|