补下单任务网关规格
抓取在服务器、下单在本地,本地在 NAT 后没有公网入口,因此下单请求由本地长轮询 主动领取,而不是从服务器推进来。docs/order-gateway.md 给出可直接实现的规格: 任务队列与状态镜像的表结构、四个接口契约、任务状态与订单状态的分层、证据留痕约定、 配置项与验收清单。 其中一条是安全关键:租约过期**不自动重投**。本地可能已经下单成功只是回报断网, 自动重投等于再买一次;过期任务转 stale 告警,恢复时必须先查站点订单列表核对。 站点交互(加购/下单/付款)尚未实测,文档里明确留成接口缝并列出待实测项。
This commit is contained in:
@@ -30,6 +30,10 @@
|
|||||||
(`tests/test_architecture.py` 会守着)。交易侧需要商品信息时,走抓取服务的 HTTP 接口——
|
(`tests/test_architecture.py` 会守着)。交易侧需要商品信息时,走抓取服务的 HTTP 接口——
|
||||||
下单要用的 `purchase` 块本来就是抓取服务的对外契约。
|
下单要用的 `purchase` 块本来就是抓取服务的对外契约。
|
||||||
|
|
||||||
|
抓取服务部署在服务器,交易服务部署在本地(便于管理账号、排查支付问题),本地在 NAT 后
|
||||||
|
没有公网入口,因此下单请求不是推进来的,而是由本地长轮询主动领取。
|
||||||
|
任务网关与本地 worker 的规格见 [docs/order-gateway.md](docs/order-gateway.md)(待实现)。
|
||||||
|
|
||||||
```
|
```
|
||||||
app/
|
app/
|
||||||
shared/ 配置、错误码、日志、响应信封与鉴权(两侧共用,不认识两侧)
|
shared/ 配置、错误码、日志、响应信封与鉴权(两侧共用,不认识两侧)
|
||||||
|
|||||||
@@ -0,0 +1,328 @@
|
|||||||
|
# 下单任务网关(order-gateway)与本地 worker 规格
|
||||||
|
|
||||||
|
本文是交付给实现方的规格。目标读者是写代码的人或工具,不是使用者。
|
||||||
|
|
||||||
|
## 1. 背景与硬约束
|
||||||
|
|
||||||
|
抓取服务部署在服务器(有公网入口),下单服务部署在本地(便于管理账号、排查支付问题),
|
||||||
|
本地机在 NAT 后面**没有公网入口**。因此下单请求不能从服务器「推」进来,只能由本地
|
||||||
|
「拉」出去取。
|
||||||
|
|
||||||
|
四条不可协商的约束,实现时任何取舍都不得违反:
|
||||||
|
|
||||||
|
1. **下单是不可逆的写操作**。任何自动重试、自动重投都可能变成重复下单。
|
||||||
|
2. **同一账号的写操作必须串行**。全局同时最多允许一个任务处于执行中。
|
||||||
|
3. **本地不开任何入站端口**。所有跨机通信都由本地发起出站请求。
|
||||||
|
4. **证据留在本地**。HTML 快照与截图不回传,服务器只存摘要与本地相对路径。
|
||||||
|
|
||||||
|
## 2. 拓扑
|
||||||
|
|
||||||
|
```
|
||||||
|
上游业务系统(服务器)
|
||||||
|
│ ① POST /api/orders 下单意图 → task_id
|
||||||
|
▼
|
||||||
|
order-gateway(服务器:任务队列 + 状态镜像,本文要新建的服务)
|
||||||
|
▲ ② GET /api/orders/lease 本地长轮询领取(出站)
|
||||||
|
│ ③ POST /api/orders/{id}/report 本地回报状态(出站)
|
||||||
|
│
|
||||||
|
trading worker(本地,app/trading 内,零入站端口)
|
||||||
|
│ ④ 加购 → 下单 → 付款,每步落证据到本地磁盘
|
||||||
|
▼
|
||||||
|
乐天 / ラクマ
|
||||||
|
```
|
||||||
|
|
||||||
|
抓取服务(:31107)不参与这条链路的编排。本地需要商品数据时,直接出站请求抓取服务的
|
||||||
|
`POST /api/item_detail` 取 `purchase` 块(见 README「加购与下单」)。
|
||||||
|
|
||||||
|
**order-gateway 必须是独立部署单元,不能塞进抓取服务**:抓取服务已定为无状态、可多开实例,
|
||||||
|
而任务队列有状态,多实例会抢同一批任务。
|
||||||
|
|
||||||
|
## 3. 两层状态,不要混为一谈
|
||||||
|
|
||||||
|
| | 任务状态(task.status) | 订单状态(order state) |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 含义 | 这个任务被谁领了、做完没有 | 这笔订单在站点上走到哪一步 |
|
||||||
|
| 权威方 | order-gateway | 本地 trading(gateway 只存镜像) |
|
||||||
|
| 取值 | `queued` `leased` `running` `succeeded` `failed` `needs_human` `stale` | `created` `in_cart` `ordered` `awaiting_payment` `paid` `shipped` `delivered` `cancelled` |
|
||||||
|
|
||||||
|
任务状态迁移:
|
||||||
|
|
||||||
|
```
|
||||||
|
queued ──lease──> leased ──首次 report──> running ──terminal report──> succeeded
|
||||||
|
▲ │ │ └─> failed
|
||||||
|
│ └──── 租约过期 ──────────┴──> stale └─> needs_human
|
||||||
|
└── 只有人工介入才能从 stale 回到 queued(见 §5)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. order-gateway
|
||||||
|
|
||||||
|
技术栈与现有仓库保持一致:Python 3.13 + FastAPI + pydantic-settings + SQLite,
|
||||||
|
**不引入 Redis / MQ**。响应信封、鉴权与错误码沿用现有约定(见 §8)。
|
||||||
|
|
||||||
|
职责边界——gateway **不做**这些事:不解析站点页面、不接触账号 cookie、不存证据原文、
|
||||||
|
不判断订单业务规则。它只是一个带租约的任务队列 + 状态镜像 + 查询入口。
|
||||||
|
|
||||||
|
### 4.1 数据模型
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE tasks (
|
||||||
|
task_id TEXT PRIMARY KEY, -- 幂等键,见 §4.2
|
||||||
|
site TEXT NOT NULL, -- rakuten | rakuma
|
||||||
|
intent_json TEXT NOT NULL, -- 下单意图原文,gateway 不解释内容
|
||||||
|
status TEXT NOT NULL, -- 见 §3
|
||||||
|
lease_owner TEXT, -- worker_id
|
||||||
|
lease_expires_at TEXT, -- ISO8601 UTC
|
||||||
|
lease_count INTEGER NOT NULL DEFAULT 0, -- 被领取过几次,>1 即发生过恢复
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_tasks_pending ON tasks(status, created_at);
|
||||||
|
|
||||||
|
-- 状态镜像:append-only,最新一条即当前状态。不做 UPDATE,便于事后复盘。
|
||||||
|
CREATE TABLE task_reports (
|
||||||
|
task_id TEXT NOT NULL,
|
||||||
|
state TEXT NOT NULL, -- 订单状态,见 §3
|
||||||
|
payable_yen INTEGER, -- 实际应付金额
|
||||||
|
pay_deadline TEXT, -- 付款期限 ISO8601,コンビニ払い 常见三天
|
||||||
|
site_order_id TEXT, -- 站点侧订单号
|
||||||
|
evidence_ref TEXT, -- 本地相对路径,如 20260727-abc123/03-order-confirm
|
||||||
|
detail TEXT,
|
||||||
|
reported_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (task_id, state) -- 同一状态重复上报视为同一次,见 §4.5
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE workers (
|
||||||
|
worker_id TEXT PRIMARY KEY,
|
||||||
|
last_seen_at TEXT NOT NULL -- 每次 lease 请求刷新,兼作心跳
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 POST /api/orders — 上游提交下单意图
|
||||||
|
|
||||||
|
请求:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"task_id": "po-20260727-0001", // 可选,上游自带的幂等键;不传则服务端生成
|
||||||
|
"site": "rakuten",
|
||||||
|
"intent": { // gateway 原样透传,结构由 trading 侧定义
|
||||||
|
"item_url": "https://item.rakuten.co.jp/shop/code/",
|
||||||
|
"quantity": 1,
|
||||||
|
"variant_id": "...", // 多规格商品必填
|
||||||
|
"options": {},
|
||||||
|
"max_total_yen": 30000 // 可选,覆盖本次的金额上限
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
响应 `data`:`{"task_id": "...", "status": "queued", "created": true}`
|
||||||
|
|
||||||
|
**幂等**:同一个 `task_id` 重复提交不新建任务,返回既有任务且 `created=false`。
|
||||||
|
上游重发不会变成两单。
|
||||||
|
|
||||||
|
### 4.3 GET /api/orders/lease — 本地长轮询领取
|
||||||
|
|
||||||
|
查询参数:`worker_id`(必填)、`wait`(可选,默认 30,最大 60 秒)、`site`(可选,限定站点)。
|
||||||
|
|
||||||
|
行为:
|
||||||
|
|
||||||
|
- 刷新 `workers.last_seen_at`(这就是心跳,不另建心跳接口)。
|
||||||
|
- **全局并发度 1**:若已存在 `leased` 或 `running` 的任务,直接返回空,绝不发第二个任务。
|
||||||
|
按站点隔离时可放宽为「每 site 最多 1 个」,但同一 site 内必须串行。
|
||||||
|
- 有可领任务时,在一个事务里 `queued → leased`、写入 `lease_owner` 与
|
||||||
|
`lease_expires_at = now + lease_ttl`(建议 300 秒),`lease_count += 1`。
|
||||||
|
- 无任务时**挂起**到 `wait` 秒后返回 `data: null`,HTTP 仍为 200。
|
||||||
|
实现可用轮询数据库(间隔 ≤1 秒)或条件变量,不要求真正的推送。
|
||||||
|
|
||||||
|
响应 `data`(有任务时):
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"task_id": "po-20260727-0001",
|
||||||
|
"site": "rakuten",
|
||||||
|
"intent": { },
|
||||||
|
"lease_expires_at": "2026-07-27T09:15:00Z",
|
||||||
|
"lease_count": 1, // >1 表示这是恢复领取,worker 必须先核对,见 §5
|
||||||
|
"known_state": "ordered" // 之前上报过的最新订单状态;首次领取为 null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 POST /api/orders/{task_id}/renew — 续租
|
||||||
|
|
||||||
|
参数 `worker_id`。执行时间可能超过租约 TTL(下单页面慢、付款要等),worker
|
||||||
|
每 60 秒续一次。校验 `lease_owner` 匹配且任务未终结,否则报 `6002`。
|
||||||
|
|
||||||
|
### 4.5 POST /api/orders/{task_id}/report — 本地回报
|
||||||
|
|
||||||
|
请求:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"worker_id": "local-01",
|
||||||
|
"state": "awaiting_payment",
|
||||||
|
"payable_yen": 12800,
|
||||||
|
"pay_deadline": "2026-07-30T14:59:00Z",
|
||||||
|
"site_order_id": "266123-20260727-0001234",
|
||||||
|
"evidence_ref": "po-20260727-0001/03-order-confirm",
|
||||||
|
"detail": "コンビニ払い,付款单号 12345678",
|
||||||
|
"terminal": false // true 表示这个任务到此结束
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
行为:
|
||||||
|
|
||||||
|
- 校验 `worker_id == lease_owner`,否则 `6002`。
|
||||||
|
- 首次 report 把任务从 `leased` 推到 `running`。
|
||||||
|
- 写入 `task_reports`。**同一 `(task_id, state)` 重复上报是幂等的**——网络抖动导致 worker
|
||||||
|
重发时覆盖同一行,不产生第二条记录。
|
||||||
|
- `terminal=true` 时释放租约,任务置为:`state=paid`(或按 §6 的完成定义)→ `succeeded`;
|
||||||
|
worker 明确失败 → `failed`;worker 报告需要人介入(如弹了 3DS)→ `needs_human`。
|
||||||
|
|
||||||
|
### 4.6 查询接口
|
||||||
|
|
||||||
|
- `GET /api/orders/{task_id}` — 任务 + 最新状态 + 完整状态历史。
|
||||||
|
- `GET /api/orders?status=&site=&limit=&offset=` — 列表,运维与上游对账用。
|
||||||
|
- `GET /health` — 含 `queued` 数量、当前 `leased/running` 任务、各 worker 的
|
||||||
|
`last_seen_seconds`。
|
||||||
|
|
||||||
|
### 4.7 告警(gateway 侧只做这两条兜底)
|
||||||
|
|
||||||
|
1. **worker 失联**:`now - last_seen_at > 5 分钟`(正常每 30 秒会来一次 lease)。
|
||||||
|
2. **任务长时间无人领**:`queued` 且 `now - created_at > 10 分钟`。
|
||||||
|
|
||||||
|
付款期限监控**不在 gateway**:本地机 7×24 在线,那套逻辑放本地(§6),
|
||||||
|
gateway 不重复实现一遍定时器。
|
||||||
|
|
||||||
|
## 5. 租约过期:绝不自动重投(本文最关键的一条)
|
||||||
|
|
||||||
|
常规任务队列在租约超时后会把任务放回队列重新分发。**这里必须禁止**:本地可能已经
|
||||||
|
下单成功,只是回报那一步断网了;自动重投等于再买一次。
|
||||||
|
|
||||||
|
正确行为:
|
||||||
|
|
||||||
|
- 租约过期 → 任务置 `stale` 并告警,**不回 `queued`**,不会被 §4.3 的正常领取取到。
|
||||||
|
- 恢复只能显式发起:`POST /api/orders/{task_id}/reclaim`(参数 `worker_id`),把
|
||||||
|
`stale` 重新租给 worker,返回体里 `lease_count > 1` 且带上 `known_state`。
|
||||||
|
- worker 收到 `lease_count > 1` 的任务时,**执行前必须先查站点的订单列表**,
|
||||||
|
确认这笔到底下没下(用 intent 里的商品 + 时间窗口比对,能拿到 `site_order_id`
|
||||||
|
最好)。确认已下单则直接补报状态,绝不重新提交。
|
||||||
|
- 核对不出结论时,上报 `needs_human` 交给人,不猜。
|
||||||
|
|
||||||
|
宁可卡住等人看一眼,也不赌一次重复下单。
|
||||||
|
|
||||||
|
## 6. 本地 worker(在 app/trading 内新增)
|
||||||
|
|
||||||
|
单个常驻 asyncio 任务,**串行**,不并发。主循环:
|
||||||
|
|
||||||
|
```
|
||||||
|
while running:
|
||||||
|
task = await gateway.lease(worker_id, wait=30) # 出站长轮询
|
||||||
|
if task is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if local_db.has_finished(task.task_id): # 本地幂等闸门
|
||||||
|
await gateway.report(task, local_db.final_state(task), terminal=True)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if task.lease_count > 1: # 恢复领取,见 §5
|
||||||
|
verdict = await verify_on_site(task) # 先查站点订单列表
|
||||||
|
if verdict.already_ordered:
|
||||||
|
await gateway.report(...); continue
|
||||||
|
if verdict.unknown:
|
||||||
|
await gateway.report(state="needs_human", terminal=True); continue
|
||||||
|
|
||||||
|
async with renew_lease_every(60):
|
||||||
|
await execute(task) # 见下
|
||||||
|
```
|
||||||
|
|
||||||
|
`execute` 的每一步都遵循同一个模式:**动作 → 落证据 → 写本地 SQLite → 回报 gateway**。
|
||||||
|
顺序不能颠倒,先落证据再回报,保证服务器上看到的状态一定有本地证据可查。
|
||||||
|
|
||||||
|
步骤:`加购 → 校验购物车 → 进入下单确认页 → 金额守卫 → 提交下单 → 付款 → 监控`。
|
||||||
|
|
||||||
|
- **金额守卫**:从确认页解析实际应付,超过 `intent.max_total_yen`(缺省用
|
||||||
|
`RAKUTEN_ORDER_MAX_TOTAL_YEN`)直接中止并上报 `needs_human`,错误码 `5004`。
|
||||||
|
- **付款**:按已定方案走自动付款;一旦检测到 3DS / 短信验证等人工环节,
|
||||||
|
立即上报 `needs_human` 并保留现场,不尝试绕过。
|
||||||
|
- **付款后监控**:本地常驻轮询订单状态与付款期限,状态变化时继续 report
|
||||||
|
(任务已 `terminal` 的仍可上报,gateway 追加到 `task_reports`)。
|
||||||
|
|
||||||
|
### 6.1 证据留痕约定
|
||||||
|
|
||||||
|
```
|
||||||
|
{RAKUTEN_EVIDENCE_DIR}/{task_id}/
|
||||||
|
01-cart-add.html 01-cart-add.png 01-cart-add.meta.json
|
||||||
|
02-cart-check.html ...
|
||||||
|
03-order-confirm.*
|
||||||
|
04-order-submit.*
|
||||||
|
05-payment.*
|
||||||
|
```
|
||||||
|
|
||||||
|
`meta.json` 至少含:请求 URL、方法、HTTP 状态码、时间戳、本地订单状态。
|
||||||
|
report 里的 `evidence_ref` 填 `{task_id}/{序号}-{步骤名}`,不含扩展名。
|
||||||
|
|
||||||
|
本地 SQLite 存订单主表 + 状态迁移事件表 + 证据索引;**它是执行事实的权威记录**,
|
||||||
|
gateway 上的镜像仅供上游查询。
|
||||||
|
|
||||||
|
## 7. 配置项
|
||||||
|
|
||||||
|
沿用 `RAKUTEN_` 前缀与现有 `.env` 机制。
|
||||||
|
|
||||||
|
gateway 侧:
|
||||||
|
|
||||||
|
- `RAKUTEN_GATEWAY_HOST` / `RAKUTEN_GATEWAY_PORT`(建议 31109)
|
||||||
|
- `RAKUTEN_GATEWAY_DB_PATH`(默认 `data/gateway.db`)
|
||||||
|
- `RAKUTEN_LEASE_TTL_SECONDS`(默认 300)
|
||||||
|
- `RAKUTEN_LEASE_MAX_WAIT_SECONDS`(默认 60)
|
||||||
|
- `RAKUTEN_WORKER_OFFLINE_ALERT_SECONDS`(默认 300)
|
||||||
|
|
||||||
|
trading 侧新增:
|
||||||
|
|
||||||
|
- `RAKUTEN_ORDER_GATEWAY_URL`(留空则不启动 worker,只跑登录态接口)
|
||||||
|
- `RAKUTEN_WORKER_ID`(默认取主机名)
|
||||||
|
- `RAKUTEN_TRADING_DB_PATH`(默认 `data/trading.db`)
|
||||||
|
- `RAKUTEN_EVIDENCE_DIR`(默认 `data/evidence`)
|
||||||
|
- `RAKUTEN_SCRAPER_BASE_URL`(取 `purchase` 块用,如 `https://<服务器>:31107`)
|
||||||
|
|
||||||
|
## 8. 对外契约沿用现有约定
|
||||||
|
|
||||||
|
- 响应信封:`{"success": bool, "msg": str, "data": T|null, "code": int}`
|
||||||
|
- 鉴权:`Authorization: Bearer <token>`,与现有服务同一个 token
|
||||||
|
- 错误码沿用 README 的表,新增 `6xxx` 段(任务编排):
|
||||||
|
|
||||||
|
| code | 含义 | HTTP |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 6001 | 任务不存在 | 404 |
|
||||||
|
| 6002 | 租约无效:不是持有者、已过期或任务已终结 | 409 |
|
||||||
|
| 6003 | 任务状态不允许该操作(如对已终结任务 reclaim) | 409 |
|
||||||
|
| 6004 | 已有任务在执行中,本次不发放(正常返回空即可,仅诊断用) | 200 |
|
||||||
|
|
||||||
|
## 9. 验收清单
|
||||||
|
|
||||||
|
实现方自测这些,全部离线可测(用假的站点交互桩):
|
||||||
|
|
||||||
|
- [ ] 同 `task_id` 提交两次只产生一个任务
|
||||||
|
- [ ] 两个 worker 同时 lease,只有一个拿到任务
|
||||||
|
- [ ] 有任务在 `leased`/`running` 时,lease 一律返回空
|
||||||
|
- [ ] 无任务时 lease 挂起到 `wait` 秒才返回,且返回 200 + `data: null`
|
||||||
|
- [ ] 同一 `(task_id, state)` 重复 report 不产生第二条记录
|
||||||
|
- [ ] 租约过期后任务变 `stale`,**且不会被普通 lease 取到**
|
||||||
|
- [ ] `reclaim` 领回的任务 `lease_count > 1` 且带 `known_state`
|
||||||
|
- [ ] worker 收到 `lease_count > 1` 时,走核对分支而不是直接执行
|
||||||
|
- [ ] 本地已完成的任务再次被领取时,直接补报而不重新下单
|
||||||
|
- [ ] 金额超过上限时中止并上报 `needs_human`,站点侧无提交动作
|
||||||
|
- [ ] worker 超过阈值没来 lease 时 `/health` 报异常
|
||||||
|
- [ ] 每一步的证据文件在 report 之前就已落盘
|
||||||
|
|
||||||
|
## 10. 尚未确定的部分
|
||||||
|
|
||||||
|
**加购 → 下单 → 付款的实际站点交互没有实测过**,本文不给这一段的具体表单与端点。
|
||||||
|
实现时把它留成明确的接口缝,未实测前调用直接抛「未实现」,不要写猜测的提交逻辑。
|
||||||
|
|
||||||
|
已知需要实测确认的点:
|
||||||
|
|
||||||
|
1. 自动付款是否触发 3D Secure 或短信验证。若触发,这条路走不通,付款环节改为
|
||||||
|
「下单到 `awaiting_payment` + 上报 `needs_human` 交人工」,其余环节不变。
|
||||||
|
2. 下单确认页的实际应付金额、付款方式、付款期限、站点订单号各自在哪个字段。
|
||||||
|
3. 订单列表页能否按商品 + 时间窗口可靠地反查出「这单下没下」(§5 的恢复核对依赖它)。
|
||||||
|
4. ラクマ 侧尚无加购契约(README 已注明未实现),首版只做乐天市场。
|
||||||
Reference in New Issue
Block a user