ForcePilot/backend/server/routers/channels/allowlist_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

458 lines
16 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.

"""白名单域 RouterALW-01~ALW-06 + P1缺口
本 router 实现渠道账户白名单条目的全部 HTTP 端点,覆盖查询 / 新增 /
修改 / 删除 / 批量导入 / 导出 / 批量删除 / 清理过期共 8 个操作。所有端点
统一采用模板 A控制面端口路由通过 ``get_channel_use_cases`` 装配
``ChannelUseCases``,经 ``whitelist_management`` 端口调用类型化方法,由
``ChannelControlService._executeControl`` 内部走控制面管道
auth → permission → rate_limit → dispatch → audit
鉴权策略:全部端点使用 ``get_admin_user`` 依赖,要求管理员或超级管理员角色。
角色校验由依赖函数完成router 内不做角色判断(规范 §4
路径设计:``/batch`` / ``/export`` / ``/batch-delete`` / ``/clean-expired``
静态后缀先于 ``/{peer_id}`` 动态路径声明(规范 §6.5),避免静态路径被动态
参数捕获。子 router 不自行设置 prefix根前缀 ``/channels`` 由
``channels_router`` 聚合 router 统一追加。
端点清单(对应本设计方案 §2.1 + P1缺口补全
- GET /{channel_type}/{account_id}/allowlist/{policy_type} ALW-01 list_whitelist
- POST /{channel_type}/{account_id}/allowlist/{policy_type} ALW-02 add_whitelist_entry
- POST /{channel_type}/{account_id}/allowlist/{policy_type}/batch ALW-05 batch_add_whitelist
- GET /{channel_type}/{account_id}/allowlist/{policy_type}/export ALW-06 export_whitelist
- POST /{channel_type}/{account_id}/allowlist/{policy_type}/batch-delete ALW-BATCH-DEL 批量删除白名单
- POST /{channel_type}/{account_id}/allowlist/{policy_type}/clean-expired ALW-CLEAN-EXPIRED 清理过期白名单
- PUT /{channel_type}/{account_id}/allowlist/{policy_type}/{peer_id} ALW-03 update_whitelist_entry
- DELETE /{channel_type}/{account_id}/allowlist/{policy_type}/{peer_id} ALW-04 remove_whitelist_entry
"""
from __future__ import annotations
from typing import Any
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.whitelist import (
BatchWhitelistDeleteCmd,
CleanExpiredWhitelistCmd,
WhitelistEntry,
WhitelistPolicyType,
)
from yuxi.channels.contract.errors import ValidationError
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
allowlist_router = APIRouter(tags=["channels-allowlist"])
def _parse_policy_type(raw: str) -> WhitelistPolicyType:
"""string → WhitelistPolicyType失败抛 ValidationError。"""
try:
return WhitelistPolicyType(raw)
except ValueError as exc:
raise ValidationError(
"policy_type",
f"unsupported policy_type: {raw} (expected: dm | group)",
) from exc
def _parse_export_format(raw: str) -> str:
"""校验导出格式参数,失败抛 ValidationError。
仅允许 ``json`` / ``csv`` 两种取值,原样返回(无需类型转换)。
"""
if raw not in ("json", "csv"):
raise ValidationError(
"format",
f"unsupported format: {raw} (expected: json | csv)",
)
return raw
class WhitelistAddRequest(BaseModel):
"""新增白名单条目请求。字段对齐 ``WhitelistManagementPort.addWhitelistEntry`` 入参。"""
model_config = ConfigDict(frozen=True)
peer_id: str = Field(..., description="对端 ID")
peer_name: str | None = Field(default=None, description="对端名称")
reason: str | None = Field(default=None, description="加白原因")
expires_at: str | None = Field(
default=None,
description="过期时间ISO 8601缺失表示永久",
)
class WhitelistUpdateRequest(BaseModel):
"""修改白名单条目请求PATCH 语义)。
支持修改 ``peer_name`` / ``reason`` / ``expires_at`` 三字段;
``peer_id`` 与 ``policy_type`` 为路径参数不可变更。字段缺失(``None``
表示不修改该字段,保留原值;如需清除 ``expires_at``(设为永久),
请使用删除 + 重新添加流程。
"""
model_config = ConfigDict(frozen=True)
peer_name: str | None = Field(default=None, description="对端名称None=不修改)")
reason: str | None = Field(default=None, description="加白原因None=不修改)")
expires_at: str | None = Field(
default=None,
description="过期时间ISO 8601None=不修改保留原值",
)
class WhitelistBatchItem(BaseModel):
"""批量导入白名单条目项。"""
model_config = ConfigDict(frozen=True)
peer_id: str = Field(..., description="对端 ID")
peer_name: str | None = Field(default=None, description="对端名称")
reason: str | None = Field(default=None, description="加白原因")
expires_at: str | None = Field(default=None, description="过期时间ISO 8601")
class WhitelistBatchRequest(BaseModel):
"""批量导入白名单请求。
``items`` 长度上限 500规范 §2.3.5``stop_on_error`` 默认 False
跳过失败项继续导入。
"""
model_config = ConfigDict(frozen=True)
items: list[WhitelistBatchItem] = Field(
...,
min_length=1,
max_length=500,
description="批量条目(上限 500",
)
stop_on_error: bool = Field(
default=False,
description="是否在首条失败时终止;默认 False 跳过失败项继续导入",
)
class WhitelistBatchDeleteRequest(BaseModel):
"""批量删除白名单条目请求ALW-BATCH-DEL"""
model_config = ConfigDict(frozen=True)
peer_ids: list[str] = Field(
...,
min_length=1,
max_length=500,
description="待删除对端 ID 列表(上限 500",
)
class WhitelistCleanExpiredRequest(BaseModel):
"""清理过期白名单条目请求ALW-CLEAN-EXPIRED"""
model_config = ConfigDict(frozen=True)
older_than: str | None = Field(
default=None,
description="过期阈值ISO 8601缺失取当前时间",
)
max_count: int = Field(
default=500,
ge=1,
le=1000,
description="单次最大清理数1-1000默认 500",
)
@allowlist_router.get(
"/{channel_type}/{account_id}/allowlist/{policy_type}",
response_model=dict,
)
async def list_whitelist(
channel_type: ChannelType,
account_id: str,
policy_type: str,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""查询白名单条目列表FR-18 / AC-42
``policy_type`` 非法时返回 400。对应控制面操作 ``whitelist/list``。
"""
operator = build_operator(current_user, request)
policy = _parse_policy_type(policy_type)
result = await use_cases.whitelist_management.listWhitelist(
channel_type=channel_type,
account_id=account_id,
policy_type=policy,
operator=operator,
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@allowlist_router.post(
"/{channel_type}/{account_id}/allowlist/{policy_type}",
response_model=dict,
)
async def add_whitelist_entry(
channel_type: ChannelType,
account_id: str,
policy_type: str,
payload: WhitelistAddRequest,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""新增白名单条目FR-18 / AC-20 / AC-42 / AC-65"""
operator = build_operator(current_user, request)
policy = _parse_policy_type(policy_type)
expires_at = parse_datetime("expires_at", payload.expires_at)
entry = WhitelistEntry(
peer_id=payload.peer_id,
peer_type=policy,
peer_name=payload.peer_name,
reason=payload.reason,
expires_at=expires_at,
)
result = await use_cases.whitelist_management.addWhitelistEntry(
channel_type=channel_type,
account_id=account_id,
entry=entry,
operator=operator,
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@allowlist_router.post(
"/{channel_type}/{account_id}/allowlist/{policy_type}/batch",
response_model=dict,
)
async def batch_add_whitelist(
channel_type: ChannelType,
account_id: str,
policy_type: str,
payload: WhitelistBatchRequest,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""批量导入白名单条目FR-18
支持两种语义:
- ``stop_on_error=True``:首条失败即终止,整批回滚(按异常抛出)
- ``stop_on_error=False``(默认):跳过失败项,响应中返回成功与失败列表
单批上限 500 条,超过返回 422 ``VALIDATION_ERROR``。
"""
operator = build_operator(current_user, request)
policy = _parse_policy_type(policy_type)
entries = tuple(
WhitelistEntry(
peer_id=item.peer_id,
peer_type=policy,
peer_name=item.peer_name,
reason=item.reason,
expires_at=_parse_expires_at(item.expires_at),
)
for item in payload.items
)
result = await use_cases.whitelist_management.batchAddWhitelistEntries(
channel_type=channel_type,
account_id=account_id,
policy_type=policy,
entries=entries,
stop_on_error=payload.stop_on_error,
operator=operator,
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@allowlist_router.get(
"/{channel_type}/{account_id}/allowlist/{policy_type}/export",
response_model=dict,
)
async def export_whitelist(
channel_type: ChannelType,
account_id: str,
policy_type: str,
request: Request,
format: str = Query(default="json", description="导出格式json / csv"),
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""导出白名单条目FR-18
支持 ``json`` / ``csv`` 两种格式:
- ``json``:返回结构化 JSON 数组
- ``csv``:返回 ``data.content`` 为 CSV 字符串,``data.filename`` 为建议文件名
"""
operator = build_operator(current_user, request)
policy = _parse_policy_type(policy_type)
fmt = _parse_export_format(format)
result = await use_cases.whitelist_management.exportWhitelist(
channel_type=channel_type,
account_id=account_id,
policy_type=policy,
format=fmt,
operator=operator,
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@allowlist_router.post(
"/{channel_type}/{account_id}/allowlist/{policy_type}/batch-delete",
response_model=dict,
)
async def batch_delete_whitelist(
channel_type: ChannelType,
account_id: str,
policy_type: str,
payload: WhitelistBatchDeleteRequest,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""批量删除白名单条目ALW-BATCH-DEL
逐条独立事务删除(模式 D 部分成功语义),复用单条 ``whitelist/remove``
逻辑。单批上限 500 条,单条失败不回滚已成功条目。对应控制面操作
``whitelist/batch_delete``。
响应:``{total, deleted: [peer_id, ...], failed: [{id, error_code, message}, ...]}``。
"""
operator = build_operator(current_user, request)
policy = _parse_policy_type(policy_type)
cmd = BatchWhitelistDeleteCmd(
channel_type=channel_type,
account_id=account_id,
policy_type=policy,
operator=operator,
peer_ids=tuple(payload.peer_ids),
)
result = await use_cases.whitelist_management.batchDeleteWhitelist(cmd)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@allowlist_router.post(
"/{channel_type}/{account_id}/allowlist/{policy_type}/clean-expired",
response_model=dict,
)
async def clean_expired_whitelist(
channel_type: ChannelType,
account_id: str,
policy_type: str,
payload: WhitelistCleanExpiredRequest,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""清理过期白名单条目ALW-CLEAN-EXPIRED
查询过期白名单条目(``expires_at < older_than``,缺省取当前时间)并
批量删除(单一事务 COOPERATIVE fail-closed。``max_count`` 限制单次
最大清理数1-1000默认 500。对应控制面操作 ``whitelist/clean_expired``。
响应:``{total, cleaned}``。
"""
operator = build_operator(current_user, request)
policy = _parse_policy_type(policy_type)
cmd = CleanExpiredWhitelistCmd(
channel_type=channel_type,
account_id=account_id,
policy_type=policy,
operator=operator,
older_than=parse_datetime("older_than", payload.older_than),
max_count=payload.max_count,
)
result = await use_cases.whitelist_management.cleanExpiredWhitelist(cmd)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@allowlist_router.put(
"/{channel_type}/{account_id}/allowlist/{policy_type}/{peer_id}",
response_model=dict,
)
async def update_whitelist_entry(
channel_type: ChannelType,
account_id: str,
policy_type: str,
peer_id: str,
payload: WhitelistUpdateRequest,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""修改白名单条目FR-18 / AC-41 / AC-42
支持修改 ``peer_name`` / ``reason`` / ``expires_at`` 三字段;
``peer_id`` 与 ``policy_type`` 为路径参数,不可变更。
条目不存在时返回 404AC-41
"""
operator = build_operator(current_user, request)
policy = _parse_policy_type(policy_type)
expires_at = parse_datetime("expires_at", payload.expires_at)
result = await use_cases.whitelist_management.updateWhitelistEntry(
channel_type=channel_type,
account_id=account_id,
peer_id=peer_id,
policy_type=policy,
peer_name=payload.peer_name,
reason=payload.reason,
expires_at=expires_at,
operator=operator,
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@allowlist_router.delete(
"/{channel_type}/{account_id}/allowlist/{policy_type}/{peer_id}",
response_model=dict,
)
async def remove_whitelist_entry(
channel_type: ChannelType,
account_id: str,
policy_type: str,
peer_id: str,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""删除白名单条目FR-18 / AC-41 / AC-42
``peer_id`` 与 ``policy_type`` 为路径参数,联合定位唯一条目。
条目不存在时返回 404AC-41删除成功后立即触发 ``ConfigChanged``
事件,``WhitelistRegistry`` 同步移除(<1sFR-37
"""
operator = build_operator(current_user, request)
policy = _parse_policy_type(policy_type)
result = await use_cases.whitelist_management.removeWhitelistEntry(
channel_type=channel_type,
account_id=account_id,
peer_id=peer_id,
policy_type=policy,
operator=operator,
)
raiseOnControlFailure(result)
return {
"success": True,
"data": serialize_control_data(result.data) if result.data else {},
}