1. 为渠道账户ID查询添加最小长度校验,统一分析模块常量引用 2. 新增扫码登录向导端点,完善文档说明 3. 优化配对统计接口,移除无效参数 4. 为出站箱接口添加批量上限与202状态码 5. 新增测试用例、访问规则、配额等模块的查询与校验参数 6. 新增适配器健康批量查询、健康检查触发接口 7. 统一告警、审计日志的错误处理方式 8. 新增插件配置账户ID支持,优化批量操作响应 9. 新增环境健康批量查询、Webhook限流与参数校验 10. 完善会话管理、审计日志的参数与文档说明 11. 修复导入模块的校验错误处理逻辑
423 lines
17 KiB
Python
423 lines
17 KiB
Python
"""配对审批域 Router。
|
||
|
||
提供 DM 安全配对审批的 11 个 HTTP 端点,全部由 ``get_admin_user`` 守门,仅
|
||
管理员可访问。Router 不含任何业务逻辑,仅做协议翻译:将 HTTP 请求参数组装
|
||
为契约层命令/参数,调用 ``use_cases.pairing_management`` 的类型化方法走控制
|
||
面管道,再通过 ``raiseOnControlFailure`` 转译失败、``serialize_control_data``
|
||
序列化成功结果(模板 A)。
|
||
|
||
端点清单:
|
||
- GET /pairings PRG-QUERY-01 列出配对审批记录
|
||
- GET /pairings/count PRG-COUNT 统计配对记录数
|
||
- POST /pairings PRG-CREATE-01 发起配对请求
|
||
- POST /pairings/batch-approve PRG-BATCH-ACT 批量批准配对
|
||
- POST /pairings/batch-reject PRG-BATCH-ACT 批量拒绝配对
|
||
- POST /pairings/clean-expired PRG-CLEAN-EXPIRED 清理过期配对
|
||
- GET /pairings/stats PRG-STATS 配对统计
|
||
- GET /pairings/{pairing_id} PRG-01 查询配对详情
|
||
- POST /pairings/{pairing_id}/approve PRG-ACT-01 批准配对(FR-33)
|
||
- POST /pairings/{pairing_id}/reject PRG-ACT-02 拒绝配对
|
||
- POST /pairings/{pairing_id}/revoke PRG-ACT-03 撤销配对
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from typing import Any
|
||
|
||
from fastapi import APIRouter, Body, Depends, Query, Request
|
||
from pydantic import BaseModel, ConfigDict, Field
|
||
from yuxi.channels.contract.dtos.channel import ChannelType
|
||
from yuxi.channels.contract.dtos.pairing import (
|
||
BatchPairingCmd,
|
||
CleanExpiredPairingsCmd,
|
||
PairingQuery,
|
||
PairingStatsQuery,
|
||
PairingStatus,
|
||
)
|
||
from yuxi.storage.postgres.models_business import User
|
||
|
||
from server.routers.channels import (
|
||
Granularity,
|
||
LARGE_LIMIT,
|
||
OFFSET,
|
||
build_operator,
|
||
get_channel_use_cases,
|
||
parse_datetime,
|
||
raiseOnControlFailure,
|
||
serialize_control_data,
|
||
)
|
||
from server.utils.auth_middleware import get_admin_user
|
||
|
||
pairing_router = APIRouter(tags=["channels-pairing"])
|
||
|
||
|
||
# ---------------- Request Schemas ----------------
|
||
|
||
|
||
class CreatePairingRequest(BaseModel):
|
||
"""发起配对请求的请求体。"""
|
||
|
||
model_config = ConfigDict(frozen=True)
|
||
|
||
channel_type: ChannelType = Field(..., description="渠道类型(必填)")
|
||
account_id: str = Field(..., min_length=1, description="渠道账户 ID")
|
||
peer_id: str = Field(..., min_length=1, description="对端 ID")
|
||
peer_name: str | None = Field(default=None, max_length=128, description="对端名称")
|
||
expires_in_seconds: int = Field(default=604800, ge=1, le=2592000, description="配对有效期(秒,默认7天)")
|
||
|
||
|
||
class PairingActionRequest(BaseModel):
|
||
"""配对审批操作(批准/拒绝)的请求体。"""
|
||
|
||
model_config = ConfigDict(frozen=True)
|
||
|
||
approver_id: str | None = Field(default=None, min_length=1, description="审批人 ID(缺省取 operator.user_id)")
|
||
reason: str | None = Field(default=None, max_length=512, description="操作原因(审计用)")
|
||
|
||
|
||
class PairingRevokeRequest(BaseModel):
|
||
"""配对撤销操作的请求体。"""
|
||
|
||
model_config = ConfigDict(frozen=True)
|
||
|
||
reason: str | None = Field(default=None, max_length=512, description="撤销原因(审计用)")
|
||
|
||
|
||
class BatchPairingActionRequest(BaseModel):
|
||
"""批量配对审批操作(批准/拒绝)的请求体(PRG-BATCH-ACT)。
|
||
|
||
``pairing_ids`` 至少 1 条、最多 500 条,避免单次请求过大。
|
||
"""
|
||
|
||
model_config = ConfigDict(frozen=True)
|
||
|
||
pairing_ids: list[str] = Field(..., min_length=1, max_length=500, description="配对 ID 列表(1-500 条)")
|
||
reason: str | None = Field(default=None, max_length=512, description="操作原因(审计用)")
|
||
|
||
|
||
class CleanExpiredPairingsRequest(BaseModel):
|
||
"""清理过期配对的请求体(PRG-CLEAN-EXPIRED)。
|
||
|
||
``max_count`` 限定单次清理规模(1-1000,默认 500),``older_than`` 可选
|
||
过期时间上界(ISO 8601,缺省取当前时间)。
|
||
"""
|
||
|
||
model_config = ConfigDict(frozen=True)
|
||
|
||
max_count: int = Field(default=500, ge=1, le=1000, description="单次最大清理数(1-1000,默认 500)")
|
||
older_than: str | None = Field(default=None, description="过期时间上界(ISO 8601,缺省取当前时间)")
|
||
|
||
|
||
# ---------------- Endpoints ----------------
|
||
|
||
|
||
@pairing_router.get("/pairings", response_model=dict)
|
||
async def list_pairings(
|
||
request: Request,
|
||
channel_type: ChannelType | None = Query(default=None, description="按渠道类型过滤"),
|
||
account_id: str | None = Query(default=None, description="按账户 ID 过滤"),
|
||
peer_id: str | None = Query(default=None, description="按对端 ID 过滤"),
|
||
status: PairingStatus | None = Query(default=None, description="按状态过滤"),
|
||
created_after: str | None = Query(default=None, description="申请时间下界(ISO 8601,含,按 requested_at 过滤)"),
|
||
created_before: str | None = Query(default=None, description="申请时间上界(ISO 8601,含,按 requested_at 过滤)"),
|
||
limit: int = LARGE_LIMIT,
|
||
offset: int = OFFSET,
|
||
use_cases=Depends(get_channel_use_cases),
|
||
current_user: User = Depends(get_admin_user),
|
||
) -> dict[str, Any]:
|
||
"""列出配对审批记录(FR-33,PRG-QUERY-01)。
|
||
|
||
支持按渠道/账户/对端/状态过滤,按 requested_at 倒序返回。
|
||
对应控制面操作 ``pairing/list``。
|
||
"""
|
||
operator = build_operator(current_user, request)
|
||
created_after_dt = parse_datetime("created_after", created_after)
|
||
created_before_dt = parse_datetime("created_before", created_before)
|
||
query = PairingQuery(
|
||
channel_type=channel_type,
|
||
account_id=account_id,
|
||
peer_id=peer_id,
|
||
status=status,
|
||
created_after=created_after_dt,
|
||
created_before=created_before_dt,
|
||
limit=limit,
|
||
offset=offset,
|
||
)
|
||
result = await use_cases.pairing_management.listPairings(query, operator)
|
||
raiseOnControlFailure(result)
|
||
return {"success": True, "data": serialize_control_data(result.data)}
|
||
|
||
|
||
@pairing_router.get("/pairings/count", response_model=dict)
|
||
async def count_pairings(
|
||
request: Request,
|
||
channel_type: ChannelType | None = Query(default=None, description="按渠道类型过滤"),
|
||
account_id: str | None = Query(default=None, description="按账户 ID 过滤"),
|
||
peer_id: str | None = Query(default=None, description="按对端 ID 过滤"),
|
||
status: PairingStatus | None = Query(default=None, description="按状态过滤"),
|
||
created_after: str | None = Query(default=None, description="申请时间下界(ISO 8601,含,按 requested_at 过滤)"),
|
||
created_before: str | None = Query(default=None, description="申请时间上界(ISO 8601,含,按 requested_at 过滤)"),
|
||
use_cases=Depends(get_channel_use_cases),
|
||
current_user: User = Depends(get_admin_user),
|
||
) -> dict[str, Any]:
|
||
"""统计配对审批记录数(FR-33 待办角标)。
|
||
|
||
支持按渠道/账户/对端/状态过滤,返回单个整数计数。
|
||
对应控制面操作 ``pairing/count``。
|
||
"""
|
||
operator = build_operator(current_user, request)
|
||
created_after_dt = parse_datetime("created_after", created_after)
|
||
created_before_dt = parse_datetime("created_before", created_before)
|
||
# count 仅消费过滤字段,limit/offset 由 use case 与 dispatch handler 忽略,
|
||
# 此处不传入以避免误导维护者认为 count 会取行数据。
|
||
query = PairingQuery(
|
||
channel_type=channel_type,
|
||
account_id=account_id,
|
||
peer_id=peer_id,
|
||
status=status,
|
||
created_after=created_after_dt,
|
||
created_before=created_before_dt,
|
||
)
|
||
result = await use_cases.pairing_management.countPairings(query, operator)
|
||
raiseOnControlFailure(result)
|
||
return {"success": True, "data": serialize_control_data(result.data)}
|
||
|
||
|
||
@pairing_router.post("/pairings", response_model=dict)
|
||
async def create_pairing(
|
||
payload: CreatePairingRequest,
|
||
request: Request,
|
||
use_cases=Depends(get_channel_use_cases),
|
||
current_user: User = Depends(get_admin_user),
|
||
) -> dict[str, Any]:
|
||
"""发起配对请求(PRG-CREATE-01)。
|
||
|
||
创建 DM 安全配对请求,等待审批流程。
|
||
对应控制面操作 ``pairing/create``。
|
||
"""
|
||
operator = build_operator(current_user, request)
|
||
result = await use_cases.pairing_management.createPairing(
|
||
channel_type=payload.channel_type,
|
||
account_id=payload.account_id,
|
||
peer_id=payload.peer_id,
|
||
operator=operator,
|
||
peer_name=payload.peer_name,
|
||
expires_in_seconds=payload.expires_in_seconds,
|
||
)
|
||
raiseOnControlFailure(result)
|
||
return {"success": True, "data": serialize_control_data(result.data)}
|
||
|
||
|
||
@pairing_router.post("/pairings/batch-approve", response_model=dict)
|
||
async def batch_approve_pairings(
|
||
payload: BatchPairingActionRequest,
|
||
request: Request,
|
||
use_cases=Depends(get_channel_use_cases),
|
||
current_user: User = Depends(get_admin_user),
|
||
) -> dict[str, Any]:
|
||
"""批量批准 DM 安全配对(PRG-BATCH-ACT)。
|
||
|
||
逐条独立事务复用单条 approve 逻辑(含降级检查 FR-36),非 PENDING 配对
|
||
逐条记入 failed。对应控制面操作 ``pairing/batch_approve``。
|
||
|
||
响应:``{total, succeeded: [{pairing_id, status}], failed: [{id, error_code, message}]}``。
|
||
"""
|
||
operator = build_operator(current_user, request)
|
||
cmd = BatchPairingCmd(
|
||
pairing_ids=tuple(payload.pairing_ids),
|
||
operator=operator,
|
||
reason=payload.reason,
|
||
)
|
||
result = await use_cases.pairing_management.batchApprovePairings(cmd)
|
||
raiseOnControlFailure(result)
|
||
return {"success": True, "data": serialize_control_data(result.data)}
|
||
|
||
|
||
@pairing_router.post("/pairings/batch-reject", response_model=dict)
|
||
async def batch_reject_pairings(
|
||
payload: BatchPairingActionRequest,
|
||
request: Request,
|
||
use_cases=Depends(get_channel_use_cases),
|
||
current_user: User = Depends(get_admin_user),
|
||
) -> dict[str, Any]:
|
||
"""批量拒绝 DM 安全配对(PRG-BATCH-ACT)。
|
||
|
||
逐条独立事务复用单条 reject 逻辑(含降级检查 FR-36),非 PENDING 配对
|
||
逐条记入 failed。对应控制面操作 ``pairing/batch_reject``。
|
||
|
||
响应:``{total, succeeded: [{pairing_id, status}], failed: [{id, error_code, message}]}``。
|
||
"""
|
||
operator = build_operator(current_user, request)
|
||
cmd = BatchPairingCmd(
|
||
pairing_ids=tuple(payload.pairing_ids),
|
||
operator=operator,
|
||
reason=payload.reason,
|
||
)
|
||
result = await use_cases.pairing_management.batchRejectPairings(cmd)
|
||
raiseOnControlFailure(result)
|
||
return {"success": True, "data": serialize_control_data(result.data)}
|
||
|
||
|
||
@pairing_router.post("/pairings/clean-expired", response_model=dict)
|
||
async def clean_expired_pairings(
|
||
payload: CleanExpiredPairingsRequest,
|
||
request: Request,
|
||
use_cases=Depends(get_channel_use_cases),
|
||
current_user: User = Depends(get_admin_user),
|
||
) -> dict[str, Any]:
|
||
"""清理过期 DM 安全配对(PRG-CLEAN-EXPIRED)。
|
||
|
||
查询过期 PENDING 配对,单一事务内批量更新为 EXPIRED 状态并发布
|
||
``PairingExpired`` 事件(COOPERATIVE fail-closed,单条失败回滚整批)。
|
||
对应控制面操作 ``pairing/clean_expired``。
|
||
|
||
响应:``{total, cleaned: [pairing_id, ...]}``。
|
||
"""
|
||
operator = build_operator(current_user, request)
|
||
cmd = CleanExpiredPairingsCmd(
|
||
operator=operator,
|
||
older_than=parse_datetime("older_than", payload.older_than),
|
||
max_count=payload.max_count,
|
||
)
|
||
result = await use_cases.pairing_management.cleanExpiredPairings(cmd)
|
||
raiseOnControlFailure(result)
|
||
return {"success": True, "data": serialize_control_data(result.data)}
|
||
|
||
|
||
@pairing_router.get("/pairings/stats", response_model=dict)
|
||
async def get_pairing_stats(
|
||
request: Request,
|
||
channel_type: ChannelType | None = Query(default=None, description="按渠道类型过滤"),
|
||
start_time: str | None = Query(
|
||
default=None,
|
||
description="起始时间过滤(ISO 8601,含)",
|
||
),
|
||
end_time: str | None = Query(
|
||
default=None,
|
||
description="结束时间过滤(ISO 8601,含)",
|
||
),
|
||
granularity: Granularity = Query(default="day", description="时间粒度"),
|
||
use_cases=Depends(get_channel_use_cases),
|
||
current_user: User = Depends(get_admin_user),
|
||
) -> dict[str, Any]:
|
||
"""配对审批统计(PRG-STATS)。
|
||
|
||
对应控制面操作 ``pairing/stats``(由 ``ChannelControlService.getPairingStats``
|
||
内部构造 ``ControlCmd`` 并委托 ``_executeControl`` 执行控制面管道)。
|
||
聚合配对审批记录,计算申请/批准/拒绝/撤销/过期计数、批准率、平均审批
|
||
时长与按时间粒度切片的趋势。``start_time`` / ``end_time`` 可选,
|
||
同时提供时需满足 ``start_time < end_time``。
|
||
|
||
编排链路:HTTP 入参 → ``build_operator`` 构造操作人 → 协议翻译
|
||
(``parse_datetime``)→ 构造 ``PairingStatsQuery`` →
|
||
``use_cases.pairing_management.getPairingStats`` → ``raiseOnControlFailure``
|
||
转译失败 → ``serialize_control_data`` 序列化统计结果返回。
|
||
"""
|
||
operator = build_operator(current_user, request)
|
||
query = PairingStatsQuery(
|
||
channel_type=channel_type,
|
||
start_time=parse_datetime("start_time", start_time),
|
||
end_time=parse_datetime("end_time", end_time),
|
||
granularity=granularity,
|
||
)
|
||
result = await use_cases.pairing_management.getPairingStats(query=query, operator=operator)
|
||
raiseOnControlFailure(result)
|
||
return {"success": True, "data": serialize_control_data(result.data)}
|
||
|
||
|
||
@pairing_router.get("/pairings/{pairing_id}", response_model=dict)
|
||
async def get_pairing(
|
||
pairing_id: str,
|
||
request: Request,
|
||
use_cases=Depends(get_channel_use_cases),
|
||
current_user: User = Depends(get_admin_user),
|
||
) -> dict[str, Any]:
|
||
"""按 pairing_id 查询配对详情(PRG-01)。
|
||
|
||
供管理员审批前核对配对信息。对应控制面操作 ``pairing/get``。
|
||
"""
|
||
operator = build_operator(current_user, request)
|
||
result = await use_cases.pairing_management.getPairing(
|
||
pairing_id=pairing_id,
|
||
operator=operator,
|
||
)
|
||
raiseOnControlFailure(result)
|
||
return {"success": True, "data": serialize_control_data(result.data)}
|
||
|
||
|
||
@pairing_router.post("/pairings/{pairing_id}/approve", response_model=dict)
|
||
async def approve_pairing(
|
||
pairing_id: str,
|
||
request: Request,
|
||
payload: PairingActionRequest | None = Body(default=None),
|
||
use_cases=Depends(get_channel_use_cases),
|
||
current_user: User = Depends(get_admin_user),
|
||
) -> dict[str, Any]:
|
||
"""批准 DM 安全配对(FR-33,PRG-ACT-01)。
|
||
|
||
仅 PENDING 状态可批准;过期配对先惰性转为 EXPIRED 再抛 RuleViolationError;
|
||
降级期间渠道的配对审批暂停(FR-36)返回 503。
|
||
对应控制面操作 ``pairing/approve``。
|
||
"""
|
||
operator = build_operator(current_user, request)
|
||
approver_id = payload.approver_id if payload else None
|
||
reason = payload.reason if payload else None
|
||
result = await use_cases.pairing_management.approvePairing(
|
||
pairing_id=pairing_id,
|
||
operator=operator,
|
||
approver_id=approver_id,
|
||
reason=reason,
|
||
)
|
||
raiseOnControlFailure(result)
|
||
return {"success": True, "data": serialize_control_data(result.data)}
|
||
|
||
|
||
@pairing_router.post("/pairings/{pairing_id}/reject", response_model=dict)
|
||
async def reject_pairing(
|
||
pairing_id: str,
|
||
request: Request,
|
||
payload: PairingActionRequest | None = Body(default=None),
|
||
use_cases=Depends(get_channel_use_cases),
|
||
current_user: User = Depends(get_admin_user),
|
||
) -> dict[str, Any]:
|
||
"""拒绝配对(PRG-ACT-02)。
|
||
|
||
仅 PENDING 状态可拒绝;reason 缺省填充 "rejected by operator"。
|
||
对应控制面操作 ``pairing/reject``。
|
||
"""
|
||
operator = build_operator(current_user, request)
|
||
approver_id = payload.approver_id if payload else None
|
||
reason = payload.reason if payload else None
|
||
result = await use_cases.pairing_management.rejectPairing(
|
||
pairing_id=pairing_id,
|
||
operator=operator,
|
||
approver_id=approver_id,
|
||
reason=reason,
|
||
)
|
||
raiseOnControlFailure(result)
|
||
return {"success": True, "data": serialize_control_data(result.data)}
|
||
|
||
|
||
@pairing_router.post("/pairings/{pairing_id}/revoke", response_model=dict)
|
||
async def revoke_pairing(
|
||
pairing_id: str,
|
||
request: Request,
|
||
payload: PairingRevokeRequest | None = Body(default=None),
|
||
use_cases=Depends(get_channel_use_cases),
|
||
current_user: User = Depends(get_admin_user),
|
||
) -> dict[str, Any]:
|
||
"""撤销已批准配对(PRG-ACT-03)。
|
||
|
||
仅 APPROVED 状态可撤销;reason 缺省填充 "revoked by {operator.user_id}"。
|
||
对应控制面操作 ``pairing/revoke``。
|
||
"""
|
||
operator = build_operator(current_user, request)
|
||
reason = payload.reason if payload else None
|
||
result = await use_cases.pairing_management.revokePairing(
|
||
pairing_id=pairing_id,
|
||
operator=operator,
|
||
reason=reason,
|
||
)
|
||
raiseOnControlFailure(result)
|
||
return {"success": True, "data": serialize_control_data(result.data)}
|