1. 为渠道账户ID查询添加最小长度校验,统一分析模块常量引用 2. 新增扫码登录向导端点,完善文档说明 3. 优化配对统计接口,移除无效参数 4. 为出站箱接口添加批量上限与202状态码 5. 新增测试用例、访问规则、配额等模块的查询与校验参数 6. 新增适配器健康批量查询、健康检查触发接口 7. 统一告警、审计日志的错误处理方式 8. 新增插件配置账户ID支持,优化批量操作响应 9. 新增环境健康批量查询、Webhook限流与参数校验 10. 完善会话管理、审计日志的参数与文档说明 11. 修复导入模块的校验错误处理逻辑
516 lines
19 KiB
Python
516 lines
19 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, Path, 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)
|
||
|
||
# 路径参数校验(避免空串穿透到业务层,与 directory_router 保持一致)
|
||
_ACCOUNT_ID_PATH: str = Path(..., min_length=1, description="渠道账户 ID")
|
||
_POLICY_TYPE_PATH: str = Path(..., description=f"白名单策略类型({_WHITELIST_POLICY_VALUES})")
|
||
_PEER_ID_PATH: str = Path(..., min_length=1, max_length=256, description="对端 ID(非空,最长 256 字符)")
|
||
|
||
|
||
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 = _ACCOUNT_ID_PATH,
|
||
policy_type: str = _POLICY_TYPE_PATH,
|
||
request: Request = None,
|
||
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 = _ACCOUNT_ID_PATH,
|
||
policy_type: str = _POLICY_TYPE_PATH,
|
||
payload: WhitelistAddRequest = ...,
|
||
request: Request = None,
|
||
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 = _ACCOUNT_ID_PATH,
|
||
policy_type: str = _POLICY_TYPE_PATH,
|
||
payload: WhitelistBatchRequest = ...,
|
||
request: Request = None,
|
||
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)
|
||
response: dict[str, Any] = {"success": True, "data": serialize_control_data(result.data)}
|
||
if result.status == "partial":
|
||
response["partial"] = True
|
||
return response
|
||
|
||
|
||
@allowlist_router.get(
|
||
"/{channel_type}/{account_id}/allowlist/{policy_type}/export",
|
||
response_model=dict,
|
||
)
|
||
async def export_whitelist(
|
||
channel_type: ChannelType,
|
||
account_id: str = _ACCOUNT_ID_PATH,
|
||
policy_type: str = _POLICY_TYPE_PATH,
|
||
request: Request = None,
|
||
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 = _ACCOUNT_ID_PATH,
|
||
policy_type: str = _POLICY_TYPE_PATH,
|
||
payload: WhitelistBatchDeleteRequest = ...,
|
||
request: Request = None,
|
||
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)
|
||
response: dict[str, Any] = {"success": True, "data": serialize_control_data(result.data)}
|
||
if result.status == "partial":
|
||
response["partial"] = True
|
||
return response
|
||
|
||
|
||
@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 = _ACCOUNT_ID_PATH,
|
||
policy_type: str = _POLICY_TYPE_PATH,
|
||
payload: WhitelistCleanExpiredRequest = ...,
|
||
request: Request = None,
|
||
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)
|
||
response: dict[str, Any] = {"success": True, "data": serialize_control_data(result.data)}
|
||
if result.status == "partial":
|
||
response["partial"] = True
|
||
return response
|
||
|
||
|
||
@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 = _ACCOUNT_ID_PATH,
|
||
policy_type: str = _POLICY_TYPE_PATH,
|
||
payload: WhitelistUpdateRequest = ...,
|
||
request: Request = None,
|
||
peer_id: str = _PEER_ID_PATH,
|
||
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 = _ACCOUNT_ID_PATH,
|
||
policy_type: str = _POLICY_TYPE_PATH,
|
||
request: Request = None,
|
||
peer_id: str = _PEER_ID_PATH,
|
||
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)}
|