2026-07-02 03:22:12 +08:00
|
|
|
|
"""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
|
2026-07-06 20:49:35 +08:00
|
|
|
|
from collections.abc import Mapping
|
2026-07-02 03:22:12 +08:00
|
|
|
|
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,
|
2026-07-03 19:18:13 +08:00
|
|
|
|
NotFoundError,
|
2026-07-02 03:22:12 +08:00
|
|
|
|
)
|
|
|
|
|
|
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
|
|
|
|
|
|
|
2026-07-03 19:18:13 +08:00
|
|
|
|
__all__ = ["RedisConfigAdapter"]
|
|
|
|
|
|
|
2026-07-02 03:22:12 +08:00
|
|
|
|
|
|
|
|
|
|
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),适配器不管理其生命周期。
|
2026-07-03 19:18:13 +08:00
|
|
|
|
通过 ``LoggerPort`` 记录日志(INV-7 / 端口抽象),logger 强制注入。
|
2026-07-02 03:22:12 +08:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(
|
|
|
|
|
|
self,
|
|
|
|
|
|
redis_client: Redis,
|
2026-07-03 19:18:13 +08:00
|
|
|
|
logger: LoggerPort,
|
2026-07-06 20:49:35 +08:00
|
|
|
|
key_to_scope_map: Mapping[str, ConfigScope] | None = None,
|
2026-07-02 03:22:12 +08:00
|
|
|
|
) -> None:
|
|
|
|
|
|
"""初始化 Redis 配置适配器。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
redis_client: ``redis.asyncio.Redis`` 客户端实例,由外部构造并
|
|
|
|
|
|
注入;适配器不管理其生命周期(构造注入,INV-5)。需使用
|
|
|
|
|
|
``decode_responses=True`` 创建,保证读写值为字符串。
|
2026-07-03 19:18:13 +08:00
|
|
|
|
logger: 日志被驱动端口,强制注入,用于记录降级分支异常日志
|
|
|
|
|
|
(INV-10 可观测性)。
|
2026-07-06 20:49:35 +08:00
|
|
|
|
key_to_scope_map: F-02 配置作用域映射(``key → ConfigScope``),
|
|
|
|
|
|
由 ``ConfigScopeRegistry.key_to_scope_map`` 注入(只读视图)。
|
|
|
|
|
|
``_build_key`` 在 key 存在于映射时校验声明作用域与实际作用域
|
|
|
|
|
|
一致性,不一致产生 WARNING 日志(不阻断读写)。为 ``None`` 时不
|
|
|
|
|
|
校验(向后兼容)。registry 通过 ``dict.update`` 原地修改内部
|
|
|
|
|
|
dict,adapter 持有的只读视图在 discover 后可见插件声明的作用域。
|
2026-07-02 03:22:12 +08:00
|
|
|
|
"""
|
|
|
|
|
|
self._redis = redis_client
|
|
|
|
|
|
self._logger = logger
|
2026-07-06 20:49:35 +08:00
|
|
|
|
self._key_to_scope_map: Mapping[str, ConfigScope] | None = key_to_scope_map
|
2026-07-02 03:22:12 +08:00
|
|
|
|
|
|
|
|
|
|
async def get(
|
|
|
|
|
|
self,
|
|
|
|
|
|
key: str,
|
|
|
|
|
|
scope: ConfigScope = ConfigScope.GLOBAL,
|
|
|
|
|
|
target: str | None = None,
|
|
|
|
|
|
) -> ConfigValue:
|
|
|
|
|
|
"""读取配置值。
|
|
|
|
|
|
|
|
|
|
|
|
从 Redis 读取指定作用域与目标的配置值。若作用域级别不存在,回退到
|
|
|
|
|
|
全局配置(PLUGIN 作用域除外——插件配置独立,不回退全局)。若全局
|
|
|
|
|
|
配置也不存在,返回 schema 默认值(若定义);否则抛
|
2026-07-03 19:18:13 +08:00
|
|
|
|
``NotFoundError``(HTTP 404),与 driving 端口 ``getConfig @failure``
|
|
|
|
|
|
声明一致。
|
2026-07-02 03:22:12 +08:00
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
key: 配置键。
|
|
|
|
|
|
scope: 配置作用域(默认 GLOBAL)。
|
|
|
|
|
|
target: 目标(channel_type / account_id / plugin_id,可选)。
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
ConfigValue,包含键、值、版本与作用域。
|
|
|
|
|
|
|
|
|
|
|
|
Raises:
|
2026-07-03 19:18:13 +08:00
|
|
|
|
NotFoundError: 配置不存在且 schema 中无默认值。
|
2026-07-02 03:22:12 +08:00
|
|
|
|
DependencyError: Redis 依赖故障。
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
2026-07-06 20:49:35 +08:00
|
|
|
|
scoped_key = await self._build_key(key, scope, target)
|
2026-07-02 03:22:12 +08:00
|
|
|
|
value = await self._redis.get(scoped_key)
|
|
|
|
|
|
actual_key = scoped_key
|
|
|
|
|
|
# PLUGIN 作用域配置独立,不回退到全局配置
|
2026-07-08 03:57:05 +08:00
|
|
|
|
# skip_scope_check=True:回退到 GLOBAL 时,声明作用域(如 CHANNEL)
|
|
|
|
|
|
# 与实际作用域(GLOBAL)必然不一致,此为正常回退语义而非配置错误,
|
|
|
|
|
|
# 跳过 F-02 校验避免误报 "config scope mismatch" 告警。
|
2026-07-02 03:22:12 +08:00
|
|
|
|
if value is None and scope != ConfigScope.PLUGIN:
|
2026-07-08 03:57:05 +08:00
|
|
|
|
actual_key = await self._build_key(key, ConfigScope.GLOBAL, None, skip_scope_check=True)
|
2026-07-02 03:22:12 +08:00
|
|
|
|
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,
|
|
|
|
|
|
)
|
2026-07-03 19:18:13 +08:00
|
|
|
|
raise NotFoundError(
|
|
|
|
|
|
resource="config",
|
|
|
|
|
|
id=key,
|
|
|
|
|
|
)
|
2026-07-02 03:22:12 +08:00
|
|
|
|
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,
|
|
|
|
|
|
)
|
2026-07-03 19:18:13 +08:00
|
|
|
|
except NotFoundError:
|
2026-07-02 03:22:12 +08:00
|
|
|
|
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:
|
2026-07-06 20:49:35 +08:00
|
|
|
|
scoped_key = await self._build_key(cmd.key, cmd.scope, cmd.target)
|
2026-07-02 03:22:12 +08:00
|
|
|
|
|
|
|
|
|
|
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:
|
2026-07-06 20:49:35 +08:00
|
|
|
|
await pipe.reset()
|
2026-07-02 03:22:12 +08:00
|
|
|
|
|
|
|
|
|
|
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:
|
2026-07-06 20:49:35 +08:00
|
|
|
|
scoped_key = await self._build_key(key, scope, target)
|
2026-07-02 03:22:12 +08:00
|
|
|
|
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)
|
2026-07-03 19:18:13 +08:00
|
|
|
|
except ValueError as exc:
|
|
|
|
|
|
await self._logger.warn(
|
|
|
|
|
|
"redis config updated_at parse failed",
|
|
|
|
|
|
key=key,
|
|
|
|
|
|
raw_value=updated_at_raw,
|
|
|
|
|
|
error=str(exc),
|
|
|
|
|
|
)
|
2026-07-02 03:22:12 +08:00
|
|
|
|
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:
|
2026-07-03 19:18:13 +08:00
|
|
|
|
NotFoundError: 目标版本不存在于历史记录。
|
2026-07-02 03:22:12 +08:00
|
|
|
|
ConfigVersionConflictError: 配置版本冲突(并发修改)。
|
|
|
|
|
|
ConfigRollbackError: Redis 回滚失败。
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
2026-07-06 20:49:35 +08:00
|
|
|
|
scoped_key = await self._build_key(cmd.key, cmd.scope, cmd.target)
|
2026-07-02 03:22:12 +08:00
|
|
|
|
|
|
|
|
|
|
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:
|
2026-07-03 19:18:13 +08:00
|
|
|
|
raise NotFoundError(
|
|
|
|
|
|
resource="config_version",
|
|
|
|
|
|
id=f"{cmd.key}@v{cmd.target_version}",
|
|
|
|
|
|
)
|
2026-07-02 03:22:12 +08:00
|
|
|
|
|
|
|
|
|
|
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:
|
2026-07-06 20:49:35 +08:00
|
|
|
|
await pipe.reset()
|
2026-07-02 03:22:12 +08:00
|
|
|
|
|
|
|
|
|
|
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 (
|
2026-07-03 19:18:13 +08:00
|
|
|
|
NotFoundError,
|
2026-07-02 03:22:12 +08:00
|
|
|
|
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:
|
2026-07-06 20:49:35 +08:00
|
|
|
|
scoped_key = await self._build_key(key, scope, target)
|
2026-07-02 03:22:12 +08:00
|
|
|
|
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)
|
2026-07-03 19:18:13 +08:00
|
|
|
|
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),
|
|
|
|
|
|
)
|
2026-07-02 03:22:12 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-07-08 03:57:05 +08:00
|
|
|
|
async def _build_key(
|
|
|
|
|
|
self,
|
|
|
|
|
|
key: str,
|
|
|
|
|
|
scope: ConfigScope,
|
|
|
|
|
|
target: str | None,
|
|
|
|
|
|
*,
|
|
|
|
|
|
skip_scope_check: bool = False,
|
|
|
|
|
|
) -> str:
|
2026-07-06 20:49:35 +08:00
|
|
|
|
"""构建 Redis 存储键并校验作用域一致性(F-02)。
|
2026-07-02 03:22:12 +08:00
|
|
|
|
|
|
|
|
|
|
依据作用域与目标构建分层配置键,支持全局、渠道级、账户级与插件级配置。
|
|
|
|
|
|
|
2026-07-06 20:49:35 +08:00
|
|
|
|
F-02 作用域一致性校验:当 ``key_to_scope_map`` 已注入且 ``key`` 在
|
|
|
|
|
|
映射中声明了作用域时,校验声明作用域与传入的实际作用域是否一致。
|
|
|
|
|
|
不一致时产生 WARNING 日志(含 key 名、声明作用域、实际作用域),
|
|
|
|
|
|
**不阻断读写**(向后兼容,允许迁移期存在暂时不一致)。未声明作用域
|
|
|
|
|
|
的 key 不产生告警(向后兼容)。
|
|
|
|
|
|
|
2026-07-08 03:57:05 +08:00
|
|
|
|
``skip_scope_check=True`` 用于 ``get`` 方法的 GLOBAL 回退路径——该
|
|
|
|
|
|
回退是设计内的降级行为(CHANNEL/ACCOUNT 未命中时查 GLOBAL),并非
|
|
|
|
|
|
作用域使用错误,不应触发 F-02 告警。
|
|
|
|
|
|
|
2026-07-02 03:22:12 +08:00
|
|
|
|
Args:
|
|
|
|
|
|
key: 配置键。
|
2026-07-06 20:49:35 +08:00
|
|
|
|
scope: 配置作用域(实际读写作用域)。
|
2026-07-02 03:22:12 +08:00
|
|
|
|
target: 目标(channel_type / account_id / plugin_id,可选)。
|
2026-07-08 03:57:05 +08:00
|
|
|
|
skip_scope_check: 跳过 F-02 校验(仅 ``get`` 回退路径使用)。
|
2026-07-02 03:22:12 +08:00
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
Redis 存储键字符串。
|
|
|
|
|
|
"""
|
2026-07-08 03:57:05 +08:00
|
|
|
|
if not skip_scope_check and self._key_to_scope_map is not None:
|
2026-07-06 20:49:35 +08:00
|
|
|
|
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 "",
|
|
|
|
|
|
)
|
2026-07-02 03:22:12 +08:00
|
|
|
|
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:
|
2026-07-03 19:18:13 +08:00
|
|
|
|
await self._logger.warn(
|
|
|
|
|
|
"redis config ping failed",
|
|
|
|
|
|
error=str(exc),
|
|
|
|
|
|
)
|
2026-07-02 03:22:12 +08:00
|
|
|
|
return False
|