"""配置热更新 DTO。 定义配置热更新端口的命令与值对象,包括配置热更新模式、配置作用域、 配置版本、配置字段、更新 / 回滚配置命令与配置值。所有 DTO 均为 ``dataclass(frozen=True)``,仅依赖标准库与契约层内部类型,用于配置 热更新(FR-37)。 """ from __future__ import annotations from dataclasses import dataclass from datetime import datetime from enum import StrEnum from typing import Any, Literal from yuxi.channels.contract.dtos.common import BatchOperationFailure, Operator from yuxi.channels.contract.errors import ValidationError def _normalize_config_field_type(type_value: str) -> str: """将配置字段类型别名归一化为规范值。""" mapping = { "string": "str", "integer": "int", "boolean": "bool", "enum": "str", } return mapping.get(type_value, type_value) class ConfigHotReloadMode(StrEnum): """配置热更新模式。 标识配置变更的热更新策略,用于控制配置变更的生效方式。继承 ``str, Enum`` 以支持 JSON 序列化与字符串比较。 取值: OFF: 不热更新。 HOT: 所有可热更新的配置项均热更新。 RESTART: 所有变更触发重启。 HYBRID: 混合模式(默认)。 """ OFF = "off" HOT = "hot" RESTART = "restart" HYBRID = "hybrid" class ConfigScope(StrEnum): """配置作用域。 标识配置项的作用域层级,用于配置管理与作用域过滤。继承 ``str, Enum`` 以支持 JSON 序列化与字符串比较。 取值: GLOBAL: 全局配置。 CHANNEL: 渠道级配置。 ACCOUNT: 账户级配置。 PLUGIN: 插件级配置(target 为 plugin_id)。 """ GLOBAL = "global" CHANNEL = "channel" ACCOUNT = "account" PLUGIN = "plugin" @dataclass(frozen=True) class ConfigVersion: """配置版本。 描述配置项的版本信息,包括键、版本号与更新时间,用于乐观并发控制 与版本回溯。 字段: key: 配置键。 version: 版本号。 updated_at: 更新时间;配置不存在(version=0)时为 None, 避免误报"刚刚更新"。 """ key: str version: int updated_at: datetime | None @dataclass(frozen=True) class ConfigField: """配置字段。 描述配置项的字段元信息,包括键、类型、是否必填、默认值、是否可热 更新、是否敏感、作用域与约束,用于配置校验与热更新决策。 字段: key: 配置键。 type: 字段类型(str | int | bool | json)。 required: 是否必填(默认 False)。 default: 默认值(默认 None)。 hot_reloadable: 是否可热更新(默认 True)。 sensitive: 是否为敏感字段(默认 False)。敏感字段在审计日志、 配置变更事件序列化与配置导出时执行脱敏或过滤,由 ``SensitiveFieldRegistry`` 派生为 ``key → sensitive`` 映射 供消费点查询(F-01)。 scope: 配置作用域(默认 None,向后兼容)。声明该 key 应当归属的 作用域层级,由 ``ConfigScopeRegistry`` 派生为 ``key → ConfigScope`` 映射,供 ``RedisConfigAdapter._build_key`` 在读写时校验作用域一致性——声明作用域与实际作用域不一致时产生 WARNING 日志,不阻断读写(F-02)。未声明(None)时不产生告警。 constraints: 约束(可选)。 title: 配置项中文显示名称(可选),供管理后台展示。 description: 配置项说明(可选),供管理后台展示帮助文案。 category: 配置项分组(可选),用于管理后台按功能分组展示。 """ key: str type: Literal["str", "int", "bool", "json"] required: bool = False default: Any = None hot_reloadable: bool = True sensitive: bool = False scope: ConfigScope | None = None constraints: dict[str, Any] | None = None title: str | None = None description: str | None = None category: str | None = None def __post_init__(self) -> None: """将非标准类型别名归一化为契约声明的规范值。 manifest.json 与部分插件入口可能使用 ``string`` / ``integer`` / ``boolean`` / ``enum`` 等别名;统一转为 ``str`` / ``int`` / ``bool``, 其中 ``enum`` 视为受约束的字符串,由 ``constraints.values`` 描述可选值。 """ normalized = _normalize_config_field_type(self.type) if normalized != self.type: object.__setattr__(self, "type", normalized) @dataclass(frozen=True) class UpdateConfigCmd: """更新配置命令(FR-37)。 由配置端口方法引用,描述一次配置更新请求,携带键、值、作用域、目标、 期望版本与操作人,用于配置更新与乐观并发控制。 字段: key: 配置键。 value: 配置值。 operator: 操作人(审计用)。 scope: 配置作用域(默认 GLOBAL)。 target: 目标(channel_type / account_id / plugin_id,可选)。 expected_version: 期望版本(可选,用于乐观并发控制)。 """ key: str value: Any operator: Operator scope: ConfigScope = ConfigScope.GLOBAL target: str | None = None expected_version: int | None = None def __post_init__(self) -> None: """校验必填字段非空与版本号合理性(FR-37)。 ``key`` 必须非空,``expected_version`` 提供时必须为正整数, ``scope`` 为 ``channel`` / ``account`` / ``plugin`` 时 ``target`` 必须非空。在构造时即抛出 ``ValidationError``,adapter 不再做该校验(INV-8)。 """ if not self.key: raise ValidationError("key", "must not be empty") if self.expected_version is not None and self.expected_version <= 0: raise ValidationError("expected_version", "must be positive") if self.scope in (ConfigScope.CHANNEL, ConfigScope.ACCOUNT, ConfigScope.PLUGIN) and not self.target: raise ValidationError( "target", "must not be empty when scope is channel, account or plugin", ) @dataclass(frozen=True) class RollbackConfigCmd: """回滚配置命令(FR-37)。 由配置端口方法引用,描述一次配置回滚请求,携带键、目标版本、作用域、 目标与操作人,用于配置版本回滚。 字段: key: 配置键。 target_version: 目标版本。 operator: 操作人(审计用)。 scope: 配置作用域(默认 GLOBAL)。 target: 目标(channel_type / account_id / plugin_id,可选)。 """ key: str target_version: int operator: Operator scope: ConfigScope = ConfigScope.GLOBAL target: str | None = None def __post_init__(self) -> None: """校验必填字段非空与目标版本号合理性(FR-37)。 ``key`` 必须非空,``target_version`` 必须为正整数, ``scope`` 为 ``channel`` / ``account`` / ``plugin`` 时 ``target`` 必须非空。在构造时即抛出 ``ValidationError``,adapter 不再做该校验(INV-8)。 """ if not self.key: raise ValidationError("key", "must not be empty") if self.target_version <= 0: raise ValidationError("target_version", "must be positive") if self.scope in (ConfigScope.CHANNEL, ConfigScope.ACCOUNT, ConfigScope.PLUGIN) and not self.target: raise ValidationError( "target", "must not be empty when scope is channel, account or plugin", ) @dataclass(frozen=True) class ConfigValue: """配置值。 描述配置项的当前值,包括键、值、版本与作用域,用于配置查询与通知。 字段: key: 配置键。 value: 配置值。 version: 版本号。 scope: 配置作用域。 """ key: str value: Any version: int scope: ConfigScope @dataclass(frozen=True) class ConfigHistoryEntry: """配置历史版本条目。 描述配置项的单个历史版本信息,包括版本号、值与更新时间,用于配置 版本历史查询(FR-37)。``updated_at`` 在历史条目未持久化时间戳时 为 ``None``(兼容历史数据)。 字段: version: 版本号。 value: 配置值(已反序列化的 Python 对象)。 updated_at: 更新时间;历史数据未存储时间戳时为 ``None``。 """ version: int value: Any updated_at: datetime | None @dataclass(frozen=True) class ConfigExportCmd: """配置导出命令(CFG-EXPORT)。 由 ``ConfigManagementPort.exportConfig`` 引用,导出指定作用域的配置, 需记录操作人以满足审计要求(超级管理员)。 字段: operator: 操作人(审计用)。 scope: 配置作用域(默认 GLOBAL)。 target: 目标(channel_type 或 account_id,可选)。 """ operator: Operator scope: ConfigScope = ConfigScope.GLOBAL target: str | None = None @dataclass(frozen=True) class ConfigExportResult: """配置导出结果(CFG-EXPORT)。 字段: scope: 配置作用域。 config_data: 导出的配置数据。 version: 导出格式版本。 exported_at: 导出时间戳。 target: 目标(可选)。 """ scope: ConfigScope config_data: dict[str, Any] version: int exported_at: datetime target: str | None = None @dataclass(frozen=True) class ImportConfigCmd: """配置导入命令(CFG-IMPORT)。 由 ``ConfigManagementPort.importConfig`` 引用,逐条校验 schema 并写入, 需记录操作人以满足审计要求(超级管理员)。 字段: operator: 操作人(审计用)。 config_data: 导入的配置数据。 scope: 配置作用域(默认 GLOBAL)。 target: 目标(可选)。 overwrite: 是否覆盖已存在 key(默认 False)。 dry_run: 是否仅预校验不写入(默认 False)。 """ operator: Operator config_data: dict[str, Any] scope: ConfigScope = ConfigScope.GLOBAL target: str | None = None overwrite: bool = False dry_run: bool = False @dataclass(frozen=True) class ImportConfigResult: """配置导入结果(CFG-IMPORT)。 字段: imported_count: 成功导入数。 skipped_count: 跳过数(overwrite=false 时已存在)。 failed_count: 失败数。 failed_keys: 失败的配置 key 元组。 imported_at: 导入时间戳。 """ imported_count: int skipped_count: int failed_count: int failed_keys: tuple[str, ...] imported_at: datetime @dataclass(frozen=True) class ConfigUpdateItem: """单条配置更新项(CFG-BATCH-UPDATE)。 描述批量更新中的单条配置项,含 key、value 与期望版本(乐观并发控制)。 字段: key: 配置键。 value: 配置值。 expected_version: 期望版本(可选,用于乐观并发控制)。 """ key: str value: Any expected_version: int | None = None @dataclass(frozen=True) class BatchConfigUpdateSuccessItem: """批量配置更新成功条目(CFG-BATCH-UPDATE)。 字段: key: 配置键。 new_version: 更新后的新版本号。 """ key: str new_version: int @dataclass(frozen=True) class BatchUpdateConfigCmd: """批量更新配置命令(CFG-BATCH-UPDATE)。 由 ``ConfigManagementPort.batchUpdateConfig`` 引用,逐条复用 config/update 逻辑(版本校验、hot-reload 检查),需记录操作人。 字段: operator: 操作人(审计用)。 updates: 配置更新项元组(必填,1-50,key 不可重复)。 scope: 配置作用域(默认 GLOBAL)。 target: 目标(可选)。 stop_on_error: 首条失败即终止(默认 False,逐条独立事务)。 """ operator: Operator updates: tuple[ConfigUpdateItem, ...] scope: ConfigScope = ConfigScope.GLOBAL target: str | None = None stop_on_error: bool = False def __post_init__(self) -> None: """校验 updates 非空、长度上限与 key 唯一性(FR-37)。 ``updates`` 必须非空且长度不超过 50;同一批次内 ``key`` 不可重复, 否则 ``BatchExecutor`` 按 key 反查 update item 时会重复命中第一条, 导致后续同 key 条目的 value 被静默丢弃(INV-8)。 """ if not self.updates: raise ValidationError("updates", "must not be empty") if len(self.updates) > 50: raise ValidationError("updates", "must not exceed 50 items") keys = [item.key for item in self.updates] if not all(keys): raise ValidationError("key", "must not be empty") seen: set[str] = set() for key in keys: if key in seen: raise ValidationError( "updates", f"duplicate key in batch update: {key}", ) seen.add(key) if self.scope in (ConfigScope.CHANNEL, ConfigScope.ACCOUNT, ConfigScope.PLUGIN) and not self.target: raise ValidationError( "target", "must not be empty when scope is channel, account or plugin", ) @dataclass(frozen=True) class BatchUpdateConfigResult: """批量更新配置结果(CFG-BATCH-UPDATE)。 描述批量更新配置的执行结果,``failed`` 使用通用 ``BatchOperationFailure``(``id`` 字段承载 key)。 字段: total: 待更新配置总数。 succeeded: 成功条目元组。 failed: 失败条目元组。 """ total: int succeeded: tuple[BatchConfigUpdateSuccessItem, ...] failed: tuple[BatchOperationFailure, ...]