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(
|
||||
select(func.count(User.id)).filter(
|
||||
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 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": "部门已删除"}
|
||||
|
||||
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 解析中的正则回溯风险。
|
||||
- 调整 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,保证测试部门、撤换部门等场景可直接删除,并补充对应集成测试覆盖该链路
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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: '取消',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user