Files
rakuten-api/docs/order-gateway.md

623 lines
32 KiB
Markdown

# 下单任务网关(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)
```
> 账号**只读**查询(上游问「账号里真实的订单长什么样」,
> 见 [§11](#11-账号只读查询通道))有一套独立的查询单状态机与通道,不复用这张任务表——
> 查询是可安全重投的,与「下单绝不重投」是两套相反的安全语义,分表就是要防止
> 这两个 if 被改错。
## 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(交易服务仅覆盖乐天市场)
intent_json TEXT NOT NULL, -- 下单意图原文,gateway 不解释内容
callback_url TEXT, -- 终结类事件通知地址(可选),见 §4.8
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 侧定义
"items": [{ // 新格式:一次购买多个商品
"item_url": "https://item.rakuten.co.jp/shop/code/",
"quantity": 1,
"variant_id": "...", // 多规格商品必填,取自 /api/item_detail 的 sku.variants[]
"choice": ["名入れ:希望する"] // 店铺自定义必填选项,格式「选项名:取值名」
}],
"max_total_yen": 30000 // 可选,覆盖本次的金额上限
},
"callback_url": "https://upstream.example.com/hooks/rakuten-order" // 可选,终结类事件通知,见 §4.8
}
```
`items` 必须是非空数组,worker 会按顺序将每项加入同一购物车后再进入结算。
为兼容已发布客户端,也可继续使用旧格式:`intent.item_url` 加同级的
`quantity` / `variant_id` / `choice`,其语义等同于只有一个元素的 `items`
响应 `data``{"task_id": "...", "status": "queued", "created": true}`
**幂等**:同一个 `task_id` 重复提交不新建任务,返回既有任务且 `created=false`
上游重发不会变成两单。注意 `callback_url` 只在首次创建时写入,幂等重发不更新
既有任务的任何字段——要换通知地址必须用新 `task_id`
### 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 不重复实现一遍定时器。
### 4.8 回调通知(callback_url,2026-08-16)
上游提交任务时可带 `callback_url`(http/https 绝对 URL,其余提交时 422)。
登记后网关在**终结类事件**发生时向该地址 POST 一条 JSON 通知,上游免去轮询。
触发时机只有两类,每类一次:
1. **任务到达终态**`succeeded` / `failed` / `needs_human`):worker 的
`terminal=true` report 真正把任务推入终态的那一次。中间态 report
(in_cart / ordered / awaiting_payment 非 terminal)不通知;任务已终结后的
后续 report(如付款后监控的 shipped/delivered)也不重复通知。
2. **任务被置 stale**:租约过期被 sweep 标记(见 §5)。上游收到后应走人工
排查 + reclaim 流程。
终态事件的 payload(无信封,直接是 JSON 对象):
```jsonc
{
"task_id": "po-20260727-0001",
"site": "rakuten",
"event": "terminal",
"status": "succeeded", // 终态:succeeded / failed / needs_human
"state": "paid", // 本次 report 的订单状态
"payable_yen": 12800,
"pay_deadline": "2026-07-30T14:59:00Z",
"site_order_id": "266123-20260727-0001234",
"evidence_ref": "po-20260727-0001/05-payment",
"detail": "付款完成",
"reported_at": "2026-07-27T09:15:00Z"
}
```
stale 事件的 payload:`{"task_id", "site", "event": "stale", "status": "stale",
"detail"}`
投递语义是 **best-effort 单次尝试**:超时(`RAKUTEN_CALLBACK_TIMEOUT_SECONDS`
默认 10 秒)、非 2xx、连接错误都只记网关日志,不重试、不阻塞 report/sweep
主流程。回调只是「省轮询」的提示,可能丢失——**权威状态仍以
`GET /api/orders/{task_id}` 为准**,上游应保留对账轮询(可降低频率)。
回调出站沿用统一代理策略(`app/shared/proxy.py`,按目标 host 判定 bypass);
上游如需鉴权,可把 token 编进 callback_url 的 query 里自行校验。
## 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)
- `RAKUTEN_CALLBACK_TIMEOUT_SECONDS`(默认 10,回调通知单次 HTTP 超时,见 §4.8)
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 |
| 6005 | 查询单不存在(账号只读查询通道,见 §11.4) | 404 |
| 6006 | 查询单租约无效(账号只读查询通道,见 §11.4) | 409 |
## 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 的恢复核对依赖它,
`verify.verify_on_site` 仍是恒返回 unknown 的桩)。2026-08-13 已经拿到过
`order.my.rakuten.co.jp` 的真实 HTML(用于实现付款后监控,见
`site_interact.py::check_order_status` / `_parse_order_status`),页面按
订单号能查到「注文番号」「配送阶段进度条」,但没有验证过按商品名/时间窗口
反查、也没有验证过多页/分页场景——对实现 §5 恢复核对有参考价值,不能直接复用。
> **交易范围**:交易服务只覆盖乐天市场(rakuten)。ラクマ 的抓取仍在抓取服务里提供,
> 但不进入交易链路——没有加购契约,也永不实现下单/付款/订单监控。
## 11. 账号只读查询通道
上游有时要的不是「网关记的这笔任务走到哪一步」,而是「**已登录账号在站点上真实的
订单是什么样**」——两者会不一致:站点侧被商家取消、金额调整、或者根本是走别的
渠道下的单,网关的状态镜像都看不见。
但账号只在本地机上(NAT 后无公网入口),上游只能打网关。所以这条通道与下单任务
同构:上游把查询意图放进队列,本地 worker 出站长轮询领走、去站点上真读一次、回结果。
### 11.1 为什么不复用 `/api/orders` 那条队列
三条理由,任何一条单独成立就足够:
1. **全局并发度 1 是给写操作设的**。查询排在下单后面会被堵死,而只读本来可以并行。
2. **「租约过期绝不自动重投」是写操作的安全约束**。只读操作重投是安全的,套上
§5 那套只会制造一堆需要人工 reclaim 的 stale 噪音。
3. **`task_reports` 的语义是订单状态镜像**(主键 `(task_id, state)`),塞查询结果
会污染对账口径。
因此单开一张 `account_queries` 表、一套 `QueryStatus` 词汇表、一条 worker 循环。
两条通道的语义对照写在 `app/gateway/query_queue.py` 顶部,改任一边前先看它。
```
queued ──lease──> leased ──result──> succeeded
▲ │ └─> failed
│ │
└─ 租约过期自动重投 ┘ (attempts 用尽 → failed;超过查询单 TTL → expired)
```
**账号级串行靠什么保证**:本地 `SiteInteractor` 里那把 `asyncio.Lock`——下单的写
操作与这里的只读操作都要先拿它。网关侧不重复实现一遍并发闸门。代价是下单跑着时
查询要排队等锁,因此 worker 侧对每次执行套 `RAKUTEN_ACCOUNT_QUERY_TIMEOUT_SECONDS`
超时按失败回报,上游重发即可(只读,重发无副作用)。
### 11.2 接口
**纯异步**:提交只拿单号,结果去 GET 取。网关不提供「挂起等结果」的接口——下单
可能占着账号锁跑几分钟,同步等待会把上游的连接一起卡住。
- `POST /api/account/queries` — 提交查询单(幂等,同 `query_id` 返回既有单)
```jsonc
{
"query_id": "aq-20260816-0001", // 可选,不传服务端生成
"site": "rakuten",
"kind": "order_list", // order_list | order_detail
"params": { // 网关原样透传,结构由 trading 侧定义
"since": "2026-08-01T00:00:00Z", // order_list 可选,缺省不设下界
"max_pages": 3 // order_list 可选,1..20
// order_detail 必填:{"order_number": "306087-20260813-0863947697"}
}
}
```
响应 `data`:`{"query_id": "...", "status": "queued", "created": true}`
- `GET /api/account/queries/lease` — 本地长轮询领取(参数 `worker_id`、`wait`、`site`)。
**不刷 worker 心跳**:心跳的语义是「下单 worker 还活着」,查询循环活着不代表它活着。
- `POST /api/account/queries/{id}/result` — 本地回结果
(`{worker_id, success, result?, error_code?, error_message?}`)
- `GET /api/account/queries/{id}` — 取状态与结果
- `GET /api/account/queries?status=&kind=&limit=&offset=` — 列表(运维排查)
### 11.3 结果结构:原样透传 + 规范化字段
两者都给。规范化字段是稳定契约,站点原始 JSON 是逃生舱——站点比我们的模型多给的
字段(金额、配送、状态文案)不该在中间层被悄悄丢掉。
`kind=order_list`(复用 `list_recent_orders`,2026-08-13 实测过的路径):
```jsonc
{
"kind": "order_list",
"since": "2026-08-01T00:00:00Z",
"max_pages": 3,
"window_fully_covered": true, // ← 见下,false 时结论不可当「没有」用
"orders": [{"order_number": "...", "order_date": "...", "shop_id": 306087,
"shop_name": "...", "items": [{"item_id": 0, "item_name": "", "item_url": ""}]}],
"raw_pages": [ /* 站点 __INITIAL_STATE__.orderListData 原文,按翻页顺序 */ ]
}
```
**`window_fully_covered=false` 时,「列表里没有某笔订单」不等于「账号里没有」**——
它表示翻页在覆盖完窗口前就停了(命中 `max_pages`、页面改版、掉登录)。上游据此
决定是缩小窗口重查还是交人工,不要把它当成确定性的否定结论。这与 §5 恢复核对里
「核对不出结论就报 needs_human」是同一条原则。
`kind=order_detail`(复用 `fetch_order_detail`):
```jsonc
{
"kind": "order_detail",
"order_number": "306087-20260813-0863947697",
"found": true, // false 不是错误:站点自己说下单后要 ~10 分钟才反映
"stage_label": "出荷", // 站点原文(配送阶段)
"order_state": "shipped", // 映射到 OrderState;映射不到为 null
"delivery_status": "CHECKING_ORDER", // 站点结构化配送状态码;列表页/stepper 路径为 null
"raw": { /* 页面 __INITIAL_STATE__ 原文 */ },
"raw_available": true
}
```
> **实测边界(2026-08-16 更新)**:上次评估时「详情页 `__INITIAL_STATE__` 结构从未
> 拿到真实样本」——**已用真账号真实爬过一次**(`scripts/probe_order_detail.py`,
> 样本落盘 `.probe/order_detail/`,订单号 306087-20260813-0863947697)。确认:
> - 详情页 `pageType="ph-detail"`,配送阶段有两处可解析信号:CSS 进度条
> (`_parse_order_status`)**以及** `orderData.shippingList[].deliveryInfo.
> deliveryStatus` 结构化枚举(`_parse_order_detail_status`,2026-08-16 新增)。
> `stage_label` 优先取 `deliveryStatusTitle` 站点原文(如「ご注文確認中」)。
> - 进度条解析修了一个此前未暴露的回归:不锚定 `<li class="item--3gWCU...">` 时,
> 详情页的面包屑 `<li>` 会先于进度条第 0 项被 `findall` 抓走,导致带 `-active--`
> 的当前阶段被错位跳过、永远判不出阶段(见 `_ORDER_STEPPER_ITEM_PATTERN` 注释)。
> - `deliveryStatus` 的枚举→`OrderState` 映射**仍只实测过 `CHECKING_ORDER` 一个值**,
> 未识别的枚举值一律不接管、交回 stepper 兜底(避免掐掉 monitor 的
> SHIPPED/DELIVERED 上报)。`delivery_status` 字段把这个原始码原样带给上游对账。
> - `raw` 仍原样透传,想要金额/收货地址/付款方式的调用方从 `raw.orderData` 自取
> (2026-08-16 样本确认其结构:`orderSummary` / `itemObject.itemList[]` /
> `shippingList[].deliveryInfo.deliveryAddress` / `paymentInfoModel` 等),
> 本服务仍不自担字段抽取的去重/解释责任。
结果体积上限 `RAKUTEN_QUERY_RESULT_MAX_BYTES`:超限先丢 `raw_pages`/`raw` 并置
`raw_omitted`;丢完仍超限则整单判失败,让上游缩小窗口重来——不把几 MB 页面状态
塞进网关 SQLite。
### 11.4 错误码(新增 `600x`)
| code | 含义 | HTTP |
| --- | --- | --- |
| 6005 | 查询单不存在(或已过保留期被清理) | 404 |
| 6006 | 查询单租约无效:不是持有者、已被重投给别人或已终结 | 409 |
worker 回结果撞上 6006 属于正常情况(上一轮超时、单子已重投),**丢弃本次结果即可,
不要重发**——否则会覆盖新一轮的结果。
### 11.5 配置项
| 配置 | 默认 | 说明 |
| --- | --- | --- |
| `RAKUTEN_QUERY_LEASE_TTL_SECONDS` | 180 | 查询单租约 TTL,过期自动重投 |
| `RAKUTEN_QUERY_TTL_SECONDS` | 900 | 查询单整体存活上限,超时置 expired |
| `RAKUTEN_QUERY_MAX_ATTEMPTS` | 3 | 最多被领取几次,用尽置 failed |
| `RAKUTEN_QUERY_RETENTION_SECONDS` | 604800 | 终态查询单保留时长,过期由 sweep 清理 |
| `RAKUTEN_ACCOUNT_QUERY_TIMEOUT_SECONDS` | 120 | worker 单次站点读取上限(含等账号锁) |
| `RAKUTEN_ACCOUNT_QUERY_DEFAULT_MAX_PAGES` | 3 | order_list 缺省翻页上限 |
| `RAKUTEN_QUERY_RESULT_MAX_BYTES` | 1048576 | 结果 JSON 体积上限 |
### 11.6 验收清单
- [x]`query_id` 提交两次只产生一张单
- [x] `kind` 非法在提交时就 422,不等 worker 领走才失败
- [x] 有查询在飞时,下单任务照样能被 lease(两条通道互不阻塞)
- [x] 多张查询单可以同时在飞(没有并发度 1 闸门)
- [x] 租约过期 → 回 `queued` 且能被再次领取,`attempt` 递增
- [x] 重投次数用尽 → `failed`,不再无限重投
- [x] 超过查询单 TTL → `expired`,且不会再被领取
- [x] 重投后旧 worker 的迟到结果被拒(6006),不覆盖新结果
- [x] 终态单过保留期被清理,进行中的单不受影响
- [x] 站点原始 JSON 完整出现在结果里;翻页上限生效且如实标 `window_fully_covered`
- [x] 站点报错(掉登录 5001 等)原样带着错误码回给上游
- [x] 执行超时按失败回报,不把查询单挂到租约过期
- [x] worker 任何异常都不逃出主循环,每张单都有一次回报
## 12. 定时下派通道:周期性发现账号订单并回写网关(2026-08-16)
### 12.1 解决什么
§11 的查询通道要拿订单详情,得**上游自己已经知道 order_number 再下派**。可账号在
站点上可能走了别的渠道下单、被商家改状态,网关的状态镜像根本看不到。本通道让
网关**自己定期盘点账号**:派一张 `order_list` 查询(走 §11 那条队列,本地 worker
出站真读),把「网关编目里还没有的订单」**回写进新的 `account_orders` 表**,再逐笔
`order_detail` 查询把配送阶段沉淀进去。之后上游直接查 `GET /api/account/orders`
就能拿到账号里真实有哪些订单,不必自己记单号。
一句话:**gateway 从「等上游告诉它账号里有什么」变成「自己定时去账号里看,并把
新订单写回自己」**。
### 12.2 实现与边界(最重要的两条)
**1. collector 只消费「规范化字段」,绝不解析站点原始 JSON。**「网关不解释
query 的 params/result」这条原则照旧适用于上游自提的查询单;collector 自主派发的
这批 `discover-*` 单是**网关自己的编排数据**,它们的结果里 worker 已经把
`orders[].order_number/order_date/shop_id/shop_name``delivery_status/order_state`
抽成了稳定契约字段(§11.3 明说这是「规范化字段是稳定契约」),collector 把它
沉淀进编目不算解释站点内容。代码见 `app/gateway/collector.py::_parse_list_result`
只读这几个键)。
**2. 状态无痕(不新增任何编排跟踪表)。** collector 只在内存记「本进程派出去
但还没处理结果」的 `_pending`;进程重启即清空,重启后下一轮 tick 派一张全新 list
扫描,结果幂等 upsert 进编目,没有重复行。`order_detail` 的 query_id 用
`discover-detail-<order_number>-<日期>` **按天分段**:同一天内重复 submit 命中幂等
返回既有单(不会因每轮 tick 都满足「详情过期」而反复创建),跨天自然生成新单刷新
详情;同一天内已派过详情的订单由 `_detail_dispatched_this_day` 在内存里挡掉,避免
「found=False 订单一直算过期、每轮都重发」。已消费的 `discover-*` 查询单留在
`account_queries`,由既有 sweep 按保留期(默认 7 天)清理。
### 12.3 接口
- `GET /api/account/orders?state=&limit=&offset=` — 列出编目订单,按最近出现倒序。
编目行只含规范化字段(订单号/店铺/日期/配送状态),要看细节仍去拿对应订单的
`order_detail` 查询单。
- `GET /api/account/orders/{order_number}` — 单笔编目订单;编目里没有返回 6005。
- `POST /api/account/discovery/trigger` — 手动立即派一轮 `order_list` 扫描
(不等定时器到点),返回本轮下派的单号与 `created`
collector 本身不回写「已经存在于下单任务表 `tasks` 的订单」——`account_orders`
**账号真实订单的编目**,与 `tasks`(下单意图及其镜像)是两回事,两者通过
`order_number` 天然对账(上游要确认「这单网上下了没」就是查 `tasks`,要「账号里
实际有什么」就是查 `account_orders`)。
### 12.4 配置项
| 配置 | 默认 | 说明 |
| --- | --- | --- |
| `RAKUTEN_ACCOUNT_DISCOVERY_ENABLED` | true | 关闭则 collector 完全不启动 |
| `RAKUTEN_ACCOUNT_DISCOVERY_INTERVAL_SECONDS` | 21600 | 两轮 `order_list` 扫描间隔(6h) |
| `RAKUTEN_ACCOUNT_DISCOVERY_MAX_PAGES` | 3 | list 扫描翻页上限 |
| `RAKUTEN_ACCOUNT_DETAIL_REFRESH_SECONDS` | 86400 | 单笔详情刷新间隔(24h) |
### 12.5 验收清单
- [x] 定时派 `order_list` / `order_detail`,结果落到 `account_orders` 编目
- [x] 采集到编目中不存在的订单 → 回写新行;重复采集只刷新不重复
- [x] 列表扫描不抹掉已取过的详情阶段(配送状态/详情时间保留)
- [x] 同一天内同一订单的详情单只派一张;跨天自然刷新详情
- [x] 手动 trigger 立即派一轮 list,返回的单在查询队列里可见
- [x] `GET /api/account/orders` 列编目;单笔不存在返回 6005
- [x] collector 只读规范化字段,不解析 raw/raw_pages 站点原始 JSON