"""扩展数据定时拉取引擎 — 从外部 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()