实现租约恢复核对:verify_on_site 接真实订单列表反查
新增 SiteInteractor.list_recent_orders(分页拉 order.my.rakuten.co.jp 订单列表, 按商品 URL 反查)+ GatewayClient.get_task,替换掉恒返回 UNKNOWN 的桩。 NOT_ORDERED 分支目前只有逻辑验证、没有真实多单数据支撑,刻意仍路由到 needs_human,不自动重新下单。全程只读查询,未触发任何真实付款操作。
This commit is contained in:
@@ -127,6 +127,16 @@ class GatewayClient:
|
||||
body = await self._request("POST", f"/api/orders/{task_id}/report", json=payload)
|
||||
return body["data"]
|
||||
|
||||
async def get_task(self, task_id: str) -> dict[str, Any]:
|
||||
"""任务详情,返回网关响应里的 data 字段(含 created_at)
|
||||
|
||||
供 verify.verify_on_site 恢复核对用:LeaseTask(lease/reclaim 的响应)
|
||||
不带 created_at,规格 §5「按创建时间 ~ stale 之间」的窗口核对必须单独
|
||||
查一次这个接口才能拿到。
|
||||
"""
|
||||
body = await self._request("GET", f"/api/orders/{task_id}")
|
||||
return body["data"]
|
||||
|
||||
async def reclaim(self, task_id: str, worker_id: str) -> LeaseTask:
|
||||
"""恢复领取。返回的 LeaseTask 必然 lease_count > 1"""
|
||||
body = await self._request(
|
||||
|
||||
@@ -162,7 +162,7 @@ class WorkerRunner:
|
||||
task.lease_count,
|
||||
task.task_id,
|
||||
)
|
||||
verdict = await verify.verify_on_site(task)
|
||||
verdict = await verify.verify_on_site(task, gateway=self._gateway, site=self._site)
|
||||
if verdict.verdict == verify.VerifyVerdict.ALREADY_ORDERED:
|
||||
await self._report_safe(
|
||||
task,
|
||||
@@ -176,12 +176,16 @@ class WorkerRunner:
|
||||
await self._db.mark_finished(task.task_id, OrderState.ORDERED.value)
|
||||
return
|
||||
|
||||
# NOT_ORDERED 也走 needs_human:当前 verify 桩不会返回这个值,但留接口给
|
||||
# 未来真正能可靠核对时——那时再决定 NOT_ORDERED 是否直接重新执行
|
||||
# NOT_ORDERED 也走 needs_human(2026-08-13 verify_on_site 已实现,会真的
|
||||
# 返回这个值,但刻意仍不自动重新执行):核对逻辑目前只有「账号 1 笔订单」
|
||||
# 的真实数据支撑 ALREADY_ORDERED 分支,NOT_ORDERED 分支只验证过逻辑本身,
|
||||
# 没有被真实多单场景跑过——在这条判断被更多真实数据验证之前,即使确认
|
||||
# 未下单也交人工决定是否重新提交,不自动触发新的下单动作。这是刻意的
|
||||
# 保守选择,不是遗漏;要不要放开需要显式决定,不在这里静默改。
|
||||
await self._report_safe(
|
||||
task,
|
||||
state=_coerce_state(task.known_state),
|
||||
detail=f"恢复核对无法定论:{verdict.detail}",
|
||||
detail=f"恢复核对结论({verdict.verdict.value}):{verdict.detail}",
|
||||
terminal=True,
|
||||
terminal_status=TaskStatus.NEEDS_HUMAN,
|
||||
)
|
||||
|
||||
@@ -56,6 +56,14 @@ data/evidence/checkout-research-20260811/NOTES.md):
|
||||
(刚接单,未发货)这一阶段,「出荷」「配達完了」两个状态转换点没有被真实数据
|
||||
验证过,只是按进度条文案直译映射(_ORDER_STAGE_TO_STATE);取消/退款没有
|
||||
找到可靠信号,检测不到,遇到需要人工核对订单列表页。
|
||||
- list_recent_orders(规格 §5 恢复核对用,verify.verify_on_site 调用)**2026-08-13
|
||||
用真账号实测过** order.my.rakuten.co.jp/purchase-history/order-list:这是与
|
||||
check_order_status 不同的另一个页面(真正的「我的订单列表」,不是
|
||||
detail_page_view 那个 act 分支),pageType="ph-list" 时 __INITIAL_STATE__.
|
||||
orderListData 直接是结构化 JSON(ordersFound/orderList[].items[].itemUrl 等),
|
||||
不需要正则抠 DOM。当时账号只有 1 笔订单、1 页,验证了单页解析与
|
||||
`?page=2` 超出范围返回空列表;多页翻页时「新订单排在前面」的排序假设、以及
|
||||
分页游标本身,都没有被真实多页数据验证过。
|
||||
|
||||
**httpx 不能用于带账号的写操作**:Rakuten 对账号操作有 TLS/HTTP2 指纹校验,
|
||||
同一份 cookie Playwright 能用、httpx 不能。所以本模块全程使用 Playwright
|
||||
@@ -71,7 +79,8 @@ import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from app.shared.errors import (
|
||||
@@ -295,6 +304,122 @@ _ORDER_STAGE_TO_STATE: dict[str, OrderState] = {
|
||||
"配達完了": OrderState.DELIVERED,
|
||||
}
|
||||
|
||||
# ---- 订单列表反查:verify_on_site 恢复核对用(规格 §5,2026-08-13 实测确认)----
|
||||
# order.my.rakuten.co.jp/purchase-history/order-list 是真实的「我的订单列表」页
|
||||
# (不是 _ORDER_DETAIL_URL_TEMPLATE 那个 detail_page_view 分支,两者 act 不同)。
|
||||
# 探测路径:my.rakuten.co.jp 首页有 scid=myr_popular_purchasehist 链接指向
|
||||
# order.my.rakuten.co.jp/,服务端把它 302 到 /purchase-history/order-list;直接
|
||||
# 访问该固定路径同样命中,不需要带 scid。pageType="ph-list" 时 __INITIAL_STATE__.
|
||||
# orderListData 是结构化 JSON(ordersFound/pageSize/orderList[],每条订单带
|
||||
# items[].itemUrl),不需要正则抠 DOM——这是与订单详情页最大的不同。
|
||||
# ?page=N 控制分页(2026-08-13 用 page=2 验证过「超出范围返回空 orderList」,
|
||||
# 但账号当时只有 1 笔订单、1 页,真实多页场景的排序/翻页边界未被验证过)。
|
||||
_ORDER_LIST_URL = "https://order.my.rakuten.co.jp/purchase-history/order-list"
|
||||
# 安全上限:避免账号订单极多时无限翻页;命中上限仍未翻完窗口时
|
||||
# window_fully_covered=False,调用方(verify.verify_on_site)转 unknown,不猜。
|
||||
_ORDER_LIST_MAX_PAGES = 20
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class OrderListItem:
|
||||
"""订单列表页单笔订单里的一个商品条目"""
|
||||
|
||||
item_url: str | None
|
||||
item_name: str = ""
|
||||
item_id: int | str | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class OrderListEntry:
|
||||
"""订单列表页单笔订单"""
|
||||
|
||||
order_number: str
|
||||
order_date: str # 站点原始 ISO8601 字符串,如 "2026-08-13T09:47:28.000Z"
|
||||
shop_id: int | str | None = None
|
||||
shop_name: str = ""
|
||||
items: list[OrderListItem] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class OrderListPage:
|
||||
"""order-list 单页解析结果(_parse_order_list 的返回值)"""
|
||||
|
||||
entries: list[OrderListEntry]
|
||||
orders_found: int | None = None
|
||||
page_size: int | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class OrderListWindow:
|
||||
"""list_recent_orders 的返回值:某个时间窗口内翻页汇总的结果
|
||||
|
||||
window_fully_covered=True 才代表「窗口内的订单已经看全」——可能是因为翻到了
|
||||
比 since 更早的订单、可能是列表本身翻完了、也可能是 ordersFound 已经对上。
|
||||
False 表示翻页在覆盖完窗口前就停了(命中 _ORDER_LIST_MAX_PAGES,或页面结构
|
||||
解析不出 ordersFound 之类的异常),此时 entries 里「没有匹配」不能当作
|
||||
「确实没下单」——调用方必须转 unknown,不能默认 NOT_ORDERED。
|
||||
"""
|
||||
|
||||
entries: list[OrderListEntry]
|
||||
window_fully_covered: bool
|
||||
|
||||
|
||||
def parse_order_datetime(raw: str | None) -> datetime | None:
|
||||
"""把站点返回的 ISO8601 字符串解析成 datetime,解析不出就返回 None(不猜)"""
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _OrderListAccumulator:
|
||||
"""list_recent_orders 翻页时的累积状态(内部用,不对外暴露)"""
|
||||
|
||||
entries: list[OrderListEntry] = field(default_factory=list)
|
||||
total_found: int | None = None
|
||||
is_first_page: bool = True
|
||||
gave_up: bool = False # True=第一页就拿不到结构化数据,翻页无意义,直接放弃
|
||||
|
||||
|
||||
def _accumulate_order_list_page(
|
||||
acc: _OrderListAccumulator, page: OrderListPage, *, since: datetime
|
||||
) -> tuple[_OrderListAccumulator, bool]:
|
||||
"""把一页解析结果并入累积状态,返回 (新累积状态, 是否应该停止翻页)
|
||||
|
||||
纯函数,不碰 Playwright——list_recent_orders 每翻一页调一次,离线单测可以
|
||||
直接喂一串 OrderListPage 模拟多页翻页,不需要真实浏览器(与本文件其余
|
||||
_parse_* 纯函数同一套测试思路)。
|
||||
|
||||
「停止翻页」不等于「窗口已覆盖完」:acc.gave_up=True 表示第一页就拿不到
|
||||
结构化列表数据(页面结构变了/act 分支不对/未登录跳转),此时也会停止翻页,
|
||||
但调用方必须按「没覆盖」处理,不能当成真的翻完了。
|
||||
"""
|
||||
if acc.is_first_page and page.orders_found is None:
|
||||
return (
|
||||
_OrderListAccumulator(entries=acc.entries, total_found=acc.total_found, is_first_page=False, gave_up=True),
|
||||
True,
|
||||
)
|
||||
|
||||
total_found = acc.total_found if acc.total_found is not None else page.orders_found
|
||||
if not page.entries:
|
||||
return (
|
||||
_OrderListAccumulator(entries=acc.entries, total_found=total_found, is_first_page=False),
|
||||
True,
|
||||
)
|
||||
|
||||
new_entries = acc.entries + page.entries
|
||||
new_acc = _OrderListAccumulator(entries=new_entries, total_found=total_found, is_first_page=False)
|
||||
|
||||
oldest_dt = parse_order_datetime(page.entries[-1].order_date)
|
||||
if oldest_dt is not None and oldest_dt < since:
|
||||
return new_acc, True
|
||||
if total_found is not None and len(new_entries) >= total_found:
|
||||
return new_acc, True
|
||||
return new_acc, False
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CheckoutSummary:
|
||||
@@ -1474,6 +1599,51 @@ class SiteInteractor:
|
||||
|
||||
return _parse_order_status(html, site_order_id)
|
||||
|
||||
async def list_recent_orders(self, *, since: datetime) -> OrderListWindow:
|
||||
"""恢复核对用:拉取「任务创建时间之后」的订单列表(规格 §5 依赖它)
|
||||
|
||||
由 verify.verify_on_site 调用,不由 worker 主循环直接调。翻页直到看到
|
||||
order_date 早于 since 的订单(说明窗口内的都已经看过一遍)、或
|
||||
ordersFound 已经全部翻完、或到达 _ORDER_LIST_MAX_PAGES 上限。命中上限仍
|
||||
没能确认覆盖完整窗口时,`OrderListWindow.window_fully_covered=False`——
|
||||
调用方据此转 unknown,绝不能把「没翻完」当成「翻完了但没有」。
|
||||
|
||||
2026-08-13 只用「账号只有 1 笔订单、1 页」的真实数据验证过单页解析与
|
||||
page=2 返回空列表这两点;多页翻页的排序假设(新订单在前)未经真实数据
|
||||
验证,多页场景上线前应重新探测确认。
|
||||
|
||||
Raises:
|
||||
NotLoggedInError: 登录态失效
|
||||
OrderOperationError: 订单列表页打开/渲染失败
|
||||
"""
|
||||
async with self._lock:
|
||||
await self._auth_session.require_logged_in("rakuten")
|
||||
await self._refresh_context_if_stale()
|
||||
|
||||
acc = _OrderListAccumulator()
|
||||
stop = False
|
||||
|
||||
page = await self._context.new_page()
|
||||
try:
|
||||
for page_num in range(1, _ORDER_LIST_MAX_PAGES + 1):
|
||||
url = _ORDER_LIST_URL if page_num == 1 else f"{_ORDER_LIST_URL}?page={page_num}"
|
||||
try:
|
||||
await page.goto(url, wait_until="domcontentloaded", timeout=30_000)
|
||||
await page.wait_for_timeout(2_000)
|
||||
except Exception as exc:
|
||||
raise OrderOperationError(
|
||||
f"订单列表页打开失败:page={page_num} {type(exc).__name__}: {exc}"
|
||||
) from exc
|
||||
html = await page.content()
|
||||
page_result = _parse_order_list(html)
|
||||
acc, stop = _accumulate_order_list_page(acc, page_result, since=since)
|
||||
if stop:
|
||||
break
|
||||
finally:
|
||||
await page.close()
|
||||
|
||||
return OrderListWindow(entries=acc.entries, window_fully_covered=stop and not acc.gave_up)
|
||||
|
||||
|
||||
# ---- 模块级辅助函数(纯函数,便于单测)----
|
||||
|
||||
@@ -1642,3 +1812,44 @@ def _parse_order_status(html: str, site_order_id: str) -> OrderStatusSnapshot:
|
||||
html=html,
|
||||
)
|
||||
return OrderStatusSnapshot(found=True, html=html)
|
||||
|
||||
|
||||
def _parse_order_list(html: str) -> OrderListPage:
|
||||
"""订单列表页解析核心逻辑(纯函数,供 list_recent_orders 调用,便于离线单测)
|
||||
|
||||
2026-08-13 用真账号真实探测过 order.my.rakuten.co.jp/purchase-history/order-list:
|
||||
pageType="ph-list" 时 __INITIAL_STATE__.orderListData 是结构化 JSON,不需要
|
||||
正则抠 DOM——直接复用 _parse_initial_state。pageType 不是 "ph-list"(比如
|
||||
命中了旧的 detail_page_view 错误分支、或未登录被跳转)时 orderListData 拿
|
||||
不到,返回空列表 + orders_found=None,调用方据此判断「没拿到列表数据」而不
|
||||
是「列表是空的」。
|
||||
"""
|
||||
state = _parse_initial_state(html)
|
||||
if not state or state.get("pageType") != "ph-list":
|
||||
return OrderListPage(entries=[])
|
||||
|
||||
data = state.get("orderListData") or {}
|
||||
entries: list[OrderListEntry] = []
|
||||
for order in data.get("orderList") or []:
|
||||
items = [
|
||||
OrderListItem(
|
||||
item_url=it.get("itemUrl"),
|
||||
item_name=it.get("itemName", ""),
|
||||
item_id=it.get("itemId"),
|
||||
)
|
||||
for it in order.get("items") or []
|
||||
]
|
||||
entries.append(
|
||||
OrderListEntry(
|
||||
order_number=order.get("orderNumber", ""),
|
||||
order_date=order.get("orderDate", ""),
|
||||
shop_id=order.get("shopId"),
|
||||
shop_name=order.get("shopName", ""),
|
||||
items=items,
|
||||
)
|
||||
)
|
||||
return OrderListPage(
|
||||
entries=entries,
|
||||
orders_found=data.get("ordersFound"),
|
||||
page_size=data.get("pageSize"),
|
||||
)
|
||||
|
||||
@@ -4,16 +4,27 @@
|
||||
此时 worker 不能盲目重新提交(可能上次已经下单成功,只是回报断网),必须先查
|
||||
站点订单列表比对。
|
||||
|
||||
订单列表反查的实现需要实测(规格 §10 第 3 条:订单列表页能否按商品 + 时间窗口
|
||||
可靠地反查出「这单下没下」)。当前为桩:恒返回 `unknown`,让 runner 走
|
||||
needs_human 分支。**绝不**默认按「没下单」处理——那是猜,可能变成重复下单。
|
||||
2026-08-13 实现:核对逻辑用真实账号 + 真实订单验证过订单列表的数据结构(见
|
||||
app/trading/worker/site_interact.py::list_recent_orders 与 _parse_order_list 的
|
||||
模块文档),但当时账号只有 1 笔订单,ALREADY_ORDERED 分支有真实数据支撑,
|
||||
NOT_ORDERED / 多笔命中 / 多页翻页这几个分支目前只有逻辑,没有被真实多单数据
|
||||
跑过——**绝不**在没有把握时猜:任何一步信息不足都转 UNKNOWN,宁可卡住等人看
|
||||
一眼,也不赌一次重复下单。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from typing import TYPE_CHECKING
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from app.shared.errors import AppError
|
||||
from app.trading.worker.models import LeaseTask
|
||||
from app.trading.worker.site_interact import parse_order_datetime
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.trading.worker.client import GatewayClient
|
||||
from app.trading.worker.site_interact import SiteInteractor
|
||||
|
||||
|
||||
class VerifyVerdict(StrEnum):
|
||||
@@ -31,16 +42,85 @@ class VerifyResult:
|
||||
detail: str = ""
|
||||
|
||||
|
||||
async def verify_on_site(task: LeaseTask) -> VerifyResult:
|
||||
def _normalize_item_url(url: str | None) -> str | None:
|
||||
"""去掉查询串与末尾斜杠,只留 scheme+host+path 用于比对
|
||||
|
||||
订单列表返回的 itemUrl 实测带 `?variantId=...`(见 _parse_order_list 的真实
|
||||
样例),intent.item_url 通常不带——按查询串比对会产生假阴性,所以只比
|
||||
路径本身。
|
||||
"""
|
||||
if not url:
|
||||
return None
|
||||
parts = urlsplit(url)
|
||||
return f"{parts.scheme}://{parts.netloc}{parts.path.rstrip('/')}"
|
||||
|
||||
|
||||
async def verify_on_site(
|
||||
task: LeaseTask, *, gateway: "GatewayClient", site: "SiteInteractor"
|
||||
) -> VerifyResult:
|
||||
"""核对一笔任务是否已在站点上下过单
|
||||
|
||||
桩实现:永远返回 UNKNOWN。**绝不返回 NOT_ORDERED**——除非真实订单列表反查
|
||||
能可靠证明这一点,否则视为「无法判断」交人工,宁可卡住等人看一眼。
|
||||
|
||||
实现方需要补的实测:用 intent 里的商品 + 时间窗口(created_at 到 stale 之间)
|
||||
比对订单列表,能拿到 site_order_id 最好。
|
||||
核对链路:intent.item_url → 查任务创建时间(GET /api/orders/{task_id},
|
||||
LeaseTask 本身不带 created_at)→ 拉「创建时间之后」的订单列表 → 按商品 URL
|
||||
比对。任何一环拿不到足够信息都返回 UNKNOWN,不猜——尤其是 NOT_ORDERED,
|
||||
只有在确认翻完了窗口内的全部订单后才允许返回,否则「没找到」可能只是没翻
|
||||
到那一页。
|
||||
"""
|
||||
intent = task.intent or {}
|
||||
item_url = intent.get("item_url")
|
||||
if not item_url:
|
||||
return VerifyResult(
|
||||
VerifyVerdict.UNKNOWN, detail="intent 缺 item_url,无法比对商品"
|
||||
)
|
||||
target = _normalize_item_url(item_url)
|
||||
|
||||
try:
|
||||
task_detail = await gateway.get_task(task.task_id)
|
||||
except AppError as exc:
|
||||
return VerifyResult(
|
||||
VerifyVerdict.UNKNOWN, detail=f"查询任务创建时间失败:{exc.message}"
|
||||
)
|
||||
|
||||
created_at_raw = task_detail.get("created_at")
|
||||
created_at = parse_order_datetime(created_at_raw) if created_at_raw else None
|
||||
if created_at is None:
|
||||
return VerifyResult(
|
||||
VerifyVerdict.UNKNOWN, detail=f"任务创建时间不可解析:{created_at_raw!r}"
|
||||
)
|
||||
|
||||
try:
|
||||
window = await site.list_recent_orders(since=created_at)
|
||||
except Exception as exc: # noqa: BLE001 — 站点交互失败一律转 unknown,不重试
|
||||
return VerifyResult(
|
||||
VerifyVerdict.UNKNOWN,
|
||||
detail=f"订单列表查询失败:{type(exc).__name__}: {exc}",
|
||||
)
|
||||
|
||||
matches = [
|
||||
entry
|
||||
for entry in window.entries
|
||||
if any(_normalize_item_url(it.item_url) == target for it in entry.items)
|
||||
]
|
||||
if len(matches) == 1:
|
||||
return VerifyResult(
|
||||
VerifyVerdict.ALREADY_ORDERED,
|
||||
site_order_id=matches[0].order_number,
|
||||
detail=f"订单列表命中 1 笔匹配商品的订单({matches[0].order_number})",
|
||||
)
|
||||
if len(matches) > 1:
|
||||
return VerifyResult(
|
||||
VerifyVerdict.UNKNOWN,
|
||||
detail=(
|
||||
f"订单列表命中 {len(matches)} 笔匹配商品的订单,"
|
||||
f"无法唯一确定:{[m.order_number for m in matches]}"
|
||||
),
|
||||
)
|
||||
if window.window_fully_covered:
|
||||
return VerifyResult(
|
||||
VerifyVerdict.NOT_ORDERED,
|
||||
detail="已核对任务创建时间之后的全部订单,未找到匹配商品",
|
||||
)
|
||||
return VerifyResult(
|
||||
verdict=VerifyVerdict.UNKNOWN,
|
||||
detail="订单列表反查未实现:见 docs/order-gateway.md §10 第 3 条",
|
||||
VerifyVerdict.UNKNOWN,
|
||||
detail="订单列表未能确认翻完任务创建时间之后的全部订单",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"""订单列表页探针:为实现 verify_on_site(规格 §5 恢复核对)取真实 DOM 结构
|
||||
|
||||
只读导航,不做任何加购/下单操作。目标:确认 order.my.rakuten.co.jp 的订单列表页
|
||||
(不带 order_number/shop_id 参数)能否按「商品名 + 时间窗口」反查出某个任务是否已
|
||||
下单——这是 app/trading/worker/verify.py::verify_on_site 目前的桩要补的实测。
|
||||
|
||||
用法:
|
||||
.venv/Scripts/python.exe scripts/probe_order_list.py
|
||||
.venv/Scripts/python.exe scripts/probe_order_list.py --headful
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from app.shared.config import get_settings # noqa: E402
|
||||
from app.trading.core import auth_site # noqa: E402
|
||||
|
||||
PROBE_DIR = Path(__file__).resolve().parent.parent / ".probe" / "order_list"
|
||||
PROBE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
ORDER_LIST_URL = "https://order.my.rakuten.co.jp/purchase-history/"
|
||||
KNOWN_ORDER_ID = "306087-20260813-0863947697"
|
||||
|
||||
|
||||
def save(name: str, content: str) -> Path:
|
||||
path = PROBE_DIR / name
|
||||
path.write_text(content, encoding="utf-8")
|
||||
print(f" saved -> {path} ({len(content)} bytes)")
|
||||
return path
|
||||
|
||||
|
||||
async def run(*, headful: bool) -> int:
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
settings = get_settings()
|
||||
state_path = settings.auth_state_path / "rakuten_state.json"
|
||||
if not state_path.exists():
|
||||
print(f"找不到登录态文件:{state_path}")
|
||||
return 1
|
||||
|
||||
async with async_playwright() as pw:
|
||||
browser = await pw.chromium.launch(
|
||||
headless=not headful,
|
||||
channel=settings.browser_channel or None,
|
||||
args=["--no-first-run", "--disable-blink-features=AutomationControlled"],
|
||||
)
|
||||
context = await browser.new_context(
|
||||
storage_state=state_path,
|
||||
user_agent=auth_site.RAKUTEN_USER_AGENT,
|
||||
locale="ja-JP",
|
||||
timezone_id="Asia/Tokyo",
|
||||
viewport={"width": 390, "height": 844},
|
||||
is_mobile=True,
|
||||
has_touch=True,
|
||||
)
|
||||
page = await context.new_page()
|
||||
|
||||
api_calls: list[tuple[int, str]] = []
|
||||
|
||||
def on_response(r):
|
||||
url = str(r.url)
|
||||
if any(kw in url for kw in ["purchase-history", "order", "history"]):
|
||||
api_calls.append((r.status, url))
|
||||
|
||||
page.on("response", on_response)
|
||||
|
||||
print("=== 访问订单列表页(不带参数)===")
|
||||
await page.goto(ORDER_LIST_URL, wait_until="domcontentloaded", timeout=30_000)
|
||||
await page.wait_for_timeout(3000)
|
||||
html = await page.content()
|
||||
save("00-list-default.html", html)
|
||||
print(f" final_url={page.url} body_len={len(html)}")
|
||||
print(f" 含已知订单号 {KNOWN_ORDER_ID}: {KNOWN_ORDER_ID in html}")
|
||||
|
||||
# 尝试常见的日期范围/分页查询参数,看服务端是否支持按时间窗口过滤
|
||||
candidate_qs = [
|
||||
"?period=3months",
|
||||
"?range=3",
|
||||
f"?order_number={KNOWN_ORDER_ID.split('-')[0]}",
|
||||
]
|
||||
for qs in candidate_qs:
|
||||
url = ORDER_LIST_URL + qs
|
||||
print(f"\n=== 尝试 {url} ===")
|
||||
try:
|
||||
resp = await page.goto(url, wait_until="domcontentloaded", timeout=20_000)
|
||||
await page.wait_for_timeout(2000)
|
||||
html2 = await page.content()
|
||||
status = resp.status if resp else None
|
||||
print(f" status={status} final_url={page.url} body_len={len(html2)}")
|
||||
safe_name = re.sub(r"[^\w]+", "_", qs) or "root"
|
||||
save(f"01{safe_name}.html", html2)
|
||||
except Exception as exc:
|
||||
print(f" 失败:{type(exc).__name__}: {exc}")
|
||||
|
||||
print("\n=== 捕获的 XHR ===")
|
||||
for status, url in api_calls[-30:]:
|
||||
print(f" {status} {url[:160]}")
|
||||
|
||||
# 在默认列表页上找商品名/日期相关的文本线索
|
||||
print("\n=== 默认列表页文本线索(商品名/日期候选片段)===")
|
||||
for pat in [
|
||||
r'"itemName"\s*:\s*"([^"]{2,60})"',
|
||||
r'"orderDate"\s*:\s*"([^"]{2,40})"',
|
||||
r'"orderNumber"\s*:\s*"([^"]{2,40})"',
|
||||
r"\d{4}[年/]\d{1,2}[月/]\d{1,2}日?",
|
||||
]:
|
||||
matches = re.findall(pat, html)
|
||||
print(f" pattern={pat!r} -> {matches[:5]}")
|
||||
|
||||
await browser.close()
|
||||
|
||||
print(f"\n探针输出目录:{PROBE_DIR}")
|
||||
return 0
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--headful", action="store_true")
|
||||
args = parser.parse_args()
|
||||
return await run(headful=args.headful)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(asyncio.run(main()))
|
||||
@@ -15,18 +15,26 @@ from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.shared.errors import CartOperationError, InvalidRequestError, OrderOperationError
|
||||
from app.trading.worker.models import LeaseTask
|
||||
from app.shared.task_state import OrderState
|
||||
from app.trading.worker.site_interact import (
|
||||
CheckoutSummary,
|
||||
OrderListEntry,
|
||||
OrderListPage,
|
||||
OrderStatusSnapshot,
|
||||
SiteInteractor,
|
||||
_accumulate_order_list_page,
|
||||
_extract_error_message,
|
||||
_extract_purchase_fields,
|
||||
_OrderListAccumulator,
|
||||
_parse_checkout_summary,
|
||||
_parse_initial_state,
|
||||
_parse_order_list,
|
||||
_parse_order_status,
|
||||
parse_order_datetime,
|
||||
)
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures"
|
||||
@@ -329,6 +337,186 @@ async def test_check_order_status_without_start_raises():
|
||||
await site.check_order_status(_REAL_ORDER_ID)
|
||||
|
||||
|
||||
# ---- _parse_order_list:verify_on_site 恢复核对用,2026-08-13 用真实账号跑
|
||||
# order.my.rakuten.co.jp/purchase-history/order-list 验证过这套 __INITIAL_STATE__
|
||||
# 结构(pageType="ph-list" 时 orderListData 直接是结构化 JSON,不需要正则抠 DOM)。
|
||||
# 下面 fixture 里的字段名/嵌套结构与真实响应一致,只是精简掉了大量与匹配逻辑
|
||||
# 无关的字段(推荐组件、通知、会员信息等)。
|
||||
|
||||
|
||||
def _real_order_list_state(
|
||||
*,
|
||||
orders_found: int = 1,
|
||||
page_size: int = 25,
|
||||
orders: list[dict] | None = None,
|
||||
page_type: str = "ph-list",
|
||||
) -> dict:
|
||||
if orders is None:
|
||||
orders = [
|
||||
{
|
||||
"orderNumber": _REAL_ORDER_ID,
|
||||
"orderDate": "2026-08-13T09:47:28.000Z",
|
||||
"shopId": 306087,
|
||||
"shopName": "BACKYARD FAMILY インテリアタウン",
|
||||
"items": [
|
||||
{
|
||||
"itemId": 10422789,
|
||||
"itemName": "ウォールフック 粘着フック",
|
||||
"itemUrl": "https://item.rakuten.co.jp/moccasin/ds001iwrgesaaa2/?variantId=ds001iwrgesaaa2-1a-2a",
|
||||
"itemPrice": 297,
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
return {
|
||||
"pageType": page_type,
|
||||
"orderListData": {
|
||||
"ordersFound": orders_found,
|
||||
"pageSize": page_size,
|
||||
"orderList": orders,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_parse_order_list_extracts_entries_from_real_shape():
|
||||
html = _wrap_state(_real_order_list_state())
|
||||
page = _parse_order_list(html)
|
||||
assert page.orders_found == 1
|
||||
assert page.page_size == 25
|
||||
assert len(page.entries) == 1
|
||||
entry = page.entries[0]
|
||||
assert entry.order_number == _REAL_ORDER_ID
|
||||
assert entry.order_date == "2026-08-13T09:47:28.000Z"
|
||||
assert entry.shop_id == 306087
|
||||
assert len(entry.items) == 1
|
||||
assert entry.items[0].item_url == (
|
||||
"https://item.rakuten.co.jp/moccasin/ds001iwrgesaaa2/?variantId=ds001iwrgesaaa2-1a-2a"
|
||||
)
|
||||
|
||||
|
||||
def test_parse_order_list_multiple_items_in_one_order():
|
||||
orders = [
|
||||
{
|
||||
"orderNumber": "111-20260101-0000000001",
|
||||
"orderDate": "2026-01-01T00:00:00.000Z",
|
||||
"items": [
|
||||
{"itemUrl": "https://item.rakuten.co.jp/shop/a/", "itemName": "A"},
|
||||
{"itemUrl": "https://item.rakuten.co.jp/shop/b/", "itemName": "B"},
|
||||
],
|
||||
}
|
||||
]
|
||||
html = _wrap_state(_real_order_list_state(orders=orders))
|
||||
page = _parse_order_list(html)
|
||||
assert len(page.entries[0].items) == 2
|
||||
|
||||
|
||||
def test_parse_order_list_wrong_page_type_returns_empty_and_no_orders_found():
|
||||
"""pageType 不是 ph-list(比如命中了 detail_page_view 的错误分支):拿不到结构化数据"""
|
||||
html = _wrap_state(_real_order_list_state(page_type="ph-error"))
|
||||
page = _parse_order_list(html)
|
||||
assert page.entries == []
|
||||
assert page.orders_found is None
|
||||
|
||||
|
||||
def test_parse_order_list_no_initial_state_returns_empty():
|
||||
page = _parse_order_list("<html>没有 state 的页面</html>")
|
||||
assert page.entries == []
|
||||
assert page.orders_found is None
|
||||
|
||||
|
||||
def test_parse_order_list_zero_orders_still_has_orders_found():
|
||||
"""账号没有任何订单:orderList 空,但 orders_found=0(不是 None)——区分「真的没有」与「解析不出」"""
|
||||
html = _wrap_state(_real_order_list_state(orders_found=0, orders=[]))
|
||||
page = _parse_order_list(html)
|
||||
assert page.entries == []
|
||||
assert page.orders_found == 0
|
||||
|
||||
|
||||
# ---- parse_order_datetime ----
|
||||
|
||||
|
||||
def test_parse_order_datetime_parses_real_format():
|
||||
dt = parse_order_datetime("2026-08-13T09:47:28.000Z")
|
||||
assert dt == datetime(2026, 8, 13, 9, 47, 28, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def test_parse_order_datetime_none_and_invalid():
|
||||
assert parse_order_datetime(None) is None
|
||||
assert parse_order_datetime("") is None
|
||||
assert parse_order_datetime("不是日期") is None
|
||||
|
||||
|
||||
# ---- _accumulate_order_list_page:list_recent_orders 翻页决策的纯函数核心,
|
||||
# 2026-08-13 只有「账号 1 笔订单、1 页」的真实数据支撑第一个分支,其余分支
|
||||
# (多页、命中窗口边界、拿不到结构化数据)目前只验证逻辑正确,未经真实多页数据跑过 ----
|
||||
|
||||
|
||||
def _page(entries: list[OrderListEntry], *, orders_found: int | None) -> OrderListPage:
|
||||
return OrderListPage(entries=entries, orders_found=orders_found, page_size=25)
|
||||
|
||||
|
||||
def _entry(order_number: str, order_date: str) -> OrderListEntry:
|
||||
return OrderListEntry(order_number=order_number, order_date=order_date, items=[])
|
||||
|
||||
|
||||
_SINCE = datetime(2026, 8, 1, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def test_accumulate_single_page_all_within_window_and_matches_orders_found():
|
||||
acc = _OrderListAccumulator()
|
||||
page = _page([_entry("o1", "2026-08-10T00:00:00Z")], orders_found=1)
|
||||
new_acc, stop = _accumulate_order_list_page(acc, page, since=_SINCE)
|
||||
assert stop is True
|
||||
assert new_acc.gave_up is False
|
||||
assert [e.order_number for e in new_acc.entries] == ["o1"]
|
||||
|
||||
|
||||
def test_accumulate_stops_when_oldest_entry_older_than_since():
|
||||
"""翻到比 since 更早的订单:窗口内数据已经看全,停止翻页且视为覆盖完整"""
|
||||
acc = _OrderListAccumulator(entries=[_entry("o1", "2026-08-10T00:00:00Z")], total_found=5, is_first_page=False)
|
||||
page = _page([_entry("o2", "2026-07-01T00:00:00Z")], orders_found=5)
|
||||
new_acc, stop = _accumulate_order_list_page(acc, page, since=_SINCE)
|
||||
assert stop is True
|
||||
assert new_acc.gave_up is False
|
||||
assert [e.order_number for e in new_acc.entries] == ["o1", "o2"]
|
||||
|
||||
|
||||
def test_accumulate_continues_when_more_pages_remain():
|
||||
"""orders_found 还没凑够、最老的一条也没早于 since:应该继续翻下一页"""
|
||||
acc = _OrderListAccumulator()
|
||||
page = _page([_entry("o1", "2026-08-10T00:00:00Z")], orders_found=3)
|
||||
new_acc, stop = _accumulate_order_list_page(acc, page, since=_SINCE)
|
||||
assert stop is False
|
||||
assert new_acc.total_found == 3
|
||||
|
||||
|
||||
def test_accumulate_empty_page_means_fully_covered():
|
||||
"""翻到空页(没有更多订单了):不是错误,视为窗口已覆盖完"""
|
||||
acc = _OrderListAccumulator(total_found=1, is_first_page=False)
|
||||
page = _page([], orders_found=1)
|
||||
new_acc, stop = _accumulate_order_list_page(acc, page, since=_SINCE)
|
||||
assert stop is True
|
||||
assert new_acc.gave_up is False
|
||||
|
||||
|
||||
def test_accumulate_first_page_missing_orders_found_gives_up():
|
||||
"""第一页就拿不到结构化数据(orders_found=None):停止翻页,但 gave_up=True,不算覆盖完"""
|
||||
acc = _OrderListAccumulator()
|
||||
page = _page([], orders_found=None)
|
||||
new_acc, stop = _accumulate_order_list_page(acc, page, since=_SINCE)
|
||||
assert stop is True
|
||||
assert new_acc.gave_up is True
|
||||
|
||||
|
||||
# ---- list_recent_orders 在没启动 Playwright 时应失败 ----
|
||||
|
||||
|
||||
async def test_list_recent_orders_without_start_raises():
|
||||
site = SiteInteractor(auth_session=None, settings=None) # type: ignore[arg-type]
|
||||
with pytest.raises((AttributeError, TypeError)):
|
||||
await site.list_recent_orders(since=_SINCE)
|
||||
|
||||
|
||||
# ---- submit_order / pay:没有 enter_checkout 留存的确认页会话时应报错,不静默成功 ----
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
"""verify.verify_on_site 单元测试
|
||||
|
||||
用桩 gateway(只需要 get_task)和桩 site(只需要 list_recent_orders)跑通全部
|
||||
分支——不涉及 Playwright/httpx,纯逻辑测试。核对链路本身(intent.item_url →
|
||||
查任务创建时间 → 拉订单列表 → 按商品 URL 比对)2026-08-13 用真实账号验证过
|
||||
数据结构(见 test_site_interact.py 里 _parse_order_list 那组测试),但当时账号
|
||||
只有 1 笔订单,这里 NOT_ORDERED / 多笔命中 / 查询失败这几个分支只覆盖逻辑本身,
|
||||
没有被真实多单数据跑过。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from app.shared.errors import AppError
|
||||
from app.trading.worker import verify
|
||||
from app.trading.worker.models import LeaseTask
|
||||
from app.trading.worker.site_interact import OrderListEntry, OrderListItem, OrderListWindow
|
||||
|
||||
|
||||
def _make_task(*, item_url: str | None = "https://item.rakuten.co.jp/shop/x/") -> LeaseTask:
|
||||
intent: dict[str, Any] = {}
|
||||
if item_url is not None:
|
||||
intent["item_url"] = item_url
|
||||
return LeaseTask(task_id="t1", site="rakuten", intent=intent, lease_count=2)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeGateway:
|
||||
created_at: str | None = "2026-08-01T00:00:00Z"
|
||||
fail_with: Exception | None = None
|
||||
calls: list[str] = field(default_factory=list)
|
||||
|
||||
async def get_task(self, task_id: str) -> dict[str, Any]:
|
||||
self.calls.append(task_id)
|
||||
if self.fail_with is not None:
|
||||
raise self.fail_with
|
||||
return {"task_id": task_id, "created_at": self.created_at}
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeSite:
|
||||
window: OrderListWindow | None = None
|
||||
fail_with: Exception | None = None
|
||||
received_since: datetime | None = None
|
||||
|
||||
async def list_recent_orders(self, *, since: datetime) -> OrderListWindow:
|
||||
self.received_since = since
|
||||
if self.fail_with is not None:
|
||||
raise self.fail_with
|
||||
assert self.window is not None
|
||||
return self.window
|
||||
|
||||
|
||||
def _entry(order_number: str, item_url: str) -> OrderListEntry:
|
||||
return OrderListEntry(
|
||||
order_number=order_number,
|
||||
order_date="2026-08-10T00:00:00Z",
|
||||
items=[OrderListItem(item_url=item_url)],
|
||||
)
|
||||
|
||||
|
||||
# ---- intent 缺 item_url:不查任何东西,直接 unknown ----
|
||||
|
||||
|
||||
async def test_missing_item_url_returns_unknown_without_calling_gateway():
|
||||
gateway = FakeGateway()
|
||||
site = FakeSite()
|
||||
result = await verify.verify_on_site(_make_task(item_url=None), gateway=gateway, site=site)
|
||||
assert result.verdict == verify.VerifyVerdict.UNKNOWN
|
||||
assert gateway.calls == []
|
||||
|
||||
|
||||
# ---- 查任务创建时间失败/不可解析 ----
|
||||
|
||||
|
||||
async def test_get_task_failure_returns_unknown():
|
||||
gateway = FakeGateway(fail_with=AppError(message="网关挂了", code="X", err_code=9999))
|
||||
site = FakeSite()
|
||||
result = await verify.verify_on_site(_make_task(), gateway=gateway, site=site)
|
||||
assert result.verdict == verify.VerifyVerdict.UNKNOWN
|
||||
assert "网关挂了" in result.detail
|
||||
|
||||
|
||||
async def test_unparseable_created_at_returns_unknown():
|
||||
gateway = FakeGateway(created_at="不是日期")
|
||||
site = FakeSite()
|
||||
result = await verify.verify_on_site(_make_task(), gateway=gateway, site=site)
|
||||
assert result.verdict == verify.VerifyVerdict.UNKNOWN
|
||||
|
||||
|
||||
# ---- 站点订单列表查询失败 ----
|
||||
|
||||
|
||||
async def test_site_list_failure_returns_unknown():
|
||||
gateway = FakeGateway()
|
||||
site = FakeSite(fail_with=RuntimeError("Playwright 挂了"))
|
||||
result = await verify.verify_on_site(_make_task(), gateway=gateway, site=site)
|
||||
assert result.verdict == verify.VerifyVerdict.UNKNOWN
|
||||
assert "Playwright 挂了" in result.detail
|
||||
|
||||
|
||||
# ---- 命中 1 笔:已下单(2026-08-13 真实账号验证过的分支)----
|
||||
|
||||
|
||||
async def test_exact_one_match_returns_already_ordered():
|
||||
gateway = FakeGateway()
|
||||
site = FakeSite(
|
||||
window=OrderListWindow(
|
||||
entries=[_entry("306087-20260813-0863947697", "https://item.rakuten.co.jp/shop/x/")],
|
||||
window_fully_covered=True,
|
||||
)
|
||||
)
|
||||
result = await verify.verify_on_site(_make_task(), gateway=gateway, site=site)
|
||||
assert result.verdict == verify.VerifyVerdict.ALREADY_ORDERED
|
||||
assert result.site_order_id == "306087-20260813-0863947697"
|
||||
|
||||
|
||||
async def test_match_ignores_query_string_difference():
|
||||
"""订单列表的 itemUrl 带 ?variantId=...,intent.item_url 通常不带——只比路径"""
|
||||
gateway = FakeGateway()
|
||||
site = FakeSite(
|
||||
window=OrderListWindow(
|
||||
entries=[_entry("o1", "https://item.rakuten.co.jp/shop/x/?variantId=abc-1")],
|
||||
window_fully_covered=True,
|
||||
)
|
||||
)
|
||||
result = await verify.verify_on_site(
|
||||
_make_task(item_url="https://item.rakuten.co.jp/shop/x/"), gateway=gateway, site=site
|
||||
)
|
||||
assert result.verdict == verify.VerifyVerdict.ALREADY_ORDERED
|
||||
|
||||
|
||||
# ---- 命中 0 笔且窗口确认覆盖完:未下单 ----
|
||||
|
||||
|
||||
async def test_no_match_and_fully_covered_returns_not_ordered():
|
||||
gateway = FakeGateway()
|
||||
site = FakeSite(
|
||||
window=OrderListWindow(
|
||||
entries=[_entry("o1", "https://item.rakuten.co.jp/other-shop/y/")],
|
||||
window_fully_covered=True,
|
||||
)
|
||||
)
|
||||
result = await verify.verify_on_site(_make_task(), gateway=gateway, site=site)
|
||||
assert result.verdict == verify.VerifyVerdict.NOT_ORDERED
|
||||
|
||||
|
||||
# ---- 命中 0 笔但窗口没确认覆盖完:不能猜没下单 ----
|
||||
|
||||
|
||||
async def test_no_match_and_not_fully_covered_returns_unknown():
|
||||
gateway = FakeGateway()
|
||||
site = FakeSite(window=OrderListWindow(entries=[], window_fully_covered=False))
|
||||
result = await verify.verify_on_site(_make_task(), gateway=gateway, site=site)
|
||||
assert result.verdict == verify.VerifyVerdict.UNKNOWN
|
||||
|
||||
|
||||
# ---- 命中多笔:无法唯一确定 ----
|
||||
|
||||
|
||||
async def test_multiple_matches_returns_unknown():
|
||||
gateway = FakeGateway()
|
||||
site = FakeSite(
|
||||
window=OrderListWindow(
|
||||
entries=[
|
||||
_entry("o1", "https://item.rakuten.co.jp/shop/x/"),
|
||||
_entry("o2", "https://item.rakuten.co.jp/shop/x/"),
|
||||
],
|
||||
window_fully_covered=True,
|
||||
)
|
||||
)
|
||||
result = await verify.verify_on_site(_make_task(), gateway=gateway, site=site)
|
||||
assert result.verdict == verify.VerifyVerdict.UNKNOWN
|
||||
assert "o1" in result.detail and "o2" in result.detail
|
||||
|
||||
|
||||
# ---- since 正确传递给 site.list_recent_orders(用任务创建时间,不是别的)----
|
||||
|
||||
|
||||
async def test_since_passed_to_site_matches_task_created_at():
|
||||
gateway = FakeGateway(created_at="2026-08-05T12:00:00Z")
|
||||
site = FakeSite(window=OrderListWindow(entries=[], window_fully_covered=True))
|
||||
await verify.verify_on_site(_make_task(), gateway=gateway, site=site)
|
||||
assert site.received_since == datetime(2026, 8, 5, 12, 0, 0, tzinfo=timezone.utc)
|
||||
@@ -40,11 +40,18 @@ class FakeGateway:
|
||||
renews: int = 0
|
||||
fail_report_with: Exception | None = None
|
||||
|
||||
# verify_on_site 恢复核对会调 get_task 拿 created_at;测试默认给一个可解析
|
||||
# 的时间戳,具体核对分支由 monkeypatch 替换 verify.verify_on_site 控制
|
||||
task_created_at: str = "2026-01-01T00:00:00Z"
|
||||
|
||||
async def lease(
|
||||
self, worker_id: str, *, wait: int = 30, site: str | None = None
|
||||
) -> LeaseTask | None:
|
||||
return None # 主循环测试不通过 lease 喂数据,直接调 handle
|
||||
|
||||
async def get_task(self, task_id: str) -> dict[str, Any]:
|
||||
return {"task_id": task_id, "created_at": self.task_created_at}
|
||||
|
||||
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"}
|
||||
@@ -193,15 +200,17 @@ async def test_already_finished_task_is_reported_not_re_executed(
|
||||
async def test_recovery_task_with_unknown_verdict_reports_needs_human(
|
||||
runner: WorkerRunner, local_db: LocalDB, monkeypatch
|
||||
):
|
||||
"""lease_count > 1 + verify 桩返回 unknown → 转 needs_human,不执行"""
|
||||
"""lease_count > 1 + verify 返回 unknown(intent 缺 item_url)→ 转 needs_human,不执行"""
|
||||
|
||||
# verify 桩默认返回 unknown,不用 monkeypatch
|
||||
# verify.verify_on_site 已是真实实现(2026-08-13),但 _make_task 默认 intent 为空,
|
||||
# 缺 item_url 时函数第一步就短路返回 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"]
|
||||
assert "unknown" in terminal["detail"]
|
||||
assert "item_url" in terminal["detail"]
|
||||
|
||||
|
||||
async def test_recovery_task_with_already_ordered_verdict_reports_succeeded(
|
||||
@@ -209,7 +218,7 @@ async def test_recovery_task_with_already_ordered_verdict_reports_succeeded(
|
||||
):
|
||||
"""lease_count > 1 + verify 返回 already_ordered → 补报 succeeded,不重新下单"""
|
||||
|
||||
async def _already_ordered(task): # noqa: ANN001
|
||||
async def _already_ordered(task, **kwargs): # noqa: ANN001
|
||||
return verify.VerifyResult(
|
||||
verdict=verify.VerifyVerdict.ALREADY_ORDERED,
|
||||
site_order_id="ord-1",
|
||||
@@ -226,8 +235,34 @@ async def test_recovery_task_with_already_ordered_verdict_reports_succeeded(
|
||||
assert terminal["site_order_id"] == "ord-1"
|
||||
assert terminal["terminal_status"] == TaskStatus.SUCCEEDED
|
||||
|
||||
# 本地 DB 也应当被标记为已完成
|
||||
assert await local_db.has_finished("t1") is True
|
||||
|
||||
async def test_recovery_task_with_not_ordered_verdict_still_reports_needs_human(
|
||||
runner: WorkerRunner, local_db: LocalDB, monkeypatch
|
||||
):
|
||||
"""lease_count > 1 + verify 确认 not_ordered → 仍转 needs_human,不自动重新下单
|
||||
|
||||
刻意的保守选择(见 runner._handle_recovery 里的注释):NOT_ORDERED 分支目前
|
||||
只有逻辑正确性,没有被真实多单数据验证过,放开自动重新执行前必须显式决定。
|
||||
"""
|
||||
|
||||
async def _not_ordered(task, **kwargs): # noqa: ANN001
|
||||
return verify.VerifyResult(
|
||||
verdict=verify.VerifyVerdict.NOT_ORDERED,
|
||||
detail="已核对任务创建时间之后的全部订单,未找到匹配商品",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(verify, "verify_on_site", _not_ordered)
|
||||
|
||||
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 "not_ordered" in terminal["detail"]
|
||||
|
||||
# needs_human 分支不标记本地完成(与 UNKNOWN 分支一致):只有 ALREADY_ORDERED
|
||||
# 才 mark_finished,避免把「转人工」误当成本地已完成的终态
|
||||
assert await local_db.has_finished("t1") is False
|
||||
|
||||
|
||||
# ---- 站点交互未实现 → needs_human(§10 接口缝)----
|
||||
|
||||
Reference in New Issue
Block a user