按 docs/order-gateway.md 落地:第三个部署单元 app.gateway(:31109)承担任务队列 + 状态镜像;本地 worker 在 app.trading.worker 内,按 RAKUTEN_ORDER_GATEWAY_URL 决定是否启动。规格 §5 最关键约束已守:租约过期绝不自动重投,恢复只能 reclaim, worker 收到 lease_count>1 时先核对站点订单。 站点交互(加购/下单/付款/订单列表反查)按规格 §10 留接口缝,site_interact.py 全部 NotImplementedError,verify.py 恒返回 unknown——等真实账号实测后再填, 不写猜测的提交逻辑。 310 个测试全绿,覆盖规格 §9 验收清单 12 条;架构测试守住三方互不 import。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
182 lines
5.8 KiB
Python
182 lines
5.8 KiB
Python
"""本地订单 SQLite:执行事实的权威记录
|
|
|
|
与网关的 tasks/task_reports 表对偶,但本地这张表才是「这单到底下没下、付没付」
|
|
的权威。网关失联或数据丢失时,本地这张表用于补报与对账。
|
|
|
|
三张表:
|
|
- orders:每笔任务一行,记录开始/结束时间与最终状态
|
|
- order_events:append-only 的状态迁移事件(与网关 task_reports 对账)
|
|
- evidence_index:每步证据文件的相对路径索引,便于事后翻查
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import aiosqlite
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
SCHEMA = """
|
|
CREATE TABLE IF NOT EXISTS orders (
|
|
task_id TEXT PRIMARY KEY,
|
|
site TEXT NOT NULL,
|
|
intent_json TEXT NOT NULL,
|
|
final_state TEXT,
|
|
started_at TEXT NOT NULL,
|
|
finished_at TEXT
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS order_events (
|
|
task_id TEXT NOT NULL,
|
|
state TEXT NOT NULL,
|
|
detail TEXT,
|
|
evidence_ref TEXT,
|
|
recorded_at TEXT NOT NULL,
|
|
PRIMARY KEY (task_id, state)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS evidence_index (
|
|
task_id TEXT NOT NULL,
|
|
step_no INTEGER NOT NULL,
|
|
step_name TEXT NOT NULL,
|
|
rel_path TEXT NOT NULL,
|
|
saved_at TEXT NOT NULL,
|
|
PRIMARY KEY (task_id, step_no)
|
|
);
|
|
"""
|
|
|
|
|
|
def _utcnow_iso() -> str:
|
|
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class OrderRow:
|
|
task_id: str
|
|
site: str
|
|
intent_json: str
|
|
final_state: str | None
|
|
started_at: str
|
|
finished_at: str | None
|
|
|
|
@property
|
|
def intent(self) -> dict[str, Any]:
|
|
return json.loads(self.intent_json)
|
|
|
|
|
|
class LocalDB:
|
|
"""本地订单 SQLite 访问对象"""
|
|
|
|
def __init__(self, db_path: Path):
|
|
self._db_path = db_path
|
|
self._conn: aiosqlite.Connection | None = None
|
|
|
|
async def start(self) -> None:
|
|
self._db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
self._conn = await aiosqlite.connect(str(self._db_path))
|
|
self._conn.row_factory = aiosqlite.Row
|
|
await self._conn.executescript(SCHEMA)
|
|
await self._conn.commit()
|
|
logger.info("本地订单 DB 已就绪:%s", self._db_path)
|
|
|
|
async def close(self) -> None:
|
|
if self._conn is not None:
|
|
await self._conn.close()
|
|
self._conn = None
|
|
|
|
@property
|
|
def conn(self) -> aiosqlite.Connection:
|
|
if self._conn is None:
|
|
raise RuntimeError("LocalDB 未启动:先调用 start()")
|
|
return self._conn
|
|
|
|
# ---- orders ----
|
|
|
|
async def ensure_started(self, task_id: str, site: str, intent: dict[str, Any]) -> None:
|
|
"""记录任务开始执行。已存在则忽略(同任务被重新领回时)"""
|
|
await self.conn.execute(
|
|
"INSERT OR IGNORE INTO orders (task_id, site, intent_json, started_at) "
|
|
"VALUES (?, ?, ?, ?)",
|
|
(task_id, site, json.dumps(intent, ensure_ascii=False), _utcnow_iso()),
|
|
)
|
|
await self.conn.commit()
|
|
|
|
async def get_order(self, task_id: str) -> OrderRow | None:
|
|
async with self.conn.execute(
|
|
"SELECT * FROM orders WHERE task_id = ?",
|
|
(task_id,),
|
|
) as cur:
|
|
row = await cur.fetchone()
|
|
if row is None:
|
|
return None
|
|
return OrderRow(
|
|
task_id=row["task_id"],
|
|
site=row["site"],
|
|
intent_json=row["intent_json"],
|
|
final_state=row["final_state"],
|
|
started_at=row["started_at"],
|
|
finished_at=row["finished_at"],
|
|
)
|
|
|
|
async def has_finished(self, task_id: str) -> bool:
|
|
"""是否已有终态记录。用于主循环里的本地幂等闸门"""
|
|
async with self.conn.execute(
|
|
"SELECT 1 FROM orders WHERE task_id = ? AND finished_at IS NOT NULL",
|
|
(task_id,),
|
|
) as cur:
|
|
return await cur.fetchone() is not None
|
|
|
|
async def mark_finished(self, task_id: str, final_state: str) -> None:
|
|
await self.conn.execute(
|
|
"UPDATE orders SET final_state = ?, finished_at = ? WHERE task_id = ?",
|
|
(final_state, _utcnow_iso(), task_id),
|
|
)
|
|
await self.conn.commit()
|
|
|
|
async def final_state(self, task_id: str) -> str | None:
|
|
async with self.conn.execute(
|
|
"SELECT final_state FROM orders WHERE task_id = ?",
|
|
(task_id,),
|
|
) as cur:
|
|
row = await cur.fetchone()
|
|
return row["final_state"] if row else None
|
|
|
|
# ---- events ----
|
|
|
|
async def record_event(
|
|
self,
|
|
task_id: str,
|
|
state: str,
|
|
*,
|
|
detail: str = "",
|
|
evidence_ref: str | None = None,
|
|
) -> None:
|
|
"""记录一次状态迁移事件(与网关 task_reports 对账)
|
|
|
|
同一 (task_id, state) 重复记录视为同一次——INSERT OR IGNORE,与网关侧的
|
|
INSERT OR REPLACE 不同:本地这张表是事件日志,不覆盖既有记录。
|
|
"""
|
|
await self.conn.execute(
|
|
"INSERT OR IGNORE INTO order_events "
|
|
"(task_id, state, detail, evidence_ref, recorded_at) VALUES (?, ?, ?, ?, ?)",
|
|
(task_id, state, detail, evidence_ref, _utcnow_iso()),
|
|
)
|
|
await self.conn.commit()
|
|
|
|
# ---- evidence index ----
|
|
|
|
async def index_evidence(
|
|
self, task_id: str, step_no: int, step_name: str, rel_path: str
|
|
) -> None:
|
|
await self.conn.execute(
|
|
"INSERT OR REPLACE INTO evidence_index "
|
|
"(task_id, step_no, step_name, rel_path, saved_at) VALUES (?, ?, ?, ?, ?)",
|
|
(task_id, step_no, step_name, rel_path, _utcnow_iso()),
|
|
)
|
|
await self.conn.commit()
|