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

458 lines
16 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 统一追加
端点清单对应本设计方案 §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 {},
}