Compare commits

..

2 Commits

Author SHA1 Message Date
39b7df2ce0 refactor(channel-domain): 重构端口定义与导入结构
1. 新增多个领域端口协议类,包括签名验证、路由贡献、关键词匹配等完整的端口定义
2. 重构ChannelRouteContributor重命名为ChannelRouteContributorPort并统一导入路径
3. 调整端口目录结构,拆分external和internal子目录并分别导出
4. 更新所有引用了旧端口定义的业务代码和测试用例
2026-05-31 17:38:33 +08:00
6b9fb39807 refactor(channel infra): 重构基础设施代码目录结构
本次提交将原cache、config目录迁移至cache_infra和configuration目录,统一基础设施代码组织:
1. 移动Redis相关缓存实现到cache_infra目录
2. 移动配置相关实现到configuration目录
3. 更新所有模块导入路径和测试文件引用路径
4. 新增对应目录的初始化文件和完整单元测试
5. 重构容器绑定的依赖导入路径
2026-05-31 17:13:08 +08:00
52 changed files with 329 additions and 54 deletions

View File

@ -6,7 +6,7 @@ import json
from yuxi.channel.domain.middleware.configurable import Configurable
from yuxi.channel.domain.port.config_reload_port import ConfigReloadPort
from yuxi.channel.domain.service.pipeline import Pipeline
from yuxi.channel.infrastructure.config.channel_config import ChannelConfig
from yuxi.channel.infrastructure.configuration.channel_config import ChannelConfig
class ConfigService:

View File

@ -9,7 +9,7 @@ from yuxi.channel.domain.model.message.dispatch_result import SendResult
from yuxi.channel.domain.model.message.unified_message import UnifiedMessage
from yuxi.channel.domain.model.shared.channel_capabilities import ChannelCapabilities
from yuxi.channel.domain.model.shared.channel_type import ChannelType
from yuxi.channel.domain.port.channel_route_contributor import ChannelRouteContributor
from yuxi.channel.domain.port.channel_route_contributor_port import ChannelRouteContributorPort
from yuxi.channel.domain.port.ws_connection_port import WsConnectionPort
logger = logging.getLogger(__name__)
@ -57,7 +57,7 @@ class FeishuAdapter:
return self._ws
@property
def route_contributor(self) -> ChannelRouteContributor | None:
def route_contributor(self) -> ChannelRouteContributorPort | None:
return _FeishuRouteContributor()
@classmethod

View File

@ -8,7 +8,7 @@ from yuxi.channel.domain.model.message.dispatch_result import SendResult
from yuxi.channel.domain.model.message.unified_message import UnifiedMessage
from yuxi.channel.domain.model.shared.channel_capabilities import ChannelCapabilities
from yuxi.channel.domain.model.shared.channel_type import ChannelType
from yuxi.channel.domain.port.channel_route_contributor import ChannelRouteContributor
from yuxi.channel.domain.port.channel_route_contributor_port import ChannelRouteContributorPort
from yuxi.channel.domain.port.ws_connection_port import WsConnectionPort
logger = logging.getLogger(__name__)
@ -55,7 +55,7 @@ class HooksAdapter:
return None
@property
def route_contributor(self) -> ChannelRouteContributor | None:
def route_contributor(self) -> ChannelRouteContributorPort | None:
return _HooksRouteContributor()
@classmethod

View File

@ -8,7 +8,7 @@ from yuxi.channel.domain.model.message.dispatch_result import SendResult
from yuxi.channel.domain.model.message.unified_message import UnifiedMessage
from yuxi.channel.domain.model.shared.channel_capabilities import ChannelCapabilities
from yuxi.channel.domain.model.shared.channel_type import ChannelType
from yuxi.channel.domain.port.channel_route_contributor import ChannelRouteContributor
from yuxi.channel.domain.port.channel_route_contributor_port import ChannelRouteContributorPort
from yuxi.channel.domain.port.sse_push_port import SsePushPort
from yuxi.channel.domain.port.ws_connection_port import WsConnectionPort
@ -41,7 +41,7 @@ class WebAdapter:
return None
@property
def route_contributor(self) -> ChannelRouteContributor | None:
def route_contributor(self) -> ChannelRouteContributorPort | None:
return _WebRouteContributor()
@classmethod

