实现租约恢复核对: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:
2026-08-13 23:58:54 +08:00
parent 51e2538438
commit 93c1a83406
8 changed files with 868 additions and 22 deletions
+188
View File
@@ -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 留存的确认页会话时应报错,不静默成功 ----
+188
View File
@@ -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)
+41 -6
View File
@@ -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 接口缝)----