ForcePilot/backend/server/routers/channels/allowlist_router.py

500 lines
18 KiB
Python
Raw Normal View History

"""白名单域 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 (
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 = Query(default=100, ge=1, le=1000, description="分页大小1-1000默认 100"),
offset: int = Query(default=0, ge=0, description="偏移量(>=0默认 0"),
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)}