"""路由绑定管理 Router(RB-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`` tier:match_value 必须为空。 - ``chat_type`` tier:match_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`` 由路径参数提供,不在请求体中。 """ model_config = ConfigDict(frozen=True) 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, 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)}