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

268 lines
12 KiB
Python
Raw Normal View History

"""路由绑定管理 RouterRB-01~RB-05
提供渠道-Agent 路由绑定规则的 5 HTTP 端点全部由 ``get_admin_user`` 守门
仅管理员可访问Router 不含任何业务逻辑仅做协议翻译 HTTP 请求参数组装
为契约层命令/参数调用 ``use_cases.route_binding_management`` 的类型化方法走
控制面管道再通过 ``raiseOnControlFailure`` 转译失败``serialize_control_data``
序列化成功结果模板 A
鉴权策略全部端点使用 ``get_admin_user`` 依赖要求管理员或超级管理员角色
角色校验由依赖函数完成router 内不做角色判断规范 §4
路径设计``/route-bindings`` ``/route-bindings/resolve`` 静态路径先于
``/route-bindings/{binding_id}`` 动态路径声明规范 §6.5避免 ``resolve``
被捕获为 binding_id router 不自行设置 prefix根前缀 ``/channels``
``channels_router`` 聚合 router 统一追加
端点清单
- GET /route-bindings RB-01 list 列出路由绑定规则
- POST /route-bindings RB-02 create 创建路由绑定规则
- POST /route-bindings/resolve RB-05 resolve 测试路由解析
- PUT /route-bindings/{binding_id} RB-03 update 更新路由绑定规则
- DELETE /route-bindings/{binding_id} RB-04 delete 删除路由绑定规则
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, Query, Request
from pydantic import BaseModel, ConfigDict, Field, model_validator
from yuxi.channels.contract.dtos.channel import ChannelType
from yuxi.channels.contract.dtos.route import (
BindingContext,
RouteBindingFilter,
SaveRouteBindingCmd,
UpdateRouteBindingCmd,
)
from yuxi.channels.contract.errors import ValidationError
from yuxi.storage.postgres.models_business import User
from server.routers.channels import (
build_operator,
get_channel_use_cases,
raiseOnControlFailure,
serialize_control_data,
)
from server.utils.auth_middleware import get_admin_user
route_binding_router = APIRouter(tags=["channels-route-binding"])
# ---------------- Request Schemas ----------------
class CreateRouteBindingRequest(BaseModel):
"""创建路由绑定规则请求。字段对齐 ``SaveRouteBindingCmd``。
``match_value`` ``match_source`` 的组合约束由 ``_validate_match_value``
校验``account`` / ``default`` tier 不允许携带 match_value``chat_type``
tier match_value 必须为 ``p2p`` ``group``同时拒绝当前运行时尚未
实现 matcher 的实验性 tier``channel_session`` / ``channel_type``
"""
model_config = ConfigDict(frozen=True)
channel_type: ChannelType = Field(..., description="渠道类型(必填)")
account_id: str = Field(..., min_length=1, description="渠道账户 ID")
match_source: str = Field(..., min_length=1, description="匹配来源tier 名)")
match_value: str | None = Field(default=None, description="匹配值(按 tier 约束)")
agent_binding: str = Field(..., min_length=1, description="Agent 绑定agent slug")
description: str | None = Field(default=None, description="描述")
#: 当前运行时已实现 matcher 的 tier 集合;其余 tier 创建规则会被拒绝。
#: 与 factory._registerDefaultMatchers / route_match_registry.applyRule 保持一致。
_SUPPORTED_MATCH_SOURCES: frozenset[str] = frozenset(
{"session_key", "identity_id", "peer_id", "chat_type", "account", "default"}
)
@model_validator(mode="after")
def _validate_match_value(self) -> CreateRouteBindingRequest:
"""校验 match_source 与 match_value 的组合约束。
- 不支持 ``channel_session`` / ``channel_type`` 等实验性 tier
运行时尚未注册 matcher创建规则会导致永久失效
- ``account`` / ``default`` tiermatch_value 必须为空
- ``chat_type`` tiermatch_value 必须为 ``p2p`` ``group``
"""
if self.match_source not in self._SUPPORTED_MATCH_SOURCES:
raise ValidationError(
"match_source",
f"match_source '{self.match_source}' is not supported by runtime matcher",
)
if self.match_source in ("account", "default"):
if self.match_value:
raise ValidationError(
"match_value",
f"match_value must be empty for match_source={self.match_source}",
)
elif self.match_source == "chat_type":
if self.match_value not in ("p2p", "group"):
raise ValidationError(
"match_value",
"match_value must be 'p2p' or 'group' for match_source=chat_type",
)
return self
class UpdateRouteBindingRequest(BaseModel):
"""更新路由绑定规则请求。所有业务字段可选,仅传入字段被更新。
字段语义未提供不在请求体中表示不修改 ``UpdateRouteBindingCmd``
``None`` 默认值表示"不更新"``binding_id`` 由路径参数提供不在请求体中
``version`` 必填取自上次加载GET/POST 返回的 ``version``用于乐观锁
校验服务端持久化版本不一致时返回 409客户端需重新拉取后重试
"""
model_config = ConfigDict(frozen=True)
version: int = Field(..., description="乐观锁版本号(加载时获取,更新时回传)")
match_value: str | None = Field(default=None, description="匹配值")
agent_binding: str | None = Field(default=None, description="Agent 绑定agent slug")
enabled: bool | None = Field(default=None, description="是否启用")
description: str | None = Field(default=None, description="描述")
class ResolveRouteBindingRequest(BaseModel):
"""测试路由解析请求。字段对齐 ``BindingContext``。"""
model_config = ConfigDict(frozen=True)
session_key: str = Field(..., min_length=1, description="会话键")
channel_type: ChannelType = Field(..., description="渠道类型")
account_id: str = Field(..., min_length=1, description="渠道账户 ID")
chat_type: str = Field(..., description="会话类型p2p | group")
peer_id: str = Field(..., min_length=1, description="对端 ID")
unified_identity_id: str | None = Field(default=None, description="统一身份 ID")
conversation_id: str | None = Field(default=None, description="内部会话 ID")
# ---------------- Endpoints ----------------
@route_binding_router.get("/route-bindings", response_model=dict)
async def list_route_bindings(
request: Request,
channel_type: ChannelType | None = Query(default=None, description="按渠道类型过滤"),
account_id: str | None = Query(default=None, description="按账户 ID 过滤"),
match_source: str | None = Query(default=None, description="按匹配来源过滤"),
enabled: bool | None = Query(default=None, description="按启用状态过滤"),
agent_binding: str | None = Query(default=None, description="按绑定 Agent 过滤"),
limit: int = Query(default=20, 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]:
"""列出路由绑定规则RB-01
支持按渠道类型 / 账户 / 匹配来源 / 启用状态 / 绑定 Agent 过滤分页返回
对应控制面操作 ``route_binding/list``
"""
operator = build_operator(current_user, request)
filter = RouteBindingFilter(
channel_type=channel_type,
account_id=account_id,
match_source=match_source,
enabled=enabled,
agent_binding=agent_binding,
)
result = await use_cases.route_binding_management.listRouteBindings(operator, filter, limit=limit, offset=offset)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@route_binding_router.post("/route-bindings", response_model=dict)
async def create_route_binding(
payload: CreateRouteBindingRequest,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""创建路由绑定规则RB-02
对应控制面操作 ``route_binding/create``
"""
operator = build_operator(current_user, request)
cmd = SaveRouteBindingCmd(
channel_type=payload.channel_type,
account_id=payload.account_id,
match_source=payload.match_source,
match_value=payload.match_value,
agent_binding=payload.agent_binding,
description=payload.description,
)
result = await use_cases.route_binding_management.createRouteBinding(operator, cmd)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@route_binding_router.post("/route-bindings/resolve", response_model=dict)
async def resolve_route_binding(
payload: ResolveRouteBindingRequest,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""测试路由解析RB-05
传入 ``BindingContext`` 字段返回匹配到的路由绑定规则含匹配来源
命中层级等诊断元数据对应控制面操作 ``route_binding/resolve``
"""
operator = build_operator(current_user, request)
ctx = BindingContext(
session_key=payload.session_key,
channel_type=payload.channel_type,
account_id=payload.account_id,
chat_type=payload.chat_type,
peer_id=payload.peer_id,
unified_identity_id=payload.unified_identity_id,
conversation_id=payload.conversation_id,
)
result = await use_cases.route_binding_management.resolveRouteBinding(operator, ctx)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@route_binding_router.put("/route-bindings/{binding_id}", response_model=dict)
async def update_route_binding(
binding_id: str,
payload: UpdateRouteBindingRequest,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""更新路由绑定规则RB-03
``binding_id`` 由路径参数提供请求体字段全部可选仅传入字段被更新
对应控制面操作 ``route_binding/update``
"""
operator = build_operator(current_user, request)
cmd = UpdateRouteBindingCmd(
binding_id=binding_id,
expected_version=payload.version,
match_value=payload.match_value,
agent_binding=payload.agent_binding,
enabled=payload.enabled,
description=payload.description,
)
result = await use_cases.route_binding_management.updateRouteBinding(operator, cmd)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@route_binding_router.delete("/route-bindings/{binding_id}", response_model=dict)
async def delete_route_binding(
binding_id: str,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""删除路由绑定规则RB-04
对应控制面操作 ``route_binding/delete``
"""
operator = build_operator(current_user, request)
result = await use_cases.route_binding_management.deleteRouteBinding(operator, binding_id)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}