init: Prefect ETL sync project (RIIN/SDS → PG) with 12 sync flows

This commit is contained in:
yeuimu
2026-07-23 08:13:09 +00:00
commit 4cba1da4e1
41 changed files with 6219 additions and 0 deletions
+15
View File
@@ -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",
]
+36
View File
@@ -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)]
+36
View File
@@ -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()
+22
View File
@@ -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))
+98
View File
@@ -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()