1. 移除多个导出接口的显式response_model声明 2. 调整access_rule和test_case的创建接口位置,修复静态路径冲突 3. 优化适配器配置校验的异常处理逻辑 4. 重构集成路由的查询逻辑,统一使用get_integration_or_raise 5. 新增channels路由组下的capability、reports、dashboard、webhook、wizard、doctor、directory、session共8个子路由模块 6. 注册channels_router到全局路由列表
397 lines
18 KiB
Python
397 lines
18 KiB
Python
"""审计日志域 Router(AUD-QUERY-01 / AUD-05 / AUD-06 / AUD-07 / AUD-01 / AUD-02 / AUD-03 + P0缺口)。
|
||
|
||
本 router 实现审计日志域的全部 HTTP 端点,覆盖审计日志查询、统计、操作类型
|
||
枚举与异步导出、导出任务管理共 11 个操作。所有端点统一采用模板 A(控制面端口路由),通过
|
||
``get_channel_use_cases`` 装配 ``ChannelUseCases``,经 ``audit_query`` 端口
|
||
调用类型化方法,由 ``ChannelControlService`` 内部走控制面管道
|
||
(auth → permission → rate_limit → dispatch → audit)。
|
||
|
||
鉴权策略:全部端点使用 ``get_admin_user`` 依赖,要求管理员或超级管理员角色。
|
||
角色校验由依赖函数完成,router 内不做角色判断(规范 §4)。
|
||
|
||
模板选型:模板 A(控制面端口路由)——全部端点需要审计留痕(记录"谁查了 /
|
||
导了审计日志"),数据面端口不经控制面管道无法在 audit 阶段留痕。
|
||
|
||
路径设计:静态路径(``/audit/logs`` / ``/audit/logs/stats`` /
|
||
``/audit/operations`` / ``/audit/export``)先于动态路径
|
||
(``/audit/logs/{log_id}`` / ``/audit/export/{task_id}``)声明,避免被动态
|
||
路径捕获(规范 §6.5)。子 router 不自行设置 prefix,根前缀 ``/channels``
|
||
由 ``channels_router`` 聚合 router 统一追加。完整 HTTP 路径为
|
||
``/channels/audit/*``(不含 ``{channel_type}`` 段,审计日志为跨渠道全局能力)。
|
||
|
||
特殊处理:AUD-03(``GET /audit/export/{task_id}/download``)返回
|
||
``StreamingResponse``(二进制文件流),不遵循 ``{"success": True, "data": ...}``
|
||
标准响应结构。控制面管道返回 ``AuditExportDownloadResult``(含 file_path),
|
||
Router 层根据文件路径返回文件流。审计留痕在控制面管道的 audit 阶段完成。
|
||
Router 层在打开文件前校验路径合法性:路径必须以预配置导出目录为前缀,文件名
|
||
仅含 task_id 与 ``.json`` 扩展名(防路径遍历,§1.5 决策 9)。
|
||
|
||
tags 命名:``audit_router = APIRouter(tags=["channels-audit"])``,与现有
|
||
``channels-config`` / ``channels-account`` 等子 router 命名规范一致。
|
||
|
||
端点清单(对应《审计日志域设计方案》§2.1 + P0缺口补全):
|
||
- GET /audit/logs AUD-QUERY-01 query_audit_logs
|
||
- GET /audit/logs/stats AUD-05 get_audit_log_stats
|
||
- GET /audit/operations AUD-06 list_audit_operations
|
||
- POST /audit/export AUD-01 create_audit_export_task
|
||
- GET /audit/export AUD-EXPORT-LIST-01 list_export_tasks
|
||
- GET /audit/logs/{log_id} AUD-07 get_audit_log
|
||
- GET /audit/export/{task_id} AUD-02 get_audit_export_task
|
||
- DELETE /audit/export/{task_id} AUD-EXPORT-CANCEL-01 cancel_export_task
|
||
- GET /audit/retention-policy AUD-RETENTION-GET get_retention_policy
|
||
- PUT /audit/retention-policy AUD-RETENTION-PUT update_retention_policy
|
||
- GET /audit/export/{task_id}/download AUD-03 download_audit_export_report
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import tempfile
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
from fastapi import APIRouter, Depends, Query, Request
|
||
from fastapi.responses import StreamingResponse
|
||
from pydantic import BaseModel, ConfigDict, Field
|
||
|
||
from yuxi.channels.contract.dtos.audit import (
|
||
AuditExportStatus,
|
||
AuditExportTaskListQuery,
|
||
AuditOperationType,
|
||
AuditQuery,
|
||
RetentionPolicyUpdateCmd,
|
||
)
|
||
from yuxi.channels.contract.dtos.channel import ChannelType
|
||
from yuxi.channels.contract.errors import InternalError, RuleViolationError
|
||
from yuxi.storage.postgres.models_business import User
|
||
|
||
from server.routers.channels import (
|
||
build_operator,
|
||
get_channel_use_cases,
|
||
parse_datetime,
|
||
raiseOnControlFailure,
|
||
serialize_control_data,
|
||
)
|
||
from server.utils.auth_middleware import get_admin_user, get_superadmin_user
|
||
|
||
audit_router = APIRouter(tags=["channels-audit"])
|
||
|
||
|
||
class CreateAuditExportRequest(BaseModel):
|
||
"""提交审计日志导出任务请求体(AUD-01)。
|
||
|
||
``format`` 当前仅支持 ``"json"``,由 ``AuditExportTaskCreateCmd.__post_init__``
|
||
在构造时校验,非法值抛 ``ValidationError``。
|
||
"""
|
||
|
||
model_config = ConfigDict(frozen=True)
|
||
|
||
operation_type: AuditOperationType | None = Field(default=None, description="操作类型过滤")
|
||
operator: str | None = Field(default=None, description="操作人过滤")
|
||
target_channel: ChannelType | None = Field(default=None, description="目标渠道过滤")
|
||
target_account: str | None = Field(default=None, description="目标账户过滤")
|
||
start_time: str | None = Field(default=None, description="起始时间(ISO 8601)")
|
||
end_time: str | None = Field(default=None, description="结束时间(ISO 8601)")
|
||
format: str = Field(default="json", description="导出格式(当前仅支持 json)")
|
||
|
||
|
||
class UpdateRetentionPolicyRequest(BaseModel):
|
||
"""更新审计保留策略请求体(AUD-RETENTION-PUT)。
|
||
|
||
字段约束与 ``RetentionPolicyUpdateCmd`` 一致,dispatch 阶段二次校验
|
||
``auto_archive_before_days < default_retention_days``。
|
||
"""
|
||
|
||
model_config = ConfigDict(frozen=True)
|
||
|
||
default_retention_days: int = Field(default=90, ge=1, description="默认保留天数")
|
||
by_operation_type: dict[str, Any] | None = Field(
|
||
default=None, description="按操作类型分组的保留天数"
|
||
)
|
||
auto_archive_enabled: bool = Field(default=True, description="是否启用自动归档")
|
||
auto_archive_before_days: int = Field(
|
||
default=80, ge=1, description="归档提前天数(须小于 default_retention_days)"
|
||
)
|
||
|
||
|
||
@audit_router.get("/audit/logs", response_model=dict)
|
||
async def query_audit_logs(
|
||
request: Request,
|
||
operation_type: AuditOperationType | None = Query(default=None, description="操作类型"),
|
||
operator: str | None = Query(default=None, description="操作人用户 ID"),
|
||
target_channel: ChannelType | None = Query(default=None, description="目标渠道类型"),
|
||
target_account: str | None = Query(default=None, description="目标账户 ID"),
|
||
start_time: str | None = Query(default=None, description="起始时间(ISO 8601)"),
|
||
end_time: str | None = Query(default=None, description="结束时间(ISO 8601)"),
|
||
limit: int = Query(default=100, ge=1, le=500, description="分页大小"),
|
||
offset: int = Query(default=0, ge=0, description="分页偏移"),
|
||
use_cases=Depends(get_channel_use_cases),
|
||
current_user: User = Depends(get_admin_user),
|
||
) -> dict[str, Any]:
|
||
"""查询审计日志列表(AUD-QUERY-01)。对应控制面操作 audit/query。
|
||
|
||
``operation_type`` 查询参数由 FastAPI 按 ``AuditOperationType`` 枚举校验,
|
||
非法值返回 422。
|
||
"""
|
||
operator_vo = build_operator(current_user, request)
|
||
start_time_dt = parse_datetime("start_time", start_time)
|
||
end_time_dt = parse_datetime("end_time", end_time)
|
||
query = AuditQuery(
|
||
operation_type=operation_type,
|
||
operator=operator,
|
||
target_channel=target_channel,
|
||
target_account=target_account,
|
||
start_time=start_time_dt,
|
||
end_time=end_time_dt,
|
||
limit=limit,
|
||
offset=offset,
|
||
)
|
||
result = await use_cases.audit_query.queryAuditLogs(query=query, operator=operator_vo)
|
||
raiseOnControlFailure(result)
|
||
return {"success": True, "data": serialize_control_data(result.data)}
|
||
|
||
|
||
@audit_router.get("/audit/logs/stats", response_model=dict)
|
||
async def get_audit_log_stats(
|
||
request: Request,
|
||
operation_type: AuditOperationType | None = Query(default=None, description="操作类型"),
|
||
target_channel: ChannelType | None = Query(default=None, description="目标渠道类型"),
|
||
target_account: str | None = Query(default=None, description="目标账户 ID"),
|
||
start_time: str | None = Query(default=None, description="起始时间(ISO 8601)"),
|
||
end_time: str | None = Query(default=None, description="结束时间(ISO 8601)"),
|
||
use_cases=Depends(get_channel_use_cases),
|
||
current_user: User = Depends(get_admin_user),
|
||
) -> dict[str, Any]:
|
||
"""查询审计日志统计聚合(AUD-05)。对应控制面操作 audit/stats。
|
||
|
||
统计查询不含 ``limit`` / ``offset`` 分页参数,对全量匹配数据聚合。
|
||
"""
|
||
operator_vo = build_operator(current_user, request)
|
||
start_time_dt = parse_datetime("start_time", start_time)
|
||
end_time_dt = parse_datetime("end_time", end_time)
|
||
query = AuditQuery(
|
||
operation_type=operation_type,
|
||
target_channel=target_channel,
|
||
target_account=target_account,
|
||
start_time=start_time_dt,
|
||
end_time=end_time_dt,
|
||
)
|
||
result = await use_cases.audit_query.getAuditLogStats(query=query, operator=operator_vo)
|
||
raiseOnControlFailure(result)
|
||
return {"success": True, "data": serialize_control_data(result.data)}
|
||
|
||
|
||
@audit_router.get("/audit/operations", response_model=dict)
|
||
async def list_audit_operations(
|
||
request: Request,
|
||
use_cases=Depends(get_channel_use_cases),
|
||
current_user: User = Depends(get_admin_user),
|
||
) -> dict[str, Any]:
|
||
"""查询审计操作类型枚举(AUD-06)。对应控制面操作 audit/operations。
|
||
|
||
返回 ``AuditOperationType`` 全部枚举值列表,dispatch 阶段直接返回静态枚举,
|
||
无 DB 操作。
|
||
"""
|
||
operator_vo = build_operator(current_user, request)
|
||
result = await use_cases.audit_query.listAuditOperations(operator=operator_vo)
|
||
raiseOnControlFailure(result)
|
||
return {"success": True, "data": serialize_control_data(result.data)}
|
||
|
||
|
||
@audit_router.post("/audit/export", response_model=dict)
|
||
async def create_audit_export_task(
|
||
payload: CreateAuditExportRequest,
|
||
request: Request,
|
||
use_cases=Depends(get_channel_use_cases),
|
||
current_user: User = Depends(get_admin_user),
|
||
) -> dict[str, Any]:
|
||
"""提交审计日志导出任务(AUD-01)。对应控制面操作 audit/export/create。
|
||
|
||
导出任务异步执行,控制面仅创建任务记录并入队 ARQ,返回
|
||
``AuditExportTaskInfo``(含 ``task_id``)。
|
||
"""
|
||
operator_vo = build_operator(current_user, request)
|
||
start_time_dt = parse_datetime("start_time", payload.start_time)
|
||
end_time_dt = parse_datetime("end_time", payload.end_time)
|
||
query = AuditQuery(
|
||
operation_type=payload.operation_type,
|
||
operator=payload.operator,
|
||
target_channel=payload.target_channel,
|
||
target_account=payload.target_account,
|
||
start_time=start_time_dt,
|
||
end_time=end_time_dt,
|
||
)
|
||
result = await use_cases.audit_query.createAuditExportTask(
|
||
query=query,
|
||
operator=operator_vo,
|
||
format=payload.format,
|
||
)
|
||
raiseOnControlFailure(result)
|
||
return {"success": True, "data": serialize_control_data(result.data)}
|
||
|
||
|
||
@audit_router.get("/audit/export", response_model=dict)
|
||
async def list_export_tasks(
|
||
request: Request,
|
||
status: AuditExportStatus | None = Query(default=None, description="按任务状态过滤"),
|
||
limit: int = Query(default=50, ge=1, le=200, description="分页大小"),
|
||
offset: int = Query(default=0, ge=0, description="分页偏移"),
|
||
use_cases=Depends(get_channel_use_cases),
|
||
current_user: User = Depends(get_admin_user),
|
||
) -> dict[str, Any]:
|
||
"""查询审计导出任务列表(AUD-EXPORT-LIST-01)。
|
||
|
||
支持按状态过滤与分页,按 submitted_at 倒序返回。
|
||
对应控制面操作 ``audit/export/list``。
|
||
"""
|
||
operator_vo = build_operator(current_user, request)
|
||
query = AuditExportTaskListQuery(
|
||
status=status,
|
||
limit=limit,
|
||
offset=offset,
|
||
)
|
||
result = await use_cases.audit_query.listExportTasks(query=query, operator=operator_vo)
|
||
raiseOnControlFailure(result)
|
||
return {"success": True, "data": serialize_control_data(result.data)}
|
||
|
||
|
||
@audit_router.get("/audit/retention-policy", response_model=dict)
|
||
async def get_retention_policy(
|
||
request: Request,
|
||
use_cases=Depends(get_channel_use_cases),
|
||
current_user: User = Depends(get_admin_user),
|
||
) -> dict[str, Any]:
|
||
"""查询审计保留策略(AUD-RETENTION-GET)。
|
||
|
||
对应控制面操作 ``audit/retention_policy_get``。复用 ConfigManager 读取
|
||
``audit_retention_policy`` 配置键(GLOBAL 作用域),配置未初始化时返回
|
||
默认值(default_retention_days=90、auto_archive_enabled=True、
|
||
auto_archive_before_days=80、updated_at=None)。
|
||
"""
|
||
operator_vo = build_operator(current_user, request)
|
||
result = await use_cases.audit_query.getRetentionPolicy(operator=operator_vo)
|
||
raiseOnControlFailure(result)
|
||
return {"success": True, "data": serialize_control_data(result.data)}
|
||
|
||
|
||
@audit_router.put("/audit/retention-policy", response_model=dict)
|
||
async def update_retention_policy(
|
||
payload: UpdateRetentionPolicyRequest,
|
||
request: Request,
|
||
use_cases=Depends(get_channel_use_cases),
|
||
current_user: User = Depends(get_superadmin_user),
|
||
) -> dict[str, Any]:
|
||
"""更新审计保留策略(AUD-RETENTION-PUT)。
|
||
|
||
对应控制面操作 ``audit/retention_policy_update``。复用 ConfigManager 更新
|
||
``audit_retention_policy`` 配置键(GLOBAL 作用域),dispatch 阶段校验
|
||
``auto_archive_before_days < default_retention_days``,``updated_at``
|
||
刷新为当前时间并随配置值持久化。
|
||
"""
|
||
operator_vo = build_operator(current_user, request)
|
||
cmd = RetentionPolicyUpdateCmd(
|
||
operator=operator_vo,
|
||
default_retention_days=payload.default_retention_days,
|
||
by_operation_type=payload.by_operation_type,
|
||
auto_archive_enabled=payload.auto_archive_enabled,
|
||
auto_archive_before_days=payload.auto_archive_before_days,
|
||
)
|
||
result = await use_cases.audit_query.updateRetentionPolicy(cmd=cmd)
|
||
raiseOnControlFailure(result)
|
||
return {"success": True, "data": serialize_control_data(result.data)}
|
||
|
||
|
||
@audit_router.get("/audit/logs/{log_id}", response_model=dict)
|
||
async def get_audit_log(
|
||
log_id: str,
|
||
request: Request,
|
||
use_cases=Depends(get_channel_use_cases),
|
||
current_user: User = Depends(get_admin_user),
|
||
) -> dict[str, Any]:
|
||
"""查询单条审计日志详情(AUD-07)。对应控制面操作 audit/get。
|
||
|
||
``log_id`` 不存在时返回 ``NOT_FOUND``,由 ``raiseOnControlFailure`` 映射为
|
||
``NotFoundError``(HTTP 404)。
|
||
"""
|
||
operator_vo = build_operator(current_user, request)
|
||
result = await use_cases.audit_query.getAuditLog(log_id=log_id, operator=operator_vo)
|
||
raiseOnControlFailure(result)
|
||
return {"success": True, "data": serialize_control_data(result.data)}
|
||
|
||
|
||
@audit_router.get("/audit/export/{task_id}", response_model=dict)
|
||
async def get_audit_export_task(
|
||
task_id: str,
|
||
request: Request,
|
||
use_cases=Depends(get_channel_use_cases),
|
||
current_user: User = Depends(get_admin_user),
|
||
) -> dict[str, Any]:
|
||
"""查询审计导出任务状态(AUD-02)。对应控制面操作 audit/export/get。
|
||
|
||
返回 ``AuditExportTaskInfo``(含 ``status`` / ``total_records`` /
|
||
``completed_at`` / ``error``)。任务不存在时返回 ``NOT_FOUND``。
|
||
"""
|
||
operator_vo = build_operator(current_user, request)
|
||
result = await use_cases.audit_query.getAuditExportTask(task_id=task_id, operator=operator_vo)
|
||
raiseOnControlFailure(result)
|
||
return {"success": True, "data": serialize_control_data(result.data)}
|
||
|
||
|
||
@audit_router.delete("/audit/export/{task_id}", response_model=dict)
|
||
async def cancel_export_task(
|
||
task_id: str,
|
||
request: Request,
|
||
use_cases=Depends(get_channel_use_cases),
|
||
current_user: User = Depends(get_admin_user),
|
||
) -> dict[str, Any]:
|
||
"""取消审计导出任务(AUD-EXPORT-CANCEL-01)。
|
||
|
||
取消正在进行或等待中的导出任务,已完成/失败/已取消的任务不可取消。
|
||
对应控制面操作 ``audit/export/cancel``。
|
||
"""
|
||
operator_vo = build_operator(current_user, request)
|
||
result = await use_cases.audit_query.cancelExportTask(task_id=task_id, operator=operator_vo)
|
||
raiseOnControlFailure(result)
|
||
return {"success": True, "data": serialize_control_data(result.data)}
|
||
|
||
|
||
@audit_router.get("/audit/export/{task_id}/download")
|
||
async def download_audit_export_report(
|
||
task_id: str,
|
||
request: Request,
|
||
use_cases=Depends(get_channel_use_cases),
|
||
current_user: User = Depends(get_admin_user),
|
||
) -> StreamingResponse:
|
||
"""下载审计导出报告(AUD-03)。对应控制面操作 audit/export/download。
|
||
|
||
控制面管道返回 ``AuditExportDownloadResult``(含 ``file_path``),Router
|
||
层根据文件路径返回 ``StreamingResponse``。此端点返回二进制文件流,不
|
||
遵循 ``{"success": True, "data": ...}`` 标准响应结构(文件下载语义特例)。
|
||
|
||
Router 层在打开文件前校验路径合法性:路径必须以预配置导出目录为前缀,
|
||
文件名仅含 ``task_id`` 与 ``.json`` 扩展名,防止路径遍历攻击(§1.5 决策 9)。
|
||
校验失败抛 ``RuleViolationError``(HTTP 422)。
|
||
"""
|
||
operator_vo = build_operator(current_user, request)
|
||
result = await use_cases.audit_query.downloadAuditExportReport(
|
||
task_id=task_id,
|
||
operator=operator_vo,
|
||
)
|
||
raiseOnControlFailure(result)
|
||
if not isinstance(result.data, dict) or "file_path" not in result.data:
|
||
raise InternalError(message="control pipeline returned malformed data for audit export download")
|
||
file_path = result.data["file_path"]
|
||
resolved = Path(file_path).resolve()
|
||
export_dir = Path(
|
||
os.getenv("AUDIT_EXPORT_DIR") or str(Path(tempfile.gettempdir()) / "audit_export")
|
||
).resolve()
|
||
if not resolved.is_relative_to(export_dir) or resolved.name != f"{task_id}.json":
|
||
raise RuleViolationError("audit_export_invalid_file_path")
|
||
return StreamingResponse(
|
||
open(file_path, "rb"),
|
||
media_type="application/json",
|
||
headers={
|
||
"Content-Disposition": f'attachment; filename="audit_export_{task_id}.json"',
|
||
},
|
||
)
|