实现下单任务网关与本地 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:
+55
View File
@@ -6,6 +6,7 @@
- 3xxx: 反爬/上游阻断相关错误
- 4xxx: 页面解析错误
- 5xxx: 加购/下单错误(需要账号登录态的写操作)
- 6xxx: 下单任务编排错误(网关侧的租约与状态机)
"""
@@ -169,3 +170,57 @@ class OrderGuardError(AppError):
def __init__(self, message: str):
super().__init__(message=message, code="ORDER_GUARD", err_code=5004, retryable=False)
# ---- 下单任务编排(仅网关进程 app.gateway.main 使用)----
# 下单不可逆,因此任务队列侧的错误一律标记 retryable=False——重复入队/重投
# 都可能变成重复下单。详见 docs/order-gateway.md §8。
class TaskNotFoundError(AppError):
"""任务不存在"""
def __init__(self, task_id: str):
super().__init__(
message=f"任务不存在:{task_id}",
code="TASK_NOT_FOUND",
err_code=6001,
retryable=False,
status_code=404,
)
self.task_id = task_id
class LeaseInvalidError(AppError):
"""租约无效:不是持有者、已过期或任务已终结
worker 在 renew / report / reclaim 时必须校验自己是当前租约的持有者,且任务
尚未终结。任意一条不满足都报 6002,让 worker 停下来而不是猜测当前状态。
"""
def __init__(self, message: str = "租约无效"):
super().__init__(
message=message,
code="LEASE_INVALID",
err_code=6002,
retryable=False,
status_code=409,
)
class InvalidTaskStateError(AppError):
"""任务状态不允许该操作
例如对已终结(succeeded/failed/needs_human)的任务再 reclaim。与 6002 的区别:
6002 是租约本身的问题(不是持有者 / 已过期),6003 是状态机层面的问题
(当前状态不接受这个动作)。
"""
def __init__(self, message: str = "任务状态不允许该操作"):
super().__init__(
message=message,
code="INVALID_TASK_STATE",
err_code=6003,
retryable=False,
status_code=409,
)
+65
View File
@@ -0,0 +1,65 @@
"""任务与订单状态枚举:网关与本地 worker 共享的词汇表
放在 shared 层是因为这是网关与 worker 之间的 HTTP 契约——两侧都需要知道合法
取值,不能由任何一方私有持有。shared 本身不引入对 scraping/trading 的依赖,
这个模块也一样:纯枚举与状态集合,不引用任何业务模块。
两层状态不要混:task.status 描述「这个任务被谁领了、做完没有」,order state
描述「这笔订单在站点上走到哪一步」。前者权威方是网关,后者权威方是本地 trading,
网关只存镜像。详见 docs/order-gateway.md §3。
迁移图(task.status):
queued ──lease──> leased ──首次 report──> running ──terminal report──> succeeded
▲ │ │ └─> failed
│ └──── 租约过期 ──────────┴──> stale └─> needs_human
└── 只有人工介入才能从 stale 回到 leased(reclaim)
注意 stale **不**自动回 queued——下单不可逆,自动重投等于再买一次。
"""
from __future__ import annotations
from enum import StrEnum
class TaskStatus(StrEnum):
"""任务状态(网关权威)"""
QUEUED = "queued" # 已入队,等待 worker 领取
LEASED = "leased" # 已被 worker 领走,尚未首次 report
RUNNING = "running" # worker 已首次 report,正在进行
SUCCEEDED = "succeeded" # 终态:成功
FAILED = "failed" # 终态:worker 明确失败
NEEDS_HUMAN = "needs_human" # 终态:需要人介入(如 3DS、核对不出结论)
STALE = "stale" # 租约过期,等人工 reclaim,不自动重投
# 终态集合:到达后任何 renew / report / reclaim 都报 6003
TERMINAL_STATUSES: frozenset[TaskStatus] = frozenset(
{TaskStatus.SUCCEEDED, TaskStatus.FAILED, TaskStatus.NEEDS_HUMAN}
)
# 处于「执行中」的状态:这些状态存在时,lease 一律返回空(全局并发度 1)
ACTIVE_STATUSES: frozenset[TaskStatus] = frozenset({TaskStatus.LEASED, TaskStatus.RUNNING})
# 可以被普通 lease 领走的状态:只有 queued
LEASABLE_STATUSES: frozenset[TaskStatus] = frozenset({TaskStatus.QUEUED})
# 可以被 reclaim 领走的状态:只有 stale
RECLAIMABLE_STATUSES: frozenset[TaskStatus] = frozenset({TaskStatus.STALE})
class OrderState(StrEnum):
"""订单状态(本地权威,网关只存镜像)
沿用 docs/order-gateway.md §3 给的取值。网关本身不解释这些值,只是字符串透传。
"""
CREATED = "created"
IN_CART = "in_cart"
ORDERED = "ordered"
AWAITING_PAYMENT = "awaiting_payment"
PAID = "paid"
SHIPPED = "shipped"
DELIVERED = "delivered"
CANCELLED = "cancelled"