1. 新增统一分页常量定义,替换各路由零散的Query参数声明 2. 批量替换多个列表接口的分页参数为预设常量 3. 优化插件目录、消息搜索等接口的参数与文档 4. 修复死信导出接口的参数命名不一致问题
502 lines
18 KiB
Python
502 lines
18 KiB
Python
"""白名单域 Router(ALW-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-42,PATCH 三态语义)。
|
||
|
||
支持修改 ``peer_name`` / ``reason`` / ``expires_at`` 三字段;
|
||
``peer_id`` 与 ``policy_type`` 为路径参数,不可变更。条目不存在时
|
||
返回 404(AC-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`` 为路径参数,联合定位唯一条目。
|
||
条目不存在时返回 404(AC-41);删除成功后立即触发 ``ConfigChanged``
|
||
事件,``WhitelistRegistry`` 同步移除(<1s,FR-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)}
|