parent
5734229866
commit
2100edfd6e
@ -640,7 +640,7 @@ async def delete_user(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 检查是否是部门的唯一管理员
|
# 检查是否是部门的唯一管理员
|
||||||
if user.role == "admin":
|
if user.role == "admin" and current_user.role != "superadmin":
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(func.count(User.id)).filter(
|
select(func.count(User.id)).filter(
|
||||||
User.department_id == user.department_id, User.role == "admin", User.is_deleted == 0
|
User.department_id == user.department_id, User.role == "admin", User.is_deleted == 0
|
||||||
|
|||||||
@ -7,10 +7,10 @@ import re
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import delete as sqlalchemy_delete, select, func
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from yuxi.storage.postgres.models_business import Department, User
|
from yuxi.storage.postgres.models_business import APIKey, AgentConfig, Department, User
|
||||||
from yuxi.repositories.department_repository import DepartmentRepository
|
from yuxi.repositories.department_repository import DepartmentRepository
|
||||||
from yuxi.repositories.user_repository import UserRepository
|
from yuxi.repositories.user_repository import UserRepository
|
||||||
from server.utils.auth_middleware import get_superadmin_user, get_admin_user, get_db
|
from server.utils.auth_middleware import get_superadmin_user, get_admin_user, get_db
|
||||||
@ -62,6 +62,21 @@ class DepartmentResponse(BaseModel):
|
|||||||
user_count: int = 0
|
user_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_default_department(db: AsyncSession) -> Department:
|
||||||
|
result = await db.execute(select(Department).filter(Department.id == 1))
|
||||||
|
default_department = result.scalar_one_or_none()
|
||||||
|
if default_department is not None:
|
||||||
|
return default_department
|
||||||
|
|
||||||
|
default_department = Department(
|
||||||
|
name="默认部门",
|
||||||
|
description="系统在删除部门时自动创建的默认部门",
|
||||||
|
)
|
||||||
|
db.add(default_department)
|
||||||
|
await db.flush()
|
||||||
|
return default_department
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# === 部门管理路由 ===
|
# === 部门管理路由 ===
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@ -226,22 +241,27 @@ async def delete_department(
|
|||||||
if not department:
|
if not department:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="部门不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="部门不存在")
|
||||||
|
|
||||||
# 检查部门下是否有用户
|
if department.id == 1: # 默认部门的ID为1
|
||||||
user_count_result = await db.execute(
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="默认部门不允许删除")
|
||||||
select(func.count(User.id)).filter(User.department_id == department_id, User.is_deleted == 0)
|
|
||||||
)
|
|
||||||
user_count = user_count_result.scalar()
|
|
||||||
|
|
||||||
if user_count > 0:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST, detail=f"无法删除部门,该部门下还有 {user_count} 个用户"
|
|
||||||
)
|
|
||||||
|
|
||||||
department_name = department.name
|
department_name = department.name
|
||||||
|
result = await db.execute(select(User).filter(User.department_id == department_id))
|
||||||
|
department_users = result.scalars().all()
|
||||||
|
|
||||||
|
if department_users:
|
||||||
|
for user in department_users:
|
||||||
|
user.department_id = 1 # 将用户迁移到默认部门
|
||||||
|
|
||||||
|
await db.execute(sqlalchemy_delete(AgentConfig).where(AgentConfig.department_id == department_id))
|
||||||
|
await db.execute(sqlalchemy_delete(APIKey).where(APIKey.department_id == department_id))
|
||||||
await db.delete(department)
|
await db.delete(department)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
# 记录操作
|
# 记录操作
|
||||||
await log_operation(db, current_user.id, "删除部门", f"删除部门: {department_name}", request)
|
if department_users:
|
||||||
|
detail = f"删除部门: {department_name},迁移 {len(department_users)} 个用户到默认部门"
|
||||||
|
else:
|
||||||
|
detail = f"删除部门: {department_name}"
|
||||||
|
await log_operation(db, current_user.id, "删除部门", detail, request)
|
||||||
|
|
||||||
return {"success": True, "message": "部门已删除"}
|
return {"success": True, "message": "部门已删除"}
|
||||||
|
|||||||
97
backend/test/integration/api/test_department_router.py
Normal file
97
backend/test/integration/api/test_department_router.py
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
"""
|
||||||
|
Integration tests for department management API routes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_superadmin_can_delete_department_with_users(test_client, admin_headers):
|
||||||
|
suffix = uuid.uuid4().hex[:8]
|
||||||
|
department_payload = {
|
||||||
|
"name": f"pytest_department_{suffix}",
|
||||||
|
"description": "integration test department",
|
||||||
|
"admin_user_id": f"pta_{suffix}",
|
||||||
|
"admin_password": "RouterDept123!",
|
||||||
|
}
|
||||||
|
user_payload = {
|
||||||
|
"username": f"dept_user_{suffix}",
|
||||||
|
"password": "RouterUser123!",
|
||||||
|
"role": "user",
|
||||||
|
}
|
||||||
|
|
||||||
|
department_id = None
|
||||||
|
created_user_id = None
|
||||||
|
department_admin_id = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
create_department_response = await test_client.post(
|
||||||
|
"/api/departments",
|
||||||
|
json=department_payload,
|
||||||
|
headers=admin_headers,
|
||||||
|
)
|
||||||
|
assert create_department_response.status_code == 201, create_department_response.text
|
||||||
|
department_id = create_department_response.json()["id"]
|
||||||
|
|
||||||
|
create_user_response = await test_client.post(
|
||||||
|
"/api/auth/users",
|
||||||
|
json={**user_payload, "department_id": department_id},
|
||||||
|
headers=admin_headers,
|
||||||
|
)
|
||||||
|
assert create_user_response.status_code == 200, create_user_response.text
|
||||||
|
created_user_id = create_user_response.json()["id"]
|
||||||
|
|
||||||
|
list_users_response = await test_client.get("/api/auth/users", headers=admin_headers)
|
||||||
|
assert list_users_response.status_code == 200, list_users_response.text
|
||||||
|
users_before_delete = list_users_response.json()
|
||||||
|
department_admin = next((user for user in users_before_delete if user["user_id"] == department_payload["admin_user_id"]), None)
|
||||||
|
assert department_admin is not None
|
||||||
|
department_admin_id = department_admin["id"]
|
||||||
|
|
||||||
|
delete_department_response = await test_client.delete(f"/api/departments/{department_id}", headers=admin_headers)
|
||||||
|
assert delete_department_response.status_code == 200, delete_department_response.text
|
||||||
|
assert delete_department_response.json()["success"] is True
|
||||||
|
department_id = None
|
||||||
|
|
||||||
|
deleted_department_response = await test_client.get(
|
||||||
|
f"/api/departments/{create_department_response.json()['id']}",
|
||||||
|
headers=admin_headers,
|
||||||
|
)
|
||||||
|
assert deleted_department_response.status_code == 404, deleted_department_response.text
|
||||||
|
|
||||||
|
list_users_after_delete_response = await test_client.get("/api/auth/users", headers=admin_headers)
|
||||||
|
assert list_users_after_delete_response.status_code == 200, list_users_after_delete_response.text
|
||||||
|
users_after_delete = list_users_after_delete_response.json()
|
||||||
|
|
||||||
|
migrated_admin = next((user for user in users_after_delete if user["id"] == department_admin_id), None)
|
||||||
|
assert migrated_admin is not None
|
||||||
|
assert migrated_admin["department_name"] == "默认部门"
|
||||||
|
|
||||||
|
migrated_user = next((user for user in users_after_delete if user["id"] == created_user_id), None)
|
||||||
|
assert migrated_user is not None
|
||||||
|
assert migrated_user["department_name"] == "默认部门"
|
||||||
|
finally:
|
||||||
|
if department_admin_id is not None:
|
||||||
|
await test_client.delete(f"/api/auth/users/{department_admin_id}", headers=admin_headers)
|
||||||
|
if created_user_id is not None:
|
||||||
|
await test_client.delete(f"/api/auth/users/{created_user_id}", headers=admin_headers)
|
||||||
|
if department_id is not None:
|
||||||
|
await test_client.delete(f"/api/departments/{department_id}", headers=admin_headers)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_superadmin_cannot_delete_default_department(test_client, admin_headers):
|
||||||
|
departments_response = await test_client.get("/api/departments", headers=admin_headers)
|
||||||
|
assert departments_response.status_code == 200, departments_response.text
|
||||||
|
departments = departments_response.json()
|
||||||
|
|
||||||
|
default_department = next((department for department in departments if department["name"] == "默认部门"), None)
|
||||||
|
assert default_department is not None
|
||||||
|
|
||||||
|
delete_response = await test_client.delete(f"/api/departments/{default_department['id']}", headers=admin_headers)
|
||||||
|
assert delete_response.status_code == 400, delete_response.text
|
||||||
|
assert delete_response.json()["detail"] == "默认部门不允许删除"
|
||||||
@ -38,6 +38,7 @@
|
|||||||
- 收紧文件系统安全边界:viewer/chat 下载与删除路径统一基于解析后的真实路径做允许目录校验,阻止通过软链接逃逸工作区/线程目录;同时将密码哈希默认实现升级为 Argon2,并移除 skill frontmatter 解析中的正则回溯风险。
|
- 收紧文件系统安全边界:viewer/chat 下载与删除路径统一基于解析后的真实路径做允许目录校验,阻止通过软链接逃逸工作区/线程目录;同时将密码哈希默认实现升级为 Argon2,并移除 skill frontmatter 解析中的正则回溯风险。
|
||||||
- 调整 Skills 导入能力:`/api/system/skills/import` 现在除 ZIP 外也支持直接上传单个 `SKILL.md`,前端上传入口与后端导入服务同步兼容,便于快速导入单文件技能
|
- 调整 Skills 导入能力:`/api/system/skills/import` 现在除 ZIP 外也支持直接上传单个 `SKILL.md`,前端上传入口与后端导入服务同步兼容,便于快速导入单文件技能
|
||||||
- 新增 Skills 远程安装能力:Skills 管理页支持填写 `owner/repo` 或 GitHub URL,后端通过隔离的临时 `HOME` 调用 `npx skills add` 下载指定 skill,再复用现有导入链路写入 `saves/skills` 和数据库,避免将 `~/.agents/skills` 直接作为系统主存储;前端远程安装弹窗补充多选串行安装与批量进度展示,复用现有单 skill 安装接口逐个提交请求
|
- 新增 Skills 远程安装能力:Skills 管理页支持填写 `owner/repo` 或 GitHub URL,后端通过隔离的临时 `HOME` 调用 `npx skills add` 下载指定 skill,再复用现有导入链路写入 `saves/skills` 和数据库,避免将 `~/.agents/skills` 直接作为系统主存储;前端远程安装弹窗补充多选串行安装与批量进度展示,复用现有单 skill 安装接口逐个提交请求
|
||||||
|
- 调整部门删除语义:删除部门时不再要求用户数为 0,而是将部门下用户迁移到默认部门,同时清理部门级配置和部门 API Key,保证测试部门、撤换部门等场景可直接删除,并补充对应集成测试覆盖该链路
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -57,7 +57,7 @@
|
|||||||
size="small"
|
size="small"
|
||||||
danger
|
danger
|
||||||
@click="confirmDeleteDepartment(record)"
|
@click="confirmDeleteDepartment(record)"
|
||||||
:disabled="record.user_count > 0"
|
:disabled="record.id === 1"
|
||||||
class="action-btn lucide-icon-btn"
|
class="action-btn lucide-icon-btn"
|
||||||
>
|
>
|
||||||
<Trash2 :size="14" />
|
<Trash2 :size="14" />
|
||||||
@ -422,7 +422,7 @@ const handleDepartmentFormSubmit = async () => {
|
|||||||
const confirmDeleteDepartment = (department) => {
|
const confirmDeleteDepartment = (department) => {
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: '确认删除部门',
|
title: '确认删除部门',
|
||||||
content: `确定要删除部门 "${department.name}" 吗?此操作不可撤销。部门下必须没有用户才能删除。`,
|
content: `确定要删除部门 "${department.name}" 吗?此操作不可撤销。该部门下的用户会被迁移到默认部门,部门级配置和部门 API Key 会一并清理。`,
|
||||||
okText: '删除',
|
okText: '删除',
|
||||||
okType: 'danger',
|
okType: 'danger',
|
||||||
cancelText: '取消',
|
cancelText: '取消',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user