feat(auth): 下放扩展管理权限

允许管理员进入扩展管理并管理 Skills,补充权限测试和 PR 检查说明,保持前后端权限口径一致。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Wenjie Zhang 2026-05-14 13:13:41 +08:00
parent ef963617fc
commit 33a71068ed
10 changed files with 86 additions and 92 deletions

View File

@ -121,3 +121,4 @@ make format # 格式化代码
1. 参考 [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) 规范编写提交信息。
2. 使用中文提交信息,标题简洁明了,描述具体改动内容和原因。
3. 创建 PR 需要参考 [CONTRIBUTING.md](CONTRIBUTING.md) 中的 PR 模板[PULL_REQUEST_TEMPLATE.md](.github/PULL_REQUEST_TEMPLATE.md),并在提交前完成其中的检查项。

View File

@ -121,3 +121,4 @@ make format # 格式化代码
1. 参考 [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) 规范编写提交信息。
2. 使用中文提交信息,标题简洁明了,描述具体改动内容和原因。
3. 创建 PR 需要参考 [CONTRIBUTING.md](CONTRIBUTING.md) 中的 PR 模板[PULL_REQUEST_TEMPLATE.md](.github/PULL_REQUEST_TEMPLATE.md),并在提交前完成其中的检查项。

View File

@ -28,6 +28,7 @@ docker logs api-dev --tail 100
2. 在对应目录完成开发与测试
3. 提交清晰的 Commit Message
4. 发起 Pull Request并说明修改内容、原因和验证方式
5. PR 模板 [PULL_REQUEST_TEMPLATE.md](.github/PULL_REQUEST_TEMPLATE.md) 中的检查项需要在提交前完成
示例:

View File

