ForcePilot/backend/package/yuxi/channels/adapters/redis_config_adapter.py
Kris c7eb196a7e chore: 批量完成多模块迭代优化与功能完善
本次提交涵盖了近百处代码优化与功能补全,包括:
1. 完善配置与数据模型:新增expired_at配对记录字段、路由绑定乐观锁版本控制、会话路由信息追踪字段
2. 优化业务流程:添加幂等记录操作人审计、会话合并领域服务文档更新、媒体处理异步化改造
3. 新增功能能力:健康检查时间更新、会话路由信息更新接口、内容审核/幂等记录清理定时任务
4. 修复与简化:移除废弃的max_message_length属性、修复微信iLink适配器配置读取路径、简化配对过期扫描逻辑
5. 代码规范优化:统一敏感词检测工具导入、完善事务上下文处理注释、调整wechat_woc入站适配器sender回退逻辑
2026-07-08 03:57:05 +08:00

588 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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,
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,
key_to_scope_map: Mapping[str, ConfigScope] | 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`` 原地修改内部
dictadapter 持有的只读视图在 discover 后可见插件声明的作用域。
"""
self._redis = redis_client
self._logger = logger
self._key_to_scope_map: Mapping[str, ConfigScope] | None = key_to_scope_map
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 = 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 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 = 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``,避免误报"刚刚更新"
"""
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 回滚失败。
"""
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 元组;无历史记录时返回空元组。
"""
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
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:
默认值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