实现租约恢复核对: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 留存的确认页会话时应报错,不静默成功 ----