@ -12,6 +12,9 @@ PROMPT = f"""
专门用来回答用户的问题请根据用户提供的信息尽可能详细地回答问题
如果你不确定答案可以说你不知道但请尽量提供相关的信息或建议请保持礼貌和专业
<| 内部执行约束 |>
以下内容仅用于指导你的内部执行过程不属于面向用户的基本设定除非用户明确询问系统如何工作否则不要主动向用户说明工作区文件系统知识库路径工具调用方式等内部实现细节
<| 文件系统约束 |>
系统主要工作路径为 {VIRTUAL_PATH_PREFIX}但必须遵守规范
- {VIRTUAL_PATH_WORKSPACE}用于存放工作文件用户目录不要轻易写入

View File

@ -9,7 +9,7 @@ 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 server.utils.auth_middleware import get_admin_user, get_db
from yuxi.services.remote_skill_install_service import install_remote_skill, list_remote_skills
from yuxi.services.skill_service import (
BuiltinSkillUpdateConflictError,
@ -92,10 +92,10 @@ async def list_skills_route(
@skills.get("/dependency-options")
async def get_skill_dependency_options_route(
_current_user: User = Depends(get_superadmin_user),
_current_user: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""获取 skill 依赖项可选列表(仅超级管理员)。"""
"""获取 skill 依赖项可选列表(管理员)。"""
try:
return {"success": True, "data": await get_skill_dependency_options(db)}
except Exception as e:
@ -105,7 +105,7 @@ async def get_skill_dependency_options_route(
@skills.get("/builtin")
async def list_builtin_skills_route(
_current_user: User = Depends(get_superadmin_user),
_current_user: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
try:
@ -141,7 +141,7 @@ async def list_builtin_skills_route(
@skills.post("/builtin/{slug}/install")
async def install_builtin_skill_route(
slug: str,
current_user: User = Depends(get_superadmin_user),
current_user: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
try:
@ -160,7 +160,7 @@ async def install_builtin_skill_route(
async def update_builtin_skill_route(
slug: str,
payload: BuiltinSkillUpdateRequest,
current_user: User = Depends(get_superadmin_user),
current_user: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
try:
@ -188,10 +188,10 @@ async def update_builtin_skill_route(
@skills.post("/import")
async def import_skill_route(
file: UploadFile = File(...),
current_user: User = Depends(get_superadmin_user),
current_user: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""导入技能包(支持 ZIP 或单个 SKILL.md仅超级管理员)。"""
"""导入技能包(支持 ZIP 或单个 SKILL.md管理员)。"""
try:
file_bytes = await file.read()
item = await import_skill_zip(
@ -213,7 +213,7 @@ async def import_skill_route(
@skills.post("/remote/list")
async def list_remote_skills_route(
payload: RemoteSkillSourceRequest,
_current_user: User = Depends(get_superadmin_user),
_current_user: User = Depends(get_admin_user),
):
try:
return {"success": True, "data": await list_remote_skills(payload.source)}
@ -229,7 +229,7 @@ async def list_remote_skills_route(
@skills.post("/remote/install")
async def install_remote_skill_route(
payload: RemoteSkillInstallRequest,
current_user: User = Depends(get_superadmin_user),
current_user: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
try:
@ -254,10 +254,10 @@ async def install_remote_skill_route(
@skills.get("/{slug}/tree")
async def get_skill_tree_route(
slug: str,
_current_user: User = Depends(get_superadmin_user),
_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}
@ -274,10 +274,10 @@ async def get_skill_tree_route(
async def get_skill_file_route(
slug: str,
path: str = Query(..., description="相对 skill 根目录路径"),
_current_user: User = Depends(get_superadmin_user),
_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}
@ -294,10 +294,10 @@ async def get_skill_file_route(
async def create_skill_file_route(
slug: str,
payload: SkillNodeCreateRequest,
current_user: User = Depends(get_superadmin_user),
current_user: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""创建技能文件或目录(仅超级管理员)。"""
"""创建技能文件或目录(管理员)。"""
try:
await create_skill_node(
db,
@ -321,10 +321,10 @@ async def create_skill_file_route(
async def update_skill_file_route(
slug: str,
payload: SkillFileUpdateRequest,
current_user: User = Depends(get_superadmin_user),
current_user: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""更新技能文本文件(仅超级管理员)。"""
"""更新技能文本文件(管理员)。"""
try:
await update_skill_file(
db,
@ -347,10 +347,10 @@ async def update_skill_file_route(
async def update_skill_dependencies_route(
slug: str,
payload: SkillDependenciesUpdateRequest,
current_user: User = Depends(get_superadmin_user),
current_user: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""更新 skill 依赖(仅超级管理员)。"""
"""更新 skill 依赖(管理员)。"""
try:
item = await update_skill_dependencies(
db,
@ -374,10 +374,10 @@ async def update_skill_dependencies_route(
async def delete_skill_file_route(
slug: str,
path: str = Query(..., description="相对 skill 根目录路径"),
_current_user: User = Depends(get_superadmin_user),
_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}
@ -394,10 +394,10 @@ async def delete_skill_file_route(
async def export_skill_route(
slug: str,
background_tasks: BackgroundTasks,
_current_user: User = Depends(get_superadmin_user),
_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)
@ -418,10 +418,10 @@ async def export_skill_route(
@skills.delete("/{slug}")
async def delete_skill_route(
slug: str,
_current_user: User = Depends(get_superadmin_user),
_current_user: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""删除技能(目录 + 数据库记录,仅超级管理员)。"""
"""删除技能(目录 + 数据库记录,管理员)。"""
try:
await delete_skill(db, slug=slug)
return {"success": True}

View File

@ -4,11 +4,11 @@ from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from server.routers.skill_router import skills
from server.utils.auth_middleware import get_admin_user, get_db, get_superadmin_user
from server.utils.auth_middleware import get_admin_user, get_db
from yuxi.storage.postgres.models_business import Skill, User
def _build_app(*, allow_superadmin: bool) -> FastAPI:
def _build_app(*, allow_admin: bool = True) -> FastAPI:
app = FastAPI()
app.include_router(skills, prefix="/api")
@ -16,6 +16,8 @@ def _build_app(*, allow_superadmin: bool) -> FastAPI:
return None
async def fake_admin_user():
if not allow_admin:
raise HTTPException(status_code=403, detail="需要管理员权限")
return User(
username="admin",
user_id="admin",
@ -23,19 +25,8 @@ def _build_app(*, allow_superadmin: bool) -> FastAPI:
role="admin",
)
async def fake_superadmin_user():
if not allow_superadmin:
raise HTTPException(status_code=403, detail="需要超级管理员权限")
return User(
username="root",
user_id="root",
password_hash="x",
role="superadmin",
)
app.dependency_overrides[get_db] = fake_db
app.dependency_overrides[get_admin_user] = fake_admin_user
app.dependency_overrides[get_superadmin_user] = fake_superadmin_user
return app
@ -52,7 +43,7 @@ def test_list_skills_route_returns_data(monkeypatch):
monkeypatch.setattr("server.routers.skill_router.list_skills", fake_list_skills)
app = _build_app(allow_superadmin=True)
app = _build_app()
client = TestClient(app)
resp = client.get("/api/system/skills")
assert resp.status_code == 200, resp.text
@ -61,15 +52,21 @@ def test_list_skills_route_returns_data(monkeypatch):
assert payload["data"][0]["slug"] == "demo"
def test_import_skill_requires_superadmin():
app = _build_app(allow_superadmin=False)
def test_skill_management_requires_admin():
app = _build_app(allow_admin=False)
client = TestClient(app)
resp = client.post(
dependency_resp = client.get("/api/system/skills/dependency-options")
assert dependency_resp.status_code == 403
import_resp = client.post(
"/api/system/skills/import",
files={"file": ("demo.zip", b"not zip", "application/zip")},
)
assert resp.status_code == 403
assert import_resp.status_code == 403
delete_resp = client.delete("/api/system/skills/demo")
assert delete_resp.status_code == 403
def test_import_skill_route_accepts_skill_md(monkeypatch):
@ -90,7 +87,7 @@ def test_import_skill_route_accepts_skill_md(monkeypatch):
monkeypatch.setattr("server.routers.skill_router.import_skill_zip", fake_import_skill_zip)
app = _build_app(allow_superadmin=True)
app = _build_app()
client = TestClient(app)
resp = client.post(
@ -100,7 +97,7 @@ def test_import_skill_route_accepts_skill_md(monkeypatch):
assert resp.status_code == 200, resp.text
assert captured["filename"] == "SKILL.md"
assert "name: demo" in captured["file_bytes"]
assert captured["created_by"] == "root"
assert captured["created_by"] == "admin"
def test_update_skill_file_passes_operator(monkeypatch):
@ -114,7 +111,7 @@ def test_update_skill_file_passes_operator(monkeypatch):
monkeypatch.setattr("server.routers.skill_router.update_skill_file", fake_update_skill_file)
app = _build_app(allow_superadmin=True)
app = _build_app()
client = TestClient(app)
resp = client.put(
@ -127,7 +124,7 @@ def test_update_skill_file_passes_operator(monkeypatch):
assert resp.status_code == 200, resp.text
assert captured["slug"] == "demo"
assert captured["relative_path"] == "SKILL.md"
assert captured["updated_by"] == "root"
assert captured["updated_by"] == "admin"
def test_dependency_options_route(monkeypatch):
@ -140,7 +137,7 @@ def test_dependency_options_route(monkeypatch):
monkeypatch.setattr("server.routers.skill_router.get_skill_dependency_options", fake_get_skill_dependency_options)
app = _build_app(allow_superadmin=True)
app = _build_app()
client = TestClient(app)
resp = client.get("/api/system/skills/dependency-options")
assert resp.status_code == 200, resp.text
@ -178,7 +175,7 @@ def test_update_skill_dependencies_route(monkeypatch):
monkeypatch.setattr("server.routers.skill_router.update_skill_dependencies", fake_update_skill_dependencies)
app = _build_app(allow_superadmin=True)
app = _build_app()
client = TestClient(app)
resp = client.put(
"/api/system/skills/demo/dependencies",
@ -193,7 +190,7 @@ def test_update_skill_dependencies_route(monkeypatch):
assert captured["tool_dependencies"] == ["calculator"]
assert captured["mcp_dependencies"] == ["mcp-a"]
assert captured["skill_dependencies"] == ["other-skill"]
assert captured["updated_by"] == "root"
assert captured["updated_by"] == "admin"
def test_list_remote_skills_route(monkeypatch):
@ -203,7 +200,7 @@ def test_list_remote_skills_route(monkeypatch):
monkeypatch.setattr("server.routers.skill_router.list_remote_skills", fake_list_remote_skills)
app = _build_app(allow_superadmin=True)
app = _build_app()
client = TestClient(app)
resp = client.post("/api/system/skills/remote/list", json={"source": "anthropics/skills"})
assert resp.status_code == 200, resp.text
@ -230,7 +227,7 @@ def test_install_remote_skill_route(monkeypatch):
monkeypatch.setattr("server.routers.skill_router.install_remote_skill", fake_install_remote_skill)
app = _build_app(allow_superadmin=True)
app = _build_app()
client = TestClient(app)
resp = client.post(
"/api/system/skills/remote/install",
@ -242,4 +239,4 @@ def test_install_remote_skill_route(monkeypatch):
assert payload["data"]["slug"] == "frontend-design"
assert captured["source"] == "anthropics/skills"
assert captured["skill"] == "frontend-design"
assert captured["created_by"] == "root"
assert captured["created_by"] == "admin"

View File

@ -18,6 +18,8 @@
- 拓宽检索的知识源统一多知识源channel目前已知知识库/知识图谱/网页,可拓展:个人知识库、数据库、历史对话等
- 前置任务,多知识库并行检索(扩展 query_kb
- 新增 query_keywords 工具,专门用于基于关键词命中的排序,也结合词频(和 BM25 的区别?)
- 调研将当前知识库映射为虚拟文件系统工具的可行性,先明确文件树映射、权限边界、内容读取与 Agent 工具调用形态,再决定是否实现
- 参考 AgenticRAG 方案扩展当前 Search 工具:等待虚拟文件系统构建完成后,改进 Search 返回递增文件序列 ID新增 Find 与 Open 能力Summary 暂缓
- 评估,基于 Agent 的评估,这里应该是结合 Langfuse 实现
### Bugs
@ -36,6 +38,7 @@
### 0.6.2 开发记录
<!-- 0.6.2 的内容请放在这里 -->
- 下放扩展管理权限:普通管理员现在可进入扩展管理并完整管理 Tools、MCP、SubAgent、Skills同步放开 Skill 管理接口权限并补充权限测试。
- 调整 Agent 知识库默认选择:未显式配置知识库时默认启用当前用户可访问的全部知识库,显式保存空列表仍表示不启用知识库。
- 优化评估基准自动生成:仅支持 commonrag/Milvus 知识库,默认参考 chunks 数量改为 1多 chunk 场景复用知识库向量检索选择相似 chunks不再对全量 chunks 重新计算 embedding并移除前端 Embedding 模型选择。
- 修复知识库文档入库状态回退:当已解析文件缺失 `markdown_file` 解析产物时,索引流程会将文件状态恢复为未解析,便于重新解析而不是停留在索引失败。

View File

@ -1,10 +1,4 @@
import {
apiAdminGet,
apiSuperAdminDelete,
apiSuperAdminGet,
apiSuperAdminPost,
apiSuperAdminPut
} from './base'
import { apiAdminGet, apiAdminPost, apiAdminPut, apiAdminDelete } from './base'
const BASE_URL = '/api/system/skills'
@ -15,67 +9,67 @@ export const listSkills = async () => {
export const importSkillZip = async (file) => {
const formData = new FormData()
formData.append('file', file)
return apiSuperAdminPost(`${BASE_URL}/import`, formData)
return apiAdminPost(`${BASE_URL}/import`, formData)
}
export const listRemoteSkills = async (source) => {
return apiSuperAdminPost(`${BASE_URL}/remote/list`, { source })
return apiAdminPost(`${BASE_URL}/remote/list`, { source })
}
export const installRemoteSkill = async (payload) => {
return apiSuperAdminPost(`${BASE_URL}/remote/install`, payload)
return apiAdminPost(`${BASE_URL}/remote/install`, payload)
}
export const getSkillDependencyOptions = async () => {
return apiSuperAdminGet(`${BASE_URL}/dependency-options`)
return apiAdminGet(`${BASE_URL}/dependency-options`)
}
export const listBuiltinSkills = async () => {
return apiSuperAdminGet(`${BASE_URL}/builtin`)
return apiAdminGet(`${BASE_URL}/builtin`)
}
export const installBuiltinSkill = async (slug) => {
return apiSuperAdminPost(`${BASE_URL}/builtin/${encodeURIComponent(slug)}/install`)
return apiAdminPost(`${BASE_URL}/builtin/${encodeURIComponent(slug)}/install`)
}
export const updateBuiltinSkill = async (slug, force = false) => {
return apiSuperAdminPost(`${BASE_URL}/builtin/${encodeURIComponent(slug)}/update`, { force })
return apiAdminPost(`${BASE_URL}/builtin/${encodeURIComponent(slug)}/update`, { force })
}
export const getSkillTree = async (slug) => {
return apiSuperAdminGet(`${BASE_URL}/${encodeURIComponent(slug)}/tree`)
return apiAdminGet(`${BASE_URL}/${encodeURIComponent(slug)}/tree`)
}
export const getSkillFile = async (slug, path) => {
return apiSuperAdminGet(
return apiAdminGet(
`${BASE_URL}/${encodeURIComponent(slug)}/file?path=${encodeURIComponent(path)}`
)
}
export const createSkillFile = async (slug, payload) => {
return apiSuperAdminPost(`${BASE_URL}/${encodeURIComponent(slug)}/file`, payload)
return apiAdminPost(`${BASE_URL}/${encodeURIComponent(slug)}/file`, payload)
}
export const updateSkillFile = async (slug, payload) => {
return apiSuperAdminPut(`${BASE_URL}/${encodeURIComponent(slug)}/file`, payload)
return apiAdminPut(`${BASE_URL}/${encodeURIComponent(slug)}/file`, payload)
}
export const updateSkillDependencies = async (slug, payload) => {
return apiSuperAdminPut(`${BASE_URL}/${encodeURIComponent(slug)}/dependencies`, payload)
return apiAdminPut(`${BASE_URL}/${encodeURIComponent(slug)}/dependencies`, payload)
}
export const deleteSkillFile = async (slug, path) => {
return apiSuperAdminDelete(
return apiAdminDelete(
`${BASE_URL}/${encodeURIComponent(slug)}/file?path=${encodeURIComponent(path)}`
)
}
export const exportSkill = async (slug) => {
return apiSuperAdminGet(`${BASE_URL}/${encodeURIComponent(slug)}/export`, {}, 'blob')
return apiAdminGet(`${BASE_URL}/${encodeURIComponent(slug)}/export`, {}, 'blob')
}
export const deleteSkill = async (slug) => {
return apiSuperAdminDelete(`${BASE_URL}/${encodeURIComponent(slug)}`)
return apiAdminDelete(`${BASE_URL}/${encodeURIComponent(slug)}`)
}
export const skillApi = {

View File

@ -152,14 +152,12 @@ const mainList = computed(() => {
})
}
if (userStore.isSuperAdmin) {
items.push({
name: '扩展管理',
path: '/extensions',
icon: Blocks,
activeIcon: Blocks
})
}
items.push({
name: '模型配置',

View File

@ -134,8 +134,7 @@ const router = createRouter({
meta: {
keepAlive: false,
requiresAuth: true,
requiresAdmin: true,
requiresSuperAdmin: true
requiresAdmin: true
}
},
{
@ -145,8 +144,7 @@ const router = createRouter({
meta: {
keepAlive: false,
requiresAuth: true,
requiresAdmin: true,
requiresSuperAdmin: true
requiresAdmin: true
}
},
{
@ -156,8 +154,7 @@ const router = createRouter({
meta: {
keepAlive: false,
requiresAuth: true,
requiresAdmin: true,
requiresSuperAdmin: true
requiresAdmin: true
}
},
{
@ -167,8 +164,7 @@ const router = createRouter({
meta: {
keepAlive: false,
requiresAuth: true,
requiresAdmin: true,
requiresSuperAdmin: true
requiresAdmin: true
}
}
]