fix: 调整部门删除逻辑,迁移用户至默认部门并清理相关配置,更新前端提示信息 Error: 部门删除限制

Fixes #607
This commit is contained in:
Wenjie Zhang 2026-04-04 11:10:30 +08:00
parent 5734229866
commit 2100edfd6e
5 changed files with 134 additions and 16 deletions

View File

@ -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(
select(func.count(User.id)).filter(
User.department_id == user.department_id, User.role == "admin", User.is_deleted == 0

View File

@ -7,10 +7,10 @@ import re
from fastapi import APIRouter, Depends, HTTPException, status, Request
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 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.user_repository import UserRepository
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
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:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="部门不存在")
# 检查部门下是否有用户
user_count_result = await db.execute(
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} 个用户"
)
if department.id == 1: # 默认部门的ID为1
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="默认部门不允许删除")
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.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": "部门已删除"}

View 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"] == "默认部门不允许删除"

View File

@ -38,6 +38,7 @@
- 收紧文件系统安全边界viewer/chat 下载与删除路径统一基于解析后的真实路径做允许目录校验,阻止通过软链接逃逸工作区/线程目录;同时将密码哈希默认实现升级为 Argon2并移除 skill frontmatter 解析中的正则回溯风险。
- 调整 Skills 导入能力:`/api/system/skills/import` 现在除 ZIP 外也支持直接上传单个 `SKILL.md`,前端上传入口与后端导入服务同步兼容,便于快速导入单文件技能
- 新增 Skills 远程安装能力Skills 管理页支持填写 `owner/repo` 或 GitHub URL后端通过隔离的临时 `HOME` 调用 `npx skills add` 下载指定 skill再复用现有导入链路写入 `saves/skills` 和数据库,避免将 `~/.agents/skills` 直接作为系统主存储;前端远程安装弹窗补充多选串行安装与批量进度展示,复用现有单 skill 安装接口逐个提交请求
- 调整部门删除语义:删除部门时不再要求用户数为 0而是将部门下用户迁移到默认部门同时清理部门级配置和部门 API Key保证测试部门、撤换部门等场景可直接删除并补充对应集成测试覆盖该链路
---

View File

@ -57,7 +57,7 @@
size="small"
danger
@click="confirmDeleteDepartment(record)"
:disabled="record.user_count > 0"
:disabled="record.id === 1"
class="action-btn lucide-icon-btn"
>
<Trash2 :size="14" />
@ -422,7 +422,7 @@ const handleDepartmentFormSubmit = async () => {
const confirmDeleteDepartment = (department) => {
Modal.confirm({
title: '确认删除部门',
content: `确定要删除部门 "${department.name}" 吗?此操作不可撤销。部门下必须没有用户才能删除`,
content: `确定要删除部门 "${department.name}" 吗?此操作不可撤销。该部门下的用户会被迁移到默认部门,部门级配置和部门 API Key 会一并清理`,
okText: '删除',
okType: 'danger',
cancelText: '取消',