结算流程用真实下单证据修正:新卡代填 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
+28 -4
View File
@@ -28,7 +28,7 @@ import contextlib
import logging
from typing import TYPE_CHECKING
from app.shared.errors import AppError, OrderGuardError
from app.shared.errors import AppError, CheckoutBlockedError, OrderGuardError
from app.shared.task_state import OrderState, TaskStatus
from app.trading.worker import verify
from app.trading.worker.client import GatewayClient
@@ -203,6 +203,21 @@ class WorkerRunner:
terminal=True,
terminal_status=TaskStatus.NEEDS_HUMAN,
)
except CheckoutBlockedError as exc:
# 站点风控拦截(session upgrade / 3DS 等):转 needs_human 交人工接管,
# 不当普通失败重试(规格 §10.1)
logger.warning(
"结算被站点风控拦截,转 needs_human:task_id=%s msg=%s",
task.task_id,
exc.message,
)
await self._report_safe(
task,
state=_coerce_state(task.known_state),
detail=exc.message,
terminal=True,
terminal_status=TaskStatus.NEEDS_HUMAN,
)
except AppError as exc:
logger.warning(
"执行失败:task_id=%s code=%s msg=%s",
@@ -256,6 +271,7 @@ class WorkerRunner:
action=self._noop(),
state=OrderState.CREATED,
detail=f"下单确认页已解析:应付 {summary.payable_yen}",
html=checkout_html,
evidence_meta={
"payable_yen": summary.payable_yen,
"site_order_id": summary.site_order_id,
@@ -333,16 +349,24 @@ class WorkerRunner:
payable_yen: int | None = None,
pay_deadline: str | None = None,
evidence_meta: dict | None = None,
html: str | None = None,
) -> None:
"""单步执行:动作 → 落证据 → 写本地 → 回报 gateway"""
await action()
"""单步执行:动作 → 落证据 → 写本地 → 回报 gateway
`action` 的返回值若是字符串,视为该步骤要落盘的页面 HTML(add_to_cart /
verify_cart 均已改为返回渲染后的页面);显式传入的 `html` 优先级更高,
供 step3 等已经单独拿到页面 HTML 的调用点直接使用。
"""
result = await action()
if html is None and isinstance(result, str):
html = result
meta = {
"step": step_name,
"state": state.value,
**(evidence_meta or {}),
}
evidence_ref = self._evidence.write_step(
task.task_id, step_no, step_name, meta=meta
task.task_id, step_no, step_name, html=html, meta=meta
)
await self._db.index_evidence(task.task_id, step_no, step_name, evidence_ref)
await self._db.record_event(