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