实现下单任务网关与本地 worker

按 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>
This commit is contained in:
2026-07-27 16:25:20 +08:00
co-authored by Claude Opus 4.6
parent 91bfab7196
commit 07107094a7
35 changed files with 4342 additions and 62 deletions
+72 -3
View File
@@ -3,14 +3,16 @@
配置项统一使用 RAKUTEN_ 前缀,例如 RAKUTEN_APP_PORT=31107。
支持 .env 文件自动加载。
抓取服务与交易服务是两个进程,但共用这一个 Settings 类:两边都要日志、代理、
超时与同一个 Bearer token,拆成份配置只会让部署时多维护一套。下面按
「通用 / 仅抓取 / 仅交易」分区标注,各进程只读自己那部分。
抓取、交易与下单任务网关是三个进程,但共用这一个 Settings 类:三方都要日志、
代理、超时与同一个 Bearer token,拆成份配置只会让部署时多维护一套。下面按
「通用 / 仅抓取 / 仅交易 / 仅网关 / 仅交易 worker」分区标注,各进程只读自己那部分。
"""
import socket
from functools import lru_cache
from pathlib import Path
from typing import Literal
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
BASE_DIR = Path(__file__).resolve().parent.parent.parent
@@ -47,6 +49,11 @@ class Settings(BaseSettings):
trading_host: str = "0.0.0.0"
trading_port: int = 31108
# 下单任务网关监听地址(第三个部署单元)。网关部署在服务器侧,本地 worker
# 通过出站长轮询从这里领任务。详见 docs/order-gateway.md。
gateway_host: str = "0.0.0.0"
gateway_port: int = 31109
# ---- 日志配置 ----
log_level: str = "INFO"
log_to_file: bool | None = None # None 表示根据环境自动决定
@@ -102,6 +109,36 @@ class Settings(BaseSettings):
# 页面改版导致买到远超预期的订单。设为 0 表示不设上限(不建议)。
order_max_total_yen: int = 30000
# ---- 下单任务网关(仅网关进程 app.gateway.main 使用)----
# 网关的 SQLite 文件路径(相对项目根目录)。任务队列与状态镜像都在这里,
# 部署时务必放在持久化卷上,丢了等于丢了一批下单任务。
gateway_db_path: str = "data/gateway.db"
# 任务租约 TTL(秒)。worker 领取后必须在此时间内首次 report 或 renew,
# 否则网关把任务标记为 stale。**绝不自动重投**(见 docs/order-gateway.md §5)。
lease_ttl_seconds: int = 300
# 长轮询单次最长挂起秒数。worker 端的 wait 参数会被夹到这个上限。
lease_max_wait_seconds: int = Field(default=60, ge=1, le=300)
# worker 心跳超时阈值(秒)。网关 /health 据此判断 worker 是否失联:
# 正常 worker 每 30 秒来一次 lease,超过该阈值未来 lease 即视为异常。
worker_offline_alert_seconds: int = 300
# ---- 本地下单 worker(仅交易服务内的 worker 子模块使用)----
# 网关 URL。**留空则不启动 worker**,交易服务只跑登录态接口。
# 部署形态:本地机(NAT 后无公网入口)通过出站长轮询领任务,详见
# docs/order-gateway.md。
order_gateway_url: str = ""
# worker 标识。同一时间只能有一个 worker 持有 lease,这个值用来区分不同
# 本地机;留空时取主机名。
worker_id: str | None = None
# 本地订单 SQLite 文件路径。订单主表 + 状态事件 + 证据索引都在这里,
# 是执行事实的权威记录;网关上只是镜像。
trading_db_path: str = "data/trading.db"
# 页面证据落盘目录(HTML 快照 + 截图 + meta.json),按 task_id 分子目录。
evidence_dir: str = "data/evidence"
# 抓取服务基地址。worker 需要商品数据(加购要用 purchase 块)时出站请求这里,
# 不直接 import 解析器——抓取与交易是两个进程,详见 README「两个部署单元」。
scraper_base_url: str = ""
# ---- 目标站点 ----
home_url: str = DEFAULT_HOME_URL
@@ -155,6 +192,38 @@ class Settings(BaseSettings):
path.mkdir(parents=True, exist_ok=True)
return path
@property
def gateway_db_path_resolved(self) -> Path:
"""网关 SQLite 文件的绝对路径,父目录不存在时创建"""
path = Path(self.gateway_db_path)
if not path.is_absolute():
path = BASE_DIR / path
path.parent.mkdir(parents=True, exist_ok=True)
return path
@property
def trading_db_path_resolved(self) -> Path:
"""本地订单 SQLite 文件的绝对路径,父目录不存在时创建"""
path = Path(self.trading_db_path)
if not path.is_absolute():
path = BASE_DIR / path
path.parent.mkdir(parents=True, exist_ok=True)
return path
@property
def evidence_path(self) -> Path:
"""证据目录的绝对路径,不存在时创建"""
path = Path(self.evidence_dir)
if not path.is_absolute():
path = BASE_DIR / path
path.mkdir(parents=True, exist_ok=True)
return path
@property
def worker_id_effective(self) -> str:
"""worker 标识:显式配置优先,否则取主机名"""
return self.worker_id or socket.gethostname()
@lru_cache(maxsize=1)
def get_settings() -> Settings: