实现付款后订单监控:真实探测 order.my.rakuten.co.jp 配送阶段
用真实订单号 306087-20260813-0863947697 探测 order.my.rakuten.co.jp(订单列表/ 详情页),拿到真实 DOM 结构后实现 SiteInteractor.check_order_status: - 详情页 URL 可直接从 site_order_id 构造(shop_id 是订单号第一段) - 配送阶段用「进度条」组件的 4 个固定阶段(ショップ/出荷/配達店/配達完了), 当前阶段的 class 带 -active-- 中缀,映射到 OrderState.SHIPPED/DELIVERED - 查不到订单号、进度条解析不出新阶段都不算错误,交给轮询循环继续重试 runner.WorkerRunner 新增 _monitor_order 后台轮询:付款成功上报后以 asyncio.create_task 起后台任务(不阻塞主循环领下一单,因为 SiteInteractor 的 Playwright 操作全程持锁串行化),状态变化时用 terminal=False 追加 report; 新增 cancel_monitors() 在服务关闭时于 SiteInteractor.close() 之前收尾。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,13 +17,16 @@ import pytest
|
||||
|
||||
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,
|
||||
OrderStatusSnapshot,
|
||||
SiteInteractor,
|
||||
_extract_error_message,
|
||||
_extract_purchase_fields,
|
||||
_parse_checkout_summary,
|
||||
_parse_initial_state,
|
||||
_parse_order_status,
|
||||
)
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures"
|
||||
@@ -250,13 +253,80 @@ def test_site_interactor_construction_does_not_require_playwright():
|
||||
assert site._per_task_state == {}
|
||||
|
||||
|
||||
# ---- monitor 仍未实现,抛 NotImplementedError ----
|
||||
# ---- _parse_order_status:付款后监控的解析核心,2026-08-13 用真实订单号
|
||||
# 306087-20260813-0863947697 的详情页 HTML 验证过(见 _ORDER_STEPPER_ITEM_PATTERN
|
||||
# 上方注释)。下面 fixture 里的 4 个 <li> 是从真实页面摘录的进度条结构原样保留,
|
||||
# 只是把每个 class 里易变的 CSS modules hash 换成了固定占位,不影响所依赖的
|
||||
# `-active--` 中缀。
|
||||
|
||||
_REAL_ORDER_ID = "306087-20260813-0863947697"
|
||||
|
||||
|
||||
async def test_monitor_unimplemented():
|
||||
def _stepper_html(*, active_stage: str | None, order_id: str = _REAL_ORDER_ID) -> str:
|
||||
"""构造「订单号 + 4 阶段进度条」的最小 fixture,active_stage 指定哪一阶带 -active-- class"""
|
||||
stages = ["ショップ", "出荷", "配達店", "配達完了"]
|
||||
items = []
|
||||
for i, stage in enumerate(stages):
|
||||
active_cls = " item-shipping-active--1mu0i" if stage == active_stage else ""
|
||||
items.append(
|
||||
f'<li class="item--3gWCU item-{i}--hash title-m--3FZT3{active_cls}">'
|
||||
f'<div class="title--2uGVi">{stage}</div></li>'
|
||||
)
|
||||
return f"<div>注文番号:{order_id}</div><ul>{''.join(items)}</ul>"
|
||||
|
||||
|
||||
def test_parse_order_status_not_found_when_order_id_missing():
|
||||
snapshot = _parse_order_status(_stepper_html(active_stage="ショップ", order_id="999999-x"), _REAL_ORDER_ID)
|
||||
assert snapshot.found is False
|
||||
assert snapshot.order_state is None
|
||||
|
||||
|
||||
def test_parse_order_status_shop_stage_has_no_order_state_mapping():
|
||||
"""「ショップ」(已接单未发货)阶段没有对应的 OrderState——已经在 ORDERED/PAID 报过了"""
|
||||
snapshot = _parse_order_status(_stepper_html(active_stage="ショップ"), _REAL_ORDER_ID)
|
||||
assert snapshot.found is True
|
||||
assert snapshot.stage_label == "ショップ"
|
||||
assert snapshot.order_state is None
|
||||
|
||||
|
||||
def test_parse_order_status_shipped_stage_maps_to_shipped():
|
||||
snapshot = _parse_order_status(_stepper_html(active_stage="出荷"), _REAL_ORDER_ID)
|
||||
assert snapshot.order_state == OrderState.SHIPPED
|
||||
|
||||
|
||||
def test_parse_order_status_depot_stage_also_maps_to_shipped():
|
||||
"""「配達店」(配送网点中转)没有单独状态,归入 SHIPPED"""
|
||||
snapshot = _parse_order_status(_stepper_html(active_stage="配達店"), _REAL_ORDER_ID)
|
||||
assert snapshot.order_state == OrderState.SHIPPED
|
||||
|
||||
|
||||
def test_parse_order_status_delivered_stage_maps_to_delivered():
|
||||
snapshot = _parse_order_status(_stepper_html(active_stage="配達完了"), _REAL_ORDER_ID)
|
||||
assert snapshot.order_state == OrderState.DELIVERED
|
||||
|
||||
|
||||
def test_parse_order_status_no_active_marker_returns_found_without_state():
|
||||
"""进度条 4 项都没有 -active-- class(页面结构变了/解析不出):found 但 order_state=None"""
|
||||
snapshot = _parse_order_status(_stepper_html(active_stage=None), _REAL_ORDER_ID)
|
||||
assert snapshot.found is True
|
||||
assert snapshot.order_state is None
|
||||
assert snapshot.stage_label is None
|
||||
|
||||
|
||||
def test_order_status_snapshot_defaults():
|
||||
s = OrderStatusSnapshot(found=False)
|
||||
assert s.stage_label is None
|
||||
assert s.order_state is None
|
||||
assert s.html == ""
|
||||
|
||||
|
||||
# ---- check_order_status 在没启动 Playwright 时应失败 ----
|
||||
|
||||
|
||||
async def test_check_order_status_without_start_raises():
|
||||
site = SiteInteractor(auth_session=None, settings=None) # type: ignore[arg-type]
|
||||
with pytest.raises(NotImplementedError):
|
||||
await site.monitor(_make_task(), "ord-1")
|
||||
with pytest.raises((AttributeError, TypeError)):
|
||||
await site.check_order_status(_REAL_ORDER_ID)
|
||||
|
||||
|
||||
# ---- submit_order / pay:没有 enter_checkout 留存的确认页会话时应报错,不静默成功 ----
|
||||
|
||||
+137
-2
@@ -12,6 +12,7 @@ evidence store / site,覆盖 §6 主循环的分支:
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -25,7 +26,7 @@ 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
|
||||
from app.trading.worker.site_interact import CheckoutSummary, SiteInteractor
|
||||
from app.trading.worker.site_interact import CheckoutSummary, OrderStatusSnapshot, SiteInteractor
|
||||
|
||||
|
||||
# ---- 桩:网关客户端 ----
|
||||
@@ -123,6 +124,10 @@ def runner(tmp_path, local_db, evidence, site) -> WorkerRunner:
|
||||
class _FakeSettings:
|
||||
worker_id = "test"
|
||||
order_max_total_yen = 30000
|
||||
# 测试环境不真的等 3 小时:间隔设 0,次数设小,_monitor_order 的循环
|
||||
# 靠 asyncio.sleep(0) 立刻推进,不拖慢测试
|
||||
order_monitor_poll_interval_seconds = 0
|
||||
order_monitor_max_checks = 5
|
||||
|
||||
@property
|
||||
def worker_id_effective(self) -> str:
|
||||
@@ -234,7 +239,7 @@ async def test_unimplemented_site_interaction_becomes_needs_human(
|
||||
"""站点交互抛 NotImplementedError → runner 转 needs_human
|
||||
|
||||
add_to_cart / verify_cart / enter_checkout / parse_checkout / submit_order /
|
||||
pay 现在均已实现(monitor 仍未实现),这里用桩显式模拟「某一步没实现」,
|
||||
pay / check_order_status 现在均已实现,这里用桩显式模拟「某一步没实现」,
|
||||
覆盖 runner._execute_with_renewal 里 NotImplementedError → needs_human 的分支,
|
||||
与具体哪个方法真的未实现解耦。
|
||||
"""
|
||||
@@ -432,3 +437,133 @@ async def test_evidence_files_exist_before_each_report(
|
||||
# 至少有一个 step 调了 report,且每次 report 之前证据都在
|
||||
assert seen_evidence_at_report, "应当至少有一次带 evidence_ref 的 report"
|
||||
assert all(seen_evidence_at_report), "某次 report 之前证据文件未落盘"
|
||||
|
||||
|
||||
# ---- 付款后监控(规格 §6「付款后监控」)----
|
||||
|
||||
|
||||
async def test_successful_order_spawns_monitor_task_and_cancel_monitors_cleans_up(
|
||||
runner: WorkerRunner, local_db: LocalDB
|
||||
):
|
||||
"""付款成功后 execute() 应起一个后台监控任务、不阻塞返回;cancel_monitors 能干净收尾
|
||||
|
||||
check_order_status 桩故意永不返回(挂在一个手动控制的 Event 上),模拟「监控任务
|
||||
还在跑」这个可观察状态——用来确认 execute() 没有同步 await 监控循环
|
||||
(不然 handle() 早该被这个永不返回的调用卡死,测试会超时而不是正常结束)。
|
||||
"""
|
||||
|
||||
async def _noop(task): # noqa: ANN001
|
||||
return None
|
||||
|
||||
async def _checkout_html(task): # noqa: ANN001
|
||||
return "<html>checkout</html>"
|
||||
|
||||
async def _parse(html: str):
|
||||
return CheckoutSummary(payable_yen=297)
|
||||
|
||||
async def _submit(task): # noqa: ANN001
|
||||
return "306087-20260813-0863947697"
|
||||
|
||||
async def _pay(task, site_order_id): # noqa: ANN001
|
||||
return None
|
||||
|
||||
never_resolves = asyncio.Event()
|
||||
|
||||
async def _hang_check(order_id: str): # noqa: ANN001
|
||||
await never_resolves.wait()
|
||||
|
||||
runner._site.add_to_cart = _noop # type: ignore[assignment]
|
||||
runner._site.verify_cart = _noop # type: ignore[assignment]
|
||||
runner._site.enter_checkout = _checkout_html # type: ignore[assignment]
|
||||
runner._site.parse_checkout = _parse # type: ignore[assignment]
|
||||
runner._site.submit_order = _submit # type: ignore[assignment]
|
||||
runner._site.pay = _pay # type: ignore[assignment]
|
||||
runner._site.check_order_status = _hang_check # type: ignore[assignment]
|
||||
|
||||
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_status"] == TaskStatus.SUCCEEDED
|
||||
|
||||
assert len(runner._monitor_tasks) == 1
|
||||
|
||||
await runner.cancel_monitors()
|
||||
assert len(runner._monitor_tasks) == 0
|
||||
|
||||
|
||||
async def test_monitor_order_reports_state_changes_and_stops_at_delivered(
|
||||
runner: WorkerRunner, evidence: EvidenceStore
|
||||
):
|
||||
"""状态没变化不重复 report;到「配達完了」立刻停止轮询,不再多探测一次"""
|
||||
snapshots = [
|
||||
OrderStatusSnapshot(found=False),
|
||||
OrderStatusSnapshot(found=True, stage_label="ショップ", order_state=None, html="<html>shop</html>"),
|
||||
OrderStatusSnapshot(
|
||||
found=True, stage_label="出荷", order_state=OrderState.SHIPPED, html="<html>shipped</html>"
|
||||
),
|
||||
OrderStatusSnapshot(
|
||||
found=True, stage_label="配達完了", order_state=OrderState.DELIVERED, html="<html>delivered</html>"
|
||||
),
|
||||
]
|
||||
calls: list[OrderStatusSnapshot] = []
|
||||
|
||||
async def _check(order_id: str): # noqa: ANN001
|
||||
calls.append(snapshots[len(calls)])
|
||||
return calls[-1]
|
||||
|
||||
runner._site.check_order_status = _check # type: ignore[assignment]
|
||||
|
||||
await runner._monitor_order(_make_task(task_id="t1"), "306087-20260813-0863947697")
|
||||
|
||||
assert len(calls) == 4 # 配達完了那次之后立刻返回,不会再多轮询一次
|
||||
|
||||
gateway: FakeGateway = runner._gateway_for_test # type: ignore[attr-defined]
|
||||
reported_states = [r["state"] for r in gateway.reports]
|
||||
assert reported_states == [OrderState.SHIPPED, OrderState.DELIVERED]
|
||||
assert all(r["terminal"] is False for r in gateway.reports) # 追加上报,不重新终结任务
|
||||
|
||||
base = evidence.step_dir("t1")
|
||||
assert (base / "06-monitor-shipped.html").exists()
|
||||
assert (base / "07-monitor-delivered.html").exists()
|
||||
|
||||
|
||||
async def test_monitor_order_stops_after_max_checks_without_finding_order(
|
||||
runner: WorkerRunner,
|
||||
):
|
||||
"""一直查不到订单(found=False):轮询到上限后正常退出,不报错、不 report"""
|
||||
|
||||
async def _check(order_id: str): # noqa: ANN001
|
||||
return OrderStatusSnapshot(found=False)
|
||||
|
||||
runner._site.check_order_status = _check # type: ignore[assignment]
|
||||
|
||||
await runner._monitor_order(_make_task(task_id="t1"), "ord-1")
|
||||
|
||||
gateway: FakeGateway = runner._gateway_for_test # type: ignore[attr-defined]
|
||||
assert gateway.reports == []
|
||||
|
||||
|
||||
async def test_monitor_order_swallows_check_errors_and_keeps_polling(
|
||||
runner: WorkerRunner,
|
||||
):
|
||||
"""单次探测异常(如登录态失效)只记日志继续重试,不会让后台任务崩掉"""
|
||||
call_count = 0
|
||||
|
||||
async def _check(order_id: str): # noqa: ANN001
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count <= 2:
|
||||
raise RuntimeError("模拟登录态失效")
|
||||
return OrderStatusSnapshot(
|
||||
found=True, stage_label="配達完了", order_state=OrderState.DELIVERED, html="<html>ok</html>"
|
||||
)
|
||||
|
||||
runner._site.check_order_status = _check # type: ignore[assignment]
|
||||
|
||||
await runner._monitor_order(_make_task(task_id="t1"), "ord-1")
|
||||
|
||||
assert call_count == 3 # 前两次异常被吞掉继续重试,第三次成功拿到 DELIVERED 后停止
|
||||
gateway: FakeGateway = runner._gateway_for_test # type: ignore[attr-defined]
|
||||
assert [r["state"] for r in gateway.reports] == [OrderState.DELIVERED]
|
||||
|
||||
Reference in New Issue
Block a user