from __future__ import annotations from enum import Enum from typing import Dict, List, Optional from pydantic import BaseModel, ConfigDict, Field class OrphanAction(str, Enum): CREATE_REMOTE = "create_remote" CREATE_LOCAL = "create_local" DELETE_LOCAL = "delete_local" DELETE_REMOTE = "delete_remote" NONE = "none" class UpdateDirection(str, Enum): PUSH = "push" PULL = "pull" BIDIRECTIONAL = "bidirectional" NONE = "none" class PreBindDataIdPair(BaseModel): model_config = ConfigDict(validate_assignment=True, extra="forbid") local_data_id: str remote_data_id: str class StrategyConfig(BaseModel): model_config = ConfigDict(validate_assignment=True, extra="forbid") auto_bind: bool = True auto_bind_fields: List[str] = Field(default_factory=list) pre_bind_data_id: List[PreBindDataIdPair] = Field(default_factory=list) depend_fields: Dict[str, str] = Field(default_factory=dict) local_orphan_action: OrphanAction = OrphanAction.NONE remote_orphan_action: OrphanAction = OrphanAction.NONE update_direction: UpdateDirection = UpdateDirection.PUSH # 字段级同步方向覆盖。 # field_direction_key: 节点 data 中用作查表键的字段名,如 "key"。 # field_direction_overrides: 字段值 → UpdateDirection 的映射。 # 未命中映射的节点沿用 update_direction 作为默认方向。 field_direction_key: Optional[str] = None field_direction_overrides: Dict[str, UpdateDirection] = Field(default_factory=dict) # post-check 一致性比较时忽略的顶层字段。 # 只影响最终一致性评估,不影响 create/update 的差异检测。 post_check_ignore_fields: List[str] = Field(default_factory=list) # post-check 一致性比较时,忽略列表型字段内各元素的特定子键。 # 格式: {字段名: [子键, ...]},如 {"plan_node": ["is_audit"]}。 # 用于排除后端硬写死、永远与本地不一致的字段,避免误报。 post_check_ignore_list_item_fields: Dict[str, List[str]] = Field(default_factory=dict) # schema diff / validate 时忽略的顶层字段。 compare_ignore_fields: List[str] = Field(default_factory=list) # 当 auto-bind 命中“多对一”候选时,允许稳定选择一条进行绑定,剩余候选按 orphan/create 流程继续处理。 # 默认关闭,避免在一般场景下引入隐式匹配。 allow_many_to_one_auto_bind: bool = False class StrategyRuntimeConfig(BaseModel): """Pipeline 层策略运行配置(按 node_type)。""" model_config = ConfigDict(validate_assignment=True, extra="forbid") node_type: str skip_sync: bool = False skip_post_check: bool = False config: StrategyConfig = Field(default_factory=StrategyConfig) class ConfigPresets: @staticmethod def first_sync() -> StrategyConfig: return StrategyConfig( local_orphan_action=OrphanAction.CREATE_REMOTE, remote_orphan_action=OrphanAction.CREATE_LOCAL, update_direction=UpdateDirection.PUSH, ) @staticmethod def daily_sync() -> StrategyConfig: return ConfigPresets.push_only() @staticmethod def repair_sync() -> StrategyConfig: return StrategyConfig( local_orphan_action=OrphanAction.NONE, remote_orphan_action=OrphanAction.NONE, update_direction=UpdateDirection.NONE, ) @staticmethod def pull_only() -> StrategyConfig: return StrategyConfig( local_orphan_action=OrphanAction.NONE, remote_orphan_action=OrphanAction.CREATE_LOCAL, update_direction=UpdateDirection.PULL, ) @staticmethod def push_only() -> StrategyConfig: return StrategyConfig( local_orphan_action=OrphanAction.CREATE_REMOTE, remote_orphan_action=OrphanAction.NONE, update_direction=UpdateDirection.PUSH, ) @staticmethod def get_preset(name: str) -> StrategyConfig: presets = { "first_sync": ConfigPresets.first_sync, "daily_sync": ConfigPresets.daily_sync, "repair_sync": ConfigPresets.repair_sync, "pull_only": ConfigPresets.pull_only, "push_only": ConfigPresets.push_only, } if name not in presets: raise ValueError(f"Unknown preset: {name}. Available: {list(presets.keys())}") return presets[name]()