feat: LDAP 账号管理 — OU管理/添加用户/删除用户/批量导入
后端新增 4 个 LDAP 管理端点: - POST /ldap-config/create-ou: 创建组织单元 - POST /ldap-config/create-user: 创建单个用户 (SSHA加密) - POST /ldap-config/delete-user: 删除用户 - POST /ldap-config/batch-import-ldap: 批量导入用户 - POST /ldap-config/user-list: 查询用户列表(恢复) 前端 LdapConfigPage 增强: - LDAP 账号管理折叠区(配置存在即显示) - OU 下拉选择 + 根节点选项 + 数量统计 - 用户列表表格(全选/单选/DN显示) - 新建 OU 弹框(名称+父级DN+描述) - 添加用户弹框(用户名+密码+显示名+邮箱+目标OU) - 删除选中用户按钮(确认后批量删除) 前端 UsersPage BatchImportModal 三 Tab: - 本地导入 / 导入到 LDAP / 从 LDAP 导入 测试修复: - test_user_endpoint.py: 分页格式兼容 - test_users.py: 分页格式兼容 - test_system_settings.py: 精简后配置项匹配
This commit is contained in:
1 parent
344c519209
commit
be2d6f3894
20 files changed
+2229
-1019
No files matched your search
@@ -1,32 +1,70 @@
|
||||
from typing import Optional, List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select, func
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy import select, func, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from io import BytesIO
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user, hash_password, require_role
|
||||
from app.models.user import User
|
||||
from app.schemas.user import UserCreate, UserUpdate, UserResponse
|
||||
from app.schemas.user import UserCreate, UserUpdate, UserResponse, PasswordReset
|
||||
from app.services.audit_log_service import AuditLogService
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["用户管理"])
|
||||
|
||||
|
||||
@router.get("", response_model=List[UserResponse])
|
||||
@router.get("")
|
||||
async def list_users(
|
||||
role: Optional[str] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
search: Optional[str] = Query(None),
|
||||
role: Optional[str] = Query(None),
|
||||
is_active: Optional[bool] = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role("admin")),
|
||||
):
|
||||
stmt = select(User).order_by(User.id)
|
||||
"""搜索分页用户列表"""
|
||||
query = select(User)
|
||||
count_q = select(func.count(User.id))
|
||||
|
||||
if search:
|
||||
like = f"%{search}%"
|
||||
cond = or_(User.username.contains(search), User.display_name.contains(like), User.email.contains(like))
|
||||
query = query.where(cond)
|
||||
count_q = count_q.where(cond)
|
||||
if role:
|
||||
stmt = stmt.where(User.role == role)
|
||||
query = query.where(User.role == role)
|
||||
count_q = count_q.where(User.role == role)
|
||||
if is_active is not None:
|
||||
stmt = stmt.where(User.is_active == is_active)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().all()
|
||||
query = query.where(User.is_active == is_active)
|
||||
count_q = count_q.where(User.is_active == is_active)
|
||||
|
||||
total = (await db.execute(count_q)).scalar() or 0
|
||||
query = query.order_by(User.id).offset((page - 1) * page_size).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
users = result.scalars().all()
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"items": [
|
||||
{
|
||||
"id": u.id,
|
||||
"username": u.username,
|
||||
"display_name": u.display_name,
|
||||
"email": u.email,
|
||||
"phone": getattr(u, 'phone', None),
|
||||
"role": u.role,
|
||||
"is_active": u.is_active,
|
||||
"last_login": u.last_login.isoformat() if u.last_login else None,
|
||||
"created_at": u.created_at.isoformat() if u.created_at else None,
|
||||
}
|
||||
for u in users
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.post("", response_model=UserResponse, status_code=201)
|
||||
@@ -35,6 +73,7 @@ async def create_user(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role("admin")),
|
||||
):
|
||||
"""创建用户"""
|
||||
exists = await db.execute(select(User).where(User.username == data.username))
|
||||
if exists.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="用户名已存在")
|
||||
@@ -42,29 +81,149 @@ async def create_user(
|
||||
user = User(
|
||||
username=data.username,
|
||||
display_name=data.display_name,
|
||||
email=data.email,
|
||||
phone=data.phone,
|
||||
role=data.role,
|
||||
hashed_password=hash_password(data.password),
|
||||
email=data.email,
|
||||
phone=getattr(data, 'phone', None),
|
||||
role=data.role,
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
await AuditLogService.log(
|
||||
db, user_id=current_user.id, action="CREATE_USER",
|
||||
target_type="user", target_id=user.id,
|
||||
detail=f"创建用户: {data.username} ({data.role})",
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
# ── 固定路径路由 (必须在 /{user_id} 之前) ──
|
||||
|
||||
|
||||
@router.post("/batch-import")
|
||||
async def batch_import_users(
|
||||
data: dict,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role("admin")),
|
||||
):
|
||||
"""批量导入用户 — 本地模式(Excel) 或 LDAP模式"""
|
||||
users_data = data.get("users", [])
|
||||
import_type = data.get("import_type", "local")
|
||||
skip_existing = data.get("skip_existing", True)
|
||||
|
||||
if not users_data:
|
||||
raise HTTPException(status_code=400, detail="没有导入数据")
|
||||
|
||||
success_count = 0
|
||||
skipped_count = 0
|
||||
failed_users = []
|
||||
|
||||
for u in users_data:
|
||||
username = u.get("username", "")
|
||||
display_name = u.get("display_name", "")
|
||||
if not username or not display_name:
|
||||
failed_users.append({"username": username, "error": "缺少用户名或姓名"})
|
||||
continue
|
||||
|
||||
exists = await db.execute(select(User).where(User.username == username))
|
||||
if exists.scalar_one_or_none():
|
||||
if skip_existing:
|
||||
skipped_count += 1
|
||||
continue
|
||||
failed_users.append({"username": username, "error": "用户名已存在"})
|
||||
continue
|
||||
|
||||
user = User(
|
||||
username=username,
|
||||
display_name=display_name,
|
||||
hashed_password=hash_password(u.get("password", username)),
|
||||
email=u.get("email"),
|
||||
phone=u.get("phone"),
|
||||
role=u.get("role", "developer"),
|
||||
)
|
||||
db.add(user)
|
||||
success_count += 1
|
||||
|
||||
await db.commit()
|
||||
return {
|
||||
"success_count": success_count,
|
||||
"skipped_count": skipped_count,
|
||||
"failed_count": len(failed_users),
|
||||
"failed_users": failed_users,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/batch-import-ldap")
|
||||
async def batch_import_from_ldap(
|
||||
data: dict,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role("admin")),
|
||||
):
|
||||
"""从 LDAP 批量导入用户"""
|
||||
users_data = data.get("users", [])
|
||||
if not users_data:
|
||||
raise HTTPException(status_code=400, detail="没有导入数据")
|
||||
|
||||
success_count = 0
|
||||
skipped_count = 0
|
||||
failed_users = []
|
||||
|
||||
for u in users_data:
|
||||
username = u.get("username", u.get("sAMAccountName", ""))
|
||||
display_name = u.get("display_name", u.get("displayName", ""))
|
||||
email = u.get("email", u.get("mail", ""))
|
||||
|
||||
if not username:
|
||||
failed_users.append({"username": "", "error": "缺少用户名"})
|
||||
continue
|
||||
|
||||
exists = await db.execute(select(User).where(User.username == username))
|
||||
if exists.scalar_one_or_none():
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
user = User(
|
||||
username=username,
|
||||
display_name=display_name or username,
|
||||
hashed_password=hash_password(username),
|
||||
email=email or None,
|
||||
role="developer",
|
||||
)
|
||||
db.add(user)
|
||||
success_count += 1
|
||||
|
||||
await db.commit()
|
||||
return {
|
||||
"success_count": success_count,
|
||||
"skipped_count": skipped_count,
|
||||
"failed_count": len(failed_users),
|
||||
"failed_users": failed_users,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/import-template")
|
||||
async def download_import_template(
|
||||
current_user: User = Depends(require_role("admin")),
|
||||
):
|
||||
"""下载用户导入模板 — CSV 格式"""
|
||||
import csv, io
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(["姓名", "邮箱", "手机", "角色"])
|
||||
writer.writerow(["张三", "zhangsan@example.com", "13800138000", "developer"])
|
||||
writer.writerow(["李四", "lisi@example.com", "", "dba"])
|
||||
return StreamingResponse(
|
||||
iter([output.getvalue().encode("utf-8-sig")]),
|
||||
media_type="text/csv;charset=utf-8",
|
||||
headers={"Content-Disposition": "attachment; filename=users_import_template.csv"},
|
||||
)
|
||||
|
||||
|
||||
# ── 动态路径路由 ──
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserResponse)
|
||||
async def get_user(
|
||||
user_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role("admin")),
|
||||
):
|
||||
"""获取单个用户"""
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
@@ -79,12 +238,15 @@ async def update_user(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role("admin")),
|
||||
):
|
||||
"""更新用户信息"""
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
if "password" in update_data:
|
||||
user.hashed_password = hash_password(update_data.pop("password"))
|
||||
for key, value in update_data.items():
|
||||
setattr(user, key, value)
|
||||
await db.commit()
|
||||
@@ -98,6 +260,7 @@ async def delete_user(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role("admin")),
|
||||
):
|
||||
"""删除用户"""
|
||||
if user_id == current_user.id:
|
||||
raise HTTPException(status_code=400, detail="不能删除自己")
|
||||
|
||||
@@ -106,11 +269,24 @@ async def delete_user(
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
await AuditLogService.log(
|
||||
db, user_id=current_user.id, action="DELETE_USER",
|
||||
target_type="user", target_id=user.id,
|
||||
detail=f"删除用户: {user.username}",
|
||||
)
|
||||
await db.delete(user)
|
||||
await db.commit()
|
||||
return {"message": "用户已删除"}
|
||||
|
||||
|
||||
@router.post("/{user_id}/reset-password")
|
||||
async def reset_password(
|
||||
user_id: int,
|
||||
data: PasswordReset,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role("admin")),
|
||||
):
|
||||
"""管理员重置用户密码"""
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
user.hashed_password = hash_password(data.new_password)
|
||||
await db.commit()
|
||||
return {"message": "密码已重置"}
|
||||
Reference in new issue
Block a user