From fee71576a27fcf92eaaea6059a53f307c640c377 Mon Sep 17 00:00:00 2001 From: Kris <2893855659@qq.com> Date: Tue, 12 May 2026 00:54:15 +0800 Subject: [PATCH] feat(gateway): add core gateway protocol implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新建了完整的网关协议模块,包含版本协商、基础类型定义、信道请求模型、错误码规范、通信帧结构以及握手流程,实现了完整的客户端-服务端通信协议基础框架 --- backend/package/yuxi/gateway/__init__.py | 39 ++++++++++ .../package/yuxi/gateway/protocol/__init__.py | 37 +++++++++ .../package/yuxi/gateway/protocol/channels.py | 19 +++++ .../package/yuxi/gateway/protocol/errors.py | 27 +++++++ .../package/yuxi/gateway/protocol/frames.py | 36 +++++++++ .../yuxi/gateway/protocol/handshake.py | 75 +++++++++++++++++++ .../yuxi/gateway/protocol/primitives.py | 14 ++++ .../package/yuxi/gateway/protocol/version.py | 13 ++++ 8 files changed, 260 insertions(+) create mode 100644 backend/package/yuxi/gateway/__init__.py create mode 100644 backend/package/yuxi/gateway/protocol/__init__.py create mode 100644 backend/package/yuxi/gateway/protocol/channels.py create mode 100644 backend/package/yuxi/gateway/protocol/errors.py create mode 100644 backend/package/yuxi/gateway/protocol/frames.py create mode 100644 backend/package/yuxi/gateway/protocol/handshake.py create mode 100644 backend/package/yuxi/gateway/protocol/primitives.py create mode 100644 backend/package/yuxi/gateway/protocol/version.py diff --git a/backend/package/yuxi/gateway/__init__.py b/backend/package/yuxi/gateway/__init__.py new file mode 100644 index 00000000..d74c03e4 --- /dev/null +++ b/backend/package/yuxi/gateway/__init__.py @@ -0,0 +1,39 @@ +from yuxi.gateway.protocol import ( + ChannelLogoutRequest, + ChannelStartRequest, + ChannelStatusRequest, + ConnectParams, + ErrorCode, + ErrorDetail, + EventFrame, + GatewayFrame, + HelloFeatures, + HelloOk, + NonEmptyString, + PositiveInt, + PROTOCOL_VERSION, + RequestFrame, + ResponseFrame, + handle_handshake, + negotiate_version, +) + +__all__ = [ + "RequestFrame", + "ResponseFrame", + "EventFrame", + "GatewayFrame", + "ErrorCode", + "ErrorDetail", + "PROTOCOL_VERSION", + "negotiate_version", + "ConnectParams", + "HelloFeatures", + "HelloOk", + "handle_handshake", + "NonEmptyString", + "PositiveInt", + "ChannelStatusRequest", + "ChannelStartRequest", + "ChannelLogoutRequest", +] diff --git a/backend/package/yuxi/gateway/protocol/__init__.py b/backend/package/yuxi/gateway/protocol/__init__.py new file mode 100644 index 00000000..7c290095 --- /dev/null +++ b/backend/package/yuxi/gateway/protocol/__init__.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from typing import Annotated + +from pydantic import Field + +from yuxi.gateway.protocol.channels import ChannelLogoutRequest, ChannelStartRequest, ChannelStatusRequest +from yuxi.gateway.protocol.errors import ErrorCode, ErrorDetail +from yuxi.gateway.protocol.frames import EventFrame, RequestFrame, ResponseFrame +from yuxi.gateway.protocol.handshake import ConnectParams, HelloFeatures, HelloOk, handle_handshake +from yuxi.gateway.protocol.primitives import NonEmptyString, PositiveInt +from yuxi.gateway.protocol.version import PROTOCOL_VERSION, negotiate_version + +GatewayFrame = Annotated[ + RequestFrame | ResponseFrame | EventFrame, + Field(discriminator="type"), +] + +__all__ = [ + "RequestFrame", + "ResponseFrame", + "EventFrame", + "GatewayFrame", + "ErrorCode", + "ErrorDetail", + "PROTOCOL_VERSION", + "negotiate_version", + "ConnectParams", + "HelloFeatures", + "HelloOk", + "handle_handshake", + "NonEmptyString", + "PositiveInt", + "ChannelStatusRequest", + "ChannelStartRequest", + "ChannelLogoutRequest", +] diff --git a/backend/package/yuxi/gateway/protocol/channels.py b/backend/package/yuxi/gateway/protocol/channels.py new file mode 100644 index 00000000..30ff3b9f --- /dev/null +++ b/backend/package/yuxi/gateway/protocol/channels.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from pydantic import BaseModel + + +class ChannelStatusRequest(BaseModel): + channel_id: str | None = None + account_id: str | None = None + + +class ChannelStartRequest(BaseModel): + channel_id: str + account_id: str | None = None + + +class ChannelLogoutRequest(BaseModel): + channel_id: str + account_id: str | None = None + logged_out: bool = True diff --git a/backend/package/yuxi/gateway/protocol/errors.py b/backend/package/yuxi/gateway/protocol/errors.py new file mode 100644 index 00000000..adf2d1d0 --- /dev/null +++ b/backend/package/yuxi/gateway/protocol/errors.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from enum import IntEnum + +from pydantic import BaseModel + + +class ErrorCode(IntEnum): + INVALID_REQUEST = -32600 + METHOD_NOT_FOUND = -32601 + INVALID_PARAMS = -32602 + INTERNAL_ERROR = -32603 + RATE_LIMITED = -32000 + AUTH_REQUIRED = -32001 + NOT_LINKED = -32002 + NOT_PAIRED = -32003 + AGENT_TIMEOUT = -32004 + APPROVAL_NOT_FOUND = -32005 + UNAVAILABLE = -32006 + + +class ErrorDetail(BaseModel): + code: ErrorCode + message: str + details: str | None = None + retryable: bool = False + retry_after_ms: int | None = None diff --git a/backend/package/yuxi/gateway/protocol/frames.py b/backend/package/yuxi/gateway/protocol/frames.py new file mode 100644 index 00000000..26fc1fe3 --- /dev/null +++ b/backend/package/yuxi/gateway/protocol/frames.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Literal + +from pydantic import BaseModel, Field + +if TYPE_CHECKING: + from yuxi.gateway.protocol.errors import ErrorDetail + + +class RequestFrame(BaseModel): + type: Literal["req"] = "req" + id: str + method: str + params: dict[str, Any] = Field(default_factory=dict) + + +class ResponseFrame(BaseModel): + type: Literal["res"] = "res" + id: str + ok: bool + payload: Any = None + error: ErrorDetail | None = None + + +class EventFrame(BaseModel): + type: Literal["event"] = "event" + event: str + payload: Any = None + seq: int = 0 + state_version: int = 0 + + +from yuxi.gateway.protocol.errors import ErrorDetail # noqa: E402 + +ResponseFrame.model_rebuild() diff --git a/backend/package/yuxi/gateway/protocol/handshake.py b/backend/package/yuxi/gateway/protocol/handshake.py new file mode 100644 index 00000000..c878727f --- /dev/null +++ b/backend/package/yuxi/gateway/protocol/handshake.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import uuid +from typing import Literal + +from pydantic import BaseModel, Field + +from yuxi.gateway.protocol.version import negotiate_version + + +class ConnectParams(BaseModel): + min_protocol: int = 1 + max_protocol: int = 1 + client_id: str = "" + client_version: str = "" + client_platform: str = "" + auth_token: str | None = None + caps: list[str] = Field(default_factory=list) + + client_display_name: str = "" + client_device_family: str = "" + client_model_identifier: str = "" + client_mode: str = "default" + client_instance_id: str = "" + + commands: list[str] = Field(default_factory=list) + permissions: dict[str, bool] = Field(default_factory=dict) + + path_env: str = "" + role: str = "" + scopes: list[str] = Field(default_factory=list) + + locale: str = "" + user_agent: str = "" + + +class HelloFeatures(BaseModel): + methods: list[str] = Field(default_factory=list) + events: list[str] = Field(default_factory=list) + + +class HelloOk(BaseModel): + type: Literal["hello-ok"] = "hello-ok" + protocol: int + server_version: str + server_id: str + session_id: str + features: HelloFeatures = Field(default_factory=HelloFeatures) + snapshot: dict | None = None + dm_policy: str = "pairing" + group_policy: str = "allowlist" + conn_id: str = "" + max_payload: int = 1048576 + max_buffered_bytes: int = 10485760 + tick_interval_ms: int = 5000 + + +async def handle_handshake(params: ConnectParams) -> HelloOk: + protocol = negotiate_version(params.min_protocol, params.max_protocol) + if protocol is None: + raise ValueError("Protocol version negotiation failed") + + session_id = str(uuid.uuid4()) + conn_id = f"conn-{session_id[:8]}" + return HelloOk( + protocol=protocol, + server_version="1.0.0", + server_id="forcepilot-gateway", + session_id=session_id, + conn_id=conn_id, + features=HelloFeatures( + methods=["channel.status", "channel.start", "channel.stop", "chat.send", "chat.abort"], + events=["channel.status_change", "tick", "chat", "channel.logout", "config.reload"], + ), + ) diff --git a/backend/package/yuxi/gateway/protocol/primitives.py b/backend/package/yuxi/gateway/protocol/primitives.py new file mode 100644 index 00000000..421721aa --- /dev/null +++ b/backend/package/yuxi/gateway/protocol/primitives.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from typing import Annotated + +from pydantic import AfterValidator, StringConstraints + +NonEmptyString = Annotated[str, StringConstraints(min_length=1)] + + +def _ensure_positive(v: int) -> int: + return v if v >= 1 else 1 + + +PositiveInt = Annotated[int, AfterValidator(_ensure_positive)] diff --git a/backend/package/yuxi/gateway/protocol/version.py b/backend/package/yuxi/gateway/protocol/version.py new file mode 100644 index 00000000..09bfadb8 --- /dev/null +++ b/backend/package/yuxi/gateway/protocol/version.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +PROTOCOL_VERSION: int = 1 + + +def negotiate_version(client_min: int, client_max: int) -> int | None: + server_max = PROTOCOL_VERSION + server_min = 1 + + negotiated = min(client_max, server_max) + if negotiated >= max(client_min, server_min): + return negotiated + return None