feat(channel-domain): 新增领域模型、异常、端口及基础工具类

新增消息日志、发件箱、会话等领域模型,新增多类型业务异常定义,添加工厂协议端口与数据库工作单元实现,同时补充飞书、钩子、Web渠道的请求验证器,以及数据转换类和异常翻译工具
This commit is contained in:
Kris 2026-05-31 21:42:35 +08:00
parent c61d5f0163
commit ff36b9f334
23 changed files with 425 additions and 0 deletions

View File

@ -0,0 +1,66 @@
from __future__ import annotations
import hashlib
import hmac
import logging
import time
from yuxi.channel.domain.port.external.channel_request_verifier_port import (
VerifyResult,
)
logger = logging.getLogger(__name__)
class FeishuRequestVerifier:
def __init__(
self,
verification_token: str = "",
encrypt_key: str = "",
timestamp_tolerance: int = 300,
):
self._verification_token = verification_token
self._encrypt_key = encrypt_key
self._timestamp_tolerance = timestamp_tolerance
@property
def channel_type(self) -> str:
return "feishu"
@property
def enabled(self) -> bool:
return bool(self._encrypt_key)
@property
def has_verification_token(self) -> bool:
return bool(self._verification_token)
async def verify(self, body: bytes, headers: dict[str, str]) -> VerifyResult:
if not self._encrypt_key:
return VerifyResult(passed=True, method="feishu_none", reason="no encrypt_key configured")
signature = headers.get("x-lark-signature", "")
timestamp = headers.get("x-lark-request-timestamp", "")
nonce = headers.get("x-lark-request-nonce", "")
if not signature or not timestamp:
return VerifyResult(passed=False, method="feishu_signature", reason="missing signature headers")
try:
ts = float(timestamp)
if abs(time.time() - ts) > self._timestamp_tolerance:
return VerifyResult(passed=False, method="feishu_signature", reason="timestamp expired")
except (ValueError, TypeError):
return VerifyResult(passed=False, method="feishu_signature", reason="invalid timestamp")
sign_string = f"{timestamp}{nonce}{self._encrypt_key}{body.decode('utf-8', errors='replace')}"
expected = hashlib.sha256(sign_string.encode("utf-8")).hexdigest()
if not hmac.compare_digest(signature.lower(), expected.lower()):
return VerifyResult(passed=False, method="feishu_signature", reason="signature mismatch")
return VerifyResult(passed=True, method="feishu_signature")
def verify_token(self, token: str) -> bool:
if not self._verification_token:
return True
return hmac.compare_digest(token, self._verification_token)

View File

@ -0,0 +1,42 @@
from __future__ import annotations
import hashlib
import hmac
from yuxi.channel.domain.port.external.channel_request_verifier_port import (
VerifyResult,
)
class HooksRequestVerifier:
@property
def channel_type(self) -> str:
return "hooks"
@property
def enabled(self) -> bool:
return True
async def verify(self, body: bytes, headers: dict[str, str], *, secret: str | None = None) -> VerifyResult:
if secret is None:
return VerifyResult(passed=False, method="hooks_none", reason="secret not configured, request rejected")
if not secret:
return VerifyResult(passed=True, method="hooks_none", reason="no secret configured")
auth_header = headers.get("authorization", "")
if auth_header.startswith("Bearer "):
token = auth_header[7:]
if hmac.compare_digest(token, secret):
return VerifyResult(passed=True, method="hooks_bearer")
return VerifyResult(passed=False, method="hooks_bearer", reason="invalid bearer token")
signature = headers.get("x-signature-256") or headers.get("x-hub-signature-256")
if signature:
sig = signature[7:] if signature.startswith("sha256=") else signature
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
if hmac.compare_digest(sig, expected):
return VerifyResult(passed=True, method="hooks_hmac")
return VerifyResult(passed=False, method="hooks_hmac", reason="invalid signature")
return VerifyResult(passed=False, method="hooks_none", reason="authentication required")

View File

