这一批提交包含: 1. 配置项敏感字段标记与测试用例修复 2. 路由绑定乐观锁支持与静态路径校验 3. 微信WOC插件能力适配与新增单元测试 4. 多个适配器的接口对齐与测试补全 5. 新增定时任务清理处理器与依赖注入容器测试 6. 错误码体系扩展与整合测试 7. 删除临时验证脚本与代码清理
268 lines
12 KiB
Python
268 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 (
|
||
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`` 由路径参数提供,不在请求体中。
|
||
``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)}
|