first commit

This commit is contained in:
strepsiades
2026-03-09 16:31:42 +08:00
commit 4a9c444b10
486 changed files with 52479 additions and 0 deletions
+57
View File
@@ -0,0 +1,57 @@
"""Public package entrypoint for the ECM sync engine."""
from __future__ import annotations
from importlib.metadata import PackageNotFoundError, version
from pathlib import Path
import sys
_PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(_PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(_PROJECT_ROOT))
from . import domain as _domain
from .common.registry import DomainRegistry
from .config import (
ApiDataSourceConfig,
JsonlDataSourceConfig,
PipelineRunConfig,
build_config,
build_default_config,
load_named_preset_overrides,
load_overrides_from_file,
)
from .datasource import ApiClient, ApiDataSource, BaseApiHandler, BaseJsonlHandler, JsonlDataSource
from .pipeline import FullSyncPipeline, create_pipeline_from_config, run_pipeline_from_config
try:
__version__ = version("ecm-sync-system")
except PackageNotFoundError:
__version__ = "0.1.0"
def ensure_domain_registry_loaded() -> None:
"""Ensure built-in domain registrations are imported."""
_domain # keep module import for side effects
__all__ = [
"__version__",
"ensure_domain_registry_loaded",
"DomainRegistry",
"PipelineRunConfig",
"ApiDataSourceConfig",
"JsonlDataSourceConfig",
"build_config",
"build_default_config",
"load_overrides_from_file",
"load_named_preset_overrides",
"ApiClient",
"ApiDataSource",
"JsonlDataSource",
"BaseApiHandler",
"BaseJsonlHandler",
"FullSyncPipeline",
"create_pipeline_from_config",
"run_pipeline_from_config",
]
+65
View File
@@ -0,0 +1,65 @@
from __future__ import annotations
import argparse
import asyncio
import logging
from pathlib import Path
from .config import build_config, load_overrides_from_file
from .pipeline import run_pipeline_from_config
def package_project_root() -> Path:
return Path(__file__).resolve().parents[1]
def resolve_config_path(raw_path: str, *, project_root: Path) -> Path:
path = Path(raw_path)
if not path.is_absolute():
path = project_root / path
return path.resolve()
async def _main() -> int:
project_root = package_project_root()
parser = argparse.ArgumentParser(description="Run sync pipeline from config override file")
parser.add_argument(
"--config_path",
required=True,
help="Path to a JSON/YAML run profile. Relative paths are resolved from the repository root.",
)
args = parser.parse_args()
cfg_path = resolve_config_path(args.config_path, project_root=project_root)
if not cfg_path.exists() or not cfg_path.is_file():
raise FileNotFoundError(f"Config file not found: {cfg_path}")
overrides = load_overrides_from_file(cfg_path, project_root)
config = build_config(project_root=project_root, overrides=overrides)
mode = f"{config.local_datasource.type}->{config.remote_datasource.type}"
print(f"🧩 Loaded config file: {cfg_path}")
await run_pipeline_from_config(
config,
title=f"sync_state_machine pipeline ({mode})",
print_summary=True,
)
return 0
def main() -> int:
try:
return asyncio.run(_main())
except KeyboardInterrupt:
print("\n⚠️ Interrupted by user")
return 130
except Exception as exc:
print(f"\n❌ Pipeline failed: {type(exc).__name__}: {exc}")
return 1
finally:
logging.shutdown()
if __name__ == "__main__":
raise SystemExit(main())
+15
View File
@@ -0,0 +1,15 @@
from .types import SyncAction, SyncStatus, BindingStatus
from .sync_node import SyncNode
from .persistence import PersistenceBackend
from .collection import DataCollection
from .binding import BindingManager
__all__ = [
"SyncAction",
"SyncStatus",
"BindingStatus",
"SyncNode",
"PersistenceBackend",
"DataCollection",
"BindingManager",
]
+161
View File
@@ -0,0 +1,161 @@
import json
import logging
from typing import Optional, Dict, Any, List
from .persistence import PersistenceBackend
from .types import BindingStatus, BindingRecord
logger = logging.getLogger(__name__)
class BindingManager:
"""
BindingManager 负责维护不同 DataCollection 间节点的绑定关系。
所有查询均在内存中进行,持久化仅作为备份。
"""
def __init__(self, persistence: PersistenceBackend, auto_persist: bool = True):
self.persistence = persistence
self.auto_persist = auto_persist
# { node_type: { local_id: remote_id } }
self._bindings: Dict[str, Dict[str, Optional[str]]] = {}
self._deleted: Dict[str, set[str]] = {}
def _get_type_bindings(self, node_type: str) -> Dict[str, Optional[str]]:
if node_type not in self._bindings:
self._bindings[node_type] = {}
return self._bindings[node_type]
def _get_deleted(self, node_type: str) -> set[str]:
if node_type not in self._deleted:
self._deleted[node_type] = set()
return self._deleted[node_type]
async def bind(self, node_type: str, local_id: str, remote_id: Optional[str], manual: bool = False):
"""
建立本地节点与远程节点间的映射(带中文日志)
Args:
node_type: 节点类型
local_id: 本地节点 ID
remote_id: 远程节点 ID
manual: 是否为手工绑定(用于日志区分)
"""
bindings = self._get_type_bindings(node_type)
bindings[local_id] = remote_id
self._get_deleted(node_type).discard(local_id)
# 记录日志:手工绑定保留 info,自动绑定降为 debug 避免日志过量
op_type = "手工" if manual else "自动"
log_line = (
f"[{op_type}绑定] 节点类型={node_type}, "
f"本地节点={local_id} <-> 远程节点={remote_id}"
)
if manual:
logger.info(log_line)
else:
logger.debug(log_line)
if self.auto_persist:
await self.persistence.save_binding(node_type, local_id, {"remote_id": remote_id})
async def unbind(self, node_type: str, local_id: str, manual: bool = False):
"""
解除映射关系并物理删除(带中文日志)
Args:
node_type: 节点类型
local_id: 本地节点 ID
manual: 是否为手工解绑(用于日志区分)
"""
bindings = self._get_type_bindings(node_type)
remote_id = bindings.get(local_id)
if local_id in bindings:
del bindings[local_id]
# 记录日志(中文,区分手工/自动)
op_type = "手工" if manual else "自动"
logger.info(
f"[{op_type}解绑] 节点类型={node_type}, "
f"本地节点={local_id} (原远程节点={remote_id})"
)
if self.auto_persist:
await self.persistence.delete_binding(node_type, local_id)
else:
self._get_deleted(node_type).add(local_id)
async def get_remote_id(self, node_type: str, local_id: str) -> Optional[str]:
"""查询本地节点对应的远程 ID"""
bindings = self._get_type_bindings(node_type)
return bindings.get(local_id)
async def get_local_id(self, node_type: str, remote_id: str) -> Optional[str]:
"""查询远程节点对应的本地 ID (内存遍历)"""
bindings = self._get_type_bindings(node_type)
for lid, rid in bindings.items():
if rid == remote_id:
return lid
return None
async def get_record(self, node_type: str, local_id: str) -> Optional[BindingRecord]:
"""获取结构化绑定记录"""
remote_id = self._get_type_bindings(node_type).get(local_id)
if remote_id is None:
return None
return BindingRecord(
local_id=local_id,
remote_id=remote_id
)
async def get_all_records(self, node_type: str) -> List[BindingRecord]:
"""获取指定类型的所有完整绑定记录 (结构化对象)"""
bindings = self._get_type_bindings(node_type)
return [BindingRecord(local_id=lid, remote_id=rid) for lid, rid in bindings.items()]
async def get_all_bindings(self, node_type: str) -> List[tuple[str, Optional[str]]]:
"""获取指定类型的所有 ID 映射关系 (local_id, remote_id)"""
bindings = self._get_type_bindings(node_type)
return [(lid, rid) for lid, rid in bindings.items()]
async def load_from_persistence(self, node_type: str):
"""从持久化层加载特定类型的绑定关系到内存"""
raw_bindings = await self.persistence.load_bindings(node_type)
bindings = self._get_type_bindings(node_type)
self._get_deleted(node_type).clear()
for rb in raw_bindings:
payload = rb.get("payload") or {}
bindings[rb["local_id"]] = payload.get("remote_id")
async def persist(self) -> None:
"""将所有绑定关系写回持久化层"""
if not self._bindings and not self._deleted:
logger.info("🔍 [BindingManager.persist] 跳过:无绑定变更")
return
type_count = 0
saved_total = 0
deleted_total = 0
touched_types: List[str] = []
for node_type, bindings in self._bindings.items():
deleted = list(self._get_deleted(node_type))
binding_count = len(bindings)
deleted_count = len(deleted)
type_count += 1
saved_total += binding_count
deleted_total += deleted_count
if binding_count > 0 or deleted_count > 0:
touched_types.append(node_type)
if deleted:
await self.persistence.delete_bindings_bulk(node_type, deleted)
self._get_deleted(node_type).clear()
if bindings:
await self.persistence.save_bindings_bulk(node_type, bindings)
touched_text = ",".join(touched_types) if touched_types else "none"
logger.info(
f"🔍 [BindingManager.persist] 完成: types={type_count}, saved={saved_total}, deleted={deleted_total}, touched=[{touched_text}]"
)
+540
View File
@@ -0,0 +1,540 @@
import uuid
from enum import Enum
from typing import Dict, List, Optional, Any, Set, Callable, Literal
from .sync_node import SyncNode
from .types import SyncStatus
from .persistence import PersistenceBackend
class DataCollection:
"""
DataCollection 管理一组同步节点的内存集合。
支持快速遍历、CRUD 以及依赖检查。
唯一性保证:
- node_id: 全局唯一,由 Collection.generate_node_id() 生成
- data_id: 业务主键,允许空字符串但不允许重复(非空时)
node_id 生成策略:
- 由 Collection.generate_node_id() 统一生成
- Collection.add() 会检查 node_id 唯一性,重复时抛出异常
"""
def __init__(
self,
collection_id: str,
persistence: Optional[PersistenceBackend] = None,
auto_persist: bool = False,
sm_runtime: Optional[Any] = None,
):
self.collection_id = collection_id
self.persistence = persistence
self.auto_persist = auto_persist
self._sm_runtime = sm_runtime
self._nodes: Dict[str, SyncNode] = {} # 内存缓存
# data_id 全局唯一(按 collection 维度),用于 O(1) 反查 node_id
self._data_id_to_node_id: Dict[str, str] = {}
# auto_persist=False 时,delete() 不会立即落库;这里记录删除集合,persist() 时统一写回
self._deleted_node_ids: set[str] = set()
@staticmethod
def is_bootstrap_blocked(node: SyncNode) -> bool:
"""是否为加载异常隔离节点(不参与后续自动流程)。"""
context = node.context
if not isinstance(context, dict):
return False
return bool(context.get("bootstrap_blocked"))
def set_state_machine_runtime(self, runtime: Any) -> None:
"""设置 Collection 默认状态机 runtime。"""
self._sm_runtime = runtime
async def add(self, node: SyncNode):
# 检查 node_id 唯一性
if node.node_id in self._nodes:
raise ValueError(
f"Duplicate node_id detected: {node.node_id} already exists in collection"
)
# 检查 data_id 唯一性(允许空字符串)
if node.data_id and node.data_id != "":
existing_node_id = self._data_id_to_node_id.get(node.data_id)
if existing_node_id is not None:
raise ValueError(
f"Duplicate data_id detected: {node.data_id} already bound to node_id={existing_node_id}"
)
self._data_id_to_node_id[node.data_id] = node.node_id
self._nodes[node.node_id] = node
self._deleted_node_ids.discard(node.node_id)
if self.persistence and self.auto_persist:
await self.persistence.save_node(self.collection_id, node)
async def update(self, node: SyncNode):
old_node = self._nodes.get(node.node_id)
if old_node and old_node.data_id and old_node.data_id != "" and old_node.data_id != node.data_id:
# node_id 不变但 data_id 变更,清理旧索引
self._data_id_to_node_id.pop(old_node.data_id, None)
if node.data_id and node.data_id != "":
existing_node_id = self._data_id_to_node_id.get(node.data_id)
if existing_node_id is not None and existing_node_id != node.node_id:
raise ValueError(
f"Duplicate data_id detected: {node.data_id} already bound to node_id={existing_node_id}"
)
self._data_id_to_node_id[node.data_id] = node.node_id
self._nodes[node.node_id] = node
self._deleted_node_ids.discard(node.node_id)
if self.persistence and self.auto_persist:
await self.persistence.save_node(self.collection_id, node)
async def delete(self, node_id: str):
old_node = self._nodes.get(node_id)
if old_node and old_node.data_id and old_node.data_id != "":
self._data_id_to_node_id.pop(old_node.data_id, None)
if node_id in self._nodes:
del self._nodes[node_id]
if self.persistence:
if self.auto_persist:
await self.persistence.delete_node(self.collection_id, node_id)
else:
self._deleted_node_ids.add(node_id)
def get(self, node_id: str) -> Optional[SyncNode]:
return self._nodes.get(node_id)
def get_by_node_id(self, node_id: str) -> Optional[SyncNode]:
"""别名方法,与 get() 功能相同"""
return self.get(node_id)
def generate_node_id(self) -> str:
"""生成唯一的 node_id"""
return str(uuid.uuid4())
def get_node_id_by_data_id(self, data_id: str) -> Optional[str]:
"""按 data_id 反查 node_idO(1))。"""
return self._data_id_to_node_id.get(data_id)
def get_by_data_id_any(self, data_id: str) -> Optional[SyncNode]:
"""按 data_id 查找节点(不要求 node_type)(O(1))。"""
node_id = self._data_id_to_node_id.get(data_id)
if not node_id:
return None
return self._nodes.get(node_id)
def get_by_data_id(self, node_type: str, data_id: str) -> Optional[SyncNode]:
"""按 node_type 和 data_id 查找节点"""
node = self.get_by_data_id_any(data_id)
if not node:
return None
return node if node.node_type == node_type else None
def filter(
self,
node_type: Optional[str] = None,
filter: Optional[Dict[str, Any]] = None,
node_filter: Optional[
Dict[
Literal[
"node_id",
"data_id",
"depend_ids",
"origin_data",
"action",
"status",
"binding_status",
"error",
"context",
"sync_log",
"node_type",
],
Any,
]
| Callable[[SyncNode], bool]
] = None
) -> List[SyncNode]:
"""
获取节点列表。
- node_type: 按 node_type 过滤。
- filter: 按 node.data 中的属性过滤 (业务数据)。
- node_filter: 按 SyncNode 顶级属性过滤
- 如果是字典:按属性匹配 (如 {"status": SyncStatus.PENDING})
- 如果是函数:自定义过滤函数 (如 lambda n: n.status == SyncStatus.PENDING)
"""
results = self._nodes.values()
# 1. 按 node_type 过滤
if node_type:
results = [n for n in results if n.node_type == node_type]
else:
results = list(results)
# 2. 按 node_filter 过滤 (顶级属性)
if node_filter:
if callable(node_filter):
# 函数过滤器
results = [n for n in results if node_filter(n)]
else:
filtered = []
for node in results:
match = True
for k, v in node_filter.items():
if k == "node_id":
actual = node.node_id
elif k == "data_id":
actual = node.data_id
elif k == "depend_ids":
actual = node.depend_ids
elif k == "origin_data":
actual = node.origin_data
elif k == "action":
actual = node.action
elif k == "status":
actual = node.status
elif k == "binding_status":
actual = node.binding_status
elif k == "error":
actual = node.error
elif k == "context":
actual = node.context
elif k == "sync_log":
actual = node.sync_log
elif k == "node_type":
actual = node.node_type
else:
match = False
break
if actual != v:
match = False
break
if match:
filtered.append(node)
results = filtered
# 3. 按 filter 过滤 (data 内部)
if filter:
filtered = []
for node in results:
node_data = node.get_data()
if not node_data:
match = False
else:
match = True
for k, v in filter.items():
actual_val = node_data.get(k)
if actual_val != v:
match = False
break
if match:
filtered.append(node)
results = filtered
return results
@staticmethod
def _expected_matches_actual(expected: Any, actual: Any) -> bool:
if isinstance(expected, list):
return any(DataCollection._expected_matches_actual(item, actual) for item in expected)
if isinstance(expected, str):
actual_candidates = set()
if isinstance(actual, Enum):
actual_candidates.update({str(actual.name), str(actual.value)})
else:
actual_candidates.add(str(actual))
expected_lower = expected.lower()
return any(str(candidate).lower() == expected_lower for candidate in actual_candidates)
return expected == actual
@classmethod
def _node_matches_state_id(
cls,
runtime,
*,
node: SyncNode,
state_id: str,
action_hint: Optional[str] = None,
match_fields: Optional[Set[str]] = None,
) -> bool:
states = runtime._config.raw.get("states")
pseudo_states = runtime._config.raw.get("pseudo_states")
if not isinstance(states, dict):
raise RuntimeError("invalid state-machine config: missing states")
if pseudo_states is not None and not isinstance(pseudo_states, dict):
raise RuntimeError("invalid state-machine config: pseudo_states must be mapping")
state_def = states.get(state_id)
if state_def is None and isinstance(pseudo_states, dict):
state_def = pseudo_states.get(state_id)
if not isinstance(state_def, dict):
return False
snapshot = {
"binding_status": node.binding_status,
"action": action_hint if action_hint is not None else node.action,
"status": node.status,
"data_id_exist": bool(node.data_id),
}
selected_fields = match_fields or set(snapshot.keys())
for field, actual in snapshot.items():
if field not in selected_fields:
continue
if field not in state_def:
continue
expected = state_def[field]
if not cls._expected_matches_actual(expected, actual):
return False
return True
def filter_by_state_ids(
self,
runtime: Optional[Any] = None,
*,
state_ids: List[str],
node_type: Optional[str] = None,
include_bootstrap_blocked: bool = False,
action_hint: Optional[str] = None,
match_fields: Optional[Set[str]] = None,
) -> List[SyncNode]:
"""
按状态机状态名(Sxx)筛选节点。
说明:
- 状态定义来源于 `runtime` 关联的 YAML 配置;
- 默认排除 `bootstrap_blocked` 隔离节点;
- `action_hint` 可用于匹配类似 E40 场景的动作分支。
"""
runtime_obj = runtime or self._sm_runtime
if runtime_obj is None:
raise RuntimeError("state-machine runtime is not set for collection")
candidates = self.filter(node_type=node_type)
matched: List[SyncNode] = []
for node in candidates:
if not include_bootstrap_blocked and self.is_bootstrap_blocked(node):
continue
if any(
self._node_matches_state_id(
runtime_obj,
node=node,
state_id=state_id,
action_hint=action_hint,
match_fields=match_fields,
)
for state_id in state_ids
):
matched.append(node)
return matched
def check_depends(self, node_ids: List[str]) -> bool:
"""
检查指定的 node_id 列表是否存在,且状态是否不是 FAILED。
Args:
node_ids: 依赖的 node_id 列表
Returns:
bool: 所有依赖均满足则返回 True
"""
for nid in node_ids:
node = self.get(nid)
if not node:
return False
if node.status == SyncStatus.FAILED:
return False
return True
async def load_from_persistence(self):
"""
从后端加载数据到内存
使用 DomainRegistry 恢复正确的 SyncNode 类型。
如果 node_type 未注册,会抛出 ValueError。
**加载阶段**
- 原样恢复持久化状态(binding_status/action/status/error
- 清空 sync_log(避免跨轮次累积日志)
- data/origin_data 保留(由 DataSource 覆盖)
**初始化归并(E01)**:
- 由 Pipeline 在加载后调用 run_reset() 触发 E01
- CREATE 僵尸节点进入 S15 并删除
- 其余节点进入 S00UNCHECKED/NONE/PENDING
"""
if not self.persistence:
return
self._nodes = {}
self._data_id_to_node_id = {}
self._deleted_node_ids.clear()
node_dicts = await self.persistence.load_nodes(self.collection_id)
from .types import SyncAction, SyncStatus, BindingStatus
from .registry import DomainRegistry
for node_dict in node_dicts:
node_type = node_dict["node_type"]
# 从注册中心获取正确的 SyncNode 类(未注册会抛出 ValueError
node_class = DomainRegistry.get_node_class(node_type)
assert issubclass(node_class, SyncNode)
# 严格恢复 action/status/binding_status;非法持久化值进入隔离态(S17 语义)
parse_errors: list[str] = []
# 原样恢复 action 字段(可能是字符串或枚举)
raw_action = node_dict.get("action", SyncAction.NONE)
if isinstance(raw_action, str):
try:
action_value = SyncAction(raw_action)
except ValueError:
parse_errors.append(f"invalid action={raw_action}")
action_value = SyncAction.NONE
elif isinstance(raw_action, SyncAction):
action_value = raw_action
else:
if raw_action is None:
action_value = SyncAction.NONE
else:
parse_errors.append(f"invalid action type={type(raw_action).__name__}")
action_value = SyncAction.NONE
# 原样恢复 status 字段(可能是字符串或枚举)
raw_status = node_dict.get("status", SyncStatus.PENDING)
if isinstance(raw_status, str):
try:
status_value = SyncStatus(raw_status)
except ValueError:
parse_errors.append(f"invalid status={raw_status}")
status_value = SyncStatus.FAILED
elif isinstance(raw_status, SyncStatus):
status_value = raw_status
else:
if raw_status is None:
status_value = SyncStatus.PENDING
else:
parse_errors.append(f"invalid status type={type(raw_status).__name__}")
status_value = SyncStatus.FAILED
# 原样恢复 binding_status 字段(可能是字符串或枚举)
raw_binding_status = node_dict.get("binding_status", BindingStatus.UNCHECKED)
if isinstance(raw_binding_status, str):
try:
binding_status_value = BindingStatus(raw_binding_status)
except ValueError:
parse_errors.append(f"invalid binding_status={raw_binding_status}")
binding_status_value = BindingStatus.ABNORMAL
elif isinstance(raw_binding_status, BindingStatus):
binding_status_value = raw_binding_status
else:
if raw_binding_status is None:
binding_status_value = BindingStatus.UNCHECKED
else:
parse_errors.append(
f"invalid binding_status type={type(raw_binding_status).__name__}"
)
binding_status_value = BindingStatus.ABNORMAL
raw_context = node_dict.get("context", {})
context_value = raw_context if isinstance(raw_context, dict) else {}
error_value = node_dict.get("error")
if parse_errors:
context_value = dict(context_value)
context_value["bootstrap_blocked"] = True
context_value["bootstrap_block_reason"] = "invalid_persisted_enum"
context_value["bootstrap_block_errors"] = parse_errors
binding_status_value = BindingStatus.ABNORMAL
action_value = SyncAction.NONE
status_value = SyncStatus.FAILED
details = "; ".join(parse_errors)
error_value = f"LOAD_INVALID_ENUM: {details}"
# 使用正确的类型创建节点(保留持久化状态,后续由 E01 统一归并)
try:
node = node_class(
node_id=node_dict["node_id"],
data_id=node_dict.get("data_id", ""),
depend_ids=node_dict.get("depend_ids", []),
data=node_dict.get("data"), # 保留,由 DataSource 覆盖
origin_data=node_dict.get("origin_data"), # 保留,由 DataSource 覆盖
action=action_value,
status=status_value,
binding_status=binding_status_value,
error=error_value,
sync_log=None, # 初始化加载时清空上一轮流转日志
context=context_value,
)
node.context["_loaded_from_persistence"] = True
except Exception:
import logging
logger = logging.getLogger(__name__)
logger.error(
f"Load failed for node_type={node_type}, node_id={node_dict.get('node_id')}",
exc_info=True
)
logger.error(f"Offending data: {node_dict.get('data')}")
raise
self._nodes[node.node_id] = node
if node.data_id and node.data_id != "":
existing_node_id = self._data_id_to_node_id.get(node.data_id)
if existing_node_id is not None and existing_node_id != node.node_id:
raise ValueError(
f"Duplicate data_id detected while loading: {node.data_id} already bound to node_id={existing_node_id}"
)
self._data_id_to_node_id[node.data_id] = node.node_id
node_types = {node.node_type for node in self._nodes.values()}
for node_type in node_types:
override_map = await self.persistence.load_bind_data_id_overrides(node_type)
for node in self.filter(node_type=node_type):
bind_data_id = override_map.get(node.node_id)
if bind_data_id:
node.context["bind_data_id"] = bind_data_id
else:
node.context.pop("bind_data_id", None)
async def persist(self) -> None:
"""将当前 collection 全量持久化到后端"""
if not self.persistence:
return
# 先落库删除(用于 auto_persist=False 的场景)
if self._deleted_node_ids:
await self.persistence.delete_nodes_bulk(self.collection_id, list(self._deleted_node_ids))
self._deleted_node_ids.clear()
if not self._nodes:
return
await self.persistence.save_nodes_bulk(self.collection_id, list(self._nodes.values()))
async def filter_by_project_ids(self, target_project_ids: List[str]) -> int:
"""按目标项目集合过滤当前 collection,返回删除数量。"""
target_set = set(target_project_ids or [])
if not target_set:
return 0
nodes = self.filter()
deleted_count = 0
for node in nodes:
should_delete = False
if node.node_type == "project":
if node.data_id not in target_set:
should_delete = True
else:
data = node.get_data() or {}
project_id = data.get("project_id")
if project_id and project_id not in target_set:
should_delete = True
if should_delete:
await self.delete(node.node_id)
deleted_count += 1
return deleted_count
+411
View File
@@ -0,0 +1,411 @@
import aiosqlite
import json
import os
import sqlite3
from typing import Optional, Dict, List, Any, TYPE_CHECKING
if TYPE_CHECKING:
from .sync_node import SyncNode
class PersistenceBackend:
"""
Persistence 负责同步系统运行时数据的持久化存储(aiosqlite 实现)。
主要作为 DataCollection 和 BindingManager 的后端存储。
"""
def __init__(self, db_path: str = ":memory:", backend: str = "sqlite"):
self.db_path = db_path
self.backend = backend
self.conn: Optional[aiosqlite.Connection] = None
@staticmethod
def _is_read_only_sql(sql: str) -> bool:
stmt = (sql or "").strip().lower()
return stmt.startswith("select") or stmt.startswith("pragma") or stmt.startswith("with")
@classmethod
def query_records(
cls,
*,
backend: str,
db_path: str,
sql: str,
limit: int = 200,
) -> tuple[List[str], List[Dict[str, Any]]]:
if backend != "sqlite":
raise NotImplementedError(f"records query backend not supported yet: {backend}")
if not cls._is_read_only_sql(sql):
raise ValueError("Only read-only SQL is allowed")
path = db_path
if not os.path.exists(path):
raise FileNotFoundError(f"DB not found: {path}")
with sqlite3.connect(path) as conn:
conn.row_factory = sqlite3.Row
cursor = conn.execute(sql)
rows = cursor.fetchmany(max(1, min(limit, 2000)))
columns = [item[0] for item in (cursor.description or [])]
data = [{k: row[k] for k in columns} for row in rows]
return columns, data
@classmethod
def records_summary(
cls,
*,
backend: str,
db_path: str,
) -> Dict[str, Any]:
summary: Dict[str, Any] = {
"backend": backend,
"db_path": db_path,
"db_exists": os.path.exists(db_path),
"tables": [],
"sample_bindings": [],
}
if not os.path.exists(db_path):
return summary
if backend != "sqlite":
summary["error"] = f"records summary backend not supported yet: {backend}"
return summary
with sqlite3.connect(db_path) as conn:
conn.row_factory = sqlite3.Row
tables = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
).fetchall()
table_names = [str(r["name"]) for r in tables]
for name in table_names:
count = conn.execute(f"SELECT COUNT(1) AS c FROM {name}").fetchone()["c"]
summary["tables"].append({"name": name, "count": int(count)})
if "bindings" in table_names:
sample = conn.execute(
"SELECT node_type, local_id, remote_id FROM bindings ORDER BY last_updated DESC LIMIT 50"
).fetchall()
summary["sample_bindings"] = [
{
"node_type": row["node_type"],
"local_id": row["local_id"],
"remote_id": row["remote_id"],
}
for row in sample
]
return summary
async def initialize(self):
"""异步初始化数据库连接和表结构"""
if self.backend != "sqlite":
raise NotImplementedError(f"Persistence backend not supported yet: {self.backend}")
# 确保目录存在
if self.db_path != ":memory:":
dir_path = os.path.dirname(self.db_path)
if dir_path:
os.makedirs(dir_path, exist_ok=True)
self.conn = await aiosqlite.connect(self.db_path)
self.conn.row_factory = aiosqlite.Row
await self._init_db()
async def _init_db(self):
# 1. 节点表: 仅用于持久化存储 DataCollection 中的 SyncNode
# 注意: depend_ids不持久化,每次从data实时计算
await self.conn.execute("""
CREATE TABLE IF NOT EXISTS nodes (
collection_id TEXT NOT NULL,
node_id TEXT NOT NULL,
node_type TEXT NOT NULL,
data_id TEXT,
bind_data_id TEXT,
data TEXT,
origin_data TEXT,
action TEXT,
status TEXT,
binding_status TEXT,
error TEXT,
sync_log TEXT,
context TEXT,
PRIMARY KEY (collection_id, node_id)
)
""")
await self._ensure_nodes_schema()
# 2. 绑定表: 存储本地到远程的映射关系
# 只保留 local_id / remote_id 两字段(remote_id 可空)
await self._ensure_bindings_schema()
await self.conn.commit()
async def _ensure_nodes_schema(self) -> None:
cursor = await self.conn.execute("PRAGMA table_info(nodes)")
rows = await cursor.fetchall()
columns = [r[1] for r in rows] if rows else []
if "bind_data_id" not in columns:
await self.conn.execute("ALTER TABLE nodes ADD COLUMN bind_data_id TEXT")
async def _ensure_bindings_schema(self) -> None:
"""确保 bindings 表为 (node_type, local_id, remote_id) 结构。
若检测到旧表含 payload 列,则进行迁移。
"""
cursor = await self.conn.execute("PRAGMA table_info(bindings)")
rows = await cursor.fetchall()
columns = [r[1] for r in rows] if rows else []
if not columns:
await self.conn.execute("""
CREATE TABLE IF NOT EXISTS bindings (
node_type TEXT NOT NULL,
local_id TEXT NOT NULL,
remote_id TEXT,
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (node_type, local_id)
)
""")
return
if "payload" in columns and "remote_id" not in columns:
# 迁移旧结构 payload -> remote_id
await self.conn.execute("""
CREATE TABLE IF NOT EXISTS bindings_new (
node_type TEXT NOT NULL,
local_id TEXT NOT NULL,
remote_id TEXT,
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (node_type, local_id)
)
""")
async with self.conn.execute("SELECT node_type, local_id, payload FROM bindings") as cur:
old_rows = await cur.fetchall()
for row in old_rows:
try:
payload_dict = json.loads(row[2]) if row[2] else {}
except json.JSONDecodeError:
payload_dict = {}
remote_id = payload_dict.get("remote_id")
await self.conn.execute(
"INSERT OR REPLACE INTO bindings_new (node_type, local_id, remote_id) VALUES (?, ?, ?)",
(row[0], row[1], remote_id),
)
await self.conn.execute("DROP TABLE bindings")
await self.conn.execute("ALTER TABLE bindings_new RENAME TO bindings")
# --- DataCollection 支持 ---
async def save_node(self, collection_id: str, node: "SyncNode"):
"""保存或更新单个节点(depend_ids不持久化,运行时从data实时计算)"""
bind_data_id = None
if isinstance(node.context, dict):
value = node.context.get("bind_data_id")
if value is not None and value != "":
bind_data_id = str(value)
await self.conn.execute("""
INSERT OR REPLACE INTO nodes (
collection_id, node_id, node_type, data_id, bind_data_id,
data, origin_data, action, status, binding_status, error, sync_log, context
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
collection_id,
node.node_id,
node.node_type,
node.data_id,
bind_data_id,
json.dumps(node.data) if node.data else None,
json.dumps(node.origin_data) if node.origin_data else None,
node.action.value,
node.status.value,
node.binding_status.value,
node.error,
node.sync_log,
json.dumps(node.context) if node.context else None
))
await self.conn.commit()
async def save_nodes_bulk(self, collection_id: str, nodes: List["SyncNode"]):
"""批量保存或更新节点 (单次提交,depend_ids不持久化)"""
if not nodes:
return
rows = []
for node in nodes:
bind_data_id = None
if isinstance(node.context, dict):
value = node.context.get("bind_data_id")
if value is not None and value != "":
bind_data_id = str(value)
rows.append((
collection_id,
node.node_id,
node.node_type,
node.data_id,
bind_data_id,
json.dumps(node.data) if node.data else None,
json.dumps(node.origin_data) if node.origin_data else None,
node.action.value,
node.status.value,
node.binding_status.value,
node.error,
node.sync_log,
json.dumps(node.context) if node.context else None
))
await self.conn.executemany("""
INSERT OR REPLACE INTO nodes (
collection_id, node_id, node_type, data_id, bind_data_id,
data, origin_data, action, status, binding_status, error, sync_log, context
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", rows)
await self.conn.commit()
async def delete_node(self, collection_id: str, node_id: str):
"""删除单个节点"""
await self.conn.execute("DELETE FROM nodes WHERE collection_id = ? AND node_id = ?", (collection_id, node_id))
await self.conn.commit()
async def delete_nodes_bulk(self, collection_id: str, node_ids: List[str]):
"""批量删除节点 (单次提交)"""
if not node_ids:
return
rows = [(collection_id, node_id) for node_id in node_ids]
await self.conn.executemany(
"DELETE FROM nodes WHERE collection_id = ? AND node_id = ?",
rows,
)
await self.conn.commit()
async def load_nodes(self, collection_id: str) -> List[Dict[str, Any]]:
"""从特定 collection 中加载所有节点(depend_ids初始化为空,稍后从data实时计算)"""
async with self.conn.execute("SELECT * FROM nodes WHERE collection_id = ?", (collection_id,)) as cursor:
rows = await cursor.fetchall()
results = []
for row in rows:
results.append({
"node_id": row["node_id"],
"node_type": row["node_type"],
"data_id": row["data_id"],
"bind_data_id": row["bind_data_id"],
"depend_ids": [], # 不从数据库加载,运行时从data实时计算
"data": json.loads(row["data"]) if row["data"] else None,
"origin_data": json.loads(row["origin_data"]) if row["origin_data"] else None,
"action": row["action"],
"status": row["status"],
"binding_status": row["binding_status"],
"error": row["error"],
"sync_log": row["sync_log"],
"context": json.loads(row["context"]) if row["context"] else {}
})
if results[-1]["bind_data_id"] and isinstance(results[-1]["context"], dict):
results[-1]["context"]["bind_data_id"] = results[-1]["bind_data_id"]
return results
# --- BindingManager 支持 ---
async def save_binding(self, node_type: str, local_id: str, payload_dict: Dict[str, Any]):
"""保存或更新绑定关系及其元数据"""
remote_id = payload_dict.get("remote_id") if payload_dict else None
await self.conn.execute("""
INSERT OR REPLACE INTO bindings (node_type, local_id, remote_id)
VALUES (?, ?, ?)
""", (node_type, local_id, remote_id))
await self.conn.commit()
async def save_bindings_bulk(self, node_type: str, bindings: Dict[str, Optional[str]]):
"""批量保存或更新绑定关系 (单次提交)"""
if not bindings:
return
rows = []
for local_id, remote_id in bindings.items():
rows.append((node_type, local_id, remote_id))
await self.conn.executemany(
"INSERT OR REPLACE INTO bindings (node_type, local_id, remote_id) VALUES (?, ?, ?)",
rows,
)
await self.conn.commit()
async def delete_binding(self, node_type: str, local_id: str):
"""删除绑定关系"""
await self.conn.execute(
"DELETE FROM bindings WHERE node_type = ? AND local_id = ?",
(node_type, local_id)
)
await self.conn.commit()
async def delete_bindings_bulk(self, node_type: str, local_ids: List[str]):
"""批量删除绑定关系 (单次提交)"""
if not local_ids:
return
rows = [(node_type, local_id) for local_id in local_ids]
await self.conn.executemany(
"DELETE FROM bindings WHERE node_type = ? AND local_id = ?",
rows,
)
await self.conn.commit()
async def load_bindings(self, node_type: str) -> List[Dict[str, Any]]:
"""回写该类型下的所有绑定关系到内存"""
async with self.conn.execute(
"SELECT local_id, remote_id FROM bindings WHERE node_type = ?",
(node_type,)
) as cursor:
rows = await cursor.fetchall()
return [
{"local_id": r["local_id"], "payload": {"remote_id": r["remote_id"]}}
for r in rows
]
async def load_bind_data_id_overrides(self, node_type: str) -> Dict[str, Optional[str]]:
"""基于 bindings 表计算 node_id -> bind_data_id 覆盖映射。
规则:
- local 节点的 bind_data_id 取其绑定 remote 节点的 data_id
- remote 节点的 bind_data_id 取其绑定 local 节点的 data_id
"""
sql = """
SELECT b.local_id AS src_node_id, rn.data_id AS bind_data_id
FROM bindings b
LEFT JOIN nodes rn
ON rn.node_id = b.remote_id
AND rn.node_type = b.node_type
WHERE b.node_type = ?
UNION ALL
SELECT b.remote_id AS src_node_id, ln.data_id AS bind_data_id
FROM bindings b
LEFT JOIN nodes ln
ON ln.node_id = b.local_id
AND ln.node_type = b.node_type
WHERE b.node_type = ?
AND b.remote_id IS NOT NULL
"""
async with self.conn.execute(sql, (node_type, node_type)) as cursor:
rows = await cursor.fetchall()
result: Dict[str, Optional[str]] = {}
for row in rows:
src_node_id = row["src_node_id"]
if not src_node_id:
continue
result[str(src_node_id)] = row["bind_data_id"]
return result
async def dump_to_runtime(self, filename: str):
"""将当前状态转储到 _runtime 文件夹 (使用 VACUUM INTO)"""
runtime_path = os.path.join("_runtime", filename)
if os.path.exists(runtime_path):
os.remove(runtime_path)
# VACUUM INTO 是备份 SQLite 的推荐方式 (SQLite 3.27+)
await self.conn.execute("VACUUM INTO ?", (runtime_path,))
async def close(self):
"""关闭数据库连接(包含异常处理)"""
if self.conn:
try:
await self.conn.close()
except Exception as e:
print(f"⚠️ Error closing persistence: {e}")
finally:
self.conn = None
+267
View File
@@ -0,0 +1,267 @@
"""
Domain 注册中心
提供统一的 domain 组件注册和查询接口。
各模块(Collection/DataSource/Strategy)按需获取。
使用方式:
1. 在 domain/__init__.py 中注册所有组件
2. 各模块按需查询
示例:
# 注册
from ..common.registry import DomainRegistry
from .contract import ContractSyncNode, ContractJsonlHandler
DomainRegistry.register(
node_type="contract",
schema=ContractResponse,
node_class=ContractSyncNode,
jsonl_handler_class=ContractJsonlHandler
)
# Collection 查询 SyncNode 类
node_class = DomainRegistry.get_node_class("contract")
# DataSource 查询 Handler
handler_class = DomainRegistry.get_jsonl_handler("contract")
"""
from typing import Dict, Type, Optional, Any
from dataclasses import dataclass, field
from pydantic import BaseModel
@dataclass
class DomainRegistration:
"""
单个 domain 的注册信息
包含:
- node_class: SyncNode 子类(用于 Collection 恢复)
- strategy_class: Strategy 子类(用于 SyncSystem 初始化)
- handlers: 各种 Handler(用于 DataSource 初始化)
"""
node_type: str
schema: Type[BaseModel]
node_class: Type[Any] # Type[SyncNode[T]],避免循环导入
strategy_class: Optional[Type[Any]] = None # 可选,部分 domain 可能只读
jsonl_handler_class: Optional[Type[Any]] = None
api_handler_class: Optional[Type[Any]] = None
db_handler_class: Optional[Type[Any]] = None
metadata: Dict[str, Any] = field(default_factory=dict) # 额外元数据
class DomainRegistry:
"""
Domain 注册中心
职责分离:
- Collection: 只关心 SyncNode 类(恢复类型)
- DataSource: 只关心 Handler 类(执行层)
- Strategy: 只关心 Strategy 类(业务逻辑)
每个 domain 只注册一次,各模块按需获取。
"""
_registry: Dict[str, DomainRegistration] = {}
@classmethod
def register(
cls,
node_type: str,
schema: Type[BaseModel],
node_class: Type[Any],
strategy_class: Optional[Type[Any]] = None,
jsonl_handler_class: Optional[Type[Any]] = None,
api_handler_class: Optional[Type[Any]] = None,
db_handler_class: Optional[Type[Any]] = None,
**metadata
) -> None:
"""
注册一个 domain
Args:
node_type: 节点类型(如 "contract"
schema: Pydantic Schema 类
node_class: SyncNode 子类
strategy_class: Strategy 子类(可选)
jsonl_handler_class: JSONL Handler 类(可选)
api_handler_class: API Handler 类(可选)
db_handler_class: DB Handler 类(可选)
**metadata: 额外的元数据
Raises:
ValueError: 如果 node_type 已注册
"""
if node_type in cls._registry:
raise ValueError(f"Domain '{node_type}' already registered")
cls._registry[node_type] = DomainRegistration(
node_type=node_type,
schema=schema,
node_class=node_class,
strategy_class=strategy_class,
jsonl_handler_class=jsonl_handler_class,
api_handler_class=api_handler_class,
db_handler_class=db_handler_class,
metadata=metadata
)
@classmethod
def get_node_class(cls, node_type: str) -> Type[Any]:
"""
获取 SyncNode 类(用于 Collection 恢复)
Args:
node_type: 节点类型
Returns:
SyncNode 子类
Raises:
ValueError: 如果 node_type 未注册
"""
reg = cls._registry.get(node_type)
if not reg:
raise ValueError(f"Unknown node_type: '{node_type}'. Available: {list(cls._registry.keys())}")
return reg.node_class
@classmethod
def get_strategy_class(cls, node_type: str) -> Optional[Type[Any]]:
"""
获取 Strategy 类(用于 SyncSystem
Args:
node_type: 节点类型
Returns:
Strategy 类,如果未注册则返回 None
"""
reg = cls._registry.get(node_type)
if not reg:
raise ValueError(f"Unknown node_type: '{node_type}'")
return reg.strategy_class
@classmethod
def get_jsonl_handler(cls, node_type: str) -> Optional[Type[Any]]:
"""
获取 JSONL Handler 类(用于 DataSource
Args:
node_type: 节点类型
Returns:
Handler 类,如果未注册则返回 None
"""
reg = cls._registry.get(node_type)
if not reg:
raise ValueError(f"Unknown node_type: '{node_type}'")
return reg.jsonl_handler_class
@classmethod
def get_api_handler(cls, node_type: str) -> Optional[Type[Any]]:
"""获取 API Handler 类"""
reg = cls._registry.get(node_type)
if not reg:
raise ValueError(f"Unknown node_type: '{node_type}'")
return reg.api_handler_class
@classmethod
def get_db_handler(cls, node_type: str) -> Optional[Type[Any]]:
"""获取 DB Handler 类"""
reg = cls._registry.get(node_type)
if not reg:
raise ValueError(f"Unknown node_type: '{node_type}'")
return reg.db_handler_class
@classmethod
def get_schema(cls, node_type: str) -> Type[BaseModel]:
"""获取 Schema 类"""
reg = cls._registry.get(node_type)
if not reg:
raise ValueError(f"Unknown node_type: '{node_type}'")
return reg.schema
@classmethod
def get_registration(cls, node_type: str) -> DomainRegistration:
"""获取完整的注册信息"""
reg = cls._registry.get(node_type)
if not reg:
raise ValueError(f"Unknown node_type: '{node_type}'")
return reg
@classmethod
def is_registered(cls, node_type: str) -> bool:
"""检查 node_type 是否已注册"""
return node_type in cls._registry
@classmethod
def list_registered(cls) -> list[str]:
"""列出所有已注册的 node_type"""
return list(cls._registry.keys())
@classmethod
def get_registered_types(cls) -> list[str]:
"""列出所有已注册的 node_typelist_registered 的别名)"""
return cls.list_registered()
@classmethod
def clear(cls) -> None:
"""清空注册表(仅用于测试)"""
cls._registry.clear()
@classmethod
def print_registry(cls) -> None:
"""打印所有注册的 domain 信息(用于调试)"""
if not cls._registry:
print("⚠️ DomainRegistry is empty - no domains registered")
return
print(f"\n{'='*80}")
print(f"📋 DomainRegistry - {len(cls._registry)} domain(s) registered")
print(f"{'='*80}\n")
for node_type, reg in sorted(cls._registry.items()):
print(f"🔹 {node_type}")
print(f" Schema: {reg.schema.__name__}")
print(f" Node Class: {reg.node_class.__name__}")
print(f" Strategy Class: {reg.strategy_class.__name__ if reg.strategy_class else '❌ None'}")
print(f" JSONL Handler: {reg.jsonl_handler_class.__name__ if reg.jsonl_handler_class else '❌ None'}")
print(f" API Handler: {reg.api_handler_class.__name__ if reg.api_handler_class else '❌ None'}")
print(f" DB Handler: {reg.db_handler_class.__name__ if reg.db_handler_class else '❌ None'}")
if reg.metadata:
print(f" Metadata: {reg.metadata}")
print()
print(f"{'='*80}\n")
@classmethod
def get_summary(cls) -> Dict[str, Dict[str, bool]]:
"""
获取注册摘要(用于自动化检查)
Returns:
字典,key 为 node_typevalue 为各组件的注册状态
Example:
{
"contract": {
"has_node": True,
"has_strategy": True,
"has_jsonl_handler": True,
"has_api_handler": True,
"has_db_handler": False
},
...
}
"""
summary = {}
for node_type, reg in cls._registry.items():
summary[node_type] = {
"has_node": reg.node_class is not None,
"has_strategy": reg.strategy_class is not None,
"has_jsonl_handler": reg.jsonl_handler_class is not None,
"has_api_handler": reg.api_handler_class is not None,
"has_db_handler": reg.db_handler_class is not None,
}
return summary
+435
View File
@@ -0,0 +1,435 @@
"""
SyncNode - 核心状态机容器
将data存储为Dict,但支持schema验证和Pydantic模型转换。
提供统一的数据访问接口:get_data() / put_data()
"""
import copy
from contextlib import contextmanager
from typing import TypeVar, Generic, Optional, Any, Dict, List, Type
from pydantic import BaseModel, ValidationError
from .types import SyncAction, SyncStatus, BindingStatus
T = TypeVar("T", bound=BaseModel)
class SyncNode(Generic[T]):
"""
核心状态机容器
数据存储策略:
- origin_data/data 内部存储为 Dict
- 通过 get_data() 获取数据副本(深拷贝)
- 通过 set_data() 设置数据(自动验证+深拷贝)
- 通过 to_model() 转换为 Pydantic 模型实例
基类 SyncNode 不应直接实例化,应通过子类使用。
子类应设置类变量 node_type 和 schema
```python
class ContractSyncNode(SyncNode[ContractResponse]):
node_type = "contract"
schema = ContractResponse
```
"""
# 类变量:子类必须设置
node_type: str = None # type: ignore
schema: Type[T] = None # type: ignore
_CORE_FIELDS = {"binding_status", "action", "status", "data_id"}
def __setattr__(self, name: str, value: Any) -> None:
if (
name in self._CORE_FIELDS
and self.__dict__.get("_core_guard_enabled", False)
and self.__dict__.get("_core_write_depth", 0) <= 0
):
raise RuntimeError(
f"Core field '{name}' can only be modified via event apply path for node={self.__dict__.get('node_id', '<uninitialized>')}"
)
object.__setattr__(self, name, value)
@contextmanager
def allow_core_state_write(self, source: str = "unknown"):
depth = self.__dict__.get("_core_write_depth", 0)
prev_source = self.__dict__.get("_core_write_source", "")
object.__setattr__(self, "_core_write_depth", depth + 1)
object.__setattr__(self, "_core_write_source", source)
try:
yield
finally:
object.__setattr__(self, "_core_write_depth", depth)
object.__setattr__(self, "_core_write_source", prev_source)
def __init__(
self,
node_id: str,
data_id: str = "",
depend_ids: Optional[List[str]] = None,
data: Optional[Dict[str, Any]] = None,
origin_data: Optional[Dict[str, Any]] = None,
action: SyncAction = SyncAction.NONE,
status: SyncStatus = SyncStatus.PENDING,
binding_status: BindingStatus = BindingStatus.UNCHECKED,
error: Optional[str] = None,
context: Optional[Dict[str, Any]] = None,
sync_log: Optional[str] = None
):
object.__setattr__(self, "_core_guard_enabled", False)
object.__setattr__(self, "_core_write_depth", 0)
# 检查类变量是否设置
if self.node_type is None:
raise ValueError(
f"{self.__class__.__name__} must define class variable 'node_type'. "
f"Example: node_type = 'contract'"
)
if self.schema is None:
raise ValueError(
f"{self.__class__.__name__} must define class variable 'schema'. "
f"Example: schema = YourSchema"
)
self.node_id = node_id
self.data_id = data_id
self.depend_ids = depend_ids if depend_ids is not None else []
if origin_data is not None:
self.origin_data = copy.deepcopy(origin_data)
else:
self.origin_data = None
self.action = action
self.status = status
self.binding_status = binding_status
self.error = error
self.sync_log = sync_log # Debug/info级别的日志信息
# Context 字典:存储额外的上下文信息(如 project_id, contract_id 等)
self.context: Dict[str, Any] = context if context is not None else {}
# 在初始化时验证并设置 data
if data is not None:
self._validate_and_set_data(data)
if self.origin_data is None:
self.origin_data = copy.deepcopy(self.data)
else:
self.data = None
object.__setattr__(self, "_core_guard_enabled", True)
def _validate_and_set_data(self, data: Dict[str, Any]) -> None:
"""验证并设置数据(内部方法)"""
if not isinstance(data, dict):
self.data = None
raise ValueError(f"Data validation failed for {self.node_type}: data must be a dict")
try:
# 基础兼容性:将 _id 转换为 id
data_to_validate = data.copy()
if "id" not in data_to_validate and "_id" in data_to_validate:
data_to_validate["id"] = data_to_validate["_id"]
model = self.schema.model_validate(data_to_validate)
# 使用 model_dump() 获得完整校验后的数据,去掉 schema 之外的字段
self.data = model.model_dump()
except Exception as e:
# 如果出错就存 None, 然后 error 里加个错
self.data = None
self.error = f"Validation failed: {str(e)}"
# 不要轻易降级错误。如果校验失败,应抛出 ValueError 供上层处理
raise ValueError(f"Data validation failed for {self.node_type}: {e}")
# ===== 数据访问方法 =====
def get_data(self, exclude_unset: bool = False) -> Optional[Dict[str, Any]]:
"""
获取数据的副本。
Args:
exclude_unset: 是否排除未设置(默认值)的字段。
Returns:
数据字典的副本,如果为None则返回None
"""
if self.data is None:
return None
if not exclude_unset:
return copy.deepcopy(self.data)
try:
model = self.schema.model_validate(self.data)
return model.model_dump(exclude_unset=True)
except Exception:
return copy.deepcopy(self.data)
def set_data(self, data: Optional[Dict[str, Any]], validate: bool = True) -> None:
"""
设置数据(自动深拷贝)。
Args:
data: 要设置的数据字典
validate: 是否进行schema验证(默认True)
Raises:
ValueError: 如果数据验证失败
"""
if data is None:
self.data = None
return
if validate:
self._validate_and_set_data(data)
else:
self.data = copy.deepcopy(data)
def get_origin_data(self) -> Optional[Dict[str, Any]]:
"""
获取原始数据的深拷贝副本。
Returns:
原始数据字典的副本,如果为None则返回None
"""
if self.origin_data is None:
return None
return copy.deepcopy(self.origin_data)
def set_origin_data(self, data: Optional[Dict[str, Any]]) -> None:
"""
设置原始数据(自动深拷贝)。
Args:
data: 要设置的原始数据字典
"""
if data is None:
self.origin_data = None
return
if self.schema is None:
raise ValueError(f"Cannot set origin_data without schema for {self.node_type}")
try:
model = self.schema.model_validate(copy.deepcopy(data))
# 使用 exclude_unset=True 确保 origin_data 也只记录实际提供/返回的字段
self.origin_data = model.model_dump(exclude_unset=True)
except AttributeError as e:
raise ValueError(
f"Origin data validation failed for {self.node_type}: model_dump not available"
) from e
except (ValidationError, TypeError, ValueError) as e:
raise ValueError(f"Origin data validation failed for {self.node_type}: {e}") from e
# ===== Schema 相关方法 =====
def get_schema(self) -> Optional[Type[T]]:
"""
获取数据的 Schema 类型。
Returns:
Pydantic Model 类型,如果未设置则返回 None
"""
return self.schema
def set_schema(self, schema: Type[T]) -> None:
"""
设置数据的 Schema 类型。
Args:
schema: Pydantic Model 类型
"""
self.schema = schema
def validate(self) -> bool:
"""
验证当前数据是否符合 schema。
Returns:
True 如果验证通过或无 schema,False 如果验证失败
"""
if self.schema is None or self.data is None:
return True
try:
self.schema.model_validate(self.data)
return True
except ValidationError:
return False
def to_model(self) -> Optional[T]:
"""
将数据转换为 Pydantic 模型实例。
Returns:
模型实例,如果无数据或无schema则返回None
Raises:
ValidationError: 如果数据验证失败
"""
if self.schema is None or self.data is None:
return None
return self.schema.model_validate(self.data)
def from_model(self, model: T) -> None:
"""
从 Pydantic 模型实例设置数据。
Args:
model: Pydantic 模型实例
"""
try:
self.data = model.model_dump()
except AttributeError as e:
raise ValueError(
f"Cannot convert model to dict: model_dump not available ({type(model)})"
) from e
# 自动设置schema
if self.schema is None:
self.schema = type(model)
# ===== 辅助方法 =====
def get_field(self, field_name: str, default: Any = None) -> Any:
"""
从data中获取指定字段的值。
Args:
field_name: 字段名
default: 默认值
Returns:
字段值,如果不存在则返回default
"""
if self.data is None:
return default
return self.data.get(field_name, default)
def set_field(self, field_name: str, value: Any) -> None:
"""
设置data中的指定字段。
Args:
field_name: 字段名
value: 字段值
"""
if self.data is None:
self.data = {}
self.data[field_name] = value
def has_field(self, field_name: str) -> bool:
"""
检查data中是否存在指定字段。
Args:
field_name: 字段名
Returns:
True 如果字段存在
"""
if self.data is None:
return False
return field_name in self.data
# ===== 状态管理方法(带日志记录) =====
def append_log(self, message: str) -> None:
"""追加日志到 sync_log(中文)"""
import time
from datetime import datetime
timestamp = datetime.fromtimestamp(time.time()).strftime("%H:%M:%S")
log_entry = f"[{timestamp}] {message}"
if self.sync_log:
self.sync_log += "\n" + log_entry
else:
self.sync_log = log_entry
def set_status(self, new_status: SyncStatus, reason: str = "") -> None:
"""
设置status并更新时间戳和日志(如果状态改变)
Args:
new_status: 新的执行状态
reason: 状态变化原因(中文)
"""
if self.status != new_status:
old_status = self.status
self.status = new_status
reason_text = f" - {reason}" if reason else ""
# DEBUG 日志
import logging
logger = logging.getLogger(__name__)
logger.debug(
f"[{self.node_type}] 节点 {self.node_id}: "
f"执行状态 {old_status.value}{new_status.value}{reason_text}"
)
def set_data_id(self, new_data_id: str, reason: str = "") -> None:
"""
设置 data_id(仅允许在 event apply 路径内调用)
"""
if self.data_id != new_data_id:
old_data_id = self.data_id
self.data_id = new_data_id
reason_text = f" - {reason}" if reason else ""
self.append_log(f"数据ID: {old_data_id or '<empty>'}{new_data_id or '<empty>'}{reason_text}")
import logging
logger = logging.getLogger(__name__)
logger.debug(
f"[{self.node_type}] 节点 {self.node_id}: "
f"data_id {old_data_id or '<empty>'}{new_data_id or '<empty>'}{reason_text}"
)
def set_binding_status(self, new_status: BindingStatus, reason: str = "") -> None:
"""
设置binding_status并更新时间戳和日志(如果状态改变)
Args:
new_status: 新的绑定状态
reason: 状态变化原因(中文)
"""
if self.binding_status != new_status:
if self.__dict__.get("_core_guard_enabled", False):
write_source = self.__dict__.get("_core_write_source", "")
if write_source != "state_machine":
raise RuntimeError(
f"binding_status update must go through state machine for node={self.node_id}, source={write_source or '<none>'}"
)
old_status = self.binding_status
self.binding_status = new_status
reason_text = reason or "<no-reason>"
# DEBUG 日志
import logging
logger = logging.getLogger(__name__)
logger.debug(
f"[{self.node_type}] 节点 {self.node_id}: "
f"绑定状态 {old_status.value}{new_status.value} - {reason_text}"
)
def set_action(self, new_action: SyncAction, reason: str = "") -> None:
"""
设置action并更新时间戳和日志(如果动作改变)
Args:
new_action: 新的同步动作
reason: 动作变化原因(中文)
"""
if self.action != new_action:
old_action = self.action
self.action = new_action
reason_text = f" - {reason}" if reason else ""
# DEBUG 日志
import logging
logger = logging.getLogger(__name__)
logger.debug(
f"[{self.node_type}] 节点 {self.node_id}: "
f"同步动作 {old_action.value}{new_action.value}{reason_text}"
)
+15
View File
@@ -0,0 +1,15 @@
from __future__ import annotations
from typing import Any, Mapping, TypeGuard
from pydantic import BaseModel
def is_pydantic_model_class(value: Any) -> TypeGuard[type[BaseModel]]:
return isinstance(value, type) and issubclass(value, BaseModel)
def get_pydantic_model_fields(value: Any) -> Mapping[str, Any]:
if is_pydantic_model_class(value):
return value.model_fields
return {}
+48
View File
@@ -0,0 +1,48 @@
"""
Common type definitions for the sync system.
This module contains only enum definitions. SyncNode is defined in sync_node.py.
"""
from enum import Enum
from pydantic import BaseModel
from typing import Optional
class SyncAction(str, Enum):
"""同步动作枚举"""
NONE = "NONE" # 保持现状 (SKIP)
CREATE = "CREATE" # 创建新对象
UPDATE = "UPDATE" # 更新现有对象
DELETE = "DELETE" # 删除对象
class SyncStatus(str, Enum):
"""同步执行状态枚举"""
PENDING = "PENDING"
IN_PROGRESS = "IN_PROGRESS"
SUCCESS = "SUCCESS"
FAILED = "FAILED"
SKIPPED = "SKIPPED"
PRECONDITION_FAILED = "PRECONDITION_FAILED"
class BindingStatus(str, Enum):
"""绑定状态枚举"""
UNCHECKED = "unchecked" # 初始瞬态 (Init)
# --- 终态 (Final States) ---
NORMAL = "normal" # 稳态: 绑定存在,数据健康
MISSING = "missing" # 孤儿: 未找到绑定对象
# --- 阻断态 (Blocking States) ---
ABNORMAL = "abnormal" # 只有ID没有数据 (Corrupted)
WARNING = "warning" # 匹配成功但数据不可用 (Data Invalid) 或 对方损坏
DEPENDENCY_ERROR = "dep_err" # 依赖项缺失、父级ID解析失败或异常
PEER_CREATING = "peer_creating" # 对侧创建中,本侧等待提交
class BindingRecord(BaseModel):
"""持久化绑定记录的结构化对象 (纯 ID 映射)"""
local_id: str
remote_id: Optional[str] = None
+51
View File
@@ -0,0 +1,51 @@
from .datasource_config import ApiDataSourceConfig, JsonlDataSourceConfig, DataSourceConfig
from .persist_config import PersistConfig
from .logging_config import LoggingConfig, resolve_log_file_path
from .run_presets import (
list_preset_names,
get_preset_file_candidates,
resolve_preset_file,
load_overrides_from_file,
load_named_preset_overrides,
)
from .strategy_config import (
OrphanAction,
UpdateDirection,
StrategyConfig,
StrategyRuntimeConfig,
ConfigPresets,
)
from .pipeline_config import PipelineRunConfig, DEFAULT_NODE_TYPES
from .builder import (
build_default_config,
build_config,
apply_config_overrides,
apply_env_overrides,
config_snapshot,
)
__all__ = [
"ApiDataSourceConfig",
"JsonlDataSourceConfig",
"PipelineRunConfig",
"DataSourceConfig",
"PersistConfig",
"LoggingConfig",
"resolve_log_file_path",
"list_preset_names",
"get_preset_file_candidates",
"resolve_preset_file",
"load_overrides_from_file",
"load_named_preset_overrides",
"OrphanAction",
"UpdateDirection",
"StrategyConfig",
"StrategyRuntimeConfig",
"ConfigPresets",
"DEFAULT_NODE_TYPES",
"build_default_config",
"build_config",
"apply_config_overrides",
"apply_env_overrides",
"config_snapshot",
]
+189
View File
@@ -0,0 +1,189 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Dict, Mapping, Optional, Union
from pydantic import BaseModel
from ..common.registry import DomainRegistry
from .datasource_config import DataSourceConfig
from .pipeline_config import PipelineRunConfig
from .strategy_config import ConfigPresets, StrategyRuntimeConfig
def _deep_merge(base: Mapping[str, Any], override: Mapping[str, Any]) -> Dict[str, Any]:
merged: Dict[str, Any] = dict(base)
for key, value in override.items():
if isinstance(value, dict) and isinstance(merged.get(key), dict):
if key in {"local_datasource", "remote_datasource"}:
current_type = merged[key].get("type")
next_type = value.get("type", current_type)
if current_type != next_type:
merged[key] = dict(value)
continue
merged[key] = _deep_merge(merged[key], value)
else:
merged[key] = value
return merged
def _default_strategy_map(node_types: list[str]) -> Dict[str, StrategyRuntimeConfig]:
strategy_map: Dict[str, StrategyRuntimeConfig] = {}
for node_type in node_types:
strategy_class = DomainRegistry.get_strategy_class(node_type)
if strategy_class is None:
continue
strategy_map[node_type] = StrategyRuntimeConfig(
node_type=node_type,
config=strategy_class.default_config.model_copy(deep=True),
)
return strategy_map
def _apply_strategy_overrides(
node_types: list[str],
strategy_overrides: Any,
) -> list[StrategyRuntimeConfig]:
strategy_map = _default_strategy_map(node_types)
if isinstance(strategy_overrides, list):
for item in strategy_overrides:
runtime_cfg = StrategyRuntimeConfig.model_validate(item)
strategy_map[runtime_cfg.node_type] = runtime_cfg
return [strategy_map[n] for n in node_types if n in strategy_map]
if not isinstance(strategy_overrides, dict):
return [strategy_map[n] for n in node_types if n in strategy_map]
for node_type, raw_override in strategy_overrides.items():
if node_type not in strategy_map:
strategy_map[node_type] = StrategyRuntimeConfig(node_type=node_type)
runtime_cfg = strategy_map[node_type]
override = dict(raw_override or {})
preset = override.pop("preset", None)
if preset:
runtime_cfg.config = ConfigPresets.get_preset(str(preset))
override.pop("skip_sync", None)
override.pop("force_sync", None)
override.pop("load_mode", None)
if "config" in override and isinstance(override["config"], dict):
runtime_cfg.config = runtime_cfg.config.model_validate(
_deep_merge(runtime_cfg.config.model_dump(), override.pop("config"))
)
if override:
runtime_cfg.config = runtime_cfg.config.model_validate(
_deep_merge(runtime_cfg.config.model_dump(), override)
)
return [strategy_map[n] for n in node_types if n in strategy_map]
def config_snapshot(config: PipelineRunConfig) -> Dict[str, Any]:
snap = config.model_dump(mode="json")
for key in ("local_datasource", "remote_datasource"):
ds = snap.get(key) or {}
if ds.get("api_secret"):
ds["api_secret"] = "***"
return snap
def build_default_config(project_root: Path) -> PipelineRunConfig:
defaults = {
"local_datasource": {
"type": "jsonl",
"jsonl_dir": str(project_root / "filtered_datasource" / "datasource" / "ecm-2025-12-02"),
"read_only": False,
"handler_configs": {},
},
"remote_datasource": {
"type": "jsonl",
"jsonl_dir": str(project_root / "remote_datasource" / "datasource"),
"read_only": False,
"handler_configs": {},
},
"target_project_ids": [],
"persist": {
"db_path": str(project_root / "test_results" / "sync_state_machine" / "pipeline_default.db"),
"enable": True,
"wipe_on_start": False,
},
"logging": {
"initialize": True,
"file_path": None,
"level": 20,
"suppress_node_logs": True,
},
}
config = PipelineRunConfig.model_validate(defaults)
config.strategies = _apply_strategy_overrides(config.node_types, {})
return config
def apply_config_overrides(config: PipelineRunConfig, overrides: Dict[str, Any]) -> PipelineRunConfig:
merged = _deep_merge(config.model_dump(), overrides)
raw_strategies = merged.pop("strategies", None)
legacy_strategy_overrides = merged.pop("strategy_overrides", None)
resolved = PipelineRunConfig.model_validate(merged)
if raw_strategies is not None:
resolved.strategies = _apply_strategy_overrides(resolved.node_types, raw_strategies)
else:
resolved.strategies = _apply_strategy_overrides(resolved.node_types, legacy_strategy_overrides or {})
return resolved
def apply_env_overrides(
config: PipelineRunConfig,
project_root: Path,
*,
dotenv_file: Optional[Union[str, Path]] = None,
) -> PipelineRunConfig:
raise NotImplementedError(
"Environment-based overrides are removed. Use JSON config files via overrides_file instead."
)
def build_config(
*,
project_root: Path,
local_datasource: Optional[Union[DataSourceConfig, Dict[str, Any]]] = None,
remote_datasource: Optional[Union[DataSourceConfig, Dict[str, Any]]] = None,
overrides: Optional[Dict[str, Any]] = None,
overrides_file: Optional[Union[str, Path]] = None,
dotenv_file: Optional[Union[str, Path]] = None,
) -> PipelineRunConfig:
if dotenv_file is not None:
raise ValueError("dotenv_file is no longer supported; use overrides_file JSON instead")
config = build_default_config(project_root)
if local_datasource is not None:
local_payload = (
local_datasource.model_dump() if isinstance(local_datasource, BaseModel) else dict(local_datasource)
)
config = apply_config_overrides(config, {"local_datasource": local_payload})
if remote_datasource is not None:
remote_payload = (
remote_datasource.model_dump() if isinstance(remote_datasource, BaseModel) else dict(remote_datasource)
)
config = apply_config_overrides(config, {"remote_datasource": remote_payload})
if overrides_file:
override_path = Path(overrides_file)
file_data = json.loads(override_path.read_text(encoding="utf-8"))
config = apply_config_overrides(config, file_data)
if overrides:
config = apply_config_overrides(config, overrides)
return config
@@ -0,0 +1,34 @@
from __future__ import annotations
from typing import Annotated, Any, Dict, List, Literal, Union
from pydantic import BaseModel, ConfigDict, Field
class JsonlDataSourceConfig(BaseModel):
model_config = ConfigDict(extra="forbid", validate_assignment=True)
type: Literal["jsonl"] = "jsonl"
jsonl_dir: str
read_only: bool = False
target_project_ids: List[str] = Field(default_factory=list)
handler_configs: Dict[str, Dict[str, Any]] = Field(default_factory=dict)
class ApiDataSourceConfig(BaseModel):
model_config = ConfigDict(extra="forbid", validate_assignment=True)
type: Literal["api"] = "api"
api_base_url: str = ""
api_uid: str = ""
api_secret: str = ""
api_debug: bool = False
poll_max_retries: int = 10
poll_interval: float = 0.5
api_rate_limit_max_requests: int = 10
api_rate_limit_window_seconds: float = 10.0
target_project_ids: List[str] = Field(min_length=1)
handler_configs: Dict[str, Dict[str, Any]] = Field(default_factory=dict)
DataSourceConfig = Annotated[Union[JsonlDataSourceConfig, ApiDataSourceConfig], Field(discriminator="type")]
@@ -0,0 +1,30 @@
from datetime import datetime
from pathlib import Path
from typing import Optional
from pydantic import BaseModel, ConfigDict
class LoggingConfig(BaseModel):
model_config = ConfigDict(extra="forbid", validate_assignment=True)
initialize: bool = True
file_path: Optional[str] = None
file_dir: Optional[str] = None
file_name_pattern: Optional[str] = None
level: int = 20
suppress_node_logs: bool = True
def resolve_log_file_path(cfg: LoggingConfig) -> Optional[str]:
if cfg.file_path:
return cfg.file_path
if not cfg.file_dir:
return None
pattern = cfg.file_name_pattern or "pipeline_{timestamp}.log"
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = pattern.replace("{timestamp}", ts)
log_dir = Path(cfg.file_dir)
log_dir.mkdir(parents=True, exist_ok=True)
return str(log_dir / filename)
@@ -0,0 +1,600 @@
meta:
name: node_state_machine
version: 0.1
language: zh-CN
desc: "节点状态机配置(用于驱动/校验未来的状态机组件)"
# 命名约定(配置内唯一真名;图上不再有 display/别名):
# - Sxx: State / PseudoState(节点)
# - Exx: Event(事件)
# - 事件分支由渲染器在边上追加 a/b/c...(对应 outcomes 顺序)
id_convention:
state_prefix: "S"
event_prefix: "E"
# Variables are computed by the caller and passed into the state machine.
# Rule: the YAML never directly mutates "peer" nodes. Cross-node changes must be
# represented as separate invocations with different inputs.
context:
is_create_zombie: { type: bool, desc: "初始化时判定为 CREATE 僵尸:action=CREATE 且 (status=FAILED 或 data_id 为空)" }
has_binding_record: { type: bool, desc: "binding_manager 里存在绑定记录 (remote_id/local_id 非空)" }
core_binding_result: { type: enum, values: [NORMAL, WARNING, ABNORMAL], desc: "SyncDecisionEngine.determine_core_status() 给本节点的结果(仅在 has_binding_record=true 时有效)" }
peer_core_binding_result: { type: enum, values: [NORMAL, WARNING, ABNORMAL], desc: "SyncDecisionEngine.determine_core_status() 给对方节点的结果(用于静态约束 core matrix 对称性)" }
data_present: { type: bool, desc: "node.get_data() 非空" }
has_depend_fields: { type: bool, desc: "config.depend_fields 非空" }
dep_satisfied: { type: bool, desc: "依赖满足:空依赖值跳过;非空依赖值必须能在 collection 中按 data_id 找到且节点状态可用" }
auto_bind_enabled: { type: bool, desc: "config.auto_bind && auto_bind_fields 非空" }
auto_bind_outcome:
type: enum
values: [ONE_TO_ONE, AMBIGUOUS, ZERO, KEY_MISSING, KEY_ID_RESOLVE_FAIL, DATA_MISSING]
desc: "自动绑定结果(由业务代码完成全集搜索+去噪后 1:1 判定)"
create_enabled: { type: bool, desc: "create() 是否对当前方向启用 (local_orphan_action/remote_orphan_action)" }
create_id_resolve_ok: { type: bool, desc: "IDResolver.resolve_and_validate() 是否成功" }
spawn_success: { type: bool, desc: "创建副作用(spawn + bind)是否成功" }
update_enabled: { type: bool, desc: "update() 是否启用 (update_direction != NONE)" }
target_has_data_id: { type: bool, desc: "UPDATE 目标节点 data_id 非空" }
update_id_resolve_ok: { type: bool, desc: "UPDATE 前 IDResolver.resolve_and_validate() 是否成功" }
has_diff: { type: bool, desc: "needs_update_check 判定有差异" }
handler_result:
type: enum
values: [SUCCESS, FAILED, SKIPPED, IN_PROGRESS]
desc: "TaskResult.status"
poll_timeout: { type: bool, desc: "异步轮询超过 max_retries" }
execute_action:
type: enum
values: [CREATE, UPDATE, DELETE, NONE]
desc: "E40 执行时的动作类型(用于区分 SKIPPED 的落点)"
# State IDs are for visualization and static validation.
# NOTE: 本状态机按“加载时清空执行状态,再开始本轮”建模:
# - 加载/初始化后,稳态节点的 status 统一回到 PENDING
# - 上一轮遗留的 FAILED/SUCCESS 等不作为本轮的入口状态
states:
# --- Stable entry to bind (after init reset + zombie cleanup) ---
S00:
binding_status: UNCHECKED
action: NONE
status: PENDING
data_id_exist: true
desc: |
进入 bind 前的唯一起点
binding_status: UNCHECKED
action: NONE
status: PENDING
data_id_exist: true
# --- Bind results ---
S01:
binding_status: NORMAL
action: NONE
status: PENDING
data_id_exist: true
desc: |
绑定正常(待后续 update 检查)
binding_status: NORMAL
action: NONE
status: PENDING
data_id_exist: true
S02:
binding_status: MISSING
action: NONE
status: PENDING
data_id_exist: true
desc: |
孤儿节点(可能进入 create / auto_bind / delete
binding_status: MISSING
action: NONE
status: PENDING
data_id_exist: true
S03:
binding_status: ABNORMAL
action: NONE
status: PENDING
data_id_exist: true
desc: |
数据损坏/缺失(阻断态,需人工)
binding_status: ABNORMAL
action: NONE
status: PENDING
data_id_exist: true
S04:
binding_status: WARNING
action: NONE
status: PENDING
data_id_exist: true
desc: |
不可自动处理(阻断态,需人工)
binding_status: WARNING
action: NONE
status: PENDING
data_id_exist: true
S05:
binding_status: DEPENDENCY_ERROR
action: NONE
status: PENDING
data_id_exist: true
desc: |
依赖项不满足/ID解析失败(阻断态)
binding_status: DEPENDENCY_ERROR
action: NONE
status: PENDING
data_id_exist: true
# --- Create & Update prepared actions (target-side nodes) ---
S06:
binding_status: NORMAL
action: CREATE
status: PENDING
data_id_exist: false
shape: pseudo
desc: |
待执行 CREATE(执行入口)
binding_status: NORMAL
action: CREATE
status: PENDING
data_id_exist: false
S07:
binding_status: NORMAL
action: UPDATE
status: PENDING
data_id_exist: true
desc: |
准备更新
binding_status: NORMAL
action: UPDATE
status: PENDING
data_id_exist: true
S08:
binding_status: NORMAL
action: UPDATE
status: PRECONDITION_FAILED
data_id_exist: [false, true]
desc: |
更新前置失败(缺 data_id / 依赖ID未解析等)
binding_status: NORMAL
action: UPDATE
status: PRECONDITION_FAILED
data_id_exist: false | true
S09:
binding_status: NORMAL
action: DELETE
status: PENDING
data_id_exist: true
desc: |
待执行 DELETE(执行入口)
binding_status: NORMAL
action: DELETE
status: PENDING
data_id_exist: true
# --- Sync results ---
S10C:
binding_status: NORMAL
action: CREATE
status: IN_PROGRESS
data_id_exist: false
desc: |
CREATE 异步执行中
binding_status: NORMAL
action: CREATE
status: IN_PROGRESS
data_id_exist: false
S10U:
binding_status: NORMAL
action: UPDATE
status: IN_PROGRESS
data_id_exist: true
desc: |
UPDATE 异步执行中
binding_status: NORMAL
action: UPDATE
status: IN_PROGRESS
data_id_exist: true
S10D:
binding_status: NORMAL
action: DELETE
status: IN_PROGRESS
data_id_exist: true
desc: |
DELETE 异步执行中
binding_status: NORMAL
action: DELETE
status: IN_PROGRESS
data_id_exist: true
S11C:
binding_status: NORMAL
action: CREATE
status: SUCCESS
data_id_exist: true
desc: |
CREATE 执行成功
binding_status: NORMAL
action: CREATE
status: SUCCESS
data_id_exist: true
S11U:
binding_status: NORMAL
action: UPDATE
status: SUCCESS
data_id_exist: true
desc: |
UPDATE 执行成功
binding_status: NORMAL
action: UPDATE
status: SUCCESS
data_id_exist: true
S11D:
binding_status: NORMAL
action: DELETE
status: SUCCESS
data_id_exist: true
desc: |
DELETE 执行成功(随后删除节点)
binding_status: NORMAL
action: DELETE
status: SUCCESS
data_id_exist: true
S12C:
binding_status: NORMAL
action: CREATE
status: FAILED
data_id_exist: [false, true]
desc: |
CREATE 执行失败
binding_status: NORMAL
action: CREATE
status: FAILED
data_id_exist: false | true
S12U:
binding_status: NORMAL
action: UPDATE
status: FAILED
data_id_exist: [false, true]
desc: |
UPDATE 执行失败
binding_status: NORMAL
action: UPDATE
status: FAILED
data_id_exist: false | true
S12D:
binding_status: NORMAL
action: DELETE
status: FAILED
data_id_exist: [false, true]
desc: |
DELETE 执行失败
binding_status: NORMAL
action: DELETE
status: FAILED
data_id_exist: false | true
S13:
binding_status: PEER_CREATING
action: NONE
status: PENDING
data_id_exist: true
desc: |
对侧创建进行中(本侧等待提交)
binding_status: PEER_CREATING
action: NONE
status: PENDING
data_id_exist: true
# Handler 在 update 路径中可能把 action 降级为 NONE 并返回 SKIPPED。
# NOTE: 这会产生 (NORMAL, NONE, SKIPPED),并且该节点会被统计到 none 列。
S14:
binding_status: NORMAL
action: NONE
status: SKIPPED
data_id_exist: true
desc: |
更新被跳过(执行时降级为 NONE)
binding_status: NORMAL
action: NONE
status: SKIPPED
data_id_exist: true
pseudo_states:
S15:
desc: "节点被清理删除(zombie cleanup"
S16:
desc: "持久化加载输入态:本轮 E01 的输入节点集合(可包含上轮遗留与已稳定节点)"
S17:
desc: "加载异常隔离态:持久化枚举非法,保留错误供人工处理,不进入自动流程"
stages:
bootstrap:
desc: "启动/初始化(仅针对持久化加载出的节点):僵尸清理与归并到 S00"
entry_states: [S16]
exit_states: [S00, S15]
bind:
desc: "绑定判定:core matrix +(可选)依赖检查 +(可选)自动绑定;stage 仅约束边界,不要求内部顺序"
entry_states: [S00]
exit_states: [S01, S02, S03, S04, S05, S13]
create_prepare:
desc: "Create: 从 MISSING 源节点发起创建请求并在副作用提交后归并"
entry_states: [S02, S13]
exit_states: [S01, S02, S04, S05, S09, S13]
update_prepare:
desc: "Update: 检测差异并在目标节点上设置 UPDATE/PRECONDITION_FAILED"
entry_states: [S01]
exit_states: [S01, S07, S08]
post_create_ready:
desc: "创建成功后归并:CREATE 成功节点回到 NORMAL/NONE/PENDING,作为 update 起点"
entry_states: [S11C]
exit_states: [S01]
sync_execute:
desc: "Sync: DataSource 调用 handler,根据 TaskResult 更新 status;部分 handler 可能降级 action"
entry_states: [S06, S07, S09, S10C, S10U, S10D]
exit_states: [S10C, S10U, S10D, S11C, S11U, S11D, S12C, S12U, S12D, S14]
transitions:
E01:
stage: bootstrap
desc: "初始化:删CREATE僵尸;其余重置后进入起点"
from: [S16]
outcomes:
- when: { is_create_zombie: true }
to: S15
- when: { is_create_zombie: false }
to: S00
# --- Bind phase1 core ---
E10:
stage: bind
desc: "核心绑定判定:有绑定按core结果分流;无绑定→MISSING"
from: [S00]
outcomes:
- when: { has_binding_record: true, core_binding_result: NORMAL, peer_core_binding_result: NORMAL }
to: S01
emit:
note: "绑定关系一致,进入正常态"
- when: { has_binding_record: true, core_binding_result: WARNING, peer_core_binding_result: ABNORMAL }
to: S04
emit:
note: "对端异常,本端标记WARNING"
- when: { has_binding_record: true, core_binding_result: ABNORMAL, peer_core_binding_result: WARNING }
to: S03
emit:
note: "本端异常,进入ABNORMAL"
- when: { has_binding_record: true, core_binding_result: ABNORMAL, peer_core_binding_result: ABNORMAL }
to: S03
emit:
note: "双方异常,进入ABNORMAL"
- when: { has_binding_record: false }
to: S02
emit:
note: "无绑定记录,标记为MISSING"
# --- Bind phase2 dependency ---
E15:
stage: bind
desc: "依赖检查:失败→阻断;缺少依赖配置→WARNING;通过→不变(不在图上画自环)"
from: [S02]
outcomes:
- when: { has_depend_fields: false, data_present: true }
to: S04
emit:
note: "缺少依赖字段配置,无法自动处理"
- when: { data_present: false }
to: S03
emit:
note: "节点数据为空或不存在"
- when: { data_present: true, has_depend_fields: true, dep_satisfied: false }
to: S05
emit:
note: "依赖项不满足"
# --- Bind phase3 auto bind ---
E16:
stage: bind
desc: "自动绑定:1:1→NORMAL;歧义/缺字段/数据为空→阻断;N:0 且允许创建→S13 等待创建提交"
from: [S02]
outcomes:
- when: { auto_bind_enabled: false }
to: S04
emit:
note: "自动绑定未启用"
- when: { auto_bind_enabled: true, auto_bind_outcome: DATA_MISSING }
to: S04
emit:
note: "节点数据为空,无法自动绑定"
- when: { auto_bind_enabled: true, auto_bind_outcome: ONE_TO_ONE }
to: S01
emit:
note: "自动绑定成功(1:1"
- when: { auto_bind_enabled: true, auto_bind_outcome: AMBIGUOUS }
to: S04
emit:
note: "存在多个候选节点,无法自动绑定"
- when: { auto_bind_enabled: true, auto_bind_outcome: KEY_MISSING }
to: S04
emit:
note: "缺少业务键字段,无法自动绑定"
- when: { auto_bind_enabled: true, auto_bind_outcome: KEY_ID_RESOLVE_FAIL }
to: S05
emit:
note: "业务键中的ID字段解析失败"
- when: { auto_bind_enabled: true, auto_bind_outcome: ZERO, create_enabled: true }
to: S13
emit:
spawn_request: true
spawn_target_node_state: S06
note: "自动绑定识别为需创建,进入创建提交中间态"
- when: { auto_bind_enabled: true, auto_bind_outcome: ZERO, create_enabled: false }
to: S04
emit:
note: "未匹配到候选节点且不允许创建"
# --- Create prepare ---
E20:
stage: create_prepare
desc: "创建准备阶段2spawn 成功则提交到 NORMAL;失败保持等待态(不迁移)"
from: [S13]
outcomes:
- when: { spawn_success: true }
to: S01
emit:
note: "创建副作用提交成功"
E21:
stage: create_prepare
desc: "创建依赖解析失败→DEPENDENCY_ERROR"
from: [S02]
outcomes:
- when: { create_enabled: true, data_present: true, create_id_resolve_ok: false }
to: S05
emit:
note: "创建前依赖ID解析失败"
E22:
stage: create_prepare
desc: "孤儿自删准备:从 MISSING 进入 DELETE 执行入口"
from: [S02]
outcomes:
- when: {}
to: S09
emit:
note: "孤儿节点按策略进入自删"
# --- Update prepare (target-side) ---
E30:
stage: update_prepare
desc: "更新准备:缺data_id/依赖未解析→PRECONDITION_FAILED;有差异→UPDATE"
from: [S01]
outcomes:
- when: { update_enabled: true, target_has_data_id: false }
to: S08
emit:
note: "目标节点缺少data_id"
- when: { update_enabled: true, target_has_data_id: true, update_id_resolve_ok: false }
to: S08
emit:
note: "更新前依赖ID未解析"
- when: { update_enabled: true, target_has_data_id: true, update_id_resolve_ok: true, has_diff: true }
to: S07
emit:
note: "检测到数据差异,准备更新"
# --- Post-create normalization ---
E41:
stage: post_create_ready
desc: "创建成功后归并到 update 起点"
from: [S11C]
outcomes:
- when: { execute_action: CREATE }
to: S01
emit:
note: "创建成功后转入更新起点"
# --- Sync execute (split by action) ---
E40C_START:
stage: sync_execute
desc: "CREATE 执行入口:进入异步执行中"
from: [S06]
outcomes:
- when: { handler_result: IN_PROGRESS }
to: S10C
emit:
note: "CREATE 进入异步执行中"
E40C:
stage: sync_execute
desc: "CREATE 执行结果回写:进行中/成功/失败"
from: [S10C]
outcomes:
- when: { handler_result: SUCCESS }
to: S11C
emit:
note: "CREATE 执行成功"
- when: { handler_result: FAILED }
to: S12C
emit:
note: "CREATE 执行失败"
E40U_START:
stage: sync_execute
desc: "UPDATE 执行入口:进入异步执行中"
from: [S07]
outcomes:
- when: { handler_result: IN_PROGRESS }
to: S10U
emit:
note: "UPDATE 进入异步执行中"
E40U:
stage: sync_execute
desc: "UPDATE 执行结果回写:进行中/成功/失败/跳过"
from: [S10U]
outcomes:
- when: { handler_result: SUCCESS }
to: S11U
emit:
note: "UPDATE 执行成功"
- when: { handler_result: FAILED }
to: S12U
emit:
note: "UPDATE 执行失败"
- when: { handler_result: SKIPPED }
to: S14
emit:
note: "更新降级并跳过"
E40D_START:
stage: sync_execute
desc: "DELETE 执行入口:进入异步执行中"
from: [S09]
outcomes:
- when: { handler_result: IN_PROGRESS }
to: S10D
emit:
note: "DELETE 进入异步执行中"
E40D:
stage: sync_execute
desc: "DELETE 执行结果回写:进行中/成功/失败"
from: [S10D]
outcomes:
- when: { handler_result: SUCCESS }
to: S11D
emit:
note: "DELETE 执行成功"
delete_node: true
- when: { handler_result: FAILED }
to: S12D
emit:
note: "DELETE 执行失败"
invariants:
INV-1_UNCHECKED_ACTION_NONE:
desc: "UNCHECKED 不允许有动作"
when: { binding_status: UNCHECKED }
require: { action: NONE }
INV-2_BLOCKING_NO_ACTION:
desc: "阻断态不允许执行动作"
when_any:
- { binding_status: ABNORMAL }
- { binding_status: WARNING }
- { binding_status: DEPENDENCY_ERROR }
require: { action: NONE }
INV-3_PRECOND_IMPLIES_UPDATE:
desc: "PRECONDITION_FAILED 必须伴随 action=UPDATE"
when: { status: PRECONDITION_FAILED }
require: { action: UPDATE }
@@ -0,0 +1,13 @@
from typing import Any, Dict, Literal
from pydantic import BaseModel, ConfigDict, Field
class PersistConfig(BaseModel):
model_config = ConfigDict(extra="forbid", validate_assignment=True)
backend: Literal["sqlite", "sqlalchemy", "custom"] = "sqlite"
db_path: str
backend_options: Dict[str, Any] = Field(default_factory=dict)
enable: bool = True
wipe_on_start: bool = False
@@ -0,0 +1,48 @@
from __future__ import annotations
from typing import List
from pydantic import BaseModel, ConfigDict, Field
from .datasource_config import DataSourceConfig
from .logging_config import LoggingConfig
from .persist_config import PersistConfig
from .strategy_config import StrategyRuntimeConfig
DEFAULT_NODE_TYPES: List[str] = [
"company",
"supplier",
"user",
"project",
"contract",
"contract_change",
"construction_task",
"construction_task_detail",
"material",
"material_detail",
"contract_settlement",
"contract_settlement_detail",
"personnel",
"personnel_detail",
"preparation",
"production",
"lar",
"lar_change",
"project_detail_data",
"units",
]
class PipelineRunConfig(BaseModel):
model_config = ConfigDict(extra="forbid", validate_assignment=True)
node_types: List[str] = Field(default_factory=lambda: list(DEFAULT_NODE_TYPES))
target_project_ids: List[str] = Field(default_factory=list)
local_datasource: DataSourceConfig
remote_datasource: DataSourceConfig
strategies: List[StrategyRuntimeConfig] = Field(default_factory=list)
persist: PersistConfig
logging: LoggingConfig = Field(default_factory=LoggingConfig)
+86
View File
@@ -0,0 +1,86 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Dict, List, Tuple
PRESET_FILES: Dict[str, str] = {
"jsonl_sm": "jsonl_to_jsonl",
"simple_sm": "jsonl_to_api",
}
def _run_profiles_dir() -> Path:
return Path(__file__).resolve().parents[2] / "run_profiles"
def _preset_templates_dir() -> Path:
return _run_profiles_dir() / "preset"
def _replace_placeholders(value: Any, project_root: Path) -> Any:
if isinstance(value, str):
return value.replace("${PROJECT_ROOT}", str(project_root))
if isinstance(value, dict):
return {k: _replace_placeholders(v, project_root) for k, v in value.items()}
if isinstance(value, list):
return [_replace_placeholders(v, project_root) for v in value]
return value
def list_preset_names() -> List[str]:
return sorted(PRESET_FILES.keys())
def get_preset_file_candidates(preset_name: str) -> List[Path]:
if preset_name not in PRESET_FILES:
raise ValueError(f"Unknown preset: {preset_name}. Available: {list_preset_names()}")
base = PRESET_FILES[preset_name]
run_profiles_root = _run_profiles_dir()
preset_templates_root = _preset_templates_dir()
return [
run_profiles_root / f"{base}.local.yaml",
run_profiles_root / f"{base}.local.yml",
run_profiles_root / f"{base}.local.json",
preset_templates_root / f"{base}.default.yaml",
preset_templates_root / f"{base}.default.yml",
preset_templates_root / f"{base}.default.json",
]
def resolve_preset_file(preset_name: str) -> Tuple[Path, bool]:
candidates = get_preset_file_candidates(preset_name)
for idx, path in enumerate(candidates):
if path.exists() and path.is_file():
return path, idx > 0
raise FileNotFoundError(
f"No preset file found for {preset_name}. Tried: {[str(p) for p in candidates]}"
)
def load_overrides_from_file(file_path: Path, project_root: Path) -> Dict[str, Any]:
suffix = file_path.suffix.lower()
text = file_path.read_text(encoding="utf-8")
if suffix in {".yaml", ".yml"}:
try:
import yaml # type: ignore
except Exception as exc:
raise RuntimeError(
"YAML config requires PyYAML. Install with: pip install pyyaml"
) from exc
payload = yaml.safe_load(text)
else:
payload = json.loads(text)
resolved = _replace_placeholders(payload, project_root)
if not isinstance(resolved, dict):
raise ValueError(f"Preset file must contain object mapping: {file_path}")
return resolved
def load_named_preset_overrides(project_root: Path, preset_name: str) -> Tuple[Dict[str, Any], Path, bool]:
file_path, used_default = resolve_preset_file(preset_name)
overrides = load_overrides_from_file(file_path, project_root)
return overrides, file_path, used_default
@@ -0,0 +1,43 @@
"""Compatibility layer for legacy imports.
Prefer importing from `sync_state_machine.config` directly.
"""
from .datasource_config import ApiDataSourceConfig, JsonlDataSourceConfig, DataSourceConfig
from .persist_config import PersistConfig
from .logging_config import LoggingConfig
from .strategy_config import (
OrphanAction,
UpdateDirection,
StrategyConfig,
StrategyRuntimeConfig,
ConfigPresets,
)
from .pipeline_config import PipelineRunConfig, DEFAULT_NODE_TYPES
from .builder import (
build_default_config,
build_config,
apply_config_overrides,
apply_env_overrides,
config_snapshot,
)
__all__ = [
"ApiDataSourceConfig",
"JsonlDataSourceConfig",
"DataSourceConfig",
"PersistConfig",
"LoggingConfig",
"OrphanAction",
"UpdateDirection",
"StrategyConfig",
"StrategyRuntimeConfig",
"ConfigPresets",
"PipelineRunConfig",
"DEFAULT_NODE_TYPES",
"build_default_config",
"build_config",
"apply_config_overrides",
"apply_env_overrides",
"config_snapshot",
]
@@ -0,0 +1,113 @@
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 一致性比较时,忽略列表型字段内各元素的特定子键。
# 格式: {字段名: [子键, ...]},如 {"plan_node": ["is_audit"]}。
# 用于排除后端硬写死、永远与本地不一致的字段,避免误报。
post_check_ignore_list_item_fields: Dict[str, List[str]] = Field(default_factory=dict)
class StrategyRuntimeConfig(BaseModel):
"""Pipeline 层策略运行配置(按 node_type)。"""
model_config = ConfigDict(validate_assignment=True, extra="forbid")
node_type: str
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]()
+252
View File
@@ -0,0 +1,252 @@
# DataSource Layer Implementation
## Overview
This document describes the new datasource layer for `sync_system_new`, which is completely decoupled from the sync strategy and state machine logic.
## Architecture
The datasource layer follows a pure execution model:
1. **Load**: Reads data from backend and creates `SyncNode` instances
2. **Save**: Executes actions (CREATE/UPDATE/DELETE) based on `node.action`
3. **Update Status**: Sets `node.status` to SUCCESS or FAILED after execution
### Key Principles
- **No Strategy Logic**: Datasource doesn't make decisions about what to sync
- **State-Driven**: Only reads `node.action` and `node.status` to determine behavior
- **No ID Resolution**: IDs are already resolved by the strategy layer
- **Backend Agnostic**: Abstract base class supports multiple backends
## Components
### 1. Base Protocol (`base.py`)
#### `DataSourceProtocol`
Protocol defining the interface for all datasource implementations:
```python
async def load_nodes(
node_type: str,
collection: DataCollection,
**filters: Any
) -> None:
"""Load data from backend and create SyncNodes"""
async def save_nodes(
node_type: str,
collection: DataCollection
) -> None:
"""Execute pending actions for nodes"""
```
#### `BaseDataSource`
Abstract base class providing common functionality:
- `load_nodes`: Creates SyncNodes from raw data
- `save_nodes`: Executes actions and updates status
- `_handle_create/update/delete`: Action-specific handlers
Subclasses implement:
- `_load_raw_data`: Load from backend
- `_create_item`: Execute CREATE
- `_update_item`: Execute UPDATE
- `_delete_item`: Execute DELETE
- `_generate_id`: Generate new IDs
### 2. JSONL Implementation (`jsonl_datasource.py`)
#### `JsonlDataSource`
JSONL-based datasource for local testing:
**Features:**
- Loads `.jsonl` files into memory
- Generates UUIDs for new items
- Supports filtering during load
- Supports read-only mode
- Persists changes back to files
**File Structure:**
```
data/
├── contract_1.jsonl
├── project_1.jsonl
└── ...
```
**Usage:**
```python
datasource = JsonlDataSource(Path("data/"))
# Load nodes
await datasource.load_nodes("contract", collection, project_id="P1")
# Execute actions
await datasource.save_nodes("contract", collection)
# Persist to disk
await datasource.save_all()
```
### 3. Domain Handlers
Domain-specific handlers provide business logic (optional):
**Example: `domain/contract/jsonl_handler.py`**
```python
class ContractJsonlHandler:
@staticmethod
def validate_create(data: Any) -> None:
"""Validate before create"""
@staticmethod
def transform_for_create(data: Any) -> Dict[str, Any]:
"""Transform data before create"""
```
## Node Lifecycle
### Loading Phase
1. Datasource reads raw data from backend
2. Creates `SyncNode` for each item:
- `node_id`: Unique session ID (e.g., "contract_C1")
- `data_id`: Backend ID (e.g., "C1")
- `origin_data`: Raw data from backend
- `data`: Copy of origin_data (may be modified by strategy)
- `action`: NONE (initial)
- `status`: PENDING (initial)
### Execution Phase
For each node with `status == PENDING` and `action != NONE`:
1. **CREATE**:
- Generate new ID via `_generate_id`
- Call `_create_item` with new ID and data
- Set `node.data_id` to new ID
- Set `node.status` to SUCCESS
2. **UPDATE**:
- Call `_update_item` with existing `data_id` and data
- Set `node.status` to SUCCESS
3. **DELETE**:
- Call `_delete_item` with `data_id`
- Set `node.status` to SUCCESS
4. **Error Handling**:
- On exception: Set `node.status` to FAILED
- Set `node.error` to exception message
## Integration with Sync System
The datasource is used by the pipeline layer:
```python
# 1. Load data
local_ds = JsonlDataSource(Path("local_data/"))
remote_ds = JsonlDataSource(Path("remote_data/"))
local_collection = DataCollection("local")
remote_collection = DataCollection("remote")
await local_ds.load_nodes("contract", local_collection)
await remote_ds.load_nodes("contract", remote_collection)
# 2. Strategy layer determines actions
strategy = ContractSyncStrategy(...)
await strategy.bind(...) # Sets node.action
# 3. Execute actions
await remote_ds.save_nodes("contract", remote_collection)
await remote_ds.save_all()
```
## Testing
Comprehensive test suite in `tests/test_datasource.py`:
- Basic loading and node creation
- Filtering during load
- CREATE/UPDATE/DELETE actions
- Error handling and status updates
- Read-only mode
- Multiple node types
Run tests:
```bash
python -m pytest sync_system_new/tests/test_datasource.py -v
```
## Future Extensions
### API DataSource
For production use, implement `ApiDataSource`:
```python
class ApiDataSource(BaseDataSource):
async def _load_raw_data(self, node_type: str, **filters):
# Call REST API
async def _create_item(self, node_type: str, item_id: str, data):
# POST request
async def _generate_id(self, node_type: str, data):
# Return None - server generates ID
```
### Database DataSource
For SQL databases:
```python
class DatabaseDataSource(BaseDataSource):
async def _load_raw_data(self, node_type: str, **filters):
# SELECT query
async def _create_item(self, node_type: str, item_id: str, data):
# INSERT query
```
## Comparison with Legacy System
### Legacy System (`sync_system/datasource/`)
- Tightly coupled with RepositoryProtocol
- Mixed concerns (data access + business logic)
- Schema validation in datasource layer
- Different interface for each entity type
### New System (`sync_system_new/datasource/`)
- Decoupled from strategy and state machine
- Pure data access (business logic in domain handlers)
- Generic interface for all entity types
- Direct integration with SyncNode and DataCollection
## Key Differences
| Aspect | Legacy | New |
|--------|--------|-----|
| Coupling | Tight with sync policies | Decoupled from sync logic |
| Interface | Repository per entity | Generic load/save |
| ID Handling | Mixed responsibilities | IDs already resolved |
| State Management | Mixed with data access | SyncNode-based |
| Testing | Complex setup | Simple, focused tests |
## Conclusion
The new datasource layer provides:
1. **Clear Separation**: Data access is isolated from sync logic
2. **Flexibility**: Easy to add new backend types
3. **Testability**: Simple, focused tests
4. **Type Safety**: Full integration with Pydantic models
5. **Maintainability**: Single responsibility per component
+41
View File
@@ -0,0 +1,41 @@
"""
DataSource Layer for sync_state_machine
DataSource 只负责底层 I/O,不关心节点类型。
Handler 负责类型转换和业务逻辑。
"""
from .datasource import BaseDataSource
from .handler import (
NodeHandler,
BaseNodeHandler,
NodeHandlerRegistry,
ValidationResult
)
from .task_result import TaskResult, TaskStatus
# API DataSource
from .api import ApiClient, ApiDataSource, BaseApiHandler
# JSONL DataSource
from .jsonl import JsonlDataSource, BaseJsonlHandler
__all__ = [
# 基础类
"BaseDataSource",
"NodeHandler",
"BaseNodeHandler",
"NodeHandlerRegistry",
"TaskResult",
"TaskStatus",
"ValidationResult",
# API DataSource
"ApiClient",
"ApiDataSource",
"BaseApiHandler",
# JSONL DataSource
"JsonlDataSource",
"BaseJsonlHandler",
]
@@ -0,0 +1,15 @@
"""
API DataSource 模块
提供基于 HTTP API 的数据源实现。
"""
from .client import ApiClient
from .datasource import ApiDataSource
from .handler import BaseApiHandler
__all__ = [
"ApiClient",
"ApiDataSource",
"BaseApiHandler",
]
+627
View File
@@ -0,0 +1,627 @@
"""
ApiClient - HTTP 通信层
职责:
- 封装所有 HTTP 通信逻辑
- 管理 httpx.AsyncClient,维护连接池和 session
- 生成请求签名(timestamp, nonce, sign
- 统一请求入口,自动注入 uid
- 批量 Push Log 查询
"""
import hashlib
import hmac
import time
import uuid
import json as json_lib
import logging
import asyncio
from typing import Dict, List, Any, Optional
from collections import defaultdict, deque
from pathlib import Path
from datetime import datetime
import httpx
from schemas.common.push_system import PushIdsSchema
def _build_default_push_steps() -> List[Dict[str, str]]:
"""构造最小审批步骤字段,满足第三方 v2 push schema。"""
return []
class ApiClient:
"""API HTTP 客户端"""
_TRACE_TEXT_MAX_LEN = 1000
def __init__(
self,
base_url: str,
uid: str,
secret: str,
debug: bool = False,
log_file: Optional[Path] = None,
rate_limit_max_requests: int = 30,
rate_limit_window_seconds: float = 10.0,
):
"""
初始化 API 客户端
Args:
base_url: API 基础 URL(如 https://api.example.com
uid: 用户标识
secret: 签名密钥
debug: 是否启用调试模式(打印请求详情)
log_file: 日志文件路径(可选)
rate_limit_max_requests: 限流窗口内最大请求数(<=0 表示禁用)
rate_limit_window_seconds: 限流窗口秒数(<=0 表示禁用)
"""
self._base_url = base_url.rstrip('/')
self._uid = uid
self._secret = secret
self._client: Optional[httpx.AsyncClient] = None
self._debug = debug
self._log_file = log_file
self._trace_seq = 0
self._trace_by_push_id: Dict[str, deque[Dict[str, Any]]] = defaultdict(deque)
self._trace_by_biz_id: Dict[str, deque[Dict[str, Any]]] = defaultdict(deque)
self._trace_by_op: Dict[str, deque[Dict[str, Any]]] = defaultdict(deque)
self._load_trace_by_item: Dict[tuple[str, str], deque[Dict[str, Any]]] = defaultdict(deque)
self._logger = logging.getLogger(__name__)
self._rate_limit_max_requests = int(rate_limit_max_requests)
self._rate_limit_window_seconds = float(rate_limit_window_seconds)
self._rate_limit_lock = asyncio.Lock()
self._rate_limit_timestamps: deque[float] = deque()
def _is_rate_limit_enabled(self) -> bool:
return self._rate_limit_max_requests > 0 and self._rate_limit_window_seconds > 0
async def _acquire_rate_limit_slot(self) -> None:
if not self._is_rate_limit_enabled():
return
while True:
wait_seconds = 0.0
async with self._rate_limit_lock:
now = time.monotonic()
window_start = now - self._rate_limit_window_seconds
while self._rate_limit_timestamps and self._rate_limit_timestamps[0] <= window_start:
self._rate_limit_timestamps.popleft()
if len(self._rate_limit_timestamps) < self._rate_limit_max_requests:
self._rate_limit_timestamps.append(now)
return
oldest = self._rate_limit_timestamps[0]
wait_seconds = max(0.001, self._rate_limit_window_seconds - (now - oldest))
await asyncio.sleep(wait_seconds)
async def initialize(self):
"""初始化 HTTP 客户端"""
if self._client is None:
# trust_env=False 阻止从环境变量读取代理设置(避免 localhost 走代理导致超时)
self._client = httpx.AsyncClient(timeout=300.0, trust_env=False)
async def close(self):
"""关闭 HTTP 客户端"""
if self._client:
await self._client.aclose()
self._client = None
def _log(self, message: str, level: str = "DEBUG"):
"""
统一日志输出
Args:
message: 日志消息
level: 日志级别(INFO/ERROR
"""
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
log_line = f"[{timestamp}] [{level}] {message}"
level_num = logging.getLevelNamesMapping().get(level.upper(), logging.DEBUG)
self._logger.log(level_num, log_line)
# 文件输出
if self._log_file:
self._log_file.parent.mkdir(parents=True, exist_ok=True)
with open(self._log_file, 'a', encoding='utf-8') as f:
f.write(log_line + '\n')
def _get_client(self) -> httpx.AsyncClient:
"""获取 HTTP 客户端实例"""
if self._client is None:
raise RuntimeError("ApiClient not initialized. Call 'await client.initialize()' first.")
return self._client
def _derive_op_kind(self, method: str, endpoint: str) -> str:
endpoint_norm = endpoint.lower().strip()
if endpoint_norm == "/push/result":
return "poll"
method_upper = method.upper()
if method_upper == "GET":
if endpoint_norm.endswith("/list"):
return "load"
return "get"
if endpoint_norm.endswith("/create"):
return "create"
if endpoint_norm.endswith("/delete"):
return "delete"
if endpoint_norm.endswith("/update") or endpoint_norm.endswith("/plan"):
return "update"
if method_upper == "POST":
return "create"
if method_upper == "PUT":
return "update"
if method_upper == "DELETE":
return "delete"
return method.lower()
def _derive_node_type_from_list_endpoint(self, endpoint: str) -> Optional[str]:
endpoint_norm = endpoint.strip("/")
if not endpoint_norm.endswith("/list"):
return None
prefix = endpoint_norm[:-5]
if not prefix:
return None
return prefix.replace("/", "_")
def _record_trace(
self,
*,
method: str,
endpoint: str,
url: str,
request_params: Optional[Dict[str, Any]],
request_payload: Optional[Dict[str, Any]],
response: Optional[Dict[str, Any]],
elapsed_ms: int,
) -> None:
self._trace_seq += 1
op_kind = self._derive_op_kind(method, endpoint)
payload = request_payload or {}
response_view = self._build_trace_response_view(op_kind=op_kind, response=response)
params_text = self._to_trace_text(request_params or {}, max_len=self._TRACE_TEXT_MAX_LEN)
payload_text = self._to_trace_text(payload, max_len=self._TRACE_TEXT_MAX_LEN)
response_text = self._to_trace_text(response_view, max_len=self._TRACE_TEXT_MAX_LEN)
trace = {
"trace_id": self._trace_seq,
"op": op_kind,
"method": method.upper(),
"endpoint": endpoint,
"url": url,
"params": request_params or {},
"payload": payload,
"response": response_view,
"params_text": params_text,
"payload_text": payload_text,
"response_text": response_text,
"elapsed_ms": elapsed_ms,
"ts": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
}
push_id = payload.get("push_id")
biz_id = payload.get("biz_id")
if isinstance(push_id, str) and push_id:
self._trace_by_push_id[push_id].append(trace)
if isinstance(biz_id, str) and biz_id:
self._trace_by_biz_id[biz_id].append(trace)
self._trace_by_op[op_kind].append(trace)
if op_kind == "load":
node_type = self._derive_node_type_from_list_endpoint(endpoint)
data = (response or {}).get("data") if isinstance(response, dict) else None
if isinstance(data, list):
items = data
elif isinstance(data, dict):
items = data.get("list", [])
else:
items = []
if node_type and isinstance(items, list):
for item in items:
if not isinstance(item, dict):
continue
item_id = item.get("id") or item.get("_id")
if not item_id:
continue
self._load_trace_by_item[(node_type, str(item_id))].append(trace)
def _to_trace_text(self, value: Any, *, max_len: int) -> str:
try:
text = json_lib.dumps(value, ensure_ascii=False, separators=(",", ":"))
except Exception:
text = str(value)
if len(text) > max_len:
return f"{text[:max_len]}...(已截断)"
return text
def _build_trace_response_view(self, *, op_kind: str, response: Optional[Dict[str, Any]]) -> Any:
return response or {}
def consume_traces_by_push_id(self, push_id: str) -> List[Dict[str, Any]]:
if not push_id:
return []
bucket = self._trace_by_push_id.get(push_id)
if not bucket:
return []
traces = list(bucket)
bucket.clear()
return traces
def consume_latest_trace_by_biz_id(self, biz_id: str, op_kind: Optional[str] = None) -> Optional[Dict[str, Any]]:
if not biz_id:
return None
bucket = self._trace_by_biz_id.get(biz_id)
if not bucket:
return None
if op_kind is None:
return bucket.pop() if bucket else None
for index in range(len(bucket) - 1, -1, -1):
trace = bucket[index]
if trace.get("op") == op_kind:
del bucket[index]
return trace
return None
def peek_latest_trace_by_biz_id(self, biz_id: str, op_kind: Optional[str] = None) -> Optional[Dict[str, Any]]:
if not biz_id:
return None
bucket = self._trace_by_biz_id.get(biz_id)
if not bucket:
return None
if op_kind is None:
return bucket[-1] if bucket else None
for index in range(len(bucket) - 1, -1, -1):
trace = bucket[index]
if trace.get("op") == op_kind:
return trace
return None
def consume_latest_trace_by_op(self, op_kind: str) -> Optional[Dict[str, Any]]:
bucket = self._trace_by_op.get(op_kind)
if not bucket:
return None
return bucket.pop() if bucket else None
def peek_latest_trace_by_op(self, op_kind: str) -> Optional[Dict[str, Any]]:
bucket = self._trace_by_op.get(op_kind)
if not bucket:
return None
return bucket[-1] if bucket else None
def consume_load_traces(self, node_type: str, data_id: str) -> List[Dict[str, Any]]:
key = (node_type, data_id)
bucket = self._load_trace_by_item.get(key)
if not bucket:
return []
traces = list(bucket)
bucket.clear()
return traces
def _generate_signature(self, params: Dict[str, Any]) -> Dict[str, str]:
"""
生成签名参数(兼容旧版签名算法)
Args:
params: 请求参数
Returns:
包含 timestamp, nonce, sign 的字典
"""
timestamp = int(time.time() * 1000) # 毫秒时间戳
nonce = uuid.uuid4().hex[:16] # 16位随机字符串
# 构建签名参数
sign_params = params.copy()
sign_params['timestamp'] = timestamp
sign_params['nonce'] = nonce
# 按key排序
sorted_items = sorted(sign_params.items())
# 构建签名字符串
items = []
for k, v in sorted_items:
if v is None:
v_str = "null"
elif isinstance(v, bool):
v_str = "true" if v else "false"
elif isinstance(v, str):
v_str = v
elif isinstance(v, (int, float)):
v_str = str(v)
elif isinstance(v, (list, dict)):
v_str = json_lib.dumps(v, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
else:
v_str = str(v)
items.append(f"{k}={v_str}")
sign_str = "&".join(items)
# 使用 HMAC-SHA256 生成签名
sign = hmac.new(self._secret.encode("utf-8"), sign_str.encode("utf-8"), hashlib.sha256).hexdigest()
return {
'timestamp': str(timestamp),
'nonce': nonce,
'sign': sign
}
async def request(
self,
method: str,
endpoint: str,
params: Optional[Dict[str, Any]] = None,
data: Optional[Dict[str, Any]] = None,
json_data: Optional[Dict[str, Any]] = None,
json: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
统一请求入口,自动签名
Args:
method: HTTP 方法(GET/POST/PUT/DELETE
endpoint: API 端点(如 /api/v2/project
params: URL 查询参数
data: 请求体数据(form data
json_data: JSON 请求体数据
json: JSON 请求体数据(兼容参数)
Returns:
响应 JSON 数据
Raises:
httpx.HTTPStatusError: HTTP 错误
"""
# 兼容json参数
if json is not None and json_data is None:
json_data = json
client = self._get_client()
url = f"{self._base_url}{endpoint}"
await self._acquire_rate_limit_slot()
# 记录请求信息
self._log(f"\n{'='*60}", level="DEBUG")
self._log(f"REQUEST: {method} {endpoint}", level="DEBUG")
self._log(f"Full URL: {url}", level="DEBUG")
if params:
self._log(f"Params: {json_lib.dumps(params, ensure_ascii=False, indent=2)}", level="DEBUG")
if data:
self._log(f"Data: {json_lib.dumps(data, ensure_ascii=False, indent=2)}", level="DEBUG")
if json_data:
self._log(f"JSON: {json_lib.dumps(json_data, ensure_ascii=False, indent=2)}", level="DEBUG")
# 准备签名参数
if method.upper() == 'GET':
# GET 请求:uid 放在 params 中
if params is None:
params = {}
params['uid'] = self._uid
# 转换所有参数值为字符串(除了 list/dict)
params = {k: str(v) if not isinstance(v, (list, dict)) else v for k, v in params.items()}
sign_params = params.copy()
# 生成签名并附加到 params
signature = self._generate_signature(sign_params)
params.update(signature)
else:
# POST/PUT/DELETEuid 和签名放在 body 中(符合 v2 schema
if data is None:
data = {}
if 'steps' not in data:
data['steps'] = _build_default_push_steps()
data['uid'] = self._uid
sign_params = data.copy()
if params:
sign_params.update(params)
signature = self._generate_signature(sign_params)
data.update(signature)
# 发送请求并增强异常处理
start_time = time.time()
try:
response = await client.request(
method=method,
url=url,
params=params,
json=data if method.upper() in ['POST', 'PUT'] else None
)
# 记录响应时间
elapsed = time.time() - start_time
self._log(f"Response Time: {elapsed:.3f}s", level="DEBUG")
# 检查 HTTP 状态
response.raise_for_status()
# 记录响应
result = response.json()
self._log(f"Status Code: {response.status_code}", level="DEBUG")
self._log(f"Response: {json_lib.dumps(result, ensure_ascii=False, indent=2)}", level="DEBUG")
self._log(f"{'='*60}\n", level="DEBUG")
self._record_trace(
method=method,
endpoint=endpoint,
url=url,
request_params=params,
request_payload=data if method.upper() in ['POST', 'PUT', 'DELETE'] else None,
response=result,
elapsed_ms=int(elapsed * 1000),
)
return result
except Exception as e:
import traceback
# 记录错误
elapsed = time.time() - start_time
error_msg = "\n========== [ApiClient HTTP Error] ==========\n"
error_msg += f"Request: {method} {url}\n"
error_msg += f"Params: {params}\n"
if method.upper() in ['POST', 'PUT']:
error_msg += f"JSON: {data}\n"
error_msg += f"Exception: {type(e).__name__}: {e}\n"
error_msg += f"Elapsed: {elapsed:.3f}s\n"
response_obj = e.response if isinstance(e, httpx.HTTPStatusError) else None
if response_obj is not None:
error_msg += f"Status Code: {response_obj.status_code}\n"
error_msg += f"Response Headers: {response_obj.headers}\n"
try:
error_msg += f"Response Text: {response_obj.text}\n"
except Exception:
pass
error_msg += traceback.format_exc()
error_msg += "============================================\n"
# 记录到日志文件
self._log(error_msg, level="ERROR")
raise
async def get(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""
GET 请求
Args:
endpoint: API 端点
params: URL 查询参数
Returns:
响应 JSON 数据
"""
return await self.request('GET', endpoint, params=params)
async def post(self, endpoint: str, data: Dict[str, Any]) -> Dict[str, Any]:
"""
POST 请求,data 中已包含 push_id/biz_id(由 Handler 生成)
Args:
endpoint: API 端点
data: 请求体数据(包含业务数据和 push_id/biz_id
Returns:
响应 JSON 数据
"""
return await self.request('POST', endpoint, data=data)
async def put(self, endpoint: str, data: Dict[str, Any]) -> Dict[str, Any]:
"""
PUT 请求,data 中已包含 push_id/biz_id
Args:
endpoint: API 端点
data: 请求体数据
Returns:
响应 JSON 数据
"""
return await self.request('PUT', endpoint, data=data)
async def delete(self, endpoint: str, data: Dict[str, Any]) -> Dict[str, Any]:
"""
DELETE 请求,data 中已包含 push_id/biz_id
Args:
endpoint: API 端点
data: 请求体数据
Returns:
响应 JSON 数据
"""
return await self.request('DELETE', endpoint, data=data)
async def get_push_logs(self, push_ids: List[str]) -> Dict[str, Dict[str, Any]]:
"""
批量查询 Push Log
注意:单个查询也传单元素列表
当前实现:用 for 循环模拟,后续等后端实现批量接口
Args:
push_ids: Push ID 列表
Returns:
{push_id: push_log_data} 的映射
"""
result: Dict[str, Dict[str, Any]] = {}
if not push_ids:
return result
try:
payload = PushIdsSchema(push_ids=','.join(push_ids)).model_dump(exclude_none=True)
response = await self.request(
method='POST',
endpoint='/push/result',
data=payload
)
except httpx.HTTPStatusError as e:
return {push_id: {'error': str(e)} for push_id in push_ids}
data_list: List[Dict[str, Any]] = []
if isinstance(response, dict):
raw_data = response.get('data')
if isinstance(raw_data, list):
data_list = [item for item in raw_data if isinstance(item, dict)]
elif isinstance(raw_data, dict):
data_list = [raw_data]
elif 'push_id' in response and 'status' in response:
data_list = [response]
for item in data_list:
push_id = item.get('push_id')
if not push_id:
continue
poll_trace = {
"trace_id": f"poll-{push_id}-{int(time.time() * 1000)}",
"op": "poll",
"method": "POST",
"endpoint": "/push/result",
"url": f"{self._base_url}/push/result",
"params": {},
"payload": {"push_ids": push_id},
"response": item,
"elapsed_ms": 0,
"ts": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
}
self._trace_by_push_id[push_id].append(poll_trace)
status = (item.get('status') or '').lower()
error_msg = item.get('error') or ''
result_data = item.get('result') or {}
if status == 'success':
# result_data 的结构是 {"code": 200, "message": "OK", "data": {"biz_id": "..."}}
# 需要提取 result_data["data"] 作为最终的 data
inner_data = result_data.get('data', {}) if isinstance(result_data, dict) else {}
result[push_id] = {
'code': 200,
'status': 'success',
'data': inner_data
}
elif status in {'failed', 'not_find'}:
result[push_id] = {
'code': 500,
'status': 'failed',
'message': error_msg or status or 'Unknown error',
'error': error_msg or status or 'Unknown error'
}
else:
result[push_id] = {
'status': status or 'processing'
}
for push_id in push_ids:
if push_id not in result:
result[push_id] = {'status': 'processing'}
return result
@@ -0,0 +1,264 @@
"""
ApiDataSource - API 数据源协调层
职责:
- 持有 ApiClient 实例,提供给 Handler 使用
- 实现 DataSource 接口(load_all, sync_all
- 协调多个 Handler 的执行顺序
- 统一异常处理和日志记录
"""
import json
from typing import Optional
from pathlib import Path
from ..datasource import BaseDataSource
from ..handler import NodeHandler, ensure_handler_compat
from ..task_result import TaskResult
from .client import ApiClient
class ApiDataSource(BaseDataSource):
"""基于 API 的数据源实现"""
_TRACE_MAX_PARAMS_LEN = 1000
_TRACE_MAX_RESPONSE_LEN = 1000
def __init__(
self,
base_url: str,
uid: str,
secret: str,
poll_mode: str = "async",
debug: bool = False,
log_file: Optional[Path] = None,
poll_max_retries: int = 1,
poll_interval: float = 0.5,
rate_limit_max_requests: int = 30,
rate_limit_window_seconds: float = 10.0,
):
"""
初始化 API 数据源
Args:
base_url: API 基础 URL
uid: 用户标识
secret: 签名密钥
debug: 是否启用调试模式
log_file: 日志文件路径(可选)
"""
super().__init__(poll_max_retries=poll_max_retries, poll_interval=poll_interval)
self.client = ApiClient(
base_url,
uid,
secret,
debug=debug,
log_file=log_file,
rate_limit_max_requests=rate_limit_max_requests,
rate_limit_window_seconds=rate_limit_window_seconds,
)
self.poll_mode = poll_mode
async def initialize(self):
"""初始化数据源(初始化 HTTP 客户端)"""
await self.client.initialize()
async def close(self):
"""关闭数据源(关闭 HTTP 客户端)"""
await self.client.close()
def register_handler(self, handler: NodeHandler) -> None:
"""
注册业务 Handler
Args:
handler: API Handler 实例
"""
compatible_handler = ensure_handler_compat(handler)
compatible_handler.set_api_client(self.client)
compatible_handler.set_poll_mode(self.poll_mode)
# 调用基类注册方法
super().register_handler(compatible_handler)
# 如果 collection 已设置,确保新 handler 拿到引用
if self._collection is not None:
compatible_handler.set_collection(self._collection) # type: ignore[arg-type]
@staticmethod
def _fmt_trace_value(value, max_len: int = 600) -> str:
try:
text = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
except Exception:
text = str(value)
if len(text) > max_len:
return text[:max_len] + "...<truncated>"
return text
def _append_trace_log(self, node, trace: dict, *, stage: str) -> None:
method = str(trace.get("method", "")).upper()
endpoint = str(trace.get("endpoint", ""))
url = str(trace.get("url", ""))
params = trace.get("params_text")
if not isinstance(params, str):
params = self._fmt_trace_value(trace.get("params", {}), max_len=self._TRACE_MAX_PARAMS_LEN)
payload = trace.get("payload_text")
if not isinstance(payload, str):
payload = self._fmt_trace_value(trace.get("payload", {}), max_len=self._TRACE_MAX_PARAMS_LEN)
response = trace.get("response_text")
if not isinstance(response, str):
response = self._fmt_trace_value(trace.get("response", {}), max_len=self._TRACE_MAX_RESPONSE_LEN)
elapsed_ms = trace.get("elapsed_ms", 0)
node.append_log(f"API链路[{stage}] {method} {endpoint}")
node.append_log(f" url={url}")
if params not in {"{}", "null"}:
node.append_log(f" params={params}")
if payload not in {"{}", "null"}:
node.append_log(f" payload={payload}")
node.append_log(f" response={response}")
node.append_log(f" elapsed_ms={elapsed_ms}")
def _consume_and_append_push_traces(self, node, push_id: str, *, stage: str) -> int:
if not push_id:
return 0
traces = self.client.consume_traces_by_push_id(push_id)
for trace in traces:
self._append_trace_log(node, trace, stage=stage)
return len(traces)
@staticmethod
def _extract_push_id_from_trace(trace: dict | None) -> Optional[str]:
if trace is None:
return None
payload = trace.get("payload")
if not isinstance(payload, dict):
return None
push_id = payload.get("push_id")
return push_id if isinstance(push_id, str) and push_id else None
def _resolve_push_id_for_success(self, node, result: TaskResult) -> Optional[str]:
if result.task_id:
return result.task_id
if result.push_id:
return result.push_id
action_name = node.action.value.lower()
biz_id = node.data_id or ""
if biz_id:
by_biz_and_op = self.client.peek_latest_trace_by_biz_id(biz_id, op_kind=action_name)
if by_biz_and_op is not None:
push_id = self._extract_push_id_from_trace(by_biz_and_op)
if push_id:
self.client.consume_latest_trace_by_biz_id(biz_id, op_kind=action_name)
return push_id
by_biz_any = self.client.peek_latest_trace_by_biz_id(biz_id)
if by_biz_any is not None:
push_id = self._extract_push_id_from_trace(by_biz_any)
if push_id:
self.client.consume_latest_trace_by_biz_id(biz_id)
return push_id
return None
def _append_missing_push_id_debug_trace(self, node) -> None:
action_name = node.action.value.lower()
biz_id = node.data_id or ""
if biz_id:
trace = self.client.peek_latest_trace_by_biz_id(biz_id, op_kind=action_name)
if trace is None:
trace = self.client.peek_latest_trace_by_biz_id(biz_id)
if trace is not None:
self._append_trace_log(node, trace, stage="submit-missing-push-id")
return
node.append_log(
f"API链路[submit-missing-push-id] 未找到匹配 biz_id 的请求记录,action={node.action.value}, data_id={node.data_id or '<empty>'}"
)
return
node.append_log(
f"API链路[submit-missing-push-id] 未找到可用请求记录(已禁用按操作类型兜底),action={node.action.value}, data_id={node.data_id or '<empty>'}"
)
return None
def _on_node_loaded(self, handler: NodeHandler, node) -> None:
data_id = node.data_id or ""
if not data_id:
return
traces = self.client.consume_load_traces(node.node_type, data_id)
for trace in traces:
self._append_trace_log(node, trace, stage="load")
async def _intercept_success_result(self, handler: NodeHandler, node, result: TaskResult, async_tasks: dict) -> bool:
if node.action.value not in {"CREATE", "UPDATE", "DELETE"}:
return False
push_id = self._resolve_push_id_for_success(node, result)
if not push_id:
self._append_missing_push_id_debug_trace(node)
raise RuntimeError(
f"[{node.node_type}] unified polling requires push_id for {node.action.value}, "
f"but none resolved for node={node.node_id}, data_id={node.data_id}"
)
synthetic = TaskResult.in_progress(node_id=node.node_id, task_id=push_id)
appended = self._consume_and_append_push_traces(node, push_id, stage="submit")
if appended == 0:
node.append_log(f"API链路[submit] 未捕获请求记录,push_id={push_id}")
await self._handle_in_progress_result(node, synthetic, async_tasks)
return True
def _on_in_progress_result(self, handler: NodeHandler, node, result: TaskResult) -> None:
if result.task_id:
appended = self._consume_and_append_push_traces(node, result.task_id, stage="submit")
if appended == 0:
node.append_log(f"API链路[submit] 未捕获请求记录,push_id={result.task_id}")
def _on_poll_result(self, handler: NodeHandler, node, task_id: str, result: TaskResult) -> None:
appended = self._consume_and_append_push_traces(node, task_id, stage="poll")
if appended == 0:
node.append_log(f"API链路[poll] 未捕获请求记录,push_id={task_id}")
def _on_failed_result(self, handler: NodeHandler, node, result: TaskResult) -> None:
if node.action.value not in {"CREATE", "UPDATE", "DELETE"}:
return
if result.push_id:
traces = self.client.consume_traces_by_push_id(result.push_id)
if traces:
for trace in traces:
self._append_trace_log(node, trace, stage="submit-failed")
return
node.append_log(f"API链路[submit-failed] 未捕获请求记录,push_id={result.push_id}")
action_name = node.action.value.lower()
biz_id = node.data_id or ""
trace = None
if biz_id:
trace = self.client.consume_latest_trace_by_biz_id(biz_id, op_kind=action_name)
if trace is None:
trace = self.client.consume_latest_trace_by_biz_id(biz_id)
if trace is not None:
self._append_trace_log(node, trace, stage="submit-failed")
return
if biz_id:
node.append_log(
f"API链路[submit-failed] 未找到匹配 biz_id 的请求记录,action={node.action.value}, data_id={biz_id}"
)
return
node.append_log(
f"API链路[submit-failed] 未找到可用请求记录(已禁用按操作类型兜底),action={node.action.value}, data_id=<empty>"
)
# load_all 和 sync_all 继承自 BaseDataSource
# BaseDataSource 会按顺序调用 Handler 的方法
@@ -0,0 +1,25 @@
"""
API Handler 通用错误常量
"""
# API Client 相关错误
ERROR_API_CLIENT_NOT_INITIALIZED = "api_client not initialized"
ERROR_API_CLIENT_NOT_INJECTED = "api_client not injected"
# Collection 相关错误
ERROR_COLLECTION_NOT_SET = "collection not set"
# 数据验证错误
ERROR_NODE_DATA_NONE = "Node data is None or validation failed"
ERROR_VALIDATION_FAILED = "Validation failed"
# 业务数据错误
ERROR_PROJECT_ID_NOT_FOUND = "project_id not found"
ERROR_CONTRACT_ID_NOT_FOUND = "contract_id not found"
ERROR_ID_NOT_FOUND = "id not found"
# 操作不支持错误
ERROR_OPERATION_NOT_SUPPORTED = "Operation not supported"
ERROR_CREATE_NOT_SUPPORTED = "Create operation not supported"
ERROR_UPDATE_NOT_SUPPORTED = "Update operation not supported"
ERROR_DELETE_NOT_SUPPORTED = "Delete operation not supported"
@@ -0,0 +1,538 @@
"""
BaseApiHandler - API Handler 基类
职责:
- 实现 Handler 接口(load, create, update, delete, poll
- 使用 self.api_client 发送 HTTP 请求
- 生成 push_id 和 biz_id(业务标识)
- 从 collection 获取依赖节点数据
- 管理节点的 context(存储 project_id, contract_id 等)
- 实现 poll 方法,调用 api_client.get_push_logs 批量查询
- 提取 created_id(因为不同业务的 id 字段名和位置可能不同)
- 返回 TaskResultSUCCESS/FAILED/IN_PROGRESS
"""
import uuid
import asyncio
from abc import abstractmethod
from typing import ClassVar, Dict, List, Any, Optional, TYPE_CHECKING, TypeVar
from pydantic import BaseModel
from ...common.type_safety import get_pydantic_model_fields
from ..handler import NodeHandler
from ..task_result import TaskResult
from ..handler import ValidationResult # Import ValidationResult
if TYPE_CHECKING:
from ...common.sync_node import SyncNode
from ...common.collection import DataCollection
from .client import ApiClient
T = TypeVar("T", bound=BaseModel)
class BaseApiHandler(NodeHandler[T]):
"""API Handler 基类"""
# 子类需要设置这些属性
_node_type: str
_node_class: type['SyncNode']
_schema: Any
def __init__(self):
# 子类应该在调用 super().__init__() 之前或之后设置 _node_type, _node_class, _schema
self.api_client: 'ApiClient' = None # type: ignore # 由 DataSource 注入
self.poll_mode: str = "async" # async | sync
self._collection: 'DataCollection' = None # type: ignore # 由 DataSource 注入
# 子类在 __init__ 中设置此列表以声明 create/update 中涉及的 Pydantic schema 类;
# post-check 将只比较这些 schema 覆盖的字段,过滤后端只读的派生字段。
self.update_schemas: List[type] = []
def get_update_fields(self, *, exclude: frozenset = frozenset({'id'})) -> List[str]:
"""从 update_schemas 中提取所有可写字段名,排除标识键(默认排除 id)。"""
fields: set = set()
for schema in self.update_schemas:
for field_name in get_pydantic_model_fields(schema):
if field_name not in exclude:
fields.add(field_name)
return sorted(fields)
def set_api_client(self, client: 'ApiClient') -> None:
self.api_client = client
def set_collection(self, collection: 'DataCollection') -> None:
"""设置 Collection(由 DataSource 调用)"""
self._collection = collection
@property
def node_type(self) -> str:
"""返回节点类型"""
return self._node_type
@property
def node_class(self) -> type['SyncNode']:
"""返回节点类"""
return self._node_class
@property
def schema(self) -> Any:
"""返回 schema(子类可重写)"""
return self._schema
def _generate_push_id(self) -> str:
"""
生成 push_id(推送唯一标识)
Returns:
UUID 格式的 push_id
"""
return str(uuid.uuid4())
def _generate_biz_id(self, node: 'SyncNode') -> str:
"""
生成 biz_id(业务标识)
子类可重写此方法实现自定义业务 ID 生成逻辑。
默认使用 node_id 作为 biz_id。
Args:
node: 同步节点
Returns:
业务标识符
"""
return node.node_id
def _create_basic_node(self, data: Dict[str, Any], depend_ids: Optional[List[str]] = None) -> "SyncNode":
"""创建或复用基础节点(默认使用 data['id'] 或 data['_id']"""
data_id = str(data.get("id") or data.get("_id") or "")
if self._collection is None:
raise RuntimeError("Collection not set. Call set_collection() before creating nodes.")
if data_id:
existing_node = self._collection.get_by_data_id(self.node_type, data_id)
if existing_node is not None:
existing_node.depend_ids = list(depend_ids or [])
existing_node.set_data(data.copy())
existing_node.set_origin_data(data.copy())
return existing_node
node_id = self._collection.generate_node_id()
node = self.node_class(
node_id=node_id,
data_id=data_id,
data=data.copy(),
depend_ids=depend_ids or [],
origin_data=data.copy(),
)
return node
def _create_node(self, data: Dict[str, Any]) -> "SyncNode":
"""默认节点创建(子类可覆盖并补充 context)"""
return self._create_basic_node(data)
def extract_id(self, data: Dict[str, Any]) -> str:
"""从原始数据提取 ID(默认优先 id,其次 _id)"""
return str(data.get("id") or data.get("_id") or "")
def extract_created_id(self, push_log: Dict[str, Any]) -> Optional[str]:
"""
从 push_log 提取 created_id
子类可重写此方法以适配不同的响应结构。
默认从 push_log['data']['biz_id'] 提取。
Args:
push_log: Push Log 响应数据
Returns:
创建的对象 ID,未找到返回 None
"""
try:
data = push_log.get('data', {})
if isinstance(data, dict):
return data.get('biz_id')
return None
except (KeyError, AttributeError):
return None
def set_poll_mode(self, mode: str) -> None:
"""设置轮询模式(sync/async"""
if mode not in {"sync", "async"}:
raise ValueError(f"Unsupported poll mode: {mode}")
self.poll_mode = mode
def _extract_created_id_from_response(self, response: Dict[str, Any]) -> Optional[str]:
"""从同步创建响应提取创建的 ID(默认 data.biz_id"""
data = response.get("data") if isinstance(response, dict) else None
if isinstance(data, dict):
return data.get("biz_id")
return None
def _build_create_task_result(
self,
node_id: str,
response: Optional[Dict[str, Any]] = None,
task_id: Optional[str] = None,
fallback_data_id: Optional[str] = None,
) -> TaskResult:
"""根据轮询模式构建 create 的 TaskResult"""
if self.poll_mode == "sync":
created_id = None
if response is not None:
created_id = self._extract_created_id_from_response(response)
if not created_id:
created_id = fallback_data_id
return TaskResult.success(node_id=node_id, data_id=created_id)
if not task_id:
return TaskResult.failed(node_id=node_id, error="Missing task_id for async create")
return TaskResult.in_progress(node_id=node_id, task_id=task_id)
# ========== 通用辅助方法 ==========
def _validate_schema(self, data: Dict[str, Any], schema: Any) -> Optional[str]:
"""
验证数据是否符合 schema
Args:
data: 待验证数据
schema: Pydantic schema
Returns:
错误信息,无错误返回 None
"""
try:
schema.model_validate(data)
return None
except Exception as e:
return str(e)
def _get_schema_diff(
self,
schema,
data: Dict[str, Any],
origin_data: Dict[str, Any],
node_id: Optional[str] = None
) -> Dict[str, Any] | None:
"""
获取数据在指定 schema 下的差异字段
Args:
schema: Pydantic schema 类
data: 新数据
origin_data: 原始数据
node_id: 节点ID(用于日志)
Returns:
Dict[str, Any] | None: 有差异返回差异字段字典,无差异或字段不全返回 None
"""
from pydantic import ValidationError
try:
new_model = schema.model_validate(data)
origin_model = schema.model_validate(origin_data)
new_dict = new_model.model_dump(exclude_unset=True)
origin_dict = origin_model.model_dump(exclude_unset=True)
if new_dict == origin_dict:
return None
# 返回有差异的字段
diff_fields = {k: v for k, v in new_dict.items() if origin_dict.get(k) != v}
if diff_fields:
# 打印差异详情
node_info = f"[{node_id}]" if node_id else ""
print(f" [DIFF] {self.node_type}{node_info} schema={schema.__name__}")
for k, v in diff_fields.items():
orig_v = origin_dict.get(k)
print(f"{k}: {orig_v!r}{v!r}")
return diff_fields if diff_fields else None
except ValidationError:
# schema 字段不全,视为无差异
return None
def _filter_update_data(
self,
new_data: Dict[str, Any],
original_data: Dict[str, Any],
schema: Optional[Any] = None
) -> Dict[str, Any]:
"""
过滤出需要更新的字段
规则:
1. 提取 schema 定义的所有字段(包括必填)
2. 检查是否有任何字段发生变化
3. 如果有变化,返回所有 schema 字段;否则返回空
Args:
new_data: 新数据
original_data: 原始数据
schema: 更新 schema(可选)
Returns:
包含 schema 所有字段的数据(如果有变化),否则返回空字典
"""
schema_fields = get_pydantic_model_fields(schema)
if not schema_fields:
# 无 schema,按原逻辑处理
update_data = {}
for key, new_value in new_data.items():
if new_value == "" or new_value is None:
continue
original_value = original_data.get(key)
if new_value != original_value:
update_data[key] = new_value
return update_data
# 1. 提取 schema 定义的所有字段
schema_fields = set(schema_fields.keys())
schema_data = {}
has_change = False
for key in schema_fields:
new_value = new_data.get(key)
# 排除空值
if new_value == "" or new_value is None:
continue
schema_data[key] = new_value
# 检查是否变化
original_value = original_data.get(key)
if new_value != original_value:
# 记录具体差异以便排查
# print(f" [DEBUG] Field '{key}' changed: '{original_value}' -> '{new_value}'")
has_change = True
# 2. 只有存在变化时才返回数据
return schema_data if has_change else {}
def _schema_changed_fields(
self,
schema: Any,
new_data: Dict[str, Any],
origin_data: Dict[str, Any],
) -> List[str]:
schema_fields = get_pydantic_model_fields(schema)
if not schema_fields:
return []
changed: List[str] = []
for field_name in schema_fields.keys():
if new_data.get(field_name) != origin_data.get(field_name):
changed.append(field_name)
return changed
def _build_update_schema_status(
self,
*,
schema_name: str,
schema: Any,
new_data: Dict[str, Any],
origin_data: Dict[str, Any],
will_update: bool,
update_error: Optional[str] = None,
) -> str:
new_error = self._validate_schema(new_data, schema)
origin_error = self._validate_schema(origin_data, schema)
changed_fields = self._schema_changed_fields(schema, new_data, origin_data)
if new_error:
compact = new_error.replace("\n", " ").strip()
if len(compact) > 220:
compact = compact[:220] + "..."
return f"{schema_name}: NEW_INVALID({compact})"
if not changed_fields:
return f"{schema_name}: CONSISTENT"
changed_text = ",".join(changed_fields)
if update_error:
compact = update_error.replace("\n", " ").strip()
if len(compact) > 220:
compact = compact[:220] + "..."
return f"{schema_name}: DIFF[{changed_text}] -> UPDATE_FAILED({compact})"
if will_update:
if origin_error:
compact = origin_error.replace("\n", " ").strip()
if len(compact) > 160:
compact = compact[:160] + "..."
return f"{schema_name}: DIFF[{changed_text}] -> UPDATE_TRIGGERED (origin_invalid={compact})"
return f"{schema_name}: DIFF[{changed_text}] -> UPDATE_TRIGGERED"
if origin_error:
compact = origin_error.replace("\n", " ").strip()
if len(compact) > 160:
compact = compact[:160] + "..."
return f"{schema_name}: DIFF[{changed_text}] -> NOT_TRIGGERED (origin_invalid={compact})"
return f"{schema_name}: DIFF[{changed_text}] -> NOT_TRIGGERED"
async def _execute_with_retry(
self,
operation,
max_retries: int = 3,
retry_delay: float = 1.0
) -> Any:
"""
通用重试逻辑
子类可重写此方法实现自定义重试策略。
默认策略:网络错误/5xx 重试,4xx 不重试。
Args:
operation: 异步操作(协程函数)
max_retries: 最大重试次数
retry_delay: 重试延迟(秒)
Returns:
操作执行结果
Raises:
最后一次重试的异常
"""
import httpx
last_error = None
for attempt in range(max_retries):
try:
return await operation()
except httpx.HTTPStatusError as e:
# 4xx 客户端错误不重试
if 400 <= e.response.status_code < 500:
raise
last_error = e
if attempt < max_retries - 1:
await asyncio.sleep(retry_delay * (attempt + 1))
except (httpx.NetworkError, httpx.TimeoutException) as e:
# 网络错误重试
last_error = e
if attempt < max_retries - 1:
await asyncio.sleep(retry_delay * (attempt + 1))
# 重试次数用尽
if last_error:
raise last_error
else:
raise RuntimeError("Retry failed with unknown error")
# ========== 批量接口(子类必须实现) ==========
@abstractmethod
async def create_all(self, nodes: List['SyncNode']) -> List[TaskResult]:
"""
批量创建节点
Args:
nodes: 需要创建的节点列表(DataSource 已筛选为 CREATE + PENDING
Returns:
List[TaskResult],每个结果关联 node_id
"""
pass
@abstractmethod
async def update_all(self, nodes: List['SyncNode']) -> List[TaskResult]:
"""
批量更新节点
Args:
nodes: 需要更新的节点列表(DataSource 已筛选为 UPDATE + PENDING
Returns:
List[TaskResult],每个结果关联 node_id
"""
pass
@abstractmethod
async def delete_all(self, nodes: List['SyncNode']) -> List[TaskResult]:
"""
批量删除节点
Args:
nodes: 需要删除的节点列表(DataSource 已筛选为 DELETE + PENDING
Returns:
List[TaskResult],每个结果关联 node_id
"""
pass
async def poll_tasks(self, task_ids: List[str]) -> Dict[str, TaskResult]:
"""
批量轮询异步任务状态
Args:
task_ids: 任务 ID 列表(push_id 列表)
Returns:
{task_id: TaskResult} 映射
"""
if not task_ids:
return {}
if not self.api_client:
raise RuntimeError("api_client not injected")
# 批量查询 push_logs
try:
push_logs = await self.api_client.get_push_logs(task_ids)
except Exception as e:
# 查询失败,所有任务标记为失败
return {
task_id: TaskResult.failed(error=f"Poll failed: {str(e)}")
for task_id in task_ids
}
results = {}
for task_id in task_ids:
push_log = push_logs.get(task_id, {})
if 'error' in push_log:
results[task_id] = TaskResult.failed(error=push_log['error'])
elif push_log.get('code') == 200: # 通过code==200判断成功
created_id = self.extract_created_id(push_log)
results[task_id] = TaskResult.success(data_id=created_id)
elif push_log.get('code') and push_log.get('code') != 200: # code存在但不是200表示失败
results[task_id] = TaskResult.failed(error=push_log.get('message', 'Unknown error'))
else:
# 仍在进行中
results[task_id] = TaskResult.in_progress(task_id=task_id)
return results
async def validate(self, data: Dict[str, Any]) -> ValidationResult:
"""
数据质量检查(使用 schema 验证)
Args:
data: 原始数据
Returns:
ValidationResult(是否有效 + 错误列表)
"""
error = self._validate_schema(data, self.schema)
if error:
return ValidationResult(is_valid=False, errors=[error])
return ValidationResult(is_valid=True)
# ========== 抽象方法:子类必须实现 ==========
@abstractmethod
async def load(self) -> List['SyncNode']:
"""
从 API 加载数据并创建节点
Handler 负责:
1. 加载原始数据(可使用 self._collection 查询依赖)
2. 创建 SyncNode(调用 self.create_node
3. 设置 context(如 project_id, contract_id 等)
Returns:
SyncNode 列表
"""
pass
+962
View File
@@ -0,0 +1,962 @@
"""
DataSource V2 - 状态管理 + Handler 编排层
DataSource 职责:
1. 注册和管理 Handler
2. 按依赖顺序加载数据
3. 执行同步(调用 Handler
4. 管理节点状态流转(PENDING → IN_PROGRESS → SUCCESS/FAILED
5. 处理异步任务轮询
6. 统一异常处理
DataSource 不负责:
- ❌ 后端 API 调用(由 Handler 负责)
- ❌ ID 映射(由 Strategy 负责)
- ❌ 业务逻辑(由 Strategy 负责)
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any, List, Dict, Optional, TYPE_CHECKING
from abc import ABC
from pathlib import Path
if TYPE_CHECKING:
from ..common.collection import DataCollection
from .handler import NodeHandler
from ..common.sync_node import SyncNode
from ..common.types import SyncAction
from .task_result import TaskStatus
from ..common.types import SyncStatus
from ..common.type_safety import get_pydantic_model_fields
from .handler import ensure_handler_compat
from .task_result import TaskResult
from ..engine import (
StateMachineConfig,
StateMachineRuntime,
e40_sync_execute,
)
logger = logging.getLogger(__name__)
class BaseDataSource(ABC):
"""
数据源基类 V2(状态管理 + 编排层)
核心流程:
1. 注册 Handler:每个节点类型对应一个 Handler
2. 加载数据:按依赖顺序调用 Handler.load()
3. 执行同步:根据 node.action 调用 Handler 的 create/update/delete
4. 状态管理:根据 TaskResult 更新 node.status
5. 异步轮询:处理 IN_PROGRESS 节点
示例:
# 1. 初始化 DataSource
datasource = MyDataSource()
# 2. 注册 Handler
datasource.register_handler(project_handler)
datasource.register_handler(contract_handler)
# 3. 设置 Collection
datasource.set_collection(collection)
# 4. 加载数据(按顺序)
await datasource.load_all(order=["project", "contract"])
# 5. 执行同步(按顺序)
await datasource.sync_all(order=["project", "contract"])
"""
def __init__(self, poll_max_retries: int = 1, poll_interval: float = 0.5):
self._handlers: Dict[str, "NodeHandler"] = {}
self._collection: Optional["DataCollection"] = None
self._target_project_ids: List[str] = []
# 按节点类型统计信息
# {"company": {"loaded": 10, "synced": 10, "success": 10, "failed": 0}, ...}
self._stats: Dict[str, Dict[str, int]] = {}
# 记录本次 sync 的动作统计(CREATE/UPDATE/DELETE/NONE
self._action_summary: Dict[str, Dict[str, Dict[str, int]]] = {}
# 记录本次 sync 中的节点动作(用于异步完成后的统计)
self._action_by_node_id: Dict[str, "SyncAction"] = {}
self._poll_max_retries = max(1, poll_max_retries)
self._poll_interval = max(0, poll_interval)
self._sm_runtime: Optional[StateMachineRuntime] = None
def _ensure_sm_runtime(self) -> StateMachineRuntime:
if self._sm_runtime is None:
cfg_path = Path(__file__).resolve().parents[1] / "config" / "node_state_machine.yaml"
self._sm_runtime = StateMachineRuntime(StateMachineConfig.load(cfg_path))
return self._sm_runtime
def set_state_machine_runtime(self, runtime: StateMachineRuntime) -> None:
"""注入统一状态机 runtime(通常由 pipeline 负责)。"""
self._sm_runtime = runtime
def _apply_sync_execute_state(
self,
node: "SyncNode",
*,
handler_status: "TaskStatus",
poll_timeout: bool = False,
detail: str = "",
action_override: Optional["SyncAction"] = None,
result_data_id: Optional[str] = None,
) -> bool:
action_for_event = action_override or node.action
decision = e40_sync_execute(
self._ensure_sm_runtime(),
node=node,
handler_result=handler_status.name,
action=action_for_event.value,
poll_timeout=poll_timeout,
result_data_id=result_data_id,
)
if decision is None:
if detail:
node.append_log(
f"执行状态未变更: action={action_for_event.value}, result={handler_status.name}, detail={detail}"
)
return False
if detail:
node.append_log(
f"执行状态写回: action={action_for_event.value}, result={handler_status.name}, detail={detail}"
)
return True
def _compose_node_error(self, node: "SyncNode", *, source: str, detail: str) -> str:
return (
f"{source} | node={node.node_id} | type={node.node_type} | "
f"action={node.action.value} | status={node.status.value} | detail={detail}"
)
def _on_node_loaded(self, handler: "NodeHandler", node: "SyncNode") -> None:
"""Datasource-specific hook when a loaded node is upserted into collection."""
return
async def _intercept_success_result(
self,
handler: "NodeHandler",
node: "SyncNode",
result: "TaskResult",
async_tasks: Dict[str, str],
) -> bool:
"""Datasource-specific hook to intercept SUCCESS result handling.
Return True if handled/consumed by subclass and no further default SUCCESS flow is needed.
"""
return False
def _on_in_progress_result(self, handler: "NodeHandler", node: "SyncNode", result: "TaskResult") -> None:
"""Datasource-specific hook after an IN_PROGRESS task is queued."""
return
def _on_poll_result(self, handler: "NodeHandler", node: "SyncNode", task_id: str, result: "TaskResult") -> None:
"""Datasource-specific hook after polling result is applied to node."""
return
def _on_failed_result(self, handler: "NodeHandler", node: "SyncNode", result: "TaskResult") -> None:
"""Datasource-specific hook after a FAILED result is applied to node."""
return
async def _handle_success_result(self, node: "SyncNode", result: "TaskResult") -> None:
previous_data_id = node.data_id
ok = self._apply_sync_execute_state(
node,
handler_status=result.status,
detail="远程操作成功完成",
result_data_id=result.data_id,
)
if not ok:
raise RuntimeError(f"[{node.node_type}] SUCCESS result cannot map to state machine: node={node.node_id}")
self._record_action_result(node, result.status)
await self._apply_success_result(node, result.data_id, previous_data_id=previous_data_id)
node.error = None
async def _handle_failed_result(self, node: "SyncNode", result: "TaskResult") -> None:
detail = result.error or "Unknown error"
ok = self._apply_sync_execute_state(
node,
handler_status=result.status,
detail=f"远程操作失败: {detail}",
)
if not ok:
raise RuntimeError(f"[{node.node_type}] FAILED result cannot map to state machine: node={node.node_id}")
self._record_action_result(node, result.status)
node.error = self._compose_node_error(node, source="handler_failed", detail=detail)
async def _handle_skipped_result(self, node: "SyncNode", result: "TaskResult") -> None:
from .task_result import TaskStatus as _TaskStatus
original_action = self._action_by_node_id.get(node.node_id)
if original_action is None or original_action.value != "UPDATE":
raise RuntimeError(
f"[{node.node_type}] SKIPPED is only allowed for UPDATE action: node={node.node_id}, action={original_action.value if original_action is not None else None}"
)
ok = self._apply_sync_execute_state(
node,
handler_status=_TaskStatus.SKIPPED,
detail="远程操作被跳过",
action_override=original_action,
)
if not ok:
raise RuntimeError(f"[{node.node_type}] SKIPPED result cannot map to state machine: node={node.node_id}")
self._record_action_result(node, _TaskStatus.SKIPPED)
if result.sync_log:
node.append_log(f"同步跳过: {result.sync_log}")
node.error = None
async def _handle_in_progress_result(
self,
node: "SyncNode",
result: "TaskResult",
async_tasks: Dict[str, str],
) -> None:
if not result.task_id:
node.error = self._compose_node_error(
node,
source="handler_in_progress_invalid",
detail="missing task_id",
)
node.append_log("异步任务返回无 task_id,已忽略")
return
if not result.node_id:
node.error = self._compose_node_error(
node,
source="handler_in_progress_invalid",
detail="missing node_id",
)
node.append_log("异步任务返回无 node_id,已忽略")
return
async_tasks[result.node_id] = result.task_id
ok = self._apply_sync_execute_state(
node,
handler_status=result.status,
detail=f"异步任务进行中: {result.task_id}",
)
if not ok:
node.append_log(f"异步任务进行中(无状态变更): {result.task_id}")
def set_polling_config(self, max_retries: int, interval: float = 0.5) -> None:
"""设置异步轮询参数(默认仅轮询一次)"""
self._poll_max_retries = max(1, max_retries)
self._poll_interval = max(0, interval)
def reset_stats(self) -> None:
"""重置统计信息"""
self._stats = {}
self._action_summary = {}
self._action_by_node_id = {}
def get_stats(self) -> Dict[str, Dict[str, int]]:
"""
获取按节点类型的统计信息
Returns:
dict: {"company": {"loaded": 10, "synced": 10, "success": 10, "failed": 0}, ...}
"""
return self._stats.copy()
def get_action_summary(self) -> Dict[str, Dict[str, Dict[str, int]]]:
"""
获取本次 sync 的动作统计(按节点类型)。
Returns:
dict: {
"company": {
"create": {"total": 10, "success": 8, "failed": 2},
"update": {"total": 2, "success": 2, "failed": 0},
"delete": {"total": 0, "success": 0, "failed": 0},
"none": {"total": 5, "success": 0, "failed": 0},
"total": {"total": 17, "success": 10, "failed": 2},
},
...
}
"""
return {k: {kk: vv.copy() for kk, vv in v.items()} for k, v in self._action_summary.items()}
def _init_node_stats(self, node_type: str) -> None:
"""初始化节点类型的统计信息"""
if node_type not in self._stats:
self._stats[node_type] = {
"loaded": 0,
"synced": 0,
"success": 0,
"failed": 0,
}
if node_type not in self._action_summary:
self._action_summary[node_type] = {
"create": {"total": 0, "success": 0, "failed": 0, "skipped": 0},
"update": {"total": 0, "success": 0, "failed": 0, "skipped": 0},
"delete": {"total": 0, "success": 0, "failed": 0, "skipped": 0},
"none": {"total": 0, "success": 0, "failed": 0, "skipped": 0},
"total": {"total": 0, "success": 0, "failed": 0, "skipped": 0},
}
def _set_action_totals(self, node_type: str, create: int, update: int, delete: int, none: int) -> None:
summary = self._action_summary[node_type]
summary["create"]["total"] = create
summary["update"]["total"] = update
summary["delete"]["total"] = delete
summary["none"]["total"] = none
summary["total"]["total"] = create + update + delete + none
def _record_action_result(self, node: "SyncNode", result_status: "TaskStatus") -> None:
from ..common.types import SyncAction
from .task_result import TaskStatus as _TaskStatus
# 情况 A: 原始动作就是 NONE
# 情况 B: Handler 运行时发现没差异,把 action 降级为了 NONE
action = node.action
# 获取最初锁定的动作(用于修正统计列)
original_action = self._action_by_node_id.pop(node.node_id, SyncAction.NONE)
summary = self._action_summary.get(node.node_type)
if summary is None:
return
# 如果当前 action 是 NONE,说明这次“同步”被取消了
if action == SyncAction.NONE:
# 如果原始动作不是 NONE,说明发生了降级动作,我们需要把 total 从对应列挪到 none 列
if original_action != SyncAction.NONE:
orig_key = original_action.value.lower()
if orig_key in summary and summary[orig_key]["total"] > 0:
summary[orig_key]["total"] -= 1
summary["none"]["total"] += 1
return
# 正常记录成功/失败/跳过
key = action.value.lower()
if result_status == _TaskStatus.SUCCESS:
summary[key]["success"] += 1
summary["total"]["success"] += 1
elif result_status == _TaskStatus.FAILED:
summary[key]["failed"] += 1
summary["total"]["failed"] += 1
elif result_status == _TaskStatus.SKIPPED:
summary[key]["skipped"] += 1
summary["total"]["skipped"] += 1
def set_collection(self, collection: "DataCollection") -> None:
"""
设置 Collection(在 load_all/sync_all 前调用)
Args:
collection: 数据集合
"""
self._collection = collection
collection.set_state_machine_runtime(self._ensure_sm_runtime())
# 同时设置到所有已注册的 handler
for handler in self._handlers.values():
handler.set_collection(collection)
# ========== Handler 管理 ==========
def register_handler(self, handler: "NodeHandler") -> None:
"""
注册 Handler
Args:
handler: 节点处理器实例
示例:
datasource.register_handler(ContractNodeHandler(api_client))
"""
compatible_handler = ensure_handler_compat(handler)
self._handlers[compatible_handler.node_type] = compatible_handler
self._apply_target_project_ids_to_handler(compatible_handler)
def set_target_project_ids(self, target_project_ids: Optional[List[str]]) -> None:
"""设置项目过滤列表,并在注册阶段下发给所有 handler。"""
normalized = [str(pid) for pid in (target_project_ids or []) if str(pid)]
self._target_project_ids = normalized
for handler in self._handlers.values():
self._apply_target_project_ids_to_handler(handler)
def _apply_target_project_ids_to_handler(self, handler: "NodeHandler") -> None:
handler.set_target_project_ids(self._target_project_ids)
def get_handler(self, node_type: str) -> "NodeHandler":
"""获取 Handler"""
if node_type not in self._handlers:
raise KeyError(f"Handler for '{node_type}' not registered")
return self._handlers[node_type]
async def _upsert_loaded_nodes(
self,
handler: "NodeHandler",
node_type: str,
nodes: List["SyncNode"]
) -> None:
"""将加载的节点写入 Collection(按 data_id 优先命中已有节点)"""
if self._collection is None:
raise RuntimeError("Collection not set. Call set_collection() first.")
for node in nodes:
existing_node = None
if node.data_id:
existing_node = self._collection.get_by_data_id(node_type, node.data_id)
load_source = f"{self.__class__.__name__}.load"
if existing_node is not None:
data_payload = node.get_data()
existing_node.depend_ids = list(node.depend_ids or [])
if node.context:
existing_node.context.update(node.context)
existing_node.set_data(data_payload)
existing_node.set_origin_data(data_payload)
if existing_node.context.pop("_loaded_from_persistence", False):
existing_node.append_log(f"加载来源: persistence -> {load_source} 覆盖刷新")
else:
existing_node.append_log(f"加载来源: {load_source} 覆盖刷新")
await self._collection.update(existing_node)
self._on_node_loaded(handler, existing_node)
else:
node.append_log(f"加载来源: {load_source} 新增节点")
await self._collection.add(node)
self._on_node_loaded(handler, node)
# ========== 数据加载 ==========
async def load_all(
self,
order: Optional[List[str]] = None,
data_type: Optional[str] = None
) -> None:
"""
按依赖顺序加载所有数据
注意:调用前需先 set_collection()
Args:
order: 节点类型顺序(如 ["project", "contract", "construction"])
data_type: 仅加载单一类型(与 order 互斥)
工作流程:
1. 按顺序遍历每个节点类型
2. 调用 Handler.load(collection)
3. Handler 可以从 collection 查询依赖节点(如 Contract 查询 Project
4. 使用 Handler.create_node() 创建 SyncNode
5. 添加到 Collection
示例:
datasource.set_collection(collection)
await datasource.load_all(order=["project", "contract"])
"""
if self._collection is None:
raise RuntimeError("Collection not set. Call set_collection() first.")
if data_type is not None:
order_to_load = [data_type]
elif order is not None:
order_to_load = order
else:
order_to_load = list(self._handlers.keys())
for node_type in order_to_load:
handler = self.get_handler(node_type)
# 初始化统计
self._init_node_stats(node_type)
try:
# Handler 加载数据并创建节点
nodes = await handler.load()
print(f"Loaded nodes for {node_type}: {len(nodes)}")
# 将节点写入 Collection:优先按 data_id 命中已有节点并更新
await self._upsert_loaded_nodes(handler, node_type, nodes)
# 统计加载的节点数
self._stats[node_type]["loaded"] += len(nodes)
except Exception as exc:
print(f"❌ [{node_type}] 加载失败: {exc}")
continue
# ========== 同步执行 ==========
async def sync_all(
self,
order: Optional[List[str]] = None,
data_type: Optional[str] = None,
poll_async_tasks: bool = True
) -> Dict[str, Dict[str, str]]:
"""
按依赖顺序执行同步
注意:调用前需先 set_collection()
Args:
order: 节点类型顺序
data_type: 仅同步单一类型(与 order 互斥)
工作流程:
1. 按顺序遍历每个节点类型
2. 筛选需要同步的节点(status=PENDING, action!=NONE
3. 调用 Handler 的 create/update/delete
4. 根据 TaskResult 更新节点状态
5. 处理异步任务轮询
状态流转:
- PENDING + action=CREATE/UPDATE/DELETE → IN_PROGRESS → SUCCESS/FAILED
- PENDING + action=NONE → 保持 PENDING
- DEPENDENCY_ERROR / ABNORMAL / WARNING → SKIPPED
示例:
datasource.set_collection(collection)
await datasource.sync_all(order=["project", "contract"])
"""
if self._collection is None:
raise RuntimeError("Collection not set. Call set_collection() first.")
if data_type is not None:
order_to_sync = [data_type]
elif order is not None:
order_to_sync = order
else:
order_to_sync = list(self._handlers.keys())
async_tasks_by_type: Dict[str, Dict[str, str]] = {}
for node_type in order_to_sync:
handler = self.get_handler(node_type)
# 批量同步该类型的所有节点
async_tasks = await self._sync_nodes(handler, poll_async_tasks=poll_async_tasks)
if async_tasks:
async_tasks_by_type[node_type] = async_tasks
return async_tasks_by_type
async def _sync_nodes(
self,
handler: "NodeHandler",
poll_async_tasks: bool = True
) -> Dict[str, str]:
"""
同步一批节点(批量接口)
Args:
handler: 节点处理器
流程:
1. DataSource 筛选需要处理的节点
2. 调用 handler.create_all(nodes)
3. 调用 handler.update_all(nodes)
4. 调用 handler.delete_all(nodes)
5. 根据 List[TaskResult] 更新节点状态
6. 收集并轮询异步任务
"""
from ..common.types import SyncAction, SyncStatus
from .task_result import TaskStatus as _TaskStatus
if self._collection is None:
raise RuntimeError("Collection not set. Call set_collection() first.")
collection = self._collection
# 初始化统计
self._init_node_stats(handler.node_type)
async_tasks: Dict[str, str] = {} # {node_id: task_id}
# DataSource 负责筛选需要创建的节点(排除已失败的节点)
create_nodes = [
n
for n in collection.filter_by_state_ids(
node_type=handler.node_type,
state_ids=["S06"],
)
if n.action == SyncAction.CREATE
]
# DataSource 负责筛选需要更新的节点(排除已失败的节点)
update_nodes = [
n
for n in collection.filter_by_state_ids(
node_type=handler.node_type,
state_ids=["S07"],
)
if n.action == SyncAction.UPDATE
]
# DataSource 负责筛选需要删除的节点(排除已失败的节点)
delete_nodes = [
n
for n in collection.filter_by_state_ids(
node_type=handler.node_type,
state_ids=["S09"],
)
if n.action == SyncAction.DELETE
]
if delete_nodes:
raise NotImplementedError(
f"[{handler.node_type}] delete path is not implemented yet, "
f"but found {len(delete_nodes)} node(s) in S09/DELETE."
)
# 记录本次 sync 的动作统计(在 action 被重置前)
for node in create_nodes:
self._action_by_node_id[node.node_id] = SyncAction.CREATE
for node in update_nodes:
self._action_by_node_id[node.node_id] = SyncAction.UPDATE
for node in delete_nodes:
self._action_by_node_id[node.node_id] = SyncAction.DELETE
none_count = len(
self._collection.filter(
node_type=handler.node_type,
node_filter={"action": SyncAction.NONE}
)
)
self._set_action_totals(
handler.node_type,
create=len(create_nodes),
update=len(update_nodes),
delete=len(delete_nodes),
none=none_count,
)
# 批量创建(传入节点列表)
if create_nodes:
logger.info(f"[{handler.node_type}] create提交开始: nodes={len(create_nodes)}")
try:
create_results = await handler.create_all(create_nodes)
create_success = 0
create_in_progress = 0
create_failed = 0
create_skipped = 0
for result in create_results:
if result.status == _TaskStatus.SUCCESS:
create_success += 1
elif result.status == _TaskStatus.IN_PROGRESS:
create_in_progress += 1
elif result.status == _TaskStatus.FAILED:
create_failed += 1
elif result.status == _TaskStatus.SKIPPED:
create_skipped += 1
await self._handle_task_result(handler, result, async_tasks)
logger.info(
f"[{handler.node_type}] create提交完成: success={create_success}, "
f"in_progress={create_in_progress}, failed={create_failed}, skipped={create_skipped}"
)
except Exception as e:
print(f"❌ [{handler.node_type}] create_all 异常: {e}")
for node in create_nodes:
ok = self._apply_sync_execute_state(
node,
handler_status=_TaskStatus.FAILED,
detail=str(e),
)
if not ok:
raise RuntimeError(f"[{handler.node_type}] create_all exception cannot map to state machine")
# 批量更新(传入节点列表)
if update_nodes:
try:
update_results = await handler.update_all(update_nodes)
for result in update_results:
await self._handle_task_result(handler, result, async_tasks)
except Exception as e:
print(f"❌ [{handler.node_type}] update_all 异常: {e}")
for node in update_nodes:
ok = self._apply_sync_execute_state(
node,
handler_status=_TaskStatus.FAILED,
detail=str(e),
)
if not ok:
raise RuntimeError(f"[{handler.node_type}] update_all exception cannot map to state machine")
# 批量删除(delete stage 当前未实现,检测到 S09 会在上方直接 fail-fast
# 轮询异步任务
if async_tasks and poll_async_tasks:
logger.info(
f"[{handler.node_type}] create已提交,push_id检查即将开始: pending={len(async_tasks)}, "
f"first_wait={self._poll_interval:.3f}s"
)
await self._poll_async_tasks(handler, async_tasks)
# 统计同步结果
all_nodes = self._collection.filter(node_type=handler.node_type)
synced_count = len([n for n in all_nodes if n.action != SyncAction.NONE])
success_count = len([n for n in all_nodes if n.status == SyncStatus.SUCCESS])
failed_count = len([n for n in all_nodes if n.status == SyncStatus.FAILED])
self._stats[handler.node_type]["synced"] = synced_count
self._stats[handler.node_type]["success"] = success_count
self._stats[handler.node_type]["failed"] = failed_count
return async_tasks
async def poll_async_tasks(self, async_tasks_by_type: Dict[str, Dict[str, str]]) -> None:
"""由 Pipeline 调度的异步任务轮询"""
for node_type, async_tasks in async_tasks_by_type.items():
if not async_tasks:
continue
handler = self.get_handler(node_type)
await self._poll_async_tasks(handler, async_tasks)
async def _handle_task_result(
self,
handler: "NodeHandler",
result: "TaskResult",
async_tasks: Dict[str, str]
) -> None:
"""
处理 Handler 返回的 TaskResult
Args:
collection: Collection 上下文
result: Handler 返回的结果(包含 node_id
async_tasks: 异步任务字典
"""
from .task_result import TaskStatus as _TaskStatus
if not result.node_id:
# 没有 node_id,无法定位节点
return
if self._collection is None:
# Collection 未设置,无法处理结果
return
# 从 self._collection 中查找节点
node = self._collection.get(result.node_id)
if not node:
return
if result.status == _TaskStatus.SUCCESS:
if await self._intercept_success_result(handler, node, result, async_tasks):
return
if result.status == _TaskStatus.SUCCESS:
await self._handle_success_result(node, result)
return
if result.status == _TaskStatus.FAILED:
self._on_failed_result(handler, node, result)
await self._handle_failed_result(node, result)
return
if result.status == _TaskStatus.SKIPPED:
await self._handle_skipped_result(node, result)
return
if result.status == _TaskStatus.IN_PROGRESS:
self._on_in_progress_result(handler, node, result)
await self._handle_in_progress_result(node, result, async_tasks)
return
def _write_back_data_id_to_data(self, node, data_id: str) -> None:
data = node.get_data()
if data is None:
return
schema = node.get_schema()
candidate_keys = ["id", "_id"]
model_fields = get_pydantic_model_fields(schema)
for key in candidate_keys:
if key in data:
data[key] = data_id
node.set_data(data)
return
if key in model_fields:
data[key] = data_id
node.set_data(data)
return
async def _apply_success_result(self, node, data_id: Optional[str], previous_data_id: str) -> None:
from ..common.types import SyncAction
if data_id and node.action == SyncAction.CREATE:
if node.data_id != data_id:
raise RuntimeError(
f"[{node.node_type}] data_id must be updated in event apply path: expected={data_id}, actual={node.data_id}"
)
self._write_back_data_id_to_data(node, data_id)
if self._collection is not None and data_id != previous_data_id:
await self._collection.update(node)
if node.action == SyncAction.UPDATE:
current_data = node.get_data()
if current_data is not None:
node.set_origin_data(current_data)
if node.action == SyncAction.DELETE:
if self._collection is None:
return
await self._collection.delete(node.node_id)
return
# 成功后 action 是否重置由上层策略决定
async def _poll_async_tasks(
self,
handler: "NodeHandler",
async_tasks: Dict[str, str]
) -> None:
"""
轮询异步任务直到全部完成
Args:
handler: 节点处理器
collection: Collection 上下文
async_tasks: {node_id: task_id} 映射
流程:
1. 批量调用 Handler.poll_tasks()
2. 更新节点状态
3. 如果还有未完成任务,等待后重试
"""
from .task_result import TaskStatus as _TaskStatus
max_retries = self._poll_max_retries
retry_count = 0
poll_interval = self._poll_interval # 轮询间隔(秒)
if self._collection is None:
# Collection 未设置,无法轮询任务
return
if async_tasks and poll_interval > 0:
logger.info(
f"[{handler.node_type}] push_id首次检查前等待 {poll_interval:.3f}s, pending={len(async_tasks)}"
)
await asyncio.sleep(poll_interval)
while async_tasks and retry_count < max_retries:
logger.info(
f"[{handler.node_type}] push_id检查 {retry_count + 1}/{max_retries}, pending={len(async_tasks)}"
)
# 批量查询
task_ids = list(async_tasks.values())
results = await handler.poll_tasks(task_ids)
# 处理结果
completed_nodes = []
for node_id, task_id in list(async_tasks.items()):
if task_id not in results:
continue
result = results[task_id]
node = self._collection.get(node_id)
if not node:
completed_nodes.append(node_id)
continue
if result.status == _TaskStatus.SUCCESS:
self._on_poll_result(handler, node, task_id, result)
previous_data_id = node.data_id
ok = self._apply_sync_execute_state(
node,
handler_status=result.status,
detail=f"异步任务成功完成: {task_id}",
result_data_id=result.data_id,
)
if not ok:
raise RuntimeError(f"[{node.node_type}] async SUCCESS cannot map to state machine: node={node.node_id}")
self._record_action_result(node, result.status)
await self._apply_success_result(node, result.data_id, previous_data_id=previous_data_id)
node.error = None
logger.info(
f"[{handler.node_type}] polling成功: node={node.node_id}, push_id={task_id}, data_id={result.data_id or node.data_id}"
)
completed_nodes.append(node_id)
elif result.status == _TaskStatus.FAILED:
self._on_poll_result(handler, node, task_id, result)
ok = self._apply_sync_execute_state(
node,
handler_status=result.status,
detail=f"异步任务失败: {result.error}",
)
if not ok:
raise RuntimeError(f"[{node.node_type}] async FAILED cannot map to state machine: node={node.node_id}")
self._record_action_result(node, result.status)
node.error = result.error
completed_nodes.append(node_id)
# IN_PROGRESS 继续等待
# 移除已完成的任务
for node_id in completed_nodes:
async_tasks.pop(node_id, None)
# 还有未完成任务,等待后重试
if async_tasks:
retry_count += 1
# 超时未完成的任务标记为失败
if async_tasks:
for node_id in async_tasks:
node = self._collection.get(node_id)
if node:
timeout_msg = f"异步任务超时 (等待 {max_retries * poll_interval} 秒后仍未完成)"
ok = self._apply_sync_execute_state(
node,
handler_status=_TaskStatus.FAILED,
poll_timeout=True,
detail=timeout_msg,
)
if not ok:
raise RuntimeError(f"[{node.node_type}] poll timeout cannot map to state machine: node={node.node_id}")
node.error = f"Async task timeout after {max_retries * poll_interval} seconds"
self._record_action_result(node, _TaskStatus.FAILED)
# ========== 统计信息 ==========
def get_sync_summary(self, collection: "DataCollection") -> Dict[str, Any]:
"""
获取同步统计信息
Returns:
{
"total": 总节点数,
"success": 成功数,
"failed": 失败数,
"in_progress": 进行中,
"skipped": 跳过数,
"by_type": {...} # 按类型统计
}
"""
nodes = list(collection._nodes.values())
summary = {
"total": len(nodes),
"success": sum(1 for n in nodes if n.status == SyncStatus.SUCCESS),
"failed": sum(1 for n in nodes if n.status == SyncStatus.FAILED),
"in_progress": sum(1 for n in nodes if n.status == SyncStatus.IN_PROGRESS),
"skipped": sum(1 for n in nodes if n.status == SyncStatus.SKIPPED),
"by_type": {}
}
# 按类型统计
for node_type in self._handlers.keys():
type_nodes = collection.filter(node_type=node_type)
summary["by_type"][node_type] = {
"total": len(type_nodes),
"success": sum(1 for n in type_nodes if n.status == SyncStatus.SUCCESS),
"failed": sum(1 for n in type_nodes if n.status == SyncStatus.FAILED),
}
return summary
async def close(self) -> None:
"""关闭数据源资源(子类可覆盖以实现特定的清理逻辑)"""
pass # 基类默认空实现
+579
View File
@@ -0,0 +1,579 @@
"""
节点处理器 V2 (Node Handler - Pure Execution Layer)
Handler 职责:
1. 执行后端 API 调用(load/create/update/delete
2. 返回执行结果(TaskResult
3. 提供异步任务轮询(poll
4. 提供数据质量检查(validate)
Handler 不负责:
- ❌ 状态管理(由 DataSource 负责)
- ❌ ID 映射(由 Strategy 负责,已保存在 node.data
- ❌ 依赖关系(由 Strategy 负责)
"""
from __future__ import annotations
from typing import (
TypeVar, Generic, Type, Protocol, Dict, List, Any, Optional,
runtime_checkable, TYPE_CHECKING
)
from abc import ABC, abstractmethod
from pydantic import BaseModel
from .task_result import TaskResult
if TYPE_CHECKING:
from ..common.sync_node import SyncNode
from ..common.collection import DataCollection
from .api.client import ApiClient
T = TypeVar("T", bound=BaseModel)
class ValidationResult:
"""数据质量检查结果"""
def __init__(self, is_valid: bool, errors: Optional[List[str]] = None):
self.is_valid = is_valid
self.errors = errors or []
@runtime_checkable
class NodeHandler(Protocol[T]):
"""
节点处理器协议(纯执行层)
Handler 只负责调用后端 API,不管理状态。
"""
@property
def node_type(self) -> str:
"""节点类型(如 'contract'"""
...
@property
def node_class(self) -> Type["SyncNode[T]"]:
"""SyncNode 类型"""
...
@property
def schema(self) -> Type[T]:
"""Schema 类型"""
...
def set_collection(self, collection: "DataCollection") -> None:
"""
设置 Collection 引用
Args:
collection: 数据集合
"""
...
def set_target_project_ids(self, target_project_ids: List[str]) -> None:
"""注入项目过滤列表。默认可忽略。"""
...
def set_api_client(self, client: "ApiClient") -> None:
"""注入 API 客户端。默认可忽略。"""
...
def set_poll_mode(self, mode: str) -> None:
"""设置轮询模式。默认可忽略。"""
...
def get_update_fields(self, *, exclude: frozenset[str] = frozenset({"id"})) -> List[str]:
"""返回 post-check 可比较字段列表。默认空。"""
...
async def load(self) -> List['SyncNode']:
"""
从后端加载全部数据并创建节点
注意:调用前需先 set_collection()
Returns:
SyncNode 列表
示例:
# Project Handler(无依赖)
async def load(self):
response = await self.api.get_projects()
nodes = []
for data in response["data"]:
node = self.create_node(data)
node.context["project_id"] = data["id"]
nodes.append(node)
return nodes
# Contract Handler(依赖 Project
async def load(self):
# 使用 self._collection 查询依赖
projects = self._collection.filter(node_type="project")
project_ids = [p.data_id for p in projects if p.data_id]
nodes = []
for project_id in project_ids:
response = await self.api.get_contracts(project_id=project_id)
for data in response["data"]:
node = self.create_node(data)
node.context["project_id"] = project_id
node.context["contract_id"] = data["id"]
nodes.append(node)
return nodes
"""
...
async def create_all(
self,
nodes: List["SyncNode"]
) -> List[TaskResult]:
"""
批量创建节点
Args:
nodes: 需要创建的节点列表(DataSource 已筛选为 CREATE + PENDING
Returns:
List[TaskResult]:
每个 TaskResult 必须包含 node_id,用于定位节点:
- TaskResult(status=SUCCESS, node_id="xxx", data_id="yyy")
- TaskResult(status=FAILED, node_id="xxx", error="...")
- TaskResult(status=IN_PROGRESS, node_id="xxx", task_id="...")
示例:
# JSONL Handler
async def create_all(self, nodes):
results = []
for node in nodes:
try:
new_id = str(uuid4())
data = node.get_data()
# ... 存储逻辑 ...
results.append(TaskResult.success(
node_id=node.node_id,
data_id=new_id
))
except Exception as e:
results.append(TaskResult.failed(
node_id=node.node_id,
error=str(e)
))
return results
# API Handler - 查询依赖
async def create_all(self, nodes):
results = []
for node in nodes:
# 查询依赖数据(如合同创建时查项目)
project_id = node.context.get("project_id")
projects = self._collection.filter(
node_type="project",
node_filter={"data_id": project_id}
)
if not projects:
results.append(TaskResult.failed(
node_id=node.node_id,
error=f"Project {project_id} not found"
))
continue
# 构造请求数据
data = node.get_data()
if not data:
results.append(TaskResult.failed(
node_id=node.node_id,
error="node data is None"
))
continue
contract_data = {
"project_id": projects[0].data_id,
**data
}
# 调用 API
response = await self.api.create_contract(contract_data)
results.append(TaskResult.success(
node_id=node.node_id,
data_id=response["id"]
))
return results
"""
...
async def update_all(
self,
nodes: List["SyncNode"]
) -> List[TaskResult]:
"""
批量更新节点
Args:
nodes: 需要更新的节点列表(DataSource 已筛选为 UPDATE + PENDING
Returns:
List[TaskResult](同 create_all
"""
...
async def delete_all(
self,
nodes: List["SyncNode"]
) -> List[TaskResult]:
"""
批量删除节点
Args:
nodes: 需要删除的节点列表(DataSource 已筛选为 DELETE + PENDING
Args:
collection: Collection 上下文
Returns:
List[TaskResult](同 create_all
"""
...
async def poll_tasks(self, task_ids: List[str]) -> Dict[str, TaskResult]:
"""
批量轮询异步任务状态
Args:
task_ids: 任务 ID 列表
Returns:
{task_id: TaskResult} 字典
- SUCCESS: 任务完成,返回 data_id
- FAILED: 任务失败,返回 error
- IN_PROGRESS: 仍在进行中
示例:
async def poll_tasks(self, task_ids):
response = await self.api.batch_query_tasks(task_ids)
results = {}
for task in response["tasks"]:
if task["status"] == "completed":
results[task["id"]] = TaskResult.success(
data_id=task["result"]["contract_id"]
)
elif task["status"] == "failed":
results[task["id"]] = TaskResult.failed(
error=task["error"]
)
else: # running
results[task["id"]] = TaskResult.in_progress(
task_id=task["id"]
)
return results
"""
...
async def validate(self, data: Dict[str, Any]) -> ValidationResult:
"""
数据质量检查
Args:
data: 原始数据
Returns:
ValidationResult(是否有效 + 错误列表)
示例:
async def validate(self, data):
errors = []
if not data.get("code"):
errors.append("合同编码为空")
if not data.get("project_id"):
errors.append("项目 ID 为空")
return ValidationResult(
is_valid=len(errors) == 0,
errors=errors
)
"""
...
def extract_id(self, data: Dict[str, Any]) -> str:
"""
从原始数据提取 ID
Args:
data: 原始数据
Returns:
业务主键
"""
...
def _create_node(self, data: Dict[str, Any]) -> "SyncNode[T]":
"""
从原始数据创建 SyncNode
Args:
data: 原始数据
Returns:
类型化的 SyncNode
"""
...
class BaseNodeHandler(ABC, Generic[T]):
"""
节点处理器基类
子类需实现:
- load()
- create()
- update()
- delete()(可选)
- poll_tasks()(如果支持异步)
- validate()(可选)
- extract_id()
"""
def __init__(
self,
node_type: str,
node_class: Type["SyncNode[T]"],
schema: Type[T]
):
self._node_type = node_type
self._node_class = node_class
self._schema = schema
self._collection: Optional["DataCollection"] = None
def set_collection(self, collection: "DataCollection") -> None:
"""
设置 Collection 引用
Args:
collection: 数据集合
"""
self._collection = collection
def set_target_project_ids(self, target_project_ids: List[str]) -> None:
"""默认忽略项目过滤能力。"""
return
def set_api_client(self, client: "ApiClient") -> None:
"""默认忽略 API 客户端注入。"""
return
def set_poll_mode(self, mode: str) -> None:
"""默认忽略轮询模式。"""
return
def get_update_fields(self, *, exclude: frozenset[str] = frozenset({"id"})) -> List[str]:
"""默认无 post-check 字段白名单。"""
return []
@property
def node_type(self) -> str:
return self._node_type
@property
def node_class(self) -> Type["SyncNode[T]"]:
return self._node_class
@property
def schema(self) -> Type[T]:
return self._schema
# ========== 必须实现 ==========
@abstractmethod
async def load(self) -> List['SyncNode']:
"""
从数据源加载数据并创建节点
Handler 负责:
1. 加载原始数据
2. 创建 SyncNode(调用 create_node
3. 设置 context(如 project_id, contract_id 等)
Returns:
SyncNode 列表
"""
pass
@abstractmethod
async def create_all(
self,
nodes: List["SyncNode"]
) -> List[TaskResult]:
"""批量创建节点"""
pass
@abstractmethod
async def update_all(
self,
nodes: List["SyncNode"]
) -> List[TaskResult]:
"""批量更新节点"""
pass
@abstractmethod
def extract_id(self, data: Dict[str, Any]) -> str:
"""提取 ID"""
pass
# ========== 可选实现 ==========
async def delete_all(
self,
nodes: List["SyncNode"]
) -> List[TaskResult]:
"""批量删除节点(默认不支持)"""
return [
TaskResult.failed(node_id=node.node_id, error="Delete operation not supported")
for node in nodes
]
async def poll_tasks(self, task_ids: List[str]) -> Dict[str, TaskResult]:
"""轮询异步任务(默认不支持)"""
return {
task_id: TaskResult.failed(error="Async tasks not supported")
for task_id in task_ids
}
async def validate(self, data: Dict[str, Any]) -> ValidationResult:
"""数据质量检查(默认不检查)"""
return ValidationResult(is_valid=True)
def create_node(self, data: Dict[str, Any]) -> "SyncNode[T]":
"""创建或复用 SyncNode(默认实现)"""
data_id = self.extract_id(data)
if self._collection is None:
raise RuntimeError("Collection not set. Call set_collection() before create_node().")
if data_id:
existing_node = self._collection.get_by_data_id(self.node_type, data_id)
if existing_node is not None:
existing_node.set_data(data.copy())
existing_node.set_origin_data(data.copy())
return existing_node
node_id = self._collection.generate_node_id()
return self.node_class(
node_id=node_id,
data_id=data_id,
data=data.copy()
)
def _create_node(self, data: Dict[str, Any]) -> "SyncNode[T]":
"""兼容 NodeHandler 协议的默认实现"""
return self.create_node(data)
class CompatibleNodeHandler(Generic[T]):
"""兼容旧式 Handler,实现扩展后的可选接口并委托给原对象。"""
def __init__(self, handler: NodeHandler[T]):
self._handler = handler
@property
def node_type(self) -> str:
return self._handler.node_type
@property
def node_class(self) -> Type["SyncNode[T]"]:
return self._handler.node_class
@property
def schema(self) -> Type[T]:
return self._handler.schema
def set_collection(self, collection: "DataCollection") -> None:
self._handler.set_collection(collection)
def set_target_project_ids(self, target_project_ids: List[str]) -> None:
method = getattr(self._handler, "set_target_project_ids", None)
if callable(method):
method(target_project_ids)
def set_api_client(self, client: "ApiClient") -> None:
method = getattr(self._handler, "set_api_client", None)
if callable(method):
method(client)
def set_poll_mode(self, mode: str) -> None:
method = getattr(self._handler, "set_poll_mode", None)
if callable(method):
method(mode)
def get_update_fields(self, *, exclude: frozenset[str] = frozenset({"id"})) -> List[str]:
method = getattr(self._handler, "get_update_fields", None)
if callable(method):
return method(exclude=exclude)
return []
async def load(self) -> List["SyncNode"]:
return await self._handler.load()
async def create_all(self, nodes: List["SyncNode"]) -> List[TaskResult]:
return await self._handler.create_all(nodes)
async def update_all(self, nodes: List["SyncNode"]) -> List[TaskResult]:
return await self._handler.update_all(nodes)
async def delete_all(self, nodes: List["SyncNode"]) -> List[TaskResult]:
return await self._handler.delete_all(nodes)
async def poll_tasks(self, task_ids: List[str]) -> Dict[str, TaskResult]:
return await self._handler.poll_tasks(task_ids)
async def validate(self, data: Dict[str, Any]) -> ValidationResult:
return await self._handler.validate(data)
def extract_id(self, data: Dict[str, Any]) -> str:
return self._handler.extract_id(data)
def _create_node(self, data: Dict[str, Any]) -> "SyncNode[T]":
return self._handler._create_node(data)
def __getattr__(self, item: str) -> Any:
return getattr(self._handler, item)
def ensure_handler_compat(handler: NodeHandler[T]) -> NodeHandler[T]:
if isinstance(handler, BaseNodeHandler) or isinstance(handler, CompatibleNodeHandler):
return handler
return CompatibleNodeHandler(handler)
class NodeHandlerRegistry:
"""节点处理器注册表"""
_handlers: Dict[str, NodeHandler] = {}
@classmethod
def register(cls, node_type: str, handler: NodeHandler) -> None:
"""注册 handler"""
cls._handlers[node_type] = handler
@classmethod
def get(cls, node_type: str) -> NodeHandler:
"""获取 handler"""
if node_type not in cls._handlers:
raise KeyError(f"Handler for '{node_type}' not registered")
return cls._handlers[node_type]
@classmethod
def has(cls, node_type: str) -> bool:
"""检查是否已注册"""
return node_type in cls._handlers
@classmethod
def clear(cls) -> None:
"""清空注册表"""
cls._handlers.clear()
@classmethod
def list_handlers(cls) -> List[str]:
"""列出所有已注册的 node_type"""
return list(cls._handlers.keys())
@@ -0,0 +1,13 @@
"""
JSONL DataSource 模块
提供基于 JSONL 文件的数据源实现,用于测试和本地开发。
"""
from .datasource import JsonlDataSource
from .handler import BaseJsonlHandler
__all__ = [
"JsonlDataSource",
"BaseJsonlHandler",
]
@@ -0,0 +1,67 @@
"""
JsonlDataSource - JSONL 文件数据源
职责:
- 提供 JSONL 文件目录管理
- 实现 DataSource 接口(load_all, sync_all
- 管理内存缓存和文件写入
- 统一异常处理和状态管理
"""
from __future__ import annotations
from pathlib import Path
from typing import Dict, Any, Set
from ..datasource import BaseDataSource
class JsonlDataSource(BaseDataSource):
"""
JSONL 文件数据源
提供文件路径和基础功能,具体的文件查找和读取由各个 Handler 负责。
Args:
dir_path: JSONL 文件目录
read_only: 是否只读模式
示例:
datasource = JsonlDataSource(Path("filtered_datasource/datasource/ecm-2025-12-02"))
datasource.register_handler(ContractJsonlHandler(datasource))
collection = DataCollection()
datasource.set_collection(collection)
await datasource.load_all(order=["project", "contract"])
await datasource.sync_all(order=["project", "contract"])
"""
def __init__(self, dir_path: Path, read_only: bool = False):
super().__init__()
self.dir_path = Path(dir_path)
self.read_only = read_only
# 用于 create/update/delete 的内存缓存
self._data: Dict[str, Dict[str, Dict[str, Any]]] = {} # {node_type: {id: data}}
self._dirty_types: Set[str] = set()
def _on_node_loaded(self, handler, node) -> None:
node.append_log(
f"JSONL链路[load] dir={self.dir_path.as_posix()} node_type={node.node_type} data_id={node.data_id or '<empty>'}"
)
def _on_in_progress_result(self, handler, node, result) -> None:
node.append_log(
f"JSONL链路[submit] action={node.action.value} data_id={node.data_id or '<empty>'}"
)
def _on_poll_result(self, handler, node, task_id: str, result) -> None:
node.append_log(
f"JSONL链路[poll] task_id={task_id} status={result.status.value}"
)
# load_all 和 sync_all 继承自 BaseDataSource
# 保存逻辑在 BaseJsonlHandler 中实现
@@ -0,0 +1,350 @@
"""
BaseJsonlHandler - JSONL Handler 基类
职责:
- 查找和读取 JSONL 文件
- 实现批量 Handler 接口(load, create_all, update_all, delete_all
- 使用 datasource 的内存缓存
- 返回 List[TaskResult](统一状态管理)
- 捕获所有异常并转换为 TaskResult.failed()
"""
from __future__ import annotations
import json
import re
from typing import Dict, List, Any, TYPE_CHECKING, Optional, Set
from uuid import uuid4
from ..handler import BaseNodeHandler
from ..task_result import TaskResult
if TYPE_CHECKING:
from ...common.sync_node import SyncNode
from .datasource import JsonlDataSource
class BaseJsonlHandler(BaseNodeHandler):
"""
JSONL Handler 基类
负责查找和读取对应的 JSONL 文件。
示例:
class ContractJsonlHandler(BaseJsonlHandler):
def __init__(self, datasource: JsonlDataSource):
super().__init__(
datasource=datasource,
node_type="contract",
node_class=ContractSyncNode,
schema=ContractResponse
)
"""
def __init__(
self,
datasource: JsonlDataSource,
node_type: str,
node_class: type,
schema: type
):
super().__init__(node_type, node_class, schema)
self.datasource = datasource
self._target_project_ids: Optional[Set[str]] = None
def set_target_project_ids(self, target_project_ids: List[str]) -> None:
"""注入项目过滤列表,供 load 阶段预过滤使用。"""
if target_project_ids:
self._target_project_ids = {str(pid) for pid in target_project_ids if str(pid)}
else:
self._target_project_ids = None
def _should_skip_item_by_project_filter(self, item: Dict[str, Any], item_id: str) -> bool:
if not self._target_project_ids:
return False
if self.node_type == "project":
return item_id not in self._target_project_ids
project_id = item.get("project_id")
if project_id:
return str(project_id) not in self._target_project_ids
return False
async def load(self) -> List['SyncNode']:
"""
查找并加载对应的 JSONL 文件并创建节点
文件命名规则:{node_type}_N.jsonl (N 为数字)
例如:contract_1.jsonl, contract_2.jsonl
注意:load 操作的异常由 DataSource 的 load_all 捕获处理
"""
nodes = []
if not self.datasource.dir_path.exists():
return nodes
# 查找所有匹配的文件:{node_type}_N.jsonl
pattern = re.compile(rf"^{re.escape(self.node_type)}_\d+\.jsonl$")
for file_path in self.datasource.dir_path.iterdir():
if not pattern.match(file_path.name):
continue
# 读取文件
with open(file_path, "r", encoding="utf-8") as f:
for line_num, line in enumerate(f, 1):
line = line.strip()
if not line:
continue
try:
item = json.loads(line)
item_id = self.extract_id(item)
if self._should_skip_item_by_project_filter(item, item_id):
continue
# 创建节点
node = self.create_node(item)
nodes.append(node)
# 同时加载到缓存
if item_id:
bucket = self.datasource._data.setdefault(self.node_type, {})
bucket[item_id] = item
except json.JSONDecodeError as e:
print(f"Warning: Invalid JSON in {file_path.name}:{line_num}: {e}")
# 继续处理下一行
return nodes
def extract_id(self, data: Dict[str, Any]) -> str:
"""
提取数据 ID
默认实现:优先使用 'id',其次 '_id'
子类可重写此方法。
Args:
data: 数据字典
Returns:
数据 ID
"""
return str(data.get("id") or data.get("_id") or "")
async def create_all(
self,
nodes: List['SyncNode']
) -> List[TaskResult]:
"""
批量创建节点
Args:
nodes: 需要创建的节点列表(DataSource 已筛选为 CREATE + PENDING
Returns:
List[TaskResult],每个结果关联 node_id
"""
results = []
for node in nodes:
try:
# 生成新 ID(如果没有)
if not node.data_id:
new_id = str(uuid4())
else:
new_id = node.data_id
# 获取数据字典
# 使用 exclude_unset=False 以确保测试中的完整性校验(如 amount 字段)能通过
# 因为 JSONL 存储通常需要完整记录
data_dict = node.get_data(exclude_unset=False)
if data_dict is None:
results.append(TaskResult.failed(
node_id=node.node_id,
error="Cannot get data from node"
))
continue
# 设置 ID 和 type
data_dict["id"] = new_id
data_dict["_id"] = new_id
if "type" not in data_dict:
data_dict["type"] = self.node_type
# 存储到缓存
bucket = self.datasource._data.setdefault(self.node_type, {})
bucket[new_id] = data_dict
self.datasource._dirty_types.add(self.node_type)
results.append(TaskResult.success(
node_id=node.node_id,
data_id=new_id
))
except Exception as e:
results.append(TaskResult.failed(
node_id=node.node_id,
error=f"Create failed: {str(e)}"
))
# 自动保存到文件
await self._save_to_file()
return results
async def update_all(
self,
nodes: List['SyncNode']
) -> List[TaskResult]:
"""
批量更新节点
Args:
nodes: 需要更新的节点列表(DataSource 已筛选为 UPDATE + PENDING
Returns:
List[TaskResult],每个结果关联 node_id
"""
results = []
for node in nodes:
try:
if not node.data_id:
results.append(TaskResult.failed(
node_id=node.node_id,
error="Cannot update node without data_id"
))
continue
# 获取数据字典
# JSONL 更新通常也是完整记录替换,所以使用 exclude_unset=False
data_dict = node.get_data(exclude_unset=False)
if data_dict is None:
results.append(TaskResult.failed(
node_id=node.node_id,
error="Cannot get data from node"
))
continue
# 检查是否存在
bucket = self.datasource._data.get(self.node_type, {})
existing = bucket.get(node.data_id)
if existing is None:
results.append(TaskResult.failed(
node_id=node.node_id,
error=f"Item {node.data_id} not found"
))
continue
# 合并更新
existing.update(data_dict)
existing["id"] = node.data_id
existing["_id"] = node.data_id
if "type" not in existing:
existing["type"] = self.node_type
# 更新缓存
bucket[node.data_id] = existing
self.datasource._dirty_types.add(self.node_type)
results.append(TaskResult.success(
node_id=node.node_id,
data_id=node.data_id
))
except Exception as e:
results.append(TaskResult.failed(
node_id=node.node_id,
error=f"Update failed: {str(e)}"
))
# 自动保存到文件
await self._save_to_file()
return results
async def _save_to_file(self) -> None:
"""
将缓存数据写回 JSONL 文件
在批量操作(create_all/update_all/delete_all)后自动调用。
"""
if self.datasource.read_only:
return
if self.node_type not in self.datasource._dirty_types:
return
items = self.datasource._data.get(self.node_type, {})
if not items:
# 如果缓存为空,可能是删除了所有数据,仍需写入空文件
pass
try:
# 写入到 {node_type}_1.jsonl
file_path = self.datasource.dir_path / f"{self.node_type}_1.jsonl"
file_path.parent.mkdir(parents=True, exist_ok=True)
with open(file_path, "w", encoding="utf-8") as f:
for item in items.values():
f.write(json.dumps(item, ensure_ascii=False) + "\n")
# 清除该类型的 dirty 标记
self.datasource._dirty_types.discard(self.node_type)
except Exception as e:
print(f"Error saving {self.node_type}: {str(e)}")
# 不抛出异常,继续执行
async def delete_all(
self,
nodes: List['SyncNode']
) -> List[TaskResult]:
"""
批量删除节点
Args:
nodes: 需要删除的节点列表(DataSource 已筛选为 DELETE + PENDING
Returns:
List[TaskResult],每个结果关联 node_id
"""
results = []
for node in nodes:
try:
if not node.data_id:
results.append(TaskResult.failed(
node_id=node.node_id,
error="Cannot delete node without data_id"
))
continue
# 删除缓存
bucket = self.datasource._data.get(self.node_type, {})
if node.data_id in bucket:
del bucket[node.data_id]
self.datasource._dirty_types.add(self.node_type)
results.append(TaskResult.success(
node_id=node.node_id
))
else:
results.append(TaskResult.failed(
node_id=node.node_id,
error=f"Item {node.data_id} not found"
))
except Exception as e:
results.append(TaskResult.failed(
node_id=node.node_id,
error=f"Delete failed: {str(e)}"
))
# 自动保存到文件
await self._save_to_file()
return results
@@ -0,0 +1,122 @@
"""
TaskResult - Handler 执行结果
统一的任务执行结果,用于所有 DataSource 的 Handler。
"""
from enum import Enum
from typing import Dict, Any, Optional
class TaskStatus(Enum):
"""任务执行状态"""
SUCCESS = "success" # 同步完成,立即成功
FAILED = "failed" # 同步失败
IN_PROGRESS = "in_progress" # 异步任务进行中
SKIPPED = "skipped" # 跳过(无实际更新或不符合更新条件)
class TaskResult:
"""
Handler 执行结果
用于封装 create/update/delete 操作的返回值。
适用于所有 DataSourceAPI、JSONL 等)。
支持批量操作时通过 node_id 定位结果。
"""
def __init__(
self,
status: TaskStatus,
node_id: Optional[str] = None,
data_id: Optional[str] = None,
task_id: Optional[str] = None,
push_id: Optional[str] = None,
error: Optional[str] = None,
sync_log: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None
):
"""
Args:
status: 任务状态(SUCCESS/FAILED/IN_PROGRESS
node_id: 关联的节点 ID(批量操作时用于定位节点)
data_id: 成功时返回的业务 IDCREATE 操作必需)
task_id: 异步任务 IDstatus=IN_PROGRESS 时必需)
push_id: 推送请求 ID(用于统一 push/poll 跟踪)
error: 失败原因(status=FAILED 时必需)
sync_log: Debug/Info级别日志(SKIPPED时使用)
metadata: 其他元数据
"""
normalized_metadata = dict(metadata or {})
if push_id is None:
raw_push_id = normalized_metadata.get("push_id")
if isinstance(raw_push_id, str) and raw_push_id:
push_id = raw_push_id
elif push_id:
normalized_metadata.setdefault("push_id", push_id)
self.status = status
self.node_id = node_id
self.data_id = data_id
self.task_id = task_id
self.push_id = push_id
self.error = error
self.sync_log = sync_log
self.metadata = normalized_metadata
@classmethod
def success(
cls,
node_id: Optional[str] = None,
data_id: Optional[str] = None,
push_id: Optional[str] = None,
**metadata,
) -> "TaskResult":
"""创建成功结果"""
return cls(TaskStatus.SUCCESS, node_id=node_id, data_id=data_id, push_id=push_id, metadata=metadata)
@classmethod
def failed(
cls,
node_id: Optional[str] = None,
error: str = "",
push_id: Optional[str] = None,
**metadata,
) -> "TaskResult":
"""创建失败结果"""
return cls(TaskStatus.FAILED, node_id=node_id, error=error, push_id=push_id, metadata=metadata)
@classmethod
def skipped(
cls,
node_id: Optional[str] = None,
reason: str = "",
push_id: Optional[str] = None,
**metadata,
) -> "TaskResult":
"""创建跳过结果(无实际更新等)"""
return cls(TaskStatus.SKIPPED, node_id=node_id, sync_log=reason, push_id=push_id, metadata=metadata)
@classmethod
def in_progress(
cls,
node_id: Optional[str] = None,
task_id: str = "",
push_id: Optional[str] = None,
**metadata,
) -> "TaskResult":
"""创建进行中结果"""
return cls(TaskStatus.IN_PROGRESS, node_id=node_id, task_id=task_id, push_id=push_id, metadata=metadata)
def __repr__(self) -> str:
parts = [f"status={self.status.value}"]
if self.node_id:
parts.append(f"node_id={self.node_id}")
if self.data_id:
parts.append(f"data_id={self.data_id}")
if self.task_id:
parts.append(f"task_id={self.task_id}")
if self.push_id:
parts.append(f"push_id={self.push_id}")
if self.error:
parts.append(f"error={self.error!r}")
return f"TaskResult({', '.join(parts)})"
+32
View File
@@ -0,0 +1,32 @@
"""
Domain 模块
导入所有 domain 的 register 模块,触发 DomainRegistry 注册。
"""
# 导入所有 domain register,触发注册
from .company import register as _company_register
from .user import register as _user_register
from .supplier import register as _supplier_register
from .project import register as _project_register
from .project_detail import register as _project_detail_register
from .contract import register as _contract_register
from .construction import register as _construction_register
from .construction_task_detail import register as _construction_task_detail_register
from .material import register as _material_register
from .material_detail import register as _material_detail_register
from .contract_settlement import register as _contract_settlement_register
from .contract_settlement_detail import register as _contract_settlement_detail_register
from .personnel import register as _personnel_register
from .personnel_detail import register as _personnel_detail_register
from .preparation import register as _preparation_register
from .production import register as _production_register
from .lar import register as _lar_register
from .lar_change import register as _lar_change_register
from .units import register as _units_register
from .contract_change import register as _contract_change_register
from .attachment import register as _attachment_register
# 所有组件通过 DomainRegistry 访问,不再单独导出
__all__ = []
@@ -0,0 +1,8 @@
"""
Attachment Domain
"""
from .sync_node import AttachmentSyncNode
from .sync_strategy import AttachmentSyncStrategy
from .jsonl_handler import AttachmentJsonlHandler
__all__ = ["AttachmentSyncNode", "AttachmentSyncStrategy", "AttachmentJsonlHandler"]
@@ -0,0 +1,102 @@
"""
Attachment API Handler
职责:
- 加载附件列表(依赖 Project)
- 附件创建/更新/删除(批量接口)
"""
from typing import List, Dict, Any
from ...datasource.api.handler import BaseApiHandler
from ...datasource.task_result import TaskResult
from ...common.sync_node import SyncNode
from .sync_node import AttachmentSyncNode, AttachmentSchema
class AttachmentApiHandler(BaseApiHandler):
"""附件 API Handler"""
def __init__(self):
super().__init__()
self._node_type = "attachment"
self._node_class = AttachmentSyncNode
self._schema = AttachmentSchema
async def load(self) -> List[SyncNode]:
"""加载附件列表(依赖 Project"""
projects = self._collection.filter(node_type="project")
project_ids = [p.data_id for p in projects if p.data_id]
if not project_ids:
return []
all_attachments = []
for project_id in project_ids:
try:
attachments = await api_get_attachment_list(self.api_client, project_id)
all_attachments.extend(attachments)
except Exception as e:
print(f"❌ Failed to load attachments for project {project_id}: {e}")
return all_attachments
async def create_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
"""批量创建附件(已禁用)"""
return [
TaskResult.failed(node_id=node.node_id, error="Attachment push disabled")
for node in nodes
]
async def update_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
"""批量更新附件(已禁用)"""
return [
TaskResult.failed(node_id=node.node_id, error="Attachment push disabled")
for node in nodes
]
async def delete_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
"""批量删除附件(已禁用)"""
return [
TaskResult.failed(node_id=node.node_id, error="Attachment push disabled")
for node in nodes
]
async def poll_tasks(self, task_ids: List[str]) -> Dict[str, TaskResult]:
"""轮询异步任务状态(占位)"""
return await super().poll_tasks(task_ids)
def _create_node(self, data: Dict[str, Any]) -> SyncNode:
node = self._create_basic_node(data, depend_ids=[])
if data.get("project_id"):
node.context["project_id"] = data["project_id"]
return node
# ========== 静态 API 函数 ==========
async def api_get_attachment_list(client, project_id: str) -> List[Dict[str, Any]]:
response = await client.request(
method="GET",
endpoint="/attachment/list",
params={"project_id": project_id}
)
if response.get("code") != 200:
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
return response.get("list", [])
async def api_create_attachment(client, data: Dict[str, Any], push_id: str, biz_id: str) -> Dict[str, Any]:
request_data = {"push_id": push_id, "biz_id": biz_id, **data}
response = await client.request(method="POST", endpoint="/attachment/create", data=request_data)
if response.get("code") != 200:
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
return response
async def api_delete_attachment(client, attachment_id: str, push_id: str, biz_id: str) -> None:
request_data = {"push_id": push_id, "biz_id": biz_id, "id": attachment_id}
response = await client.request(method="DELETE", endpoint="/attachment/delete", data=request_data)
if response.get("code") != 200:
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
@@ -0,0 +1,12 @@
"""
Attachment JSONL Handler
"""
from typing import Dict, Any
from ...datasource import BaseJsonlHandler, JsonlDataSource
from .sync_node import AttachmentSyncNode
class AttachmentJsonlHandler(BaseJsonlHandler):
"""附件 JSONL Handler"""
def __init__(self, datasource: JsonlDataSource):
super().__init__(datasource, "attachment", AttachmentSyncNode, Dict[str, Any])
@@ -0,0 +1,16 @@
"""Attachment domain registration"""
from ...common.registry import DomainRegistry
from .sync_node import AttachmentSyncNode, AttachmentSchema
from .sync_strategy import AttachmentSyncStrategy
from .jsonl_handler import AttachmentJsonlHandler
from .api_handler import AttachmentApiHandler
# 注册 attachment domain
DomainRegistry.register(
node_type="attachment",
schema=AttachmentSchema,
node_class=AttachmentSyncNode,
strategy_class=AttachmentSyncStrategy,
jsonl_handler_class=AttachmentJsonlHandler,
api_handler_class=AttachmentApiHandler,
)
@@ -0,0 +1,17 @@
"""
Attachment Domain
"""
from typing import Dict, Any
from pydantic import BaseModel, ConfigDict
from ...common import SyncNode
class AttachmentSchema(BaseModel):
"""允许任意附件字段的 schema"""
model_config = ConfigDict(extra="allow")
class AttachmentSyncNode(SyncNode[Dict[str, Any]]):
"""附件同步节点"""
node_type = "attachment"
schema = AttachmentSchema
@@ -0,0 +1,20 @@
from ...sync_system.strategy import DefaultSyncStrategy
from ...sync_system.config import StrategyConfig, OrphanAction, UpdateDirection
from .sync_node import AttachmentSchema
class AttachmentSyncStrategy(DefaultSyncStrategy[AttachmentSchema]):
"""
Attachment 同步策略(仅作为依赖,不执行同步动作)。
"""
schema = AttachmentSchema
default_config = StrategyConfig(
auto_bind=False,
auto_bind_fields=[],
depend_fields={},
local_orphan_action=OrphanAction.NONE,
remote_orphan_action=OrphanAction.NONE,
update_direction=UpdateDirection.PUSH,
)
@@ -0,0 +1,6 @@
"""Company domain"""
from .sync_node import CompanySyncNode
from .sync_strategy import CompanySyncStrategy
from .jsonl_handler import CompanyJsonlHandler
__all__ = ["CompanySyncNode", "CompanySyncStrategy", "CompanyJsonlHandler"]
@@ -0,0 +1,131 @@
"""
Company API Handler (只读)
职责:
- 加载公司列表
- 不支持创建/更新/删除
"""
from typing import List, Dict, Any
from ...datasource.api.handler import BaseApiHandler
from ...datasource.task_result import TaskResult
from ...common.sync_node import SyncNode
from .sync_node import CompanySyncNode
from schemas.company.company import CompanyResponse
class CompanyApiHandler(BaseApiHandler):
"""公司 API Handler (只读)"""
def __init__(self):
super().__init__()
self._node_type = "company"
self._node_class = CompanySyncNode
self._schema = CompanyResponse
async def load(self) -> List[SyncNode]:
"""加载公司列表(支持分页)"""
try:
nodes = []
page = 1
page_size = 1000
while True:
# 获取一页数据
companies, total = await api_get_company_list(
self.api_client,
page=page,
page_size=page_size
)
# 没有数据了,退出循环
if not companies:
break
# 转换为节点
for company_model in companies:
company_data = company_model.model_dump()
nodes.append(self._create_node(company_data))
# 检查是否还有下一页
if page * page_size >= total:
break
page += 1
return nodes
except Exception as e:
print(f"❌ Failed to load companies: {e}")
import traceback
traceback.print_exc()
return []
async def create_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
"""不支持创建"""
return [
TaskResult.failed(node_id=node.node_id, error="Company creation not supported")
for node in nodes
]
async def update_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
"""不支持更新"""
return [
TaskResult.failed(node_id=node.node_id, error="Company update not supported")
for node in nodes
]
async def delete_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
"""不支持删除"""
return [
TaskResult.failed(node_id=node.node_id, error="Company deletion not supported")
for node in nodes
]
async def poll_tasks(self, task_ids: List[str]) -> Dict[str, TaskResult]:
"""轮询异步任务状态(占位)"""
return await super().poll_tasks(task_ids)
def _create_node(self, data: Dict[str, Any]) -> SyncNode:
return self._create_basic_node(data, depend_ids=[])
# ========== 静态 API 函数 ==========
async def api_get_company_list(client, page: int = 1, page_size: int = 100) -> tuple[List[CompanyResponse], int]:
"""
获取公司列表 GET /company/list
Args:
client: API客户端
page: 页码,从1开始
page_size: 每页大小
Returns:
tuple[List[CompanyResponse], int]: (验证过的公司列表, 总数)
"""
from pydantic import ValidationError
response = await client.request(
method="GET",
endpoint="/company/list",
params={"page": page, "page_size": page_size}
)
if response.get("code") != 200:
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
# V2 API 返回格式: {code: 200, data: {list: [...], total: 123, ...}}
data = response.get("data", {})
items = data.get("list", [])
total = data.get("total", 0)
# 验证每个返回项
validated_items = []
for item in items:
try:
validated_item = CompanyResponse.model_validate(item)
validated_items.append(validated_item)
except ValidationError as e:
print(f"⚠️ Skipping invalid company item: {e}")
continue
return validated_items, total
@@ -0,0 +1,11 @@
"""Company Domain - JSONL Handler"""
from ...datasource import BaseJsonlHandler, JsonlDataSource
from .sync_node import CompanySyncNode
from schemas.company.company import CompanyResponse
class CompanyJsonlHandler(BaseJsonlHandler):
"""公司 JSONL Handler"""
def __init__(self, datasource: JsonlDataSource):
super().__init__(datasource, "company", CompanySyncNode, CompanyResponse)
@@ -0,0 +1,17 @@
"""Company domain registration"""
from ...common.registry import DomainRegistry
from schemas.company.company import CompanyResponse
from .sync_node import CompanySyncNode
from .sync_strategy import CompanySyncStrategy
from .jsonl_handler import CompanyJsonlHandler
from .api_handler import CompanyApiHandler
# 注册 company domain
DomainRegistry.register(
node_type="company",
schema=CompanyResponse,
node_class=CompanySyncNode,
strategy_class=CompanySyncStrategy,
jsonl_handler_class=CompanyJsonlHandler,
api_handler_class=CompanyApiHandler,
)
@@ -0,0 +1,11 @@
from ...common.sync_node import SyncNode
from schemas.company.company import CompanyResponse
class CompanySyncNode(SyncNode[CompanyResponse]):
"""公司同步节点"""
node_type = "company"
schema = CompanyResponse
def __init__(self, **kwargs):
super().__init__(**kwargs)
@@ -0,0 +1,29 @@
from ...sync_system.strategy import DefaultSyncStrategy
from ...sync_system.config import StrategyConfig, OrphanAction, UpdateDirection
from schemas.company.company import CompanyResponse
class CompanySyncStrategy(DefaultSyncStrategy[CompanyResponse]):
"""
Company 同步策略
业务规则:
1. 使用 (code) 作为业务唯一键
2. 支持自动绑定
3. 默认配置为日常推送模式(本地创建推送到远程,不拉取远程孤儿)
schema 已在类定义中设置,禁止在初始化时传入其他 schema。
"""
# 类变量:schema 固定为 ContractResponse
schema = CompanyResponse
# 类变量:默认配置
default_config = StrategyConfig(
auto_bind=True,
auto_bind_fields=["credit_code"],
depend_fields={},
local_orphan_action=OrphanAction.NONE,
remote_orphan_action=OrphanAction.CREATE_LOCAL,
update_direction=UpdateDirection.PULL,
)
@@ -0,0 +1,6 @@
"""Construction domain"""
from .sync_node import ConstructionTaskSyncNode
from .sync_strategy import ConstructionTaskSyncStrategy
from .jsonl_handler import ConstructionTaskJsonlHandler
__all__ = ["ConstructionTaskSyncNode", "ConstructionTaskSyncStrategy", "ConstructionTaskJsonlHandler"]
@@ -0,0 +1,321 @@
"""
Construction Task API Handler (V2)
职责:
- 加载施工任务列表(依赖 Project)
- 施工任务创建/更新/删除(批量接口)
"""
from typing import List, Dict, Any
from ...datasource.api.handler import BaseApiHandler
from ...datasource.task_result import TaskResult
from ...common.sync_node import SyncNode
from ...datasource.api.errors import (
ERROR_NODE_DATA_NONE,
ERROR_VALIDATION_FAILED,
ERROR_PROJECT_ID_NOT_FOUND
)
from .sync_node import ConstructionTaskSyncNode
from schemas.construction.construction import (
ConstructionResponse,
ConstructionCreate,
ConstructionUpdate,
ConstructionPlanUpdate
)
class ConstructionTaskApiHandler(BaseApiHandler):
"""施工任务 API Handler"""
def __init__(self):
super().__init__()
self._node_type = "construction_task"
self._node_class = ConstructionTaskSyncNode
self._schema = ConstructionResponse
self.update_schemas = [ConstructionCreate, ConstructionUpdate, ConstructionPlanUpdate]
async def load(self) -> List[SyncNode]:
"""加载施工任务列表(依赖 Project)"""
projects = self._collection.filter(node_type="project")
project_ids = [p.data_id for p in projects if p.data_id]
if not project_ids:
return []
all_tasks = []
for project_id in project_ids:
try:
tasks = await api_get_construction_task_list(self.api_client, project_id)
for task in tasks:
# 已验证的 Pydantic model,转换为 dict
task_data = task.model_dump()
all_tasks.append(self._create_node(task_data))
except Exception as e:
print(f"❌ Failed to load construction tasks for project {project_id}: {e}")
return all_tasks
async def create_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
"""批量创建施工任务"""
results = []
for node in nodes:
data = node.get_data()
if not data:
results.append(TaskResult.failed(node_id=node.node_id, error=ERROR_NODE_DATA_NONE))
continue
project_id = node.context.get("project_id") or data.get("project_id")
if not project_id:
results.append(TaskResult.failed(node_id=node.node_id, error=ERROR_PROJECT_ID_NOT_FOUND))
continue
push_id = self._generate_push_id()
try:
# 验证并转换为 Pydantic model
from pydantic import ValidationError
create_data = {"project_id": project_id, **data}
try:
create_model = ConstructionCreate.model_validate(create_data)
except ValidationError as e:
results.append(TaskResult.failed(node_id=node.node_id, error=f"{ERROR_VALIDATION_FAILED}: {e}"))
continue
response = await api_create_construction_task(self.api_client, create_model, push_id)
if self.poll_mode == "sync":
created_id = self._extract_created_id_from_response(response)
if not created_id:
results.append(
TaskResult.failed(
node_id=node.node_id,
error="Missing created_id in create response"
)
)
continue
results.append(TaskResult.success(node_id=node.node_id, data_id=created_id))
else:
results.append(TaskResult.in_progress(node_id=node.node_id, task_id=push_id))
except Exception as e:
results.append(TaskResult.failed(node_id=node.node_id, error=str(e)))
return results
async def update_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
"""
批量更新施工任务
支持两种更新类型:
1. ConstructionUpdate - 基础信息更新 (PUT /construction/update)
业务逻辑:如果show_name字段不同,那么仅当type == 'QT'时才更新
2. ConstructionPlanUpdate - 计划信息更新 (PUT /construction/plan)
更新逻辑:
- 对每种 schema,将原始数据和新数据都转换为 schema 格式
- dump 成字典后比较
- 如果有差异且满足业务条件,调用对应 API
"""
results = []
for node in nodes:
data = node.get_data()
if not data:
results.append(TaskResult.failed(node_id=node.node_id, error="node data is None"))
continue
origin_data = node.get_origin_data() or {}
biz_id = node.data_id or data.get("id")
if not biz_id:
results.append(TaskResult.failed(node_id=node.node_id, error="Missing biz_id for update"))
continue
node_updated = False
error = None
schema_status_lines: List[str] = []
# 1. 基础信息更新 (ConstructionUpdate)
# 业务逻辑:如果 show_name 字段不同,那么仅当 type == 'QT' 时才更新
base_new_schema_error = self._validate_schema(data, ConstructionUpdate)
base_changed_fields = self._schema_changed_fields(ConstructionUpdate, data, origin_data)
base_update_called = False
base_update_error: str | None = None
if base_new_schema_error:
base_update_error = base_new_schema_error
elif base_changed_fields:
should_update = True
if "show_name" in base_changed_fields:
task_type = data.get("task_type") or data.get("type")
if task_type != "QT":
should_update = False
if should_update:
try:
update_model = ConstructionUpdate.model_validate(data)
push_id = self._generate_push_id()
await api_update_construction_task(self.api_client, update_model, push_id, biz_id)
node_updated = True
base_update_called = True
except Exception as e:
error = str(e)
base_update_error = str(e)
else:
base_update_error = "show_name changed but task_type != QT"
schema_status_lines.append(
self._build_update_schema_status(
schema_name="ConstructionUpdate",
schema=ConstructionUpdate,
new_data=data,
origin_data=origin_data,
will_update=base_update_called,
update_error=base_update_error,
)
)
# 2. 计划信息更新 (ConstructionPlanUpdate)
plan_new_schema_error = self._validate_schema(data, ConstructionPlanUpdate)
plan_changed_fields = self._schema_changed_fields(ConstructionPlanUpdate, data, origin_data)
plan_update_called = False
plan_update_error: str | None = None
if not error:
if plan_new_schema_error:
plan_update_error = plan_new_schema_error
elif plan_changed_fields:
try:
plan_model = ConstructionPlanUpdate.model_validate(data)
plan_dict = plan_model.model_dump(exclude_unset=True)
push_id = self._generate_push_id()
await api_update_construction_task_plan(self.api_client, plan_dict, push_id, biz_id)
node_updated = True
plan_update_called = True
except Exception as e:
error = str(e)
plan_update_error = str(e)
schema_status_lines.append(
self._build_update_schema_status(
schema_name="ConstructionPlanUpdate",
schema=ConstructionPlanUpdate,
new_data=data,
origin_data=origin_data,
will_update=plan_update_called,
update_error=plan_update_error,
)
)
for line in schema_status_lines:
node.append_log(f"UPDATE_SCHEMA: {line}")
# 汇总结果
if error:
results.append(TaskResult.failed(node_id=node.node_id, error=error))
elif node_updated:
results.append(TaskResult.success(node_id=node.node_id))
else:
# 没有任何字段需要更新
results.append(TaskResult.skipped(
node_id=node.node_id,
reason="No updatable fields changed | " + "; ".join(schema_status_lines)
))
return results
async def delete_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
"""批量删除施工任务"""
results = []
for node in nodes:
push_id = self._generate_push_id()
biz_id = node.data_id
if not biz_id:
results.append(TaskResult.failed(node_id=node.node_id, error="Missing biz_id for delete"))
continue
try:
await api_delete_construction_task(self.api_client, biz_id, push_id)
results.append(TaskResult.success(node_id=node.node_id))
except Exception as e:
results.append(TaskResult.failed(node_id=node.node_id, error=str(e)))
return results
async def poll_tasks(self, task_ids: List[str]) -> Dict[str, TaskResult]:
"""轮询异步任务状态"""
return await super().poll_tasks(task_ids)
def _create_node(self, data: Dict[str, Any]) -> SyncNode:
node = self._create_basic_node(data, depend_ids=[])
if data.get("project_id"):
node.context["project_id"] = data["project_id"]
node.context["task_id"] = node.data_id
return node
# ========== 静态 API 函数 ==========
async def api_get_construction_task_list(client, project_id: str) -> List[ConstructionResponse]:
response = await client.request(
method="GET",
endpoint="/construction/list",
params={"project_id": project_id}
)
if response.get("code") != 200:
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
items = response.get("data", [])
# 验证每个返回项
from pydantic import ValidationError
validated_items = []
for item in items:
try:
validated_item = ConstructionResponse.model_validate(item)
validated_items.append(validated_item)
except ValidationError as e:
print(f"⚠️ Skipping invalid item: {e}")
continue
return validated_items
async def api_create_construction_task(client, data: ConstructionCreate, push_id: str) -> Dict[str, Any]:
request_data = {"push_id": push_id, "data": data.model_dump()}
response = await client.request(method="POST", endpoint="/construction/create", data=request_data)
if response.get("code") != 200:
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
return response
async def api_update_construction_task(client, data: ConstructionUpdate, push_id: str, biz_id: str) -> None:
request_data = {"push_id": push_id, "biz_id": biz_id, "data": data.model_dump(exclude_unset=True)}
response = await client.request(method="PUT", endpoint="/construction/update", data=request_data)
if response.get("code") != 200:
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
async def api_get_construction_task(client, task_id: str) -> Dict[str, Any]:
"""GET /construction - 获取单个施工任务"""
response = await client.request(
method="GET",
endpoint="/construction",
params={"task_id": task_id}
)
if response.get("code") != 200:
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
return response.get("data", {})
async def api_update_construction_task_plan(client, data: Dict[str, Any], push_id: str, biz_id: str) -> None:
"""PUT /construction/plan - 更新施工计划"""
request_data = {"push_id": push_id, "biz_id": biz_id, "data": data}
response = await client.request(method="PUT", endpoint="/construction/plan", data=request_data)
if response.get("code") != 200:
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
async def api_delete_construction_task(client, task_id: str, push_id: str) -> None:
request_data = {"push_id": push_id, "biz_id": task_id}
response = await client.request(method="DELETE", endpoint="/construction/delete", data=request_data)
if response.get("code") != 200:
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
@@ -0,0 +1,10 @@
"""Construction Task Domain - JSONL Handler"""
from ...datasource import BaseJsonlHandler, JsonlDataSource
from .sync_node import ConstructionTaskSyncNode
from schemas.construction.construction import ConstructionResponse
class ConstructionTaskJsonlHandler(BaseJsonlHandler):
"""施工任务 JSONL Handler"""
def __init__(self, datasource: JsonlDataSource):
super().__init__(datasource, "construction_task", ConstructionTaskSyncNode, ConstructionResponse)
@@ -0,0 +1,17 @@
"""Construction task domain registration"""
from ...common.registry import DomainRegistry
from schemas.construction.construction import ConstructionResponse
from .sync_node import ConstructionTaskSyncNode
from .sync_strategy import ConstructionTaskSyncStrategy
from .jsonl_handler import ConstructionTaskJsonlHandler
from .api_handler import ConstructionTaskApiHandler
# 注册 construction_task domain
DomainRegistry.register(
node_type="construction_task",
schema=ConstructionResponse,
node_class=ConstructionTaskSyncNode,
strategy_class=ConstructionTaskSyncStrategy,
jsonl_handler_class=ConstructionTaskJsonlHandler,
api_handler_class=ConstructionTaskApiHandler,
)
@@ -0,0 +1,11 @@
from ...common.sync_node import SyncNode
from schemas.construction.construction import ConstructionResponse
class ConstructionTaskSyncNode(SyncNode[ConstructionResponse]):
"""施工任务同步节点"""
node_type = "construction_task"
schema = ConstructionResponse
def __init__(self, **kwargs):
super().__init__(**kwargs)
@@ -0,0 +1,20 @@
from ...sync_system.strategy import DefaultSyncStrategy
from ...sync_system.config import StrategyConfig, OrphanAction, UpdateDirection
from schemas.construction.construction import ConstructionResponse
class ConstructionTaskSyncStrategy(DefaultSyncStrategy[ConstructionResponse]):
"""
Construction Task 同步策略(合同子业务)。
"""
schema = ConstructionResponse
default_config = StrategyConfig(
auto_bind=True,
auto_bind_fields=["contract_id", "task_type"],
depend_fields={"contract_id": "contract", "project_id": "project"},
local_orphan_action=OrphanAction.CREATE_REMOTE,
remote_orphan_action=OrphanAction.NONE,
update_direction=UpdateDirection.PUSH,
)
@@ -0,0 +1,8 @@
"""
Construction Task Detail Domain
"""
from .sync_node import ConstructionTaskDetailSyncNode
from .sync_strategy import ConstructionTaskDetailSyncStrategy
from .jsonl_handler import ConstructionTaskDetailJsonlHandler
__all__ = ["ConstructionTaskDetailSyncNode", "ConstructionTaskDetailSyncStrategy", "ConstructionTaskDetailJsonlHandler"]
@@ -0,0 +1,283 @@
"""
Construction Task Detail API Handler (V2)
职责:
- 加载施工明细列表(依赖 ConstructionTask
- 施工明细创建/更新/删除(批量接口)
"""
from typing import List, Dict, Any
from ...datasource.api.handler import BaseApiHandler
from ...datasource.task_result import TaskResult
from ...common.sync_node import SyncNode
from ...datasource.api.errors import ERROR_NODE_DATA_NONE, ERROR_VALIDATION_FAILED, ERROR_ID_NOT_FOUND
from .sync_node import ConstructionTaskDetailSyncNode
from schemas.construction.construction_detail import (
ConstructionDetailResponse,
ConstructionDetailCreate,
ConstructionDetailUpdate
)
class ConstructionTaskDetailApiHandler(BaseApiHandler):
"""施工明细 API Handler"""
def __init__(self):
super().__init__()
self._node_type = "construction_task_detail"
self._node_class = ConstructionTaskDetailSyncNode
self._schema = ConstructionDetailResponse
self.update_schemas = [ConstructionDetailCreate, ConstructionDetailUpdate]
async def load(self) -> List[SyncNode]:
"""加载施工明细列表(依赖 ConstructionTask"""
tasks = self._collection.filter(node_type="construction_task")
task_ids = [t.data_id for t in tasks if t.data_id]
if not task_ids:
return []
all_details = []
for task_id in task_ids:
try:
details = await api_get_construction_task_detail_list(self.api_client, task_id)
for detail_model in details:
# 已验证的 Pydantic model,转换为 dict
detail_data = detail_model.model_dump()
all_details.append(self._create_node(detail_data))
except Exception as e:
print(f"❌ Failed to load construction details for task {task_id}: {e}")
return all_details
async def create_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
"""批量创建施工明细"""
from pydantic import ValidationError
results = []
for node in nodes:
data = node.get_data()
if not data:
results.append(TaskResult.failed(node_id=node.node_id, error=ERROR_NODE_DATA_NONE))
continue
# 从data的parent_id获取task_id
task_id = data.get("parent_id")
if not task_id:
results.append(TaskResult.failed(node_id=node.node_id, error=ERROR_ID_NOT_FOUND))
continue
# 验证并转换为 Pydantic model
try:
create_data = {"task_id": task_id, **data}
create_model = ConstructionDetailCreate.model_validate(create_data)
except ValidationError as e:
results.append(TaskResult.failed(node_id=node.node_id, error=f"{ERROR_VALIDATION_FAILED}: {e}"))
continue
push_id = self._generate_push_id()
try:
response = await api_create_construction_task_detail(self.api_client, create_model, push_id)
if self.poll_mode == "sync":
created_id = self._extract_created_id_from_response(response)
if not created_id:
results.append(
TaskResult.failed(
node_id=node.node_id,
error="Missing created_id in create response"
)
)
continue
results.append(TaskResult.success(node_id=node.node_id, data_id=created_id))
else:
results.append(TaskResult.in_progress(node_id=node.node_id, task_id=push_id))
except Exception as e:
results.append(TaskResult.failed(node_id=node.node_id, error=str(e)))
return results
async def update_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
"""
批量更新施工明细
按 task_id 分组,每组使用同一个 push_id 批量提交
"""
from pydantic import ValidationError
from collections import defaultdict
results = []
# 按 task_id 分组
groups: Dict[str, List[tuple[SyncNode, Dict[str, Any]]]] = defaultdict(list)
for node in nodes:
data = node.get_data()
if not data:
results.append(TaskResult.failed(node_id=node.node_id, error=ERROR_NODE_DATA_NONE))
continue
origin_data = node.get_origin_data() or {}
# 使用 _get_schema_diff 检查差异并打印日志
diff_fields = self._get_schema_diff(ConstructionDetailUpdate, data, origin_data, node.node_id)
if not diff_fields:
from ...common.types import SyncAction
results.append(TaskResult.skipped(node_id=node.node_id, reason="No updatable fields changed"))
continue
# 获取 task_idparent_id
task_id = data.get("parent_id") or node.context.get("task_id")
if not task_id:
results.append(TaskResult.failed(node_id=node.node_id, error="Missing task_id (parent_id)"))
continue
# 需要完整的 schema 数据(包括必填字段)
update_data = self._filter_update_data(data, origin_data, ConstructionDetailUpdate)
# 验证并转换为 Pydantic model
try:
update_model = ConstructionDetailUpdate.model_validate(update_data)
groups[task_id].append((node, update_model))
except ValidationError as e:
results.append(TaskResult.failed(node_id=node.node_id, error=f"{ERROR_VALIDATION_FAILED}: {e}"))
# 逐组批量更新
for task_id, group_items in groups.items():
push_id = self._generate_push_id()
# 准备批量数据
detail_list = [model.model_dump(mode='json', exclude_unset=True) for node, model in group_items]
# 使用 parent_idtask_id)作为 biz_id
biz_id = task_id
try:
# 批量调用 API
await api_update_construction_task_detail(
self.api_client,
{"detail_list": detail_list},
push_id,
biz_id,
)
# 整组成功
for node, _ in group_items:
if self.poll_mode == "sync":
results.append(TaskResult.success(node_id=node.node_id))
else:
results.append(TaskResult.in_progress(node_id=node.node_id, task_id=push_id))
except Exception as e:
# 整组失败
error_msg = str(e)
for node, _ in group_items:
results.append(TaskResult.failed(node_id=node.node_id, error=error_msg))
return results
async def delete_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
"""批量删除施工明细"""
results = []
for node in nodes:
push_id = self._generate_push_id()
biz_id = node.data_id
if not biz_id:
results.append(TaskResult.failed(node_id=node.node_id, error="Missing biz_id for delete"))
continue
try:
await api_delete_construction_task_detail(self.api_client, biz_id, push_id)
results.append(TaskResult.success(node_id=node.node_id))
except Exception as e:
results.append(TaskResult.failed(node_id=node.node_id, error=str(e)))
return results
async def poll_tasks(self, task_ids: List[str]) -> Dict[str, TaskResult]:
"""轮询异步任务状态"""
return await super().poll_tasks(task_ids)
def _create_node(self, data: Dict[str, Any]) -> SyncNode:
node = self._create_basic_node(data, depend_ids=[])
if data.get("task_id"):
node.context["task_id"] = data["task_id"]
return node
# ========== 静态 API 函数 ==========
async def api_get_construction_task_detail_list(client, task_id: str) -> List[ConstructionDetailResponse]:
"""
获取施工明细列表 GET /construction_task_detail/list
Args:
client: API 客户端
task_id: 施工任务ID
Returns:
List[ConstructionDetailResponse]: 验证过的明细列表
"""
from pydantic import ValidationError
response = await client.request(
method="GET",
endpoint="/construction/detail/list",
params={"task_id": task_id}
)
if response.get("code") != 200:
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
items = response.get("data", [])
# 验证每个返回项
validated_items = []
for item in items:
try:
validated_item = ConstructionDetailResponse.model_validate(item)
validated_items.append(validated_item)
except ValidationError as e:
print(f"⚠️ Skipping invalid detail item: {e}")
continue
return validated_items
async def api_create_construction_task_detail(client, data: ConstructionDetailCreate, push_id: str) -> Dict[str, Any]:
"""
创建施工明细 POST /construction/detail/create
Args:
client: API 客户端
data: 创建数据(Pydantic model
push_id: 推送ID
biz_id: 业务ID
"""
request_data = {"push_id": push_id, "data": data.model_dump(mode='json')}
response = await client.request(method="POST", endpoint="/construction/detail/create", data=request_data)
if response.get("code") != 200:
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
return response
async def api_update_construction_task_detail(client, data: Dict[str, Any], push_id: str, biz_id: str) -> None:
"""
更新施工明细 PUT /construction/detail/update
Args:
client: API 客户端
data: 更新数据(Pydantic model
push_id: 推送ID
biz_id: 业务ID
"""
request_data = {"push_id": push_id, "biz_id": biz_id, "data": data}
response = await client.request(method="PUT", endpoint="/construction/detail/update", data=request_data)
if response.get("code") != 200:
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
async def api_delete_construction_task_detail(client, detail_id: str, push_id: str) -> None:
request_data = {"push_id": push_id, "biz_id": detail_id}
response = await client.request(method="DELETE", endpoint="/construction/detail/delete", data=request_data)
if response.get("code") != 200:
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
@@ -0,0 +1,12 @@
"""
Construction Task Detail JSONL Handler
"""
from ...datasource import BaseJsonlHandler, JsonlDataSource
from .sync_node import ConstructionTaskDetailSyncNode
from schemas.construction.construction_detail import ConstructionDetailResponse
class ConstructionTaskDetailJsonlHandler(BaseJsonlHandler):
"""施工任务明细 JSONL Handler"""
def __init__(self, datasource: JsonlDataSource):
super().__init__(datasource, "construction_task_detail", ConstructionTaskDetailSyncNode, ConstructionDetailResponse)
@@ -0,0 +1,17 @@
"""Construction task detail domain registration"""
from ...common.registry import DomainRegistry
from schemas.construction.construction_detail import ConstructionDetailResponse
from .sync_node import ConstructionTaskDetailSyncNode
from .sync_strategy import ConstructionTaskDetailSyncStrategy
from .jsonl_handler import ConstructionTaskDetailJsonlHandler
from .api_handler import ConstructionTaskDetailApiHandler
# 注册 construction_task_detail domain
DomainRegistry.register(
node_type="construction_task_detail",
schema=ConstructionDetailResponse,
node_class=ConstructionTaskDetailSyncNode,
strategy_class=ConstructionTaskDetailSyncStrategy,
jsonl_handler_class=ConstructionTaskDetailJsonlHandler,
api_handler_class=ConstructionTaskDetailApiHandler,
)
@@ -0,0 +1,11 @@
"""
Construction Task Detail Domain
"""
from ...common import SyncNode
from schemas.construction.construction_detail import ConstructionDetailResponse
class ConstructionTaskDetailSyncNode(SyncNode[ConstructionDetailResponse]):
"""施工任务明细同步节点"""
node_type = "construction_task_detail"
schema = ConstructionDetailResponse
@@ -0,0 +1,20 @@
from ...sync_system.strategy import DefaultSyncStrategy
from ...sync_system.config import StrategyConfig, OrphanAction, UpdateDirection
from schemas.construction.construction_detail import ConstructionDetailResponse
class ConstructionTaskDetailSyncStrategy(DefaultSyncStrategy[ConstructionDetailResponse]):
"""
Construction Task Detail 同步策略(合同子业务明细)。
"""
schema = ConstructionDetailResponse
default_config = StrategyConfig(
auto_bind=True,
auto_bind_fields=["parent_id", "date"],
depend_fields={"parent_id": "construction_task"},
local_orphan_action=OrphanAction.CREATE_REMOTE,
remote_orphan_action=OrphanAction.NONE,
update_direction=UpdateDirection.PUSH,
)
@@ -0,0 +1,7 @@
"""Contract domain"""
from .sync_node import ContractSyncNode
from .sync_strategy import ContractSyncStrategy
from .jsonl_handler import ContractJsonlHandler
__all__ = ["ContractSyncNode", "ContractSyncStrategy", "ContractJsonlHandler"]
@@ -0,0 +1,329 @@
"""
Contract API Handler
职责:
- 加载合同列表(依赖 Project)
- 合同创建(POST /contract/create,异步)
- 合同更新(PUT /contract/update,同步)
- 合同删除(DELETE /contract/delete,同步)
"""
from typing import List, Dict, Any, Optional
from ...datasource.api.handler import BaseApiHandler
from ...datasource.task_result import TaskResult
from ...common.sync_node import SyncNode
from ...datasource.api.client import ApiClient
from .sync_node import ContractSyncNode
from schemas.contract.contract import ContractResponse, ContractCreate, ContractUpdate
# ========== 静态 API 函数 ==========
async def api_get_contract_list(client: ApiClient, project_id: str) -> List[ContractResponse]:
"""GET /contract/list - 获取项目合同列表"""
response = await client.request(
method="GET",
endpoint="/contract/list",
params={"project_id": project_id}
)
if response.get("code") != 200:
raise Exception(f"Failed to get contract list: {response.get('message', 'Unknown error')}")
# V2 API 返回格式:{code: 200, data: [...]}
items = response.get("data", [])
return [ContractResponse.model_validate(item) for item in items]
async def api_get_contract(client: ApiClient, contract_id: str) -> ContractResponse:
"""GET /contract - 获取合同详情"""
response = await client.request(
method="GET",
endpoint="/contract",
params={"contract_id": contract_id}
)
if response.get("code") != 200:
raise Exception(f"Failed to get contract: {response.get('message', 'Unknown error')}")
return ContractResponse.model_validate(response["data"])
async def api_create_contract(client: ApiClient, data: Dict[str, Any], push_id: str) -> Dict[str, Any]:
"""POST /contract/create - 创建合同(异步)"""
validated_data = ContractCreate.model_validate(data)
request_data = {"push_id": push_id, "data": validated_data.model_dump(exclude_none=True)}
response = await client.request("POST", "/contract/create", data=request_data)
if response.get("code") != 200:
raise Exception(f"Failed to create contract: {response.get('message', 'Unknown error')}")
return response
async def api_update_contract(client: ApiClient, data_id: str, data: Dict[str, Any], push_id: str) -> Dict[str, Any]:
"""PUT /contract/update - 更新合同(同步)"""
validated_data = ContractUpdate.model_validate(data)
request_data = {"push_id": push_id, "biz_id": data_id, "data": validated_data.model_dump(exclude_none=True)}
response = await client.request("PUT", "/contract/update", data=request_data)
if response.get("code") != 200:
raise Exception(f"Failed to update contract: {response.get('message', 'Unknown error')}")
return response
async def api_delete_contract(client: ApiClient, data_id: str, push_id: str) -> Dict[str, Any]:
"""DELETE /contract/delete - 删除合同(同步)"""
request_data = {"push_id": push_id, "biz_id": data_id}
response = await client.request("DELETE", "/contract/delete", data=request_data)
if response.get("code") != 200:
raise Exception(f"Failed to delete contract: {response.get('message', 'Unknown error')}")
return response
# ========== Handler ==========
class ContractApiHandler(BaseApiHandler):
"""合同 API Handler"""
def __init__(self):
super().__init__()
self._node_type = "contract"
self._node_class = ContractSyncNode
self._schema = ContractResponse
self.update_schemas = [ContractCreate, ContractUpdate]
# ========== Handler 接口实现 ==========
async def load(self) -> List[SyncNode]:
"""
加载合同列表
依赖 Project:从 collection 获取项目列表,然后加载这些项目的合同
Returns:
合同数据列表
"""
try:
# 从 collection 获取项目列表
projects = self._collection.filter(node_type="project")
project_ids = [p.data_id for p in projects if p.data_id]
if not project_ids:
print("⚠️ No projects found, skipping contract load")
return []
# 为每个项目加载合同
all_nodes = []
for project_id in project_ids:
try:
contracts = await api_get_contract_list(self.api_client, project_id)
# 已验证的 Pydantic model,转换为 dict 并创建 node
for contract in contracts:
contract_data = contract.model_dump()
all_nodes.append(self._create_node(contract_data))
except Exception as e:
print(f"❌ Failed to load contracts for project {project_id}: {e}")
continue
return all_nodes
except Exception as e:
print(f"❌ Failed to load contracts: {e}")
return []
async def create_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
"""批量创建合同"""
results = []
for node in nodes:
result = await self._create_single(node)
results.append(result)
return results
async def update_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
"""批量更新合同"""
results = []
for node in nodes:
result = await self._update_single(node)
results.append(result)
return results
async def delete_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
"""批量删除合同"""
results = []
for node in nodes:
result = await self._delete_single(node)
results.append(result)
return results
async def poll_tasks(self, task_ids: List[str]) -> Dict[str, TaskResult]:
"""轮询异步任务状态"""
return await super().poll_tasks(task_ids)
async def _create_single(self, node: SyncNode) -> TaskResult:
"""
创建合同(异步)
Args:
node: 同步节点
Returns:
TaskResult (IN_PROGRESS with push_id)
"""
push_id: str | None = None
try:
# 获取数据
data = node.get_data()
if not data:
return TaskResult.failed(node_id=node.node_id, error="Node data is None or validation failed")
# 从 context 获取 project_id
project_id = node.context.get("project_id")
if not project_id:
project_id = data.get("project_id")
if not project_id:
return TaskResult.failed(
node_id=node.node_id,
error="project_id not found in context or data"
)
# 构建请求数据(确保包含 project_id)
request_data = {
"project_id": project_id,
**data
}
# 调用 API(直接调用顶层函数)
push_id = self._generate_push_id()
response = await api_create_contract(self.api_client, request_data, push_id)
if self.poll_mode == "sync":
created_id = self._extract_created_id_from_response(response)
if not created_id:
return TaskResult.failed(
node_id=node.node_id,
error="Missing created_id in create response"
)
return TaskResult.success(node_id=node.node_id, data_id=created_id)
else:
return TaskResult.in_progress(node_id=node.node_id, task_id=push_id)
except Exception as e:
return TaskResult.failed(
node_id=node.node_id,
error=f"Create failed: {str(e)}",
push_id=push_id,
)
async def _update_single(self, node: SyncNode) -> TaskResult:
"""
更新合同(同步)
自动选择需要更新的字段(基于 update_schema
Args:
node: 同步节点
Returns:
TaskResult (SUCCESS or FAILED)
"""
try:
data = node.get_data()
if not data:
return TaskResult.failed(node_id=node.node_id, error="Missing data for update")
# 过滤出需要更新的字段(排除空值、未变化的字段)
origin_data = node.get_origin_data() or {}
update_data = self._filter_update_data(
new_data=data,
original_data=origin_data,
schema=ContractUpdate,
)
# 如果没有需要更新的字段,降级 action 并返回跳过
if not update_data:
return TaskResult.skipped(node_id=node.node_id, reason="No updatable fields changed")
# Schema 验证
error = self._validate_schema(update_data, ContractUpdate)
if error:
return TaskResult.failed(
node_id=node.node_id,
error=f"Schema validation failed: {error}"
)
# 生成 push_id 和 biz_id
push_id = self._generate_push_id()
biz_id = node.data_id or update_data.get("id")
if not biz_id:
return TaskResult.failed(node_id=node.node_id, error="Missing biz_id for update")
# 调用 API
response = await api_update_contract(self.api_client, biz_id, update_data, push_id)
# 检查响应状态(更新接口是同步的)
if response.get("code") == 200:
return TaskResult.success(node_id=node.node_id)
else:
return TaskResult.failed(
node_id=node.node_id,
error=f"API returned error: {response.get('message', 'Unknown error')}"
)
except Exception as e:
return TaskResult.failed(
node_id=node.node_id,
error=f"Update failed: {str(e)}"
)
async def _delete_single(self, node: SyncNode) -> TaskResult:
"""
删除合同(同步)
Args:
node: 同步节点
Returns:
TaskResult (SUCCESS or FAILED)
"""
if not self.api_client:
return TaskResult.failed(
node_id=node.node_id,
error="api_client not initialized"
)
try:
# 生成 push_id 和 biz_id
push_id = self._generate_push_id()
biz_id = node.data_id
if not biz_id:
return TaskResult.failed(node_id=node.node_id, error="Missing biz_id for delete")
# 调用 API
response = await api_delete_contract(self.api_client, biz_id, push_id)
# 检查响应状态
if response.get("code") == 200:
return TaskResult.success(node_id=node.node_id)
else:
return TaskResult.failed(
node_id=node.node_id,
error=f"API returned error: {response.get('message', 'Unknown error')}"
)
except Exception as e:
return TaskResult.failed(
node_id=node.node_id,
error=f"Delete failed: {str(e)}"
)
# ========== 辅助方法 ==========
def _create_node(self, data: Dict[str, Any]) -> SyncNode:
"""创建合同同步节点(设置 project_id 到 context"""
node = self._create_basic_node(data, depend_ids=[])
# 设置 project_id 到 context(供子节点使用)
if data.get("project_id"):
node.context["project_id"] = data["project_id"]
return node
@@ -0,0 +1,14 @@
"""
Contract JSONL Handler
"""
from ...datasource import BaseJsonlHandler, JsonlDataSource
from .sync_node import ContractSyncNode
from schemas.contract.contract import ContractResponse
class ContractJsonlHandler(BaseJsonlHandler):
"""合同 JSONL Handler"""
def __init__(self, datasource: JsonlDataSource):
super().__init__(datasource, "contract", ContractSyncNode, ContractResponse)
@@ -0,0 +1,17 @@
"""Contract domain registration"""
from ...common.registry import DomainRegistry
from schemas.contract.contract import ContractResponse
from .sync_node import ContractSyncNode
from .sync_strategy import ContractSyncStrategy
from .jsonl_handler import ContractJsonlHandler
from .api_handler import ContractApiHandler
# 注册 contract domain
DomainRegistry.register(
node_type="contract",
schema=ContractResponse,
node_class=ContractSyncNode,
strategy_class=ContractSyncStrategy,
jsonl_handler_class=ContractJsonlHandler,
api_handler_class=ContractApiHandler,
)
@@ -0,0 +1,11 @@
from ...common.sync_node import SyncNode
from schemas.contract.contract import ContractResponse
class ContractSyncNode(SyncNode[ContractResponse]):
"""合同同步节点"""
node_type = "contract"
schema = ContractResponse
def __init__(self, **kwargs):
super().__init__(**kwargs)
@@ -0,0 +1,30 @@
from ...sync_system.strategy import DefaultSyncStrategy
from ...sync_system.config import StrategyConfig, OrphanAction, UpdateDirection
from schemas.contract.contract import ContractResponse
class ContractSyncStrategy(DefaultSyncStrategy[ContractResponse]):
"""
Contract 同步策略
业务规则:
1. 使用 (project_id, code) 作为业务唯一键
2. 依赖于 project 和 company
3. 支持自动绑定
4. 默认配置为日常推送模式(本地创建推送到远程,不拉取远程孤儿)
schema 已在类定义中设置,禁止在初始化时传入其他 schema。
"""
# 类变量:schema 固定为 ContractResponse
schema = ContractResponse
# 类变量:默认配置
default_config = StrategyConfig(
auto_bind=True,
auto_bind_fields=["project_id","company_id", "code"],
depend_fields={"project_id": "project", "company_id": "supplier"},
local_orphan_action=OrphanAction.CREATE_REMOTE,
remote_orphan_action=OrphanAction.NONE,
update_direction=UpdateDirection.PUSH,
)
@@ -0,0 +1,13 @@
"""Contract Change (合同变更) Domain Module"""
from .sync_node import ContractChangeSyncNode
from .sync_strategy import ContractChangeSyncStrategy
from .api_handler import ContractChangeApiHandler
from .jsonl_handler import ContractChangeJsonlHandler
__all__ = [
"ContractChangeSyncNode",
"ContractChangeSyncStrategy",
"ContractChangeApiHandler",
"ContractChangeJsonlHandler",
]
@@ -0,0 +1,171 @@
"""
Contract Change (合同变更) API Handler
职责:
- 创建和更新合同变更记录(依赖 Project 和 Contract
实现的接口:
POST:
- /contract_change/create - 创建合同变更
PUT:
- /contract_change/update - 更新合同变更
"""
from typing import List, Dict, Any
from ...datasource.api.handler import BaseApiHandler
from ...datasource.task_result import TaskResult
from ...common.sync_node import SyncNode
from ...datasource.api.errors import ERROR_NODE_DATA_NONE, ERROR_VALIDATION_FAILED
from .sync_node import ContractChangeSyncNode
from schemas.project_extensions.contract_change import ContractChangeResponse, PostContractChange, CreateContractChange
class ContractChangeApiHandler(BaseApiHandler):
"""合同变更 API Handler"""
def __init__(self):
super().__init__()
self._node_type = "contract_change"
self._node_class = ContractChangeSyncNode
# TODO: ContractChangeResponse schema in schemas/project_extensions/contract_change.py is missing 'project_id'
self._schema = ContractChangeResponse
async def load(self) -> List[SyncNode]:
"""加载合同变更数据"""
return []
async def create_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
"""批量创建合同变更"""
from pydantic import ValidationError
results = []
for node in nodes:
node_data = node.get_data()
if node_data is None:
results.append(TaskResult.failed(node_id=node.node_id, error=ERROR_NODE_DATA_NONE))
continue
# 验证并转换为 Pydantic model
try:
create_model = CreateContractChange.model_validate(node_data)
except ValidationError as e:
results.append(TaskResult.failed(node_id=node.node_id, error=f"{ERROR_VALIDATION_FAILED}: {e}"))
continue
push_id = self._generate_push_id()
try:
response = await api_create_contract_change(self.api_client, create_model, push_id)
if self.poll_mode == "sync":
created_id = self._extract_created_id_from_response(response)
if not created_id:
results.append(
TaskResult.failed(
node_id=node.node_id,
error="Missing created_id in create response"
)
)
continue
results.append(TaskResult.success(node_id=node.node_id, data_id=created_id))
else:
results.append(TaskResult.in_progress(node_id=node.node_id, task_id=push_id))
except Exception as e:
results.append(TaskResult.failed(node_id=node.node_id, error=str(e)))
return results
async def update_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
"""批量更新合同变更"""
from pydantic import ValidationError
results = []
for node in nodes:
node_data = node.get_data()
if node_data is None:
error_msg = node.error or "Node data is missing or invalid"
results.append(TaskResult.failed(node_id=node.node_id, error=f"Cannot update: {error_msg}"))
continue
origin_data = node.get_origin_data() or {}
update_data = self._filter_update_data(node_data, origin_data, PostContractChange)
if not update_data:
from ...common.types import SyncAction
results.append(TaskResult.skipped(node_id=node.node_id, reason="No updatable fields changed"))
continue
# 验证并转换为 Pydantic model
try:
update_model = PostContractChange.model_validate(update_data)
except ValidationError as e:
results.append(TaskResult.failed(node_id=node.node_id, error=f"{ERROR_VALIDATION_FAILED}: {e}"))
continue
push_id = self._generate_push_id()
biz_id = node.data_id or update_model.id
if not biz_id:
results.append(TaskResult.failed(node_id=node.node_id, error="Missing biz_id for update"))
continue
try:
await api_update_contract_change(self.api_client, update_model, push_id, biz_id)
results.append(TaskResult.success(node_id=node.node_id))
except Exception as e:
results.append(TaskResult.failed(node_id=node.node_id, error=str(e)))
return results
async def delete_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
"""不支持删除"""
return [
TaskResult.failed(node_id=node.node_id, error="Contract change deletion not supported")
for node in nodes
]
async def poll_tasks(self, task_ids: List[str]) -> Dict[str, TaskResult]:
"""轮询异步任务状态"""
return await super().poll_tasks(task_ids)
def _create_node(self, data: Dict[str, Any]) -> SyncNode:
node = self._create_basic_node(data, depend_ids=[])
if data.get("project_id"):
node.context["project_id"] = data["project_id"]
if data.get("contract_id"):
node.context["contract_id"] = data["contract_id"]
return node
# ========== 静态 API 函数 ==========
async def api_create_contract_change(client, data: CreateContractChange, push_id: str) -> Dict[str, Any]:
"""
POST /contract_change/create - 创建合同变更
Args:
client: API 客户端
data: 创建数据(Pydantic model
push_id: 推送ID
biz_id: 业务ID
"""
request_data = {"push_id": push_id, "data": data.model_dump()}
response = await client.request(method="POST", endpoint="/contract_change/create", data=request_data)
if response.get("code") != 200:
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
return response
async def api_update_contract_change(client, data: PostContractChange, push_id: str, biz_id: str) -> None:
"""
PUT /contract_change/update - 更新合同变更
Args:
client: API 客户端
data: 更新数据(Pydantic model
push_id: 推送ID
biz_id: 业务ID
"""
request_data = {"push_id": push_id, "biz_id": biz_id, "data": data.model_dump(exclude_unset=True)}
response = await client.request(method="PUT", endpoint="/contract_change/update", data=request_data)
if response.get("code") != 200:
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
@@ -0,0 +1,10 @@
"""Contract Change Domain - JSONL Handler"""
from ...datasource import BaseJsonlHandler, JsonlDataSource
from .sync_node import ContractChangeSyncNode
from schemas.project_extensions.contract_change import ContractChangeResponse
class ContractChangeJsonlHandler(BaseJsonlHandler):
"""合同变更 JSONL Handler"""
def __init__(self, datasource: JsonlDataSource):
super().__init__(datasource, "contract_change", ContractChangeSyncNode, ContractChangeResponse)
@@ -0,0 +1,17 @@
"""Contract change domain registration"""
from ...common.registry import DomainRegistry
from schemas.project_extensions.contract_change import ContractChangeResponse
from .sync_node import ContractChangeSyncNode
from .sync_strategy import ContractChangeSyncStrategy
from .api_handler import ContractChangeApiHandler
from .jsonl_handler import ContractChangeJsonlHandler
# 注册 contract_change domain
DomainRegistry.register(
node_type="contract_change",
schema=ContractChangeResponse,
node_class=ContractChangeSyncNode,
strategy_class=ContractChangeSyncStrategy,
jsonl_handler_class=ContractChangeJsonlHandler,
api_handler_class=ContractChangeApiHandler,
)
@@ -0,0 +1,10 @@
"""Contract Change SyncNode"""
from ...common.sync_node import SyncNode
from schemas.project_extensions.contract_change import ContractChangeResponse
class ContractChangeSyncNode(SyncNode[ContractChangeResponse]):
"""合同变更同步节点"""
node_type = "contract_change"
schema = ContractChangeResponse
@@ -0,0 +1,20 @@
from ...sync_system.strategy import DefaultSyncStrategy
from ...sync_system.config import StrategyConfig, OrphanAction, UpdateDirection
from schemas.project_extensions.contract_change import ContractChangeResponse
class ContractChangeSyncStrategy(DefaultSyncStrategy[ContractChangeResponse]):
"""
Contract Change 同步策略。
"""
schema = ContractChangeResponse
default_config = StrategyConfig(
auto_bind=True,
auto_bind_fields=["contract_id", "change_time", "title"],
depend_fields={"project_id": "project", "contract_id": "contract"},
local_orphan_action=OrphanAction.CREATE_REMOTE,
remote_orphan_action=OrphanAction.NONE,
update_direction=UpdateDirection.PUSH,
)
@@ -0,0 +1,6 @@
"""Contract settlement domain"""
from .sync_node import ContractSettlementSyncNode
from .sync_strategy import ContractSettlementSyncStrategy
from .jsonl_handler import ContractSettlementJsonlHandler
__all__ = ["ContractSettlementSyncNode", "ContractSettlementSyncStrategy", "ContractSettlementJsonlHandler"]
@@ -0,0 +1,313 @@
"""
Contract Settlement API Handler
职责:
- 加载合同结算列表(依赖 Project)
- 合同结算创建/更新/删除(批量接口)
"""
from typing import List, Dict, Any
from ...datasource.api.handler import BaseApiHandler
from ...datasource.task_result import TaskResult
from ...common.sync_node import SyncNode
from ...datasource.api.errors import (
ERROR_NODE_DATA_NONE,
ERROR_VALIDATION_FAILED,
ERROR_PROJECT_ID_NOT_FOUND
)
from .sync_node import ContractSettlementSyncNode
from schemas.contract_settlement.contract_settlement import (
ContractSettlementResponse,
ContractSettlementCreate,
ContractSettlementUpdate,
ContractSettlementPlanUpdate,
)
class ContractSettlementApiHandler(BaseApiHandler):
"""合同结算 API Handler"""
def __init__(self):
super().__init__()
self._node_type = "contract_settlement"
self._node_class = ContractSettlementSyncNode
self._schema = ContractSettlementResponse
self.update_schemas = [ContractSettlementCreate, ContractSettlementUpdate, ContractSettlementPlanUpdate]
async def load(self) -> List[SyncNode]:
"""加载合同结算列表(依赖 Project)"""
projects = self._collection.filter(node_type="project")
project_ids = [p.data_id for p in projects if p.data_id]
if not project_ids:
return []
all_settlements = []
for project_id in project_ids:
try:
settlements = await api_get_contract_settlement_list(self.api_client, project_id)
for settlement_model in settlements:
# 已经过验证的 Pydantic model,转换为 dict
settlement_data = settlement_model.model_dump()
all_settlements.append(self._create_node(settlement_data))
except Exception as e:
print(f"❌ Failed to load settlements for project {project_id}: {e}")
return all_settlements
async def create_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
"""批量创建合同结算"""
from pydantic import ValidationError
results = []
for node in nodes:
data = node.get_data()
if not data:
results.append(TaskResult.failed(node_id=node.node_id, error=ERROR_NODE_DATA_NONE))
continue
project_id = node.context.get("project_id") or data.get("project_id")
if not project_id:
results.append(TaskResult.failed(node_id=node.node_id, error=ERROR_PROJECT_ID_NOT_FOUND))
continue
# 验证并转换为 Pydantic model
try:
create_data = {"project_id": project_id, **data}
create_model = ContractSettlementCreate.model_validate(create_data)
except ValidationError as e:
results.append(TaskResult.failed(node_id=node.node_id, error=f"{ERROR_VALIDATION_FAILED}: {e}"))
continue
push_id = self._generate_push_id()
try:
response = await api_create_contract_settlement(self.api_client, create_model, push_id)
if self.poll_mode == "sync":
created_id = self._extract_created_id_from_response(response)
if not created_id:
results.append(
TaskResult.failed(
node_id=node.node_id,
error="Missing created_id in create response"
)
)
continue
results.append(TaskResult.success(node_id=node.node_id, data_id=created_id))
else:
results.append(TaskResult.in_progress(node_id=node.node_id, task_id=push_id))
except Exception as e:
results.append(TaskResult.failed(node_id=node.node_id, error=str(e)))
return results
async def update_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
"""
批量更新合同结算
支持两种更新类型:
1. ContractSettlementUpdate - 基础信息更新
2. ContractSettlementPlanUpdate - 计划信息更新
更新逻辑:
- 对每种 schema,检查是否有差异
- 如果有差异,调用对应 API
"""
from pydantic import ValidationError
results = []
for node in nodes:
data = node.get_data()
if not data:
results.append(TaskResult.failed(node_id=node.node_id, error=ERROR_NODE_DATA_NONE))
continue
origin_data = node.get_origin_data() or {}
biz_id = node.data_id or data.get("id")
if not biz_id:
results.append(TaskResult.failed(node_id=node.node_id, error="Missing biz_id for update"))
continue
node_updated = False
error = None
# 1. 基础信息更新 (ContractSettlementUpdate)
base_diff = self._get_schema_diff(ContractSettlementUpdate, data, origin_data, node.node_id)
if base_diff:
try:
update_model = ContractSettlementUpdate.model_validate(data)
push_id = self._generate_push_id()
await api_update_contract_settlement(self.api_client, update_model, push_id, biz_id)
node_updated = True
except ValidationError as e:
error = f"Validation failed: {e}"
except Exception as e:
error = f"API error: {e}"
# 2. 计划信息更新 (ContractSettlementPlanUpdate)
plan_diff = self._get_schema_diff(ContractSettlementPlanUpdate, data, origin_data, node.node_id)
if not error and plan_diff:
try:
plan_model = ContractSettlementPlanUpdate.model_validate(data)
push_id = self._generate_push_id()
# 使用计划更新专用 API
await api_update_settlement_plan(self.api_client, plan_model.model_dump(exclude_unset=True), push_id, biz_id)
node_updated = True
except ValidationError as e:
error = f"Validation failed: {e}"
except Exception as e:
error = f"API error: {e}"
# 汇总结果
if error:
results.append(TaskResult.failed(node_id=node.node_id, error=error))
elif node_updated:
results.append(TaskResult.success(node_id=node.node_id))
else:
# 没有任何字段需要更新
from ...common.types import SyncAction
results.append(TaskResult.skipped(node_id=node.node_id, reason="No updatable fields changed"))
return results
async def delete_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
"""批量删除合同结算"""
results = []
for node in nodes:
push_id = self._generate_push_id()
biz_id = node.data_id
if not biz_id:
results.append(TaskResult.failed(node_id=node.node_id, error="Missing biz_id for delete"))
continue
try:
await api_delete_contract_settlement(self.api_client, biz_id, push_id)
results.append(TaskResult.success(node_id=node.node_id))
except Exception as e:
results.append(TaskResult.failed(node_id=node.node_id, error=str(e)))
return results
async def poll_tasks(self, task_ids: List[str]) -> Dict[str, TaskResult]:
"""轮询异步任务状态(占位)"""
return await super().poll_tasks(task_ids)
def _create_node(self, data: Dict[str, Any]) -> SyncNode:
node = self._create_basic_node(data, depend_ids=[])
if data.get("project_id"):
node.context["project_id"] = data["project_id"]
node.context["settlement_id"] = node.data_id
return node
# ========== 静态 API 函数 ==========
async def api_get_settlement(client, settlement_id: str) -> Dict[str, Any]:
"""获取单个合同结算数据 GET /contract_settlement"""
response = await client.request(
method="GET",
endpoint="/contract_settlement",
params={"settlement_id": settlement_id}
)
if response.get("code") != 200:
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
return response.get("data", {})
async def api_get_contract_settlement_list(client, project_id: str) -> List[ContractSettlementResponse]:
"""
获取合同结算列表 GET /contract_settlement/list
Args:
client: API 客户端
project_id: 项目ID
Returns:
List[ContractSettlementResponse]: 验证过的结算列表
Raises:
Exception: API 返回错误或数据验证失败
"""
from pydantic import ValidationError
response = await client.request(
method="GET",
endpoint="/contract_settlement/list",
params={"project_id": project_id}
)
if response.get("code") != 200:
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
items = response.get("data", [])
# 验证每个返回项
validated_items = []
for item in items:
try:
validated_item = ContractSettlementResponse.model_validate(item)
validated_items.append(validated_item)
except ValidationError as e:
print(f"⚠️ Skipping invalid settlement item: {e}")
continue
return validated_items
async def api_create_contract_settlement(client, data: ContractSettlementCreate, push_id: str) -> Dict[str, Any]:
"""
创建合同结算 POST /contract_settlement/create
Args:
client: API 客户端
data: 创建数据(Pydantic model
push_id: 推送ID
biz_id: 业务ID
Returns:
str: push_id
Raises:
Exception: API 返回错误
"""
request_data = {"push_id": push_id, "data": data.model_dump()}
response = await client.request(method="POST", endpoint="/contract_settlement/create", data=request_data)
if response.get("code") != 200:
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
return response
async def api_update_contract_settlement(client, data: ContractSettlementUpdate, push_id: str, biz_id: str) -> None:
"""
更新合同结算 PUT /contract_settlement/update
Args:
client: API 客户端
data: 更新数据(Pydantic model
push_id: 推送ID
biz_id: 业务ID
Raises:
Exception: API 返回错误
"""
request_data = {"push_id": push_id, "biz_id": biz_id, "data": data.model_dump(exclude_unset=True)}
response = await client.request(method="PUT", endpoint="/contract_settlement/update", data=request_data)
if response.get("code") != 200:
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
async def api_delete_contract_settlement(client, settlement_id: str, push_id: str) -> None:
"""删除合同结算 DELETE /contract_settlement/delete"""
request_data = {"push_id": push_id, "biz_id": settlement_id}
response = await client.request(method="DELETE", endpoint="/contract_settlement/delete", data=request_data)
if response.get("code") != 200:
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
async def api_update_settlement_plan(client, data: Dict[str, Any], push_id: str, biz_id: str) -> None:
"""更新合同结算计划 PUT /contract_settlement/plan"""
request_data = {"push_id": push_id, "biz_id": biz_id, "data": data}
response = await client.request(method="PUT", endpoint="/contract_settlement/plan", data=request_data)
if response.get("code") != 200:
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
@@ -0,0 +1,10 @@
"""Contract Settlement Domain - JSONL Handler"""
from ...datasource import BaseJsonlHandler, JsonlDataSource
from .sync_node import ContractSettlementSyncNode
from schemas.contract_settlement.contract_settlement import ContractSettlementResponse
class ContractSettlementJsonlHandler(BaseJsonlHandler):
"""合同结算 JSONL Handler"""
def __init__(self, datasource: JsonlDataSource):
super().__init__(datasource, "contract_settlement", ContractSettlementSyncNode, ContractSettlementResponse)
@@ -0,0 +1,17 @@
"""Contract settlement domain registration"""
from ...common.registry import DomainRegistry
from schemas.contract_settlement.contract_settlement import ContractSettlementResponse
from .sync_node import ContractSettlementSyncNode
from .sync_strategy import ContractSettlementSyncStrategy
from .jsonl_handler import ContractSettlementJsonlHandler
from .api_handler import ContractSettlementApiHandler
# 注册 contract_settlement domain
DomainRegistry.register(
node_type="contract_settlement",
schema=ContractSettlementResponse,
node_class=ContractSettlementSyncNode,
strategy_class=ContractSettlementSyncStrategy,
jsonl_handler_class=ContractSettlementJsonlHandler,
api_handler_class=ContractSettlementApiHandler,
)
@@ -0,0 +1,11 @@
from ...common.sync_node import SyncNode
from schemas.contract_settlement.contract_settlement import ContractSettlementResponse
class ContractSettlementSyncNode(SyncNode[ContractSettlementResponse]):
"""合同结算同步节点"""
node_type = "contract_settlement"
schema = ContractSettlementResponse
def __init__(self, **kwargs):
super().__init__(**kwargs)
@@ -0,0 +1,20 @@
from ...sync_system.strategy import DefaultSyncStrategy
from ...sync_system.config import StrategyConfig, OrphanAction, UpdateDirection
from schemas.contract_settlement.contract_settlement import ContractSettlementResponse
class ContractSettlementSyncStrategy(DefaultSyncStrategy[ContractSettlementResponse]):
"""
Contract Settlement 同步策略(合同子业务)。
"""
schema = ContractSettlementResponse
default_config = StrategyConfig(
auto_bind=True,
auto_bind_fields=["contract_id"],
depend_fields={"contract_id": "contract", "project_id": "project"},
local_orphan_action=OrphanAction.CREATE_REMOTE,
remote_orphan_action=OrphanAction.NONE,
update_direction=UpdateDirection.PUSH,
)
@@ -0,0 +1,8 @@
"""
Contract Settlement Detail Domain
"""
from .sync_node import ContractSettlementDetailSyncNode
from .sync_strategy import ContractSettlementDetailSyncStrategy
from .jsonl_handler import ContractSettlementDetailJsonlHandler
__all__ = ["ContractSettlementDetailSyncNode", "ContractSettlementDetailSyncStrategy", "ContractSettlementDetailJsonlHandler"]
@@ -0,0 +1,288 @@
"""
Contract Settlement Detail API Handler
职责:
- 加载合同结算明细列表(依赖 ContractSettlement
- 合同结算明细创建/更新/删除(批量接口)
"""
from typing import List, Dict, Any
from ...datasource.api.handler import BaseApiHandler
from ...datasource.task_result import TaskResult
from ...common.sync_node import SyncNode
from ...datasource.api.errors import ERROR_NODE_DATA_NONE, ERROR_VALIDATION_FAILED, ERROR_ID_NOT_FOUND
from .sync_node import ContractSettlementDetailSyncNode
from schemas.contract_settlement.contract_settlement_detail import (
ContractSettlementDetailResponse,
ContractSettlementDetailCreate,
ContractSettlementDetailUpdate
)
class ContractSettlementDetailApiHandler(BaseApiHandler):
"""合同结算明细 API Handler"""
def __init__(self):
super().__init__()
self._node_type = "contract_settlement_detail"
self._node_class = ContractSettlementDetailSyncNode
self._schema = ContractSettlementDetailResponse
self.update_schemas = [ContractSettlementDetailCreate, ContractSettlementDetailUpdate]
async def load(self) -> List[SyncNode]:
"""加载合同结算明细列表(依赖 ContractSettlement"""
settlements = self._collection.filter(node_type="contract_settlement")
settlement_ids = [s.data_id for s in settlements if s.data_id]
if not settlement_ids:
return []
all_details = []
for settlement_id in settlement_ids:
try:
details = await api_get_contract_settlement_detail_list(self.api_client, settlement_id)
for detail_model in details:
detail_data = detail_model.model_dump()
all_details.append(self._create_node(detail_data))
except Exception as e:
print(f"❌ Failed to load settlement details for {settlement_id}: {e}")
return all_details
async def create_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
"""批量创建合同结算明细"""
from pydantic import ValidationError
results = []
for node in nodes:
data = node.get_data()
if not data:
results.append(TaskResult.failed(node_id=node.node_id, error=ERROR_NODE_DATA_NONE))
continue
# 从data的parent_id获取settlement_id
settlement_id = data.get("parent_id")
if not settlement_id:
results.append(TaskResult.failed(node_id=node.node_id, error=ERROR_ID_NOT_FOUND))
continue
try:
create_data = {"settlement_id": settlement_id, **data}
create_model = ContractSettlementDetailCreate.model_validate(create_data)
except ValidationError as e:
results.append(TaskResult.failed(node_id=node.node_id, error=f"{ERROR_VALIDATION_FAILED}: {e}"))
continue
push_id = self._generate_push_id()
try:
response = await api_create_contract_settlement_detail(self.api_client, create_model, push_id)
if self.poll_mode == "sync":
created_id = self._extract_created_id_from_response(response)
if not created_id:
results.append(
TaskResult.failed(
node_id=node.node_id,
error="Missing created_id in create response"
)
)
continue
results.append(TaskResult.success(node_id=node.node_id, data_id=created_id))
else:
results.append(TaskResult.in_progress(node_id=node.node_id, task_id=push_id))
except Exception as e:
results.append(TaskResult.failed(node_id=node.node_id, error=str(e)))
return results
async def update_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
"""
批量更新合同结算明细
按 settlement_id 分组,每组使用同一个 push_id 批量提交
"""
from pydantic import ValidationError
from collections import defaultdict
results = []
# 按 settlement_id 分组
groups: Dict[str, List[tuple[SyncNode, Dict[str, Any]]]] = defaultdict(list)
for node in nodes:
data = node.get_data()
if not data:
results.append(TaskResult.failed(node_id=node.node_id, error=ERROR_NODE_DATA_NONE))
continue
origin_data = node.get_origin_data() or {}
# 使用 _get_schema_diff 检查差异并打印日志
diff_fields = self._get_schema_diff(ContractSettlementDetailUpdate, data, origin_data, node.node_id)
if not diff_fields:
from ...common.types import SyncAction
results.append(TaskResult.skipped(node_id=node.node_id, reason="No updatable fields changed"))
continue
# 获取 settlement_idparent_id
settlement_id = data.get("parent_id") or node.context.get("settlement_id")
if not settlement_id:
results.append(TaskResult.failed(node_id=node.node_id, error="Missing settlement_id (parent_id)"))
continue
# 需要完整的 schema 数据(包括必填字段)
update_data = self._filter_update_data(data, origin_data, ContractSettlementDetailUpdate)
try:
update_model = ContractSettlementDetailUpdate.model_validate(update_data)
groups[settlement_id].append((node, update_model))
except ValidationError as e:
results.append(TaskResult.failed(node_id=node.node_id, error=f"{ERROR_VALIDATION_FAILED}: {e}"))
# 逐组批量更新
for settlement_id, group_items in groups.items():
push_id = self._generate_push_id()
# 准备批量数据
detail_list = [model.model_dump(mode='json', exclude_unset=True) for node, model in group_items]
# 使用第一个节点的 biz_id(对于批量更新,通常使用 parent_id)
biz_id = settlement_id
try:
# 批量调用 API
await api_update_contract_settlement_detail(
self.api_client,
{"detail_list": detail_list},
push_id,
biz_id,
)
# 整组成功
for node, _ in group_items:
results.append(TaskResult.success(node_id=node.node_id, push_id=push_id))
except Exception as e:
# 整组失败
error_msg = str(e)
for node, _ in group_items:
results.append(TaskResult.failed(node_id=node.node_id, error=error_msg))
return results
async def delete_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
"""批量删除合同结算明细"""
results = []
for node in nodes:
push_id = self._generate_push_id()
biz_id = node.data_id
if not biz_id:
results.append(TaskResult.failed(node_id=node.node_id, error="Missing biz_id for delete"))
continue
try:
await api_delete_contract_settlement_detail(self.api_client, biz_id, push_id)
results.append(TaskResult.success(node_id=node.node_id))
except Exception as e:
results.append(TaskResult.failed(node_id=node.node_id, error=str(e)))
return results
async def poll_tasks(self, task_ids: List[str]) -> Dict[str, TaskResult]:
"""轮询异步任务状态(占位)"""
return await super().poll_tasks(task_ids)
def _create_node(self, data: Dict[str, Any]) -> SyncNode:
node = self._create_basic_node(data, depend_ids=[])
if data.get("settlement_id"):
node.context["settlement_id"] = data["settlement_id"]
return node
# ========== 静态 API 函数 ==========
async def api_get_contract_settlement_detail_list(client, settlement_id: str) -> List[ContractSettlementDetailResponse]:
"""
获取合同结算明细列表
Args:
client: API 客户端
settlement_id: 结算ID
Returns:
List[ContractSettlementDetailResponse]: 验证过的明细列表
Raises:
Exception: API 返回错误或数据验证失败
"""
from pydantic import ValidationError
response = await client.request(
method="GET",
endpoint="/contract_settlement/detail/list",
params={"settlement_id": settlement_id}
)
if response.get("code") != 200:
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
items = response.get("data", [])
validated_items = []
for item in items:
try:
validated_item = ContractSettlementDetailResponse.model_validate(item)
validated_items.append(validated_item)
except ValidationError as e:
print(f"⚠️ Skipping invalid detail item: {e}")
continue
return validated_items
async def api_create_contract_settlement_detail(client, data: ContractSettlementDetailCreate, push_id: str) -> Dict[str, Any]:
"""
创建合同结算明细
Args:
client: API 客户端
data: 创建数据(Pydantic model
push_id: 推送ID
biz_id: 业务ID
Returns:
str: push_id
Raises:
Exception: API 返回错误
"""
request_data = {"push_id": push_id, "data": data.model_dump(mode='json')}
response = await client.request(method="POST", endpoint="/contract_settlement/detail/create", data=request_data)
if response.get("code") != 200:
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
return response
async def api_update_contract_settlement_detail(client, data: Dict[str, Any], push_id: str, biz_id: str) -> None:
"""
更新合同结算明细
Args:
client: API 客户端
data: 更新数据(Pydantic model
push_id: 推送ID
biz_id: 业务ID
Raises:
Exception: API 返回错误
"""
request_data = {"push_id": push_id, "biz_id": biz_id, "data": data}
response = await client.request(method="PUT", endpoint="/contract_settlement/detail/update", data=request_data)
if response.get("code") != 200:
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
async def api_delete_contract_settlement_detail(client, detail_id: str, push_id: str) -> None:
request_data = {"push_id": push_id, "biz_id": detail_id}
response = await client.request(method="DELETE", endpoint="/contract_settlement/detail/delete", data=request_data)
if response.get("code") != 200:
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
@@ -0,0 +1,12 @@
"""
Contract Settlement Detail JSONL Handler
"""
from ...datasource import BaseJsonlHandler, JsonlDataSource
from .sync_node import ContractSettlementDetailSyncNode
from schemas.contract_settlement.contract_settlement_detail import ContractSettlementDetailResponse
class ContractSettlementDetailJsonlHandler(BaseJsonlHandler):
"""合同结算明细 JSONL Handler"""
def __init__(self, datasource: JsonlDataSource):
super().__init__(datasource, "contract_settlement_detail", ContractSettlementDetailSyncNode, ContractSettlementDetailResponse)
@@ -0,0 +1,17 @@
"""Contract settlement detail domain registration"""
from ...common.registry import DomainRegistry
from schemas.contract_settlement.contract_settlement_detail import ContractSettlementDetailResponse
from .sync_node import ContractSettlementDetailSyncNode
from .sync_strategy import ContractSettlementDetailSyncStrategy
from .jsonl_handler import ContractSettlementDetailJsonlHandler
from .api_handler import ContractSettlementDetailApiHandler
# 注册 contract_settlement_detail domain
DomainRegistry.register(
node_type="contract_settlement_detail",
schema=ContractSettlementDetailResponse,
node_class=ContractSettlementDetailSyncNode,
strategy_class=ContractSettlementDetailSyncStrategy,
jsonl_handler_class=ContractSettlementDetailJsonlHandler,
api_handler_class=ContractSettlementDetailApiHandler,
)
@@ -0,0 +1,11 @@
"""
Contract Settlement Detail Domain
"""
from ...common import SyncNode
from schemas.contract_settlement.contract_settlement_detail import ContractSettlementDetailResponse
class ContractSettlementDetailSyncNode(SyncNode[ContractSettlementDetailResponse]):
"""合同结算明细同步节点"""
node_type = "contract_settlement_detail"
schema = ContractSettlementDetailResponse
@@ -0,0 +1,20 @@
from ...sync_system.strategy import DefaultSyncStrategy
from ...sync_system.config import StrategyConfig, OrphanAction, UpdateDirection
from schemas.contract_settlement.contract_settlement_detail import ContractSettlementDetailResponse
class ContractSettlementDetailSyncStrategy(DefaultSyncStrategy[ContractSettlementDetailResponse]):
"""
Contract Settlement Detail 同步策略(合同子业务明细)。
"""
schema = ContractSettlementDetailResponse
default_config = StrategyConfig(
auto_bind=True,
auto_bind_fields=["parent_id", "date"],
depend_fields={"parent_id": "contract_settlement"},
local_orphan_action=OrphanAction.CREATE_REMOTE,
remote_orphan_action=OrphanAction.NONE,
update_direction=UpdateDirection.PUSH,
)
@@ -0,0 +1,6 @@
"""LAR domain"""
from .sync_node import LarSyncNode
from .sync_strategy import LarSyncStrategy
from .jsonl_handler import LarJsonlHandler
__all__ = ["LarSyncNode", "LarSyncStrategy", "LarJsonlHandler"]
@@ -0,0 +1,218 @@
"""
LAR (移民征地) API Handler
职责:
- 更新移民征地数据(依赖 Project)
- 支持主数据、搬迁信息、投资信息三种更新
- 不支持创建和删除(数据随项目自动生成)
实现的接口:
PUT:
- /lar/main - 更新主数据
- /lar/resettlement - 更新搬迁信息
- /lar/investment - 更新投资信息
"""
from typing import List, Dict, Any
from ...datasource.api.handler import BaseApiHandler
from ...datasource.task_result import TaskResult
from ...common.sync_node import SyncNode
from ...datasource.api.errors import ERROR_VALIDATION_FAILED
from .sync_node import LarSyncNode
from schemas.project_extensions.lar import (
LarDetailSchema,
LarMainInfoUpdateSchema,
LarResettlementSchema,
LarInvestmentSchema
)
class LarApiHandler(BaseApiHandler):
"""移民征地 API Handler"""
def __init__(self):
super().__init__()
self._node_type = "lar"
self._node_class = LarSyncNode
self._schema = LarDetailSchema
async def load(self) -> List[SyncNode]:
"""
加载移民征地数据(从 project all_info 中提取)
注意:LAR 是可选的,只有特定项目类型(水电、抽蓄)才有
"""
from ..project.api_handler import ProjectApiHandler
# 获取已加载的项目
projects = self._collection.filter(node_type="project")
if not projects:
print("⚠️ No projects found, skipping LAR load")
return []
nodes = []
for project in projects:
project_id = project.data_id
try:
# 从 project context 或缓存获取 all_info
all_info = project.context.get("all_info")
if not all_info:
all_info = await ProjectApiHandler.get_all_info(
self.api_client,
project_id
)
# 提取 lar(注意:lar 是单个对象,不是列表)
lar_data = all_info.get("lar")
if lar_data:
try:
validated = self._schema.model_validate(lar_data)
node = self._create_node(validated.model_dump())
node.context["project_id"] = project_id
nodes.append(node)
print(f" Project {project_id}: 1 LAR")
except Exception as e:
print(f"⚠️ Skipping invalid LAR for project {project_id}: {e}")
continue
except Exception as e:
print(f"❌ Failed to load LAR for project {project_id}: {e}")
continue
return nodes
async def create_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
"""不支持创建(LAR随项目自动生成)"""
return [
TaskResult.failed(node_id=node.node_id, error="LAR creation not supported (auto-generated with project)")
for node in nodes
]
async def update_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
"""批量更新移民征地数据"""
from pydantic import ValidationError
results = []
# 定义三种更新类型
update_configs = [
(LarMainInfoUpdateSchema, api_update_lar_main),
(LarResettlementSchema, api_update_lar_resettlement),
(LarInvestmentSchema, api_update_lar_investment),
]
for node in nodes:
node_data = node.get_data()
if not node_data:
error_msg = node.error or "Node data is missing or invalid"
results.append(TaskResult.failed(node_id=node.node_id, error=f"Cannot update: {error_msg}"))
continue
origin_data = node.get_origin_data() or {}
node_updated = False
for schema, api_func in update_configs:
# 获取过滤后的更新数据
update_data = self._filter_update_data(node_data, origin_data, schema)
if update_data:
# 验证并转换为 Pydantic model
try:
update_model = schema.model_validate(update_data)
except ValidationError as e:
results.append(TaskResult.failed(node_id=node.node_id, error=f"{ERROR_VALIDATION_FAILED}: {e}"))
break
push_id = self._generate_push_id()
update_model_data = update_model.model_dump()
biz_id = node.data_id or update_model_data.get("id")
if not biz_id:
results.append(TaskResult.failed(node_id=node.node_id, error="Missing biz_id for update"))
break
try:
await api_func(self.api_client, update_model, push_id, biz_id)
node_updated = True
except Exception as e:
results.append(TaskResult.failed(node_id=node.node_id, error=str(e)))
break
if node_updated:
results.append(TaskResult.success(node_id=node.node_id))
elif not any(r.node_id == node.node_id for r in results):
# 如果没有任何更新且没有报错,视为跳过(归一化后无差异)
reason = f"No physical diff found across all 3 LAR sub-schemas for {node.data_id} despite strategy update request (Normalization Discrepancy)."
print(f" [WARNING] [lar] {reason}")
results.append(TaskResult.skipped(node_id=node.node_id, reason=reason))
return results
async def delete_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
"""不支持删除(LAR随项目存在)"""
return [
TaskResult.failed(node_id=node.node_id, error="LAR deletion not supported")
for node in nodes
]
async def poll_tasks(self, task_ids: List[str]) -> Dict[str, TaskResult]:
"""轮询异步任务状态"""
return await super().poll_tasks(task_ids)
def _create_node(self, data: Dict[str, Any]) -> SyncNode:
node = self._create_basic_node(data, depend_ids=[])
if data.get("project_id"):
node.context["project_id"] = data["project_id"]
node.context["lar_id"] = node.data_id
return node
# ========== 静态 API 函数 ==========
async def api_update_lar_main(client, data: LarMainInfoUpdateSchema, push_id: str, biz_id: str) -> None:
"""
PUT /lar/main - 更新主数据
Args:
client: API 客户端
data: 更新数据(Pydantic model
push_id: 推送ID
biz_id: 业务ID
"""
request_data = {"push_id": push_id, "biz_id": biz_id, "data": data.model_dump(exclude_unset=True)}
response = await client.request(method="PUT", endpoint="/lar/main", data=request_data)
if response.get("code") != 200:
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
async def api_update_lar_resettlement(client, data: LarResettlementSchema, push_id: str, biz_id: str) -> None:
"""
PUT /lar/resettlement - 更新搬迁信息
Args:
client: API 客户端
data: 更新数据(Pydantic model
push_id: 推送ID
biz_id: 业务ID
"""
request_data = {"push_id": push_id, "biz_id": biz_id, "data": data.model_dump(exclude_unset=True)}
response = await client.request(method="PUT", endpoint="/lar/resettlement", data=request_data)
if response.get("code") != 200:
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
async def api_update_lar_investment(client, data: LarInvestmentSchema, push_id: str, biz_id: str) -> None:
"""
PUT /lar/investment - 更新投资信息
Args:
client: API 客户端
data: 更新数据(Pydantic model
push_id: 推送ID
biz_id: 业务ID
"""
request_data = {"push_id": push_id, "biz_id": biz_id, "data": data.model_dump(exclude_unset=True)}
response = await client.request(method="PUT", endpoint="/lar/investment", data=request_data)
if response.get("code") != 200:
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
@@ -0,0 +1,10 @@
"""LAR Domain - JSONL Handler"""
from ...datasource import BaseJsonlHandler, JsonlDataSource
from .sync_node import LarSyncNode
from schemas.project_extensions.lar import LarDetailSchema
class LarJsonlHandler(BaseJsonlHandler):
"""移民征地 JSONL Handler"""
def __init__(self, datasource: JsonlDataSource):
super().__init__(datasource, "lar", LarSyncNode, LarDetailSchema)
+17
View File
@@ -0,0 +1,17 @@
"""LAR domain registration"""
from ...common.registry import DomainRegistry
from schemas.project_extensions.lar import LarDetailSchema
from .sync_node import LarSyncNode
from .sync_strategy import LarSyncStrategy
from .jsonl_handler import LarJsonlHandler
from .api_handler import LarApiHandler
# 注册 lar domain
DomainRegistry.register(
node_type="lar",
schema=LarDetailSchema,
node_class=LarSyncNode,
strategy_class=LarSyncStrategy,
jsonl_handler_class=LarJsonlHandler,
api_handler_class=LarApiHandler,
)
@@ -0,0 +1,14 @@
"""LAR SyncNode"""
from typing import Dict, Any
from ...common.sync_node import SyncNode
from schemas.project_extensions.lar import LarDetailSchema
class LarSyncNode(SyncNode[LarDetailSchema]):
"""移民征地同步节点"""
node_type = "lar"
schema = LarDetailSchema
def __init__(self, **kwargs):
super().__init__(**kwargs)
@@ -0,0 +1,20 @@
from ...sync_system.strategy import DefaultSyncStrategy
from ...sync_system.config import StrategyConfig, OrphanAction, UpdateDirection
from schemas.project_extensions.lar import LarDetailSchema
class LarSyncStrategy(DefaultSyncStrategy[LarDetailSchema]):
"""
LAR 同步策略(项目扩展)。
"""
schema = LarDetailSchema
default_config = StrategyConfig(
auto_bind=True,
auto_bind_fields=["project_id"],
depend_fields={"project_id": "project"},
local_orphan_action=OrphanAction.NONE,
remote_orphan_action=OrphanAction.NONE,
update_direction=UpdateDirection.PUSH,
)
@@ -0,0 +1,8 @@
"""LAR Change (移民变更) Domain Module"""
from .sync_node import LarChangeSyncNode
from .sync_strategy import LarChangeSyncStrategy
from .api_handler import LarChangeApiHandler
from .jsonl_handler import LarChangeJsonlHandler
__all__ = ["LarChangeSyncNode", "LarChangeSyncStrategy", "LarChangeApiHandler", "LarChangeJsonlHandler"]
@@ -0,0 +1,180 @@
"""
LAR Change (移民变更) API Handler
职责:
- 创建和更新移民变更记录(依赖 Project)
实现的接口:
POST:
- /lar/change - 创建移民变更
PUT:
- /lar/change - 更新移民变更
"""
from typing import List, Dict, Any
from ...datasource.api.handler import BaseApiHandler
from ...datasource.task_result import TaskResult
from ...common.sync_node import SyncNode
from ...datasource.api.errors import ERROR_NODE_DATA_NONE, ERROR_VALIDATION_FAILED
from .sync_node import LarChangeSyncNode
from schemas.project_extensions.lar import (
LarChangeDetailSchema,
LarChangeCreateSchema,
LarChangeUpdateSchema
)
class LarChangeApiHandler(BaseApiHandler):
"""移民变更 API Handler"""
def __init__(self):
super().__init__()
self._node_type = "lar_change"
self._node_class = LarChangeSyncNode
self._schema = LarChangeDetailSchema
async def load(self) -> List[SyncNode]:
"""
加载移民变更数据(依赖 Project)
"""
projects = self._collection.filter(node_type="project")
project_ids = [p.data_id for p in projects if p.data_id]
if not project_ids:
return []
# 移民变更通常需要单独查询,这里暂时返回空
return []
async def create_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
"""批量创建移民变更"""
from pydantic import ValidationError
results = []
for node in nodes:
node_data = node.get_data()
if node_data is None:
results.append(TaskResult.failed(node_id=node.node_id, error=ERROR_NODE_DATA_NONE))
continue
# 验证并转换为 Pydantic model
try:
create_model = LarChangeCreateSchema.model_validate(node_data)
except ValidationError as e:
results.append(TaskResult.failed(node_id=node.node_id, error=f"{ERROR_VALIDATION_FAILED}: {e}"))
continue
push_id = self._generate_push_id()
try:
response = await api_create_lar_change(self.api_client, create_model, push_id)
if self.poll_mode == "sync":
created_id = self._extract_created_id_from_response(response)
if not created_id:
results.append(
TaskResult.failed(
node_id=node.node_id,
error="Missing created_id in create response"
)
)
continue
results.append(TaskResult.success(node_id=node.node_id, data_id=created_id))
else:
results.append(TaskResult.in_progress(node_id=node.node_id, task_id=push_id))
except Exception as e:
results.append(TaskResult.failed(node_id=node.node_id, error=str(e)))
return results
async def update_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
"""批量更新移民变更"""
from pydantic import ValidationError
results = []
for node in nodes:
node_data = node.get_data()
if node_data is None:
error_msg = node.error or "Node data is missing or invalid"
results.append(TaskResult.failed(node_id=node.node_id, error=f"Cannot update: {error_msg}"))
continue
origin_data = node.get_origin_data() or {}
update_data = self._filter_update_data(node_data, origin_data, LarChangeUpdateSchema)
if not update_data:
from ...common.types import SyncAction
results.append(TaskResult.skipped(node_id=node.node_id, reason="No updatable fields changed"))
continue
# 验证并转换为 Pydantic model
try:
update_model = LarChangeUpdateSchema.model_validate(update_data)
except ValidationError as e:
results.append(TaskResult.failed(node_id=node.node_id, error=f"{ERROR_VALIDATION_FAILED}: {e}"))
continue
push_id = self._generate_push_id()
biz_id = node.data_id or update_model.id
if not biz_id:
results.append(TaskResult.failed(node_id=node.node_id, error="Missing biz_id for update"))
continue
try:
await api_update_lar_change(self.api_client, update_model, push_id, biz_id)
results.append(TaskResult.success(node_id=node.node_id))
except Exception as e:
results.append(TaskResult.failed(node_id=node.node_id, error=str(e)))
return results
async def delete_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
"""不支持删除"""
return [
TaskResult.failed(node_id=node.node_id, error="LAR change deletion not supported")
for node in nodes
]
async def poll_tasks(self, task_ids: List[str]) -> Dict[str, TaskResult]:
"""轮询异步任务状态"""
return await super().poll_tasks(task_ids)
def _create_node(self, data: Dict[str, Any]) -> SyncNode:
node = self._create_basic_node(data, depend_ids=[])
if data.get("project_id"):
node.context["project_id"] = data["project_id"]
return node
# ========== 静态 API 函数 ==========
async def api_create_lar_change(client, data: LarChangeCreateSchema, push_id: str) -> Dict[str, Any]:
"""
POST /lar/change - 创建移民变更
Args:
client: API 客户端
data: 创建数据(Pydantic model
push_id: 推送ID
biz_id: 业务ID
"""
request_data = {"push_id": push_id, "data": data.model_dump()}
response = await client.request(method="POST", endpoint="/lar/change", data=request_data)
if response.get("code") != 200:
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
return response
async def api_update_lar_change(client, data: LarChangeUpdateSchema, push_id: str, biz_id: str) -> None:
"""
PUT /lar/change - 更新移民变更
Args:
client: API 客户端
data: 更新数据(Pydantic model
push_id: 推送ID
biz_id: 业务ID
"""
request_data = {"push_id": push_id, "biz_id": biz_id, "data": data.model_dump(exclude_unset=True)}
response = await client.request(method="PUT", endpoint="/lar/change", data=request_data)
if response.get("code") != 200:
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
@@ -0,0 +1,12 @@
"""LAR Change JSONL Data Handler"""
from ...datasource.jsonl.handler import BaseJsonlHandler
from ...datasource.jsonl.datasource import JsonlDataSource
from .sync_node import LarChangeSyncNode
from schemas.project_extensions.lar import LarChangeDetailSchema
class LarChangeJsonlHandler(BaseJsonlHandler):
"""LAR Change (移民变更) JSONL 数据处理器"""
def __init__(self, datasource: JsonlDataSource):
super().__init__(datasource, "lar_change", LarChangeSyncNode, LarChangeDetailSchema)
@@ -0,0 +1,17 @@
"""LAR change domain registration"""
from ...common.registry import DomainRegistry
from schemas.project_extensions.lar import LarChangeDetailSchema
from .sync_node import LarChangeSyncNode
from .sync_strategy import LarChangeSyncStrategy
from .api_handler import LarChangeApiHandler
from .jsonl_handler import LarChangeJsonlHandler
# 注册 lar_change domain
DomainRegistry.register(
node_type="lar_change",
schema=LarChangeDetailSchema,
node_class=LarChangeSyncNode,
strategy_class=LarChangeSyncStrategy,
api_handler_class=LarChangeApiHandler,
jsonl_handler_class=LarChangeJsonlHandler,
)
@@ -0,0 +1,10 @@
"""LAR Change SyncNode"""
from ...common.sync_node import SyncNode
from schemas.project_extensions.lar import LarChangeDetailSchema
class LarChangeSyncNode(SyncNode[LarChangeDetailSchema]):
"""移民变更同步节点"""
node_type = "lar_change"
schema = LarChangeDetailSchema
@@ -0,0 +1,20 @@
from ...sync_system.strategy import DefaultSyncStrategy
from ...sync_system.config import StrategyConfig, OrphanAction, UpdateDirection
from schemas.project_extensions.lar import LarChangeDetailSchema
class LarChangeSyncStrategy(DefaultSyncStrategy[LarChangeDetailSchema]):
"""
LAR Change 同步策略。
"""
schema = LarChangeDetailSchema
default_config = StrategyConfig(
auto_bind=True,
auto_bind_fields=["project_id", "change_time", "title"],
depend_fields={"project_id": "project"},
local_orphan_action=OrphanAction.NONE,
remote_orphan_action=OrphanAction.NONE,
update_direction=UpdateDirection.PUSH,
)
@@ -0,0 +1,6 @@
"""Material domain"""
from .sync_node import MaterialSyncNode
from .sync_strategy import MaterialSyncStrategy
from .jsonl_handler import MaterialJsonlHandler
__all__ = ["MaterialSyncNode", "MaterialSyncStrategy", "MaterialJsonlHandler"]
@@ -0,0 +1,355 @@
"""
Material API Handler (V2)
职责:
- 加载物资列表(依赖 Project)
- 物资创建/更新/删除(批量接口)
"""
from typing import List, Dict, Any
from ...datasource.api.handler import BaseApiHandler
from ...datasource.task_result import TaskResult
from ...common.sync_node import SyncNode
from ...datasource.api.errors import (
ERROR_NODE_DATA_NONE,
ERROR_VALIDATION_FAILED,
ERROR_PROJECT_ID_NOT_FOUND
)
from .sync_node import MaterialSyncNode
from schemas.material.material import MaterialResponse, MaterialCreate, MaterialUpdate, MaterialPlanUpdate
class MaterialApiHandler(BaseApiHandler):
"""物资 API Handler"""
def __init__(self):
super().__init__()
self._node_type = "material"
self._node_class = MaterialSyncNode
self._schema = MaterialResponse
self.update_schemas = [MaterialCreate, MaterialUpdate, MaterialPlanUpdate]
async def load(self) -> List[SyncNode]:
"""
加载物资列表
依赖 Project
使用 GET /material/list?project_id={id} 接口
"""
try:
projects = self._collection.filter(node_type="project")
project_ids = [p.data_id for p in projects if p.data_id]
if not project_ids:
print("⚠️ No projects found, skipping material load")
return []
all_materials = []
for project_id in project_ids:
try:
materials = await api_get_material_list(self.api_client, project_id)
for material in materials:
# 已验证的 Pydantic model,转换为 dict
material_data = material.model_dump()
all_materials.append(self._create_node(material_data))
except Exception as e:
print(f"❌ Failed to load materials for project {project_id}: {e}")
continue
return all_materials
except Exception as e:
print(f"❌ Failed to load materials: {e}")
return []
async def create_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
"""批量创建物资"""
results = []
for node in nodes:
result = await self._create_single(node)
results.append(result)
return results
async def update_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
"""
批量更新物资
支持两种更新类型:
1. MaterialUpdate - 基础信息更新
2. MaterialPlanUpdate - 计划信息更新
更新逻辑:
- 对每种 schema,检查是否有差异
- 如果有差异,调用对应 API
"""
from pydantic import ValidationError
results = []
for node in nodes:
data = node.get_data()
if not data:
results.append(TaskResult.failed(node_id=node.node_id, error=ERROR_NODE_DATA_NONE))
continue
origin_data = node.get_origin_data() or {}
biz_id = node.data_id or data.get("id")
if not biz_id:
results.append(TaskResult.failed(node_id=node.node_id, error="Missing biz_id for update"))
continue
node_updated = False
error = None
# 1. 基础信息更新 (MaterialUpdate)
base_diff = self._get_schema_diff(MaterialUpdate, data, origin_data, node.node_id)
if base_diff:
try:
update_model = MaterialUpdate.model_validate(data)
push_id = self._generate_push_id()
await api_update_material(self.api_client, update_model, push_id, biz_id)
node_updated = True
except ValidationError as e:
error = f"{ERROR_VALIDATION_FAILED}: {e}"
# 2. 计划信息更新 (MaterialPlanUpdate)
plan_diff = self._get_schema_diff(MaterialPlanUpdate, data, origin_data, node.node_id)
if not error and plan_diff:
try:
plan_model = MaterialPlanUpdate.model_validate(data)
push_id = self._generate_push_id()
# 需要调用对应的计划更新 API
await api_update_material_plan(
self.api_client,
plan_model.model_dump(exclude_unset=True),
push_id,
biz_id
)
node_updated = True
except ValidationError as e:
error = f"{ERROR_VALIDATION_FAILED}: {e}"
# 汇总结果
if error:
results.append(TaskResult.failed(node_id=node.node_id, error=error))
elif node_updated:
results.append(TaskResult.success(node_id=node.node_id))
else:
# 没有任何字段需要更新
results.append(TaskResult.skipped(node_id=node.node_id, reason="No updatable fields changed"))
return results
async def delete_all(self, nodes: List[SyncNode]) -> List[TaskResult]:
"""批量删除物资"""
results = []
for node in nodes:
result = await self._delete_single(node)
results.append(result)
return results
async def _create_single(self, node: SyncNode) -> TaskResult:
"""创建物资"""
try:
data = node.get_data()
if not data:
return TaskResult.failed(
node_id=node.node_id,
error=ERROR_NODE_DATA_NONE
)
push_id = self._generate_push_id()
project_id = node.context.get("project_id")
if not project_id:
project_id = data.get("project_id")
if not project_id:
return TaskResult.failed(
node_id=node.node_id,
error=ERROR_PROJECT_ID_NOT_FOUND
)
# 验证并转换为 Pydantic model
from pydantic import ValidationError
create_data = {"project_id": project_id, **data}
try:
create_model = MaterialCreate.model_validate(create_data)
except ValidationError as e:
return TaskResult.failed(
node_id=node.node_id,
error=f"{ERROR_VALIDATION_FAILED}: {e}"
)
response = await api_create_material(self.api_client, create_model, push_id)
if self.poll_mode == "sync":
created_id = self._extract_created_id_from_response(response)
if not created_id:
return TaskResult.failed(
node_id=node.node_id,
error="Missing created_id in create response"
)
return TaskResult.success(node_id=node.node_id, data_id=created_id)
else:
return TaskResult.in_progress(node_id=node.node_id, task_id=push_id)
except Exception as e:
return TaskResult.failed(
node_id=node.node_id,
error=f"Create failed: {str(e)}"
)
async def _update_single(self, node: SyncNode) -> TaskResult:
"""更新物资"""
if not self.api_client:
return TaskResult.failed(
node_id=node.node_id,
error="api_client not initialized"
)
try:
data = node.get_data()
if not data:
return TaskResult.failed(
node_id=node.node_id,
error="Node data is None or validation failed"
)
push_id = self._generate_push_id()
biz_id = node.data_id or data.get("id")
if not biz_id:
return TaskResult.failed(node_id=node.node_id, error="Missing biz_id for update")
# 验证并转换为 Pydantic model
from pydantic import ValidationError
try:
update_model = MaterialUpdate.model_validate(data)
except ValidationError as e:
return TaskResult.failed(
node_id=node.node_id,
error=f"{ERROR_VALIDATION_FAILED}: {e}"
)
await api_update_material(self.api_client, update_model, push_id, biz_id)
return TaskResult.success(node_id=node.node_id)
except Exception as e:
return TaskResult.failed(
node_id=node.node_id,
error=f"Update failed: {str(e)}"
)
async def _delete_single(self, node: SyncNode) -> TaskResult:
"""删除物资"""
try:
push_id = self._generate_push_id()
biz_id = node.data_id
if not biz_id:
return TaskResult.failed(node_id=node.node_id, error="Missing biz_id for delete")
request_data = {
"push_id": push_id,
"biz_id": biz_id,
}
response = await self.api_client.request(
method="DELETE",
endpoint="/material/delete",
data=request_data
)
if response.get("code") == 200:
return TaskResult.success(node_id=node.node_id)
else:
return TaskResult.failed(
node_id=node.node_id,
error=f"API returned error: {response.get('message', 'Unknown error')}"
)
except Exception as e:
return TaskResult.failed(
node_id=node.node_id,
error=f"Delete failed: {str(e)}"
)
def _create_node(self, data: Dict[str, Any]) -> SyncNode:
"""
创建物资同步节点
设置 project_id 和 material_id 到 context
"""
node = self._create_basic_node(data, depend_ids=[])
if data.get("project_id"):
node.context["project_id"] = data["project_id"]
node.context["material_id"] = node.data_id
return node
# ========== 静态 API 函数 ==========
async def api_get_material(client, material_id: str) -> Dict[str, Any]:
"""获取单个物资数据 GET /material"""
response = await client.request(
method="GET",
endpoint="/material",
params={"material_id": material_id}
)
if response.get("code") != 200:
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
return response.get("data", {})
# ========== 静态 API 函数 ==========
async def api_get_material_list(client, project_id: str) -> List[MaterialResponse]:
"""GET /material/list - 获取物资列表"""
response = await client.request(
method="GET",
endpoint="/material/list",
params={"project_id": project_id}
)
if response.get("code") != 200:
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
items = response.get("data", [])
# 验证每个返回项
from pydantic import ValidationError
validated_items = []
for item in items:
try:
validated_item = MaterialResponse.model_validate(item)
validated_items.append(validated_item)
except ValidationError as e:
print(f"⚠️ Skipping invalid material item: {e}")
continue
return validated_items
async def api_create_material(client, data: MaterialCreate, push_id: str) -> Dict[str, Any]:
"""创建物资 POST /material/create"""
request_data = {"push_id": push_id, "data": data.model_dump()}
response = await client.request(method="POST", endpoint="/material/create", data=request_data)
if response.get("code") != 200:
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
return response
async def api_update_material(client, data: MaterialUpdate, push_id: str, biz_id: str) -> None:
"""更新物资 PUT /material/update"""
request_data = {"push_id": push_id, "biz_id": biz_id, "data": data.model_dump(exclude_unset=True)}
response = await client.request(method="PUT", endpoint="/material/update", data=request_data)
if response.get("code") != 200:
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
async def api_update_material_plan(client, data: Dict[str, Any], push_id: str, biz_id: str) -> None:
"""更新物资计划 PUT /material/plan"""
request_data = {"push_id": push_id, "biz_id": biz_id, "data": data}
response = await client.request(method="PUT", endpoint="/material/plan", data=request_data)
if response.get("code") != 200:
raise Exception(f"API error: {response.get('message', 'Unknown error')}")
@@ -0,0 +1,10 @@
"""Material Domain - JSONL Handler"""
from ...datasource import BaseJsonlHandler, JsonlDataSource
from .sync_node import MaterialSyncNode
from schemas.material.material import MaterialResponse
class MaterialJsonlHandler(BaseJsonlHandler):
"""物资 JSONL Handler"""
def __init__(self, datasource: JsonlDataSource):
super().__init__(datasource, "material", MaterialSyncNode, MaterialResponse)
@@ -0,0 +1,17 @@
"""Material domain registration"""
from ...common.registry import DomainRegistry
from schemas.material.material import MaterialResponse
from .sync_node import MaterialSyncNode
from .sync_strategy import MaterialSyncStrategy
from .jsonl_handler import MaterialJsonlHandler
from .api_handler import MaterialApiHandler
# 注册 material domain
DomainRegistry.register(
node_type="material",
schema=MaterialResponse,
node_class=MaterialSyncNode,
strategy_class=MaterialSyncStrategy,
jsonl_handler_class=MaterialJsonlHandler,
api_handler_class=MaterialApiHandler,
)
@@ -0,0 +1,11 @@
from ...common.sync_node import SyncNode
from schemas.material.material import MaterialResponse
class MaterialSyncNode(SyncNode[MaterialResponse]):
"""物资同步节点"""
node_type = "material"
schema = MaterialResponse
def __init__(self, **kwargs):
super().__init__(**kwargs)
@@ -0,0 +1,20 @@
from ...sync_system.strategy import DefaultSyncStrategy
from ...sync_system.config import StrategyConfig, OrphanAction, UpdateDirection
from schemas.material.material import MaterialResponse
class MaterialSyncStrategy(DefaultSyncStrategy[MaterialResponse]):
"""
Material 同步策略(合同子业务)。
"""
schema = MaterialResponse
default_config = StrategyConfig(
auto_bind=True,
auto_bind_fields=["contract_id", "material_type"],
depend_fields={"contract_id": "contract", "project_id": "project"},
local_orphan_action=OrphanAction.CREATE_REMOTE,
remote_orphan_action=OrphanAction.NONE,
update_direction=UpdateDirection.PUSH,
)

Some files were not shown because too many files have changed in this diff Show More