feat(gateway): 账号只读查询通道——从已登录账号取真实订单

上游要的不只是网关记的任务状态镜像,还有「已登录账号在站点上的真实订单」,
但账号只在 NAT 后本地机上,只能经网关队列走。新增独立查询通道(§11):

- gateway 单开 account_queries 表 + QueryStatus 状态机,接口
  POST /api/account/queries(幂等)/ lease / {id}/result / {id}
- 不复用下单任务队列:查询是只读,租约过期可安全重投(与下单「绝不自动
  重投」相反),且不该被全局并发度 1 堵死、task_reports 是订单镜像不能污染
- 本地交易服务起第二条常驻循环 query_runner,领到即调 SiteInteractor 真读:
  order_list 复用已实测的 list_recent_orders(规范化字段 + 站点
  orderListData 原文),order_detail 复用 fetch_order_detail(配送阶段 +
  页面 __INITIAL_STATE__ 原样透传,结构未经真实样本,不抽字段)
- 账号级串行仍由 SiteInteractor 的锁保证;每次执行套超时按失败回报
- 错误码 6005/6006(查询通道,可重试只读区别于 6001-6004);/health 暴露
  queued_query_count;结果体积上限先丢原始 JSON

openapi.json 重导,docs/order-gateway.md §11、README、.env.example 补全
配置与实测边界。全量测试 404→454 通过。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-16 22:46:01 +08:00
co-authored by Claude Opus 5
parent b01c9659d1
commit c03158488b
22 changed files with 2575 additions and 43 deletions
+157
View File
@@ -54,6 +54,11 @@ queued ──lease──> leased ──首次 report──> running ──termin
└── 只有人工介入才能从 stale 回到 queued(见 §5)
```
> 账号**只读**查询(上游问「账号里真实的订单长什么样」,
> 见 [§11](#11-账号只读查询通道))有一套独立的查询单状态机与通道,不复用这张任务表——
> 查询是可安全重投的,与「下单绝不重投」是两套相反的安全语义,分表就是要防止
> 这两个 if 被改错。
## 4. order-gateway
技术栈与现有仓库保持一致:Python 3.13 + FastAPI + pydantic-settings + SQLite,
@@ -296,6 +301,8 @@ trading 侧新增:
| 6002 | 租约无效:不是持有者、已过期或任务已终结 | 409 |
| 6003 | 任务状态不允许该操作(如对已终结任务 reclaim) | 409 |
| 6004 | 已有任务在执行中,本次不发放(正常返回空即可,仅诊断用) | 200 |
| 6005 | 查询单不存在(账号只读查询通道,见 §11.4) | 404 |
| 6006 | 查询单租约无效(账号只读查询通道,见 §11.4) | 409 |
## 9. 验收清单
@@ -333,3 +340,153 @@ trading 侧新增:
> **交易范围**:交易服务只覆盖乐天市场(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
"raw": { /* 页面 __INITIAL_STATE__ 原文 */ },
"raw_available": true
}
```
> **实测边界(重要)**:`stage_label` / `order_state` 走的是 2026-08-13 用真实订单号
> 验证过的进度条解析;但**详情页的 `__INITIAL_STATE__` 结构从未拿到真实样本**。
> 因此本服务对它只做「解析得动就原样透传」,不抽任何字段——想要金额、收货地址、
> 付款方式的调用方自己从 `raw` 里取并自担结构变动风险。等拿到真实样本后再在
> `site_interact.py` 里补规范化解析,**不要在没有样本的情况下先猜着写**。
结果体积上限 `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 任何异常都不逃出主循环,每张单都有一次回报