结算流程用真实下单证据修正:新卡代填 iframe vault、订单号正则、登录态判据

- 新卡代填改用真实 DOM 结构:卡号/有效期分别托管在 Rakuten PCI 代付 vault 的
  跨域 iframe 里,此前按 autocomplete/name 猜的 selector 在主文档里根本找不到
  元素;持卡人姓名字段改用「名義人」标签相对定位,不用 placeholder 示例文案
  (TARO RAKUTEN 只是示例用户名,不是稳定标识)
- 订单号正则修正   实体导致的分隔符匹配失败(2026-08-13 真实下单验证)
- login_runner._is_logged_in 改用与 AuthSession 一致的 __INITIAL_STATE__ 判据,
  修掉此前误判「已登录」导致保存无效 storage_state 的问题
- 新增 CheckoutBlockedError:站点风控拦截(session upgrade/3DS)转 needs_human,
  不当普通失败重试
- account.yaml 支持 phone / payment.credit-card 字段
- data/evidence/(真实 PII)加入 .gitignore

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 22:36:49 +08:00
co-authored by Claude Sonnet 5
parent 55c01ae4f5
commit 5f7921b788
8 changed files with 1049 additions and 70 deletions
+106 -21
View File
@@ -15,13 +15,14 @@ from typing import Any
import pytest
from app.shared.errors import CartOperationError, InvalidRequestError
from app.shared.errors import CartOperationError, InvalidRequestError, OrderOperationError
from app.trading.worker.models import LeaseTask
from app.trading.worker.site_interact import (
CheckoutSummary,
SiteInteractor,
_extract_error_message,
_extract_purchase_fields,
_parse_checkout_summary,
_parse_initial_state,
)
@@ -249,32 +250,116 @@ def test_site_interactor_construction_does_not_require_playwright():
assert site._per_task_state == {}
# ---- 未实现的方法仍然抛 NotImplementedError ----
# ---- monitor 仍未实现,抛 NotImplementedError ----
@pytest.mark.parametrize(
"method,args",
[
("enter_checkout", (_make_task(),)),
("submit_order", (_make_task(),)),
("pay", (_make_task(), "ord-1")),
("monitor", (_make_task(), "ord-1")),
],
)
async def test_unimplemented_methods_raise(method: str, args: tuple):
site = SiteInteractor(auth_session=None, settings=None) # type: ignore[arg-type]
fn = getattr(site, method)
with pytest.raises(NotImplementedError):
await fn(*args)
async def test_parse_checkout_unimplemented():
async def test_monitor_unimplemented():
site = SiteInteractor(auth_session=None, settings=None) # type: ignore[arg-type]
with pytest.raises(NotImplementedError):
await site.parse_checkout("<html></html>")
await site.monitor(_make_task(), "ord-1")
# ---- add_to_cart / verify_cart 在没启动 Playwright 时应失败 ----
# ---- submit_order / pay:没有 enter_checkout 留存的确认页会话时应报错,不静默成功 ----
async def test_submit_order_without_checkout_page_raises():
site = SiteInteractor(auth_session=None, settings=None) # type: ignore[arg-type]
with pytest.raises(OrderOperationError):
await site.submit_order(_make_task())
async def test_pay_without_checkout_page_raises():
site = SiteInteractor(auth_session=None, settings=None) # type: ignore[arg-type]
with pytest.raises(OrderOperationError):
await site.pay(_make_task(), "ord-1")
# ---- parse_checkout / _parse_checkout_summary:金额解析已用真实确认页 HTML 验证
# (2026-08-13,data/evidence/checkout-research-20260811/i-final-state.html),
# 订单号/付款期限仍未验证。下面简单 fixture 测的是防御性行为(找不到/矛盾就报错)。
async def test_parse_checkout_delegates_to_pure_function():
site = SiteInteractor(auth_session=None, settings=None) # type: ignore[arg-type]
html = "<div>お支払い金額 1,980円</div>"
summary = await site.parse_checkout(html)
assert summary.payable_yen == 1980
def test_parse_checkout_summary_extracts_amount_order_id_deadline():
"""订单号 fixture 用真实格式(三段数字用「-」连接,见 _ORDER_ID_PATTERN 注释),
2026-08-13 之前这里用的是纯猜测的 "AB-123456" 字母前缀格式,拿到真实订单号后
已确认不是这个形状。"""
html = (
"<div>お支払い合計 12,345円</div>"
"<div>ご注文番号 306087-20260813-0863947697</div>"
"<div>お支払い期限 2026/08/20</div>"
)
summary = _parse_checkout_summary(html)
assert summary.payable_yen == 12345
assert summary.site_order_id == "306087-20260813-0863947697"
assert summary.pay_deadline == "2026/08/20"
def test_parse_checkout_summary_missing_amount_raises():
"""找不到「标签+金额」的明确匹配就报错,不落回猜数字——这个值直接喂给金额守卫"""
with pytest.raises(OrderOperationError):
_parse_checkout_summary("<div>合計 1,980円</div>") # 标签不在候选列表里
def test_parse_checkout_summary_conflicting_amounts_raises():
html = "<div>お支払い金額 1,000円</div><div>お支払い合計 2,000円</div>"
with pytest.raises(OrderOperationError):
_parse_checkout_summary(html)
def test_parse_checkout_summary_optional_fields_default_none():
summary = _parse_checkout_summary("<div>お支払い金額 500円</div>")
assert summary.payable_yen == 500
assert summary.site_order_id is None
assert summary.pay_deadline is None
def test_parse_checkout_summary_matches_real_page_dom_shape():
"""真实确认页金额结构:标签纯「支払い金額」(无「お」前缀),数字与「円」
分别在独立标签里、中间隔着大段 class 属性,不是「标签+数字+円」紧邻文案
(2026-08-13 从真实页面简化摘录的结构,见 _AMOUNT_TAG_BOUNDED_PATTERN 注释)"""
html = (
'<span class="label--x">支払い金額</span></div>'
'<div class="number-display--x">'
'<div class="value--x">297</div>'
'<span class="suffix--x"><div class="text-display--x">円</div></span>'
"</div>"
)
summary = _parse_checkout_summary(html)
assert summary.payable_yen == 297
def test_parse_checkout_summary_matches_real_order_id_dom_shape():
"""真实下单完成页订单号结构:标签和号码之间是 `&nbsp;` 实体而不是普通空白,
号码是三段数字用「-」连接(2026-08-13 真实点击「注文を確定する」后从
完成页摘录的结构,见 _ORDER_ID_PATTERN 注释)。旧版正则的分隔符只认
非单词字符,会卡在 `&nbsp;` 里的 n/b/s/p 上、完全连不到号码。"""
html = (
'<div>お支払い金額 297円</div>'
'<span class="label--x">注文番号&nbsp;306087-20260813-0863947697</span>'
)
summary = _parse_checkout_summary(html)
assert summary.site_order_id == "306087-20260813-0863947697"
# ---- enter_checkout / add_to_cart / verify_cart 在没启动 Playwright 时应失败 ----
async def test_enter_checkout_without_start_raises():
"""没调 start() 就调 enter_checkout,auth_session=None,应在 require_logged_in 处抛错
与 add_to_cart 同理:测的是「未启动时不应该静默成功」,不是 CheckoutBlockedError
这条业务分支(那条分支需要真实 Playwright page,不在离线单测范围)。
"""
site = SiteInteractor(auth_session=None, settings=None) # type: ignore[arg-type]
with pytest.raises((AttributeError, TypeError)):
await site.enter_checkout(_make_task())
async def test_add_to_cart_without_start_raises():