ForcePilot/backend/server/routers/channels/pairing_router.py

420 lines
17 KiB
Python
Raw Normal View History

"""配对审批域 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-33PRG-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-33PRG-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)}