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:
logan committed 2026-05-25 17:34:53 +08:00
1 parent 344c519209
commit be2d6f3894
20 files changed
+2229 -1019

No files matched your search

+201 -25
View File
@@ -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": "密码已重置"}