"""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 collections.abc import Mapping 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, ConfigValidationError, 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"] # CONFIG_SCHEMA 已声明键集合(§13.1 单一真相源):作为无 registry 注入时的 # 向后兼容回退。正常路径下 _assertDeclared 通过 registry.declared_keys 动态 # 查询,使插件 manifest 声明的键也能通过校验。 _DECLARED_KEYS: frozenset[str] = frozenset(f.key for f in CONFIG_SCHEMA) # 哨兵:区分"key 不在 CONFIG_SCHEMA 中"(插件 manifest 键)与"default=None" # (合法默认值)。_getSchemaDefault 返回 _MISSING 表示前者,返回 None 表示 # 后者,避免把 default=None 误判为"无默认值"而抛 NotFoundError。 _MISSING: Any = object() 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, key_to_scope_map: Mapping[str, ConfigScope] | None = None, declared_keys: set[str] | None = None, ) -> None: """初始化 Redis 配置适配器。 Args: redis_client: ``redis.asyncio.Redis`` 客户端实例,由外部构造并 注入;适配器不管理其生命周期(构造注入,INV-5)。需使用 ``decode_responses=True`` 创建,保证读写值为字符串。 logger: 日志被驱动端口,强制注入,用于记录降级分支异常日志 (INV-10 可观测性)。 key_to_scope_map: F-02 配置作用域映射(``key → ConfigScope``), 由 ``ConfigScopeRegistry.key_to_scope_map`` 注入(只读视图)。 ``_build_key`` 在 key 存在于映射时校验声明作用域与实际作用域 一致性,不一致产生 WARNING 日志(不阻断读写)。为 ``None`` 时不 校验(向后兼容)。registry 通过 ``dict.update`` 原地修改内部 dict,adapter 持有的只读视图在 discover 后可见插件声明的作用域。 declared_keys: 已声明的配置键集合,由 ``ConfigScopeRegistry._declared_keys`` 注入(内部 ``set`` 引用)。 ``_assertDeclared`` 通过此集合校验键合法性,使插件 manifest 声明的键也能通过校验。registry 通过 ``set.add`` 原地修改内部 set,adapter 持有的引用在 discover 后可见插件键。为 ``None`` 时 回退到模块级 ``_DECLARED_KEYS``(仅含 CONFIG_SCHEMA 键,向后兼容)。 """ self._redis = redis_client self._logger = logger self._key_to_scope_map: Mapping[str, ConfigScope] | None = key_to_scope_map self._declared_keys: set[str] | None = declared_keys 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 依赖故障。 ConfigValidationError: 键未在 CONFIG_SCHEMA 中声明。 """ self._assertDeclared(key) try: scoped_key = await self._build_key(key, scope, target) value = await self._redis.get(scoped_key) actual_key = scoped_key # PLUGIN 作用域配置独立,不回退到全局配置 # skip_scope_check=True:回退到 GLOBAL 时,声明作用域(如 CHANNEL) # 与实际作用域(GLOBAL)必然不一致,此为正常回退语义而非配置错误, # 跳过 F-02 校验避免误报 "config scope mismatch" 告警。 if value is None and scope != ConfigScope.PLUGIN: actual_key = await self._build_key(key, ConfigScope.GLOBAL, None, skip_scope_check=True) value = await self._redis.get(actual_key) if value is None: default = self._getSchemaDefault(key) if default is not _MISSING: # schema 中声明了该 key(含 default=None 的合法默认值), # 返回 schema 默认值;仅当 key 不在 CONFIG_SCHEMA 中 # (插件 manifest 键且未持久化)时才抛 NotFoundError。 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 依赖故障。 ConfigValidationError: 键未在 CONFIG_SCHEMA 中声明。 """ self._assertDeclared(cmd.key) try: scoped_key = await 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: await 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``,避免误报"刚刚更新"。 """ self._assertDeclared(key) try: scoped_key = await 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 回滚失败。 ConfigValidationError: 键未在 CONFIG_SCHEMA 中声明。 """ self._assertDeclared(cmd.key) try: scoped_key = await 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: await 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 元组;无历史记录时返回空元组。 """ self._assertDeclared(key) try: scoped_key = await 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 _assertDeclared(self, key: str) -> None: """校验配置键已声明(§13.1 单一真相源)。 正常路径下通过 ``ConfigScopeRegistry`` 注入的 ``declared_keys`` 集合 动态校验,该集合聚合了 ``CONFIG_SCHEMA`` 全部键与插件 ``manifest.config_schema`` 全部键,使插件专有键(如 corp_id、 bridge_url)也能通过校验——manifest 真正成为插件配置的单真相源 (F-03)。 未注入 ``declared_keys`` 时回退到模块级 ``_DECLARED_KEYS``(仅含 CONFIG_SCHEMA 键,向后兼容旧测试 / 独立构造场景)。 未声明 key 直接拒绝,不访问 Redis,防止绕过 schema 直接写 Redis 的脏值被读回(破坏单一真相源)。 Args: key: 配置键。 Raises: ConfigValidationError: 键未声明。 """ declared = self._declared_keys if self._declared_keys is not None else _DECLARED_KEYS if key not in declared: raise ConfigValidationError([f"Config key '{key}' is not declared in CONFIG_SCHEMA"]) async def _build_key( self, key: str, scope: ConfigScope, target: str | None, *, skip_scope_check: bool = False, ) -> str: """构建 Redis 存储键并校验作用域一致性(F-02)。 依据作用域与目标构建分层配置键,支持全局、渠道级、账户级与插件级配置。 F-02 作用域一致性校验:当 ``key_to_scope_map`` 已注入且 ``key`` 在 映射中声明了作用域时,校验声明作用域与传入的实际作用域是否一致。 不一致时产生 WARNING 日志(含 key 名、声明作用域、实际作用域), **不阻断读写**(向后兼容,允许迁移期存在暂时不一致)。未声明作用域 的 key 不产生告警(向后兼容)。 ``skip_scope_check=True`` 用于 ``get`` 方法的 GLOBAL 回退路径——该 回退是设计内的降级行为(CHANNEL/ACCOUNT 未命中时查 GLOBAL),并非 作用域使用错误,不应触发 F-02 告警。 Args: key: 配置键。 scope: 配置作用域(实际读写作用域)。 target: 目标(channel_type / account_id / plugin_id,可选)。 skip_scope_check: 跳过 F-02 校验(仅 ``get`` 回退路径使用)。 Returns: Redis 存储键字符串。 """ if not skip_scope_check and self._key_to_scope_map is not None: declared_scope = self._key_to_scope_map.get(key) if declared_scope is not None and declared_scope != scope: await self._logger.warn( "config scope mismatch", key=key, declared_scope=declared_scope.value, actual_scope=scope.value, target=target or "", ) 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: 默认值(可能为 ``None``,表示 ``default=None`` 的合法默认值); key 不在 ``CONFIG_SCHEMA`` 中时返回 ``_MISSING`` 哨兵, 由调用方据此抛 ``NotFoundError``。 """ for field in CONFIG_SCHEMA: if field.key == key: return field.default return _MISSING 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