Files
opsxsql/backend/app/api/v1/endpoints/user.py
T
logan be2d6f3894 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: 精简后配置项匹配
2026-05-25 17:34:53 +08:00

293 lines
9.2 KiB
Python

from typing import Optional, List
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, PasswordReset
from app.services.audit_log_service import AuditLogService
router = APIRouter(prefix="/users", tags=["用户管理"])
@router.get("")
async def list_users(
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")),
):
"""搜索分页用户列表"""
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:
query = query.where(User.role == role)
count_q = count_q.where(User.role == role)
if is_active is not None:
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)
async def create_user(
data: UserCreate,
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="用户名已存在")
user = User(
username=data.username,
display_name=data.display_name,
hashed_password=hash_password(data.password),
email=data.email,
phone=getattr(data, 'phone', None),
role=data.role,
)
db.add(user)
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:
raise HTTPException(status_code=404, detail="用户不存在")
return user
@router.put("/{user_id}", response_model=UserResponse)
async def update_user(
user_id: int,
data: UserUpdate,
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()
await db.refresh(user)
return user
@router.delete("/{user_id}")
async def delete_user(
user_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""删除用户"""
if user_id == current_user.id:
raise HTTPException(status_code=400, detail="不能删除自己")
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="用户不存在")
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": "密码已重置"}