@ -0,0 +1,39 @@
from __future__ import annotations
from yuxi.channel.domain.port.external.channel_request_verifier_port import (
VerifyResult,
)
from yuxi.channel.domain.port.external.authentication_port import (
AuthenticationPort,
)
class WebRequestVerifier:
def __init__(self, auth_service: AuthenticationPort | None = None, *, allow_anonymous: bool = False):
self._auth_service = auth_service
self._allow_anonymous = allow_anonymous
@property
def channel_type(self) -> str:
return "web"
@property
def enabled(self) -> bool:
return self._auth_service is not None
async def verify(self, body: bytes, headers: dict[str, str]) -> VerifyResult:
auth_header = headers.get("authorization", "")
if not auth_header:
if self._allow_anonymous:
return VerifyResult(passed=True, method="web_none", reason="no auth header, anonymous allowed")
if not self._auth_service:
return VerifyResult(passed=True, method="web_none", reason="no auth_service configured")
return VerifyResult(passed=False, method="web_bearer", reason="no credentials configured")
if not self._auth_service:
return VerifyResult(passed=True, method="web_none", reason="no auth_service configured")
passed, reason = await self._auth_service.authenticate(auth_header)
if passed:
return VerifyResult(passed=True, method="web_bearer")
return VerifyResult(passed=False, method="web_bearer", reason=reason)

View File

@ -0,0 +1,14 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass
class MessageDropped:
message_id: str
channel_type: str
reason: str
session_id: str = ""
trace_id: str = ""
metadata: dict[str, Any] = field(default_factory=dict)

View File

@ -0,0 +1,7 @@
from __future__ import annotations
from yuxi.channel.domain.exception.recoverable_error import RecoverableError
class CacheError(RecoverableError):
pass

View File

@ -0,0 +1,7 @@
from __future__ import annotations
from yuxi.channel.domain.exception.unrecoverable_error import UnrecoverableError
class ConcurrencyError(UnrecoverableError):
pass

View File

@ -0,0 +1,11 @@
from __future__ import annotations
from yuxi.channel.domain.exception.channel_error import ChannelError
class DuplicateBindingException(ChannelError):
def __init__(self, channel_type: str, account_id: str, group_id: str):
self.channel_type = channel_type
self.account_id = account_id
self.group_id = group_id
super().__init__(f"duplicate binding: {channel_type}/{account_id}/{group_id}")

View File

@ -0,0 +1,7 @@
from __future__ import annotations
from yuxi.channel.domain.exception.recoverable_error import RecoverableError
class DuplicateEntityError(RecoverableError):
pass

View File

@ -0,0 +1,7 @@
from __future__ import annotations
from yuxi.channel.domain.exception.unrecoverable_error import UnrecoverableError
class EntityNotFoundError(UnrecoverableError):
pass

View File

@ -0,0 +1,9 @@
from __future__ import annotations
from yuxi.channel.domain.exception.channel_error import ChannelError
class InvalidAgentConfigException(ChannelError):
def __init__(self, agent_config_id: int):
self.agent_config_id = agent_config_id
super().__init__(f"agent_config_id {agent_config_id} does not exist")

View File

@ -0,0 +1,9 @@
from __future__ import annotations
from yuxi.channel.domain.exception.channel_error import ChannelError
class SessionNotFoundError(ChannelError):
def __init__(self, *, thread_id: str):
self.thread_id = thread_id
super().__init__(f"Session not found for thread_id: {thread_id}")

View File

@ -0,0 +1,13 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class ChannelMessageData:
id: int
thread_id: str
role: str
content: str
message_id: str | None
extra_metadata: dict | None

View File

@ -0,0 +1,35 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class MessageLogData:
id: int
trace_id: str
message_id: str
channel_type: str
conversation_id: int | None
session_id: str | None
direction: str
sender_id: str | None
content_summary: str | None
agent_config_id: int | None
status: str
pipeline_result: str | None
abort_reason: str | None
worker_result: str | None
error_message: str | None
processing_time_ms: int | None
@dataclass
class MessageLogQuery:
channel_type: str | None = None
conversation_id: int | None = None
status: str | None = None
direction: str | None = None
pipeline_result: str | None = None
worker_result: str | None = None
limit: int = 50
offset: int = 0

View File

@ -0,0 +1,12 @@
from __future__ import annotations
from enum import StrEnum
class OutboxStatus(StrEnum):
PENDING = "pending"
PROCESSING = "processing"
RETRYING = "retrying"
SENT = "sent"
DEAD = "dead"
FAILED = "failed"

View File

@ -0,0 +1,8 @@
from __future__ import annotations
from typing import Protocol, runtime_checkable
@runtime_checkable
class AuthenticationPort(Protocol):
async def authenticate(self, auth_header: str, *, client_id: str = "unknown") -> tuple[bool, str]: ...

