feat(channel-domain): 新增领域模型、异常、端口及基础工具类
新增消息日志、发件箱、会话等领域模型,新增多类型业务异常定义,添加工厂协议端口与数据库工作单元实现,同时补充飞书、钩子、Web渠道的请求验证器,以及数据转换类和异常翻译工具
This commit is contained in:
parent
c61d5f0163
commit
ff36b9f334
66
backend/package/yuxi/channel/channels/feishu/verifier.py
Normal file
66
backend/package/yuxi/channel/channels/feishu/verifier.py
Normal 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)
|
||||
42
backend/package/yuxi/channel/channels/hooks/verifier.py
Normal file
42
backend/package/yuxi/channel/channels/hooks/verifier.py
Normal 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")
|
||||
39
backend/package/yuxi/channel/channels/web/verifier.py
Normal file
39
backend/package/yuxi/channel/channels/web/verifier.py
Normal 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)
|
||||
14
backend/package/yuxi/channel/domain/event/message_dropped.py
Normal file
14
backend/package/yuxi/channel/domain/event/message_dropped.py
Normal 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)
|
||||
@ -0,0 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from yuxi.channel.domain.exception.recoverable_error import RecoverableError
|
||||
|
||||
|
||||
class CacheError(RecoverableError):
|
||||
pass
|
||||
@ -0,0 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from yuxi.channel.domain.exception.unrecoverable_error import UnrecoverableError
|
||||
|
||||
|
||||
class ConcurrencyError(UnrecoverableError):
|
||||
pass
|
||||
@ -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}")
|
||||
@ -0,0 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from yuxi.channel.domain.exception.recoverable_error import RecoverableError
|
||||
|
||||
|
||||
class DuplicateEntityError(RecoverableError):
|
||||
pass
|
||||
@ -0,0 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from yuxi.channel.domain.exception.unrecoverable_error import UnrecoverableError
|
||||
|
||||
|
||||
class EntityNotFoundError(UnrecoverableError):
|
||||
pass
|
||||
@ -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")
|
||||
@ -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}")
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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"
|
||||
8
backend/package/yuxi/channel/domain/port/external/authentication_port.py
vendored
Normal file
8
backend/package/yuxi/channel/domain/port/external/authentication_port.py
vendored
Normal 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]: ...
|
||||
22
backend/package/yuxi/channel/domain/port/external/channel_request_verifier_port.py
vendored
Normal file
22
backend/package/yuxi/channel/domain/port/external/channel_request_verifier_port.py
vendored
Normal 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: ...
|
||||
@ -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: ...
|
||||
@ -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,
|
||||
)
|
||||
@ -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,
|
||||
)
|
||||
@ -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,
|
||||
)
|
||||
@ -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))
|
||||
@ -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
|
||||
Loading…
Reference in New Issue
Block a user