1. 技能列表接口:普通用户返回数据通过白名单字段投影,屏蔽 dir_path 等绝对物理路径。 2. MCP 服务器列表接口:废除原 pop() 黑名单模式,升级为显式白名单准入映射。 3. 单元测试:同步升级 test_skill_router.py 和 test_mcp_router.py,强化字段脱敏性测试断言。
487 lines
16 KiB
Python
487 lines
16 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_required_user
|
||
from yuxi.services.remote_skill_install_service import (
|
||
install_remote_skill,
|
||
install_remote_skills_batch,
|
||
list_remote_skills,
|
||
)
|
||
from yuxi.services.skill_service import (
|
||
BuiltinSkillUpdateConflictError,
|
||
create_skill_node,
|
||
delete_skill,
|
||
delete_skill_node,
|
||
export_skill_zip,
|
||
get_skill_dependency_options,
|
||
get_skill_tree,
|
||
import_skill_zip,
|
||
install_builtin_skill,
|
||
list_builtin_skill_specs,
|
||
list_skills,
|
||
read_skill_file,
|
||
update_builtin_skill,
|
||
update_skill_dependencies,
|
||
update_skill_file,
|
||
)
|
||
from yuxi.storage.postgres.models_business import User
|
||
from yuxi.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 列表")
|
||
|
||
|
||
class BuiltinSkillUpdateRequest(BaseModel):
|
||
force: bool = Field(False, description="是否强制覆盖本地已安装内容")
|
||
|
||
|
||
class RemoteSkillSourceRequest(BaseModel):
|
||
source: str = Field(..., description="skills 仓库来源,如 owner/repo 或 GitHub URL")
|
||
|
||
|
||
class RemoteSkillInstallRequest(RemoteSkillSourceRequest):
|
||
skill: str = Field(..., description="需要安装的 skill 名称")
|
||
|
||
|
||
class RemoteSkillBatchInstallRequest(RemoteSkillSourceRequest):
|
||
skills: list[str] = Field(..., description="需要安装的 skill 名称列表(批量,共享一次克隆)")
|
||
|
||
|
||
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_required_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""获取技能列表(普通用户仅获取白名单脱敏数据,管理员可读完整元数据)。"""
|
||
try:
|
||
items = await list_skills(db)
|
||
|
||
# NOTE: 针对管理员与常规登录用户分流返回,防止物理目录结构(dir_path)与系统审计信息越权暴露给常规用户
|
||
if current_user.role in ["admin", "superadmin"]:
|
||
return {"success": True, "data": [item.to_dict() for item in items]}
|
||
|
||
safe_data = []
|
||
for item in items:
|
||
safe_data.append(
|
||
{
|
||
"slug": item.slug,
|
||
"name": item.name,
|
||
"description": item.description,
|
||
"version": item.version,
|
||
"is_builtin": item.is_builtin,
|
||
}
|
||
)
|
||
return {"success": True, "data": safe_data}
|
||
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_admin_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""获取 skill 依赖项可选列表(管理员)。"""
|
||
try:
|
||
return {"success": True, "data": await get_skill_dependency_options(db)}
|
||
except Exception as e:
|
||
logger.error(f"Failed to get skill dependency options: {e}")
|
||
raise HTTPException(status_code=500, detail="获取 skill 依赖选项失败")
|
||
|
||
|
||
@skills.get("/builtin")
|
||
async def list_builtin_skills_route(
|
||
_current_user: User = Depends(get_admin_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
try:
|
||
installed_map = {item.slug: item for item in await list_skills(db)}
|
||
data = []
|
||
for spec in list_builtin_skill_specs():
|
||
installed = installed_map.get(spec["slug"])
|
||
status = "not_installed"
|
||
if installed:
|
||
status = "installed"
|
||
if installed.version != spec["version"] or installed.content_hash != spec["content_hash"]:
|
||
status = "update_available"
|
||
data.append(
|
||
{
|
||
"slug": spec["slug"],
|
||
"name": spec["name"],
|
||
"description": spec["description"],
|
||
"version": spec["version"],
|
||
"status": status,
|
||
"installed_record": installed.to_dict() if installed else None,
|
||
}
|
||
)
|
||
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 list builtin skills: {e}")
|
||
raise HTTPException(status_code=500, detail="获取内置 skill 列表失败")
|
||
|
||
|
||
@skills.post("/builtin/{slug}/install")
|
||
async def install_builtin_skill_route(
|
||
slug: str,
|
||
current_user: User = Depends(get_admin_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
try:
|
||
item = await install_builtin_skill(db, slug, installed_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 install builtin skill '{slug}': {e}")
|
||
raise HTTPException(status_code=500, detail="安装内置 skill 失败")
|
||
|
||
|
||
@skills.post("/builtin/{slug}/update")
|
||
async def update_builtin_skill_route(
|
||
slug: str,
|
||
payload: BuiltinSkillUpdateRequest,
|
||
current_user: User = Depends(get_admin_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
try:
|
||
item = await update_builtin_skill(
|
||
db,
|
||
slug,
|
||
force=payload.force,
|
||
updated_by=current_user.username,
|
||
)
|
||
return {"success": True, "data": item.to_dict()}
|
||
except BuiltinSkillUpdateConflictError as e:
|
||
raise HTTPException(
|
||
status_code=409,
|
||
detail={"needs_confirm": True, "message": str(e)},
|
||
)
|
||
except ValueError as e:
|
||
_raise_from_value_error(e)
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"Failed to update builtin skill '{slug}': {e}")
|
||
raise HTTPException(status_code=500, detail="更新内置 skill 失败")
|
||
|
||
|
||
@skills.post("/import")
|
||
async def import_skill_route(
|
||
file: UploadFile = File(...),
|
||
current_user: User = Depends(get_admin_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""导入技能包(支持 ZIP 或单个 SKILL.md,管理员)。"""
|
||
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 package: {e}")
|
||
raise HTTPException(status_code=500, detail="导入技能失败")
|
||
|
||
|
||
@skills.post("/remote/list")
|
||
async def list_remote_skills_route(
|
||
payload: RemoteSkillSourceRequest,
|
||
_current_user: User = Depends(get_admin_user),
|
||
):
|
||
try:
|
||
return {"success": True, "data": await list_remote_skills(payload.source)}
|
||
except ValueError as e:
|
||
_raise_from_value_error(e)
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"Failed to list remote skills from '{payload.source}': {e}")
|
||
raise HTTPException(status_code=500, detail="获取远程 skills 列表失败")
|
||
|
||
|
||
@skills.post("/remote/install")
|
||
async def install_remote_skill_route(
|
||
payload: RemoteSkillInstallRequest,
|
||
current_user: User = Depends(get_admin_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
try:
|
||
item = await install_remote_skill(
|
||
db,
|
||
source=payload.source,
|
||
skill=payload.skill,
|
||
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 install remote skill '{payload.skill}' from '{payload.source}': {e}")
|
||
raise HTTPException(status_code=500, detail="安装远程 skill 失败")
|
||
|
||
|
||
@skills.post("/remote/install-batch")
|
||
async def install_remote_skills_batch_route(
|
||
payload: RemoteSkillBatchInstallRequest,
|
||
current_user: User = Depends(get_admin_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""批量从同一远程仓库安装多个 skills(仅一次克隆,不存在的 skill 静默跳过)。"""
|
||
try:
|
||
results = await install_remote_skills_batch(
|
||
db,
|
||
source=payload.source,
|
||
skills=payload.skills,
|
||
created_by=current_user.username,
|
||
)
|
||
success_count = sum(1 for r in results if r["success"])
|
||
failed_count = sum(1 for r in results if not r["success"])
|
||
return {
|
||
"success": True,
|
||
"data": results,
|
||
"summary": {"total": len(results), "success": success_count, "failed": failed_count},
|
||
}
|
||
except ValueError as e:
|
||
_raise_from_value_error(e)
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"Failed to install remote skills batch from '{payload.source}': {e}")
|
||
raise HTTPException(status_code=500, detail="批量安装远程 skills 失败")
|
||
|
||
|
||
@skills.get("/{slug}/tree")
|
||
async def get_skill_tree_route(
|
||
slug: str,
|
||
_current_user: User = Depends(get_admin_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_admin_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_admin_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_admin_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_admin_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_admin_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_admin_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_admin_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="删除技能失败")
|