init: Prefect ETL sync project (RIIN/SDS → PG) with 12 sync flows
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
"""共享基础设施工具:HTTP 基类、TTL 缓存、并发、批量切分。"""
|
||||
|
||||
from api._common.batch import chunk, parse_id_text, split_lines
|
||||
from api._common.cache import TTLCache
|
||||
from api._common.concurrency import run_concurrent
|
||||
from api._common.http import TokenHttpClient
|
||||
|
||||
__all__ = [
|
||||
"TokenHttpClient",
|
||||
"TTLCache",
|
||||
"run_concurrent",
|
||||
"parse_id_text",
|
||||
"split_lines",
|
||||
"chunk",
|
||||
]
|
||||
@@ -0,0 +1,36 @@
|
||||
"""批量切分辅助:对应 TS SDK 里订单 OID/快递单号、SDS 订单关键词的批量拆分。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
_MULTI_SEP = re.compile(r"[,,\s\n\r]+")
|
||||
|
||||
|
||||
def parse_id_text(text: str) -> list[str]:
|
||||
"""把逗号/空白/换行分隔的字符串解析为去重的 ID 列表(RIIN 用)。"""
|
||||
items = _MULTI_SEP.split(text or "")
|
||||
seen: dict[str, None] = {}
|
||||
for item in items:
|
||||
item = item.strip()
|
||||
if item and item not in seen:
|
||||
seen[item] = None
|
||||
return list(seen.keys())
|
||||
|
||||
|
||||
def split_lines(text: str) -> list[str]:
|
||||
"""把换行分隔的字符串解析为去重的列表(SDS 订单号 keywords 用)。"""
|
||||
items = re.split(r"[\n\r]+", text or "")
|
||||
seen: dict[str, None] = {}
|
||||
for item in items:
|
||||
item = item.strip()
|
||||
if item and item not in seen:
|
||||
seen[item] = None
|
||||
return list(seen.keys())
|
||||
|
||||
|
||||
def chunk(items: list[str], size: int) -> list[list[str]]:
|
||||
"""把列表按 size 切成多批。"""
|
||||
if not items:
|
||||
return []
|
||||
return [items[i : i + size] for i in range(0, len(items), size)]
|
||||
@@ -0,0 +1,36 @@
|
||||
"""简单的 TTL 内存缓存(纯 Python,无第三方依赖)。
|
||||
|
||||
对应 TS SDK 的 MemoryCache,用于主数据等就近缓存,生命周期跟随 client 实例。
|
||||
注意:这是客户端层缓存,与 Prefect 的 task 缓存(cache_key_fn)是两回事。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any, Generic, TypeVar
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
_DEFAULT_TTL_SECONDS = 300
|
||||
|
||||
|
||||
class TTLCache(Generic[T]):
|
||||
def __init__(self, ttl_seconds: int | float = _DEFAULT_TTL_SECONDS) -> None:
|
||||
self._ttl = ttl_seconds
|
||||
self._store: dict[str, tuple[T, float]] = {}
|
||||
|
||||
def get(self, key: str) -> T | None:
|
||||
item = self._store.get(key)
|
||||
if item is None:
|
||||
return None
|
||||
value, expires_at = item
|
||||
if time.monotonic() > expires_at:
|
||||
self._store.pop(key, None)
|
||||
return None
|
||||
return value
|
||||
|
||||
def set(self, key: str, value: T) -> None:
|
||||
self._store[key] = (value, time.monotonic() + self._ttl)
|
||||
|
||||
def clear(self) -> None:
|
||||
self._store.clear()
|
||||
@@ -0,0 +1,22 @@
|
||||
"""并发执行辅助:线程池 + 信号量,对应 TS SDK 的 ConcurrentLimiter(默认 10)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Any, TypeVar
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
_DEFAULT_CONCURRENCY = 10
|
||||
|
||||
|
||||
def run_concurrent(
|
||||
funcs: list[Callable[[], T]],
|
||||
limit: int = _DEFAULT_CONCURRENCY,
|
||||
) -> list[T]:
|
||||
"""并发执行一批无参 callable(每个返回一个结果),保持输入顺序返回结果。"""
|
||||
if not funcs:
|
||||
return []
|
||||
with ThreadPoolExecutor(max_workers=max(1, min(limit, len(funcs)))) as pool:
|
||||
return list(pool.map(lambda fn: fn(), funcs))
|
||||
@@ -0,0 +1,98 @@
|
||||
"""基础 HTTP 客户端:token 注入 + 401 自动重登。
|
||||
|
||||
两个外部系统(RIIN / SDS)共享这套行为,仅以下差异由子类配置:
|
||||
- base_url
|
||||
- token 写入的请求头名(RIIN: Authorization,SDS: access-token)
|
||||
- 固定请求头(RIIN 带 X-Time-Zone / X-Requested-With,SDS 无)
|
||||
- 登录实现(子类实现 _login)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
class TokenHttpClient(ABC):
|
||||
"""带 token 鉴权的 HTTP 客户端基类(组合式,非强制耦合)。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
*,
|
||||
token_header: str,
|
||||
fixed_headers: dict[str, str] | None = None,
|
||||
login_markers: tuple[str, ...] = (),
|
||||
timeout: float = 30.0,
|
||||
) -> None:
|
||||
self._token: str | None = None
|
||||
self._token_header = token_header
|
||||
self._login_markers = login_markers
|
||||
self._http = httpx.Client(
|
||||
base_url=base_url,
|
||||
headers=fixed_headers or {},
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
@abstractmethod
|
||||
def _login(self) -> str:
|
||||
"""执行登录并返回新 token。子类实现具体认证逻辑。"""
|
||||
|
||||
# ----- token 生命周期 -----
|
||||
|
||||
def _ensure_token(self) -> str:
|
||||
if not self._token:
|
||||
self._token = self._login()
|
||||
return self._token
|
||||
|
||||
def _clear_token(self) -> None:
|
||||
self._token = None
|
||||
|
||||
def _is_login_call(self, url: str) -> bool:
|
||||
return any(marker in url for marker in self._login_markers)
|
||||
|
||||
# ----- 请求 -----
|
||||
|
||||
def _send(
|
||||
self,
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
params: dict[str, Any] | None = None,
|
||||
body: dict[str, Any] | None = None,
|
||||
_retry: bool = True,
|
||||
) -> Any:
|
||||
headers: dict[str, str] = {}
|
||||
if not self._is_login_call(url):
|
||||
headers[self._token_header] = self._ensure_token()
|
||||
|
||||
response = self._http.request(method, url, params=params, json=body, headers=headers)
|
||||
|
||||
if response.status_code == 401 and _retry and not self._is_login_call(url):
|
||||
self._clear_token()
|
||||
return self._send(method, url, params=params, body=body, _retry=False)
|
||||
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def get(self, url: str, params: dict[str, Any] | None = None) -> Any:
|
||||
return self._send("GET", url, params=params)
|
||||
|
||||
def post(
|
||||
self,
|
||||
url: str,
|
||||
body: dict[str, Any] | None = None,
|
||||
params: dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
return self._send("POST", url, body=body, params=params)
|
||||
|
||||
def close(self) -> None:
|
||||
self._http.close()
|
||||
|
||||
def __enter__(self) -> "TokenHttpClient":
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc: Any) -> None:
|
||||
self.close()
|
||||
Reference in New Issue
Block a user