View File

@ -0,0 +1,22 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol, runtime_checkable
@dataclass(frozen=True)
class VerifyResult:
passed: bool
method: str = ""
reason: str = ""
@runtime_checkable
class ChannelRequestVerifierPort(Protocol):
@property
def channel_type(self) -> str: ...
@property
def enabled(self) -> bool: ...
async def verify(self, body: bytes, headers: dict[str, str]) -> VerifyResult: ...

View File

@ -0,0 +1,15 @@
from __future__ import annotations
from typing import Protocol, runtime_checkable
@runtime_checkable
class UnitOfWork(Protocol):
async def commit(self) -> None: ...
async def rollback(self) -> None: ...
@runtime_checkable
class HasSession(Protocol):
def session(self) -> object: ...

View File

@ -0,0 +1,25 @@
from __future__ import annotations
from yuxi.channel.domain.model.message_log.message_log_data import MessageLogData
from yuxi.storage.postgres.models_channel import ChannelMessageLog
def to_data(row: ChannelMessageLog) -> MessageLogData:
return MessageLogData(
id=row.id,
trace_id=row.trace_id,
message_id=row.message_id,
channel_type=row.channel_type,
conversation_id=row.conversation_id,
session_id=row.session_id,
direction=row.direction,
sender_id=row.sender_id,
content_summary=row.content_summary,
agent_config_id=row.agent_config_id,
status=row.status,
pipeline_result=row.pipeline_result,
abort_reason=row.abort_reason,
worker_result=row.worker_result,
error_message=row.error_message,
processing_time_ms=row.processing_time_ms,
)

View File

@ -0,0 +1,21 @@
from __future__ import annotations
from yuxi.channel.domain.model.outbox.outbox_entry import OutboxEntry
from yuxi.storage.postgres.models_channel import ChannelOutbox
def to_domain(row: ChannelOutbox) -> OutboxEntry:
return OutboxEntry(
id=row.id,
message_id=row.message_id,
session_id=row.session_id,
channel_type=row.channel_type,
content=row.content,
status=row.status,
retry_count=row.retry_count,
max_retries=row.max_retries,
next_retry_at=row.next_retry_at,
last_error=row.last_error,
trace_id=row.trace_id,
extra_metadata=row.extra_metadata,
)

View File

@ -0,0 +1,17 @@
from __future__ import annotations
from yuxi.channel.domain.model.session.channel_session import ChannelSession
from yuxi.storage.postgres.models_business import Conversation
def to_domain(row: Conversation) -> ChannelSession:
return ChannelSession(
id=row.id,
thread_id=row.thread_id,
user_id=row.user_id,
agent_id=row.agent_id,
channel_type=row.channel_type,
channel_session_key=row.channel_session_key,
status=row.status,
title=row.title,
)

View File

@ -0,0 +1,22 @@
from __future__ import annotations
from sqlalchemy.exc import IntegrityError as SaIntegrityError
from sqlalchemy.exc import OperationalError as SaOperationalError
from yuxi.channel.domain.exception.concurrency_error import ConcurrencyError
from yuxi.channel.domain.exception.duplicate_entity_error import DuplicateEntityError
from yuxi.channel.domain.exception.entity_not_found_error import EntityNotFoundError
from yuxi.channel.domain.exception.unrecoverable_error import UnrecoverableError
def translate_db_exception(exc: Exception) -> UnrecoverableError:
if isinstance(exc, SaIntegrityError):
msg = str(exc.orig) if hasattr(exc, "orig") else str(exc)
if "unique" in msg.lower() or "duplicate" in msg.lower():
return DuplicateEntityError(msg)
if "foreign key" in msg.lower():
return EntityNotFoundError(msg)
return ConcurrencyError(msg)
if isinstance(exc, SaOperationalError):
return UnrecoverableError(f"Database operational error: {exc}")
return UnrecoverableError(str(exc))

View File

@ -0,0 +1,17 @@
from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession
class SqlAlchemyUnitOfWork:
def __init__(self, session: AsyncSession):
self._session = session
async def commit(self) -> None:
await self._session.commit()
async def rollback(self) -> None:
await self._session.rollback()
def session(self) -> AsyncSession:
return self._session