ForcePilot/backend/server/routers/channels/audit_router.py
Kris ddafd95ff0 refactor(routers): 整理并新增多组渠道相关路由功能
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到全局路由列表
2026-07-02 03:29:06 +08:00

397 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""审计日志域 RouterAUD-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"',
},
)