335 lines
10 KiB
Python
335 lines
10 KiB
Python
"""闲鱼兜底轮询器 (T8.1 §3.2)
|
||
|
||
当 Webhook 不可用(平台维护、网络抖动)时,由 ``poller`` 从外部 API
|
||
拉取未确认订单并写入数据库。Webhook 与 poller 共享 ``upsert_by_external_id``
|
||
幂等机制,因此两端都收到同一条订单不会重复入库。
|
||
|
||
本模块只暴露可注入的 :class:`XianyuOpenAPIClient` 协议与 :func:`poll_once`
|
||
函数;CLI/定时器启动由 ``scripts/`` 下的入口负责(见 T8.4 兜底 SOP)。
|
||
|
||
数据契约:
|
||
- 输入: ``XianyuOpenAPIClient.list_orders(since: int) -> Iterable[dict]``
|
||
- 输出: 每条订单都经过 :func:`data.channel_sync.xianyu_adapter.to_order` →
|
||
:func:`data.channel_sync.dao_extension.upsert_by_external_id`
|
||
- cursor: ``max(updated_at)`` 写入 ``poller_state`` 表(新建)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import sqlite3
|
||
from dataclasses import dataclass, field
|
||
from typing import Iterable, Optional, Protocol
|
||
|
||
from data.orders.models import utc_now_iso
|
||
|
||
from .dao_extension import UpsertResult, upsert_by_external_id
|
||
from .xianyu_adapter import XianyuEventError, parse_event, to_order
|
||
|
||
|
||
POLLER_STATE_SCHEMA: str = """
|
||
CREATE TABLE IF NOT EXISTS poller_state (
|
||
source TEXT PRIMARY KEY,
|
||
last_cursor TEXT,
|
||
last_run_at TEXT,
|
||
last_error TEXT,
|
||
run_count INTEGER NOT NULL DEFAULT 0,
|
||
error_count INTEGER NOT NULL DEFAULT 0
|
||
);
|
||
"""
|
||
|
||
POLLER_RUN_SCHEMA: str = """
|
||
CREATE TABLE IF NOT EXISTS poller_run (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
source TEXT NOT NULL,
|
||
started_at TEXT NOT NULL,
|
||
finished_at TEXT,
|
||
fetched INTEGER NOT NULL DEFAULT 0,
|
||
inserted INTEGER NOT NULL DEFAULT 0,
|
||
updated INTEGER NOT NULL DEFAULT 0,
|
||
unchanged INTEGER NOT NULL DEFAULT 0,
|
||
rejected INTEGER NOT NULL DEFAULT 0,
|
||
error_message TEXT
|
||
);
|
||
"""
|
||
|
||
|
||
class XianyuOpenAPIClient(Protocol):
|
||
"""外部 API 客户端协议,便于注入假实现做单测。"""
|
||
|
||
def list_orders(self, since: Optional[str]) -> list[dict]: ...
|
||
|
||
|
||
class _DefaultClient:
|
||
"""默认空实现;真实接入留给 T8.2 之后的真接入阶段。"""
|
||
|
||
def list_orders(self, since: Optional[str]) -> list[dict]:
|
||
return []
|
||
|
||
|
||
def apply_poller_schema(conn: sqlite3.Connection) -> None:
|
||
conn.executescript(POLLER_STATE_SCHEMA + POLLER_RUN_SCHEMA)
|
||
conn.commit()
|
||
|
||
|
||
@dataclass
|
||
class PollReport:
|
||
source: str
|
||
fetched: int = 0
|
||
inserted: int = 0
|
||
updated: int = 0
|
||
unchanged: int = 0
|
||
rejected: int = 0
|
||
error: Optional[str] = None
|
||
last_cursor: Optional[str] = None
|
||
upsert_results: list[UpsertResult] = field(default_factory=list)
|
||
|
||
|
||
def _ensure_schema(conn: sqlite3.Connection) -> None:
|
||
apply_poller_schema(conn)
|
||
|
||
|
||
def get_cursor(conn: sqlite3.Connection, source: str) -> Optional[str]:
|
||
row = conn.execute(
|
||
"SELECT last_cursor FROM poller_state WHERE source=?", (source,)
|
||
).fetchone()
|
||
return row[0] if row else None
|
||
|
||
|
||
def _set_cursor(
|
||
conn: sqlite3.Connection,
|
||
source: str,
|
||
cursor: Optional[str],
|
||
error: Optional[str] = None,
|
||
run_count_delta: int = 1,
|
||
error_count_delta: int = 0,
|
||
last_run_at: Optional[str] = None,
|
||
) -> None:
|
||
if last_run_at is None:
|
||
last_run_at = utc_now_iso()
|
||
conn.execute(
|
||
"""
|
||
INSERT INTO poller_state(
|
||
source, last_cursor, last_run_at, last_error,
|
||
run_count, error_count
|
||
) VALUES (?, ?, ?, ?, ?, ?)
|
||
ON CONFLICT(source) DO UPDATE SET
|
||
last_cursor = excluded.last_cursor,
|
||
last_run_at = excluded.last_run_at,
|
||
last_error = excluded.last_error,
|
||
run_count = run_count + excluded.run_count,
|
||
error_count = error_count + excluded.error_count
|
||
""",
|
||
(source, cursor, last_run_at, error, run_count_delta, error_count_delta),
|
||
)
|
||
|
||
|
||
def _record_run(
|
||
conn: sqlite3.Connection,
|
||
*,
|
||
source: str,
|
||
started_at: str,
|
||
finished_at: str,
|
||
fetched: int,
|
||
inserted: int,
|
||
updated: int,
|
||
unchanged: int,
|
||
rejected: int,
|
||
error_message: Optional[str] = None,
|
||
) -> int:
|
||
cur = conn.execute(
|
||
"""
|
||
INSERT INTO poller_run(
|
||
source, started_at, finished_at, fetched, inserted,
|
||
updated, unchanged, rejected, error_message
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
""",
|
||
(
|
||
source,
|
||
started_at,
|
||
finished_at,
|
||
fetched,
|
||
inserted,
|
||
updated,
|
||
unchanged,
|
||
rejected,
|
||
error_message,
|
||
),
|
||
)
|
||
return int(cur.lastrowid or 0)
|
||
|
||
|
||
def _parse_event_dicts(raw_orders: Iterable[dict]) -> list[dict]:
|
||
"""把 list_orders() 返回的 dict 列表规整化:必须含 raw_body (JSON str)。
|
||
|
||
兜底模式:poller 拿到的 order 字典与 Webhook body 同构;这里不重新解析,
|
||
留给 :func:`_process_one` 走完整 parse_event 路径以复用校验。
|
||
"""
|
||
out: list[dict] = []
|
||
for raw in raw_orders:
|
||
if "raw_body" not in raw:
|
||
# 上游直接给了字段时,按 Webhook body 形式重新组装
|
||
import json as _json
|
||
|
||
raw = dict(raw)
|
||
raw.setdefault("raw_body", _json.dumps(raw, ensure_ascii=False))
|
||
out.append(raw)
|
||
return out
|
||
|
||
|
||
def _process_one(
|
||
conn: sqlite3.Connection,
|
||
raw: dict,
|
||
*,
|
||
actor: str = "xianyu_poller",
|
||
) -> UpsertResult:
|
||
"""单条订单: parse_event → to_order → upsert_by_external_id。"""
|
||
event = parse_event(raw["raw_body"])
|
||
order = to_order(event)
|
||
return upsert_by_external_id(
|
||
conn,
|
||
order,
|
||
actor=actor,
|
||
reason=f"poller_event_{event.event_id}",
|
||
)
|
||
|
||
|
||
def poll_once(
|
||
conn: sqlite3.Connection,
|
||
*,
|
||
source: str = "xianyu",
|
||
client: XianyuOpenAPIClient | None = None,
|
||
actor: str = "xianyu_poller",
|
||
) -> PollReport:
|
||
"""执行一次轮询;返回 :class:`PollReport`。
|
||
|
||
- 读取上次 cursor
|
||
- 调 ``client.list_orders(since=cursor)``
|
||
- 对每条订单走 parse + upsert
|
||
- 写 ``poller_run`` / 更新 ``poller_state.cursor``
|
||
|
||
失败不会抛出,返回 ``report.error`` 携带异常信息。
|
||
"""
|
||
_ensure_schema(conn)
|
||
if client is None:
|
||
client = _DefaultClient()
|
||
|
||
started_at = utc_now_iso()
|
||
report = PollReport(source=source)
|
||
|
||
try:
|
||
cursor = get_cursor(conn, source)
|
||
raw_orders = client.list_orders(cursor)
|
||
normalized = _parse_event_dicts(raw_orders)
|
||
report.fetched = len(normalized)
|
||
for raw in normalized:
|
||
try:
|
||
res = _process_one(conn, raw, actor=actor)
|
||
except XianyuEventError as e:
|
||
report.rejected += 1
|
||
report.upsert_results.append(
|
||
UpsertResult(
|
||
order_id="",
|
||
action="rejected",
|
||
error=str(e),
|
||
)
|
||
)
|
||
continue
|
||
except Exception as e: # 解析 / 落库异常 → 单条失败不阻塞整体
|
||
report.rejected += 1
|
||
report.upsert_results.append(
|
||
UpsertResult(
|
||
order_id="",
|
||
action="rejected",
|
||
error=f"unexpected: {e!r}",
|
||
)
|
||
)
|
||
continue
|
||
report.upsert_results.append(res)
|
||
if res.action == "inserted":
|
||
report.inserted += 1
|
||
elif res.action == "updated":
|
||
report.updated += 1
|
||
elif res.action == "unchanged":
|
||
report.unchanged += 1
|
||
elif res.action == "illegal_transition":
|
||
report.rejected += 1
|
||
# cursor 推进:取最大外部 updated_at;空列表时不更新;
|
||
# fetched>0 但缺时间戳时回退到 started_at(详见 _compute_new_cursor)
|
||
report.last_cursor = _compute_new_cursor(
|
||
conn, source, raw_orders, now_iso=started_at
|
||
)
|
||
_set_cursor(
|
||
conn,
|
||
source,
|
||
cursor=report.last_cursor,
|
||
error=None,
|
||
run_count_delta=1,
|
||
error_count_delta=0,
|
||
)
|
||
_record_run(
|
||
conn,
|
||
source=source,
|
||
started_at=started_at,
|
||
finished_at=utc_now_iso(),
|
||
fetched=report.fetched,
|
||
inserted=report.inserted,
|
||
updated=report.updated,
|
||
unchanged=report.unchanged,
|
||
rejected=report.rejected,
|
||
)
|
||
conn.commit()
|
||
except Exception as e:
|
||
report.error = f"{type(e).__name__}: {e}"
|
||
_set_cursor(
|
||
conn,
|
||
source,
|
||
cursor=get_cursor(conn, source),
|
||
error=report.error,
|
||
run_count_delta=1,
|
||
error_count_delta=1,
|
||
)
|
||
_record_run(
|
||
conn,
|
||
source=source,
|
||
started_at=started_at,
|
||
finished_at=utc_now_iso(),
|
||
fetched=report.fetched,
|
||
inserted=report.inserted,
|
||
updated=report.updated,
|
||
unchanged=report.unchanged,
|
||
rejected=report.rejected,
|
||
error_message=report.error,
|
||
)
|
||
conn.commit()
|
||
return report
|
||
|
||
|
||
def _compute_new_cursor(
|
||
conn: sqlite3.Connection,
|
||
source: str,
|
||
raw_orders: list[dict],
|
||
*,
|
||
now_iso: Optional[str] = None,
|
||
) -> Optional[str]:
|
||
"""根据本批订单的最大 updated_at 推进 cursor。
|
||
|
||
异常兜底: 当 fetched>0 但本批订单全部缺 updated_at/paid_at/created_at
|
||
时,不允许 cursor 保持 None 而让 poller 永久卡在同一窗口。
|
||
推进策略: 回退到 ``utc_now_iso()``,用 "本次拉取的时间" 作为新的
|
||
cursor 下界。已知限制: 若上游 API 长期返回无时间戳的订单,可能会
|
||
跳过部分窗口;但这是 fail-forward 策略,优于无限循环。
|
||
"""
|
||
candidates: list[str] = []
|
||
for raw in raw_orders:
|
||
ts = raw.get("updated_at") or raw.get("paid_at") or raw.get("created_at")
|
||
if ts:
|
||
candidates.append(str(ts))
|
||
if candidates:
|
||
# ISO8601 字符串可直接字典序比较
|
||
return max(candidates)
|
||
if raw_orders:
|
||
# fetched>0 但本批无任何时间戳,使用当前时间作为推进值
|
||
return now_iso if now_iso is not None else utc_now_iso()
|
||
# 真正空批:沿用旧 cursor(由调用方决定是否更新)
|
||
return get_cursor(conn, source)
|