View File

@ -37,12 +37,12 @@ from yuxi.channel.domain.repository.outbox_repository import OutboxRepositoryPor
from yuxi.channel.domain.repository.session_repository import SessionRepositoryPort
from yuxi.channel.domain.service.pipeline import Pipeline
from yuxi.channel.infrastructure.agent.agent_adapter import AgentAdapter
from yuxi.channel.infrastructure.cache.redis_bot_loop_guard import RedisBotLoopGuard
from yuxi.channel.infrastructure.cache.redis_cache import RedisCache
from yuxi.channel.infrastructure.cache.redis_circuit_breaker import RedisCircuitBreaker
from yuxi.channel.infrastructure.cache.redis_rate_limiter import RedisRateLimiter
from yuxi.channel.infrastructure.config.channel_config import ChannelConfig
from yuxi.channel.infrastructure.config.redis_config_reload import RedisConfigReload
from yuxi.channel.infrastructure.cache_infra.redis_bot_loop_guard import RedisBotLoopGuard
from yuxi.channel.infrastructure.cache_infra.redis_cache import RedisCache
from yuxi.channel.infrastructure.cache_infra.redis_circuit_breaker import RedisCircuitBreaker
from yuxi.channel.infrastructure.cache_infra.redis_rate_limiter import RedisRateLimiter
from yuxi.channel.infrastructure.configuration.channel_config import ChannelConfig
from yuxi.channel.infrastructure.configuration.redis_config_reload import RedisConfigReload
from yuxi.channel.infrastructure.content_filter.composite_content_filter import CompositeContentFilter
from yuxi.channel.infrastructure.content_filter.llm_content_filter import LlmContentFilter
from yuxi.channel.infrastructure.content_filter.redis_content_filter import RedisContentFilter

View File

@ -29,7 +29,7 @@ _LAZY_IMPORTS = {
"KeywordMatcherPort": "yuxi.channel.domain.port",
"CachePort": "yuxi.channel.domain.port",
"ChannelAdapterPort": "yuxi.channel.domain.port",
"ChannelRouteContributor": "yuxi.channel.domain.port",
"ChannelRouteContributorPort": "yuxi.channel.domain.port",
"CircuitBreakerPort": "yuxi.channel.domain.port",
"ConfigReloadPort": "yuxi.channel.domain.port",
"ContentFilterPort": "yuxi.channel.domain.port",

View File

@ -1,16 +1,22 @@
from yuxi.channel.domain.port.agent_port import AgentPort
from yuxi.channel.domain.port.bot_loop_guard_port import BotLoopGuardPort
from yuxi.channel.domain.port.cache_port import CachePort
from yuxi.channel.domain.port.channel_adapter_port import ChannelAdapterPort
from yuxi.channel.domain.port.channel_route_contributor import ChannelRouteContributor
from yuxi.channel.domain.port.circuit_breaker_port import CircuitBreakerPort
from yuxi.channel.domain.port.config_reload_port import ConfigReloadPort
from yuxi.channel.domain.port.content_filter_port import ContentFilterPort, FilterResult
from yuxi.channel.domain.port.event_publisher_port import DomainEvent, EventPublisherPort
from yuxi.channel.domain.port.keyword_matcher_port import KeywordMatcherPort
from yuxi.channel.domain.port.metrics_port import MetricsPort
from yuxi.channel.domain.port.queue_port import QueuePort
from yuxi.channel.domain.port.rate_limit_port import RateLimitPort
from yuxi.channel.domain.port.signature_verify_port import SignatureVerifyPort
from yuxi.channel.domain.port.sse_push_port import SsePushPort
from yuxi.channel.domain.port.ws_connection_port import WsConnectionPort
from yuxi.channel.domain.port.external import (
ChannelAdapterPort,
ChannelRouteContributorPort,
SignatureVerifyPort,
WsConnectionPort,
)
from yuxi.channel.domain.port.internal import (
AgentPort,
BotLoopGuardPort,
CachePort,
CircuitBreakerPort,
ConfigReloadPort,
ContentFilterPort,
DomainEvent,
EventPublisherPort,
FilterResult,
KeywordMatcherPort,
MetricsPort,
QueuePort,
RateLimitPort,
SsePushPort,
)

View File

@ -0,0 +1,4 @@
from yuxi.channel.domain.port.external.channel_adapter_port import ChannelAdapterPort
from yuxi.channel.domain.port.external.channel_route_contributor_port import ChannelRouteContributorPort
from yuxi.channel.domain.port.external.signature_verify_port import SignatureVerifyPort
from yuxi.channel.domain.port.external.ws_connection_port import WsConnectionPort

View File

@ -4,6 +4,6 @@ from typing import Protocol, runtime_checkable
@runtime_checkable
class ChannelRouteContributor(Protocol):
class ChannelRouteContributorPort(Protocol):
@property
def router(self) -> object: ...

View File

@ -0,0 +1,12 @@
from yuxi.channel.domain.port.internal.agent_port import AgentPort
from yuxi.channel.domain.port.internal.bot_loop_guard_port import BotLoopGuardPort
from yuxi.channel.domain.port.internal.cache_port import CachePort
from yuxi.channel.domain.port.internal.circuit_breaker_port import CircuitBreakerPort
from yuxi.channel.domain.port.internal.config_reload_port import ConfigReloadPort
from yuxi.channel.domain.port.internal.content_filter_port import ContentFilterPort, FilterResult
from yuxi.channel.domain.port.internal.event_publisher_port import DomainEvent, EventPublisherPort
from yuxi.channel.domain.port.internal.keyword_matcher_port import KeywordMatcherPort
from yuxi.channel.domain.port.internal.metrics_port import MetricsPort
from yuxi.channel.domain.port.internal.queue_port import QueuePort
from yuxi.channel.domain.port.internal.rate_limit_port import RateLimitPort
from yuxi.channel.domain.port.internal.sse_push_port import SsePushPort

View File

@ -0,0 +1,61 @@
from __future__ import annotations
import logging
from yuxi.channel.domain.port.bot_loop_guard_port import BotLoopGuardPort
from yuxi.channel.domain.port.cache_port import CachePort
logger = logging.getLogger(__name__)
class RedisBotLoopGuard:
def __init__(
self,
cache_port: CachePort,
*,
dm_budget: int = 10,
group_budget: int = 20,
window_seconds: int = 60,
cooldown_seconds: int = 300,
) -> None:
self._cache = cache_port
self._dm_budget = dm_budget
self._group_budget = group_budget
self._window = window_seconds
self._cooldown = cooldown_seconds
async def check(
self, session_id: str, *, sender_id: str = "", is_group: bool = False
) -> bool:
if is_group and not sender_id:
logger.warning(
"bot loop guard: group message without sender_id rejected, session=%s",
session_id,
)
return False
if is_group and sender_id:
key = f"channel:bot_loop:group:{session_id}:{sender_id}"
budget = self._group_budget
else:
key = f"channel:bot_loop:dm:{session_id}"
budget = self._dm_budget
count = await self._cache.incr(key)
if count == 1:
await self._cache.expire(key, self._window)
if count > budget:
await self._cache.expire(key, self._cooldown)
return False
return True
async def reset(
self, session_id: str, *, sender_id: str = "", is_group: bool = False
) -> None:
if is_group and sender_id:
key = f"channel:bot_loop:group:{session_id}:{sender_id}"
else:
key = f"channel:bot_loop:dm:{session_id}"
await self._cache.delete(key)

View File

@ -0,0 +1,50 @@
from __future__ import annotations
import logging
import redis.asyncio as aioredis
from yuxi.channel.domain.port.cache_port import CachePort
logger = logging.getLogger(__name__)
class RedisCache(CachePort):
def __init__(self, redis: aioredis.Redis) -> None:
self._redis = redis
async def get(self, key: str) -> str | None:
raw = await self._redis.get(key)
if raw is None:
return None
return raw.decode() if isinstance(raw, bytes) else raw
async def set(
self, key: str, value: str, *, ex: int | None = None, nx: bool = False
) -> bool:
result = await self._redis.set(key, value, ex=ex, nx=nx)
if result is None:
return False
if isinstance(result, bool):
return result
if isinstance(result, bytes):
return result == b"OK"
return str(result) == "OK"
async def delete(self, key: str) -> None:
await self._redis.delete(key)
async def incr(self, key: str) -> int:
return await self._redis.incr(key)
async def expire(self, key: str, seconds: int) -> None:
await self._redis.expire(key, seconds)
async def ttl(self, key: str) -> int:
return await self._redis.ttl(key)
async def eval(self, script: str, keys: list[str], args: list[str | int]) -> tuple:
return await self._redis.eval(script, len(keys), *keys, *args)
async def publish(self, channel: str, message: str) -> None:
await self._redis.publish(channel, message)

View File

@ -0,0 +1,107 @@
from __future__ import annotations
from yuxi.channel.domain.port.cache_port import CachePort
from yuxi.channel.domain.port.circuit_breaker_port import CircuitBreakerPort
_IS_AVAILABLE_SCRIPT = """
local state_key = KEYS[1]
local probe_key = KEYS[2]
local recovery_timeout = tonumber(ARGV[1])
local half_open_max = tonumber(ARGV[2])
local state = redis.call('GET', state_key)
if not state then
return {1, 'closed'}
end
if state == 'open' then
local ttl = redis.call('TTL', state_key)
if ttl > 0 then
return {0, 'open'}
end
redis.call('SET', state_key, 'half_open', 'EX', recovery_timeout)
redis.call('DEL', probe_key)
return {1, 'half_open'}
end
if state == 'half_open' then
local count = redis.call('INCR', probe_key)
if count == 1 then
redis.call('EXPIRE', probe_key, recovery_timeout)
end
if count <= half_open_max then
return {1, 'half_open'}
end
return {0, 'half_open'}
end
return {1, 'closed'}
"""
_RECORD_FAILURE_SCRIPT = """
local failure_key = KEYS[1]
local state_key = KEYS[2]
local probe_key = KEYS[3]
local recovery_timeout = tonumber(ARGV[1])
local failure_threshold = tonumber(ARGV[2])
local count = redis.call('INCR', failure_key)
if count == 1 then
redis.call('EXPIRE', failure_key, recovery_timeout * 2)
end
if count >= failure_threshold then
redis.call('SET', state_key, 'open', 'EX', recovery_timeout)
redis.call('DEL', probe_key)
return count
end
return count
"""
class RedisCircuitBreaker:
def __init__(
self,
cache_port: CachePort,
*,
failure_threshold: int = 5,
recovery_timeout: int = 30,
half_open_max: int = 1,
) -> None:
self._cache = cache_port
self._failure_threshold = failure_threshold
self._recovery_timeout = recovery_timeout
self._half_open_max = half_open_max
async def is_available(self, agent_config_id: int) -> bool:
state_key = f"channel:circuit:state:{agent_config_id}"
probe_key = f"channel:circuit:probe:{agent_config_id}"
result = await self._cache.eval(
_IS_AVAILABLE_SCRIPT,
keys=[state_key, probe_key],
args=[str(self._recovery_timeout), str(self._half_open_max)],
)
allowed = result[0]
return bool(allowed)
async def record_success(self, agent_config_id: int) -> None:
state_key = f"channel:circuit:state:{agent_config_id}"
failure_key = f"channel:circuit:failures:{agent_config_id}"
probe_key = f"channel:circuit:probe:{agent_config_id}"
await self._cache.delete(failure_key)
await self._cache.delete(probe_key)
await self._cache.delete(state_key)
async def record_failure(self, agent_config_id: int) -> None:
failure_key = f"channel:circuit:failures:{agent_config_id}"
state_key = f"channel:circuit:state:{agent_config_id}"
probe_key = f"channel:circuit:probe:{agent_config_id}"
await self._cache.eval(
_RECORD_FAILURE_SCRIPT,
keys=[failure_key, state_key, probe_key],
args=[str(self._recovery_timeout), str(self._failure_threshold)],
)

View File

@ -0,0 +1,34 @@
from __future__ import annotations
import logging
import redis.asyncio as aioredis
logger = logging.getLogger(__name__)
class RedisRateLimiter:
def __init__(self, redis: aioredis.Redis) -> None:
self._redis = redis
async def check_and_incr(
self, key: str, *, max_attempts: int, window_seconds: int, lockout_seconds: int = 0
) -> bool:
count = await self._redis.incr(key)
if count == 1:
await self._redis.expire(key, window_seconds)
if count <= max_attempts:
return True
if lockout_seconds > 0:
lockout_key = key.replace(":attempts:", ":lockout:")
await self._redis.set(lockout_key, "1", ex=lockout_seconds)
return False
async def is_locked(self, key: str) -> tuple[bool, int]:
ttl = await self._redis.ttl(key)
if ttl is None or ttl < 0:
return False, 0
return True, ttl
async def reset(self, key: str) -> None:
await self._redis.delete(key)

View File

@ -5,7 +5,7 @@ import logging
from fastapi import FastAPI
from yuxi.channel.domain.port.channel_adapter_port import ChannelAdapterPort
from yuxi.channel.domain.port.channel_route_contributor import ChannelRouteContributor
from yuxi.channel.domain.port.channel_route_contributor_port import ChannelRouteContributorPort
logger = logging.getLogger(__name__)
@ -30,11 +30,11 @@ def register_all_channel_routes(
return registered
def _get_route_contributor(adapter: ChannelAdapterPort) -> ChannelRouteContributor | None:
def _get_route_contributor(adapter: ChannelAdapterPort) -> ChannelRouteContributorPort | None:
contributor = getattr(adapter, "route_contributor", None)
if contributor is None:
return None
if isinstance(contributor, ChannelRouteContributor):
if isinstance(contributor, ChannelRouteContributorPort):
return contributor
if hasattr(contributor, "router"):
return contributor

View File

@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from yuxi.channel.container import ChannelContainerFactory, _InfraBundle
from yuxi.channel.infrastructure.config.channel_config import ChannelConfig
from yuxi.channel.infrastructure.configuration.channel_config import ChannelConfig
class TestBuildAdapters:

View File

@ -6,7 +6,7 @@ import pytest
from yuxi.channel.container import ChannelContainerFactory, _InfraBundle
from yuxi.channel.application.service.auth_service import AuthService
from yuxi.channel.infrastructure.config.channel_config import ChannelConfig
from yuxi.channel.infrastructure.configuration.channel_config import ChannelConfig
class TestBuildAuthService:

View File

@ -5,7 +5,7 @@ from unittest.mock import patch
import pytest
from yuxi.channel.container import ChannelContainerFactory
from yuxi.channel.infrastructure.config.channel_config import ChannelConfig
from yuxi.channel.infrastructure.configuration.channel_config import ChannelConfig
class TestBuildConfig:

View File

@ -5,7 +5,7 @@ from unittest.mock import MagicMock
import pytest
from yuxi.channel.container import ChannelContainerFactory, _InfraBundle
from yuxi.channel.infrastructure.config.channel_config import ChannelConfig
from yuxi.channel.infrastructure.configuration.channel_config import ChannelConfig
class TestBuildInfra:

View File

@ -7,7 +7,7 @@ import pytest
from yuxi.channel.container import ChannelContainerFactory, _InfraBundle
from yuxi.channel.application.service.auth_service import AuthService
from yuxi.channel.domain.service.pipeline import Pipeline
from yuxi.channel.infrastructure.config.channel_config import ChannelConfig
from yuxi.channel.infrastructure.configuration.channel_config import ChannelConfig
class TestBuildPipeline:

View File

@ -6,7 +6,7 @@ import pytest
from yuxi.channel.container import ChannelContainerFactory, _InfraBundle, _WorkerBundle
from yuxi.channel.domain.service.pipeline import Pipeline
from yuxi.channel.infrastructure.config.channel_config import ChannelConfig
from yuxi.channel.infrastructure.configuration.channel_config import ChannelConfig
class TestBuildWorkers:

View File

@ -1,10 +1,10 @@
from __future__ import annotations
from yuxi.channel.domain.port.channel_route_contributor import ChannelRouteContributor
from yuxi.channel.domain.port.channel_route_contributor_port import ChannelRouteContributorPort
def test_channel_route_contributor_is_protocol() -> None:
assert hasattr(ChannelRouteContributor, "router")
def test_channel_route_contributor_port_is_protocol() -> None:
assert hasattr(ChannelRouteContributorPort, "router")
class _FakeContributor:
@ -15,4 +15,4 @@ class _FakeContributor:
def test_fake_contributor_is_instance() -> None:
obj = _FakeContributor()
assert isinstance(obj, ChannelRouteContributor)
assert isinstance(obj, ChannelRouteContributorPort)

View File

@ -0,0 +1 @@


View File

@ -5,7 +5,7 @@ from pathlib import Path
import pytest
from yuxi.channel.infrastructure.config.channel_config import ChannelConfig
from yuxi.channel.infrastructure.configuration.channel_config import ChannelConfig
@pytest.fixture

View File

@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from yuxi.channel.infrastructure.config.redis_config_reload import RedisConfigReload
from yuxi.channel.infrastructure.configuration.redis_config_reload import RedisConfigReload
@pytest.fixture

View File

@ -4,7 +4,7 @@ import pytest
from fastapi import FastAPI, APIRouter
from yuxi.channel.interfaces.rest.router.registry import register_all_channel_routes, _get_route_contributor
from yuxi.channel.domain.port.channel_route_contributor import ChannelRouteContributor
from yuxi.channel.domain.port.channel_route_contributor_port import ChannelRouteContributorPort
class _FakeContributor:

View File

@ -2,7 +2,7 @@ from __future__ import annotations
import pytest
from yuxi.channel.infrastructure.config.channel_config import ChannelConfig
from yuxi.channel.infrastructure.configuration.channel_config import ChannelConfig
class TestChannelConfig:

View File

@ -7,7 +7,7 @@ import pytest
import redis.asyncio as aioredis
from yuxi.channel.container import ChannelContainerFactory
from yuxi.channel.infrastructure.config.channel_config import ChannelConfig
from yuxi.channel.infrastructure.configuration.channel_config import ChannelConfig
class TestChannelContainerFactory:

View File

@ -4,7 +4,7 @@ from unittest.mock import AsyncMock
import pytest
from yuxi.channel.infrastructure.cache.redis_bot_loop_guard import RedisBotLoopGuard
from yuxi.channel.infrastructure.cache_infra.redis_bot_loop_guard import RedisBotLoopGuard
class TestRedisBotLoopGuard:

View File

@ -4,7 +4,7 @@ from unittest.mock import AsyncMock
import pytest
from yuxi.channel.infrastructure.cache.redis_cache import RedisCache
from yuxi.channel.infrastructure.cache_infra.redis_cache import RedisCache
class TestRedisCache:

View File

@ -4,7 +4,7 @@ from unittest.mock import AsyncMock
import pytest
from yuxi.channel.infrastructure.cache.redis_circuit_breaker import RedisCircuitBreaker
from yuxi.channel.infrastructure.cache_infra.redis_circuit_breaker import RedisCircuitBreaker
class TestRedisCircuitBreaker:

View File

@ -4,7 +4,7 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from yuxi.channel.infrastructure.config.redis_config_reload import RedisConfigReload
from yuxi.channel.infrastructure.configuration.redis_config_reload import RedisConfigReload
class TestRedisConfigReload:

View File

@ -4,7 +4,7 @@ from unittest.mock import AsyncMock
import pytest
from yuxi.channel.infrastructure.cache.redis_rate_limiter import RedisRateLimiter
from yuxi.channel.infrastructure.cache_infra.redis_rate_limiter import RedisRateLimiter
class TestRedisRateLimiter: