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