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": "密码已重置"}
+1 -1
View File
@@ -8,7 +8,7 @@ class LdapConfigCreate(BaseModel):
ldap_port: int = Field(default=389, ge=1, le=65535)
use_ldaps: bool = False
admin_dn: str = Field(..., min_length=1, max_length=500)
admin_password: str = Field(..., min_length=1, max_length=500)
admin_password: Optional[str] = Field(default=None, max_length=500)
search_filter: Optional[str] = Field(default=None, max_length=500)
base_dn: str = Field(..., min_length=1, max_length=500)
user_mapping: Optional[Dict[str, Any]] = None
+4
View File
@@ -37,3 +37,7 @@ class UserResponse(BaseModel):
class PasswordChange(BaseModel):
old_password: str
new_password: str = Field(..., min_length=6)
class PasswordReset(BaseModel):
new_password: str = Field(..., min_length=6)
+15 -148
View File
@@ -2,25 +2,15 @@
SEED_SETTINGS = [
# ── 查询控制 ──
{
"key": "query_require_approval",
"value": "false",
"value_type": "boolean",
"group": "query",
"label": "查询需要审批",
"description": "开启后,所有查询操作需要经过管理员或 DBA 审批才能执行",
"default_value": "false",
"sort_order": 1,
},
{
"key": "query_max_limit",
"value": "10000",
"value_type": "integer",
"group": "query",
"label": "查询 LIMIT 上限",
"description": "SELECT 查询返回的最大行数,超出的行会被截断。范围: 100~100000",
"description": "SELECT 查询返回的最大行数。范围: 100~100000",
"default_value": "10000",
"sort_order": 2,
"sort_order": 1,
},
{
"key": "query_timeout",
@@ -30,17 +20,7 @@ SEED_SETTINGS = [
"label": "查询超时(秒)",
"description": "单次查询最大执行时间,超时自动终止。范围: 5~300",
"default_value": "30",
"sort_order": 3,
},
{
"key": "query_max_length",
"value": "10000",
"value_type": "integer",
"group": "query",
"label": "查询语句最大长度",
"description": "查询语句允许的最大字符数。范围: 500~50000",
"default_value": "10000",
"sort_order": 4,
"sort_order": 2,
},
{
"key": "query_auto_limit",
@@ -48,29 +28,9 @@ SEED_SETTINGS = [
"value_type": "boolean",
"group": "query",
"label": "自动添加 LIMIT",
"description": "当 SELECT 语句没有 LIMIT 时自动追加,防止返回海量数据",
"description": "查询页 SELECT 无 LIMIT 时自动追加,防止返回海量数据(仅查询页,不影响工单)",
"default_value": "true",
"sort_order": 5,
},
{
"key": "query_enable_masking",
"value": "true",
"value_type": "boolean",
"group": "query",
"label": "启用数据脱敏",
"description": "自动对手机号、身份证、邮箱等敏感字段进行脱敏处理",
"default_value": "true",
"sort_order": 6,
},
{
"key": "query_save_history",
"value": "true",
"value_type": "boolean",
"group": "query",
"label": "保存查询历史",
"description": "自动记录每次查询内容,方便后续复用和审计",
"default_value": "true",
"sort_order": 7,
"sort_order": 3,
},
# ── 安全控制 ──
@@ -94,69 +54,17 @@ SEED_SETTINGS = [
"default_value": "6",
"sort_order": 11,
},
{
"key": "security_enable_audit_log",
"value": "true",
"value_type": "boolean",
"group": "security",
"label": "启用审计日志",
"description": "记录所有关键操作(查询、工单、权限变更等)",
"default_value": "true",
"sort_order": 12,
},
# ── 工单控制 ──
{
"key": "ticket_default_workflow",
"value": "standard",
"value_type": "string",
"group": "ticket",
"label": "默认审批流程",
"description": "新建工单使用的默认审批流程: standard(标准三级) / simple(简单一级) / urgent(紧急快速)",
"default_value": "standard",
"sort_order": 20,
},
{
"key": "ticket_allow_rollback",
"value": "true",
"value_type": "boolean",
"group": "ticket",
"label": "允许工单回滚",
"description": "执行失败的工单是否允许重新执行(回到 approved 状态)",
"default_value": "true",
"sort_order": 21,
},
# ── 日志清理 ──
{
"key": "cleanup_enabled",
"value": "true",
"value_type": "boolean",
"group": "cleanup",
"label": "启用自动清理",
"description": "开启后每天凌晨自动清理过期记录",
"default_value": "true",
"sort_order": 30,
},
{
"key": "cleanup_run_hour",
"value": "3",
"value_type": "integer",
"group": "cleanup",
"label": "清理执行时间(时)",
"description": "每天几点执行自动清理(0-23),默认凌晨 3 点",
"default_value": "3",
"sort_order": 31,
},
{
"key": "cleanup_ticket_days",
"value": "90",
"value_type": "integer",
"group": "cleanup",
"label": "数据变更工单保留天数",
"description": "已完成/已拒绝的工单超过保留天数将被自动清理(0 表示不清理)",
"label": "工单保留天数",
"description": "已完成/已拒绝的工单超过保留天数将被自动清理",
"default_value": "90",
"sort_order": 32,
"sort_order": 30,
},
{
"key": "cleanup_audit_log_days",
@@ -164,9 +72,9 @@ SEED_SETTINGS = [
"value_type": "integer",
"group": "cleanup",
"label": "审计日志保留天数",
"description": "审计日志超过保留天数将被自动清理(0 表示不清理)",
"description": "审计日志超过保留天数将被自动清理",
"default_value": "180",
"sort_order": 33,
"sort_order": 31,
},
{
"key": "cleanup_query_history_days",
@@ -174,19 +82,9 @@ SEED_SETTINGS = [
"value_type": "integer",
"group": "cleanup",
"label": "查询历史保留天数",
"description": "查询历史超过保留天数将被自动清理(0 表示不清理)",
"description": "查询历史超过保留天数将被自动清理",
"default_value": "30",
"sort_order": 34,
},
{
"key": "cleanup_scheduled_task_log_days",
"value": "30",
"value_type": "integer",
"group": "cleanup",
"label": "定时任务日志保留天数",
"description": "定时任务执行日志超过保留天数将被自动清理(0 表示不清理)",
"default_value": "30",
"sort_order": 35,
"sort_order": 32,
},
# ── 执行器 ──
@@ -196,47 +94,16 @@ SEED_SETTINGS = [
"value_type": "integer",
"group": "executor",
"label": "SQL 执行超时(秒)",
"description": "MySQL/PostgreSQL 工单执行单条 SQL 最大时间。范围: 10~600",
"description": "MySQL/PostgreSQL 工单执行单条 SQL 最大时间",
"default_value": "60",
"sort_order": 40,
},
{
"key": "executor_redis_timeout",
"value": "30",
"value_type": "integer",
"group": "executor",
"label": "Redis 执行超时(秒)",
"description": "Redis 工单执行单条命令最大时间。范围: 5~120",
"default_value": "30",
"sort_order": 41,
},
{
"key": "executor_mongo_timeout",
"value": "60",
"value_type": "integer",
"group": "executor",
"label": "MongoDB 执行超时(秒)",
"description": "MongoDB 工单执行单条命令最大时间。范围: 10~600",
"default_value": "60",
"sort_order": 42,
},
{
"key": "query_result_max_memory_mb",
"value": "50",
"value_type": "integer",
"group": "query",
"label": "查询结果内存上限(MB)",
"description": "单次查询结果集最大内存占用,超限自动截断。范围: 10~500",
"default_value": "50",
"sort_order": 8,
},
]
# 分组元信息
SETTING_GROUPS = {
"query": {"label": "查询控制", "icon": "🔍", "description": "控制查询行为、LIMIT、超时和脱敏策略"},
"security": {"label": "安全设置", "icon": "🔒", "description": "登录安全、密码策略、审计日志"},
"ticket": {"label": "工单设置", "icon": "📋", "description": "审批流程、回滚策略"},
"cleanup": {"label": "日志清理", "icon": "🧹", "description": "数据变更工单、审计日志、查询历史的自动清理策略"},
"security": {"label": "安全设置", "icon": "🔒", "description": "登录安全、密码策略"},
"cleanup": {"label": "日志清理", "icon": "🧹", "description": "工单、审计日志、查询历史的自动清理策略"},
"executor": {"label": "执行器", "icon": "⚙️", "description": "SQL/Redis 执行超时、批量限制、连接参数"},
}
+47 -46
View File
@@ -112,7 +112,8 @@ async def _run_async_execution(ticket_id: int) -> None:
async def _execute_scheduled_ticket(ticket_id: int) -> None:
"""执行定时工单 — 创建独立数据库会话执行。"""
"""执行定时工单 — 状态切换后 create_task 后台执行,不阻塞 APScheduler。"""
import asyncio
from app.core.database import async_session
from app.models.ticket import Ticket as TicketModel
from app.models.instance import Instance
@@ -121,56 +122,56 @@ async def _execute_scheduled_ticket(ticket_id: int) -> None:
from sqlalchemy import select
import json
logger.info("开始执行定时工单 %d", ticket_id)
logger.info("定时工单 %d 触发执行", ticket_id)
# 先切状态为 executing
async with async_session() as db:
try:
result = await db.execute(select(TicketModel).where(TicketModel.id == ticket_id))
ticket = result.scalar_one_or_none()
if not ticket:
logger.error("定时工单 %d 不存在", ticket_id)
return
result = await db.execute(select(TicketModel).where(TicketModel.id == ticket_id))
ticket = result.scalar_one_or_none()
if not ticket:
logger.error("定时工单 %d 不存在", ticket_id)
return
if ticket.status != "scheduled":
logger.warning("工单 %d 状态已变更 (%s),跳过", ticket_id, ticket.status)
return
ticket.status = "executing"
await db.commit()
if ticket.status != "scheduled":
logger.warning("工单 %d 状态已变更 (%s),跳过执行", ticket_id, ticket.status)
return
ticket.status = "executing"
await db.commit()
await db.refresh(ticket)
inst_result = await db.execute(select(Instance).where(Instance.id == ticket.instance_id))
instance = inst_result.scalar_one_or_none()
if not instance:
ticket.status = "failed"
ticket.execute_result = json.dumps({"status": "failed", "errors": ["数据源实例不存在"]}, ensure_ascii=False)
ticket.execute_time = datetime.now(timezone.utc)
await db.commit()
return
instance.password = decrypt_password(instance.password)
executor = TicketExecutor()
report = await executor.execute(ticket, instance)
ticket.status = "success" if report.status == "success" else "failed"
ticket.execute_result = report.to_json()
ticket.execute_time = datetime.now(timezone.utc)
await db.commit()
logger.info("定时工单 %d 执行完成: %s", ticket_id, report.status)
except Exception as e:
logger.exception("定时工单 %d 执行异常", ticket_id)
# 后台 task 执行,APScheduler 立即释放
async def _do_exec():
async with async_session() as db:
try:
result = await db.execute(select(TicketModel).where(TicketModel.id == ticket_id))
ticket = result.scalar_one_or_none()
if ticket:
ticket.status = "failed"
ticket.execute_result = json.dumps({"status": "failed", "errors": [f"{type(e).__name__}: {e}"]}, ensure_ascii=False)
ticket.execute_time = datetime.now(timezone.utc)
t = (await db.execute(select(TicketModel).where(TicketModel.id == ticket_id))).scalar_one_or_none()
if not t or t.status != "executing":
return
inst = (await db.execute(select(Instance).where(Instance.id == t.instance_id))).scalar_one_or_none()
if not inst:
t.status = "failed"
t.execute_result = json.dumps({"status": "failed", "errors": ["数据源实例不存在"]}, ensure_ascii=False)
t.execute_time = datetime.now(timezone.utc)
await db.commit()
except Exception:
logger.exception("更新定时工单 %d 失败状态也失败", ticket_id)
return
inst.password = decrypt_password(inst.password)
executor = TicketExecutor()
report = await executor.execute(t, inst)
t.status = "success" if report.status == "success" else "failed"
t.execute_result = report.to_json()
t.execute_time = datetime.now(timezone.utc)
await db.commit()
logger.info("定时工单 %d 执行完成: %s", ticket_id, report.status)
except Exception as e:
logger.exception("定时工单 %d 执行异常", ticket_id)
try:
t = (await db.execute(select(TicketModel).where(TicketModel.id == ticket_id))).scalar_one_or_none()
if t:
t.status = "failed"
t.execute_result = json.dumps({"status": "failed", "errors": [f"{type(e).__name__}: {e}"]}, ensure_ascii=False)
t.execute_time = datetime.now(timezone.utc)
await db.commit()
except Exception:
logger.exception("更新定时工单 %d 失败状态也失败", ticket_id)
asyncio.get_event_loop().create_task(_do_exec())
def cancel_ticket_execution(ticket_id: int) -> bool: