2c09c61165
将target_project_ids替换为更通用的data_id_filter
600 lines
21 KiB
Python
600 lines
21 KiB
Python
"""
|
||
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 字段名和位置可能不同)
|
||
- 返回 TaskResult(SUCCESS/FAILED/IN_PROGRESS)
|
||
"""
|
||
|
||
import uuid
|
||
import asyncio
|
||
from abc import abstractmethod
|
||
from copy import deepcopy
|
||
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 注入
|
||
self.handler_config: Dict[str, Any] = {}
|
||
# 子类在 __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
|
||
|
||
def set_handler_config(self, config: Dict[str, Any]) -> None:
|
||
self.handler_config = dict(config)
|
||
|
||
def get_data_id_filter(self) -> List[str]:
|
||
value = self.handler_config.get("data_id_filter", [])
|
||
if isinstance(value, str):
|
||
return [value] if value else []
|
||
if isinstance(value, list):
|
||
return [str(item) for item in value if str(item)]
|
||
return []
|
||
|
||
@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 _get_committed_approval_snapshot(self, node: 'SyncNode') -> Dict[str, Any]:
|
||
snapshot = node.context.get("approval_snapshot")
|
||
return snapshot if isinstance(snapshot, dict) else {}
|
||
|
||
def _get_pending_approval_snapshot(self, node: 'SyncNode') -> Dict[str, Any]:
|
||
snapshot = node.context.get("pending_approval_snapshot")
|
||
return snapshot if isinstance(snapshot, dict) else {}
|
||
|
||
def _get_active_approval_snapshot(self, node: 'SyncNode') -> Dict[str, Any]:
|
||
pending_snapshot = self._get_pending_approval_snapshot(node)
|
||
if pending_snapshot:
|
||
return pending_snapshot
|
||
return self._get_committed_approval_snapshot(node)
|
||
|
||
def _get_request_steps(self, node: 'SyncNode') -> List[Dict[str, Any]]:
|
||
snapshot = self._get_active_approval_snapshot(node)
|
||
steps = snapshot.get("steps")
|
||
if isinstance(steps, list):
|
||
return deepcopy(steps)
|
||
return []
|
||
|
||
def _steps_changed(self, node: 'SyncNode') -> bool:
|
||
snapshot = self._get_active_approval_snapshot(node)
|
||
return bool(snapshot.get("steps_changed"))
|
||
|
||
def _attach_request_steps(self, request_data: Dict[str, Any], node: Optional['SyncNode']) -> Dict[str, Any]:
|
||
payload = dict(request_data)
|
||
payload["steps"] = self._get_request_steps(node) if node is not None else []
|
||
return payload
|
||
|
||
def _build_full_update_data(self, new_data: Dict[str, Any], schema: Any) -> Dict[str, Any]:
|
||
validated = schema.model_validate(new_data)
|
||
return validated.model_dump(exclude_none=True)
|
||
|
||
def _mark_approval_snapshot_synced(self, node: 'SyncNode') -> None:
|
||
snapshot = self._get_active_approval_snapshot(node)
|
||
if snapshot:
|
||
committed_snapshot = deepcopy(snapshot)
|
||
committed_snapshot.pop("steps_changed", None)
|
||
node.context["approval_snapshot"] = committed_snapshot
|
||
node.context.pop("pending_approval_snapshot", None)
|
||
|
||
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,
|
||
force_full_schema: bool = False,
|
||
) -> 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:
|
||
if force_full_schema:
|
||
return new_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,
|
||
force_full_schema: bool = False,
|
||
) -> 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. 只有存在变化时才返回数据
|
||
if has_change or force_full_schema:
|
||
return schema_data
|
||
return {}
|
||
|
||
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
|