1. 为渠道账户ID查询添加最小长度校验,统一分析模块常量引用 2. 新增扫码登录向导端点,完善文档说明 3. 优化配对统计接口,移除无效参数 4. 为出站箱接口添加批量上限与202状态码 5. 新增测试用例、访问规则、配额等模块的查询与校验参数 6. 新增适配器健康批量查询、健康检查触发接口 7. 统一告警、审计日志的错误处理方式 8. 新增插件配置账户ID支持,优化批量操作响应 9. 新增环境健康批量查询、Webhook限流与参数校验 10. 完善会话管理、审计日志的参数与文档说明 11. 修复导入模块的校验错误处理逻辑
278 lines
12 KiB
Python
278 lines
12 KiB
Python
"""路由绑定管理 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 (
|
||
DEFAULT_LIMIT,
|
||
OFFSET,
|
||
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)")
|
||
enabled: bool = Field(default=True, description="是否启用(默认 True)")
|
||
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`` 由路径参数提供,不在请求体中。
|
||
``version`` 必填,取自上次加载(GET/POST 返回的 ``version``),用于乐观锁
|
||
校验:服务端持久化版本不一致时返回 409,客户端需重新拉取后重试。
|
||
|
||
``match_value`` 更新时需满足现有 ``match_source`` 的组合约束(与创建
|
||
一致,由 handler 层校验):``account`` / ``default`` tier 不允许携带
|
||
match_value;``chat_type`` tier 的 match_value 必须为 ``p2p`` 或 ``group``。
|
||
"""
|
||
|
||
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="按匹配来源过滤"),
|
||
match_value: str | None = Query(default=None, description="按匹配值过滤"),
|
||
enabled: bool | None = Query(default=None, description="按启用状态过滤"),
|
||
agent_binding: str | None = Query(default=None, description="按绑定 Agent 过滤"),
|
||
limit: int = DEFAULT_LIMIT,
|
||
offset: int = OFFSET,
|
||
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,
|
||
match_value=match_value,
|
||
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,
|
||
enabled=payload.enabled,
|
||
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)}
|