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

+411 -8
View File
@@ -1,4 +1,5 @@
import json
import os
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
@@ -71,12 +72,15 @@ async def upsert_ldap_config(
ldap.ldap_port = data.ldap_port
ldap.use_ldaps = data.use_ldaps
ldap.admin_dn = data.admin_dn
ldap.admin_password = encrypt_password(data.admin_password)
if data.admin_password:
ldap.admin_password = encrypt_password(data.admin_password)
ldap.search_filter = data.search_filter
ldap.base_dn = data.base_dn
ldap.user_mapping = json.dumps(data.user_mapping, ensure_ascii=False) if data.user_mapping else None
else:
# Create new
# Create new — 首次必须提供密码
if not data.admin_password:
raise HTTPException(status_code=400, detail="首次配置 LDAP 必须提供管理员密码")
ldap = LdapConfig(
ldap_host=data.ldap_host,
ldap_port=data.ldap_port,
@@ -100,34 +104,81 @@ async def test_ldap_connection(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""测试 LDAP 连接"""
"""测试 LDAP 连接 + 用户 Lookup 详细信息"""
ldap = await _get_existing(db)
if not ldap:
raise HTTPException(status_code=404, detail="LDAP 配置不存在,请先创建")
try:
import ldap as _ldap # noqa
# Real LDAP test path
protocol = "ldaps" if ldap.use_ldaps else "ldap"
uri = f"{protocol}://{ldap.ldap_host}:{ldap.ldap_port}"
conn = _ldap.initialize(uri)
conn.protocol_version = 3
if ldap.use_ldaps:
conn.set_option(_ldap.OPT_X_TLS_REQUIRE_CERT, _ldap.OPT_X_TLS_NEVER)
conn.set_option(_ldap.OPT_NETWORK_TIMEOUT, 10)
conn.simple_bind_s(ldap.admin_dn, decrypt_password(ldap.admin_password))
# Try binding with test user
# Search user
search_filter = ldap.search_filter or "(uid=%s)"
user_filter = search_filter.replace("%s", data.test_user)
results = conn.search_s(
ldap.base_dn,
_ldap.SCOPE_SUBTREE,
search_filter.replace("%s", data.test_user),
user_filter,
["uid", "cn", "mail", "telephoneNumber", "title", "department", "memberOf", "distinguishedName"],
)
if not results:
return {"success": False, "message": f"未找到用户: {data.test_user}"}
user_dn = results[0][0]
user_dn, user_attrs = results[0]
if not user_dn:
return {"success": False, "message": f"未找到用户: {data.test_user}"}
# Verify password by binding as user
conn.simple_bind_s(user_dn, data.test_password)
# Build user_info
user_mapping = json.loads(ldap.user_mapping) if ldap.user_mapping else {}
username_attr = user_mapping.get("username", "uid")
email_attr = user_mapping.get("email", "mail")
display_attr = user_mapping.get("display_name", "cn")
def _attr(key: str) -> str:
vals = user_attrs.get(key, [])
return vals[0].decode("utf-8", errors="ignore") if vals else ""
uid = _attr(username_attr) or data.test_user
display_name = _attr(display_attr)
email = _attr(email_attr)
# Lookup groups via memberOf
groups = []
group_vals = user_attrs.get("memberOf", [])
for g in group_vals:
g_str = g.decode("utf-8", errors="ignore")
# Extract CN from DN like "cn=ops,ou=groups,dc=..."
cn = g_str.split(",")[0]
if cn.lower().startswith("cn="):
cn = cn[3:]
groups.append(cn)
conn.unbind_s()
return {"success": True, "message": f"LDAP 连接成功,用户 {data.test_user} 验证通过"}
return {
"success": True,
"message": f"用户 {uid} 验证通过",
"user_info": {
"User lookup": "successful",
"User ID": uid,
"User DN": user_dn,
"Display Name": display_name or uid,
"Email": email or "(未设置)",
"Groups": groups,
"Group lookup": f"successful ({len(groups)} groups)" if groups else "no groups found",
},
}
except ImportError:
return {
@@ -136,3 +187,355 @@ async def test_ldap_connection(
}
except Exception as e:
return {"success": False, "message": f"LDAP 连接失败: {str(e)}"}
@router.post("/ou-list")
async def list_ldap_ous(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""获取 LDAP OU(组织单元)列表"""
ldap = await _get_existing(db)
if not ldap:
raise HTTPException(status_code=404, detail="LDAP 配置不存在")
try:
import ldap as _ldap
protocol = "ldaps" if ldap.use_ldaps else "ldap"
uri = f"{protocol}://{ldap.ldap_host}:{ldap.ldap_port}"
conn = _ldap.initialize(uri)
conn.protocol_version = 3
if ldap.use_ldaps:
conn.set_option(_ldap.OPT_X_TLS_REQUIRE_CERT, _ldap.OPT_X_TLS_NEVER)
conn.set_option(_ldap.OPT_NETWORK_TIMEOUT, 10)
conn.simple_bind_s(ldap.admin_dn, decrypt_password(ldap.admin_password))
results = conn.search_s(
ldap.base_dn,
_ldap.SCOPE_SUBTREE,
"(objectClass=organizationalUnit)",
["ou", "description", "distinguishedName"],
)
conn.unbind_s()
ous = []
for dn, attrs in results:
if dn:
ou_name = attrs.get("ou", [b""])[0].decode("utf-8", errors="ignore")
desc = attrs.get("description", [b""])[0].decode("utf-8", errors="ignore")
ous.append({"dn": dn, "name": ou_name or dn, "description": desc})
return {"ous": ous}
except ImportError:
raise HTTPException(status_code=500, detail="python-ldap 未安装")
except Exception as e:
raise HTTPException(status_code=500, detail=f"LDAP 查询失败: {str(e)}")
@router.post("/user-list")
async def list_ldap_users(
data: dict = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""获取 LDAP 用户列表"""
ldap = await _get_existing(db)
if not ldap:
raise HTTPException(status_code=404, detail="LDAP 配置不存在")
try:
import ldap as _ldap
protocol = "ldaps" if ldap.use_ldaps else "ldap"
uri = f"{protocol}://{ldap.ldap_host}:{ldap.ldap_port}"
conn = _ldap.initialize(uri)
conn.protocol_version = 3
if ldap.use_ldaps:
conn.set_option(_ldap.OPT_X_TLS_REQUIRE_CERT, _ldap.OPT_X_TLS_NEVER)
conn.set_option(_ldap.OPT_NETWORK_TIMEOUT, 10)
conn.simple_bind_s(ldap.admin_dn, decrypt_password(ldap.admin_password))
search_base = (data or {}).get("ou_dn", "") or ldap.base_dn
results = conn.search_s(
search_base,
_ldap.SCOPE_SUBTREE,
"(objectClass=inetOrgPerson)",
["uid", "cn", "mail", "dn"],
)
conn.unbind_s()
users = []
for dn, attrs in results:
if dn:
users.append({
"dn": dn,
"uid": attrs.get("uid", [b""])[0].decode("utf-8", errors="ignore"),
"cn": attrs.get("cn", [b""])[0].decode("utf-8", errors="ignore"),
"mail": attrs.get("mail", [b""])[0].decode("utf-8", errors="ignore"),
})
return {"users": users, "total": len(users)}
except ImportError:
raise HTTPException(status_code=500, detail="python-ldap 未安装")
except Exception as e:
raise HTTPException(status_code=500, detail=f"LDAP 查询用户失败: {str(e)}")
@router.post("/create-ou")
async def create_ldap_ou(
data: dict,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""在 LDAP 中创建组织单元 (OU)"""
ldap = await _get_existing(db)
if not ldap:
raise HTTPException(status_code=404, detail="LDAP 配置不存在")
ou_name = data.get("ou_name", "").strip()
parent_dn = data.get("parent_dn", "").strip()
description = data.get("description", "").strip()
if not ou_name:
raise HTTPException(status_code=400, detail="OU 名称不能为空")
try:
import ldap as _ldap
protocol = "ldaps" if ldap.use_ldaps else "ldap"
uri = f"{protocol}://{ldap.ldap_host}:{ldap.ldap_port}"
conn = _ldap.initialize(uri)
conn.protocol_version = 3
if ldap.use_ldaps:
conn.set_option(_ldap.OPT_X_TLS_REQUIRE_CERT, _ldap.OPT_X_TLS_NEVER)
conn.set_option(_ldap.OPT_NETWORK_TIMEOUT, 10)
conn.simple_bind_s(ldap.admin_dn, decrypt_password(ldap.admin_password))
new_ou_dn = f"ou={ou_name},{parent_dn}" if parent_dn else f"ou={ou_name},{ldap.base_dn}"
# Check if OU already exists
try:
conn.search_s(new_ou_dn, _ldap.SCOPE_BASE)
conn.unbind_s()
raise HTTPException(status_code=409, detail=f"OU 已存在: {new_ou_dn}")
except _ldap.NO_SUCH_OBJECT:
pass
attrs = {
"objectClass": [b"organizationalUnit", b"top"],
"ou": ou_name.encode("utf-8"),
}
if description:
attrs["description"] = description.encode("utf-8")
conn.add_s(new_ou_dn, list(attrs.items()))
conn.unbind_s()
return {"success": True, "dn": new_ou_dn, "message": f"OU \"{ou_name}\" 创建成功"}
except HTTPException:
raise
except ImportError:
raise HTTPException(status_code=500, detail="python-ldap 未安装")
except Exception as e:
raise HTTPException(status_code=500, detail=f"创建 OU 失败: {str(e)}")
@router.post("/create-user")
async def create_ldap_user(
data: dict,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""在 LDAP 中创建单个用户"""
ldap = await _get_existing(db)
if not ldap:
raise HTTPException(status_code=404, detail="LDAP 配置不存在")
username = data.get("username", "").strip()
display_name = data.get("display_name", "").strip()
email = data.get("email", "").strip()
password = data.get("password", "").strip()
ou_dn = data.get("ou_dn", "").strip() or ldap.base_dn
if not username or not password:
raise HTTPException(status_code=400, detail="用户名和密码不能为空")
try:
import ldap as _ldap
import hashlib, base64
protocol = "ldaps" if ldap.use_ldaps else "ldap"
uri = f"{protocol}://{ldap.ldap_host}:{ldap.ldap_port}"
conn = _ldap.initialize(uri)
conn.protocol_version = 3
if ldap.use_ldaps:
conn.set_option(_ldap.OPT_X_TLS_REQUIRE_CERT, _ldap.OPT_X_TLS_NEVER)
conn.set_option(_ldap.OPT_NETWORK_TIMEOUT, 10)
conn.simple_bind_s(ldap.admin_dn, decrypt_password(ldap.admin_password))
user_dn = f"uid={username},{ou_dn}"
try:
conn.search_s(user_dn, _ldap.SCOPE_BASE)
conn.unbind_s()
raise HTTPException(status_code=409, detail=f"用户已存在: {username}")
except _ldap.NO_SUCH_OBJECT:
pass
# SSHA password
salt = os.urandom(4)
digest = hashlib.sha1(password.encode() + salt).digest()
user_password = "{SSHA}" + base64.b64encode(digest + salt).decode()
attrs = {
"objectClass": [b"inetOrgPerson", b"organizationalPerson", b"person", b"top"],
"uid": username.encode("utf-8"),
"cn": (display_name or username).encode("utf-8"),
"sn": (display_name or username).encode("utf-8"),
"userPassword": user_password.encode("utf-8"),
}
if email:
attrs["mail"] = email.encode("utf-8")
conn.add_s(user_dn, list(attrs.items()))
conn.unbind_s()
return {"success": True, "dn": user_dn, "message": f"用户 \"{username}\" 创建成功"}
except HTTPException:
raise
except ImportError:
raise HTTPException(status_code=500, detail="python-ldap 未安装")
except Exception as e:
raise HTTPException(status_code=500, detail=f"创建用户失败: {str(e)}")
@router.post("/delete-user")
async def delete_ldap_user(
data: dict,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""从 LDAP 中删除用户"""
ldap = await _get_existing(db)
if not ldap:
raise HTTPException(status_code=404, detail="LDAP 配置不存在")
dn = data.get("dn", "").strip()
if not dn:
raise HTTPException(status_code=400, detail="用户 DN 不能为空")
try:
import ldap as _ldap
protocol = "ldaps" if ldap.use_ldaps else "ldap"
uri = f"{protocol}://{ldap.ldap_host}:{ldap.ldap_port}"
conn = _ldap.initialize(uri)
conn.protocol_version = 3
if ldap.use_ldaps:
conn.set_option(_ldap.OPT_X_TLS_REQUIRE_CERT, _ldap.OPT_X_TLS_NEVER)
conn.set_option(_ldap.OPT_NETWORK_TIMEOUT, 10)
conn.simple_bind_s(ldap.admin_dn, decrypt_password(ldap.admin_password))
conn.delete_s(dn)
conn.unbind_s()
return {"success": True, "message": f"用户已删除: {dn}"}
except ImportError:
raise HTTPException(status_code=500, detail="python-ldap 未安装")
except Exception as e:
raise HTTPException(status_code=500, detail=f"删除用户失败: {str(e)}")
@router.post("/batch-import-ldap")
async def batch_import_to_ldap(
data: dict,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""批量导入用户到 LDAP 服务器"""
ldap = await _get_existing(db)
if not ldap:
raise HTTPException(status_code=404, detail="LDAP 配置不存在")
users = data.get("users", [])
ou_dn = data.get("ou_dn", "").strip() or ldap.base_dn
if not users:
raise HTTPException(status_code=400, detail="用户列表不能为空")
try:
import ldap as _ldap
import hashlib, base64
protocol = "ldaps" if ldap.use_ldaps else "ldap"
uri = f"{protocol}://{ldap.ldap_host}:{ldap.ldap_port}"
conn = _ldap.initialize(uri)
conn.protocol_version = 3
if ldap.use_ldaps:
conn.set_option(_ldap.OPT_X_TLS_REQUIRE_CERT, _ldap.OPT_X_TLS_NEVER)
conn.set_option(_ldap.OPT_NETWORK_TIMEOUT, 10)
conn.simple_bind_s(ldap.admin_dn, decrypt_password(ldap.admin_password))
success_count = 0
failed_count = 0
failed_users = []
for u in users:
username = u.get("username", "").strip()
display_name = u.get("display_name", u.get("nickname", "")).strip()
email = u.get("email", "").strip()
password = u.get("password", username)
if not username:
failed_count += 1
failed_users.append({"username": "", "error": "用户名为空"})
continue
user_dn = f"uid={username},{ou_dn}"
try:
try:
conn.search_s(user_dn, _ldap.SCOPE_BASE)
failed_count += 1
failed_users.append({"username": username, "error": "用户已存在"})
continue
except _ldap.NO_SUCH_OBJECT:
pass
salt = os.urandom(4)
digest = hashlib.sha1(password.encode() + salt).digest()
user_password = "{SSHA}" + base64.b64encode(digest + salt).decode()
attrs = {
"objectClass": [b"inetOrgPerson", b"organizationalPerson", b"person", b"top"],
"uid": username.encode("utf-8"),
"cn": (display_name or username).encode("utf-8"),
"sn": (display_name or username).encode("utf-8"),
"userPassword": user_password.encode("utf-8"),
}
if email:
attrs["mail"] = email.encode("utf-8")
conn.add_s(user_dn, list(attrs.items()))
success_count += 1
except Exception as e:
failed_count += 1
failed_users.append({"username": username, "error": str(e)})
conn.unbind_s()
return {
"success": True,
"success_count": success_count,
"failed_count": failed_count,
"failed_users": failed_users,
"message": f"导入完成: 成功 {success_count}, 失败 {failed_count}",
}
except ImportError:
raise HTTPException(status_code=500, detail="python-ldap 未安装")
except Exception as e:
raise HTTPException(status_code=500, detail=f"批量导入失败: {str(e)}")
+55 -94
View File
@@ -1237,108 +1237,69 @@ async def execute_ticket(
await db.commit()
await db.refresh(ticket)
# 8. 使用多数据库执行引擎
# 8. 后台异步执行(避免大表操作阻塞 HTTP 连接)
import asyncio
from app.services.ticket_executor import TicketExecutor
executor = TicketExecutor()
try:
# 解密密码
instance.password = decrypt_password(instance.password)
async def _do_execute():
"""后台执行工单,独立 session 更新结果。"""
from app.core.database import async_session
async with async_session() as bg_db:
try:
# 重新加载对象
t = await bg_db.get(type(ticket), ticket.id)
inst = await bg_db.get(type(instance), instance.id)
if not t or not inst:
return
inst.password = decrypt_password(inst.password)
executor = TicketExecutor()
report = await executor.execute(t, inst)
# 执行工单
report = await executor.execute(ticket, instance)
# 更新状态
new_status = TicketStatus.SUCCESS.value if report.status == "success" else TicketStatus.FAILED.value
if _validate_status_transition(TicketStatus.EXECUTING.value, new_status):
t.status = new_status
else:
t.status = TicketStatus.FAILED.value
t.execute_result = report.to_json()
t.execute_time = datetime.now(timezone.utc)
# 8. 更新工单状态和结果
if report.status == "success":
new_status = TicketStatus.SUCCESS.value
else:
new_status = TicketStatus.FAILED.value
await _update_sql_items_from_report(bg_db, t.id, report)
# 验证状态转换
if _validate_status_transition(TicketStatus.EXECUTING.value, new_status):
ticket.status = new_status
else:
ticket.status = TicketStatus.FAILED.value
# 审计日志
log_detail = (
f"执行结果: {report.status}, 影响行数: {report.affected_rows}, "
f"耗时: {report.duration_ms}ms, 验证通过: {report.validation_passed}"
)
if report.errors:
log_detail += f", 错误: {'; '.join(report.errors[:3])}"
await AuditLogService.log(
bg_db, user_id=current_user.id,
action="EXECUTE_TICKET", target_type="ticket",
target_id=t.id, detail=log_detail,
)
# 通知
try:
await _send_execution_notification(bg_db, t, report)
except Exception as ne:
logger.error("执行通知发送失败: %s", ne)
await bg_db.commit()
except Exception as e:
logger.exception("后台执行工单 %d 异常", ticket_id)
try:
t = await bg_db.get(type(ticket), ticket.id)
if t:
t.status = TicketStatus.FAILED.value
t.execute_result = json.dumps({"status": "failed", "errors": [f"{type(e).__name__}: {e}"]}, ensure_ascii=False)
t.execute_time = datetime.now(timezone.utc)
await bg_db.commit()
except Exception:
logger.exception("更新失败状态异常")
ticket.execute_result = report.to_json()
ticket.execute_time = datetime.now(timezone.utc)
# 启动后台 task,立即返回 executing 状态
asyncio.get_event_loop().create_task(_do_execute())
# 8.1 更新 SQL 逐条执行记录
await _update_sql_items_from_report(db, ticket.id, report)
# 9. 记录执行日志
log_detail = (
f"执行结果: {report.status}, 影响行数: {report.affected_rows}, "
f"耗时: {report.duration_ms}ms, 验证通过: {report.validation_passed}"
)
if report.errors:
log_detail += f", 错误: {'; '.join(report.errors[:3])}"
if confirm and confirm.comment:
log_detail += f", 执行备注: {confirm.comment}"
await AuditLogService.log(
db,
user_id=current_user.id,
action="EXECUTE_TICKET",
target_type="ticket",
target_id=ticket.id,
detail=log_detail,
)
# 10. 发送通知
try:
await _send_execution_notification(db, ticket, report)
except Exception as notify_err:
logger.error("执行通知发送失败: %s", notify_err)
# 通知失败不应影响执行结果
except Exception as e:
logger.exception("工单 %d 执行异常", ticket_id)
ticket.status = TicketStatus.FAILED.value
ticket.execute_result = json.dumps({
"status": "failed",
"errors": [f"{type(e).__name__}: {e}"],
}, ensure_ascii=False)
ticket.execute_time = datetime.now(timezone.utc)
# 标记所有 pending SQL items 为 failed
await _mark_sql_items_failed(db, ticket.id, str(e))
await AuditLogService.log(
db,
user_id=current_user.id,
action="EXECUTE_TICKET",
target_type="ticket",
target_id=ticket.id,
detail=f"执行异常: {type(e).__name__}: {e}",
)
# 11. 提交事务并返回
await db.commit()
await db.refresh(ticket)
# 构建响应
ticket_data = await _enrich_ticket(ticket, db)
# 尝试解析执行报告
try:
exec_report = json.loads(ticket.execute_result) if ticket.execute_result else {}
if isinstance(exec_report, dict):
from app.schemas.execution import ExecutionReportResponse
ticket_data["execution_report"] = ExecutionReportResponse(
status=exec_report.get("status", "unknown"),
affected_rows=exec_report.get("affected_rows", 0),
duration_ms=exec_report.get("duration_ms", 0),
validation_passed=exec_report.get("validation_passed", False),
warnings=exec_report.get("warnings", []),
errors=exec_report.get("errors", []),
raw_output=exec_report.get("raw_output"),
before_snapshot_summary=exec_report.get("before_snapshot"),
after_snapshot_summary=exec_report.get("after_snapshot"),
)
except Exception:
ticket_data["execution_report"] = None
return ticket_data
+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": "密码已重置"}