diff --git a/.env.example b/.env.example index 83ce9d7..bd89819 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/Dockerfile b/Dockerfile index 141864d..f1558ec 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 \ # python -m app.trading.main # 注意交易服务持有登录态与订单监控,**只能起一个实例**,不要挂多副本。 +# +# 网关部署在服务器侧(与抓取服务同机或邻近),任务队列 SQLite 需要持久化卷: +# docker run -e RAKUTEN_HEALTH_PORT=31109 -v ./data:/app/data \ +# python -m app.gateway.main +# 网关也**只能起一个实例**:SQLite 单连接 + 全局并发度 1,多实例会抢同一个 DB。 CMD ["python", "-m", "app.scraping.main"] \ No newline at end of file diff --git a/README.md b/README.md index 20b9619..846c479 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/app/gateway/__init__.py b/app/gateway/__init__.py new file mode 100644 index 0000000..a99f36c --- /dev/null +++ b/app/gateway/__init__.py @@ -0,0 +1,12 @@ +"""下单任务网关:第三个部署单元 + +抓取服务(app.scraping)匿名、无状态、可多开;交易服务(app.trading)带账号、 +有状态、单实例;本网关是任务队列与状态镜像,部署在服务器侧,**独立于两者**: + +- 与抓取服务分进程的原因:抓取实例可以多开,而任务队列有状态,多实例会抢同一批 + 任务(同一账号的写操作必须串行)。 +- 与交易服务分进程的原因:交易在本地(NAT 后无公网入口),网关在服务器,本地 + 通过出站长轮询从这里领任务。 + +完整规格见 docs/order-gateway.md。 +""" diff --git a/app/gateway/api/__init__.py b/app/gateway/api/__init__.py new file mode 100644 index 0000000..f3f61e5 --- /dev/null +++ b/app/gateway/api/__init__.py @@ -0,0 +1 @@ +"""网关 API 包""" diff --git a/app/gateway/api/routes/__init__.py b/app/gateway/api/routes/__init__.py new file mode 100644 index 0000000..b2ea6c4 --- /dev/null +++ b/app/gateway/api/routes/__init__.py @@ -0,0 +1 @@ +"""网关路由集合""" diff --git a/app/gateway/api/routes/health.py b/app/gateway/api/routes/health.py new file mode 100644 index 0000000..da9b321 --- /dev/null +++ b/app/gateway/api/routes/health.py @@ -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 + ) diff --git a/app/gateway/api/routes/orders.py b/app/gateway/api/routes/orders.py new file mode 100644 index 0000000..7f8add2 --- /dev/null +++ b/app/gateway/api/routes/orders.py @@ -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) diff --git a/app/gateway/container.py b/app/gateway/container.py new file mode 100644 index 0000000..edb65ac --- /dev/null +++ b/app/gateway/container.py @@ -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 diff --git a/app/gateway/db.py b/app/gateway/db.py new file mode 100644 index 0000000..25163b8 --- /dev/null +++ b/app/gateway/db.py @@ -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] diff --git a/app/gateway/main.py b/app/gateway/main.py new file mode 100644 index 0000000..7b2d680 --- /dev/null +++ b/app/gateway/main.py @@ -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, + ) diff --git a/app/gateway/models.py b/app/gateway/models.py new file mode 100644 index 0000000..8bcb3bb --- /dev/null +++ b/app/gateway/models.py @@ -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) diff --git a/app/gateway/task_queue.py b/app/gateway/task_queue.py new file mode 100644 index 0000000..ab4cc84 --- /dev/null +++ b/app/gateway/task_queue.py @@ -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, + ) diff --git a/app/shared/config.py b/app/shared/config.py index f1ba6d1..2b51d75 100644 --- a/app/shared/config.py +++ b/app/shared/config.py @@ -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: diff --git a/app/shared/errors.py b/app/shared/errors.py index a46cfbe..7a89ff7 100644 --- a/app/shared/errors.py +++ b/app/shared/errors.py @@ -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, + ) diff --git a/app/shared/task_state.py b/app/shared/task_state.py new file mode 100644 index 0000000..e46695a --- /dev/null +++ b/app/shared/task_state.py @@ -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" diff --git a/app/trading/container.py b/app/trading/container.py index d0a9176..da7c411 100644 --- a/app/trading/container.py +++ b/app/trading/container.py @@ -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 diff --git a/app/trading/main.py b/app/trading/main.py index ecd139e..ac04542 100644 --- a/app/trading/main.py +++ b/app/trading/main.py @@ -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() diff --git a/app/trading/worker/__init__.py b/app/trading/worker/__init__.py new file mode 100644 index 0000000..7d7abc8 --- /dev/null +++ b/app/trading/worker/__init__.py @@ -0,0 +1,9 @@ +"""本地下单 worker:从网关长轮询领任务,本地执行下单与付款 + +部署在本地交易服务进程内(NAT 后无公网入口),通过出站长轮询从服务器侧的 +order-gateway 取任务。**零入站端口**:所有跨机通信都由本地发起。 + +主循环见 runner.WorkerRunner.run:lease → 本地幂等闸门 → 恢复核对 → 执行 → 监控。 +站点交互(加购 / 下单 / 付款)当前未实测(docs/order-gateway.md §10),留作 +NotImplementedError 缺口,runner 捕获后上报 needs_human。 +""" diff --git a/app/trading/worker/client.py b/app/trading/worker/client.py new file mode 100644 index 0000000..28a9404 --- /dev/null +++ b/app/trading/worker/client.py @@ -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"), + ) diff --git a/app/trading/worker/evidence.py b/app/trading/worker/evidence.py new file mode 100644 index 0000000..34a2467 --- /dev/null +++ b/app/trading/worker/evidence.py @@ -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 diff --git a/app/trading/worker/local_db.py b/app/trading/worker/local_db.py new file mode 100644 index 0000000..af6f733 --- /dev/null +++ b/app/trading/worker/local_db.py @@ -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() diff --git a/app/trading/worker/models.py b/app/trading/worker/models.py new file mode 100644 index 0000000..b6ea96d --- /dev/null +++ b/app/trading/worker/models.py @@ -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 diff --git a/app/trading/worker/runner.py b/app/trading/worker/runner.py new file mode 100644 index 0000000..d05b547 --- /dev/null +++ b/app/trading/worker/runner.py @@ -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 diff --git a/app/trading/worker/site_interact.py b/app/trading/worker/site_interact.py new file mode 100644 index 0000000..c1bc9ea --- /dev/null +++ b/app/trading/worker/site_interact.py @@ -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) diff --git a/app/trading/worker/verify.py b/app/trading/worker/verify.py new file mode 100644 index 0000000..bfb6789 --- /dev/null +++ b/app/trading/worker/verify.py @@ -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 条", + ) diff --git a/pyproject.toml b/pyproject.toml index cae3fc4..434b509 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/tests/test_architecture.py b/tests/test_architecture.py index f845a4a..0f6910f 100644 --- a/tests/test_architecture.py +++ b/tests/test_architecture.py @@ -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 diff --git a/tests/test_gateway_api.py b/tests/test_gateway_api.py new file mode 100644 index 0000000..eaf4cf7 --- /dev/null +++ b/tests/test_gateway_api.py @@ -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 diff --git a/tests/test_gateway_db.py b/tests/test_gateway_db.py new file mode 100644 index 0000000..266cac9 --- /dev/null +++ b/tests/test_gateway_db.py @@ -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") diff --git a/tests/test_gateway_leasing.py b/tests/test_gateway_leasing.py new file mode 100644 index 0000000..d912aae --- /dev/null +++ b/tests/test_gateway_leasing.py @@ -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 diff --git a/tests/test_gateway_recover.py b/tests/test_gateway_recover.py new file mode 100644 index 0000000..27ab285 --- /dev/null +++ b/tests/test_gateway_recover.py @@ -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 diff --git a/tests/test_worker_local.py b/tests/test_worker_local.py new file mode 100644 index 0000000..7ed6431 --- /dev/null +++ b/tests/test_worker_local.py @@ -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="cart", + 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") == "cart" + 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()) diff --git a/tests/test_worker_runner.py b/tests/test_worker_runner.py new file mode 100644 index 0000000..8cb7aef --- /dev/null +++ b/tests/test_worker_runner.py @@ -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 "checkout" + + 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 "checkout" + + 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 之前证据文件未落盘" diff --git a/uv.lock b/uv.lock index d204551..3ca0642 100644 --- a/uv.lock +++ b/uv.lock @@ -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" },