结算流程用真实下单证据修正:新卡代填 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():
+54 -7
View File
@@ -18,7 +18,7 @@ from typing import Any
import pytest
from app.shared.errors import OrderGuardError
from app.shared.errors import CheckoutBlockedError, OrderGuardError
from app.shared.task_state import OrderState, TaskStatus
from app.trading.worker import verify
from app.trading.worker.evidence import EvidenceStore
@@ -231,18 +231,27 @@ async def test_recovery_task_with_already_ordered_verdict_reports_succeeded(
async def test_unimplemented_site_interaction_becomes_needs_human(
runner: WorkerRunner, local_db: LocalDB
):
"""SiteInteractor 默认 enter_checkout 抛 NotImplementedError → runner 转 needs_human
"""站点交互抛 NotImplementedError → runner 转 needs_human
add_to_cart verify_cart 已实现(但站点未启动时会从 Playwright 调用失败,
测试里先替换为 noop 让流程跑到 enter_checkout)。
add_to_cart / verify_cart / enter_checkout / parse_checkout / submit_order /
pay 现在均已实现(monitor 仍未实现),这里用桩显式模拟「某一步没实现」,
覆盖 runner._execute_with_renewal 里 NotImplementedError → needs_human 的分支,
与具体哪个方法真的未实现解耦。
"""
# 让已实现的两步 noop,触发未实现的 enter_checkout
async def _noop(task): # noqa: ANN001
return None
async def _checkout_html(task): # noqa: ANN001
return "<html>checkout</html>"
async def _unimplemented(html): # noqa: ANN001
raise NotImplementedError("模拟:假装 parse_checkout 还没实现")
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 = _unimplemented # type: ignore[assignment]
await runner.handle(_make_task(task_id="t1"))
@@ -252,6 +261,36 @@ async def test_unimplemented_site_interaction_becomes_needs_human(
assert "未实现" in terminal["detail"]
# ---- 结算被站点风控拦截 → needs_human(规格 §10.1)----
async def test_checkout_blocked_becomes_needs_human(
runner: WorkerRunner, local_db: LocalDB
):
"""enter_checkout 抛 CheckoutBlockedError(session upgrade 被拦截)→ 转 needs_human
与普通 AppError(转 FAILED)区分开:这类阻断是站点主动要求人工验证,
不是本服务的失败,需要人工用有头浏览器接管当前登录态完成验证。
"""
async def _noop(task): # noqa: ANN001
return None
async def _blocked(task): # noqa: ANN001
raise CheckoutBlockedError("session upgrade 提交密码后 30s 内 URL 未变化,判定被站点风控拦截")
runner._site.add_to_cart = _noop # type: ignore[assignment]
runner._site.verify_cart = _noop # type: ignore[assignment]
runner._site.enter_checkout = _blocked # 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["terminal_status"] == TaskStatus.NEEDS_HUMAN
assert "风控" in terminal["detail"]
# ---- ラクマ 不在交易范围 → needs_human ----
@@ -372,13 +411,21 @@ async def test_evidence_files_exist_before_each_report(
runner._gateway_for_test.report = _spy_report # type: ignore[attr-defined]
# 让 add_to_cart / verify_cart 正常,enter_checkout 抛 NotImplemented,
# 触发前步写证据 + report,到第三步转 needs_human
# 让 add_to_cart / verify_cart / enter_checkout 正常,parse_checkout 抛
# NotImplemented(模拟某一步没实现),触发前步写证据 + report,最终转 needs_human
async def _noop(task): # noqa: ANN001
return None
async def _checkout_html(task): # noqa: ANN001
return "<html>checkout</html>"
async def _unimplemented(html): # noqa: ANN001
raise NotImplementedError("模拟:假装 parse_checkout 还没实现")
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 = _unimplemented # type: ignore[assignment]
await runner.handle(_make_task(task_id="t1"))