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( 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

View File

@ -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": "部门已删除"}

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 解析中的正则回溯风险。 - 收紧文件系统安全边界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保证测试部门、撤换部门等场景可直接删除并补充对应集成测试覆盖该链路
--- ---

View File

@ -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: '取消',