1. 清理测试文件中未使用的导入与冗余代码 2. 修复审计日志与批量操作的空值约束,统一填充"global"作为默认渠道 3. 调整批量消息撤回的响应语义,对齐其他端点的部分成功契约 4. 修复访问规则批量克隆的唯一约束问题,新增后缀自动处理逻辑 5. 替换anyio为asyncio并行调用,修正时间UTC导入路径 6. 优化前端外部系统概览页的刷新状态提示与缓存逻辑 7. 修复测试用例中的断言与请求方式问题,适配httpx删除请求特性 8. 重构后端路由的依赖注入,移除冗余的数据库会话依赖 9. 调整测试用例的权限校验逻辑,修正强制登出的权限判断 10. 修复语义分块测试的numpy依赖问题,清理冗余导入
274 lines
10 KiB
Python
274 lines
10 KiB
Python
"""AuditLog 子域 Router。
|
||
|
||
外部系统限界上下文的审计日志查询 API,覆盖审计日志列表 / 详情 / 导出。
|
||
所有端点通过 ``create_use_cases_from_db`` 装配 use_cases,经 ``audit_log_service``
|
||
端口调用用例。
|
||
|
||
路径顺序约束:静态路径 ``/export`` 必须在 ``/{log_id}`` 前声明,避免被路径参数捕获。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from datetime import UTC, datetime
|
||
from typing import Any
|
||
|
||
from fastapi import APIRouter, Depends, Path, Query
|
||
from fastapi.exceptions import RequestValidationError
|
||
from fastapi.responses import StreamingResponse
|
||
from yuxi.external_systems.infrastructure.container import UseCases
|
||
from yuxi.external_systems.use_cases.dto.audit_log import (
|
||
ExportAuditLogsInput,
|
||
GetAuditLogDetailInput,
|
||
GetAuditLogStatsInput,
|
||
ListActionTypesInput,
|
||
ListAuditLogsInput,
|
||
ListConfigChangesInput,
|
||
ListEventTypesInput,
|
||
ListOperatorsInput,
|
||
)
|
||
from yuxi.storage.postgres.models_business import User
|
||
|
||
from server.routers.external_systems import get_use_cases
|
||
from server.utils.auth_middleware import get_admin_user, get_required_user
|
||
|
||
audit_log_router = APIRouter(
|
||
prefix="/audit-logs",
|
||
tags=["external-systems-audit-log"],
|
||
)
|
||
|
||
|
||
def _validate_time_range(start: datetime | None, end: datetime | None) -> None:
|
||
"""校验时间范围语义:start 不晚于 end。
|
||
|
||
若校验失败,抛出 ``RequestValidationError``,由全局
|
||
``request_validation_error_handler`` 统一映射为 422 + 项目统一错误格式。
|
||
"""
|
||
if start is not None and end is not None and start > end:
|
||
raise RequestValidationError(
|
||
errors=[
|
||
{
|
||
"loc": ("query", "start_time"),
|
||
"msg": "start_time 不能晚于 end_time",
|
||
"type": "value_error",
|
||
"input": {"start_time": start, "end_time": end},
|
||
}
|
||
]
|
||
)
|
||
|
||
|
||
# =============================================================================
|
||
# === 静态路径端点(必须在 /{log_id} 之前声明) ===
|
||
# =============================================================================
|
||
|
||
|
||
@audit_log_router.get("/export")
|
||
async def export_audit_logs(
|
||
system_id: int | None = Query(None, ge=1),
|
||
env_key: str | None = Query(None, max_length=32),
|
||
event_type: str | None = Query(None, max_length=64),
|
||
action_type: str | None = Query(None, max_length=64),
|
||
resource: str | None = Query(None, max_length=256),
|
||
user: str | None = Query(None, max_length=64),
|
||
start_time: datetime | None = Query(None),
|
||
end_time: datetime | None = Query(None),
|
||
limit: int = Query(10000, ge=1, le=10000, description="导出记录上限,最大 10000"),
|
||
offset: int = Query(0, ge=0),
|
||
use_cases: UseCases = Depends(get_use_cases),
|
||
current_user: User = Depends(get_admin_user),
|
||
) -> StreamingResponse:
|
||
"""导出审计日志为 JSON 字节流。
|
||
|
||
审计日志含跨系统敏感操作记录,导出需管理员权限。
|
||
导出受 ``limit`` 限制(默认上限 10000 条),超出部分通过响应头
|
||
``X-Truncated`` 和 ``X-Total-Count`` 标识。
|
||
|
||
采用增量流式输出,避免一次性构建大 payload 导致内存峰值。
|
||
"""
|
||
_validate_time_range(start_time, end_time)
|
||
input_dto = ExportAuditLogsInput(
|
||
system_id=system_id,
|
||
env_key=env_key,
|
||
event_type=event_type,
|
||
action_type=action_type,
|
||
resource=resource,
|
||
user=user,
|
||
start_time=start_time,
|
||
end_time=end_time,
|
||
limit=limit,
|
||
offset=offset,
|
||
)
|
||
output = await use_cases.audit_log_service.export_audit_logs(input_dto)
|
||
|
||
timestamp_str = datetime.now(UTC).strftime("%Y%m%d%H%M%S")
|
||
exported_at = datetime.now(UTC).isoformat()
|
||
|
||
async def _stream():
|
||
# JSON 对象包裹 items 数组 + 元数据,增量 yield 每条记录
|
||
yield b'{"exported_at":'
|
||
yield json.dumps(exported_at).encode("utf-8")
|
||
yield b',"total":'
|
||
yield str(output.total).encode("utf-8")
|
||
yield b',"truncated":'
|
||
yield str(output.truncated).lower().encode("utf-8")
|
||
yield b',"count":'
|
||
yield str(len(output.items)).encode("utf-8")
|
||
yield b',"items":['
|
||
for i, item in enumerate(output.items):
|
||
if i > 0:
|
||
yield b","
|
||
yield json.dumps(item.model_dump(), ensure_ascii=False, default=str).encode(
|
||
"utf-8"
|
||
)
|
||
yield b"]}"
|
||
|
||
headers = {
|
||
"Content-Disposition": f"attachment; filename=audit_logs_{timestamp_str}.json",
|
||
"X-Total-Count": str(output.total),
|
||
"X-Truncated": str(output.truncated).lower(),
|
||
}
|
||
|
||
return StreamingResponse(
|
||
_stream(),
|
||
media_type="application/octet-stream",
|
||
headers=headers,
|
||
)
|
||
|
||
|
||
@audit_log_router.get("", response_model=dict)
|
||
async def list_audit_logs(
|
||
limit: int = Query(100, ge=1, le=500),
|
||
offset: int = Query(0, ge=0),
|
||
system_id: int | None = Query(None, ge=1),
|
||
env_key: str | None = Query(None, max_length=32),
|
||
event_type: str | None = Query(None, max_length=64),
|
||
action_type: str | None = Query(None, max_length=64),
|
||
resource: str | None = Query(None, max_length=256),
|
||
user: str | None = Query(None, max_length=64),
|
||
start_time: datetime | None = Query(None),
|
||
end_time: datetime | None = Query(None),
|
||
use_cases: UseCases = Depends(get_use_cases),
|
||
current_user: User = Depends(get_required_user),
|
||
) -> dict[str, Any]:
|
||
"""分页列出审计日志。"""
|
||
_validate_time_range(start_time, end_time)
|
||
input_dto = ListAuditLogsInput(
|
||
limit=limit,
|
||
offset=offset,
|
||
system_id=system_id,
|
||
env_key=env_key,
|
||
event_type=event_type,
|
||
action_type=action_type,
|
||
resource=resource,
|
||
user=user,
|
||
start_time=start_time,
|
||
end_time=end_time,
|
||
)
|
||
output = await use_cases.audit_log_service.list_audit_logs(input_dto)
|
||
return {"success": True, "data": output.model_dump()}
|
||
|
||
|
||
@audit_log_router.get("/stats", response_model=dict)
|
||
async def get_audit_log_stats(
|
||
system_id: int | None = Query(None, ge=1),
|
||
start_time: datetime | None = Query(None),
|
||
end_time: datetime | None = Query(None),
|
||
use_cases: UseCases = Depends(get_use_cases),
|
||
current_user: User = Depends(get_required_user),
|
||
) -> dict[str, Any]:
|
||
"""查询审计日志统计聚合(按 action_type 分组)。"""
|
||
_validate_time_range(start_time, end_time)
|
||
input_dto = GetAuditLogStatsInput(
|
||
system_id=system_id,
|
||
start_time=start_time,
|
||
end_time=end_time,
|
||
)
|
||
output = await use_cases.audit_log_service.get_audit_log_stats(input_dto)
|
||
return {"success": True, "data": output.model_dump()}
|
||
|
||
|
||
@audit_log_router.get("/config-changes", response_model=dict)
|
||
async def list_config_changes(
|
||
limit: int = Query(100, ge=1, le=500),
|
||
offset: int = Query(0, ge=0),
|
||
system_id: int | None = Query(None, ge=1),
|
||
env_key: str | None = Query(None, max_length=32),
|
||
event_type: str | None = Query(None, max_length=64),
|
||
action_type: str | None = Query(None, max_length=64),
|
||
resource: str | None = Query(None, max_length=256),
|
||
user: str | None = Query(None, max_length=64),
|
||
start_time: datetime | None = Query(None),
|
||
end_time: datetime | None = Query(None),
|
||
use_cases: UseCases = Depends(get_use_cases),
|
||
current_user: User = Depends(get_required_user),
|
||
) -> dict[str, Any]:
|
||
"""查询配置变更历史(含 snapshot_before / snapshot_after 的审计日志)。
|
||
|
||
``action_type`` 默认不限制,返回所有含快照的记录(CREATE/UPDATE/DELETE 等)。
|
||
"""
|
||
_validate_time_range(start_time, end_time)
|
||
input_dto = ListConfigChangesInput(
|
||
limit=limit,
|
||
offset=offset,
|
||
system_id=system_id,
|
||
env_key=env_key,
|
||
event_type=event_type,
|
||
action_type=action_type,
|
||
resource=resource,
|
||
user=user,
|
||
start_time=start_time,
|
||
end_time=end_time,
|
||
)
|
||
output = await use_cases.audit_log_service.list_config_changes(input_dto)
|
||
return {"success": True, "data": output.model_dump()}
|
||
|
||
|
||
@audit_log_router.get("/action-types", response_model=dict)
|
||
async def list_action_types(
|
||
use_cases: UseCases = Depends(get_use_cases),
|
||
current_user: User = Depends(get_required_user),
|
||
) -> dict[str, Any]:
|
||
"""查询审计日志中已出现的操作类型列表。"""
|
||
input_dto = ListActionTypesInput()
|
||
output = await use_cases.audit_log_service.list_action_types(input_dto)
|
||
return {"success": True, "data": output.model_dump()}
|
||
|
||
|
||
@audit_log_router.get("/event-types", response_model=dict)
|
||
async def list_event_types(
|
||
use_cases: UseCases = Depends(get_use_cases),
|
||
current_user: User = Depends(get_required_user),
|
||
) -> dict[str, Any]:
|
||
"""查询审计日志中已出现的事件类型列表。"""
|
||
input_dto = ListEventTypesInput()
|
||
output = await use_cases.audit_log_service.list_event_types(input_dto)
|
||
return {"success": True, "data": output.model_dump()}
|
||
|
||
|
||
@audit_log_router.get("/operators", response_model=dict)
|
||
async def list_operators(
|
||
use_cases: UseCases = Depends(get_use_cases),
|
||
current_user: User = Depends(get_required_user),
|
||
) -> dict[str, Any]:
|
||
"""查询审计日志中已出现的操作人列表。"""
|
||
input_dto = ListOperatorsInput()
|
||
output = await use_cases.audit_log_service.list_operators(input_dto)
|
||
return {"success": True, "data": output.model_dump()}
|
||
|
||
|
||
# =============================================================================
|
||
# === 动态路径端点 /{log_id} ===
|
||
# =============================================================================
|
||
|
||
|
||
@audit_log_router.get("/{log_id}", response_model=dict)
|
||
async def get_audit_log_detail(
|
||
log_id: int = Path(ge=1),
|
||
use_cases: UseCases = Depends(get_use_cases),
|
||
current_user: User = Depends(get_required_user),
|
||
) -> dict[str, Any]:
|
||
"""获取审计日志详情。"""
|
||
input_dto = GetAuditLogDetailInput(id=log_id)
|
||
output = await use_cases.audit_log_service.get_audit_log_detail(input_dto)
|
||
return {"success": True, "data": output.model_dump()}
|