ForcePilot/backend/server/routers/channels/allowlist_router.py
Kris af5b209be1 refactor(channel routers): 统一分页参数并优化接口实现
1.  新增统一分页常量定义,替换各路由零散的Query参数声明
2.  批量替换多个列表接口的分页参数为预设常量
3.  优化插件目录、消息搜索等接口的参数与文档
4.  修复死信导出接口的参数命名不一致问题
2026-07-11 15:55:45 +08:00

502 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.

"""白名单域 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 统一追加。
``peer_id`` 路径参数允许任意非空字符串(含 URL 编码字符),调用方需对
``/`` / ``?`` / ``#`` 等保留字符进行百分号编码RFC 3986 §2.4)。
端点清单(对应本设计方案 §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 清理过期白名单
- PATCH /{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 datetime import datetime
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 (
UNSET,
BatchWhitelistDeleteCmd,
CleanExpiredWhitelistCmd,
UnsetType,
WhitelistEntry,
WhitelistPolicyType,
WhitelistUpdateCmd,
)
from yuxi.channels.contract.errors import ValidationError
from yuxi.storage.postgres.models_business import User
from server.routers.channels import (
LARGE_LIMIT,
OFFSET,
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"])
_WHITELIST_POLICY_VALUES = " | ".join(e.value for e in WhitelistPolicyType)
_WHITELIST_EXPORT_FORMATS = ("json", "csv")
_WHITELIST_EXPORT_FORMAT_VALUES = " | ".join(_WHITELIST_EXPORT_FORMATS)
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: {_WHITELIST_POLICY_VALUES})",
) from exc
def _parse_export_format(raw: str) -> str:
"""校验导出格式参数,失败抛 ValidationError。
仅允许 ``json`` / ``csv`` 两种取值,原样返回(无需类型转换)。
"""
if raw not in _WHITELIST_EXPORT_FORMATS:
raise ValidationError(
"format",
f"unsupported format: {raw} (expected: {_WHITELIST_EXPORT_FORMAT_VALUES})",
)
return raw
class WhitelistAddRequest(BaseModel):
"""新增白名单条目请求。字段对齐 ``WhitelistManagementPort.addWhitelistEntry`` 入参。"""
model_config = ConfigDict(frozen=True)
peer_id: str = Field(..., min_length=1, 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`` 为路径参数不可变更。
字段三态语义:
- 字段未提供(不在请求体中):不修改,保留原值
- 字段显式为 ``null``:清除原值(``expires_at=null`` 设为永久;
``peer_name`` / ``reason`` 清空为 null
- 字段为具体值:更新为该值
"""
model_config = ConfigDict(frozen=True)
peer_name: str | None = Field(default=None, description="对端名称(未提供=不修改null=清空)")
reason: str | None = Field(default=None, description="加白原因(未提供=不修改null=清空)")
expires_at: str | None = Field(
default=None,
description="过期时间ISO 8601未提供=不修改null=设为永久)",
)
class WhitelistBatchItem(BaseModel):
"""批量导入白名单条目项。"""
model_config = ConfigDict(frozen=True)
peer_id: str = Field(..., min_length=1, 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,
limit: int = LARGE_LIMIT,
offset: int = OFFSET,
keyword: str | None = Query(default=None, description="关键词(模糊匹配 peer_id / peer_name"),
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``。
返回结构 ``{"whitelist": [...], "total": int, "limit": int, "offset": int}``。
"""
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,
limit=limit,
offset=offset,
keyword=keyword,
)
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_datetime("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,
export_format: str = Query(default="json", alias="format", 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(export_format)
result = await use_cases.whitelist_management.exportWhitelist(
channel_type=channel_type,
account_id=account_id,
policy_type=policy,
output_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.patch(
"/{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-42PATCH 三态语义)。
支持修改 ``peer_name`` / ``reason`` / ``expires_at`` 三字段;
``peer_id`` 与 ``policy_type`` 为路径参数,不可变更。条目不存在时
返回 404AC-41
字段三态语义:
- 字段未提供(不在请求体中):不修改,保留原值
- 字段显式为 ``null``:清除原值(``expires_at=null`` 设为永久)
- 字段为具体值:更新为该值
通过 ``model_dump(exclude_unset=True)`` 区分"未提供""显式 null"
映射为 ``WhitelistUpdateCmd`` 的 ``UNSET`` / ``None`` / 具体值三态。
"""
operator = build_operator(current_user, request)
policy = _parse_policy_type(policy_type)
explicit = payload.model_dump(exclude_unset=True)
peer_name: str | None | UnsetType = UNSET
if "peer_name" in explicit:
peer_name = explicit["peer_name"]
reason: str | None | UnsetType = UNSET
if "reason" in explicit:
reason = explicit["reason"]
expires_at: datetime | None | UnsetType = UNSET
if "expires_at" in explicit:
# ``parse_datetime`` 接受 None 返回 None清除语义接受字符串解析为 UTC datetime
expires_at = parse_datetime("expires_at", explicit["expires_at"])
cmd = WhitelistUpdateCmd(
channel_type=channel_type,
account_id=account_id,
peer_id=peer_id,
policy_type=policy,
operator=operator,
peer_name=peer_name,
reason=reason,
expires_at=expires_at,
)
result = await use_cases.whitelist_management.updateWhitelistEntry(cmd)
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)}