ForcePilot/backend/server/routers/channels/pairing_router.py
Kris 2f6a4c29bb refactor(channel routers): 统一参数校验与常量定义,新增功能端点
1. 为渠道账户ID查询添加最小长度校验,统一分析模块常量引用
2. 新增扫码登录向导端点,完善文档说明
3. 优化配对统计接口,移除无效参数
4. 为出站箱接口添加批量上限与202状态码
5. 新增测试用例、访问规则、配额等模块的查询与校验参数
6. 新增适配器健康批量查询、健康检查触发接口
7. 统一告警、审计日志的错误处理方式
8. 新增插件配置账户ID支持,优化批量操作响应
9. 新增环境健康批量查询、Webhook限流与参数校验
10. 完善会话管理、审计日志的参数与文档说明
11. 修复导入模块的校验错误处理逻辑
2026-07-11 21:39:05 +08:00

423 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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