初始化工程

This commit is contained in:
2026-07-01 22:07:55 +08:00
parent 9bb6d7a654
commit fc2b0944d2
248 changed files with 65020 additions and 316 deletions
+216
View File
@@ -0,0 +1,216 @@
"""扩展数据定时拉取引擎 — 从外部 API 拉取数据写入 Parquet。"""
from __future__ import annotations
import asyncio
import json
import logging
import threading
from datetime import date, datetime, timezone
from functools import reduce
from typing import Any
import httpx
from app.services.ext_data import (
ExtConfig,
ExtConfigStore,
PullConfig,
rows_to_parquet,
)
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# 响应解析
# ---------------------------------------------------------------------------
def _extract_rows(data: Any, path: str) -> list[dict]:
"""按 dot-path 从 JSON 响应中提取行数组。
例: path="data.list" → response["data"]["list"]
如果 path 为空,直接将 data 视为数组。
"""
if not path:
if isinstance(data, list):
return data
raise ValueError("response_path 为空但响应不是数组")
keys = path.split(".")
current = data
for key in keys:
if isinstance(current, dict):
if key not in current:
raise ValueError(f"响应中不存在路径 '{path}',缺失键 '{key}'")
current = current[key]
elif isinstance(current, list):
try:
current = current[int(key)]
except (ValueError, IndexError) as e:
raise ValueError(f"响应路径 '{path}' 解析失败: {e}") from e
else:
raise ValueError(f"响应路径 '{path}' 中间值不是 dict/list: {type(current)}")
if not isinstance(current, list):
raise ValueError(f"路径 '{path}' 指向的不是数组,而是 {type(current)}")
return current
def _apply_field_map(rows: list[dict], field_map: dict[str, str]) -> list[dict]:
"""将外部字段名映射为内部配置字段名。field_map: {外部名: 内部名}。"""
if not field_map:
return rows
mapped = []
for row in rows:
new_row: dict = {}
for k, v in row.items():
mapped_key = field_map.get(k, k)
new_row[mapped_key] = v
mapped.append(new_row)
return mapped
# ---------------------------------------------------------------------------
# 拉取执行
# ---------------------------------------------------------------------------
async def fetch_and_ingest(
config: ExtConfig,
data_dir,
) -> tuple[int, str]:
"""执行一次拉取: 请求外部 API → 解析响应 → 写入 Parquet。
Returns:
(rows_written, date_str)
"""
pull = config.pull
if not pull or not pull.url:
raise ValueError("拉取未配置或 URL 为空")
async with httpx.AsyncClient(timeout=30) as client:
headers = pull.headers or {}
kwargs: dict[str, Any] = {"headers": headers}
if pull.method.upper() == "POST" and pull.body:
kwargs["content"] = pull.body
if "content-type" not in {k.lower() for k in headers}:
kwargs["headers"]["Content-Type"] = "application/json"
resp = await client.request(pull.method.upper(), pull.url, **kwargs)
resp.raise_for_status()
# 解析 JSON
try:
data = resp.json()
except Exception as e:
raise ValueError(f"响应不是有效 JSON: {e}") from e
# 提取行
rows = _extract_rows(data, pull.response_path)
if not rows:
raise ValueError("提取到的行数为 0")
# 字段映射
rows = _apply_field_map(rows, pull.field_map)
# 校验 symbol 列
if rows and "symbol" not in rows[0]:
raise ValueError("数据行中缺少 symbol 字段,请配置 field_map 映射")
# 写入
snap = date.today()
n = rows_to_parquet(rows, config, data_dir, snapshot_date=snap)
return n, snap.isoformat()
# ---------------------------------------------------------------------------
# 调度器
# ---------------------------------------------------------------------------
class PullScheduler:
"""后台调度器:为每个启用了 pull 的 ExtConfig 维护定时任务。"""
def __init__(self) -> None:
self._tasks: dict[str, asyncio.Task] = {}
self._running = False
self._lock = threading.Lock()
def start(self, data_dir) -> None:
"""启动调度(在 lifespan startup 调用)。"""
self._running = True
self._data_dir = data_dir
logger.info("PullScheduler started")
def stop(self) -> None:
"""停止所有任务。"""
self._running = False
for task in self._tasks.values():
task.cancel()
self._tasks.clear()
logger.info("PullScheduler stopped")
def refresh(self, data_dir) -> None:
"""重新加载配置,更新调度任务(增/删/改)。"""
self._data_dir = data_dir
store = ExtConfigStore(data_dir)
configs = store.load_all()
active_ids: set[str] = set()
for config in configs:
if not config.pull or not config.pull.enabled or not config.pull.url:
continue
active_ids.add(config.id)
if config.id not in self._tasks:
# 新增调度
task = asyncio.create_task(self._run_loop(config))
self._tasks[config.id] = task
logger.info("PullScheduler: scheduled %s (every %d min)", config.id, config.pull.schedule_minutes)
# 移除不再活跃的
for cid in list(self._tasks):
if cid not in active_ids:
self._tasks[cid].cancel()
del self._tasks[cid]
logger.info("PullScheduler: removed %s", cid)
async def _run_loop(self, config: ExtConfig) -> None:
"""单个配置的定时拉取循环。"""
try:
while self._running:
pull = config.pull
if not pull:
break
interval = max(pull.schedule_minutes * 60, 60) # 至少 60s
await asyncio.sleep(interval)
if not self._running:
break
try:
# 重新加载最新配置(用户可能中途修改)
store = ExtConfigStore(self._data_dir)
fresh = store.get(config.id)
if not fresh or not fresh.pull or not fresh.pull.enabled:
break
n, d = await fetch_and_ingest(fresh, self._data_dir)
fresh.pull.last_run = datetime.now(timezone.utc).isoformat()
fresh.pull.last_status = "success"
fresh.pull.last_message = f"{n} rows @ {d}"
fresh.pull.last_rows = n
store.upsert(fresh)
logger.info("PullScheduler: %s success, %d rows", config.id, n)
except Exception as e:
store = ExtConfigStore(self._data_dir)
fresh = store.get(config.id)
if fresh and fresh.pull:
fresh.pull.last_run = datetime.now(timezone.utc).isoformat()
fresh.pull.last_status = "error"
fresh.pull.last_message = str(e)[:200]
store.upsert(fresh)
logger.warning("PullScheduler: %s error: %s", config.id, e)
except asyncio.CancelledError:
pass
# 全局单例
pull_scheduler = PullScheduler()