"""RedisConfigAdapter:实现 ConfigPort,复用 Redis。 - 配置版本化更新(WATCH/MULTI/EXEC 原子乐观并发控制) - 配置读取、更新、版本查询、回滚、热更新模式查询与 schema 查询 - 适配器不再构造/发布领域事件(CON-030 / ADP-029):``ConfigChanged`` / ``ConfigRollback`` 事件由应用层 ``ConfigManager`` 基于返回的 ``ConfigUpdateResult`` 构造并发布 - 适配器不再承载热更新策略校验(ADP-030):``NON_HOT_RELOADABLE_KEYS`` 校验由应用层 ``ConfigManager`` 执行 - Redis 客户端由外部构造注入(INV-5),适配器不管理其生命周期, 不再依赖 ``yuxi.services.run_queue_service`` 依赖边界:只依赖 yuxi.channels.contract(端口 + DTO + 错误)、 redis.asyncio、标准库。 """ from __future__ import annotations import json from datetime import datetime from typing import Any from redis.asyncio import Redis from redis.exceptions import WatchError from yuxi.channels.contract.dtos.config import ( ConfigField, ConfigHistoryEntry, ConfigHotReloadMode, ConfigScope, ConfigValue, ConfigVersion, RollbackConfigCmd, UpdateConfigCmd, ) from yuxi.channels.contract.dtos.config_update_result import ConfigUpdateResult from yuxi.channels.contract.dtos.streaming import StreamingConfig from yuxi.channels.contract.errors import ( ConfigRollbackError, ConfigVersionConflictError, DependencyError, NotFoundError, ) from yuxi.channels.contract.errors.base import Error from yuxi.channels.contract.policy.config_schema import CONFIG_SCHEMA from yuxi.channels.contract.ports.driven.config_port import ConfigPort from yuxi.channels.contract.ports.driven.logger_port import LoggerPort from yuxi.utils.datetime_utils import utc_now_naive __all__ = ["RedisConfigAdapter"] class RedisConfigAdapter(ConfigPort): """Redis 配置适配器。 实现 ``ConfigPort``,基于构造注入的 ``redis.asyncio.Redis`` 客户端 操作 Redis,支持配置读取、更新、版本查询、回滚、热更新模式查询 与 schema 查询。配置以 ``config:{scope}:{target}:{key}`` 格式存储, 版本号存储在 ``{scoped_key}:version`` 中,历史记录存储在 ``{scoped_key}:history`` 列表中。 配置更新与回滚使用 WATCH/MULTI/EXEC 保证读取-校验-写入的原子性。 适配器不再构造/发布领域事件(CON-030 / ADP-029),也不再承载热更新 策略校验(ADP-030);这两项职责由应用层 ``ConfigManager`` 承担。 ``update`` / ``rollback`` 返回 ``ConfigUpdateResult``(含版本号与旧值), 供应用层构造 ``ConfigChanged`` / ``ConfigRollback`` 领域事件。 Redis 客户端由外部构造注入(INV-5),适配器不管理其生命周期。 通过 ``LoggerPort`` 记录日志(INV-7 / 端口抽象),logger 强制注入。 """ def __init__( self, redis_client: Redis, logger: LoggerPort, ) -> None: """初始化 Redis 配置适配器。 Args: redis_client: ``redis.asyncio.Redis`` 客户端实例,由外部构造并 注入;适配器不管理其生命周期(构造注入,INV-5)。需使用 ``decode_responses=True`` 创建,保证读写值为字符串。 logger: 日志被驱动端口,强制注入,用于记录降级分支异常日志 (INV-10 可观测性)。 """ self._redis = redis_client self._logger = logger async def get( self, key: str, scope: ConfigScope = ConfigScope.GLOBAL, target: str | None = None, ) -> ConfigValue: """读取配置值。 从 Redis 读取指定作用域与目标的配置值。若作用域级别不存在,回退到 全局配置(PLUGIN 作用域除外——插件配置独立,不回退全局)。若全局 配置也不存在,返回 schema 默认值(若定义);否则抛 ``NotFoundError``(HTTP 404),与 driving 端口 ``getConfig @failure`` 声明一致。 Args: key: 配置键。 scope: 配置作用域(默认 GLOBAL)。 target: 目标(channel_type / account_id / plugin_id,可选)。 Returns: ConfigValue,包含键、值、版本与作用域。 Raises: NotFoundError: 配置不存在且 schema 中无默认值。 DependencyError: Redis 依赖故障。 """ try: scoped_key = self._build_key(key, scope, target) value = await self._redis.get(scoped_key) actual_key = scoped_key # PLUGIN 作用域配置独立,不回退到全局配置 if value is None and scope != ConfigScope.PLUGIN: actual_key = self._build_key(key, ConfigScope.GLOBAL, None) value = await self._redis.get(actual_key) if value is None: default = self._getSchemaDefault(key) if default is not None: return ConfigValue( key=key, value=default, version=0, scope=scope, ) raise NotFoundError( resource="config", id=key, ) version_str = await self._redis.get(f"{actual_key}:version") return ConfigValue( key=key, value=json.loads(value), version=int(version_str) if version_str else 0, scope=scope, ) except NotFoundError: raise except Exception as exc: raise DependencyError("redis", Error(str(exc))) from exc async def update(self, cmd: UpdateConfigCmd) -> ConfigUpdateResult: """更新配置。 使用 WATCH/MULTI/EXEC 保证读取版本-校验-写入的原子性,返回 ``ConfigUpdateResult``(含键、新版本号、旧值、新值与写入时间), 供应用层构造 ``ConfigChanged`` 领域事件(CON-030)。 适配器不再执行不可热更新配置项校验(ADP-030),该校验由应用层 ``ConfigManager`` 基于 ``NON_HOT_RELOADABLE_KEYS`` 执行。 Args: cmd: 更新配置命令。 Returns: ConfigUpdateResult,含键、新版本号、已反序列化的旧值(首次 创建时为 ``None``)、新值与写入时间。 Raises: ConfigVersionConflictError: 配置版本冲突(并发修改)。 DependencyError: Redis 依赖故障。 """ try: scoped_key = self._build_key(cmd.key, cmd.scope, cmd.target) pipe = self._redis.pipeline() await pipe.watch(f"{scoped_key}:version") try: version_str = await pipe.get(f"{scoped_key}:version") current_version = int(version_str) if version_str else 0 if cmd.expected_version is not None and cmd.expected_version != current_version: raise ConfigVersionConflictError(cmd.expected_version, current_version) old_value_raw = await pipe.get(scoped_key) # 读取被归档版本的原始写入时间,作为历史条目的 updated_at # (而非本次更新时间),确保历史时间戳语义正确。 old_updated_at_raw = await pipe.get(f"{scoped_key}:updated_at") new_version = current_version + 1 updated_at = utc_now_naive() pipe.multi() pipe.set(scoped_key, json.dumps(cmd.value, ensure_ascii=False)) pipe.set(f"{scoped_key}:version", str(new_version)) pipe.set(f"{scoped_key}:updated_at", updated_at.isoformat()) if old_value_raw is not None: pipe.lpush( f"{scoped_key}:history", json.dumps( { "version": current_version, "value": old_value_raw, "updated_at": old_updated_at_raw, }, ensure_ascii=False, ), ) try: result = await pipe.execute() except WatchError: result = None finally: pipe.reset() if result is None: raise ConfigVersionConflictError(cmd.expected_version or 0, current_version) old_value = json.loads(old_value_raw) if old_value_raw is not None else None return ConfigUpdateResult( key=cmd.key, version=new_version, old_value=old_value, new_value=cmd.value, updated_at=updated_at, ) except ConfigVersionConflictError: raise except Exception as exc: raise DependencyError("redis", Error(str(exc))) from exc async def getVersion( self, key: str, scope: ConfigScope = ConfigScope.GLOBAL, target: str | None = None, ) -> ConfigVersion: """查询配置版本。 返回指定配置项的当前版本信息,用于乐观并发控制与版本回溯。 Args: key: 配置键。 scope: 配置作用域(默认 GLOBAL)。 target: 目标(channel_type / account_id / plugin_id,可选)。 Returns: ConfigVersion,包含键、版本号与更新时间。配置不存在时返回 版本 0 与 ``updated_at=None``,避免误报"刚刚更新"。 """ try: scoped_key = self._build_key(key, scope, target) version_str = await self._redis.get(f"{scoped_key}:version") version = int(version_str) if version_str else 0 updated_at: datetime | None = None if version > 0: updated_at_raw = await self._redis.get(f"{scoped_key}:updated_at") if updated_at_raw is not None: try: updated_at = datetime.fromisoformat(updated_at_raw) except ValueError as exc: await self._logger.warn( "redis config updated_at parse failed", key=key, raw_value=updated_at_raw, error=str(exc), ) updated_at = None return ConfigVersion( key=key, version=version, updated_at=updated_at, ) except Exception as exc: raise DependencyError("redis", Error(str(exc))) from exc async def rollback(self, cmd: RollbackConfigCmd) -> ConfigUpdateResult: """回滚配置版本。 将配置回滚至指定版本。从历史记录中查找目标版本,使用 WATCH/MULTI/EXEC 保证读取历史-读取当前值-写入的原子性。返回 ``ConfigUpdateResult``(含键、新版本号、旧值即回滚前失败值、 新值即回滚后目标值、写入时间),供应用层构造 ``ConfigRollback`` 领域事件(CON-030)。 Args: cmd: 回滚配置命令。 Returns: ConfigUpdateResult,含键、新版本号、已反序列化的旧值(即回滚前 失败值)、新值(即回滚后目标值)与写入时间。 Raises: NotFoundError: 目标版本不存在于历史记录。 ConfigVersionConflictError: 配置版本冲突(并发修改)。 ConfigRollbackError: Redis 回滚失败。 """ try: scoped_key = self._build_key(cmd.key, cmd.scope, cmd.target) history_raw = await self._redis.lrange(f"{scoped_key}:history", 0, -1) target_entry: dict[str, Any] | None = None for raw in history_raw: entry = json.loads(raw) if entry.get("version") == cmd.target_version: target_entry = entry break if target_entry is None: raise NotFoundError( resource="config_version", id=f"{cmd.key}@v{cmd.target_version}", ) pipe = self._redis.pipeline() await pipe.watch(f"{scoped_key}:version") try: current_value_raw = await pipe.get(scoped_key) # 读取被归档版本的原始写入时间,作为历史条目的 updated_at # (而非本次回滚时间),确保历史时间戳语义正确。 old_updated_at_raw = await pipe.get(f"{scoped_key}:updated_at") version_str = await pipe.get(f"{scoped_key}:version") current_version = int(version_str) if version_str else 0 new_version = current_version + 1 updated_at = utc_now_naive() pipe.multi() # target_entry["value"] 已是 JSON 字符串(从历史记录反序列化得到), # 直接写入 Redis,避免 json.dumps 导致双重编码。 pipe.set(scoped_key, target_entry["value"]) pipe.set(f"{scoped_key}:version", str(new_version)) pipe.set(f"{scoped_key}:updated_at", updated_at.isoformat()) if current_value_raw is not None: pipe.lpush( f"{scoped_key}:history", json.dumps( { "version": current_version, "value": current_value_raw, "updated_at": old_updated_at_raw, }, ensure_ascii=False, ), ) try: result = await pipe.execute() except WatchError: result = None finally: pipe.reset() if result is None: raise ConfigVersionConflictError(cmd.target_version, current_version) failed_value = json.loads(current_value_raw) if current_value_raw is not None else None rollback_value = json.loads(target_entry["value"]) return ConfigUpdateResult( key=cmd.key, version=new_version, old_value=failed_value, new_value=rollback_value, updated_at=updated_at, ) except ( NotFoundError, ConfigVersionConflictError, ): raise except Exception as exc: raise ConfigRollbackError( key=cmd.key, reason=f"redis rollback failed: {exc}", ) from exc async def listHistory( self, key: str, scope: ConfigScope = ConfigScope.GLOBAL, target: str | None = None, ) -> tuple[ConfigHistoryEntry, ...]: """查询配置版本历史列表。 从 Redis ``{scoped_key}:history`` 列表读取历史版本条目,解析为 ``ConfigHistoryEntry`` 元组返回。历史条目按 LPUSH 顺序存储(最新 在前),适配器保持原始顺序返回。 兼容性:旧版历史条目未持久化 ``updated_at`` 字段,解析时缺失则 ``updated_at`` 为 ``None``。新版条目(适配器 update/rollback 写入)携带 ``updated_at`` ISO 字符串。 Args: key: 配置键。 scope: 配置作用域(默认 GLOBAL)。 target: 目标(channel_type / account_id / plugin_id,可选)。 Returns: ConfigHistoryEntry 元组;无历史记录时返回空元组。 """ try: scoped_key = self._build_key(key, scope, target) history_raw = await self._redis.lrange(f"{scoped_key}:history", 0, -1) entries: list[ConfigHistoryEntry] = [] for raw in history_raw: entry = json.loads(raw) updated_at_raw = entry.get("updated_at") updated_at: datetime | None = None if updated_at_raw is not None: try: updated_at = datetime.fromisoformat(updated_at_raw) except ValueError as exc: await self._logger.warn( "redis config history updated_at parse failed", key=key, raw_value=updated_at_raw, error=str(exc), ) updated_at = None # entry["value"] 为 JSON 字符串(写入时已序列化), # 反序列化为 Python 对象返回。 value = json.loads(entry["value"]) entries.append( ConfigHistoryEntry( version=entry["version"], value=value, updated_at=updated_at, ) ) return tuple(entries) except Exception as exc: raise DependencyError("redis", Error(str(exc))) from exc async def getHotReloadMode(self, key: str) -> ConfigHotReloadMode: """查询配置热更新模式。 当前实现固定返回 HYBRID,未来可扩展为从 Redis 读取可配置模式。 Args: key: 配置键。 Returns: ConfigHotReloadMode,固定 HYBRID。 """ return ConfigHotReloadMode.HYBRID async def getSchema(self, scope: ConfigScope = ConfigScope.GLOBAL) -> tuple[ConfigField, ...]: """查询配置 schema。 返回指定作用域的配置字段 schema,描述所有配置字段的元信息,包括 可热更新与不可热更新的配置字段。schema 元数据由契约层 ``config_schema.CONFIG_SCHEMA`` 集中定义,适配器直接引用,确保 单一真相源(§13.1 / §6.1)。 Args: scope: 配置作用域(默认 GLOBAL)。 Returns: ConfigField 元组,包含可热更新与不可热更新的配置字段。 """ return CONFIG_SCHEMA def _build_key(self, key: str, scope: ConfigScope, target: str | None) -> str: """构建 Redis 存储键。 依据作用域与目标构建分层配置键,支持全局、渠道级、账户级与插件级配置。 Args: key: 配置键。 scope: 配置作用域。 target: 目标(channel_type / account_id / plugin_id,可选)。 Returns: Redis 存储键字符串。 """ if scope == ConfigScope.GLOBAL: return f"config:global:{key}" if target is None: return f"config:{scope.value}:{key}" return f"config:{scope.value}:{target}:{key}" async def getStreamingConfig( self, scope: ConfigScope = ConfigScope.GLOBAL, target: str | None = None, ) -> StreamingConfig: """读取流式配置。 读取 ``streaming_enabled`` / ``enable_typing`` / ``streaming_min_chunk_interval_ms`` / ``streaming_ttl_seconds`` / ``typing_ttl_ms`` 五个配置项,未持久化时由 ``get`` 方法 返回 schema 默认值。``streaming_ttl_seconds`` 转换为毫秒返回, 与 ``min_chunk_interval_ms`` 单位一致。 Args: scope: 配置作用域(默认 GLOBAL)。 target: 目标(channel_type 或 account_id,可选)。 Returns: StreamingConfig,包含流式管道运行时配置。 Raises: DependencyError: Redis 依赖故障。 """ enabled_value = await self.get("streaming_enabled", scope, target) typing_enabled_value = await self.get("enable_typing", scope, target) interval_value = await self.get("streaming_min_chunk_interval_ms", scope, target) ttl_value = await self.get("streaming_ttl_seconds", scope, target) typing_ttl_value = await self.get("typing_ttl_ms", scope, target) return StreamingConfig( streaming_enabled=bool(enabled_value.value), enable_typing=bool(typing_enabled_value.value), min_chunk_interval_ms=int(interval_value.value), streaming_ttl_ms=int(ttl_value.value) * 1000, typing_ttl_ms=int(typing_ttl_value.value), ) def _getSchemaDefault(self, key: str) -> Any: """查询配置 schema 默认值。 从契约层 ``CONFIG_SCHEMA`` 字段元组中查找指定键的默认值,用于配置 未持久化时返回 schema 默认值(Task 30)。直接引用 ``CONFIG_SCHEMA`` 常量,避免在同步方法中调用 ``async getSchema``。 Args: key: 配置键。 Returns: 默认值;schema 中不存在或默认值为 None 时返回 None。 """ for field in CONFIG_SCHEMA: if field.key == key: return field.default return None async def ping(self) -> bool: """主动探测 Redis 连接可用性,故障时返回 False(降级不阻断)。 执行 Redis ``PING`` 验证连接可用性。故障时返回 False 并通过 ``self._logger`` 记录 warning 日志,不抛异常,供 ``HostBootstrap`` 启动期连通性检查使用。 Returns: True 表示连接可用,False 表示故障。 """ try: await self._redis.ping() return True except Exception as exc: await self._logger.warn( "redis config ping failed", error=str(exc), ) return False