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,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)}")
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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": "密码已重置"}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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 执行超时、批量限制、连接参数"},
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user