1. 移除多个导出接口的显式response_model声明 2. 调整access_rule和test_case的创建接口位置,修复静态路径冲突 3. 优化适配器配置校验的异常处理逻辑 4. 重构集成路由的查询逻辑,统一使用get_integration_or_raise 5. 新增channels路由组下的capability、reports、dashboard、webhook、wizard、doctor、directory、session共8个子路由模块 6. 注册channels_router到全局路由列表
458 lines
16 KiB
Python
458 lines
16 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 统一追加。
|
||
|
||
端点清单(对应本设计方案 §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 8601),None=不修改保留原值",
|
||
)
|
||
|
||
|
||
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`` 为路径参数,不可变更。
|
||
条目不存在时返回 404(AC-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`` 为路径参数,联合定位唯一条目。
|
||
条目不存在时返回 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) if result.data else {},
|
||
}
|