2026-07-02 03:29:06 +08:00
|
|
|
|
"""健康检查域 Router(HLT-AGG-01 / HLT-AGG-02 / HLT-EXP-01 / HLT-SINGLE / HLT-PROBE)。
|
|
|
|
|
|
|
|
|
|
|
|
本 router 实现渠道健康检查域的全部 HTTP 端点,覆盖聚合健康(简略 / 详细)、
|
|
|
|
|
|
诊断导出、单渠道健康、主动探测共 5 个操作。
|
|
|
|
|
|
|
|
|
|
|
|
模板选型:
|
|
|
|
|
|
- HLT-AGG-01 / HLT-AGG-02 / HLT-EXP-01:模板 B(数据面端口路由),
|
|
|
|
|
|
经 ``use_cases.health_check`` 端口调用 ``HealthCheckService`` 类型化
|
|
|
|
|
|
方法,不经控制面管道。
|
|
|
|
|
|
- HLT-SINGLE / HLT-PROBE:模板 A(控制面端口路由),经
|
|
|
|
|
|
``use_cases.health_check_control`` 端口调用 ``ChannelControlService``
|
|
|
|
|
|
类型化方法,走控制面管道五阶段(auth → permission → rate_limit →
|
|
|
|
|
|
dispatch → audit)。
|
|
|
|
|
|
|
|
|
|
|
|
鉴权策略:HLT-AGG-01 无鉴权(K8s 探针 + 公开状态页);其余端点使用
|
|
|
|
|
|
``get_admin_user`` 依赖,要求管理员角色。角色校验由依赖函数完成,router
|
|
|
|
|
|
内不做角色判断(规范 §4)。
|
|
|
|
|
|
|
|
|
|
|
|
路径设计:静态路径(``/health`` / ``/health/detail`` /
|
|
|
|
|
|
``/health/diagnostics/export``)先于动态路径
|
|
|
|
|
|
(``/{channel_type}/{account_id}/health`` /
|
|
|
|
|
|
``/{channel_type}/{account_id}/probe``)声明,避免 ``/health`` 被捕获为
|
|
|
|
|
|
``channel_type="health"``(规范 §6.5)。子 router 不自行设置 prefix,
|
|
|
|
|
|
根前缀 ``/channels`` 由 ``channels_router`` 聚合 router 统一追加。
|
|
|
|
|
|
|
|
|
|
|
|
注册顺序:``health_router`` 必须在 ``channels_router`` 聚合中先于含
|
|
|
|
|
|
``/{channel_type}`` 动态路径的 router(如 ``account_router`` /
|
|
|
|
|
|
``login_router`` / ``directory_router`` 等)注册。
|
|
|
|
|
|
|
|
|
|
|
|
端点清单(对应《健康检查域设计方案》§2.1 + P1 缺口 §2.3.17):
|
|
|
|
|
|
- GET /health HLT-AGG-01 get_health
|
|
|
|
|
|
- GET /health/detail HLT-AGG-02 get_health_detail
|
|
|
|
|
|
- POST /health/diagnostics/export HLT-EXP-01 export_diagnostics
|
|
|
|
|
|
- GET /{channel_type}/{account_id}/health HLT-SINGLE get_single_health
|
|
|
|
|
|
- POST /{channel_type}/{account_id}/probe HLT-PROBE probe_channel
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
2026-07-04 00:16:00 +08:00
|
|
|
|
from typing import Any, Literal
|
2026-07-02 03:29:06 +08:00
|
|
|
|
|
|
|
|
|
|
from fastapi import APIRouter, Depends, Query, Request
|
|
|
|
|
|
from pydantic import BaseModel, ConfigDict, Field
|
|
|
|
|
|
|
|
|
|
|
|
from yuxi.channels.contract.dtos.channel import ChannelType
|
|
|
|
|
|
from yuxi.channels.contract.dtos.health import (
|
|
|
|
|
|
DiagnosticsExportRequest,
|
|
|
|
|
|
HealthQuery,
|
|
|
|
|
|
)
|
|
|
|
|
|
from yuxi.channels.contract.dtos.health_check import ProbeCmd, SingleHealthQuery
|
|
|
|
|
|
from yuxi.storage.postgres.models_business import User
|
|
|
|
|
|
|
|
|
|
|
|
from server.routers.channels import (
|
|
|
|
|
|
build_operator,
|
|
|
|
|
|
dataclass_to_dict,
|
|
|
|
|
|
get_channel_use_cases,
|
|
|
|
|
|
raiseOnControlFailure,
|
|
|
|
|
|
serialize_control_data,
|
|
|
|
|
|
)
|
|
|
|
|
|
from server.utils.auth_middleware import get_admin_user
|
|
|
|
|
|
|
|
|
|
|
|
# build_operator 仅在需要构造 Operator 注入端口方法时使用(HLT-EXP-01)。
|
|
|
|
|
|
# HLT-AGG-01 / HLT-AGG-02 的 checkHealth(query) 契约不接受 operator,不构造。
|
|
|
|
|
|
|
|
|
|
|
|
health_router = APIRouter(tags=["channels-health"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------- 静态路径端点(须先于动态路径声明) ----------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@health_router.get("/health", response_model=dict)
|
|
|
|
|
|
async def get_health(
|
|
|
|
|
|
use_cases=Depends(get_channel_use_cases),
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
"""聚合健康检查(简略,HLT-AGG-01,FR-35)。
|
|
|
|
|
|
|
|
|
|
|
|
无鉴权端点,适用于 K8s liveness/readiness 探针与公开状态页。
|
|
|
|
|
|
返回简略视图:status / version / degraded_components / 简化渠道列表
|
2026-07-04 00:16:00 +08:00
|
|
|
|
(channel_type / plugin_state),不暴露 account_id / last_error /
|
|
|
|
|
|
worker_status / db_connection_pool_status / circuit_breaker_state 等
|
|
|
|
|
|
运维细节与业务标识,避免无鉴权端点信息泄露。
|
2026-07-02 03:29:06 +08:00
|
|
|
|
|
|
|
|
|
|
编排链路:HTTP 入参 → 构造 HealthQuery(with_probe=False)→
|
|
|
|
|
|
use_cases.health_check.checkHealth(query) 直接调用端口方法 →
|
|
|
|
|
|
从 HealthSnapshot 提取简略字段构造响应 dict。
|
|
|
|
|
|
|
|
|
|
|
|
采用模板 B(数据面):不经过控制面管道,不调用 raiseOnControlFailure,
|
2026-07-04 00:16:00 +08:00
|
|
|
|
契约异常由全局 unified_error_handler 统一映射为 HTTP 响应。响应遵循
|
|
|
|
|
|
``{"success": True, "data": ...}`` 统一结构,K8s 探针仅依赖 HTTP 200
|
|
|
|
|
|
状态码判定存活,不解析响应体结构。
|
2026-07-02 03:29:06 +08:00
|
|
|
|
"""
|
|
|
|
|
|
query = HealthQuery()
|
|
|
|
|
|
snapshot = await use_cases.health_check.checkHealth(query)
|
|
|
|
|
|
return {
|
2026-07-04 00:16:00 +08:00
|
|
|
|
"success": True,
|
|
|
|
|
|
"data": {
|
|
|
|
|
|
"status": snapshot.status,
|
|
|
|
|
|
"version": snapshot.version,
|
|
|
|
|
|
"degraded_components": list(snapshot.degraded_components),
|
|
|
|
|
|
"channels": [
|
|
|
|
|
|
{
|
|
|
|
|
|
"channel_type": ch.channel_type,
|
|
|
|
|
|
"plugin_state": ch.plugin_state,
|
|
|
|
|
|
}
|
|
|
|
|
|
for ch in snapshot.channels
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
2026-07-02 03:29:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@health_router.get("/health/detail", response_model=dict)
|
|
|
|
|
|
async def get_health_detail(
|
|
|
|
|
|
channel_type: ChannelType | None = Query(
|
|
|
|
|
|
default=None, description="按渠道过滤(None 表示全渠道)"
|
|
|
|
|
|
),
|
|
|
|
|
|
with_probe: bool = Query(
|
|
|
|
|
|
default=False, description="是否绕过缓存触发实时聚合(非调用 probeChannel)"
|
|
|
|
|
|
),
|
|
|
|
|
|
use_cases=Depends(get_channel_use_cases),
|
|
|
|
|
|
current_user: User = Depends(get_admin_user),
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
"""聚合健康检查(详细,HLT-AGG-02,FR-35)。
|
|
|
|
|
|
|
|
|
|
|
|
管理员鉴权端点,适用于运维排障。返回完整 HealthSnapshot,含
|
|
|
|
|
|
worker_status / redis_stream_status / db_connection_pool_status /
|
|
|
|
|
|
各渠道 circuit_breaker_state / queue_depth / connection_pool_status /
|
|
|
|
|
|
trace_id / error_code 等运维字段。
|
|
|
|
|
|
|
|
|
|
|
|
编排链路:HTTP 入参(channel_type / with_probe)→
|
|
|
|
|
|
构造 HealthQuery(channel_filter / with_probe)→
|
|
|
|
|
|
use_cases.health_check.checkHealth(query) 直接调用端口方法 →
|
|
|
|
|
|
dataclass_to_dict(snapshot) 序列化完整快照返回。
|
|
|
|
|
|
|
|
|
|
|
|
采用模板 B(数据面):不经过控制面管道,不调用 raiseOnControlFailure。
|
|
|
|
|
|
with_probe 参数语义:with_probe=True 绕过短期缓存触发实时聚合,
|
|
|
|
|
|
非调用 probeChannel 端口方法(详见设计方案 §1.5 决策 5)。
|
|
|
|
|
|
|
|
|
|
|
|
checkHealth(query: HealthQuery) 契约不接受 operator 参数(只读无副作用
|
|
|
|
|
|
查询),current_user 仅用于鉴权(get_admin_user 依赖副作用),不在
|
|
|
|
|
|
函数体使用。
|
|
|
|
|
|
"""
|
|
|
|
|
|
query = HealthQuery(channel_filter=channel_type, with_probe=with_probe)
|
|
|
|
|
|
snapshot = await use_cases.health_check.checkHealth(query)
|
|
|
|
|
|
return {"success": True, "data": dataclass_to_dict(snapshot)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------- Request Schemas ----------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class DiagnosticsExportPayload(BaseModel):
|
|
|
|
|
|
"""诊断导出请求体(HLT-EXP-01,Router 层 Pydantic schema)。
|
|
|
|
|
|
|
|
|
|
|
|
字段对齐契约层 DiagnosticsExportRequest DTO,但 operator 由 router
|
|
|
|
|
|
构造(从认证用户),不在 body 中。命名为 ``Payload`` 后缀以区分 Router
|
|
|
|
|
|
层 schema 与契约层 DTO,避免同名冲突(规范反模式:别名导入降低可读性)。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
model_config = ConfigDict(frozen=True)
|
|
|
|
|
|
|
|
|
|
|
|
channel_filter: ChannelType | None = Field(
|
|
|
|
|
|
default=None, description="按渠道过滤(None 表示全渠道)"
|
|
|
|
|
|
)
|
|
|
|
|
|
include_audit_logs: bool = Field(default=True, description="是否包含审计日志")
|
|
|
|
|
|
audit_log_count: int = Field(
|
|
|
|
|
|
default=100, ge=1, le=500, description="审计日志条数上限"
|
|
|
|
|
|
)
|
|
|
|
|
|
include_error_logs: bool = Field(default=True, description="是否包含错误日志")
|
|
|
|
|
|
error_log_count: int = Field(
|
|
|
|
|
|
default=100, ge=1, le=500, description="错误日志条数上限"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------- Endpoints ----------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@health_router.post("/health/diagnostics/export", response_model=dict)
|
|
|
|
|
|
async def export_diagnostics(
|
|
|
|
|
|
payload: DiagnosticsExportPayload,
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
use_cases=Depends(get_channel_use_cases),
|
|
|
|
|
|
current_user: User = Depends(get_admin_user),
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
"""导出诊断信息包(HLT-EXP-01,FR-35)。
|
|
|
|
|
|
|
|
|
|
|
|
管理员鉴权端点,适用于故障排障。导出 manifest 快照 + 插件状态 +
|
|
|
|
|
|
审计日志 + 错误日志 + 队列深度 + Worker / Redis / DB 池状态,
|
|
|
|
|
|
敏感字段已脱敏(部分遮蔽 app_secret / encrypt_key / verification_token /
|
|
|
|
|
|
access_token / refresh_token;全量遮蔽 webhook_secret / webhook_url /
|
|
|
|
|
|
password / api_key),审计日志写入 fail-closed。
|
|
|
|
|
|
|
|
|
|
|
|
编排链路:HTTP 入参 → build_operator 构造操作人 → 构造
|
|
|
|
|
|
DiagnosticsExportRequest DTO → use_cases.health_check.exportDiagnostics(request)
|
|
|
|
|
|
直接调用端口方法 → dataclass_to_dict(bundle) 序列化诊断包返回。
|
|
|
|
|
|
|
|
|
|
|
|
对应数据面端口方法 exportDiagnostics(非控制面管道路径),
|
|
|
|
|
|
不调用 raiseOnControlFailure。敏感字段脱敏由 framework 层
|
|
|
|
|
|
DiagnosticsExporter 完成,router 层不重复脱敏。鉴权由 framework 层
|
|
|
|
|
|
``get_admin_user`` 守门,对齐端口契约 ``@pre: operator 已通过权限校验
|
|
|
|
|
|
(管理员角色)``,用例服务不重复校验权限(避免双重鉴权)。
|
|
|
|
|
|
"""
|
|
|
|
|
|
operator = build_operator(current_user, request)
|
|
|
|
|
|
export_request = DiagnosticsExportRequest(
|
|
|
|
|
|
operator=operator,
|
|
|
|
|
|
channel_filter=payload.channel_filter,
|
|
|
|
|
|
include_audit_logs=payload.include_audit_logs,
|
|
|
|
|
|
audit_log_count=payload.audit_log_count,
|
|
|
|
|
|
include_error_logs=payload.include_error_logs,
|
|
|
|
|
|
error_log_count=payload.error_log_count,
|
|
|
|
|
|
)
|
|
|
|
|
|
bundle = await use_cases.health_check.exportDiagnostics(export_request)
|
|
|
|
|
|
return {"success": True, "data": dataclass_to_dict(bundle)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------- 动态路径端点(须在静态路径之后声明) ----------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ProbeRequest(BaseModel):
|
|
|
|
|
|
"""主动探测请求体(HLT-PROBE,Router 层 Pydantic schema)。
|
|
|
|
|
|
|
|
|
|
|
|
字段对齐契约层 ``ProbeCmd`` DTO 的非操作人部分,``operator`` 由 router
|
|
|
|
|
|
构造(从认证用户),不在 body 中。``probe_type`` 仅允许 ``connectivity``
|
2026-07-04 00:16:00 +08:00
|
|
|
|
或 ``full``,由 Pydantic Literal 约束在 HTTP 边界即拒绝非法值,
|
|
|
|
|
|
契约层 ``ProbeCmd.__post_init__`` 与控制面管道 dispatch_stage 重复校验
|
|
|
|
|
|
(防御深度)。
|
2026-07-02 03:29:06 +08:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
model_config = ConfigDict(frozen=True)
|
|
|
|
|
|
|
2026-07-04 00:16:00 +08:00
|
|
|
|
probe_type: Literal["connectivity", "full"] = Field(
|
2026-07-02 03:29:06 +08:00
|
|
|
|
default="connectivity",
|
|
|
|
|
|
description="探测类型(connectivity 仅测连通性,full 含凭据/权限/Webhook 全项检查)",
|
|
|
|
|
|
)
|
|
|
|
|
|
timeout_ms: int = Field(
|
|
|
|
|
|
default=5000,
|
|
|
|
|
|
ge=1000,
|
|
|
|
|
|
le=30000,
|
|
|
|
|
|
description="探测超时毫秒数(默认 5000,上限 30000)",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@health_router.get(
|
|
|
|
|
|
"/{channel_type}/{account_id}/health",
|
|
|
|
|
|
response_model=dict,
|
|
|
|
|
|
)
|
|
|
|
|
|
async def get_single_health(
|
|
|
|
|
|
channel_type: ChannelType,
|
|
|
|
|
|
account_id: str,
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
use_cases=Depends(get_channel_use_cases),
|
|
|
|
|
|
current_user: User = Depends(get_admin_user),
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
"""查询单渠道健康详情(HLT-SINGLE)。
|
|
|
|
|
|
|
|
|
|
|
|
对应控制面操作 ``health/single``(由 ``ChannelControlService.getSingleHealth``
|
|
|
|
|
|
内部构造 ``ControlCmd`` 并委托 ``_executeControl`` 执行控制面管道)。
|
|
|
|
|
|
聚合指定渠道账户的账户状态、熔断器状态、队列深度、插件状态、连接池状态、
|
2026-07-04 00:16:00 +08:00
|
|
|
|
worker 状态等健康指标,供运维排障定位单渠道健康问题。本端点为纯读聚合
|
|
|
|
|
|
(GET 安全方法语义),不触发渠道 API 调用;主动深度探测由
|
|
|
|
|
|
``POST /{channel_type}/{account_id}/probe``(HLT-PROBE)承担。
|
2026-07-02 03:29:06 +08:00
|
|
|
|
|
|
|
|
|
|
编排链路:HTTP 入参 → ``build_operator`` 构造操作人 → 构造
|
|
|
|
|
|
``SingleHealthQuery`` → ``use_cases.health_check_control.getSingleHealth``
|
|
|
|
|
|
调用控制面端口方法 → ``ControlResult`` 经 ``raiseOnControlFailure`` 转译
|
|
|
|
|
|
失败 → ``serialize_control_data`` 序列化单渠道健康结果返回。
|
|
|
|
|
|
|
|
|
|
|
|
采用模板 A(控制面):经 ``health_check_control`` 端口(与数据面
|
|
|
|
|
|
``health_check`` 字段分离)调用 ``ChannelControlService`` 类型化方法,
|
|
|
|
|
|
走控制面管道五阶段。
|
|
|
|
|
|
"""
|
|
|
|
|
|
operator = build_operator(current_user, request)
|
|
|
|
|
|
query = SingleHealthQuery(
|
|
|
|
|
|
channel_type=channel_type,
|
|
|
|
|
|
account_id=account_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
result = await use_cases.health_check_control.getSingleHealth(
|
|
|
|
|
|
query, operator=operator
|
|
|
|
|
|
)
|
|
|
|
|
|
raiseOnControlFailure(result)
|
|
|
|
|
|
return {"success": True, "data": serialize_control_data(result.data)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@health_router.post(
|
|
|
|
|
|
"/{channel_type}/{account_id}/probe",
|
|
|
|
|
|
response_model=dict,
|
|
|
|
|
|
)
|
|
|
|
|
|
async def probe_channel(
|
|
|
|
|
|
channel_type: ChannelType,
|
|
|
|
|
|
account_id: str,
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
body: ProbeRequest,
|
|
|
|
|
|
use_cases=Depends(get_channel_use_cases),
|
|
|
|
|
|
current_user: User = Depends(get_admin_user),
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
"""主动深度探测指定渠道(HLT-PROBE)。
|
|
|
|
|
|
|
|
|
|
|
|
对应控制面操作 ``health/probe``(由 ``ChannelControlService.probeChannelDeep``
|
|
|
|
|
|
内部构造 ``ControlCmd`` 并委托 ``_executeControl`` 执行控制面管道)。
|
|
|
|
|
|
管理员发起的主动深度探测,通过插件 ``ProbeableAdapter.probeDeep``
|
|
|
|
|
|
执行深度探测(``connectivity`` 仅测连通性,``full`` 含凭据 / 权限 /
|
|
|
|
|
|
Webhook 全项检查)。渠道未实现 ``probeDeep`` 时控制面返回 501。
|
|
|
|
|
|
|
|
|
|
|
|
编排链路:HTTP 入参 → ``build_operator`` 构造操作人 → 构造 ``ProbeCmd``
|
|
|
|
|
|
→ ``use_cases.health_check_control.probeChannelDeep`` 调用控制面端口方法
|
|
|
|
|
|
→ ``ControlResult`` 经 ``raiseOnControlFailure`` 转译失败 →
|
|
|
|
|
|
``serialize_control_data`` 序列化探测结果返回。
|
|
|
|
|
|
|
|
|
|
|
|
采用模板 A(控制面):经 ``health_check_control`` 端口调用
|
|
|
|
|
|
``ChannelControlService`` 类型化方法,走控制面管道五阶段。探测可触发
|
|
|
|
|
|
副作用(调用渠道 API),审计事务策略 INDEPENDENT(best-effort)。
|
|
|
|
|
|
"""
|
|
|
|
|
|
operator = build_operator(current_user, request)
|
|
|
|
|
|
cmd = ProbeCmd(
|
|
|
|
|
|
channel_type=channel_type,
|
|
|
|
|
|
account_id=account_id,
|
|
|
|
|
|
operator=operator,
|
|
|
|
|
|
probe_type=body.probe_type,
|
|
|
|
|
|
timeout_ms=body.timeout_ms,
|
|
|
|
|
|
)
|
|
|
|
|
|
result = await use_cases.health_check_control.probeChannelDeep(cmd)
|
|
|
|
|
|
raiseOnControlFailure(result)
|
|
|
|
|
|
return {"success": True, "data": serialize_control_data(result.data)}
|