diff --git a/backend/server/routers/auth_router.py b/backend/server/routers/auth_router.py index 7b565d93..cb3be58f 100644 --- a/backend/server/routers/auth_router.py +++ b/backend/server/routers/auth_router.py @@ -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 diff --git a/backend/server/routers/department_router.py b/backend/server/routers/department_router.py index e1657d6e..2d8c2e6e 100644 --- a/backend/server/routers/department_router.py +++ b/backend/server/routers/department_router.py @@ -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": "部门已删除"} diff --git a/backend/test/integration/api/test_department_router.py b/backend/test/integration/api/test_department_router.py new file mode 100644 index 00000000..df674539 --- /dev/null +++ b/backend/test/integration/api/test_department_router.py @@ -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"] == "默认部门不允许删除" diff --git a/docs/develop-guides/roadmap.md b/docs/develop-guides/roadmap.md index d366c673..536130e5 100644 --- a/docs/develop-guides/roadmap.md +++ b/docs/develop-guides/roadmap.md @@ -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,保证测试部门、撤换部门等场景可直接删除,并补充对应集成测试覆盖该链路 --- diff --git a/web/src/components/DepartmentManagementComponent.vue b/web/src/components/DepartmentManagementComponent.vue index 0d577d09..b1b20937 100644 --- a/web/src/components/DepartmentManagementComponent.vue +++ b/web/src/components/DepartmentManagementComponent.vue @@ -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" > @@ -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: '取消',