结算流程用真实下单证据修正:新卡代填 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
+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"))