ForcePilot/backend/server/routers/channels/pairing_router.py
Kris e5e9f45411 refactor(channel-routers): 批量优化各渠道路由代码与契约对齐
1. config_router: 为expected_version添加ge=1校验
2. directory_router: 补充scope校验逻辑与注释
3. login_router: 拆分强制下线权限,添加参数校验与注释更新
4. reports_router: 统一时间参数处理,修复分页限制使用契约常量
5. dashboard_router: 更新文档与响应格式,修正参数传递逻辑
6. health_router: 缩减健康检查响应字段,修复响应结构与参数校验
7. plugin_router: 新增插件目录端点,补充枚举校验与注释
8. pairing_router: 新增时间过滤参数,补充参数校验
9. __init__.py: 修复异常映射,更新trace_id获取逻辑与工具类
10. doctor_router: 重构单项检查端点,修正注释与校验逻辑
11. account_router: 新增恢复降级账户端点,补充批量操作校验
12. webhook_router: 优化webhook处理逻辑,修复流式读取与响应逻辑
13. content_review_router: 补充批量审核端点,完善参数校验与注释
14. analytics_router: 修正管道阶段描述,统一参数传递
15. wizard_router: 新增OAuth相关端点,重构路由路径与校验逻辑
2026-07-04 00:16:00 +08:00

386 lines
15 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 安全配对审批的 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, Path, 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.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)}