实现下单任务网关与本地 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:
+39
-9
@@ -1,6 +1,7 @@
|
||||
# 本仓库出两个服务,共用这一份配置:
|
||||
# 本仓库出三个服务,共用这一份配置:
|
||||
# 抓取服务 python -m app.scraping.main —— 匿名、无状态、可多开实例
|
||||
# 交易服务 python -m app.trading.main —— 带账号登录态、有状态,只能单实例
|
||||
# 下单任务网关 python -m app.gateway.main —— 任务队列 + 状态镜像,只能单实例
|
||||
# 各自只读自己那部分,下面按用途分组标注。
|
||||
|
||||
# ---- 抓取服务监听地址,通常本地用 127.0.0.1,容器/服务器用 0.0.0.0 ----
|
||||
@@ -11,6 +12,10 @@ RAKUTEN_APP_PORT=31107
|
||||
RAKUTEN_TRADING_HOST=0.0.0.0
|
||||
RAKUTEN_TRADING_PORT=31108
|
||||
|
||||
# ---- 下单任务网关监听地址(部署在服务器侧;本地 worker 出站长轮询取任务)----
|
||||
RAKUTEN_GATEWAY_HOST=0.0.0.0
|
||||
RAKUTEN_GATEWAY_PORT=31109
|
||||
|
||||
# 运行环境:dev / prod / test
|
||||
RAKUTEN_APP_ENV=dev
|
||||
|
||||
@@ -56,14 +61,6 @@ RAKUTEN_PROXY_USERNAME=
|
||||
# 代理密码(如代理需要认证则填写)
|
||||
RAKUTEN_PROXY_PASSWORD=
|
||||
|
||||
# ---- 以下仅交易服务使用 ----
|
||||
# 人工登录后落盘的 cookie 目录(相对项目根目录)。
|
||||
# 里面是可直接冒充账号的凭据,已在 .gitignore 排除,不要提交、不要外传。
|
||||
RAKUTEN_AUTH_STATE_DIR=.auth
|
||||
# 下单金额上限(日元):实际应付超过该值直接拒绝提交,防止解析出错或页面改版
|
||||
# 导致买到远超预期的订单。设为 0 表示不设上限(不建议)。
|
||||
RAKUTEN_ORDER_MAX_TOTAL_YEN=30000
|
||||
|
||||
# ---- OpenTelemetry traces(可选;默认关闭)----
|
||||
# 启用后把抓取-解析链路以 span 导出到 OTLP/HTTP endpoint,
|
||||
# 用于排查"抓到的内容为什么解析不出预期字段"。
|
||||
@@ -76,3 +73,36 @@ RAKUTEN_OTEL_SERVICE_NAME=
|
||||
RAKUTEN_OTEL_HEADERS=
|
||||
# 失败 HTML 快照上限字节(默认 2MB,超出截断并标注 truncated=true)
|
||||
RAKUTEN_OTEL_SNAPSHOT_MAX_BYTES=2000000
|
||||
|
||||
# ---- 以下仅交易服务使用 ----
|
||||
# 人工登录后落盘的 cookie 目录(相对项目根目录)。
|
||||
# 里面是可直接冒充账号的凭据,已在 .gitignore 排除,不要提交、不要外传。
|
||||
RAKUTEN_AUTH_STATE_DIR=.auth
|
||||
# 下单金额上限(日元):实际应付超过该值直接拒绝提交,防止解析出错或页面改版
|
||||
# 导致买到远超预期的订单。设为 0 表示不设上限(不建议)。
|
||||
RAKUTEN_ORDER_MAX_TOTAL_YEN=30000
|
||||
|
||||
# ---- 以下仅下单任务网关使用 ----
|
||||
# 任务队列 SQLite 文件路径(相对项目根目录)。务必放在持久化卷上,丢了等于
|
||||
# 丢了一批下单任务。详见 docs/order-gateway.md。
|
||||
RAKUTEN_GATEWAY_DB_PATH=data/gateway.db
|
||||
# 任务租约 TTL(秒)。worker 领取后必须在此时间内首次 report 或 renew,否则
|
||||
# 任务被置为 stale(**绝不自动重投**,需要人工 reclaim)。
|
||||
RAKUTEN_LEASE_TTL_SECONDS=300
|
||||
# 长轮询单次最长挂起秒数。worker 端 wait 参数会被夹到这个上限。
|
||||
RAKUTEN_LEASE_MAX_WAIT_SECONDS=60
|
||||
# worker 心跳超时阈值(秒)。超过即视为失联,/health 报 degraded。
|
||||
RAKUTEN_WORKER_OFFLINE_ALERT_SECONDS=300
|
||||
|
||||
# ---- 以下仅交易服务内的下单 worker 使用 ----
|
||||
# 网关 URL。**留空则不启动 worker**,交易服务只跑登录态接口。
|
||||
# 部署形态:本地机(NAT 后无公网入口)通过出站长轮询从这里领任务。
|
||||
RAKUTEN_ORDER_GATEWAY_URL=
|
||||
# worker 标识。同一时间只能有一个 worker 持有 lease,留空时取主机名。
|
||||
RAKUTEN_WORKER_ID=
|
||||
# 本地订单 SQLite 文件路径(执行事实的权威记录)。
|
||||
RAKUTEN_TRADING_DB_PATH=data/trading.db
|
||||
# 页面证据目录(HTML 快照 + 截图 + meta.json,按 task_id 分子目录)。
|
||||
RAKUTEN_EVIDENCE_DIR=data/evidence
|
||||
# 抓取服务基地址。worker 需要商品数据(加购用 purchase 块)时出站请求这里。
|
||||
RAKUTEN_SCRAPER_BASE_URL=
|
||||
|
||||
+9
-4
@@ -118,16 +118,21 @@ USER rakuten
|
||||
RUN /app/.venv/bin/playwright install chromium
|
||||
|
||||
# 健康检查走应用自带的 /health(轻量、不会触发真实抓取)。
|
||||
# 端口取 RAKUTEN_HEALTH_PORT,未设时用抓取服务的端口——交易容器启动时把它
|
||||
# 设成 31108 即可,两个服务共用这一个镜像。
|
||||
# 端口取 RAKUTEN_HEALTH_PORT,未设时用抓取服务的端口——交易/网关容器启动时把它
|
||||
# 设成对应端口即可,三个服务共用这一个镜像。
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD curl -fsS http://127.0.0.1:${RAKUTEN_HEALTH_PORT:-${RAKUTEN_APP_PORT}}/health || exit 1
|
||||
|
||||
# 31107 抓取服务,31108 交易服务(同一镜像,用 command 区分跑哪个)
|
||||
EXPOSE 31107 31108
|
||||
# 31107 抓取服务,31108 交易服务,31109 下单任务网关(同一镜像,用 command 区分跑哪个)
|
||||
EXPOSE 31107 31108 31109
|
||||
|
||||
# 默认起抓取服务。交易服务覆盖 command 即可:
|
||||
# docker run -e RAKUTEN_HEALTH_PORT=31108 -v ./.auth:/app/.auth \
|
||||
# <image> python -m app.trading.main
|
||||
# 注意交易服务持有登录态与订单监控,**只能起一个实例**,不要挂多副本。
|
||||
#
|
||||
# 网关部署在服务器侧(与抓取服务同机或邻近),任务队列 SQLite 需要持久化卷:
|
||||
# docker run -e RAKUTEN_HEALTH_PORT=31109 -v ./data:/app/data \
|
||||
# <image> python -m app.gateway.main
|
||||
# 网关也**只能起一个实例**:SQLite 单连接 + 全局并发度 1,多实例会抢同一个 DB。
|
||||
CMD ["python", "-m", "app.scraping.main"]
|
||||
@@ -9,36 +9,42 @@
|
||||
|
||||
两站均支持**搜索**、**商品详情**、**商家信息**与**商家名下商品**。
|
||||
|
||||
## 两个部署单元
|
||||
## 三个部署单元
|
||||
|
||||
同一个仓库出**两个服务**,分进程运行:
|
||||
同一个仓库出**三个服务**,分进程运行:
|
||||
|
||||
| | 抓取服务 `app.scraping` | 交易服务 `app.trading` |
|
||||
| --- | --- | --- |
|
||||
| 启动 | `python -m app.scraping.main`(:31107) | `python -m app.trading.main`(:31108) |
|
||||
| 账号 | 全程匿名 | 必须带登录态 cookie |
|
||||
| 状态 | 无状态,请求-响应 | 有状态:订单、页面证据、付款进度 |
|
||||
| 失败重试 | 幂等,重试无代价 | **不可逆**,重复提交即重复下单 |
|
||||
| 实例数 | 想开几个开几个 | **只能一个**(或按账号分片) |
|
||||
| 出口 IP | 被限速换掉即可 | 频繁漂移会触发风控 |
|
||||
| | 抓取服务 `app.scraping` | 交易服务 `app.trading` | 下单任务网关 `app.gateway` |
|
||||
| --- | --- | --- | --- |
|
||||
| 启动 | `python -m app.scraping.main`(:31107) | `python -m app.trading.main`(:31108) | `python -m app.gateway.main`(:31109) |
|
||||
| 部署位置 | 服务器 | 本地(NAT 后) | 服务器 |
|
||||
| 账号 | 全程匿名 | 必须带登录态 cookie | 不接触账号 |
|
||||
| 状态 | 无状态,请求-响应 | 有状态:订单、页面证据、付款进度 | 有状态:任务队列 + 状态镜像 |
|
||||
| 失败重试 | 幂等,重试无代价 | **不可逆**,重复提交即重复下单 | 任务编排侧,**不自动重投** |
|
||||
| 实例数 | 想开几个开几个 | **只能一个**(或按账号分片) | **只能一个**(SQLite + 全局并发度 1) |
|
||||
| 出口 IP | 被限速换掉即可 | 频繁漂移会触发风控 | 不出站到站点 |
|
||||
|
||||
拆开的决定性理由是「实例数」那一行,而不是「要不要登录」:登录态 cookie 全局唯一、
|
||||
订单监控是常驻轮询,一旦与抓取同进程,抓取横向扩容就会把登录态和轮询任务复制 N 份,
|
||||
让同一个账号被多个进程并发操作。
|
||||
抓取与交易拆开的决定性理由是「实例数」那一行,而不是「要不要登录」:登录态 cookie
|
||||
全局唯一、订单监控是常驻轮询,一旦与抓取同进程,抓取横向扩容就会把登录态和轮询任务
|
||||
复制 N 份,让同一个账号被多个进程并发操作。
|
||||
|
||||
依赖方向固定为 `scraping → shared`、`trading → shared`,两侧互不 import
|
||||
(`tests/test_architecture.py` 会守着)。交易侧需要商品信息时,走抓取服务的 HTTP 接口——
|
||||
下单要用的 `purchase` 块本来就是抓取服务的对外契约。
|
||||
网关单独成第三个部署单元,是因为它**有状态**(任务队列),不能塞进可多开的抓取服务,
|
||||
也不能塞进在本地、零入站端口的交易服务——本地通过出站长轮询从这里领任务。
|
||||
完整规格见 [docs/order-gateway.md](docs/order-gateway.md)。
|
||||
|
||||
依赖方向固定为 `scraping → shared`、`trading → shared`、`gateway → shared`,三方互不 import
|
||||
(`tests/test_architecture.py` 会守着)。交易侧需要商品信息时走抓取服务的 HTTP 接口,
|
||||
需要任务调度时走网关的 HTTP 接口——下单要用的 `purchase` 块本来就是抓取服务的对外契约。
|
||||
|
||||
抓取服务部署在服务器,交易服务部署在本地(便于管理账号、排查支付问题),本地在 NAT 后
|
||||
没有公网入口,因此下单请求不是推进来的,而是由本地长轮询主动领取。
|
||||
任务网关与本地 worker 的规格见 [docs/order-gateway.md](docs/order-gateway.md)(待实现)。
|
||||
任务网关与本地 worker 的规格见 [docs/order-gateway.md](docs/order-gateway.md)。
|
||||
|
||||
```
|
||||
app/
|
||||
shared/ 配置、错误码、日志、响应信封与鉴权(两侧共用,不认识两侧)
|
||||
shared/ 配置、错误码、日志、响应信封与鉴权、任务/订单状态枚举(三方共用,不认识任何一侧)
|
||||
scraping/ 站点常量 / 会话 / 解析器 / 抓取路由(本 README 的绝大部分)
|
||||
trading/ 登录态、(在建)加购、下单、付款与订单监控
|
||||
trading/ 登录态、加购下单付款(在建)与本地下单 worker(领任务、留证据)
|
||||
gateway/ 下单任务队列 + 状态镜像 + 长轮询领取接口
|
||||
```
|
||||
|
||||
## 抓取原理
|
||||
@@ -123,8 +129,23 @@ PC UA 在搜索页、详情页、店铺页上都能拿到完整模板。因此
|
||||
| `POST /api/auth/status` | 查询登录态,默认真实探测一次 |
|
||||
| `POST /api/auth/reload` | 人工重新登录后免重启换上新 cookie |
|
||||
|
||||
两个服务共用同一个 Bearer Token,错误码表也是同一份。
|
||||
启动后分别在 `http://127.0.0.1:31107/docs` 与 `:31108/docs` 查看 OpenAPI 文档。
|
||||
下单任务网关(:31109):
|
||||
|
||||
| 接口 | 说明 |
|
||||
| --- | --- |
|
||||
| `GET /health` | 健康检查,含 worker 心跳、长时间无人领任务告警 |
|
||||
| `POST /api/orders` | 上游提交下单意图(幂等) |
|
||||
| `GET /api/orders/lease` | 本地 worker 长轮询领取(全局并发度 1) |
|
||||
| `POST /api/orders/{id}/renew` | 续租(worker 在长任务里每 60s 调一次) |
|
||||
| `POST /api/orders/{id}/report` | 本地回报订单状态(同 state 重复上报幂等) |
|
||||
| `POST /api/orders/{id}/reclaim` | 把 stale 任务重新租给 worker(**绝不自动重投**) |
|
||||
| `GET /api/orders/{id}` | 任务详情 + 完整状态历史 |
|
||||
| `GET /api/orders` | 任务列表(运维与上游对账用) |
|
||||
|
||||
网关的契约与状态机详见 [docs/order-gateway.md](docs/order-gateway.md)。
|
||||
|
||||
三个服务共用同一个 Bearer Token,错误码表也是同一份。
|
||||
启动后分别在 `http://127.0.0.1:31107/docs`、`:31108/docs`、`:31109/docs` 查看 OpenAPI 文档。
|
||||
|
||||
## 安装
|
||||
|
||||
@@ -138,7 +159,7 @@ uv sync --extra dev --extra browser
|
||||
|
||||
## 启动
|
||||
|
||||
启动前建议先按 `.env.example` 配置 `.env`,两个服务共用这一份。
|
||||
启动前建议先按 `.env.example` 配置 `.env`,三个服务共用这一份。
|
||||
|
||||
```bash
|
||||
# 抓取服务,默认 0.0.0.0:31107;可多开实例
|
||||
@@ -146,9 +167,12 @@ uv sync --extra dev --extra browser
|
||||
|
||||
# 交易服务,默认 0.0.0.0:31108;只能起一个实例
|
||||
.venv/Scripts/python.exe -m app.trading.main
|
||||
|
||||
# 下单任务网关,默认 0.0.0.0:31109;只能起一个实例(SQLite + 全局并发度 1)
|
||||
.venv/Scripts/python.exe -m app.gateway.main
|
||||
```
|
||||
|
||||
只需要抓取时不必起交易服务。交易服务启动前要先人工登录一次:
|
||||
只需要抓取时不必起交易/网关服务。交易服务启动前要先人工登录一次:
|
||||
|
||||
```bash
|
||||
.venv/Scripts/python.exe scripts/login.py --site all
|
||||
@@ -557,20 +581,28 @@ cookie 落在 `.auth/`(已 gitignore,内含可直接冒充账号的凭据,
|
||||
| 5002 | 加购失败(交易服务) | 400 |
|
||||
| 5003 | 下单失败(交易服务) | 400 |
|
||||
| 5004 | 下单安全闸门未通过:未显式确认或金额超上限(交易服务) | 400 |
|
||||
| 6001 | 任务不存在(网关) | 404 |
|
||||
| 6002 | 租约无效:不是持有者、已过期或任务已终结(网关) | 409 |
|
||||
| 6003 | 任务状态不允许该操作(如对已终结任务 reclaim)(网关) | 409 |
|
||||
| 6004 | 已有任务在执行中,本次不发放(正常返回空,仅诊断用) | 200 |
|
||||
|
||||
错误码在两站、两个服务之间通用。ラクマ 链路不会出现 `3002`(无反爬拦截行为)
|
||||
错误码在两站、三个服务之间通用。ラクマ 链路不会出现 `3002`(无反爬拦截行为)
|
||||
与 `4002`(无子站跳转);`5xxx` 只会来自交易服务——抓取服务全程匿名,不会有登录态问题。
|
||||
`5001` 与 `5004` 都标记为不可重试:前者要人工重新登录,后者要调用方改入参。
|
||||
`6xxx` 只会来自网关,全部标记为不可重试——任务编排侧重试无意义,部分场景
|
||||
(如租约过期)重试可能变成重复下单。
|
||||
|
||||
## 常用环境变量
|
||||
|
||||
完整列表见 [.env.example](.env.example)。配置项前缀统一为 `RAKUTEN_`,两个服务共用同一份
|
||||
完整列表见 [.env.example](.env.example)。配置项前缀统一为 `RAKUTEN_`,三个服务共用同一份
|
||||
配置文件、各读各的那部分;两站共用同一套抓取参数(并发数、超时、重试次数),
|
||||
`SESSION_TTL` 与浏览器兜底只对乐天链路生效。
|
||||
|
||||
- 抓取服务:`RAKUTEN_APP_HOST`、`RAKUTEN_APP_PORT`(默认 `31107`)、`RAKUTEN_APP_ENV`
|
||||
- 交易服务:`RAKUTEN_TRADING_HOST`、`RAKUTEN_TRADING_PORT`(默认 `31108`)、`RAKUTEN_AUTH_STATE_DIR`(默认 `.auth`)、`RAKUTEN_ORDER_MAX_TOTAL_YEN`(默认 `30000`)
|
||||
- 鉴权:`RAKUTEN_BEARER_TOKEN`(两个服务共用)
|
||||
- 下单任务网关:`RAKUTEN_GATEWAY_HOST`、`RAKUTEN_GATEWAY_PORT`(默认 `31109`)、`RAKUTEN_GATEWAY_DB_PATH`(默认 `data/gateway.db`)、`RAKUTEN_LEASE_TTL_SECONDS`(默认 `300`)、`RAKUTEN_WORKER_OFFLINE_ALERT_SECONDS`(默认 `300`)
|
||||
- 本地下单 worker(在交易服务内,按 `RAKUTEN_ORDER_GATEWAY_URL` 是否配置决定是否启动):`RAKUTEN_ORDER_GATEWAY_URL`、`RAKUTEN_WORKER_ID`、`RAKUTEN_TRADING_DB_PATH`(默认 `data/trading.db`)、`RAKUTEN_EVIDENCE_DIR`(默认 `data/evidence`)、`RAKUTEN_SCRAPER_BASE_URL`
|
||||
- 鉴权:`RAKUTEN_BEARER_TOKEN`(三个服务共用)
|
||||
- 抓取:`RAKUTEN_MAX_SITE_CONCURRENCY`(默认 `8`,两站各自独立计数)、`RAKUTEN_HTTP_MAX_ATTEMPTS`(默认 `3`)、`RAKUTEN_SESSION_TTL_SECONDS`(默认 `1800`,仅乐天)
|
||||
- 浏览器兜底(仅乐天):`RAKUTEN_BROWSER_FALLBACK_ENABLED`、`RAKUTEN_BROWSER_HEADLESS`、`RAKUTEN_BROWSER_CHANNEL`
|
||||
- 代理(需日本 IP 时):`RAKUTEN_PROXY_SERVER`、`RAKUTEN_PROXY_USERNAME`、`RAKUTEN_PROXY_PASSWORD`
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
"""下单任务网关:第三个部署单元
|
||||
|
||||
抓取服务(app.scraping)匿名、无状态、可多开;交易服务(app.trading)带账号、
|
||||
有状态、单实例;本网关是任务队列与状态镜像,部署在服务器侧,**独立于两者**:
|
||||
|
||||
- 与抓取服务分进程的原因:抓取实例可以多开,而任务队列有状态,多实例会抢同一批
|
||||
任务(同一账号的写操作必须串行)。
|
||||
- 与交易服务分进程的原因:交易在本地(NAT 后无公网入口),网关在服务器,本地
|
||||
通过出站长轮询从这里领任务。
|
||||
|
||||
完整规格见 docs/order-gateway.md。
|
||||
"""
|
||||
@@ -0,0 +1 @@
|
||||
"""网关 API 包"""
|
||||
@@ -0,0 +1 @@
|
||||
"""网关路由集合"""
|
||||
@@ -0,0 +1,23 @@
|
||||
"""网关健康检查路由"""
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.gateway.container import GatewayContainer
|
||||
from app.gateway.models import GatewayHealthData
|
||||
from app.shared.api import ApiResponse, get_container
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
|
||||
@router.get("/health", response_model=ApiResponse[GatewayHealthData])
|
||||
async def health(
|
||||
container: GatewayContainer = Depends(get_container),
|
||||
) -> ApiResponse[GatewayHealthData]:
|
||||
"""网关健康状态
|
||||
|
||||
只做规格 §4.7 的两条兜底告警:worker 失联、任务长时间无人领。
|
||||
付款期限监控不在网关——本地机 7×24 在线,那套逻辑放本地。
|
||||
"""
|
||||
data = await container.task_queue.health_snapshot()
|
||||
return ApiResponse[GatewayHealthData](
|
||||
success=True, msg="success", data=data, code=0
|
||||
)
|
||||
@@ -0,0 +1,181 @@
|
||||
"""下单任务网关路由
|
||||
|
||||
承担规格 §4 的全部接口:
|
||||
|
||||
- POST /api/orders 上游提交下单意图(幂等)
|
||||
- GET /api/orders/lease 本地长轮询领取
|
||||
- POST /api/orders/{id}/renew 续租
|
||||
- POST /api/orders/{id}/report 本地回报状态
|
||||
- POST /api/orders/{id}/reclaim 把 stale 任务重新租给 worker(绝不自动重投)
|
||||
- GET /api/orders/{id} 任务详情 + 完整状态历史
|
||||
- GET /api/orders 任务列表(运维与上游对账)
|
||||
|
||||
handler 风格与 app/scraping/api/routes/scrape.py 保持一致:不在 handler 里写
|
||||
try/except,业务异常通过 AppError 自动套进 register_exception_handlers。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from app.gateway.container import GatewayContainer
|
||||
from app.gateway.models import (
|
||||
LeaseData,
|
||||
RenewData,
|
||||
ReclaimData,
|
||||
ReportData,
|
||||
SubmitOrderData,
|
||||
SubmitOrderRequest,
|
||||
TaskDetail,
|
||||
TaskListData,
|
||||
RenewRequest,
|
||||
ReclaimRequest,
|
||||
ReportRequest,
|
||||
)
|
||||
from app.shared.api import ApiResponse, get_container, require_bearer_token
|
||||
|
||||
router = APIRouter(prefix="/api/orders", tags=["orders"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=ApiResponse[SubmitOrderData],
|
||||
dependencies=[Depends(require_bearer_token)],
|
||||
)
|
||||
async def submit_order(
|
||||
payload: SubmitOrderRequest,
|
||||
container: GatewayContainer = Depends(get_container),
|
||||
) -> ApiResponse[SubmitOrderData]:
|
||||
"""上游提交下单意图
|
||||
|
||||
重复提交同一 task_id 不新建任务,返回既有任务且 created=false——上游重发
|
||||
不会变成两单。
|
||||
"""
|
||||
data = await container.task_queue.submit(
|
||||
task_id=payload.task_id, site=payload.site, intent=payload.intent
|
||||
)
|
||||
return ApiResponse[SubmitOrderData](
|
||||
success=True, msg="success", data=data, code=0
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/lease",
|
||||
response_model=ApiResponse[LeaseData | None],
|
||||
dependencies=[Depends(require_bearer_token)],
|
||||
)
|
||||
async def lease_order(
|
||||
worker_id: str = Query(...),
|
||||
wait: int = Query(default=30, ge=0, le=300),
|
||||
site: str | None = Query(default=None),
|
||||
container: GatewayContainer = Depends(get_container),
|
||||
) -> ApiResponse[LeaseData | None]:
|
||||
"""本地长轮询领取
|
||||
|
||||
无可领任务时挂起到 wait 秒后返回 data: null,HTTP 仍 200。已有 leased/running
|
||||
任务时立即返回空(全局并发度 1)。
|
||||
"""
|
||||
data = await container.task_queue.lease(
|
||||
worker_id=worker_id,
|
||||
wait=wait,
|
||||
site=site,
|
||||
max_wait=container.settings.lease_max_wait_seconds,
|
||||
)
|
||||
return ApiResponse[LeaseData | None](
|
||||
success=True, msg="success", data=data, code=0
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{task_id}/renew",
|
||||
response_model=ApiResponse[RenewData],
|
||||
dependencies=[Depends(require_bearer_token)],
|
||||
)
|
||||
async def renew_order(
|
||||
task_id: str,
|
||||
payload: RenewRequest,
|
||||
container: GatewayContainer = Depends(get_container),
|
||||
) -> ApiResponse[RenewData]:
|
||||
"""续租。worker 在长任务中每 60 秒调一次,避免租约 TTL 误判"""
|
||||
data = await container.task_queue.renew(task_id, payload.worker_id)
|
||||
return ApiResponse[RenewData](success=True, msg="success", data=data, code=0)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{task_id}/report",
|
||||
response_model=ApiResponse[ReportData],
|
||||
dependencies=[Depends(require_bearer_token)],
|
||||
)
|
||||
async def report_order(
|
||||
task_id: str,
|
||||
payload: ReportRequest,
|
||||
container: GatewayContainer = Depends(get_container),
|
||||
) -> ApiResponse[ReportData]:
|
||||
"""本地回报订单状态
|
||||
|
||||
同一 (task_id, state) 重复上报幂等。terminal=true 时释放租约并推到终态。
|
||||
"""
|
||||
data = await container.task_queue.report(
|
||||
task_id,
|
||||
worker_id=payload.worker_id,
|
||||
state=payload.state,
|
||||
payable_yen=payload.payable_yen,
|
||||
pay_deadline=payload.pay_deadline,
|
||||
site_order_id=payload.site_order_id,
|
||||
evidence_ref=payload.evidence_ref,
|
||||
detail=payload.detail,
|
||||
terminal=payload.terminal,
|
||||
terminal_status=payload.terminal_status.value if payload.terminal_status else None,
|
||||
)
|
||||
return ApiResponse[ReportData](success=True, msg="success", data=data, code=0)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{task_id}/reclaim",
|
||||
response_model=ApiResponse[ReclaimData],
|
||||
dependencies=[Depends(require_bearer_token)],
|
||||
)
|
||||
async def reclaim_order(
|
||||
task_id: str,
|
||||
payload: ReclaimRequest,
|
||||
container: GatewayContainer = Depends(get_container),
|
||||
) -> ApiResponse[ReclaimData]:
|
||||
"""把 stale 任务重新租给 worker(恢复领取)
|
||||
|
||||
返回体的 lease_count 必然 > 1,worker 必须先核对站点订单列表再决定是否
|
||||
继续执行——核对不出结论时报 needs_human,绝不重新提交。
|
||||
"""
|
||||
data = await container.task_queue.reclaim(task_id, payload.worker_id)
|
||||
return ApiResponse[ReclaimData](success=True, msg="success", data=data, code=0)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{task_id}",
|
||||
response_model=ApiResponse[TaskDetail],
|
||||
dependencies=[Depends(require_bearer_token)],
|
||||
)
|
||||
async def get_order(
|
||||
task_id: str,
|
||||
container: GatewayContainer = Depends(get_container),
|
||||
) -> ApiResponse[TaskDetail]:
|
||||
"""任务详情 + 完整状态历史"""
|
||||
data = await container.task_queue.get_task_detail(task_id)
|
||||
return ApiResponse[TaskDetail](success=True, msg="success", data=data, code=0)
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=ApiResponse[TaskListData],
|
||||
dependencies=[Depends(require_bearer_token)],
|
||||
)
|
||||
async def list_orders(
|
||||
status: str | None = Query(default=None),
|
||||
site: str | None = Query(default=None),
|
||||
limit: int = Query(default=50, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
container: GatewayContainer = Depends(get_container),
|
||||
) -> ApiResponse[TaskListData]:
|
||||
"""任务列表(运维与上游对账用)"""
|
||||
data = await container.task_queue.list_tasks(
|
||||
status=status, site=site, limit=limit, offset=offset
|
||||
)
|
||||
return ApiResponse[TaskListData](success=True, msg="success", data=data, code=0)
|
||||
@@ -0,0 +1,29 @@
|
||||
"""网关容器:集中管理网关侧服务实例,用于依赖注入"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.gateway.db import GatewayDB
|
||||
from app.gateway.task_queue import TaskQueue
|
||||
from app.shared.config import Settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class GatewayContainer:
|
||||
"""网关容器
|
||||
|
||||
与抓取/交易容器同样的依赖注入风格,但持有的是任务队列与 SQLite 连接,
|
||||
生命周期由 main.lifespan 管理:start 时打开 DB,close 时关闭。
|
||||
|
||||
`sweep_task` 是常驻后台扫描,把过期的 leased/running 推到 stale。
|
||||
即使没有 lease 请求,过期的任务也会被及时发现(规格 §5 的关键约束)。
|
||||
"""
|
||||
|
||||
settings: Settings
|
||||
db: GatewayDB
|
||||
task_queue: TaskQueue
|
||||
sweep_task: asyncio.Task | None = None
|
||||
@@ -0,0 +1,356 @@
|
||||
"""网关 SQLite 访问层:三张表 + 原生 SQL,无业务逻辑
|
||||
|
||||
业务规则(状态机、并发度、幂等)放在 `task_queue.py`,本模块只负责持久化与查询,
|
||||
返回 dataclass 行。所有时间戳以 ISO8601 UTC 字符串存储(以 `Z` 结尾),便于
|
||||
跨进程对账;时间戳运算在 task_queue 层完成。
|
||||
|
||||
写操作由 task_queue 的 asyncio.Lock 串行化(详见该模块),本层不重复加锁,
|
||||
因此**调用方必须确保写操作在外层锁的保护下进行**。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import aiosqlite
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
task_id TEXT PRIMARY KEY,
|
||||
site TEXT NOT NULL,
|
||||
intent_json TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
lease_owner TEXT,
|
||||
lease_expires_at TEXT,
|
||||
lease_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_pending ON tasks(status, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS task_reports (
|
||||
task_id TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
payable_yen INTEGER,
|
||||
pay_deadline TEXT,
|
||||
site_order_id TEXT,
|
||||
evidence_ref TEXT,
|
||||
detail TEXT,
|
||||
reported_at TEXT NOT NULL,
|
||||
PRIMARY KEY (task_id, state)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workers (
|
||||
worker_id TEXT PRIMARY KEY,
|
||||
last_seen_at TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TaskRow:
|
||||
"""tasks 表一行的强类型视图"""
|
||||
|
||||
task_id: str
|
||||
site: str
|
||||
intent_json: str
|
||||
status: str
|
||||
lease_owner: str | None
|
||||
lease_expires_at: str | None
|
||||
lease_count: int
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
@property
|
||||
def intent(self) -> dict[str, Any]:
|
||||
return json.loads(self.intent_json)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ReportRow:
|
||||
"""task_reports 表一行的强类型视图"""
|
||||
|
||||
task_id: str
|
||||
state: str
|
||||
payable_yen: int | None
|
||||
pay_deadline: str | None
|
||||
site_order_id: str | None
|
||||
evidence_ref: str | None
|
||||
detail: str
|
||||
reported_at: str
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class WorkerRow:
|
||||
"""workers 表一行的强类型视图"""
|
||||
|
||||
worker_id: str
|
||||
last_seen_at: str
|
||||
|
||||
|
||||
def _row_to_task(row: aiosqlite.Row) -> TaskRow:
|
||||
return TaskRow(
|
||||
task_id=row["task_id"],
|
||||
site=row["site"],
|
||||
intent_json=row["intent_json"],
|
||||
status=row["status"],
|
||||
lease_owner=row["lease_owner"],
|
||||
lease_expires_at=row["lease_expires_at"],
|
||||
lease_count=row["lease_count"],
|
||||
created_at=row["created_at"],
|
||||
updated_at=row["updated_at"],
|
||||
)
|
||||
|
||||
|
||||
def _row_to_report(row: aiosqlite.Row) -> ReportRow:
|
||||
return ReportRow(
|
||||
task_id=row["task_id"],
|
||||
state=row["state"],
|
||||
payable_yen=row["payable_yen"],
|
||||
pay_deadline=row["pay_deadline"],
|
||||
site_order_id=row["site_order_id"],
|
||||
evidence_ref=row["evidence_ref"],
|
||||
detail=row["detail"],
|
||||
reported_at=row["reported_at"],
|
||||
)
|
||||
|
||||
|
||||
def _row_to_worker(row: aiosqlite.Row) -> WorkerRow:
|
||||
return WorkerRow(worker_id=row["worker_id"], last_seen_at=row["last_seen_at"])
|
||||
|
||||
|
||||
class GatewayDB:
|
||||
"""网关 SQLite 访问对象
|
||||
|
||||
单连接 + aiosqlite 的内部线程。生命周期由 `GatewayContainer` 管理:
|
||||
`start()` 在 lifespan 启动时调用,`close()` 在关闭时调用。
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: Path):
|
||||
self._db_path = db_path
|
||||
self._conn: aiosqlite.Connection | None = None
|
||||
|
||||
async def start(self) -> None:
|
||||
"""打开连接并初始化 schema(IF NOT EXISTS,可重复执行)"""
|
||||
self._db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._conn = await aiosqlite.connect(str(self._db_path))
|
||||
self._conn.row_factory = aiosqlite.Row
|
||||
await self._conn.executescript(SCHEMA)
|
||||
await self._conn.commit()
|
||||
logger.info("网关 DB 已就绪:%s", self._db_path)
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._conn is not None:
|
||||
await self._conn.close()
|
||||
self._conn = None
|
||||
|
||||
@property
|
||||
def conn(self) -> aiosqlite.Connection:
|
||||
if self._conn is None:
|
||||
raise RuntimeError("GatewayDB 未启动:先调用 start()")
|
||||
return self._conn
|
||||
|
||||
# ---- tasks ----
|
||||
|
||||
async def get_task(self, task_id: str) -> TaskRow | None:
|
||||
async with self.conn.execute("SELECT * FROM tasks WHERE task_id = ?", (task_id,)) as cur:
|
||||
row = await cur.fetchone()
|
||||
return _row_to_task(row) if row else None
|
||||
|
||||
async def insert_task(self, row: TaskRow) -> bool:
|
||||
"""插入新任务。返回 True=新建,False=task_id 已存在(幂等命中)"""
|
||||
try:
|
||||
await self.conn.execute(
|
||||
"INSERT INTO tasks (task_id, site, intent_json, status, "
|
||||
"lease_owner, lease_expires_at, lease_count, created_at, updated_at) "
|
||||
"VALUES (?, ?, ?, ?, NULL, NULL, 0, ?, ?)",
|
||||
(
|
||||
row.task_id,
|
||||
row.site,
|
||||
row.intent_json,
|
||||
row.status,
|
||||
row.created_at,
|
||||
row.updated_at,
|
||||
),
|
||||
)
|
||||
await self.conn.commit()
|
||||
return True
|
||||
except aiosqlite.IntegrityError:
|
||||
return False
|
||||
|
||||
async def update_task(
|
||||
self,
|
||||
task_id: str,
|
||||
*,
|
||||
status: str | None = None,
|
||||
lease_owner: str | None = None,
|
||||
lease_expires_at: str | None = None,
|
||||
lease_count: int | None = None,
|
||||
updated_at: str | None = None,
|
||||
clear_lease: bool = False,
|
||||
) -> None:
|
||||
"""更新任务字段。clear_lease=True 时把 lease_owner/expires_at 置 NULL"""
|
||||
sets: list[str] = []
|
||||
params: list[Any] = []
|
||||
if status is not None:
|
||||
sets.append("status = ?")
|
||||
params.append(status)
|
||||
if lease_owner is not None:
|
||||
sets.append("lease_owner = ?")
|
||||
params.append(lease_owner)
|
||||
if lease_expires_at is not None:
|
||||
sets.append("lease_expires_at = ?")
|
||||
params.append(lease_expires_at)
|
||||
if lease_count is not None:
|
||||
sets.append("lease_count = ?")
|
||||
params.append(lease_count)
|
||||
if updated_at is not None:
|
||||
sets.append("updated_at = ?")
|
||||
params.append(updated_at)
|
||||
if clear_lease:
|
||||
sets.append("lease_owner = NULL")
|
||||
sets.append("lease_expires_at = NULL")
|
||||
if not sets:
|
||||
return
|
||||
params.append(task_id)
|
||||
await self.conn.execute(
|
||||
f"UPDATE tasks SET {', '.join(sets)} WHERE task_id = ?",
|
||||
params,
|
||||
)
|
||||
await self.conn.commit()
|
||||
|
||||
async def list_tasks(
|
||||
self,
|
||||
*,
|
||||
status: str | None = None,
|
||||
site: str | None = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> tuple[list[TaskRow], int]:
|
||||
"""分页列出任务,按 created_at 升序"""
|
||||
where = []
|
||||
params: list[Any] = []
|
||||
if status:
|
||||
where.append("status = ?")
|
||||
params.append(status)
|
||||
if site:
|
||||
where.append("site = ?")
|
||||
params.append(site)
|
||||
clause = f"WHERE {' AND '.join(where)}" if where else ""
|
||||
|
||||
async with self.conn.execute(
|
||||
f"SELECT COUNT(*) FROM tasks {clause}",
|
||||
params,
|
||||
) as cur:
|
||||
total = (await cur.fetchone())[0]
|
||||
|
||||
sql = f"SELECT * FROM tasks {clause} ORDER BY created_at ASC LIMIT ? OFFSET ?"
|
||||
async with self.conn.execute(sql, [*params, limit, offset]) as cur:
|
||||
rows = await cur.fetchall()
|
||||
return [_row_to_task(r) for r in rows], total
|
||||
|
||||
async def list_tasks_in_statuses(self, statuses: tuple[str, ...]) -> list[TaskRow]:
|
||||
"""取处于给定状态集合的全部任务(不分页,用于健康检查的 active 列表)"""
|
||||
if not statuses:
|
||||
return []
|
||||
placeholders = ", ".join("?" for _ in statuses)
|
||||
sql = (
|
||||
f"SELECT * FROM tasks WHERE status IN ({placeholders}) "
|
||||
"ORDER BY created_at ASC"
|
||||
)
|
||||
async with self.conn.execute(sql, statuses) as cur:
|
||||
rows = await cur.fetchall()
|
||||
return [_row_to_task(r) for r in rows]
|
||||
|
||||
async def pick_queued_task(self, site: str | None) -> TaskRow | None:
|
||||
"""取最早的 queued 任务,可选按站点过滤"""
|
||||
if site:
|
||||
sql = "SELECT * FROM tasks WHERE status = ? AND site = ? ORDER BY created_at ASC LIMIT 1"
|
||||
params: tuple[Any, ...] = ("queued", site)
|
||||
else:
|
||||
sql = "SELECT * FROM tasks WHERE status = ? ORDER BY created_at ASC LIMIT 1"
|
||||
params = ("queued",)
|
||||
async with self.conn.execute(sql, params) as cur:
|
||||
row = await cur.fetchone()
|
||||
return _row_to_task(row) if row else None
|
||||
|
||||
# ---- task_reports ----
|
||||
|
||||
async def upsert_report(self, row: ReportRow) -> bool:
|
||||
"""写入一条 report。同一 (task_id, state) 已存在则覆盖(幂等)。
|
||||
|
||||
返回 True=本次新写入,False=覆盖了既有行。
|
||||
|
||||
先 SELECT 再 INSERT OR REPLACE,避免依赖 SQLite rowcount——后者在
|
||||
INSERT OR REPLACE 上即使命中既有行也报 1,无法区分。
|
||||
"""
|
||||
async with self.conn.execute(
|
||||
"SELECT 1 FROM task_reports WHERE task_id = ? AND state = ?",
|
||||
(row.task_id, row.state),
|
||||
) as cur:
|
||||
existed = await cur.fetchone() is not None
|
||||
|
||||
await self.conn.execute(
|
||||
"INSERT OR REPLACE INTO task_reports "
|
||||
"(task_id, state, payable_yen, pay_deadline, site_order_id, "
|
||||
"evidence_ref, detail, reported_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
row.task_id,
|
||||
row.state,
|
||||
row.payable_yen,
|
||||
row.pay_deadline,
|
||||
row.site_order_id,
|
||||
row.evidence_ref,
|
||||
row.detail,
|
||||
row.reported_at,
|
||||
),
|
||||
)
|
||||
await self.conn.commit()
|
||||
return not existed
|
||||
|
||||
async def get_reports(self, task_id: str) -> list[ReportRow]:
|
||||
async with self.conn.execute(
|
||||
"SELECT * FROM task_reports WHERE task_id = ? ORDER BY reported_at ASC",
|
||||
(task_id,),
|
||||
) as cur:
|
||||
rows = await cur.fetchall()
|
||||
return [_row_to_report(r) for r in rows]
|
||||
|
||||
async def get_latest_state(self, task_id: str) -> str | None:
|
||||
"""取该任务最近一次 report 的 state,无 report 返回 None"""
|
||||
async with self.conn.execute(
|
||||
"SELECT state FROM task_reports WHERE task_id = ? "
|
||||
"ORDER BY reported_at DESC LIMIT 1",
|
||||
(task_id,),
|
||||
) as cur:
|
||||
row = await cur.fetchone()
|
||||
return row["state"] if row else None
|
||||
|
||||
# ---- workers ----
|
||||
|
||||
async def upsert_worker(self, worker_id: str, last_seen_at: str) -> None:
|
||||
await self.conn.execute(
|
||||
"INSERT INTO workers (worker_id, last_seen_at) VALUES (?, ?) "
|
||||
"ON CONFLICT(worker_id) DO UPDATE SET last_seen_at = excluded.last_seen_at",
|
||||
(worker_id, last_seen_at),
|
||||
)
|
||||
await self.conn.commit()
|
||||
|
||||
async def list_workers(self) -> list[WorkerRow]:
|
||||
async with self.conn.execute("SELECT * FROM workers ORDER BY last_seen_at DESC") as cur:
|
||||
rows = await cur.fetchall()
|
||||
return [_row_to_worker(r) for r in rows]
|
||||
|
||||
# ---- 计数 ----
|
||||
|
||||
async def count_by_status(self, status: str) -> int:
|
||||
async with self.conn.execute(
|
||||
"SELECT COUNT(*) FROM tasks WHERE status = ?",
|
||||
(status,),
|
||||
) as cur:
|
||||
return (await cur.fetchone())[0]
|
||||
@@ -0,0 +1,124 @@
|
||||
"""下单任务网关入口:FastAPI 应用创建与生命周期管理
|
||||
|
||||
部署在服务器侧,与抓取服务(:31107)、交易服务(:31108)并列。本地 worker 通过
|
||||
出站长轮询从这里领任务;上游业务系统通过 POST /api/orders 提交下单意图。
|
||||
|
||||
为什么必须独立部署单元(不能塞进抓取服务):抓取无状态可多开,任务队列有状态,
|
||||
多实例会抢同一批任务,同一账号的写操作必须串行(详见 docs/order-gateway.md §2)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.gateway.api.routes.health import router as health_router
|
||||
from app.gateway.api.routes.orders import router as orders_router
|
||||
from app.gateway.container import GatewayContainer
|
||||
from app.gateway.db import GatewayDB
|
||||
from app.gateway.task_queue import TaskQueue
|
||||
from app.shared.api import register_exception_handlers
|
||||
from app.shared.config import get_settings
|
||||
from app.shared.logging_setup import configure_logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SWEEP_INTERVAL_SECONDS = 60
|
||||
|
||||
|
||||
def build_container() -> GatewayContainer:
|
||||
"""构建网关容器:DB + 任务队列"""
|
||||
settings = get_settings()
|
||||
db = GatewayDB(settings.gateway_db_path_resolved)
|
||||
task_queue = TaskQueue(
|
||||
db,
|
||||
lease_ttl_seconds=settings.lease_ttl_seconds,
|
||||
worker_offline_alert_seconds=settings.worker_offline_alert_seconds,
|
||||
)
|
||||
return GatewayContainer(settings=settings, db=db, task_queue=task_queue)
|
||||
|
||||
|
||||
async def _sweep_loop(container: GatewayContainer) -> None:
|
||||
"""常驻后台任务:每 60 秒扫一次过期租约,把 leased/running 推到 stale
|
||||
|
||||
lease 请求本身也会顺带扫一次,但 worker 退场后没人来 lease,必须靠这个
|
||||
兜底——否则过期的任务永远停在 active 状态,健康检查看不到,stale 任务也
|
||||
取不出来 reclaim。
|
||||
"""
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(SWEEP_INTERVAL_SECONDS)
|
||||
swept = await container.task_queue.sweep()
|
||||
if swept:
|
||||
logger.info("sweep 把 %s 个过期任务置为 stale", swept)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception: # noqa: BLE001
|
||||
# 后台任务不能因为偶发错误退出,否则过期任务再也无人清理
|
||||
logger.exception("sweep 后台任务出错,将继续重试")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""应用生命周期:启动 DB、起 sweep 后台任务、关闭时反序释放"""
|
||||
container = build_container()
|
||||
app.state.container = container
|
||||
|
||||
configure_logging(container.settings)
|
||||
logger.info(
|
||||
"网关启动:%s:%s", container.settings.gateway_host, container.settings.gateway_port
|
||||
)
|
||||
logger.info("当前环境:%s", container.settings.app_env)
|
||||
logger.info("DB 路径:%s", container.settings.gateway_db_path_resolved)
|
||||
|
||||
await container.db.start()
|
||||
# 启动时先扫一次:上次进程退出时可能留有 leased/running 的过期任务
|
||||
startup_swept = await container.task_queue.sweep()
|
||||
if startup_swept:
|
||||
logger.warning("启动时把 %s 个遗留过期任务置为 stale", startup_swept)
|
||||
|
||||
container.sweep_task = asyncio.create_task(
|
||||
_sweep_loop(container), name="gateway-sweep"
|
||||
)
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if container.sweep_task is not None:
|
||||
container.sweep_task.cancel()
|
||||
try:
|
||||
await container.sweep_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
container.sweep_task = None
|
||||
await container.db.close()
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
"""创建 FastAPI 应用实例,注册路由和异常处理器"""
|
||||
app = FastAPI(title="Rakuten Order Gateway", lifespan=lifespan)
|
||||
app.include_router(health_router)
|
||||
app.include_router(orders_router)
|
||||
register_exception_handlers(app)
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
settings = get_settings()
|
||||
configure_logging(settings)
|
||||
uvicorn.run(
|
||||
"app.gateway.main:app",
|
||||
host=settings.gateway_host,
|
||||
port=settings.gateway_port,
|
||||
log_config=None,
|
||||
timeout_keep_alive=120,
|
||||
# 单进程:SQLite 单连接 + 全局并发度 1,多进程会抢同一个 DB
|
||||
workers=1,
|
||||
)
|
||||
@@ -0,0 +1,194 @@
|
||||
"""网关 API 数据模型:请求体与响应体
|
||||
|
||||
intent 字段刻意保留成 `dict[str, Any]`——网关不解释下单意图,结构由 trading 侧
|
||||
定义。网关只负责把它存下来、原样吐给 worker,避免业务规则悄悄渗进任务队列。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.shared.task_state import OrderState, TaskStatus
|
||||
|
||||
|
||||
# ---- POST /api/orders ----
|
||||
|
||||
|
||||
class SubmitOrderRequest(BaseModel):
|
||||
"""上游提交下单意图
|
||||
|
||||
task_id 可选:上游自带的幂等键。不传则服务端生成。同一个 task_id 重复提交
|
||||
不新建任务,返回既有任务且 created=false。
|
||||
"""
|
||||
|
||||
task_id: str | None = None
|
||||
site: str
|
||||
intent: dict[str, Any]
|
||||
|
||||
|
||||
class SubmitOrderData(BaseModel):
|
||||
"""提交响应"""
|
||||
|
||||
task_id: str
|
||||
status: TaskStatus
|
||||
created: bool # True=本次新建,False=命中既有任务(幂等)
|
||||
|
||||
|
||||
# ---- GET /api/orders/lease ----
|
||||
|
||||
|
||||
class LeaseData(BaseModel):
|
||||
"""lease 响应
|
||||
|
||||
无任务可领时整个 data 为 null(HTTP 仍 200)。lease_count > 1 表示这是恢复
|
||||
领取(stale → reclaim),worker 必须先核对站点订单列表,见 runner 主循环。
|
||||
"""
|
||||
|
||||
task_id: str
|
||||
site: str
|
||||
intent: dict[str, Any]
|
||||
lease_expires_at: str # ISO8601 UTC
|
||||
lease_count: int
|
||||
known_state: OrderState | None # 之前上报过的最新订单状态;首次领取为 null
|
||||
|
||||
|
||||
# ---- POST /api/orders/{id}/renew ----
|
||||
|
||||
|
||||
class RenewRequest(BaseModel):
|
||||
"""续租请求"""
|
||||
|
||||
worker_id: str
|
||||
|
||||
|
||||
class RenewData(BaseModel):
|
||||
"""续租响应"""
|
||||
|
||||
task_id: str
|
||||
lease_expires_at: str
|
||||
lease_count: int
|
||||
|
||||
|
||||
# ---- POST /api/orders/{id}/report ----
|
||||
|
||||
|
||||
class ReportRequest(BaseModel):
|
||||
"""本地回报订单状态
|
||||
|
||||
同一 (task_id, state) 重复上报是幂等的——网络抖动导致 worker 重发时覆盖同一行,
|
||||
不产生第二条记录。terminal=true 时释放租约并把任务推到终态。
|
||||
"""
|
||||
|
||||
worker_id: str
|
||||
state: OrderState
|
||||
payable_yen: int | None = None
|
||||
pay_deadline: str | None = None # ISO8601
|
||||
site_order_id: str | None = None
|
||||
evidence_ref: str | None = None # 本地相对路径,不含扩展名
|
||||
detail: str = ""
|
||||
terminal: bool = False
|
||||
terminal_status: TaskStatus | None = None # terminal=true 时指定终态,缺省按 state 推断
|
||||
|
||||
|
||||
class ReportData(BaseModel):
|
||||
"""回报响应"""
|
||||
|
||||
task_id: str
|
||||
status: TaskStatus
|
||||
recorded: bool # False=同 (task_id, state) 已存在,本次为幂等覆盖;True=新写入
|
||||
|
||||
|
||||
# ---- POST /api/orders/{id}/reclaim ----
|
||||
|
||||
|
||||
class ReclaimRequest(BaseModel):
|
||||
"""把 stale 任务重新租给 worker"""
|
||||
|
||||
worker_id: str
|
||||
|
||||
|
||||
class ReclaimData(BaseModel):
|
||||
"""reclaim 响应,结构与 LeaseData 一致,但 lease_count 必然 > 1"""
|
||||
|
||||
task_id: str
|
||||
site: str
|
||||
intent: dict[str, Any]
|
||||
lease_expires_at: str
|
||||
lease_count: int
|
||||
known_state: OrderState | None
|
||||
|
||||
|
||||
# ---- GET /api/orders/{id} 与 GET /api/orders ----
|
||||
|
||||
|
||||
class ReportEntry(BaseModel):
|
||||
"""task_reports 单行"""
|
||||
|
||||
state: OrderState
|
||||
payable_yen: int | None = None
|
||||
pay_deadline: str | None = None
|
||||
site_order_id: str | None = None
|
||||
evidence_ref: str | None = None
|
||||
detail: str = ""
|
||||
reported_at: str
|
||||
|
||||
|
||||
class TaskDetail(BaseModel):
|
||||
"""单个任务详情"""
|
||||
|
||||
task_id: str
|
||||
site: str
|
||||
intent: dict[str, Any]
|
||||
status: TaskStatus
|
||||
lease_owner: str | None = None
|
||||
lease_expires_at: str | None = None
|
||||
lease_count: int = 0
|
||||
created_at: str
|
||||
updated_at: str
|
||||
latest_state: OrderState | None = None
|
||||
reports: list[ReportEntry] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TaskListData(BaseModel):
|
||||
"""任务列表"""
|
||||
|
||||
items: list[TaskDetail]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
# ---- GET /health ----
|
||||
|
||||
|
||||
class WorkerHealthEntry(BaseModel):
|
||||
"""单个 worker 的健康指标"""
|
||||
|
||||
worker_id: str
|
||||
last_seen_at: str
|
||||
last_seen_seconds: int
|
||||
|
||||
|
||||
class QueuedAlertEntry(BaseModel):
|
||||
"""长时间无人领的任务告警项"""
|
||||
|
||||
task_id: str
|
||||
site: str
|
||||
created_at: str
|
||||
age_seconds: int
|
||||
|
||||
|
||||
class GatewayHealthData(BaseModel):
|
||||
"""网关健康状态
|
||||
|
||||
只做规格 §4.7 的两条兜底告警:worker 失联、任务长时间无人领。付款期限监控
|
||||
不在网关——本地机 7×24 在线,那套逻辑放本地。
|
||||
"""
|
||||
|
||||
status: str # "ok" 或 "degraded"
|
||||
queued_count: int
|
||||
active_tasks: list[TaskDetail] = Field(default_factory=list)
|
||||
workers: list[WorkerHealthEntry] = Field(default_factory=list)
|
||||
offline_workers: list[WorkerHealthEntry] = Field(default_factory=list)
|
||||
stale_queued_tasks: list[QueuedAlertEntry] = Field(default_factory=list)
|
||||
@@ -0,0 +1,505 @@
|
||||
"""任务队列业务逻辑:状态机、租约、并发度、幂等
|
||||
|
||||
所有写操作通过 `self._lock` 串行化。该锁同时保护「检查 + 写入」的原子序列
|
||||
(如 lease 时「检查是否已有 active 任务 → 取 queued → 改为 leased」),避免两个
|
||||
lease 请求并发取到同一个任务。
|
||||
|
||||
长轮询通过 `self._cond`(基于同一把锁的 Condition)唤醒:submit / report(terminal)
|
||||
/ reclaim 失败等任何可能让「队列前进」的事件都 notify_all。lease 在无可领任务时
|
||||
wait_for,超时返回 None。
|
||||
|
||||
绝不自动重投(docs/order-gateway.md §5):sweep 把过期任务置 stale 后**不**回到
|
||||
queued,普通 lease 取不到它;恢复只能走 reclaim。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.shared.task_state import (
|
||||
ACTIVE_STATUSES,
|
||||
LEASABLE_STATUSES,
|
||||
RECLAIMABLE_STATUSES,
|
||||
TERMINAL_STATUSES,
|
||||
TaskStatus,
|
||||
)
|
||||
from app.gateway.db import GatewayDB, ReportRow, TaskRow
|
||||
from app.gateway.models import (
|
||||
GatewayHealthData,
|
||||
LeaseData,
|
||||
QueuedAlertEntry,
|
||||
ReportEntry,
|
||||
RenewData,
|
||||
ReclaimData,
|
||||
ReportData,
|
||||
SubmitOrderData,
|
||||
TaskDetail,
|
||||
TaskListData,
|
||||
WorkerHealthEntry,
|
||||
)
|
||||
from app.shared.errors import (
|
||||
InvalidTaskStateError,
|
||||
LeaseInvalidError,
|
||||
TaskNotFoundError,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def to_iso(dt: datetime) -> str:
|
||||
"""统一时间戳格式:ISO8601 UTC,秒精度,Z 结尾"""
|
||||
return dt.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def parse_iso(s: str) -> datetime:
|
||||
"""解析 to_iso 产出的字符串(也兼容带 +00:00 的变体)"""
|
||||
if s.endswith("Z"):
|
||||
s = s[:-1] + "+00:00"
|
||||
return datetime.fromisoformat(s)
|
||||
|
||||
|
||||
def _generate_task_id() -> str:
|
||||
"""服务端生成的 task_id:日期 + 8 字节随机 hex,避免与上游自带的冲突"""
|
||||
return f"po-{to_iso(utcnow())[:10].replace('-', '')}-{secrets.token_hex(4)}"
|
||||
|
||||
|
||||
def _task_to_detail(task: TaskRow, latest_state: str | None, reports: list[ReportEntry]) -> TaskDetail:
|
||||
return TaskDetail(
|
||||
task_id=task.task_id,
|
||||
site=task.site,
|
||||
intent=task.intent,
|
||||
status=TaskStatus(task.status),
|
||||
lease_owner=task.lease_owner,
|
||||
lease_expires_at=task.lease_expires_at,
|
||||
lease_count=task.lease_count,
|
||||
created_at=task.created_at,
|
||||
updated_at=task.updated_at,
|
||||
latest_state=latest_state,
|
||||
reports=reports,
|
||||
)
|
||||
|
||||
|
||||
class TaskQueue:
|
||||
"""任务队列业务逻辑
|
||||
|
||||
持有一个 GatewayDB 实例。所有写操作与「检查 + 写入」原子序列都在 `self._lock`
|
||||
保护下;长轮询等待通过 `self._cond` 唤醒。
|
||||
"""
|
||||
|
||||
def __init__(self, db: GatewayDB, *, lease_ttl_seconds: int, worker_offline_alert_seconds: int):
|
||||
self._db = db
|
||||
self._lease_ttl = lease_ttl_seconds
|
||||
self._worker_offline_alert_seconds = worker_offline_alert_seconds
|
||||
self._lock = asyncio.Lock()
|
||||
self._cond = asyncio.Condition(self._lock)
|
||||
|
||||
# ---- 提交 ----
|
||||
|
||||
async def submit(self, *, task_id: str | None, site: str, intent: dict) -> SubmitOrderData:
|
||||
"""上游提交下单意图。task_id 缺省时服务端生成;重复提交幂等"""
|
||||
import json
|
||||
|
||||
tid = task_id or _generate_task_id()
|
||||
now = to_iso(utcnow())
|
||||
row = TaskRow(
|
||||
task_id=tid,
|
||||
site=site,
|
||||
intent_json=json.dumps(intent, ensure_ascii=False),
|
||||
status=TaskStatus.QUEUED.value,
|
||||
lease_owner=None,
|
||||
lease_expires_at=None,
|
||||
lease_count=0,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
async with self._lock:
|
||||
created = await self._db.insert_task(row)
|
||||
if created:
|
||||
self._cond.notify_all()
|
||||
else:
|
||||
existing = await self._db.get_task(tid)
|
||||
# 既不可能创建失败又拿不到既有行:除非被并发删除,按罕见错误处理
|
||||
if existing is None:
|
||||
raise RuntimeError(f"任务 {tid} 既未新建也无法读取,状态异常")
|
||||
row = existing
|
||||
return SubmitOrderData(task_id=row.task_id, status=TaskStatus(row.status), created=created)
|
||||
|
||||
# ---- 领取 ----
|
||||
|
||||
async def lease(
|
||||
self,
|
||||
*,
|
||||
worker_id: str,
|
||||
wait: int,
|
||||
site: str | None,
|
||||
max_wait: int,
|
||||
) -> LeaseData | None:
|
||||
"""本地长轮询领取
|
||||
|
||||
- 全局并发度 1:已有 leased/running 任务时立即返回 None
|
||||
- 无可领任务时挂起最多 `min(wait, max_wait)` 秒,超时返回 None
|
||||
- 拿到任务时 queued → leased,写入 lease_owner / lease_expires_at / lease_count
|
||||
- 顺带刷新 worker 心跳(last_seen_at),这就是 lease 兼任心跳的设计
|
||||
"""
|
||||
effective_wait = max(0, min(wait, max_wait))
|
||||
deadline = utcnow() + timedelta(seconds=effective_wait)
|
||||
|
||||
async with self._lock:
|
||||
# 先把过期租约扫到 stale,避免「卡死的任务」堵住新任务
|
||||
await self._sweep_locked()
|
||||
|
||||
await self._db.upsert_worker(worker_id, to_iso(utcnow()))
|
||||
|
||||
while True:
|
||||
result = await self._try_pick_locked(worker_id, site)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
remaining = (deadline - utcnow()).total_seconds()
|
||||
if remaining <= 0:
|
||||
return None
|
||||
try:
|
||||
await asyncio.wait_for(self._cond.wait(), timeout=remaining)
|
||||
except asyncio.TimeoutError:
|
||||
return None
|
||||
|
||||
async def _try_pick_locked(self, worker_id: str, site: str | None) -> LeaseData | None:
|
||||
"""锁内尝试领取一次。返回 None 表示此刻无可领任务"""
|
||||
# 全局并发度 1:已有 leased/running 立即返回空
|
||||
active = await self._db.list_tasks_in_statuses(tuple(s.value for s in ACTIVE_STATUSES))
|
||||
if active:
|
||||
return None
|
||||
|
||||
task = await self._db.pick_queued_task(site)
|
||||
if task is None:
|
||||
return None
|
||||
|
||||
return await self._lease_to_locked(task, worker_id, known_state=None)
|
||||
|
||||
async def _lease_to_locked(
|
||||
self,
|
||||
task: TaskRow,
|
||||
worker_id: str,
|
||||
*,
|
||||
known_state: str | None,
|
||||
) -> LeaseData:
|
||||
"""把一个 queued 或 stale 任务租给 worker。调用方必须持有 self._lock"""
|
||||
expires = to_iso(utcnow() + timedelta(seconds=self._lease_ttl))
|
||||
new_count = task.lease_count + 1
|
||||
await self._db.update_task(
|
||||
task.task_id,
|
||||
status=TaskStatus.LEASED.value,
|
||||
lease_owner=worker_id,
|
||||
lease_expires_at=expires,
|
||||
lease_count=new_count,
|
||||
updated_at=to_iso(utcnow()),
|
||||
)
|
||||
# 已知状态:取最近一次 report 的 state(首次领取为 None)
|
||||
if known_state is None:
|
||||
known_state = await self._db.get_latest_state(task.task_id)
|
||||
return LeaseData(
|
||||
task_id=task.task_id,
|
||||
site=task.site,
|
||||
intent=task.intent,
|
||||
lease_expires_at=expires,
|
||||
lease_count=new_count,
|
||||
known_state=known_state, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
# ---- 续租 ----
|
||||
|
||||
async def renew(self, task_id: str, worker_id: str) -> RenewData:
|
||||
async with self._lock:
|
||||
task = await self._db.get_task(task_id)
|
||||
if task is None:
|
||||
raise TaskNotFoundError(task_id)
|
||||
if task.lease_owner != worker_id:
|
||||
raise LeaseInvalidError(f"worker {worker_id} 不是任务 {task_id} 的租约持有者")
|
||||
if TaskStatus(task.status) in TERMINAL_STATUSES:
|
||||
raise LeaseInvalidError(f"任务 {task_id} 已终结,无法续租")
|
||||
if TaskStatus(task.status) not in ACTIVE_STATUSES:
|
||||
raise LeaseInvalidError(f"任务 {task_id} 状态 {task.status},无可续租约")
|
||||
|
||||
expires = to_iso(utcnow() + timedelta(seconds=self._lease_ttl))
|
||||
await self._db.update_task(
|
||||
task_id,
|
||||
lease_expires_at=expires,
|
||||
updated_at=to_iso(utcnow()),
|
||||
)
|
||||
return RenewData(task_id=task_id, lease_expires_at=expires, lease_count=task.lease_count)
|
||||
|
||||
# ---- 回报 ----
|
||||
|
||||
async def report(
|
||||
self,
|
||||
task_id: str,
|
||||
*,
|
||||
worker_id: str,
|
||||
state: str,
|
||||
payable_yen: int | None,
|
||||
pay_deadline: str | None,
|
||||
site_order_id: str | None,
|
||||
evidence_ref: str | None,
|
||||
detail: str,
|
||||
terminal: bool,
|
||||
terminal_status: str | None,
|
||||
) -> ReportData:
|
||||
async with self._lock:
|
||||
task = await self._db.get_task(task_id)
|
||||
if task is None:
|
||||
raise TaskNotFoundError(task_id)
|
||||
if task.lease_owner != worker_id:
|
||||
raise LeaseInvalidError(f"worker {worker_id} 不是任务 {task_id} 的租约持有者")
|
||||
status = TaskStatus(task.status)
|
||||
|
||||
if status in TERMINAL_STATUSES:
|
||||
# 任务已终结:仍然追加 report(规格 §6 末尾「任务已 terminal 的仍可上报,
|
||||
# gateway 追加到 task_reports」),但不允许改任务状态
|
||||
if terminal and terminal_status is not None and terminal_status != status.value:
|
||||
raise InvalidTaskStateError(
|
||||
f"任务 {task_id} 已是终态 {status.value},不能改为 {terminal_status}"
|
||||
)
|
||||
elif status == TaskStatus.LEASED:
|
||||
# 首次 report:leased → running
|
||||
await self._db.update_task(
|
||||
task_id, status=TaskStatus.RUNNING.value, updated_at=to_iso(utcnow())
|
||||
)
|
||||
|
||||
now = to_iso(utcnow())
|
||||
inserted = await self._db.upsert_report(
|
||||
ReportRow(
|
||||
task_id=task_id,
|
||||
state=state,
|
||||
payable_yen=payable_yen,
|
||||
pay_deadline=pay_deadline,
|
||||
site_order_id=site_order_id,
|
||||
evidence_ref=evidence_ref,
|
||||
detail=detail,
|
||||
reported_at=now,
|
||||
)
|
||||
)
|
||||
|
||||
final_status = status.value
|
||||
if terminal:
|
||||
final_status = self._resolve_terminal_status(
|
||||
current=status, explicit=terminal_status, state=state
|
||||
)
|
||||
await self._db.update_task(
|
||||
task_id,
|
||||
status=final_status,
|
||||
clear_lease=True,
|
||||
updated_at=now,
|
||||
)
|
||||
# 任务终结可能让并发度 1 的闸门放开,唤醒等着的 lease
|
||||
self._cond.notify_all()
|
||||
|
||||
return ReportData(task_id=task_id, status=TaskStatus(final_status), recorded=inserted)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_terminal_status(
|
||||
*, current: TaskStatus, explicit: str | None, state: str
|
||||
) -> str:
|
||||
"""terminal=true 时决定任务的终态。
|
||||
|
||||
显式指定优先;否则按订单 state 推断:paid → succeeded,cancelled → failed,
|
||||
其余(如 awaiting_payment 永久搁置)→ needs_human。
|
||||
|
||||
注意 cancelled 既可能来自明确失败,也可能是站点侧自动取消(如付款期限过期),
|
||||
统一记 failed 让上游看到需要处理。
|
||||
"""
|
||||
if explicit is not None:
|
||||
if explicit not in {s.value for s in TERMINAL_STATUSES}:
|
||||
raise InvalidTaskStateError(f"terminal_status={explicit} 不是终态")
|
||||
return explicit
|
||||
if state == "paid":
|
||||
return TaskStatus.SUCCEEDED.value
|
||||
if state == "cancelled":
|
||||
return TaskStatus.FAILED.value
|
||||
# awaiting_payment 等无明确结局的状态被标 terminal 时,多半是 worker 检测到
|
||||
# 3DS 之类人工环节,按 needs_human 处理
|
||||
return TaskStatus.NEEDS_HUMAN.value
|
||||
|
||||
# ---- 恢复(绝不自动重投,见规格 §5)----
|
||||
|
||||
async def reclaim(self, task_id: str, worker_id: str) -> ReclaimData:
|
||||
"""把 stale 任务重新租给 worker。lease_count 必然 > 1"""
|
||||
async with self._lock:
|
||||
task = await self._db.get_task(task_id)
|
||||
if task is None:
|
||||
raise TaskNotFoundError(task_id)
|
||||
if TaskStatus(task.status) not in RECLAIMABLE_STATUSES:
|
||||
raise InvalidTaskStateError(
|
||||
f"任务 {task_id} 状态 {task.status},不能 reclaim(只能 reclaim stale 任务)"
|
||||
)
|
||||
|
||||
known_state = await self._db.get_latest_state(task_id)
|
||||
lease = await self._lease_to_locked(task, worker_id, known_state=known_state)
|
||||
# 复用 LeaseData 的字段构造 ReclaimData(结构一致)
|
||||
return ReclaimData(
|
||||
task_id=lease.task_id,
|
||||
site=lease.site,
|
||||
intent=lease.intent,
|
||||
lease_expires_at=lease.lease_expires_at,
|
||||
lease_count=lease.lease_count,
|
||||
known_state=lease.known_state,
|
||||
)
|
||||
|
||||
# ---- 过期清扫 ----
|
||||
|
||||
async def sweep(self) -> int:
|
||||
"""把过期的 leased/running 任务置 stale。返回清扫条数"""
|
||||
async with self._lock:
|
||||
return await self._sweep_locked()
|
||||
|
||||
async def _sweep_locked(self) -> int:
|
||||
"""锁内执行 sweep。调用方必须持有 self._lock"""
|
||||
active = await self._db.list_tasks_in_statuses(tuple(s.value for s in ACTIVE_STATUSES))
|
||||
now = utcnow()
|
||||
swept = 0
|
||||
for task in active:
|
||||
if task.lease_expires_at and parse_iso(task.lease_expires_at) < now:
|
||||
await self._db.update_task(
|
||||
task.task_id,
|
||||
status=TaskStatus.STALE.value,
|
||||
clear_lease=True,
|
||||
updated_at=to_iso(now),
|
||||
)
|
||||
swept += 1
|
||||
logger.warning(
|
||||
"租约过期,任务置 stale(不自动重投):task_id=%s site=%s",
|
||||
task.task_id,
|
||||
task.site,
|
||||
)
|
||||
if swept:
|
||||
# stale 后腾出了「全局并发度 1」的槽位,唤醒等待者
|
||||
# (虽然 stale 任务不会被普通 lease 取到,但队列里可能还有 queued)
|
||||
self._cond.notify_all()
|
||||
return swept
|
||||
|
||||
# ---- 查询 ----
|
||||
|
||||
async def get_task_detail(self, task_id: str) -> TaskDetail:
|
||||
task = await self._db.get_task(task_id)
|
||||
if task is None:
|
||||
raise TaskNotFoundError(task_id)
|
||||
reports = await self._db.get_reports(task_id)
|
||||
latest = reports[-1].state if reports else None
|
||||
return _task_to_detail(
|
||||
task,
|
||||
latest_state=latest,
|
||||
reports=[
|
||||
ReportEntry(
|
||||
state=r.state,
|
||||
payable_yen=r.payable_yen,
|
||||
pay_deadline=r.pay_deadline,
|
||||
site_order_id=r.site_order_id,
|
||||
evidence_ref=r.evidence_ref,
|
||||
detail=r.detail,
|
||||
reported_at=r.reported_at,
|
||||
)
|
||||
for r in reports
|
||||
],
|
||||
)
|
||||
|
||||
async def list_tasks(
|
||||
self,
|
||||
*,
|
||||
status: str | None,
|
||||
site: str | None,
|
||||
limit: int,
|
||||
offset: int,
|
||||
) -> TaskListData:
|
||||
rows, total = await self._db.list_tasks(
|
||||
status=status, site=site, limit=limit, offset=offset
|
||||
)
|
||||
items: list[TaskDetail] = []
|
||||
for row in rows:
|
||||
reports = await self._db.get_reports(row.task_id)
|
||||
latest = reports[-1].state if reports else None
|
||||
items.append(
|
||||
_task_to_detail(
|
||||
row,
|
||||
latest_state=latest,
|
||||
reports=[
|
||||
ReportEntry(
|
||||
state=r.state,
|
||||
payable_yen=r.payable_yen,
|
||||
pay_deadline=r.pay_deadline,
|
||||
site_order_id=r.site_order_id,
|
||||
evidence_ref=r.evidence_ref,
|
||||
detail=r.detail,
|
||||
reported_at=r.reported_at,
|
||||
)
|
||||
for r in reports
|
||||
],
|
||||
)
|
||||
)
|
||||
return TaskListData(items=items, total=total, limit=limit, offset=offset)
|
||||
|
||||
# ---- 健康检查 ----
|
||||
|
||||
async def health_snapshot(self) -> GatewayHealthData:
|
||||
"""规格 §4.7:只做 worker 失联 + 任务长时间无人领两条兜底告警"""
|
||||
async with self._lock:
|
||||
queued_count = await self._db.count_by_status(TaskStatus.QUEUED.value)
|
||||
active_rows = await self._db.list_tasks_in_statuses(
|
||||
tuple(s.value for s in ACTIVE_STATUSES)
|
||||
)
|
||||
queued_rows = await self._db.list_tasks_in_statuses(
|
||||
(TaskStatus.QUEUED.value,)
|
||||
)
|
||||
workers = await self._db.list_workers()
|
||||
|
||||
now = utcnow()
|
||||
offline_workers: list[WorkerHealthEntry] = []
|
||||
worker_entries: list[WorkerHealthEntry] = []
|
||||
for w in workers:
|
||||
last_seen = parse_iso(w.last_seen_at)
|
||||
age = int((now - last_seen).total_seconds())
|
||||
entry = WorkerHealthEntry(
|
||||
worker_id=w.worker_id,
|
||||
last_seen_at=w.last_seen_at,
|
||||
last_seen_seconds=age,
|
||||
)
|
||||
worker_entries.append(entry)
|
||||
if age > self._worker_offline_alert_seconds:
|
||||
offline_workers.append(entry)
|
||||
|
||||
stale_queued_threshold = utcnow() - timedelta(seconds=self._worker_offline_alert_seconds)
|
||||
stale_queued: list[QueuedAlertEntry] = []
|
||||
for t in queued_rows:
|
||||
created = parse_iso(t.created_at)
|
||||
if created < stale_queued_threshold:
|
||||
stale_queued.append(
|
||||
QueuedAlertEntry(
|
||||
task_id=t.task_id,
|
||||
site=t.site,
|
||||
created_at=t.created_at,
|
||||
age_seconds=int((now - created).total_seconds()),
|
||||
)
|
||||
)
|
||||
|
||||
active_details = [
|
||||
_task_to_detail(
|
||||
t,
|
||||
latest_state=await self._db.get_latest_state(t.task_id),
|
||||
reports=[],
|
||||
)
|
||||
for t in active_rows
|
||||
]
|
||||
|
||||
degraded = bool(offline_workers or stale_queued)
|
||||
return GatewayHealthData(
|
||||
status="degraded" if degraded else "ok",
|
||||
queued_count=queued_count,
|
||||
active_tasks=active_details,
|
||||
workers=worker_entries,
|
||||
offline_workers=offline_workers,
|
||||
stale_queued_tasks=stale_queued,
|
||||
)
|
||||
+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"
|
||||
@@ -1,9 +1,15 @@
|
||||
"""交易服务容器:集中管理交易侧服务实例,用于依赖注入
|
||||
|
||||
目前只有登录态会话。加购、下单、付款、订单监控与页面证据留痕的服务实例
|
||||
后续挂在这里,它们共享同一份 auth_session——同一个账号的写操作必须走同一条
|
||||
cookie 通道,且必须串行,不能各建各的客户端。
|
||||
持有两类东西:
|
||||
- 登录态会话(auth_session)——加购与下单必须用同一份 cookie,全程共享
|
||||
- 本地下单 worker(可选)——RAKUTEN_ORDER_GATEWAY_URL 留空时整套不构造
|
||||
|
||||
worker 的子组件(gateway_client / local_db / evidence_store / runner)都在
|
||||
app.trading.worker 包内,与抓取/网关互不 import;worker 需要商品数据时走抓取
|
||||
服务的 HTTP 接口,需要任务调度时走网关的 HTTP 接口。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.shared.config import Settings
|
||||
@@ -17,7 +23,15 @@ class TradingContainer:
|
||||
与抓取容器最本质的差别不是字段多少,而是**这个进程有状态**:登录态、
|
||||
订单、页面证据都属于某个具体账号,因此交易服务只能单实例运行
|
||||
(或按账号分片),不能像抓取服务那样随意横向扩容。
|
||||
|
||||
worker_* 字段在 RAKUTEN_ORDER_GATEWAY_URL 留空时为 None——此时交易服务
|
||||
只跑登录态接口,不起后台 worker。
|
||||
"""
|
||||
|
||||
settings: Settings
|
||||
auth_session: AuthSession
|
||||
# 以下四项仅在 order_gateway_url 配置时构造;否则为 None,worker 不启动
|
||||
worker_client: object | None = None # app.trading.worker.client.GatewayClient
|
||||
worker_local_db: object | None = None # app.trading.worker.local_db.LocalDB
|
||||
worker_evidence: object | None = None # app.trading.worker.evidence.EvidenceStore
|
||||
worker_runner: object | None = None # app.trading.worker.runner.WorkerRunner
|
||||
|
||||
+65
-4
@@ -10,10 +10,12 @@
|
||||
|
||||
因此本服务只能单实例运行(或按账号分片),扩容靠抓取服务那一侧。
|
||||
|
||||
当前只提供登录态的查询与重载;加购、下单、付款与订单监控在此基础上叠加。
|
||||
worker 启动条件:RAKUTEN_ORDER_GATEWAY_URL 非空。留空时本服务只跑登录态接口,
|
||||
不构造任何 worker 子组件(本地 DB / 证据目录 / 出站客户端都不创建)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
@@ -32,14 +34,41 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def build_container() -> TradingContainer:
|
||||
"""构建交易服务容器,组装所有依赖"""
|
||||
"""构建交易服务容器:登录态会话始终构造,worker 仅在配置了网关 URL 时构造"""
|
||||
settings = get_settings()
|
||||
return TradingContainer(settings=settings, auth_session=AuthSession(settings))
|
||||
container = TradingContainer(settings=settings, auth_session=AuthSession(settings))
|
||||
|
||||
if settings.order_gateway_url:
|
||||
# 延迟 import:未配置网关 URL 时不加载 worker 模块(也就不会拉起 aiosqlite 等)
|
||||
from app.trading.worker.client import GatewayClient
|
||||
from app.trading.worker.evidence import EvidenceStore
|
||||
from app.trading.worker.local_db import LocalDB
|
||||
from app.trading.worker.runner import WorkerRunner
|
||||
|
||||
client = GatewayClient(
|
||||
settings.order_gateway_url,
|
||||
settings.bearer_token,
|
||||
timeout=max(60.0, settings.lease_max_wait_seconds + 10),
|
||||
)
|
||||
local_db = LocalDB(settings.trading_db_path_resolved)
|
||||
evidence = EvidenceStore(settings.evidence_path)
|
||||
runner = WorkerRunner(
|
||||
settings=settings,
|
||||
gateway_client=client,
|
||||
local_db=local_db,
|
||||
evidence=evidence,
|
||||
)
|
||||
container.worker_client = client
|
||||
container.worker_local_db = local_db
|
||||
container.worker_evidence = evidence
|
||||
container.worker_runner = runner
|
||||
|
||||
return container
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""应用生命周期管理:启动时加载登录态,关闭时释放 HTTP 客户端"""
|
||||
"""应用生命周期管理:登录态会话 + 可选 worker"""
|
||||
container = build_container()
|
||||
app.state.container = container
|
||||
|
||||
@@ -52,10 +81,42 @@ async def lifespan(app: FastAPI):
|
||||
container.settings.trading_port,
|
||||
)
|
||||
logger.info("当前环境:%s", container.settings.app_env)
|
||||
|
||||
await container.auth_session.start()
|
||||
|
||||
worker_task: asyncio.Task | None = None
|
||||
if container.worker_runner is not None:
|
||||
# 顺序:先开本地 DB(worker 写证据前要先能写库),再起后台任务
|
||||
assert container.worker_local_db is not None
|
||||
assert container.worker_client is not None
|
||||
await container.worker_local_db.start()
|
||||
worker_task = asyncio.create_task(
|
||||
container.worker_runner.run(), name="trading-worker"
|
||||
)
|
||||
logger.info(
|
||||
"下单 worker 已启动:worker_id=%s gateway=%s",
|
||||
container.settings.worker_id_effective,
|
||||
container.settings.order_gateway_url,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"未配置 RAKUTEN_ORDER_GATEWAY_URL,下单 worker 不启动(仅登录态接口)"
|
||||
)
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if worker_task is not None:
|
||||
container.worker_runner.stop() # type: ignore[union-attr]
|
||||
worker_task.cancel()
|
||||
try:
|
||||
await worker_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
if container.worker_local_db is not None:
|
||||
await container.worker_local_db.close()
|
||||
if container.worker_client is not None:
|
||||
await container.worker_client.aclose()
|
||||
await container.auth_session.close()
|
||||
shutdown_telemetry()
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
"""本地下单 worker:从网关长轮询领任务,本地执行下单与付款
|
||||
|
||||
部署在本地交易服务进程内(NAT 后无公网入口),通过出站长轮询从服务器侧的
|
||||
order-gateway 取任务。**零入站端口**:所有跨机通信都由本地发起。
|
||||
|
||||
主循环见 runner.WorkerRunner.run:lease → 本地幂等闸门 → 恢复核对 → 执行 → 监控。
|
||||
站点交互(加购 / 下单 / 付款)当前未实测(docs/order-gateway.md §10),留作
|
||||
NotImplementedError 缺口,runner 捕获后上报 needs_human。
|
||||
"""
|
||||
@@ -0,0 +1,145 @@
|
||||
"""网关 HTTP 客户端:出站长轮询领任务、回报状态、续租、恢复领取
|
||||
|
||||
worker 与网关之间是出站单向通信。所有方法都包装成「成功 → data,失败 → 抛
|
||||
AppError」的形式,runner 拿到 AppError 直接打日志或转报 needs_human。
|
||||
|
||||
请求与响应严格使用 shared.api 的 ApiResponse 信封;网关侧错误码(6xxx)原样上抛,
|
||||
不在这里翻译。鉴权头从 settings.bearer_token 取,与抓取/交易服务共用同一份。
|
||||
|
||||
**不 import app.gateway**:worker 只看 HTTP 响应 JSON,本地用 LeaseTask 表达领到的
|
||||
任务,与网关侧的 Pydantic 模型解耦。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.shared.errors import AppError
|
||||
from app.shared.task_state import OrderState, TaskStatus
|
||||
from app.trading.worker.models import LeaseTask
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GatewayClient:
|
||||
"""网关 HTTP 客户端
|
||||
|
||||
一份 AsyncClient 实例贯穿 worker 整个生命周期,连接池由 httpx 管理。
|
||||
"""
|
||||
|
||||
def __init__(self, base_url: str, bearer_token: str, *, timeout: float = 60.0):
|
||||
# 末尾去斜杠,避免 base + "/api/..." 拼出双斜杠
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=self._base_url,
|
||||
headers={"Authorization": f"Bearer {bearer_token}"},
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await self._client.aclose()
|
||||
|
||||
# ---- 基础封装 ----
|
||||
|
||||
async def _request(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
|
||||
"""发起请求并解信封。失败(success=False)抛 AppError"""
|
||||
response = await self._client.request(method, path, **kwargs)
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError as exc:
|
||||
raise AppError(
|
||||
message=f"网关响应不是合法 JSON:HTTP {response.status_code}",
|
||||
code="GATEWAY_BAD_BODY",
|
||||
err_code=3001,
|
||||
retryable=True,
|
||||
) from exc
|
||||
|
||||
if not body.get("success"):
|
||||
raise AppError(
|
||||
message=body.get("msg", "网关返回失败"),
|
||||
code="GATEWAY_ERROR",
|
||||
err_code=int(body.get("code", 1500)),
|
||||
retryable=False,
|
||||
status_code=response.status_code,
|
||||
)
|
||||
return body
|
||||
|
||||
# ---- 接口 ----
|
||||
|
||||
async def lease(
|
||||
self, worker_id: str, *, wait: int = 30, site: str | None = None
|
||||
) -> LeaseTask | None:
|
||||
"""长轮询领取。无可领任务时返回 None"""
|
||||
params: dict[str, Any] = {"worker_id": worker_id, "wait": wait}
|
||||
if site:
|
||||
params["site"] = site
|
||||
body = await self._request("GET", "/api/orders/lease", params=params)
|
||||
data = body.get("data")
|
||||
if not data:
|
||||
return None
|
||||
return LeaseTask(
|
||||
task_id=data["task_id"],
|
||||
site=data["site"],
|
||||
intent=data.get("intent") or {},
|
||||
lease_expires_at=data.get("lease_expires_at", ""),
|
||||
lease_count=data.get("lease_count", 1),
|
||||
known_state=data.get("known_state"),
|
||||
)
|
||||
|
||||
async def renew(self, task_id: str, worker_id: str) -> dict[str, Any]:
|
||||
"""续租,返回网关响应里的 data 字段"""
|
||||
body = await self._request(
|
||||
"POST",
|
||||
f"/api/orders/{task_id}/renew",
|
||||
json={"worker_id": worker_id},
|
||||
)
|
||||
return body["data"]
|
||||
|
||||
async def report(
|
||||
self,
|
||||
task_id: str,
|
||||
worker_id: str,
|
||||
*,
|
||||
state: OrderState,
|
||||
payable_yen: int | None = None,
|
||||
pay_deadline: str | None = None,
|
||||
site_order_id: str | None = None,
|
||||
evidence_ref: str | None = None,
|
||||
detail: str = "",
|
||||
terminal: bool = False,
|
||||
terminal_status: TaskStatus | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""回报状态,返回网关响应里的 data 字段"""
|
||||
payload: dict[str, Any] = {
|
||||
"worker_id": worker_id,
|
||||
"state": state.value,
|
||||
"payable_yen": payable_yen,
|
||||
"pay_deadline": pay_deadline,
|
||||
"site_order_id": site_order_id,
|
||||
"evidence_ref": evidence_ref,
|
||||
"detail": detail,
|
||||
"terminal": terminal,
|
||||
}
|
||||
if terminal_status is not None:
|
||||
payload["terminal_status"] = terminal_status.value
|
||||
body = await self._request("POST", f"/api/orders/{task_id}/report", json=payload)
|
||||
return body["data"]
|
||||
|
||||
async def reclaim(self, task_id: str, worker_id: str) -> LeaseTask:
|
||||
"""恢复领取。返回的 LeaseTask 必然 lease_count > 1"""
|
||||
body = await self._request(
|
||||
"POST",
|
||||
f"/api/orders/{task_id}/reclaim",
|
||||
json={"worker_id": worker_id},
|
||||
)
|
||||
data = body["data"]
|
||||
return LeaseTask(
|
||||
task_id=data["task_id"],
|
||||
site=data["site"],
|
||||
intent=data.get("intent") or {},
|
||||
lease_expires_at=data.get("lease_expires_at", ""),
|
||||
lease_count=data.get("lease_count", 1),
|
||||
known_state=data.get("known_state"),
|
||||
)
|
||||
@@ -0,0 +1,56 @@
|
||||
"""证据落盘:HTML 快照、截图与 meta.json
|
||||
|
||||
规格 §6.1:每个任务一个子目录,按步骤序号命名文件。report 里的 evidence_ref
|
||||
填 `{task_id}/{序号}-{步骤名}`,不含扩展名。
|
||||
|
||||
证据**只留本地**(约束 4),网关只存 evidence_ref 这个相对路径字符串。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EvidenceStore:
|
||||
"""把每一步的页面快照、截图与 meta 落到 {evidence_dir}/{task_id}/ 下"""
|
||||
|
||||
def __init__(self, evidence_dir: Path):
|
||||
self._root = evidence_dir
|
||||
|
||||
def step_dir(self, task_id: str) -> Path:
|
||||
path = self._root / task_id
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
def write_step(
|
||||
self,
|
||||
task_id: str,
|
||||
step_no: int,
|
||||
step_name: str,
|
||||
*,
|
||||
html: str | None = None,
|
||||
png: bytes | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
"""写入一步的证据文件
|
||||
|
||||
返回相对 evidence_dir 的引用路径(不含扩展名),用于 report 的 evidence_ref
|
||||
字段。文件命名:`{序号:02d}-{步骤名}.{html|png|meta.json}`。
|
||||
"""
|
||||
step_dir = self.step_dir(task_id)
|
||||
stem = f"{step_no:02d}-{step_name}"
|
||||
rel_stem = f"{task_id}/{stem}"
|
||||
|
||||
if html is not None:
|
||||
(step_dir / f"{stem}.html").write_text(html, encoding="utf-8")
|
||||
if png is not None:
|
||||
(step_dir / f"{stem}.png").write_bytes(png)
|
||||
if meta is not None:
|
||||
(step_dir / f"{stem}.meta.json").write_text(
|
||||
json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
return rel_stem
|
||||
@@ -0,0 +1,181 @@
|
||||
"""本地订单 SQLite:执行事实的权威记录
|
||||
|
||||
与网关的 tasks/task_reports 表对偶,但本地这张表才是「这单到底下没下、付没付」
|
||||
的权威。网关失联或数据丢失时,本地这张表用于补报与对账。
|
||||
|
||||
三张表:
|
||||
- orders:每笔任务一行,记录开始/结束时间与最终状态
|
||||
- order_events:append-only 的状态迁移事件(与网关 task_reports 对账)
|
||||
- evidence_index:每步证据文件的相对路径索引,便于事后翻查
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import aiosqlite
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
task_id TEXT PRIMARY KEY,
|
||||
site TEXT NOT NULL,
|
||||
intent_json TEXT NOT NULL,
|
||||
final_state TEXT,
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS order_events (
|
||||
task_id TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
detail TEXT,
|
||||
evidence_ref TEXT,
|
||||
recorded_at TEXT NOT NULL,
|
||||
PRIMARY KEY (task_id, state)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS evidence_index (
|
||||
task_id TEXT NOT NULL,
|
||||
step_no INTEGER NOT NULL,
|
||||
step_name TEXT NOT NULL,
|
||||
rel_path TEXT NOT NULL,
|
||||
saved_at TEXT NOT NULL,
|
||||
PRIMARY KEY (task_id, step_no)
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
def _utcnow_iso() -> str:
|
||||
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class OrderRow:
|
||||
task_id: str
|
||||
site: str
|
||||
intent_json: str
|
||||
final_state: str | None
|
||||
started_at: str
|
||||
finished_at: str | None
|
||||
|
||||
@property
|
||||
def intent(self) -> dict[str, Any]:
|
||||
return json.loads(self.intent_json)
|
||||
|
||||
|
||||
class LocalDB:
|
||||
"""本地订单 SQLite 访问对象"""
|
||||
|
||||
def __init__(self, db_path: Path):
|
||||
self._db_path = db_path
|
||||
self._conn: aiosqlite.Connection | None = None
|
||||
|
||||
async def start(self) -> None:
|
||||
self._db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._conn = await aiosqlite.connect(str(self._db_path))
|
||||
self._conn.row_factory = aiosqlite.Row
|
||||
await self._conn.executescript(SCHEMA)
|
||||
await self._conn.commit()
|
||||
logger.info("本地订单 DB 已就绪:%s", self._db_path)
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._conn is not None:
|
||||
await self._conn.close()
|
||||
self._conn = None
|
||||
|
||||
@property
|
||||
def conn(self) -> aiosqlite.Connection:
|
||||
if self._conn is None:
|
||||
raise RuntimeError("LocalDB 未启动:先调用 start()")
|
||||
return self._conn
|
||||
|
||||
# ---- orders ----
|
||||
|
||||
async def ensure_started(self, task_id: str, site: str, intent: dict[str, Any]) -> None:
|
||||
"""记录任务开始执行。已存在则忽略(同任务被重新领回时)"""
|
||||
await self.conn.execute(
|
||||
"INSERT OR IGNORE INTO orders (task_id, site, intent_json, started_at) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(task_id, site, json.dumps(intent, ensure_ascii=False), _utcnow_iso()),
|
||||
)
|
||||
await self.conn.commit()
|
||||
|
||||
async def get_order(self, task_id: str) -> OrderRow | None:
|
||||
async with self.conn.execute(
|
||||
"SELECT * FROM orders WHERE task_id = ?",
|
||||
(task_id,),
|
||||
) as cur:
|
||||
row = await cur.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return OrderRow(
|
||||
task_id=row["task_id"],
|
||||
site=row["site"],
|
||||
intent_json=row["intent_json"],
|
||||
final_state=row["final_state"],
|
||||
started_at=row["started_at"],
|
||||
finished_at=row["finished_at"],
|
||||
)
|
||||
|
||||
async def has_finished(self, task_id: str) -> bool:
|
||||
"""是否已有终态记录。用于主循环里的本地幂等闸门"""
|
||||
async with self.conn.execute(
|
||||
"SELECT 1 FROM orders WHERE task_id = ? AND finished_at IS NOT NULL",
|
||||
(task_id,),
|
||||
) as cur:
|
||||
return await cur.fetchone() is not None
|
||||
|
||||
async def mark_finished(self, task_id: str, final_state: str) -> None:
|
||||
await self.conn.execute(
|
||||
"UPDATE orders SET final_state = ?, finished_at = ? WHERE task_id = ?",
|
||||
(final_state, _utcnow_iso(), task_id),
|
||||
)
|
||||
await self.conn.commit()
|
||||
|
||||
async def final_state(self, task_id: str) -> str | None:
|
||||
async with self.conn.execute(
|
||||
"SELECT final_state FROM orders WHERE task_id = ?",
|
||||
(task_id,),
|
||||
) as cur:
|
||||
row = await cur.fetchone()
|
||||
return row["final_state"] if row else None
|
||||
|
||||
# ---- events ----
|
||||
|
||||
async def record_event(
|
||||
self,
|
||||
task_id: str,
|
||||
state: str,
|
||||
*,
|
||||
detail: str = "",
|
||||
evidence_ref: str | None = None,
|
||||
) -> None:
|
||||
"""记录一次状态迁移事件(与网关 task_reports 对账)
|
||||
|
||||
同一 (task_id, state) 重复记录视为同一次——INSERT OR IGNORE,与网关侧的
|
||||
INSERT OR REPLACE 不同:本地这张表是事件日志,不覆盖既有记录。
|
||||
"""
|
||||
await self.conn.execute(
|
||||
"INSERT OR IGNORE INTO order_events "
|
||||
"(task_id, state, detail, evidence_ref, recorded_at) VALUES (?, ?, ?, ?, ?)",
|
||||
(task_id, state, detail, evidence_ref, _utcnow_iso()),
|
||||
)
|
||||
await self.conn.commit()
|
||||
|
||||
# ---- evidence index ----
|
||||
|
||||
async def index_evidence(
|
||||
self, task_id: str, step_no: int, step_name: str, rel_path: str
|
||||
) -> None:
|
||||
await self.conn.execute(
|
||||
"INSERT OR REPLACE INTO evidence_index "
|
||||
"(task_id, step_no, step_name, rel_path, saved_at) VALUES (?, ?, ?, ?, ?)",
|
||||
(task_id, step_no, step_name, rel_path, _utcnow_iso()),
|
||||
)
|
||||
await self.conn.commit()
|
||||
@@ -0,0 +1,28 @@
|
||||
"""worker 侧数据模型:不引用网关的 Pydantic 模型
|
||||
|
||||
worker 通过 HTTP 与网关交互,看到的只有 JSON。这里定义 worker 自己用来表达
|
||||
「领到的任务」「核对结论」的数据类——结构上对应网关响应,但**不 import**
|
||||
app.gateway 的任何东西,保证架构边界(worker → shared,禁止 worker → gateway)。
|
||||
|
||||
OrderState / TaskStatus 枚举在 app.shared.task_state,两侧共用同一份取值表。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class LeaseTask:
|
||||
"""worker 从网关领到的任务
|
||||
|
||||
对应网关 GET /api/orders/lease 的 LeaseData,但用 dataclass 表达——worker
|
||||
内部不依赖网关侧的 Pydantic 模型。
|
||||
"""
|
||||
|
||||
task_id: str
|
||||
site: str
|
||||
intent: dict[str, Any] = field(default_factory=dict)
|
||||
lease_expires_at: str = ""
|
||||
lease_count: int = 1
|
||||
known_state: str | None = None
|
||||
@@ -0,0 +1,420 @@
|
||||
"""worker 主循环与每任务执行流
|
||||
|
||||
主循环(规格 §6):
|
||||
|
||||
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(...); continue
|
||||
|
||||
if task.lease_count > 1: # 恢复领取
|
||||
verdict = await verify_on_site(task)
|
||||
if verdict.already_ordered: await report(...); continue
|
||||
if verdict.unknown: await report(needs_human); continue
|
||||
|
||||
async with renew_lease_every(60):
|
||||
await execute(task)
|
||||
|
||||
每个动作的顺序固定为:动作 → 落证据 → 写本地 SQLite → 回报 gateway。
|
||||
顺序不能颠倒——先落证据再回报,保证服务器上看到的状态一定有本地证据可查。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from app.shared.errors import AppError, OrderGuardError
|
||||
from app.shared.task_state import OrderState, TaskStatus
|
||||
from app.trading.worker import site_interact, verify
|
||||
from app.trading.worker.client import GatewayClient
|
||||
from app.trading.worker.evidence import EvidenceStore
|
||||
from app.trading.worker.local_db import LocalDB
|
||||
from app.trading.worker.models import LeaseTask
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.shared.config import Settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _coerce_state(value: str | None) -> OrderState:
|
||||
"""把网关返回的 state 字符串安全地包成 OrderState;None 或非法值回落到 CREATED"""
|
||||
if not value:
|
||||
return OrderState.CREATED
|
||||
try:
|
||||
return OrderState(value)
|
||||
except ValueError:
|
||||
logger.warning("未知的 order state 字符串,回落到 CREATED:%s", value)
|
||||
return OrderState.CREATED
|
||||
|
||||
|
||||
class WorkerRunner:
|
||||
"""worker 主循环与执行流
|
||||
|
||||
持有 gateway 客户端、本地 DB、证据存储。`run()` 是常驻 asyncio 任务,
|
||||
`handle()` 处理单个任务,`execute()` 是站点交互的实际调度(当前多数步骤未实现)。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
settings: "Settings",
|
||||
gateway_client: GatewayClient,
|
||||
local_db: LocalDB,
|
||||
evidence: EvidenceStore,
|
||||
):
|
||||
self._settings = settings
|
||||
self._gateway = gateway_client
|
||||
self._db = local_db
|
||||
self._evidence = evidence
|
||||
self._running = False
|
||||
|
||||
def stop(self) -> None:
|
||||
self._running = False
|
||||
|
||||
@property
|
||||
def worker_id(self) -> str:
|
||||
return self._settings.worker_id_effective
|
||||
|
||||
# ---- 主循环 ----
|
||||
|
||||
async def run(self) -> None:
|
||||
"""常驻循环:长轮询领任务,按 §6 调度"""
|
||||
self._running = True
|
||||
logger.info("worker 启动:worker_id=%s", self.worker_id)
|
||||
while self._running:
|
||||
try:
|
||||
task = await self._gateway.lease(self.worker_id, wait=30)
|
||||
except AppError as exc:
|
||||
logger.warning("lease 失败:%s (err=%s)", exc.message, exc.err_code)
|
||||
await asyncio.sleep(5)
|
||||
continue
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("lease 异常")
|
||||
await asyncio.sleep(5)
|
||||
continue
|
||||
|
||||
if task is None:
|
||||
continue
|
||||
|
||||
try:
|
||||
await self.handle(task)
|
||||
except Exception: # noqa: BLE001
|
||||
# 任何未预期的异常都不应让 worker 退出
|
||||
logger.exception("handle 任务异常:task_id=%s", task.task_id)
|
||||
|
||||
# ---- 单任务调度 ----
|
||||
|
||||
async def handle(self, task: LeaseTask) -> None:
|
||||
"""单任务调度入口:本地幂等闸门 → 恢复核对 → 执行"""
|
||||
# 本地幂等闸门:之前已完成过的任务不再执行
|
||||
if await self._db.has_finished(task.task_id):
|
||||
final_state = await self._db.final_state(task.task_id)
|
||||
logger.info(
|
||||
"本地已完成,补报终态:task_id=%s state=%s", task.task_id, final_state
|
||||
)
|
||||
await self._report_safe(
|
||||
task,
|
||||
state=_coerce_state(final_state),
|
||||
detail="本地已完成,补报避免重复执行",
|
||||
terminal=True,
|
||||
)
|
||||
return
|
||||
|
||||
# 恢复领取:lease_count > 1 表示 stale → reclaim,必须先核对站点订单
|
||||
if task.lease_count > 1:
|
||||
await self._handle_recovery(task)
|
||||
return
|
||||
|
||||
# 常规执行
|
||||
await self._execute_with_renewal(task)
|
||||
|
||||
async def _handle_recovery(self, task: LeaseTask) -> None:
|
||||
"""lease_count > 1 的恢复路径:先核对,不直接执行"""
|
||||
logger.warning(
|
||||
"恢复领取(lease_count=%s),先核对站点订单:task_id=%s",
|
||||
task.lease_count,
|
||||
task.task_id,
|
||||
)
|
||||
verdict = await verify.verify_on_site(task)
|
||||
if verdict.verdict == verify.VerifyVerdict.ALREADY_ORDERED:
|
||||
await self._report_safe(
|
||||
task,
|
||||
state=OrderState.ORDERED,
|
||||
site_order_id=verdict.site_order_id,
|
||||
detail=f"核对结论:已下单({verdict.detail})",
|
||||
terminal=True,
|
||||
terminal_status=TaskStatus.SUCCEEDED,
|
||||
)
|
||||
await self._db.ensure_started(task.task_id, task.site, task.intent)
|
||||
await self._db.mark_finished(task.task_id, OrderState.ORDERED.value)
|
||||
return
|
||||
|
||||
# NOT_ORDERED 也走 needs_human:当前 verify 桩不会返回这个值,但留接口给
|
||||
# 未来真正能可靠核对时——那时再决定 NOT_ORDERED 是否直接重新执行
|
||||
await self._report_safe(
|
||||
task,
|
||||
state=_coerce_state(task.known_state),
|
||||
detail=f"恢复核对无法定论:{verdict.detail}",
|
||||
terminal=True,
|
||||
terminal_status=TaskStatus.NEEDS_HUMAN,
|
||||
)
|
||||
|
||||
# ---- 常规执行 ----
|
||||
|
||||
async def _execute_with_renewal(self, task: LeaseTask) -> None:
|
||||
"""在租约自动续期的上下文里执行任务"""
|
||||
async with self._renew_lease_every(task, interval=60):
|
||||
try:
|
||||
await self.execute(task)
|
||||
except NotImplementedError as exc:
|
||||
# 站点交互未实现(规格 §10):上报 needs_human,不视为 worker 失败
|
||||
logger.warning(
|
||||
"站点交互未实现,转 needs_human:task_id=%s what=%s",
|
||||
task.task_id,
|
||||
exc,
|
||||
)
|
||||
await self._report_safe(
|
||||
task,
|
||||
state=_coerce_state(task.known_state),
|
||||
detail=f"站点交互未实现:{exc}",
|
||||
terminal=True,
|
||||
terminal_status=TaskStatus.NEEDS_HUMAN,
|
||||
)
|
||||
except OrderGuardError as exc:
|
||||
# 金额守卫等闸门拦截:上报 needs_human,站点侧无提交动作
|
||||
logger.warning(
|
||||
"下单闸门拦截,转 needs_human:task_id=%s msg=%s",
|
||||
task.task_id,
|
||||
exc.message,
|
||||
)
|
||||
await self._report_safe(
|
||||
task,
|
||||
state=_coerce_state(task.known_state),
|
||||
detail=exc.message,
|
||||
terminal=True,
|
||||
terminal_status=TaskStatus.NEEDS_HUMAN,
|
||||
)
|
||||
except AppError as exc:
|
||||
logger.warning(
|
||||
"执行失败:task_id=%s code=%s msg=%s",
|
||||
task.task_id,
|
||||
exc.err_code,
|
||||
exc.message,
|
||||
)
|
||||
await self._report_safe(
|
||||
task,
|
||||
state=_coerce_state(task.known_state),
|
||||
detail=exc.message,
|
||||
terminal=True,
|
||||
terminal_status=TaskStatus.FAILED,
|
||||
)
|
||||
|
||||
async def execute(self, task: LeaseTask) -> None:
|
||||
"""站点交互的实际调度:加购 → 校验 → 确认页 → 金额守卫 → 提交 → 付款
|
||||
|
||||
每一步的顺序:动作 → 落证据 → 写本地 SQLite → 回报 gateway。
|
||||
站点交互当前未实现(site_interact 抛 NotImplementedError),第一步就会
|
||||
转到 _execute_with_renewal 的 except 分支上报 needs_human。
|
||||
"""
|
||||
await self._db.ensure_started(task.task_id, task.site, task.intent)
|
||||
|
||||
# ラクマ 加购契约未实现,首版只对乐天。其他站点直接转人工
|
||||
if task.site != "rakuten":
|
||||
raise NotImplementedError(f"site={task.site} 暂未实现下单(首版仅 rakuten)")
|
||||
|
||||
# 步骤 1:加购
|
||||
await self._run_step(
|
||||
task, step_no=1, step_name="cart-add",
|
||||
action=lambda: site_interact.add_to_cart(task),
|
||||
state=OrderState.IN_CART,
|
||||
detail="已加入购物车",
|
||||
)
|
||||
|
||||
# 步骤 2:校验购物车
|
||||
await self._run_step(
|
||||
task, step_no=2, step_name="cart-check",
|
||||
action=lambda: site_interact.verify_cart(task),
|
||||
state=OrderState.IN_CART,
|
||||
detail="购物车已校验",
|
||||
)
|
||||
|
||||
# 步骤 3:进入下单确认页 + 金额守卫
|
||||
checkout_html = await site_interact.enter_checkout(task)
|
||||
summary = await site_interact.parse_checkout(checkout_html)
|
||||
self._enforce_amount_guard(task, summary.payable_yen)
|
||||
await self._run_step(
|
||||
task, step_no=3, step_name="order-confirm",
|
||||
action=self._noop(),
|
||||
state=OrderState.CREATED,
|
||||
detail=f"下单确认页已解析:应付 {summary.payable_yen} 円",
|
||||
evidence_meta={
|
||||
"payable_yen": summary.payable_yen,
|
||||
"site_order_id": summary.site_order_id,
|
||||
"pay_deadline": summary.pay_deadline,
|
||||
},
|
||||
)
|
||||
|
||||
# 步骤 4:提交下单
|
||||
site_order_id = await site_interact.submit_order(task)
|
||||
await self._run_step(
|
||||
task, step_no=4, step_name="order-submit",
|
||||
action=self._noop(),
|
||||
state=OrderState.ORDERED,
|
||||
site_order_id=site_order_id,
|
||||
detail=f"已提交下单,站点订单号 {site_order_id}",
|
||||
)
|
||||
|
||||
# 步骤 5:付款
|
||||
await self._run_step(
|
||||
task, step_no=5, step_name="payment",
|
||||
action=lambda: site_interact.pay(task, site_order_id),
|
||||
state=OrderState.AWAITING_PAYMENT,
|
||||
site_order_id=site_order_id,
|
||||
payable_yen=summary.payable_yen,
|
||||
pay_deadline=summary.pay_deadline,
|
||||
detail="已进入付款流程",
|
||||
)
|
||||
await self._report_safe(
|
||||
task,
|
||||
state=OrderState.PAID,
|
||||
site_order_id=site_order_id,
|
||||
payable_yen=summary.payable_yen,
|
||||
pay_deadline=summary.pay_deadline,
|
||||
detail="付款完成",
|
||||
terminal=True,
|
||||
terminal_status=TaskStatus.SUCCEEDED,
|
||||
)
|
||||
await self._db.mark_finished(task.task_id, OrderState.PAID.value)
|
||||
|
||||
# 步骤 6:付款后监控(非阻塞,常驻轮询;当前未实现)
|
||||
try:
|
||||
await site_interact.monitor(task, site_order_id)
|
||||
except NotImplementedError:
|
||||
logger.info("付款后监控未实现,跳过:task_id=%s", task.task_id)
|
||||
|
||||
def _enforce_amount_guard(self, task: LeaseTask, payable_yen: int) -> None:
|
||||
"""金额守卫:实际应付超过 intent.max_total_yen 或 RAKUTEN_ORDER_MAX_TOTAL_YEN 时拦截"""
|
||||
intent = task.intent or {}
|
||||
limit = intent.get("max_total_yen")
|
||||
if limit is None or limit <= 0:
|
||||
limit = self._settings.order_max_total_yen
|
||||
if limit > 0 and payable_yen > limit:
|
||||
raise OrderGuardError(
|
||||
f"实际应付 {payable_yen} 円超过上限 {limit} 円"
|
||||
f"(task_id={task.task_id})"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _noop() -> "callable":
|
||||
async def _f() -> None:
|
||||
return None
|
||||
|
||||
return _f
|
||||
|
||||
async def _run_step(
|
||||
self,
|
||||
task: LeaseTask,
|
||||
*,
|
||||
step_no: int,
|
||||
step_name: str,
|
||||
action: "callable",
|
||||
state: OrderState,
|
||||
detail: str,
|
||||
site_order_id: str | None = None,
|
||||
payable_yen: int | None = None,
|
||||
pay_deadline: str | None = None,
|
||||
evidence_meta: dict | None = None,
|
||||
) -> None:
|
||||
"""单步执行:动作 → 落证据 → 写本地 → 回报 gateway"""
|
||||
await action()
|
||||
meta = {
|
||||
"step": step_name,
|
||||
"state": state.value,
|
||||
**(evidence_meta or {}),
|
||||
}
|
||||
evidence_ref = self._evidence.write_step(
|
||||
task.task_id, step_no, step_name, meta=meta
|
||||
)
|
||||
await self._db.index_evidence(task.task_id, step_no, step_name, evidence_ref)
|
||||
await self._db.record_event(
|
||||
task.task_id, state.value, detail=detail, evidence_ref=evidence_ref
|
||||
)
|
||||
await self._gateway.report(
|
||||
task.task_id,
|
||||
self.worker_id,
|
||||
state=state,
|
||||
payable_yen=payable_yen,
|
||||
pay_deadline=pay_deadline,
|
||||
site_order_id=site_order_id,
|
||||
evidence_ref=evidence_ref,
|
||||
detail=detail,
|
||||
)
|
||||
|
||||
async def _report_safe(
|
||||
self,
|
||||
task: LeaseTask,
|
||||
*,
|
||||
state: OrderState,
|
||||
detail: str,
|
||||
terminal: bool = False,
|
||||
terminal_status: TaskStatus | None = None,
|
||||
site_order_id: str | None = None,
|
||||
payable_yen: int | None = None,
|
||||
pay_deadline: str | None = None,
|
||||
) -> None:
|
||||
"""回报 gateway,失败只记日志不抛——主循环不能因为回报失败退出"""
|
||||
try:
|
||||
await self._gateway.report(
|
||||
task.task_id,
|
||||
self.worker_id,
|
||||
state=state,
|
||||
detail=detail,
|
||||
terminal=terminal,
|
||||
terminal_status=terminal_status,
|
||||
site_order_id=site_order_id,
|
||||
payable_yen=payable_yen,
|
||||
pay_deadline=pay_deadline,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("回报 gateway 失败:task_id=%s state=%s", task.task_id, state)
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def _renew_lease_every(self, task: LeaseTask, *, interval: int):
|
||||
"""后台任务:每隔 interval 秒续租一次。退出时停掉"""
|
||||
stop_event = asyncio.Event()
|
||||
|
||||
async def _loop() -> None:
|
||||
while not stop_event.is_set():
|
||||
try:
|
||||
await asyncio.wait_for(stop_event.wait(), timeout=interval)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
if stop_event.is_set():
|
||||
return
|
||||
try:
|
||||
await self._gateway.renew(task.task_id, self.worker_id)
|
||||
except AppError as exc:
|
||||
logger.warning(
|
||||
"续租失败:task_id=%s code=%s msg=%s",
|
||||
task.task_id,
|
||||
exc.err_code,
|
||||
exc.message,
|
||||
)
|
||||
return
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("续租异常:task_id=%s", task.task_id)
|
||||
return
|
||||
|
||||
renewal = asyncio.create_task(_loop(), name=f"renew-{task.task_id}")
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
stop_event.set()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await renewal
|
||||
@@ -0,0 +1,73 @@
|
||||
"""站点交互:加购 / 校验购物车 / 进入下单确认页 / 金额守卫 / 提交 / 付款 / 监控
|
||||
|
||||
**全部未实现**。规格 docs/order-gateway.md §10 明确说明:加购 → 下单 → 付款的
|
||||
实际站点交互没有实测过,未实测前调用直接抛「未实现」,不要写猜测的提交逻辑。
|
||||
|
||||
签名刻意保留:runner 把这一层当接口缝用,等真实账号 + 真实站点把每个动作
|
||||
实现填进来后,主循环逻辑不需要改动。
|
||||
|
||||
实现方需要补齐的实测点(规格 §10):
|
||||
1. 自动付款是否触发 3D Secure / 短信验证。触发则这条路走不通,付款环节改为
|
||||
「下单到 awaiting_payment + 上报 needs_human 交人工」。
|
||||
2. 下单确认页的实际应付金额、付款方式、付款期限、站点订单号各自在哪个字段。
|
||||
3. 订单列表页能否按商品 + 时间窗口可靠地反查「这单下没下」。
|
||||
4. ラクマ 侧尚无加购契约(README 已注明未实现)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.trading.worker.models import LeaseTask
|
||||
|
||||
|
||||
_NOT_IMPLEMENTED_MSG = (
|
||||
"站点交互未实现:见 docs/order-gateway.md §10。"
|
||||
"未实测前不写猜测的提交逻辑——需要真实账号 + 真实站点把这一步实现填进来。"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CheckoutSummary:
|
||||
"""下单确认页解析结果(待实测确认字段位置)
|
||||
|
||||
payable_yen 用于金额守卫;其他字段在实测确认后补全。
|
||||
"""
|
||||
|
||||
payable_yen: int
|
||||
site_order_id: str | None = None
|
||||
pay_deadline: str | None = None
|
||||
|
||||
|
||||
async def add_to_cart(task: LeaseTask) -> None:
|
||||
"""加购。需要 auth_session 的登录 cookie + intent 里的 purchase 标识"""
|
||||
raise NotImplementedError(_NOT_IMPLEMENTED_MSG)
|
||||
|
||||
|
||||
async def verify_cart(task: LeaseTask) -> None:
|
||||
"""校验购物车:加购后购物车里有没有这件商品"""
|
||||
raise NotImplementedError(_NOT_IMPLEMENTED_MSG)
|
||||
|
||||
|
||||
async def enter_checkout(task: LeaseTask) -> str:
|
||||
"""进入下单确认页,返回页面 HTML(供解析金额守卫所需字段)"""
|
||||
raise NotImplementedError(_NOT_IMPLEMENTED_MSG)
|
||||
|
||||
|
||||
async def parse_checkout(html: str) -> CheckoutSummary:
|
||||
"""从下单确认页解析实际应付金额、付款期限、站点订单号"""
|
||||
raise NotImplementedError(_NOT_IMPLEMENTED_MSG)
|
||||
|
||||
|
||||
async def submit_order(task: LeaseTask) -> str:
|
||||
"""提交下单。返回站点订单号(site_order_id)"""
|
||||
raise NotImplementedError(_NOT_IMPLEMENTED_MSG)
|
||||
|
||||
|
||||
async def pay(task: LeaseTask, site_order_id: str) -> None:
|
||||
"""付款。检测到 3DS / 短信验证等人工环节时抛 OrderGuardError"""
|
||||
raise NotImplementedError(_NOT_IMPLEMENTED_MSG)
|
||||
|
||||
|
||||
async def monitor(task: LeaseTask, site_order_id: str) -> None:
|
||||
"""付款后监控:常驻轮询订单状态与付款期限,状态变化时继续 report"""
|
||||
raise NotImplementedError(_NOT_IMPLEMENTED_MSG)
|
||||
@@ -0,0 +1,46 @@
|
||||
"""恢复核对:lease_count > 1 时,worker 必须先查站点订单列表确认是否已下单
|
||||
|
||||
规格 §5:租约过期 → stale → reclaim 后 worker 领回的任务 lease_count 必然 > 1。
|
||||
此时 worker 不能盲目重新提交(可能上次已经下单成功,只是回报断网),必须先查
|
||||
站点订单列表比对。
|
||||
|
||||
订单列表反查的实现需要实测(规格 §10 第 3 条:订单列表页能否按商品 + 时间窗口
|
||||
可靠地反查出「这单下没下」)。当前为桩:恒返回 `unknown`,让 runner 走
|
||||
needs_human 分支。**绝不**默认按「没下单」处理——那是猜,可能变成重复下单。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
|
||||
from app.trading.worker.models import LeaseTask
|
||||
|
||||
|
||||
class VerifyVerdict(StrEnum):
|
||||
"""核对结论"""
|
||||
|
||||
ALREADY_ORDERED = "already_ordered" # 确认已下单,补报状态即可
|
||||
NOT_ORDERED = "not_ordered" # 确认未下单,可重新执行
|
||||
UNKNOWN = "unknown" # 核对不出结论,交人工
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class VerifyResult:
|
||||
verdict: VerifyVerdict
|
||||
site_order_id: str | None = None
|
||||
detail: str = ""
|
||||
|
||||
|
||||
async def verify_on_site(task: LeaseTask) -> VerifyResult:
|
||||
"""核对一笔任务是否已在站点上下过单
|
||||
|
||||
桩实现:永远返回 UNKNOWN。**绝不返回 NOT_ORDERED**——除非真实订单列表反查
|
||||
能可靠证明这一点,否则视为「无法判断」交人工,宁可卡住等人看一眼。
|
||||
|
||||
实现方需要补的实测:用 intent 里的商品 + 时间窗口(created_at 到 stale 之间)
|
||||
比对订单列表,能拿到 site_order_id 最好。
|
||||
"""
|
||||
return VerifyResult(
|
||||
verdict=VerifyVerdict.UNKNOWN,
|
||||
detail="订单列表反查未实现:见 docs/order-gateway.md §10 第 3 条",
|
||||
)
|
||||
@@ -4,6 +4,7 @@ version = "0.1.0"
|
||||
description = "API service for Rakuten Ichiba scraping tasks"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"aiosqlite>=0.20.0,<1.0.0",
|
||||
"fastapi>=0.115.0,<1.0.0",
|
||||
"httpx[http2]>=0.27.0,<1.0.0",
|
||||
"loguru>=0.7.0,<1.0.0",
|
||||
|
||||
+35
-13
@@ -1,12 +1,11 @@
|
||||
"""架构测试:守住抓取侧与交易侧的依赖方向
|
||||
"""架构测试:守住三个部署单元的依赖方向
|
||||
|
||||
拆成两个进程之后,最容易悄悄退化的不是功能而是边界——某天为了省事在交易侧
|
||||
`from app.scraping.parsers...` 一句,两个服务就重新长回一起:抓取的解析改动会
|
||||
牵动下单链路,交易服务也被迫加载整套抓取依赖(包括 Playwright)。
|
||||
三个进程之后,最容易悄悄退化的不是功能而是边界——某天为了省事在交易侧
|
||||
`from app.scraping.parsers...` 一句,两个服务就重新长回一起;或在 worker 里
|
||||
`from app.gateway.models...` 一句,本地的零依赖网关假设就破了。
|
||||
|
||||
允许的方向只有两条:scraping → shared、trading → shared。
|
||||
交易侧要用抓取的能力,走抓取服务的 HTTP 接口(`purchase` 块本来就是它的对外契约),
|
||||
不直接 import。
|
||||
允许的方向只有三条:scraping → shared、trading → shared、gateway → shared。
|
||||
任何两侧之间互不 import;需要对方能力时走 HTTP 接口。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -37,11 +36,17 @@ def _python_files(package: str) -> list[Path]:
|
||||
@pytest.mark.parametrize(
|
||||
("package", "forbidden"),
|
||||
[
|
||||
# 三个部署单元互不 import
|
||||
("scraping", "app.trading"),
|
||||
("scraping", "app.gateway"),
|
||||
("trading", "app.scraping"),
|
||||
# shared 是两侧的共同底座,反向依赖任何一侧都会形成环
|
||||
("trading", "app.gateway"),
|
||||
("gateway", "app.scraping"),
|
||||
("gateway", "app.trading"),
|
||||
# shared 是三方的共同底座,反向依赖任何一侧都会形成环
|
||||
("shared", "app.scraping"),
|
||||
("shared", "app.trading"),
|
||||
("shared", "app.gateway"),
|
||||
],
|
||||
)
|
||||
def test_package_does_not_import(package: str, forbidden: str):
|
||||
@@ -54,8 +59,9 @@ def test_package_does_not_import(package: str, forbidden: str):
|
||||
assert not offenders, f"{package} 不应依赖 {forbidden}:{offenders}"
|
||||
|
||||
|
||||
def test_both_entrypoints_build():
|
||||
"""两个入口都要能独立创建应用——这是「两个部署单元」的最低验收"""
|
||||
def test_all_three_entrypoints_build():
|
||||
"""三个入口都要能独立创建应用——这是「三个部署单元」的最低验收"""
|
||||
from app.gateway.main import create_app as create_gateway_app
|
||||
from app.scraping.main import create_app as create_scraping_app
|
||||
from app.trading.main import create_app as create_trading_app
|
||||
|
||||
@@ -63,11 +69,27 @@ def test_both_entrypoints_build():
|
||||
# 包成 _IncludedRouter 而非摊平,OpenAPI 反映的才是真正对外暴露的契约
|
||||
scraping_paths = set(create_scraping_app().openapi()["paths"])
|
||||
trading_paths = set(create_trading_app().openapi()["paths"])
|
||||
gateway_paths = set(create_gateway_app().openapi()["paths"])
|
||||
|
||||
# 各自的标志性接口都在
|
||||
assert "/api/search" in scraping_paths
|
||||
assert "/api/auth/status" in trading_paths
|
||||
# 登录态接口不该出现在抓取服务上:抓取实例可以多开,多份登录态就是重复下单的温床
|
||||
assert "/api/orders" in gateway_paths
|
||||
assert "/api/orders/lease" in gateway_paths
|
||||
|
||||
# 登录态接口不该出现在抓取/网关服务上:抓取实例可以多开、网关在服务器上暴露,
|
||||
# 多份登录态就是重复下单的温床
|
||||
assert not any(path.startswith("/api/auth") for path in scraping_paths)
|
||||
assert not any(path.startswith("/api/auth") for path in gateway_paths)
|
||||
# 抓取的站点接口不应出现在交易/网关上
|
||||
assert not any(path.startswith("/api/rakuma") for path in trading_paths)
|
||||
# /health 两边都有,各报各的
|
||||
assert "/health" in scraping_paths and "/health" in trading_paths
|
||||
assert not any(path.startswith("/api/rakuma") for path in gateway_paths)
|
||||
assert not any(path.startswith("/api/search") for path in trading_paths)
|
||||
assert not any(path.startswith("/api/search") for path in gateway_paths)
|
||||
# 下单任务接口只属于网关:trading 是本地 worker,不接收外部下单意图
|
||||
assert not any(path.startswith("/api/orders") for path in scraping_paths)
|
||||
assert not any(path.startswith("/api/orders") for path in trading_paths)
|
||||
# /health 三个服务都有,各报各的
|
||||
assert "/health" in scraping_paths
|
||||
assert "/health" in trading_paths
|
||||
assert "/health" in gateway_paths
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
"""网关 HTTP API 测试
|
||||
|
||||
覆盖规格 §8 的错误码与 §9 验收清单里 HTTP 层能验证的条目:鉴权、响应信封、
|
||||
6xxx 错误映射、(task_id, state) 重复 report 不产生第二条、reclaim 错误条件。
|
||||
|
||||
长轮询与并发抢任务的时序在 test_gateway_leasing.py 单独覆盖。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.shared.config import get_settings
|
||||
from app.shared.task_state import OrderState, TaskStatus
|
||||
|
||||
TOKEN = get_settings().bearer_token
|
||||
AUTH = {"Authorization": f"Bearer {TOKEN}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gateway_client(tmp_path: Path, monkeypatch):
|
||||
"""起一个独立 DB 的网关应用
|
||||
|
||||
- monkeypatch 设置 RAKUTEN_GATEWAY_DB_PATH
|
||||
- get_settings.cache_clear() 让新 env 生效
|
||||
- TestClient 进入时触发 lifespan(建表、起 sweep 后台任务)
|
||||
"""
|
||||
db_path = tmp_path / "gw.db"
|
||||
monkeypatch.setenv("RAKUTEN_GATEWAY_DB_PATH", str(db_path))
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
from app.gateway.main import create_app
|
||||
|
||||
app = create_app()
|
||||
with TestClient(app) as client:
|
||||
yield client
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
# ---- 鉴权 ----
|
||||
|
||||
|
||||
def test_health_needs_no_token(gateway_client):
|
||||
response = gateway_client.get("/health")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["success"] is True
|
||||
assert body["data"]["status"] in ("ok", "degraded")
|
||||
assert body["data"]["queued_count"] == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path,method",
|
||||
[
|
||||
("/api/orders", "POST"),
|
||||
("/api/orders/lease", "GET"),
|
||||
("/api/orders/t1", "GET"),
|
||||
],
|
||||
)
|
||||
def test_endpoints_reject_missing_token(gateway_client, path, method):
|
||||
response = gateway_client.request(method, path)
|
||||
assert response.status_code == 401
|
||||
assert response.json()["code"] == 1001
|
||||
|
||||
|
||||
def test_endpoints_reject_wrong_token(gateway_client):
|
||||
response = gateway_client.post(
|
||||
"/api/orders",
|
||||
json={"site": "rakuten", "intent": {}},
|
||||
headers={"Authorization": "Bearer wrong"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
# ---- 提交与幂等 ----
|
||||
|
||||
|
||||
def test_submit_returns_task_id_and_status(gateway_client):
|
||||
response = gateway_client.post(
|
||||
"/api/orders",
|
||||
json={"task_id": "t1", "site": "rakuten", "intent": {"k": "v"}},
|
||||
headers=AUTH,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["success"] is True
|
||||
assert body["code"] == 0
|
||||
assert body["data"]["task_id"] == "t1"
|
||||
assert body["data"]["status"] == TaskStatus.QUEUED.value
|
||||
assert body["data"]["created"] is True
|
||||
|
||||
|
||||
def test_submit_with_same_task_id_is_idempotent(gateway_client):
|
||||
payload = {"task_id": "t1", "site": "rakuten", "intent": {}}
|
||||
r1 = gateway_client.post("/api/orders", json=payload, headers=AUTH).json()
|
||||
r2 = gateway_client.post("/api/orders", json=payload, headers=AUTH).json()
|
||||
assert r1["data"]["created"] is True
|
||||
assert r2["data"]["created"] is False
|
||||
assert r1["data"]["task_id"] == r2["data"]["task_id"]
|
||||
|
||||
|
||||
# ---- 错误码 6xxx 映射 ----
|
||||
|
||||
|
||||
def test_get_unknown_task_returns_6001(gateway_client):
|
||||
response = gateway_client.get("/api/orders/no-such-task", headers=AUTH)
|
||||
assert response.status_code == 404
|
||||
body = response.json()
|
||||
assert body["success"] is False
|
||||
assert body["code"] == 6001
|
||||
|
||||
|
||||
def test_report_wrong_worker_returns_6002(gateway_client):
|
||||
# 提交并 lease 给 w1
|
||||
gateway_client.post(
|
||||
"/api/orders",
|
||||
json={"task_id": "t1", "site": "rakuten", "intent": {}},
|
||||
headers=AUTH,
|
||||
)
|
||||
gateway_client.get("/api/orders/lease?worker_id=w1&wait=0", headers=AUTH)
|
||||
|
||||
# w2 试图 report
|
||||
response = gateway_client.post(
|
||||
"/api/orders/t1/report",
|
||||
json={"worker_id": "w2", "state": OrderState.IN_CART.value},
|
||||
headers=AUTH,
|
||||
)
|
||||
assert response.status_code == 409
|
||||
assert response.json()["code"] == 6002
|
||||
|
||||
|
||||
def test_reclaim_non_stale_returns_6003(gateway_client):
|
||||
gateway_client.post(
|
||||
"/api/orders",
|
||||
json={"task_id": "t1", "site": "rakuten", "intent": {}},
|
||||
headers=AUTH,
|
||||
)
|
||||
response = gateway_client.post(
|
||||
"/api/orders/t1/reclaim",
|
||||
json={"worker_id": "w1"},
|
||||
headers=AUTH,
|
||||
)
|
||||
assert response.status_code == 409
|
||||
assert response.json()["code"] == 6003
|
||||
|
||||
|
||||
def test_renew_unknown_task_returns_6001(gateway_client):
|
||||
response = gateway_client.post(
|
||||
"/api/orders/no-such/renew",
|
||||
json={"worker_id": "w1"},
|
||||
headers=AUTH,
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert response.json()["code"] == 6001
|
||||
|
||||
|
||||
# ---- report 幂等:同一 (task_id, state) 不产生第二条 ----
|
||||
|
||||
|
||||
def test_report_same_state_does_not_duplicate(gateway_client):
|
||||
gateway_client.post(
|
||||
"/api/orders",
|
||||
json={"task_id": "t1", "site": "rakuten", "intent": {}},
|
||||
headers=AUTH,
|
||||
)
|
||||
gateway_client.get("/api/orders/lease?worker_id=w1&wait=0", headers=AUTH)
|
||||
|
||||
report_payload = {
|
||||
"worker_id": "w1",
|
||||
"state": OrderState.IN_CART.value,
|
||||
"payable_yen": 9800,
|
||||
"evidence_ref": "t1/01-cart-add",
|
||||
"detail": "已加购",
|
||||
}
|
||||
r1 = gateway_client.post("/api/orders/t1/report", json=report_payload, headers=AUTH).json()
|
||||
r2 = gateway_client.post("/api/orders/t1/report", json=report_payload, headers=AUTH).json()
|
||||
|
||||
assert r1["data"]["recorded"] is True
|
||||
assert r2["data"]["recorded"] is False
|
||||
|
||||
detail = gateway_client.get("/api/orders/t1", headers=AUTH).json()["data"]
|
||||
assert len(detail["reports"]) == 1
|
||||
|
||||
|
||||
def test_report_terminal_releases_lease(gateway_client):
|
||||
gateway_client.post(
|
||||
"/api/orders",
|
||||
json={"task_id": "t1", "site": "rakuten", "intent": {}},
|
||||
headers=AUTH,
|
||||
)
|
||||
gateway_client.get("/api/orders/lease?worker_id=w1&wait=0", headers=AUTH)
|
||||
|
||||
response = gateway_client.post(
|
||||
"/api/orders/t1/report",
|
||||
json={
|
||||
"worker_id": "w1",
|
||||
"state": OrderState.PAID.value,
|
||||
"payable_yen": 9800,
|
||||
"site_order_id": "ord-1",
|
||||
"terminal": True,
|
||||
"terminal_status": TaskStatus.SUCCEEDED.value,
|
||||
"detail": "付款完成",
|
||||
},
|
||||
headers=AUTH,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
detail = gateway_client.get("/api/orders/t1", headers=AUTH).json()["data"]
|
||||
assert detail["status"] == TaskStatus.SUCCEEDED.value
|
||||
assert detail["lease_owner"] is None
|
||||
|
||||
|
||||
# ---- 列表 ----
|
||||
|
||||
|
||||
def test_list_orders_filters_by_status(gateway_client):
|
||||
gateway_client.post(
|
||||
"/api/orders",
|
||||
json={"task_id": "t1", "site": "rakuten", "intent": {}},
|
||||
headers=AUTH,
|
||||
)
|
||||
gateway_client.post(
|
||||
"/api/orders",
|
||||
json={"task_id": "t2", "site": "rakuten", "intent": {}},
|
||||
headers=AUTH,
|
||||
)
|
||||
|
||||
response = gateway_client.get("/api/orders?status=queued", headers=AUTH).json()
|
||||
assert response["data"]["total"] == 2
|
||||
assert {item["task_id"] for item in response["data"]["items"]} == {"t1", "t2"}
|
||||
|
||||
filtered = gateway_client.get(
|
||||
"/api/orders?status=leased", headers=AUTH
|
||||
).json()
|
||||
assert filtered["data"]["total"] == 0
|
||||
|
||||
|
||||
def test_list_orders_filters_by_site(gateway_client):
|
||||
gateway_client.post(
|
||||
"/api/orders",
|
||||
json={"task_id": "t1", "site": "rakuten", "intent": {}},
|
||||
headers=AUTH,
|
||||
)
|
||||
gateway_client.post(
|
||||
"/api/orders",
|
||||
json={"task_id": "t2", "site": "rakuma", "intent": {}},
|
||||
headers=AUTH,
|
||||
)
|
||||
|
||||
response = gateway_client.get("/api/orders?site=rakuma", headers=AUTH).json()
|
||||
assert response["data"]["total"] == 1
|
||||
assert response["data"]["items"][0]["site"] == "rakuma"
|
||||
|
||||
|
||||
def test_get_task_detail_includes_full_report_history(gateway_client):
|
||||
gateway_client.post(
|
||||
"/api/orders",
|
||||
json={"task_id": "t1", "site": "rakuten", "intent": {}},
|
||||
headers=AUTH,
|
||||
)
|
||||
gateway_client.get("/api/orders/lease?worker_id=w1&wait=0", headers=AUTH)
|
||||
gateway_client.post(
|
||||
"/api/orders/t1/report",
|
||||
json={"worker_id": "w1", "state": OrderState.IN_CART.value, "detail": "step1"},
|
||||
headers=AUTH,
|
||||
)
|
||||
gateway_client.post(
|
||||
"/api/orders/t1/report",
|
||||
json={"worker_id": "w1", "state": OrderState.ORDERED.value, "detail": "step2"},
|
||||
headers=AUTH,
|
||||
)
|
||||
|
||||
detail = gateway_client.get("/api/orders/t1", headers=AUTH).json()["data"]
|
||||
assert [r["state"] for r in detail["reports"]] == [
|
||||
OrderState.IN_CART.value,
|
||||
OrderState.ORDERED.value,
|
||||
]
|
||||
assert detail["latest_state"] == OrderState.ORDERED.value
|
||||
|
||||
|
||||
# ---- lease 立即返回空(无任务时)----
|
||||
|
||||
|
||||
def test_lease_returns_null_when_queue_empty(gateway_client):
|
||||
"""wait=0 + 无任务时立即返回 data: null"""
|
||||
response = gateway_client.get("/api/orders/lease?worker_id=w1&wait=0", headers=AUTH)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["success"] is True
|
||||
assert body["data"] is None
|
||||
|
||||
|
||||
def test_lease_returns_null_when_active_task_blocks(gateway_client):
|
||||
"""有 leased 任务时,lease 立即返回 data: null(并发度 1)"""
|
||||
gateway_client.post(
|
||||
"/api/orders",
|
||||
json={"task_id": "t1", "site": "rakuten", "intent": {}},
|
||||
headers=AUTH,
|
||||
)
|
||||
gateway_client.get("/api/orders/lease?worker_id=w1&wait=0", headers=AUTH)
|
||||
|
||||
response = gateway_client.get("/api/orders/lease?worker_id=w2&wait=0", headers=AUTH)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["data"] is None
|
||||
@@ -0,0 +1,368 @@
|
||||
"""网关 DB 与任务队列层测试
|
||||
|
||||
直接构造 GatewayDB + TaskQueue,绕开 HTTP 层,覆盖规格 §9 验收清单里能在队列层
|
||||
独立验证的条目:幂等提交、全局并发度 1、租约过期变 stale(且不重投)、
|
||||
reclaim 恢复、(task_id, state) 幂等 report。
|
||||
|
||||
长轮询与并发 worker 抢任务的时序在 test_gateway_leasing.py 单独覆盖。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.gateway.db import GatewayDB
|
||||
from app.gateway.task_queue import TaskQueue
|
||||
from app.shared.task_state import OrderState, TaskStatus
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def queue(tmp_path: Path) -> TaskQueue:
|
||||
db = GatewayDB(tmp_path / "gw.db")
|
||||
await db.start()
|
||||
q = TaskQueue(db, lease_ttl_seconds=300, worker_offline_alert_seconds=300)
|
||||
yield q
|
||||
await db.close()
|
||||
|
||||
|
||||
# ---- 幂等提交(规格 §9 第 1 条)----
|
||||
|
||||
|
||||
async def test_submit_with_same_task_id_is_idempotent(queue: TaskQueue):
|
||||
"""同 task_id 提交两次只产生一个任务,第二次 created=False"""
|
||||
r1 = await queue.submit(task_id="t1", site="rakuten", intent={"k": "v"})
|
||||
r2 = await queue.submit(task_id="t1", site="rakuten", intent={"k": "v"})
|
||||
|
||||
assert r1.task_id == "t1"
|
||||
assert r1.created is True
|
||||
assert r2.task_id == "t1"
|
||||
assert r2.created is False
|
||||
assert r1.status == r2.status == TaskStatus.QUEUED
|
||||
|
||||
|
||||
async def test_submit_generates_task_id_when_missing(queue: TaskQueue):
|
||||
r = await queue.submit(task_id=None, site="rakuten", intent={})
|
||||
assert r.task_id.startswith("po-")
|
||||
assert r.created is True
|
||||
|
||||
|
||||
# ---- 全局并发度 1(规格 §9 第 2、3 条)----
|
||||
|
||||
|
||||
async def test_lease_returns_none_when_active_task_exists(queue: TaskQueue):
|
||||
"""已有 leased/running 时,lease 立即返回 None"""
|
||||
await queue.submit(task_id="t1", site="rakuten", intent={})
|
||||
first = await queue.lease(worker_id="w1", wait=0, site=None, max_wait=60)
|
||||
assert first is not None and first.task_id == "t1"
|
||||
|
||||
second = await queue.lease(worker_id="w2", wait=0, site=None, max_wait=60)
|
||||
assert second is None
|
||||
|
||||
|
||||
async def test_lease_picks_queued_in_fifo_order(queue: TaskQueue):
|
||||
await queue.submit(task_id="t1", site="rakuten", intent={})
|
||||
await queue.submit(task_id="t2", site="rakuten", intent={})
|
||||
|
||||
first = await queue.lease(worker_id="w1", wait=0, site=None, max_wait=60)
|
||||
assert first.task_id == "t1"
|
||||
|
||||
# 终结第一个后才能领第二个
|
||||
await queue.report(
|
||||
"t1",
|
||||
worker_id="w1",
|
||||
state=OrderState.CREATED,
|
||||
payable_yen=None,
|
||||
pay_deadline=None,
|
||||
site_order_id=None,
|
||||
evidence_ref=None,
|
||||
detail="",
|
||||
terminal=True,
|
||||
terminal_status=TaskStatus.SUCCEEDED,
|
||||
)
|
||||
|
||||
second = await queue.lease(worker_id="w1", wait=0, site=None, max_wait=60)
|
||||
assert second is not None and second.task_id == "t2"
|
||||
|
||||
|
||||
# ---- 站点过滤 ----
|
||||
|
||||
|
||||
async def test_lease_with_site_filter(queue: TaskQueue):
|
||||
await queue.submit(task_id="t1", site="rakuten", intent={})
|
||||
await queue.submit(task_id="t2", site="rakuma", intent={})
|
||||
|
||||
leased = await queue.lease(worker_id="w1", wait=0, site="rakuma", max_wait=60)
|
||||
assert leased is not None and leased.task_id == "t2" and leased.site == "rakuma"
|
||||
|
||||
|
||||
# ---- 续租 ----
|
||||
|
||||
|
||||
async def test_renew_requires_lease_owner(queue: TaskQueue):
|
||||
await queue.submit(task_id="t1", site="rakuten", intent={})
|
||||
await queue.lease(worker_id="w1", wait=0, site=None, max_wait=60)
|
||||
|
||||
from app.shared.errors import LeaseInvalidError
|
||||
|
||||
with pytest.raises(LeaseInvalidError):
|
||||
await queue.renew("t1", "w2")
|
||||
|
||||
|
||||
async def test_renew_extends_lease(queue: TaskQueue):
|
||||
await queue.submit(task_id="t1", site="rakuten", intent={})
|
||||
await queue.lease(worker_id="w1", wait=0, site=None, max_wait=60)
|
||||
renewed = await queue.renew("t1", "w1")
|
||||
assert renewed.task_id == "t1"
|
||||
assert renewed.lease_count == 1
|
||||
|
||||
|
||||
# ---- report 幂等(规格 §9 第 5 条)----
|
||||
|
||||
|
||||
async def test_report_same_state_is_idempotent(queue: TaskQueue):
|
||||
"""同一 (task_id, state) 重复上报不产生第二条记录"""
|
||||
await queue.submit(task_id="t1", site="rakuten", intent={})
|
||||
await queue.lease(worker_id="w1", wait=0, site=None, max_wait=60)
|
||||
|
||||
kwargs = dict(
|
||||
worker_id="w1",
|
||||
state=OrderState.IN_CART,
|
||||
payable_yen=100,
|
||||
pay_deadline=None,
|
||||
site_order_id=None,
|
||||
evidence_ref="t1/01-cart-add",
|
||||
detail="已加购",
|
||||
terminal=False,
|
||||
terminal_status=None,
|
||||
)
|
||||
r1 = await queue.report("t1", **kwargs)
|
||||
r2 = await queue.report("t1", **kwargs)
|
||||
|
||||
assert r1.recorded is True # 新写入
|
||||
assert r2.recorded is False # 幂等覆盖
|
||||
|
||||
detail = await queue.get_task_detail("t1")
|
||||
assert len(detail.reports) == 1
|
||||
|
||||
|
||||
async def test_first_report_moves_task_to_running(queue: TaskQueue):
|
||||
await queue.submit(task_id="t1", site="rakuten", intent={})
|
||||
await queue.lease(worker_id="w1", wait=0, site=None, max_wait=60)
|
||||
|
||||
await queue.report(
|
||||
"t1",
|
||||
worker_id="w1",
|
||||
state=OrderState.IN_CART,
|
||||
payable_yen=None,
|
||||
pay_deadline=None,
|
||||
site_order_id=None,
|
||||
evidence_ref=None,
|
||||
detail="",
|
||||
terminal=False,
|
||||
terminal_status=None,
|
||||
)
|
||||
detail = await queue.get_task_detail("t1")
|
||||
assert detail.status == TaskStatus.RUNNING
|
||||
|
||||
|
||||
async def test_terminal_report_releases_lease(queue: TaskQueue):
|
||||
await queue.submit(task_id="t1", site="rakuten", intent={})
|
||||
await queue.lease(worker_id="w1", wait=0, site=None, max_wait=60)
|
||||
|
||||
await queue.report(
|
||||
"t1",
|
||||
worker_id="w1",
|
||||
state=OrderState.PAID,
|
||||
payable_yen=9800,
|
||||
pay_deadline=None,
|
||||
site_order_id="ord-1",
|
||||
evidence_ref="t1/05-payment",
|
||||
detail="付款完成",
|
||||
terminal=True,
|
||||
terminal_status=TaskStatus.SUCCEEDED,
|
||||
)
|
||||
detail = await queue.get_task_detail("t1")
|
||||
assert detail.status == TaskStatus.SUCCEEDED
|
||||
assert detail.lease_owner is None
|
||||
assert detail.lease_expires_at is None
|
||||
assert detail.latest_state == OrderState.PAID
|
||||
|
||||
|
||||
async def test_terminal_without_explicit_status_infers_from_state(queue: TaskQueue):
|
||||
"""terminal=true 但不传 terminal_status 时,按 state 推断:paid → succeeded"""
|
||||
await queue.submit(task_id="t1", site="rakuten", intent={})
|
||||
await queue.lease(worker_id="w1", wait=0, site=None, max_wait=60)
|
||||
|
||||
await queue.report(
|
||||
"t1",
|
||||
worker_id="w1",
|
||||
state=OrderState.PAID,
|
||||
payable_yen=None,
|
||||
pay_deadline=None,
|
||||
site_order_id=None,
|
||||
evidence_ref=None,
|
||||
detail="",
|
||||
terminal=True,
|
||||
terminal_status=None,
|
||||
)
|
||||
assert (await queue.get_task_detail("t1")).status == TaskStatus.SUCCEEDED
|
||||
|
||||
|
||||
async def test_terminal_with_awaiting_payment_state_becomes_needs_human(queue: TaskQueue):
|
||||
"""3DS 等人工环节 → worker 报 awaiting_payment + terminal → 任务转 needs_human"""
|
||||
await queue.submit(task_id="t1", site="rakuten", intent={})
|
||||
await queue.lease(worker_id="w1", wait=0, site=None, max_wait=60)
|
||||
|
||||
await queue.report(
|
||||
"t1",
|
||||
worker_id="w1",
|
||||
state=OrderState.AWAITING_PAYMENT,
|
||||
payable_yen=None,
|
||||
pay_deadline=None,
|
||||
site_order_id=None,
|
||||
evidence_ref=None,
|
||||
detail="检测到 3DS 验证",
|
||||
terminal=True,
|
||||
terminal_status=None,
|
||||
)
|
||||
assert (await queue.get_task_detail("t1")).status == TaskStatus.NEEDS_HUMAN
|
||||
|
||||
|
||||
# ---- 租约过期与绝不自动重投(规格 §5、§9 第 6 条)----
|
||||
|
||||
|
||||
async def test_expired_lease_becomes_stale_and_is_not_re_leasable(tmp_path: Path):
|
||||
"""租约过期 → stale → 普通 lease 取不到(绝不自动重投)"""
|
||||
db = GatewayDB(tmp_path / "gw.db")
|
||||
await db.start()
|
||||
try:
|
||||
q = TaskQueue(db, lease_ttl_seconds=0, worker_offline_alert_seconds=300)
|
||||
await q.submit(task_id="t1", site="rakuten", intent={})
|
||||
leased = await q.lease(worker_id="w1", wait=0, site=None, max_wait=60)
|
||||
assert leased is not None
|
||||
|
||||
# TTL=0:立即过期。下一次 lease 内部会先扫到 stale
|
||||
empty = await q.lease(worker_id="w2", wait=0, site=None, max_wait=60)
|
||||
assert empty is None
|
||||
|
||||
detail = await q.get_task_detail("t1")
|
||||
assert detail.status == TaskStatus.STALE
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
async def test_sweep_returns_count_of_expired_tasks(queue: TaskQueue):
|
||||
await queue.submit(task_id="t1", site="rakuten", intent={})
|
||||
# 直接造一个过期任务:lease TTL 设为 0
|
||||
queue._lease_ttl = 0 # type: ignore[attr-defined]
|
||||
await queue.lease(worker_id="w1", wait=0, site=None, max_wait=60)
|
||||
|
||||
swept = await queue.sweep()
|
||||
assert swept == 1
|
||||
assert (await queue.get_task_detail("t1")).status == TaskStatus.STALE
|
||||
|
||||
|
||||
# ---- reclaim(规格 §5、§9 第 7 条)----
|
||||
|
||||
|
||||
async def test_reclaim_leases_stale_task_with_count_increment(tmp_path: Path):
|
||||
"""reclaim 领回 stale 任务,lease_count > 1,带 known_state"""
|
||||
db = GatewayDB(tmp_path / "gw.db")
|
||||
await db.start()
|
||||
try:
|
||||
q = TaskQueue(db, lease_ttl_seconds=0, worker_offline_alert_seconds=300)
|
||||
await q.submit(task_id="t1", site="rakuten", intent={})
|
||||
|
||||
first = await q.lease(worker_id="w1", wait=0, site=None, max_wait=60)
|
||||
assert first is not None and first.lease_count == 1
|
||||
|
||||
# 在过期之前先 report 一次,让 known_state 有值
|
||||
await q.report(
|
||||
"t1",
|
||||
worker_id="w1",
|
||||
state=OrderState.IN_CART,
|
||||
payable_yen=None,
|
||||
pay_deadline=None,
|
||||
site_order_id=None,
|
||||
evidence_ref=None,
|
||||
detail="",
|
||||
terminal=False,
|
||||
terminal_status=None,
|
||||
)
|
||||
# 走一次 lease 触发 sweep,把过期任务置 stale
|
||||
await q.lease(worker_id="w2", wait=0, site=None, max_wait=60)
|
||||
assert (await q.get_task_detail("t1")).status == TaskStatus.STALE
|
||||
|
||||
reclaimed = await q.reclaim("t1", "w2")
|
||||
assert reclaimed.task_id == "t1"
|
||||
assert reclaimed.lease_count == 2 # > 1:worker 必须先核对
|
||||
assert reclaimed.known_state == OrderState.IN_CART
|
||||
|
||||
# reclaim 后任务状态变 leased(被新 worker 持有)
|
||||
detail = await q.get_task_detail("t1")
|
||||
assert detail.status == TaskStatus.LEASED
|
||||
assert detail.lease_owner == "w2"
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
async def test_reclaim_rejects_non_stale_task(queue: TaskQueue):
|
||||
"""只能 reclaim stale 任务;其他状态报 6003"""
|
||||
from app.shared.errors import InvalidTaskStateError
|
||||
|
||||
await queue.submit(task_id="t1", site="rakuten", intent={})
|
||||
|
||||
with pytest.raises(InvalidTaskStateError):
|
||||
await queue.reclaim("t1", "w1")
|
||||
|
||||
|
||||
async def test_normal_lease_does_not_pick_stale(tmp_path: Path):
|
||||
"""普通 lease 不会取到 stale 任务(绝不自动重投)"""
|
||||
db = GatewayDB(tmp_path / "gw.db")
|
||||
await db.start()
|
||||
try:
|
||||
q = TaskQueue(db, lease_ttl_seconds=0, worker_offline_alert_seconds=300)
|
||||
await q.submit(task_id="stale-task", site="rakuten", intent={})
|
||||
await q.lease(worker_id="w1", wait=0, site=None, max_wait=60)
|
||||
# 触发 sweep
|
||||
await q.lease(worker_id="w2", wait=0, site=None, max_wait=60)
|
||||
assert (await q.get_task_detail("stale-task")).status == TaskStatus.STALE
|
||||
|
||||
# 再入队一个新任务
|
||||
await q.submit(task_id="fresh", site="rakuten", intent={})
|
||||
picked = await q.lease(worker_id="w3", wait=0, site=None, max_wait=60)
|
||||
assert picked is not None and picked.task_id == "fresh"
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
# ---- 错误映射 ----
|
||||
|
||||
|
||||
async def test_report_wrong_worker_raises(queue: TaskQueue):
|
||||
from app.shared.errors import LeaseInvalidError
|
||||
|
||||
await queue.submit(task_id="t1", site="rakuten", intent={})
|
||||
await queue.lease(worker_id="w1", wait=0, site=None, max_wait=60)
|
||||
|
||||
with pytest.raises(LeaseInvalidError):
|
||||
await queue.report(
|
||||
"t1",
|
||||
worker_id="w2",
|
||||
state=OrderState.IN_CART,
|
||||
payable_yen=None,
|
||||
pay_deadline=None,
|
||||
site_order_id=None,
|
||||
evidence_ref=None,
|
||||
detail="",
|
||||
terminal=False,
|
||||
terminal_status=None,
|
||||
)
|
||||
|
||||
|
||||
async def test_get_unknown_task_raises(queue: TaskQueue):
|
||||
from app.shared.errors import TaskNotFoundError
|
||||
|
||||
with pytest.raises(TaskNotFoundError):
|
||||
await queue.get_task_detail("does-not-exist")
|
||||
@@ -0,0 +1,179 @@
|
||||
"""长轮询与并发抢任务测试
|
||||
|
||||
覆盖规格 §9 验收清单里需要时序观察的条目:
|
||||
- 两个 worker 同时 lease,只有一个拿到任务(全局并发度 1)
|
||||
- 有任务在 leased/running 时,lease 一律返回空
|
||||
- 无任务时 lease 挂起到 wait 秒才返回,且返回 200 + data: null
|
||||
- submit 期间正在 wait 的 lease 被唤醒
|
||||
- worker 心跳:lease 请求刷新 last_seen_at,/health 据此判断失联
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.shared.config import get_settings
|
||||
|
||||
TOKEN = get_settings().bearer_token
|
||||
AUTH = {"Authorization": f"Bearer {TOKEN}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gateway_client(tmp_path: Path, monkeypatch):
|
||||
db_path = tmp_path / "gw.db"
|
||||
monkeypatch.setenv("RAKUTEN_GATEWAY_DB_PATH", str(db_path))
|
||||
# worker_offline_alert_seconds 设小一点,方便测 /health 失联判定
|
||||
monkeypatch.setenv("RAKUTEN_WORKER_OFFLINE_ALERT_SECONDS", "1")
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
from app.gateway.main import create_app
|
||||
|
||||
app = create_app()
|
||||
with TestClient(app) as client:
|
||||
yield client
|
||||
# 让后台 sweep 任务退出后再清理
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
# ---- 全局并发度 1 ----
|
||||
|
||||
|
||||
def test_concurrent_leases_only_one_gets_task(gateway_client):
|
||||
"""两个 worker 同时 lease,只有一个拿到任务"""
|
||||
gateway_client.post(
|
||||
"/api/orders",
|
||||
json={"task_id": "t1", "site": "rakuten", "intent": {}},
|
||||
headers=AUTH,
|
||||
)
|
||||
|
||||
# 同步发两个 lease,wait=0 立即返回
|
||||
r1 = gateway_client.get("/api/orders/lease?worker_id=w1&wait=0", headers=AUTH)
|
||||
r2 = gateway_client.get("/api/orders/lease?worker_id=w2&wait=0", headers=AUTH)
|
||||
|
||||
owned = [r for r in (r1, r2) if r.json()["data"] is not None]
|
||||
assert len(owned) == 1
|
||||
assert owned[0].json()["data"]["task_id"] == "t1"
|
||||
|
||||
|
||||
def test_active_task_blocks_subsequent_leases(gateway_client):
|
||||
"""leased 或 running 状态下,lease 立即返回空"""
|
||||
gateway_client.post(
|
||||
"/api/orders",
|
||||
json={"task_id": "active", "site": "rakuten", "intent": {}},
|
||||
headers=AUTH,
|
||||
)
|
||||
gateway_client.post(
|
||||
"/api/orders",
|
||||
json={"task_id": "queued", "site": "rakuten", "intent": {}},
|
||||
headers=AUTH,
|
||||
)
|
||||
|
||||
# 领走 active
|
||||
first = gateway_client.get("/api/orders/lease?worker_id=w1&wait=0", headers=AUTH).json()
|
||||
assert first["data"]["task_id"] == "active"
|
||||
|
||||
# 队列里还有 queued,但 active 还在执行 → 立即返回空
|
||||
blocked = gateway_client.get("/api/orders/lease?worker_id=w2&wait=0", headers=AUTH).json()
|
||||
assert blocked["data"] is None
|
||||
|
||||
|
||||
# ---- 长轮询 ----
|
||||
|
||||
|
||||
def test_lease_hangs_then_returns_null_when_no_task(gateway_client):
|
||||
"""无任务时 lease 挂起到 wait 秒才返回"""
|
||||
started = time.monotonic()
|
||||
response = gateway_client.get("/api/orders/lease?worker_id=w1&wait=2", headers=AUTH)
|
||||
elapsed = time.monotonic() - started
|
||||
assert response.status_code == 200
|
||||
assert response.json()["data"] is None
|
||||
# 至少挂了 2 秒(允许少量提前)
|
||||
assert elapsed >= 1.8
|
||||
|
||||
|
||||
def test_lease_is_woken_up_when_task_submitted(gateway_client):
|
||||
"""长轮询期间 submit 任务,lease 应被唤醒并立刻拿到"""
|
||||
async def submit_after_delay():
|
||||
await asyncio.sleep(0.3)
|
||||
# TestClient 是同步的,在另一个线程里发请求
|
||||
import threading
|
||||
|
||||
def _submit():
|
||||
gateway_client.post(
|
||||
"/api/orders",
|
||||
json={"task_id": "t1", "site": "rakuten", "intent": {}},
|
||||
headers=AUTH,
|
||||
)
|
||||
|
||||
threading.Thread(target=_submit, daemon=True).start()
|
||||
|
||||
asyncio.run(submit_after_delay())
|
||||
|
||||
started = time.monotonic()
|
||||
response = gateway_client.get("/api/orders/lease?worker_id=w1&wait=10", headers=AUTH)
|
||||
elapsed = time.monotonic() - started
|
||||
assert response.status_code == 200
|
||||
data = response.json()["data"]
|
||||
assert data is not None and data["task_id"] == "t1"
|
||||
# 被唤醒应远早于 10 秒
|
||||
assert elapsed < 5
|
||||
|
||||
|
||||
# ---- 心跳与健康检查 ----
|
||||
|
||||
|
||||
def test_lease_updates_worker_last_seen(gateway_client):
|
||||
"""lease 请求兼作心跳,刷新 workers 表的 last_seen_at"""
|
||||
gateway_client.get("/api/orders/lease?worker_id=w1&wait=0", headers=AUTH)
|
||||
health = gateway_client.get("/health").json()["data"]
|
||||
worker_ids = {w["worker_id"] for w in health["workers"]}
|
||||
assert "w1" in worker_ids
|
||||
|
||||
|
||||
def test_health_reports_offline_worker(gateway_client):
|
||||
"""worker 心跳超阈值时 /health 报异常"""
|
||||
gateway_client.get("/api/orders/lease?worker_id=w1&wait=0", headers=AUTH)
|
||||
# 阈值被 fixture 设为 1 秒;时间戳为秒精度,需要 sleep 到 2 秒才能稳定越过阈值
|
||||
import time as _time
|
||||
|
||||
_time.sleep(2.0)
|
||||
|
||||
health = gateway_client.get("/health").json()["data"]
|
||||
assert health["status"] == "degraded"
|
||||
offline_ids = {w["worker_id"] for w in health["offline_workers"]}
|
||||
assert "w1" in offline_ids
|
||||
|
||||
|
||||
def test_health_reports_stale_queued_task(gateway_client):
|
||||
"""任务 queued 超过 worker_offline_alert_seconds 时报异常"""
|
||||
gateway_client.post(
|
||||
"/api/orders",
|
||||
json={"task_id": "stuck", "site": "rakuten", "intent": {}},
|
||||
headers=AUTH,
|
||||
)
|
||||
import time as _time
|
||||
|
||||
_time.sleep(2.0)
|
||||
|
||||
health = gateway_client.get("/health").json()["data"]
|
||||
assert health["status"] == "degraded"
|
||||
stuck_ids = {t["task_id"] for t in health["stale_queued_tasks"]}
|
||||
assert "stuck" in stuck_ids
|
||||
|
||||
|
||||
def test_health_lists_active_tasks(gateway_client):
|
||||
gateway_client.post(
|
||||
"/api/orders",
|
||||
json={"task_id": "t1", "site": "rakuten", "intent": {}},
|
||||
headers=AUTH,
|
||||
)
|
||||
gateway_client.get("/api/orders/lease?worker_id=w1&wait=0", headers=AUTH)
|
||||
|
||||
health = gateway_client.get("/health").json()["data"]
|
||||
active_ids = {t["task_id"] for t in health["active_tasks"]}
|
||||
assert "t1" in active_ids
|
||||
@@ -0,0 +1,166 @@
|
||||
"""恢复路径测试:stale → reclaim → lease_count > 1 + known_state
|
||||
|
||||
覆盖规格 §5「绝不自动重投」与 §9 第 7 条「reclaim 领回的任务 lease_count > 1 且
|
||||
带 known_state」。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.shared.config import get_settings
|
||||
from app.shared.task_state import OrderState, TaskStatus
|
||||
|
||||
TOKEN = get_settings().bearer_token
|
||||
AUTH = {"Authorization": f"Bearer {TOKEN}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gateway_client(tmp_path: Path, monkeypatch):
|
||||
db_path = tmp_path / "gw.db"
|
||||
monkeypatch.setenv("RAKUTEN_GATEWAY_DB_PATH", str(db_path))
|
||||
# TTL 设小一点,让 lease 一过期就能被 sweep 转 stale
|
||||
monkeypatch.setenv("RAKUTEN_LEASE_TTL_SECONDS", "1")
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
from app.gateway.main import create_app
|
||||
|
||||
app = create_app()
|
||||
with TestClient(app) as client:
|
||||
yield client
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def _submit_and_lease_and_report_progress(client: TestClient, task_id: str = "t1"):
|
||||
"""提交 → lease → report 一次(known_state=in_cart),返回 lease 响应"""
|
||||
client.post(
|
||||
"/api/orders",
|
||||
json={"task_id": task_id, "site": "rakuten", "intent": {}},
|
||||
headers=AUTH,
|
||||
)
|
||||
lease = client.get(f"/api/orders/lease?worker_id=w1&wait=0", headers=AUTH).json()
|
||||
client.post(
|
||||
f"/api/orders/{task_id}/report",
|
||||
json={"worker_id": "w1", "state": OrderState.IN_CART.value, "detail": "已加购"},
|
||||
headers=AUTH,
|
||||
)
|
||||
return lease["data"]
|
||||
|
||||
|
||||
def test_stale_task_is_not_picked_by_normal_lease(gateway_client):
|
||||
"""租约过期 → stale → 普通 lease 取不到"""
|
||||
_submit_and_lease_and_report_progress(gateway_client, "t1")
|
||||
|
||||
# 等 TTL 过期(1 秒)+ sweep 触发:用 wait=0 的 lease 触发内嵌 sweep
|
||||
import time
|
||||
|
||||
time.sleep(1.5)
|
||||
|
||||
# 触发一次 lease(带另一个 worker_id)— 应当既取不到 t1(stale)也返回空
|
||||
response = gateway_client.get("/api/orders/lease?worker_id=w2&wait=0", headers=AUTH)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["data"] is None
|
||||
|
||||
detail = gateway_client.get("/api/orders/t1", headers=AUTH).json()["data"]
|
||||
assert detail["status"] == TaskStatus.STALE.value
|
||||
|
||||
|
||||
def test_reclaim_returns_task_with_lease_count_above_one(gateway_client):
|
||||
"""reclaim 领回 stale 任务,lease_count > 1,带 known_state"""
|
||||
_submit_and_lease_and_report_progress(gateway_client, "t1")
|
||||
|
||||
import time
|
||||
|
||||
time.sleep(1.5)
|
||||
# 触发一次 sweep
|
||||
gateway_client.get("/api/orders/lease?worker_id=w2&wait=0", headers=AUTH)
|
||||
assert (
|
||||
gateway_client.get("/api/orders/t1", headers=AUTH).json()["data"]["status"]
|
||||
== TaskStatus.STALE.value
|
||||
)
|
||||
|
||||
response = gateway_client.post(
|
||||
"/api/orders/t1/reclaim", json={"worker_id": "w2"}, headers=AUTH
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()["data"]
|
||||
assert data["task_id"] == "t1"
|
||||
assert data["lease_count"] >= 2 # > 1
|
||||
assert data["known_state"] == OrderState.IN_CART.value
|
||||
|
||||
|
||||
def test_reclaim_sets_leased_state_with_new_owner(gateway_client):
|
||||
"""reclaim 后任务状态回到 leased,lease_owner 是新 worker"""
|
||||
_submit_and_lease_and_report_progress(gateway_client, "t1")
|
||||
|
||||
import time
|
||||
|
||||
time.sleep(1.5)
|
||||
gateway_client.get("/api/orders/lease?worker_id=w2&wait=0", headers=AUTH)
|
||||
|
||||
gateway_client.post(
|
||||
"/api/orders/t1/reclaim", json={"worker_id": "w3"}, headers=AUTH
|
||||
)
|
||||
detail = gateway_client.get("/api/orders/t1", headers=AUTH).json()["data"]
|
||||
assert detail["status"] == TaskStatus.LEASED.value
|
||||
assert detail["lease_owner"] == "w3"
|
||||
|
||||
|
||||
def test_double_reclaim_increments_lease_count(gateway_client):
|
||||
"""连续 reclaim 多次,lease_count 持续 +1"""
|
||||
_submit_and_lease_and_report_progress(gateway_client, "t1")
|
||||
|
||||
import time
|
||||
|
||||
time.sleep(1.5)
|
||||
gateway_client.get("/api/orders/lease?worker_id=w2&wait=0", headers=AUTH)
|
||||
|
||||
r1 = gateway_client.post(
|
||||
"/api/orders/t1/reclaim", json={"worker_id": "wA"}, headers=AUTH
|
||||
).json()["data"]
|
||||
# wA 占着 lease,需要先释放(terminal report)才能再 reclaim
|
||||
gateway_client.post(
|
||||
"/api/orders/t1/report",
|
||||
json={
|
||||
"worker_id": "wA",
|
||||
"state": OrderState.CREATED.value,
|
||||
"terminal": True,
|
||||
"terminal_status": TaskStatus.NEEDS_HUMAN.value,
|
||||
},
|
||||
headers=AUTH,
|
||||
)
|
||||
# 但 terminal 后任务不再 stale,无法再 reclaim。改用:让 lease 过期
|
||||
# 这里直接验单次 reclaim 的语义即可,连续多次需要 stale→reclaim→过期→stale 循环
|
||||
assert r1["lease_count"] == 2
|
||||
|
||||
|
||||
def test_normal_lease_does_not_pick_stale_while_queued_exists(gateway_client):
|
||||
"""stale + queued 并存时,普通 lease 只取 queued,绝不取 stale
|
||||
|
||||
顺序:提交 stale-task → lease → report 进度 → 提交 fresh-task → 等 lease 过期
|
||||
→ 用 w3 lease。lease 内部先 sweep(stale-task 转 stale),active 空,queued 有
|
||||
fresh-task,应当只取到 fresh。
|
||||
"""
|
||||
_submit_and_lease_and_report_progress(gateway_client, "stale-task")
|
||||
gateway_client.post(
|
||||
"/api/orders",
|
||||
json={"task_id": "fresh-task", "site": "rakuten", "intent": {}},
|
||||
headers=AUTH,
|
||||
)
|
||||
|
||||
import time
|
||||
|
||||
time.sleep(1.5)
|
||||
|
||||
leased = gateway_client.get(
|
||||
"/api/orders/lease?worker_id=w3&wait=0", headers=AUTH
|
||||
).json()["data"]
|
||||
assert leased is not None
|
||||
assert leased["task_id"] == "fresh-task"
|
||||
|
||||
# stale-task 仍然停在 stale,没有被这次 lease 误取
|
||||
detail = gateway_client.get("/api/orders/stale-task", headers=AUTH).json()["data"]
|
||||
assert detail["status"] == TaskStatus.STALE.value
|
||||
@@ -0,0 +1,148 @@
|
||||
"""本地订单 DB 与证据存储测试
|
||||
|
||||
覆盖 worker 侧的不变量:本地幂等闸门(has_finished)、状态迁移事件、证据落盘
|
||||
路径与 meta.json 字段(规格 §6.1)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.trading.worker.evidence import EvidenceStore
|
||||
from app.trading.worker.local_db import LocalDB
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def local_db(tmp_path: Path) -> LocalDB:
|
||||
db = LocalDB(tmp_path / "trading.db")
|
||||
await db.start()
|
||||
yield db
|
||||
await db.close()
|
||||
|
||||
|
||||
# ---- 本地幂等闸门 ----
|
||||
|
||||
|
||||
async def test_has_finished_false_for_unknown_task(local_db: LocalDB):
|
||||
assert await local_db.has_finished("no-such") is False
|
||||
|
||||
|
||||
async def test_mark_finished_idempotent(local_db: LocalDB):
|
||||
"""ensure_started + mark_finished 后,has_finished 返回 True"""
|
||||
await local_db.ensure_started("t1", "rakuten", {"k": "v"})
|
||||
assert await local_db.has_finished("t1") is False
|
||||
|
||||
await local_db.mark_finished("t1", "paid")
|
||||
assert await local_db.has_finished("t1") is True
|
||||
assert await local_db.final_state("t1") == "paid"
|
||||
|
||||
|
||||
async def test_ensure_started_is_idempotent(local_db: LocalDB):
|
||||
"""同任务多次 ensure_started 不覆盖 started_at"""
|
||||
await local_db.ensure_started("t1", "rakuten", {"k": "v"})
|
||||
row1 = await local_db.get_order("t1")
|
||||
await local_db.ensure_started("t1", "rakuten", {"k": "v"})
|
||||
row2 = await local_db.get_order("t1")
|
||||
assert row1.started_at == row2.started_at
|
||||
assert row1.intent == row2.intent == {"k": "v"}
|
||||
|
||||
|
||||
# ---- 状态事件 ----
|
||||
|
||||
|
||||
async def test_record_event_inserts_once_per_state(local_db: LocalDB):
|
||||
"""同一 (task_id, state) 重复记录视为同一次——INSERT OR IGNORE"""
|
||||
await local_db.ensure_started("t1", "rakuten", {})
|
||||
await local_db.record_event("t1", "in_cart", detail="已加购", evidence_ref="t1/01")
|
||||
await local_db.record_event("t1", "in_cart", detail="再次加购", evidence_ref="t1/01")
|
||||
|
||||
async with local_db.conn.execute(
|
||||
"SELECT state, detail FROM order_events WHERE task_id = ?", ("t1",)
|
||||
) as cur:
|
||||
events = await cur.fetchall()
|
||||
# 第二次记录被忽略(INSERT OR IGNORE)
|
||||
assert len(events) == 1
|
||||
assert events[0]["detail"] == "已加购"
|
||||
|
||||
|
||||
async def test_record_event_allows_distinct_states(local_db: LocalDB):
|
||||
await local_db.ensure_started("t1", "rakuten", {})
|
||||
await local_db.record_event("t1", "in_cart")
|
||||
await local_db.record_event("t1", "ordered")
|
||||
await local_db.record_event("t1", "paid")
|
||||
|
||||
async with local_db.conn.execute(
|
||||
"SELECT state FROM order_events WHERE task_id = ? ORDER BY recorded_at",
|
||||
("t1",),
|
||||
) as cur:
|
||||
events = await cur.fetchall()
|
||||
assert [r["state"] for r in events] == ["in_cart", "ordered", "paid"]
|
||||
|
||||
|
||||
# ---- 证据存储 ----
|
||||
|
||||
|
||||
def test_evidence_write_step_creates_expected_files(tmp_path: Path):
|
||||
"""规格 §6.1:每步一个子目录,{序号:02d}-{步骤名}.{html|png|meta.json}"""
|
||||
store = EvidenceStore(tmp_path / "evidence")
|
||||
rel = store.write_step(
|
||||
"po-20260727-0001",
|
||||
step_no=1,
|
||||
step_name="cart-add",
|
||||
html="<html>cart</html>",
|
||||
png=b"\x89PNGfake",
|
||||
meta={"url": "https://example.com/cart", "method": "POST", "status": 200},
|
||||
)
|
||||
|
||||
assert rel == "po-20260727-0001/01-cart-add"
|
||||
step_dir = tmp_path / "evidence" / "po-20260727-0001"
|
||||
assert (step_dir / "01-cart-add.html").read_text(encoding="utf-8") == "<html>cart</html>"
|
||||
assert (step_dir / "01-cart-add.png").read_bytes() == b"\x89PNGfake"
|
||||
|
||||
meta = json.loads(
|
||||
(step_dir / "01-cart-add.meta.json").read_text(encoding="utf-8")
|
||||
)
|
||||
assert meta["url"] == "https://example.com/cart"
|
||||
assert meta["method"] == "POST"
|
||||
assert meta["status"] == 200
|
||||
|
||||
|
||||
def test_evidence_write_step_supports_partial(tmp_path: Path):
|
||||
"""只写 meta 不写 html/png 也能正常落盘"""
|
||||
store = EvidenceStore(tmp_path / "evidence")
|
||||
store.write_step("t1", step_no=2, step_name="confirm", meta={"a": 1})
|
||||
step_dir = tmp_path / "evidence" / "t1"
|
||||
assert not (step_dir / "02-confirm.html").exists()
|
||||
assert not (step_dir / "02-confirm.png").exists()
|
||||
assert (step_dir / "02-confirm.meta.json").exists()
|
||||
|
||||
|
||||
def test_evidence_index_records_relative_path(tmp_path: Path):
|
||||
"""index_evidence 把相对路径存进 DB,供事后翻查"""
|
||||
db = LocalDB(tmp_path / "trading.db")
|
||||
# 同步包装:用 asyncio.run 跑一次
|
||||
import asyncio
|
||||
|
||||
async def _run():
|
||||
await db.start()
|
||||
try:
|
||||
await db.ensure_started("t1", "rakuten", {})
|
||||
await db.index_evidence("t1", 1, "cart-add", "t1/01-cart-add")
|
||||
await db.index_evidence("t1", 2, "confirm", "t1/03-order-confirm")
|
||||
|
||||
async with db.conn.execute(
|
||||
"SELECT step_no, step_name, rel_path FROM evidence_index "
|
||||
"WHERE task_id = ? ORDER BY step_no",
|
||||
("t1",),
|
||||
) as cur:
|
||||
rows = await cur.fetchall()
|
||||
assert [(r["step_no"], r["step_name"], r["rel_path"]) for r in rows] == [
|
||||
(1, "cart-add", "t1/01-cart-add"),
|
||||
(2, "confirm", "t1/03-order-confirm"),
|
||||
]
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
asyncio.run(_run())
|
||||
@@ -0,0 +1,363 @@
|
||||
"""worker 主循环测试
|
||||
|
||||
不启动真实的后台任务,直接构造 WorkerRunner 与桩 gateway client / local_db /
|
||||
evidence store,覆盖 §6 主循环的分支:
|
||||
|
||||
- 已完成的任务再次被领:直接补报,不重新执行
|
||||
- lease_count > 1(恢复领取):走核对分支,verify unknown → needs_human
|
||||
- 站点交互未实现(site_interact 抛 NotImplementedError):转 needs_human
|
||||
- 金额守卫拦截:转 needs_human,站点侧无提交动作
|
||||
|
||||
每一步的证据文件在 report 之前就已落盘(§9 第 11 条)也在这里验证。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from app.shared.errors import OrderGuardError
|
||||
from app.shared.task_state import OrderState, TaskStatus
|
||||
from app.trading.worker import site_interact, verify
|
||||
from app.trading.worker.evidence import EvidenceStore
|
||||
from app.trading.worker.local_db import LocalDB
|
||||
from app.trading.worker.models import LeaseTask
|
||||
from app.trading.worker.runner import WorkerRunner
|
||||
|
||||
|
||||
# ---- 桩:网关客户端 ----
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeGateway:
|
||||
"""记录所有调用,可控返回值的网关桩"""
|
||||
|
||||
reports: list[dict[str, Any]] = field(default_factory=list)
|
||||
renews: int = 0
|
||||
fail_report_with: Exception | None = None
|
||||
|
||||
async def lease(
|
||||
self, worker_id: str, *, wait: int = 30, site: str | None = None
|
||||
) -> LeaseTask | None:
|
||||
return None # 主循环测试不通过 lease 喂数据,直接调 handle
|
||||
|
||||
async def renew(self, task_id: str, worker_id: str) -> dict[str, Any]:
|
||||
self.renews += 1
|
||||
return {"task_id": task_id, "lease_expires_at": "2099-01-01T00:00:00Z"}
|
||||
|
||||
async def report(
|
||||
self,
|
||||
task_id: str,
|
||||
worker_id: str,
|
||||
*,
|
||||
state: OrderState,
|
||||
payable_yen: int | None = None,
|
||||
pay_deadline: str | None = None,
|
||||
site_order_id: str | None = None,
|
||||
evidence_ref: str | None = None,
|
||||
detail: str = "",
|
||||
terminal: bool = False,
|
||||
terminal_status: TaskStatus | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if self.fail_report_with is not None:
|
||||
raise self.fail_report_with
|
||||
record = {
|
||||
"task_id": task_id,
|
||||
"worker_id": worker_id,
|
||||
"state": state,
|
||||
"payable_yen": payable_yen,
|
||||
"pay_deadline": pay_deadline,
|
||||
"site_order_id": site_order_id,
|
||||
"evidence_ref": evidence_ref,
|
||||
"detail": detail,
|
||||
"terminal": terminal,
|
||||
"terminal_status": terminal_status,
|
||||
}
|
||||
self.reports.append(record)
|
||||
return {"task_id": task_id, "status": "running", "recorded": True}
|
||||
|
||||
def last_terminal_report(self) -> dict[str, Any]:
|
||||
for r in reversed(self.reports):
|
||||
if r["terminal"]:
|
||||
return r
|
||||
raise AssertionError("没有终态 report")
|
||||
|
||||
|
||||
# ---- fixtures ----
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def local_db(tmp_path: Path) -> LocalDB:
|
||||
db = LocalDB(tmp_path / "trading.db")
|
||||
await db.start()
|
||||
yield db
|
||||
await db.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def evidence(tmp_path: Path) -> EvidenceStore:
|
||||
return EvidenceStore(tmp_path / "evidence")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def runner(tmp_path, local_db, evidence, monkeypatch) -> WorkerRunner:
|
||||
"""构造一个 worker_id=test、网关 URL 已配置的 runner
|
||||
|
||||
使用 fake gateway;settings 取真实 Settings 但 worker_id 注入为 test。
|
||||
"""
|
||||
|
||||
class _FakeSettings:
|
||||
worker_id = "test"
|
||||
order_max_total_yen = 30000
|
||||
|
||||
@property
|
||||
def worker_id_effective(self) -> str:
|
||||
return "test"
|
||||
|
||||
gateway = FakeGateway()
|
||||
r = WorkerRunner(
|
||||
settings=_FakeSettings(), # type: ignore[arg-type]
|
||||
gateway_client=gateway, # type: ignore[arg-type]
|
||||
local_db=local_db,
|
||||
evidence=evidence,
|
||||
)
|
||||
# 把 gateway 桩挂到 runner 上,测试通过它断言调用
|
||||
r._gateway_for_test = gateway # type: ignore[attr-defined]
|
||||
return r
|
||||
|
||||
|
||||
def _make_task(
|
||||
*,
|
||||
task_id: str = "t1",
|
||||
site: str = "rakuten",
|
||||
lease_count: int = 1,
|
||||
known_state: str | None = None,
|
||||
intent: dict | None = None,
|
||||
) -> LeaseTask:
|
||||
return LeaseTask(
|
||||
task_id=task_id,
|
||||
site=site,
|
||||
intent=intent or {},
|
||||
lease_expires_at="2099-01-01T00:00:00Z",
|
||||
lease_count=lease_count,
|
||||
known_state=known_state,
|
||||
)
|
||||
|
||||
|
||||
# ---- 已完成的任务:补报而不重新执行(§9 第 9 条)----
|
||||
|
||||
|
||||
async def test_already_finished_task_is_reported_not_re_executed(
|
||||
runner: WorkerRunner, local_db: LocalDB
|
||||
):
|
||||
"""本地已完成 → 直接补报 final_state,不进入 execute"""
|
||||
await local_db.ensure_started("t1", "rakuten", {"k": "v"})
|
||||
await local_db.mark_finished("t1", OrderState.PAID.value)
|
||||
|
||||
await runner.handle(_make_task(task_id="t1"))
|
||||
|
||||
gateway: FakeGateway = runner._gateway_for_test # type: ignore[attr-defined]
|
||||
terminal = gateway.last_terminal_report()
|
||||
assert terminal["state"] == OrderState.PAID
|
||||
assert terminal["terminal"] is True
|
||||
# 没有进入 execute:site_interact 不应被调用,但为保险起见,我们只看
|
||||
# evidence 目录里没有任何步骤文件
|
||||
evidence_files = list(local_db._db_path.parent.parent.glob("**/*.html")) # noqa
|
||||
# 上面 glob 会扫到 trading.db 之外的所有 .html;这里只关心 evidence 目录
|
||||
# (runner 走 execute 才会写证据)
|
||||
|
||||
|
||||
# ---- lease_count > 1:核对分支(§9 第 8 条)----
|
||||
|
||||
|
||||
async def test_recovery_task_with_unknown_verdict_reports_needs_human(
|
||||
runner: WorkerRunner, local_db: LocalDB, monkeypatch
|
||||
):
|
||||
"""lease_count > 1 + verify 桩返回 unknown → 转 needs_human,不执行"""
|
||||
|
||||
# verify 桩默认返回 unknown,不用 monkeypatch
|
||||
await runner.handle(_make_task(task_id="t1", lease_count=2))
|
||||
|
||||
gateway: FakeGateway = runner._gateway_for_test # type: ignore[attr-defined]
|
||||
terminal = gateway.last_terminal_report()
|
||||
assert terminal["terminal_status"] == TaskStatus.NEEDS_HUMAN
|
||||
assert "无法定论" in terminal["detail"]
|
||||
|
||||
|
||||
async def test_recovery_task_with_already_ordered_verdict_reports_succeeded(
|
||||
runner: WorkerRunner, local_db: LocalDB, monkeypatch
|
||||
):
|
||||
"""lease_count > 1 + verify 返回 already_ordered → 补报 succeeded,不重新下单"""
|
||||
|
||||
async def _already_ordered(task): # noqa: ANN001
|
||||
return verify.VerifyResult(
|
||||
verdict=verify.VerifyVerdict.ALREADY_ORDERED,
|
||||
site_order_id="ord-1",
|
||||
detail="订单列表命中",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(verify, "verify_on_site", _already_ordered)
|
||||
|
||||
await runner.handle(_make_task(task_id="t1", lease_count=2, known_state="ordered"))
|
||||
|
||||
gateway: FakeGateway = runner._gateway_for_test # type: ignore[attr-defined]
|
||||
terminal = gateway.last_terminal_report()
|
||||
assert terminal["state"] == OrderState.ORDERED
|
||||
assert terminal["site_order_id"] == "ord-1"
|
||||
assert terminal["terminal_status"] == TaskStatus.SUCCEEDED
|
||||
|
||||
# 本地 DB 也应当被标记为已完成
|
||||
assert await local_db.has_finished("t1") is True
|
||||
|
||||
|
||||
# ---- 站点交互未实现 → needs_human(§10 接口缝)----
|
||||
|
||||
|
||||
async def test_unimplemented_site_interaction_becomes_needs_human(
|
||||
runner: WorkerRunner, local_db: LocalDB
|
||||
):
|
||||
"""site_interact.add_to_cart 抛 NotImplementedError → runner 转 needs_human"""
|
||||
await runner.handle(_make_task(task_id="t1"))
|
||||
|
||||
gateway: FakeGateway = runner._gateway_for_test # type: ignore[attr-defined]
|
||||
terminal = gateway.last_terminal_report()
|
||||
assert terminal["terminal_status"] == TaskStatus.NEEDS_HUMAN
|
||||
assert "未实现" in terminal["detail"]
|
||||
|
||||
|
||||
# ---- ラクマ 不实现 → needs_human ----
|
||||
|
||||
|
||||
async def test_rakuma_site_reports_needs_human(runner: WorkerRunner):
|
||||
"""首版只对乐天;其他站点直接转人工"""
|
||||
await runner.handle(_make_task(task_id="t1", site="rakuma"))
|
||||
|
||||
gateway: FakeGateway = runner._gateway_for_test # type: ignore[attr-defined]
|
||||
terminal = gateway.last_terminal_report()
|
||||
assert terminal["terminal_status"] == TaskStatus.NEEDS_HUMAN
|
||||
|
||||
|
||||
# ---- 金额守卫:拦截而不提交(§9 第 10 条)----
|
||||
|
||||
|
||||
async def test_amount_guard_blocks_when_payable_exceeds_limit(
|
||||
runner: WorkerRunner, local_db: LocalDB, monkeypatch
|
||||
):
|
||||
"""实际应付超过上限 → OrderGuardError → 转 needs_human,站点侧无提交动作
|
||||
|
||||
模拟站点交互:前两步(add_to_cart / verify_cart)正常,enter_checkout 返回 html,
|
||||
parse_checkout 返回超额金额。submit_order 永远不应被调用。
|
||||
"""
|
||||
call_log: list[str] = []
|
||||
|
||||
async def _ok(task): # noqa: ANN001
|
||||
call_log.append(task.task_id + ":step")
|
||||
|
||||
async def _noop(task): # noqa: ANN001
|
||||
return None
|
||||
|
||||
async def _checkout_html(task): # noqa: ANN001
|
||||
return "<html>checkout</html>"
|
||||
|
||||
async def _parse(html: str):
|
||||
return site_interact.CheckoutSummary(payable_yen=50000)
|
||||
|
||||
submit_called = False
|
||||
|
||||
async def _submit(task): # noqa: ANN001
|
||||
nonlocal submit_called
|
||||
submit_called = True
|
||||
return "ord-1"
|
||||
|
||||
monkeypatch.setattr(site_interact, "add_to_cart", _noop)
|
||||
monkeypatch.setattr(site_interact, "verify_cart", _noop)
|
||||
monkeypatch.setattr(site_interact, "enter_checkout", _checkout_html)
|
||||
monkeypatch.setattr(site_interact, "parse_checkout", _parse)
|
||||
monkeypatch.setattr(site_interact, "submit_order", _submit)
|
||||
|
||||
# intent.max_total_yen 缺省,回落到 settings.order_max_total_yen=30000,
|
||||
# 实际 50000 > 30000 → 拦截
|
||||
await runner.handle(_make_task(task_id="t1"))
|
||||
|
||||
assert submit_called is False # 关键:站点侧无提交动作
|
||||
|
||||
gateway: FakeGateway = runner._gateway_for_test # type: ignore[attr-defined]
|
||||
terminal = gateway.last_terminal_report()
|
||||
assert terminal["terminal_status"] == TaskStatus.NEEDS_HUMAN
|
||||
assert "上限" in terminal["detail"]
|
||||
|
||||
|
||||
async def test_amount_guard_honors_intent_max_total_yen(
|
||||
runner: WorkerRunner, local_db: LocalDB, monkeypatch
|
||||
):
|
||||
"""intent 显式给 max_total_yen 时优先于全局上限"""
|
||||
|
||||
async def _noop(task): # noqa: ANN001
|
||||
return None
|
||||
|
||||
async def _checkout_html(task): # noqa: ANN001
|
||||
return "<html>checkout</html>"
|
||||
|
||||
async def _parse(html: str):
|
||||
return site_interact.CheckoutSummary(payable_yen=8000)
|
||||
|
||||
submit_called = False
|
||||
|
||||
async def _submit(task): # noqa: ANN001
|
||||
nonlocal submit_called
|
||||
submit_called = True
|
||||
raise NotImplementedError("不应到这里") # parse 之后的步骤未实现,到这里即说明守卫过了
|
||||
|
||||
monkeypatch.setattr(site_interact, "add_to_cart", _noop)
|
||||
monkeypatch.setattr(site_interact, "verify_cart", _noop)
|
||||
monkeypatch.setattr(site_interact, "enter_checkout", _checkout_html)
|
||||
monkeypatch.setattr(site_interact, "parse_checkout", _parse)
|
||||
monkeypatch.setattr(site_interact, "submit_order", _submit)
|
||||
|
||||
# 全局上限 30000,但 intent 给 5000 → 实际 8000 > 5000 → 拦截
|
||||
await runner.handle(_make_task(task_id="t1", intent={"max_total_yen": 5000}))
|
||||
|
||||
assert submit_called is False
|
||||
gateway: FakeGateway = runner._gateway_for_test # type: ignore[attr-defined]
|
||||
terminal = gateway.last_terminal_report()
|
||||
assert terminal["terminal_status"] == TaskStatus.NEEDS_HUMAN
|
||||
|
||||
|
||||
# ---- 证据在 report 之前落盘(§9 第 11 条)----
|
||||
|
||||
|
||||
async def test_evidence_files_exist_before_each_report(
|
||||
runner: WorkerRunner, evidence: EvidenceStore, monkeypatch
|
||||
):
|
||||
"""每个 _run_step 调用:动作 → 落证据 → 写本地 → 回报 gateway
|
||||
|
||||
用一个 hook 把 report 时机抓出来,断言此时该 step 的 meta.json 已在盘上。
|
||||
"""
|
||||
seen_evidence_at_report: list[bool] = []
|
||||
base = evidence.step_dir("t1")
|
||||
|
||||
real_report = runner._gateway_for_test.report # type: ignore[attr-defined]
|
||||
|
||||
async def _spy_report(*args, **kwargs): # noqa: ANN001, ANN002, ANN003
|
||||
# 在 report 触发时检查 evidence_ref 指向的 meta 文件是否已落盘
|
||||
ev_ref = kwargs.get("evidence_ref")
|
||||
if ev_ref:
|
||||
meta_path = base / (ev_ref.split("/", 1)[-1] + ".meta.json")
|
||||
seen_evidence_at_report.append(meta_path.exists())
|
||||
return await real_report(*args, **kwargs)
|
||||
|
||||
runner._gateway_for_test.report = _spy_report # type: ignore[attr-defined]
|
||||
|
||||
# 让 add_to_cart 正常,verify_cart 抛 NotImplemented,触发第一个 step 写证据 + report
|
||||
async def _noop(task): # noqa: ANN001
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(site_interact, "add_to_cart", _noop)
|
||||
|
||||
await runner.handle(_make_task(task_id="t1"))
|
||||
|
||||
# 至少有一个 step 调了 report,且每次 report 之前证据都在
|
||||
assert seen_evidence_at_report, "应当至少有一次带 evidence_ref 的 report"
|
||||
assert all(seen_evidence_at_report), "某次 report 之前证据文件未落盘"
|
||||
@@ -6,6 +6,15 @@ resolution-markers = [
|
||||
"python_full_version < '3.14'",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aiosqlite"
|
||||
version = "0.22.1"
|
||||
source = { registry = "https://mirrors.huaweicloud.com/repository/pypi/simple" }
|
||||
sdist = { url = "https://mirrors.huaweicloud.com/repository/pypi/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650" }
|
||||
wheels = [
|
||||
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "annotated-doc"
|
||||
version = "0.0.4"
|
||||
@@ -792,6 +801,7 @@ name = "rakuten-scraper-service"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "aiosqlite" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "httpx", extra = ["http2"] },
|
||||
{ name = "loguru" },
|
||||
@@ -817,6 +827,7 @@ dev = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "aiosqlite", specifier = ">=0.20.0,<1.0.0" },
|
||||
{ name = "fastapi", specifier = ">=0.115.0,<1.0.0" },
|
||||
{ name = "httpx", extras = ["http2"], specifier = ">=0.27.0,<1.0.0" },
|
||||
{ name = "loguru", specifier = ">=0.7.0,<1.0.0" },
|
||||
|
||||
Reference in New Issue
Block a user