实现下单任务网关与本地 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:
+72
-3
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user