"""配置版本迁移 — schema_version + 迁移规则 + 自动执行 + 回滚""" from __future__ import annotations import copy import logging from collections.abc import Callable from dataclasses import dataclass from typing import Any logger = logging.getLogger(__name__) CURRENT_SCHEMA_VERSION = 1 ConfigDict = dict[str, Any] MigrationFn = Callable[[ConfigDict], ConfigDict] @dataclass class MigrationRule: from_version: int to_version: int description: str migrate: MigrationFn _migration_registry: dict[int, MigrationRule] = {} def register_migration(from_version: int, to_version: int, description: str): """装饰器:注册配置迁移规则""" if to_version != from_version + 1: raise ValueError( f"迁移规则版本不连续: v{from_version} → v{to_version}, " f"期望 to_version={from_version + 1}" ) def decorator(fn: MigrationFn) -> MigrationFn: existing = _migration_registry.get(from_version) if existing is not None: logger.warning( "重复注册迁移规则 v%d → v%d: 旧规则 (%s) 将被覆盖为新规则 (%s)", from_version, to_version, existing.description, description, ) _migration_registry[from_version] = MigrationRule( from_version=from_version, to_version=to_version, description=description, migrate=fn, ) logger.info( "注册配置迁移规则: v%d → v%d (%s)", from_version, to_version, description, ) return fn return decorator def get_migration_path(current_version: int) -> list[MigrationRule]: if current_version >= CURRENT_SCHEMA_VERSION: return [] path: list[MigrationRule] = [] version = current_version while version < CURRENT_SCHEMA_VERSION: rule = _migration_registry.get(version) if rule is None: logger.warning("缺少迁移规则: v%d → v%d", version, version + 1) break path.append(rule) version = rule.to_version return path def migrate_config( config: ConfigDict, current_version: int | None = None, *, dry_run: bool = False, ) -> tuple[ConfigDict, int, list[MigrationRule]]: """执行配置迁移,返回 (新配置, 新版本号, 已应用的迁移规则列表)。 失败时自动恢复到原始配置(回滚)。 dry_run=True 时只返回迁移后的配置预览,不实际修改任何数据。 """ if current_version is None: version = config.get("schema_version", 0) else: version = current_version path = get_migration_path(version) if not path: return config, version, [] original = copy.deepcopy(config) working = copy.deepcopy(config) applied: list[MigrationRule] = [] last_version = version for rule in path: try: working = rule.migrate(working) working["schema_version"] = rule.to_version last_version = rule.to_version applied.append(rule) logger.info( "配置迁移成功: v%d → v%d (%s)%s", rule.from_version, rule.to_version, rule.description, " [dry-run]" if dry_run else "", ) except Exception: logger.exception( "配置迁移失败: v%d → v%d (%s), 回滚到 v%d", rule.from_version, rule.to_version, rule.description, version, ) return original, version, applied if dry_run: logger.info( "迁移 dry-run 完成: v%d → v%d, 共 %d 步", version, last_version, len(applied), ) return working, last_version, applied return working, last_version, applied def backup_config(config: ConfigDict, reason: str = "迁移前备份") -> ConfigDict: logger.debug("备份配置: %s", reason) return copy.deepcopy(config) # ── 内置迁移规则 ──────────────────────────────────────── @register_migration( from_version=0, to_version=1, description="初始化 schema_version 字段", ) def migration_v0_to_v1(config: ConfigDict) -> ConfigDict: result = dict(config) if "approval" not in result: result["approval"] = { "enabled": False, "approvers": [], "auto_approve_internal": True, } return result