Files
rakuten-api/docs/order-gateway.md
T
q792602257andClaude Opus 5 f6c3976c0a feat(gateway): 定时下派通道——周期性盘点账号订单并回写网关编目
新增 §12 定时下派通道:网关自己定期派 order_list / order_detail 查询,把账号
真实订单沉淀进新表 account_orders(回写网关),上游可直接查 GET /api/account/orders
拿到账号里实际有哪些订单,不必自己记 order_number。

- collector.py: OrderDiscoveryCollector 常驻后台任务(与 sweep 并列)。每隔
  account_discovery_interval_seconds 派 order_list,收割结果后把「编目里没有的
  订单」upsert 进编目,再逐笔派 order_detail 沉淀 delivery_status/order_state。
  状态无痕:不新增编排跟踪表,仅内存 _pending + _detail_dispatched_this_day;
  detail query_id 按天分段(discover-detail-<order>-<日期>),同天幂等防重复派。
  边界:collector 只消费 worker 结果的规范化字段,绝不解析 raw/raw_pages。
- db.py: account_orders 表 + AccountOrderRow + upsert/single/list/stale 访问;
  upsert 以 order_number 为主键,重复采集只刷新,列表重扫不抹详情节点。
- 路由: GET /api/account/orders(列编目)、GET /api/account/orders/{n}(6005)、
  POST /api/account/discovery/trigger(手动立即派一轮 list)。
- config: RAKUTEN_ACCOUNT_DISCOVERY_ENABLED / _INTERVAL_SECONDS / _MAX_PAGES /
  RAKUTEN_ACCOUNT_DETAIL_REFRESH_SECONDS。
- 既有网关测试夹具统一关 discovery(会启动即派扫描污染查询队列语义),
  新表测试在 tests/test_gateway_discovery.py(13 用例,真实 QueryQueue+DB 装配)。

472 tests passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 23:35:51 +08:00

29 KiB

下单任务网关(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_detailpurchase 块(见 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)有一套独立的查询单状态机与通道,不复用这张任务表—— 查询是可安全重投的,与「下单绝不重投」是两套相反的安全语义,分表就是要防止 这两个 if 被改错。

4. order-gateway

技术栈与现有仓库保持一致:Python 3.13 + FastAPI + pydantic-settings + SQLite, 不引入 Redis / MQ。响应信封、鉴权与错误码沿用现有约定(见 §8)。

职责边界——gateway 不做这些事:不解析站点页面、不接触账号 cookie、不存证据原文、 不判断订单业务规则。它只是一个带租约的任务队列 + 状态镜像 + 查询入口。

4.1 数据模型

CREATE TABLE tasks (
    task_id          TEXT PRIMARY KEY,   -- 幂等键,见 §4.2
    site             TEXT NOT NULL,      -- rakuten(交易服务仅覆盖乐天市场)
    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 — 上游提交下单意图

请求:

{
  "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:若已存在 leasedrunning 的任务,直接返回空,绝不发第二个任务。 按站点隔离时可放宽为「每 site 最多 1 个」,但同一 site 内必须串行。
  • 有可领任务时,在一个事务里 queued → leased、写入 lease_ownerlease_expires_at = now + lease_ttl(建议 300 秒),lease_count += 1
  • 无任务时挂起wait 秒后返回 data: null,HTTP 仍为 200。 实现可用轮询数据库(间隔 ≤1 秒)或条件变量,不要求真正的推送。

响应 data(有任务时):

{
  "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 — 本地回报

请求:

{
  "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. 任务长时间无人领queuednow - 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
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 返回既有单)

    {
      "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_idwaitsite)。 不刷 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 实测过的路径):

{
  "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):

{
  "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 验收清单

  • query_id 提交两次只产生一张单
  • kind 非法在提交时就 422,不等 worker 领走才失败
  • 有查询在飞时,下单任务照样能被 lease(两条通道互不阻塞)
  • 多张查询单可以同时在飞(没有并发度 1 闸门)
  • 租约过期 → 回 queued 且能被再次领取,attempt 递增
  • 重投次数用尽 → failed,不再无限重投
  • 超过查询单 TTL → expired,且不会再被领取
  • 重投后旧 worker 的迟到结果被拒(6006),不覆盖新结果
  • 终态单过保留期被清理,进行中的单不受影响
  • 站点原始 JSON 完整出现在结果里;翻页上限生效且如实标 window_fully_covered
  • 站点报错(掉登录 5001 等)原样带着错误码回给上游
  • 执行超时按失败回报,不把查询单挂到租约过期
  • 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_namedelivery_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 验收清单

  • 定时派 order_list / order_detail,结果落到 account_orders 编目
  • 采集到编目中不存在的订单 → 回写新行;重复采集只刷新不重复
  • 列表扫描不抹掉已取过的详情阶段(配送状态/详情时间保留)
  • 同一天内同一订单的详情单只派一张;跨天自然刷新详情
  • 手动 trigger 立即派一轮 list,返回的单在查询队列里可见
  • GET /api/account/orders 列编目;单笔不存在返回 6005
  • collector 只读规范化字段,不解析 raw/raw_pages 站点原始 JSON