"""白名单域 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 {}, }