本次提交包含多类优化:
1. 移除多个路由文件中多余的空导入行,统一代码格式
2. 重构Query参数定义,将长参数拆分为多行提升可读性
3. 新增多个业务端点:
- 渠道能力画像矩阵查询CAP-03
- 配对审批计数接口用于待办角标
- 批量查询对端目录资料接口
- 向导扫码登录相关端点
- 会话实时事件SSE推送端点
- 工作台待办统计接口
4. 完善异常处理逻辑,补充OperationTimeoutError导入并优化NotImplementedError的细节返回
5. 调整路由导入顺序,修复动态路由路径冲突隐患
6. 更新文档注释与接口清单,修正部分接口描述细节
420 lines
17 KiB
Python
420 lines
17 KiB
Python
"""配对审批域 Router。
|
||
|
||
提供 DM 安全配对审批的 10 个 HTTP 端点,全部由 ``get_admin_user`` 守门,仅
|
||
管理员可访问。Router 不含任何业务逻辑,仅做协议翻译:将 HTTP 请求参数组装
|
||
为契约层命令/参数,调用 ``use_cases.pairing_management`` 的类型化方法走控制
|
||
面管道,再通过 ``raiseOnControlFailure`` 转译失败、``serialize_control_data``
|
||
序列化成功结果(模板 A)。
|
||
|
||
端点清单:
|
||
- GET /pairings PRG-QUERY-01 列出配对审批记录
|
||
- 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,
|
||
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 = Query(default=100, ge=1, le=1000, description="分页大小"),
|
||
offset: int = Query(default=0, ge=0, description="分页偏移"),
|
||
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)
|
||
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=1,
|
||
offset=0,
|
||
)
|
||
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)}
|