"""站点交互:加购 / 校验 / 清空 / 删除 / 进入下单确认页 / 金额守卫 / 提交 / 付款 / 监控
实测进度(详见 project://jp-rakuten/checkout-flow-probe-findings 与
data/evidence/checkout-research-20260811/NOTES.md):
- add_to_cart、verify_cart 已实测可用(Playwright + storage_state 走 SP 通道)
- clear_cart、remove_item 用 UI 点击 `button[aria-label^="削除"]` 路径实现,
2026-08-11 用真账号跑通下单测试时确认实际 aria-label 是「削除する」而非旧探针
记录的「削除」,selector 已改前缀匹配;modal 是否存在仍未实测确认
- enter_checkout 已实现到「进入 session upgrade 密码页并尝试自动复核」:购物车页
点击真正的「購入手続き」(注意页面上还有个促销用的「カード入会&購入手続き」
按钮,selector 必须精确匹配,不能 .first 模糊选)后,即使 SSO 会话仍有效,
Rakuten 也会整页跳转到 login.account.rakuten.com/session/upgrade 强制重输密码,
这是站点自己的风控设计,不是登录态问题。前 4 次真实账号自动化尝试里自动填密码后
点「次へ」均未能稳定提交(按钮持续被前端重渲染替换,Playwright 等不到可点击的
稳定态),行为更像是站点对自动化环境的针对性降级,不是偶发问题——命中时抛
CheckoutBlockedError 转 needs_human,**不做无限重试**。
- 2026-08-11 第 5 次由人工在浏览器里手动完成密码这一步后,实测发现 session upgrade
之后还有几个此前完全没见过的中间步骤:账号缺电话号码时插入的「会員情報の追加登録」
(补录电话号码)→ 确认收货地址(点默认地址卡片)→ 选支付方式 → 才是下单确认页。
enter_checkout 已按这个顺序扩展(_complete_phone_registration /
_confirm_default_address / _select_payment_method),但**这三步全部只有人工走
一遍时的截图作为依据,没有拿到任何一步的真实 HTML**,selector 是按可见文案反推
的推测,未经自动化路径真实验证。
- parse_checkout / submit_order / pay **有实现,且 2026-08-13 真实完整跑通了一次**:
账号 trdian022,297円小额验证购买(BACKYARD FAMILY 墙钩),人工在浏览器窗口里
核对过金额/收货地址/卡号后,现场手动触发了 submit_order 对应的「注文を確定する」
点击(不是本模块自动决策触发的——production 代码路径本身仍然只有「金额/商品
校验通过才点」这一层自动化安全阀,见 submit_order 与 CONFIRM 校验逻辑),
真实跳转到 sp.cart.step.rakuten.co.jp/complete?l2-id=step4_sp_purchase_top
(下单完成页,「ご注文がショップに送信されました」),未检测到 3DS/OTP,拿到
真订单号「注文番号 306087-20260813-0863947697」。已用这份完整证据(
data/evidence/checkout-live-20260813/、以及更早一份提交前快照
data/evidence/checkout-research-20260811/i-final-state.html)验证/修正:
- 金额解析:真实标签是「支払い金額」,无「お」前缀,数字和「円」分别在独立
标签里、中间隔着大段 class 属性——之前按简单文案猜的正则在真实页面上完全
匹配不到,现改成 tag-bounded 优先、inline 简单文案兜底的两段式匹配,详见
_AMOUNT_TAG_BOUNDED_PATTERN。金额解析仍然「找不到明确匹配就直接抛错」,
不落回弱规则猜数字——这个值要直接喂给金额守卫,猜错等于守卫形同虚设。
- 「注文を確定する」确认是真实存在的确认按钮,点击后的跳转/完成页判据也已验证。
- 订单号格式与真实 DOM 结构(标签后跟 ` ` 实体、三段数字用「-」连接)——
旧版 _ORDER_ID_PATTERN 卡在 ` ` 上完全没命中,已修正,见 _ORDER_ID_PATTERN
注释。付款期限这份证据里没有出现(大概率因为当场就付款完成、没有独立的
「期限」概念),_PAY_DEADLINE_PATTERN 仍未经验证,维持原猜测。
- 新卡代填表单的真实 DOM(卡号/月/年三个字段各自在独立跨域 iframe 里,只有
持卡人姓名是主文档普通 input),见 _CARD_NUMBER_MOUNT_SELECTOR 等常量注释
与 _fill_new_card_form;iframe 挂载点选择器验证过,iframe 内部字段选择器
仍是猜的。
这次真实提交**只此一单**,其余账号/商品/金额组合下的中间步骤分支(电话补录、
非默认地址、3DS/OTP 拦截等)仍未被真实数据验证过,继续按原有猜测处理并保留
CheckoutBlockedError 兜底,不做无限重试。
- check_order_status(付款后监控的单次探测,循环轮询在 runner.py)**2026-08-13
用真实订单号 306087-20260813-0863947697 实测过** order.my.rakuten.co.jp 的
订单列表页与详情页:两者共用同一套「配送阶段进度条」组件,固定 4 阶段
(ショップ→出荷→配達店→配達完了),当前阶段的 class 里带 `-active--` 中缀,
详见 _ORDER_STEPPER_ITEM_PATTERN 上方注释。这次真实订单当时还停在「ショップ」
(刚接单,未发货)这一阶段,「出荷」「配達完了」两个状态转换点没有被真实数据
验证过,只是按进度条文案直译映射(_ORDER_STAGE_TO_STATE);取消/退款没有
找到可靠信号,检测不到,遇到需要人工核对订单列表页。
- list_recent_orders(规格 §5 恢复核对用,verify.verify_on_site 调用)**2026-08-13
用真账号实测过** order.my.rakuten.co.jp/purchase-history/order-list:这是与
check_order_status 不同的另一个页面(真正的「我的订单列表」,不是
detail_page_view 那个 act 分支),pageType="ph-list" 时 __INITIAL_STATE__.
orderListData 直接是结构化 JSON(ordersFound/orderList[].items[].itemUrl 等),
不需要正则抠 DOM。当时账号只有 1 笔订单、1 页,验证了单页解析与
`?page=2` 超出范围返回空列表;多页翻页时「新订单排在前面」的排序假设、以及
分页游标本身,都没有被真实多页数据验证过。
**httpx 不能用于带账号的写操作**:Rakuten 对账号操作有 TLS/HTTP2 指纹校验,
同一份 cookie Playwright 能用、httpx 不能。所以本模块全程使用 Playwright
的 BrowserContext + APIRequestContext(共享 cookie,绕过 CORS)。
**并发约束**:所有公开方法入口 acquire `self._lock`,保证 HTTP 路由与 worker
主循环不会同时操作同一个 Playwright context——同一账号必须串行
(per project://jp-rakuten/trading-split「全局并发度 1」)。
"""
from __future__ import annotations
import asyncio
import json
import logging
import re
from dataclasses import dataclass, field
from datetime import datetime
from typing import TYPE_CHECKING
from app.shared.errors import (
CartOperationError,
CheckoutBlockedError,
InvalidRequestError,
NotLoggedInError,
OrderOperationError,
)
from app.shared.purchase_contract import (
INVENTORY_FLAG_DEFAULT,
INVENTORY_FLAG_MULTIPLE,
base_form_fields,
basket_domain_of,
inventory_flag_for,
)
from app.shared.task_state import OrderState
from app.trading.core import auth_site
from app.trading.worker.models import LeaseTask
if TYPE_CHECKING:
from playwright.async_api import Page
from app.shared.config import Settings
from app.trading.services.auth_session import AuthSession
from app.trading.services.login_runner import CreditCard
logger = logging.getLogger(__name__)
# 普通购买事件标识、inventory_flag 映射、basketDomain 反转义、form_fields 基础构造
# 均在 app.shared.purchase_contract,与 scraping/parsers/item.py::_purchase_info 共用同一份契约
_BASKET_PATH = "/rms/mall/bss/cartadd/set"
# 购物车页(SP)与 cart 数量 JSONP API(探针实测)
_CART_PAGE = auth_site.RAKUTEN_CART_URL
_CART_COUNT_API = "https://cart-api.step.rakuten.co.jp/rms/mall/cart/count/all/jsonp/"
# 购物车页未登录标记(旧 marker;新 SPA 上不可靠,这里只作辅助判据)
_LEGACY_LOGGED_OUT_MARKER = auth_site.RAKUTEN_LOGGED_OUT_MARKER
# 删除按钮稳定 selector:真实账号实测(2026-08-11 order-gateway 下单测试,
# data/evidence/test-order-20260811-01/02-cart-check.html)显示实际 aria-label
# 是「削除する」而非旧探针记录的「削除」,故改用前缀匹配防止站点文案再漂移一点点。
# aria-label 是 a11y 属性,比 CSS modules hash class 稳定;Rakuten 不用 data-testid
_DELETE_BUTTON_SELECTOR = 'button[aria-label^="削除"]'
# clear_cart 安全上限:防止 SPA 异常时死循环
_CLEAR_CART_MAX_ITER = 50
# 确认 modal 候选 selector:点击「削除」后站点可能弹「本当に削除しますか?」
_CONFIRM_BUTTON_SELECTORS = ('button:has-text("はい")', 'button:has-text("OK")')
# 购物车页真正的结算入口。**必须精确匹配**:页面上还有一个视觉更醒目的促销按钮
# 「カード入会&購入手続き」(楽天カード 办卡组合按钮),selector 不精确(比如用
# .first)会点错——2026-08-11 实测踩过这个坑,详见 checkout-research-20260811/NOTES.md
_CHECKOUT_BUTTON_SELECTOR = 'button[aria-label="購入手続き"]'
# 点击结算按钮后落到 session upgrade 密码页的判据:URL 里出现这些片段之一
_SESSION_UPGRADE_URL_MARKERS = ("session/upgrade", "sign_in/password")
# session upgrade 密码页的「次へ」提交按钮没有 type=submit,只能按文案匹配
_SESSION_UPGRADE_SUBMIT_SELECTOR = 'button:has-text("次へ")'
# 提交密码后等待页面跳转的轮询:每次 3s,共 _SESSION_UPGRADE_POLL_ROUNDS 次
# (合计 ~30s)。前 4 次真实尝试里从未在这个窗口内看到跳转,超时即认定被拦截。
_SESSION_UPGRADE_POLL_INTERVAL_MS = 3_000
_SESSION_UPGRADE_POLL_ROUNDS = 10
# ---- 2026-08-11 人工走一遍才发现的中间步骤(session upgrade 之后、下单确认页之前)
# 全部只有截图依据,没有真实 HTML;selector 是按可见文案反推的推测。
# 账号缺电话号码时插入的补录页;用文案 marker 判断(没抓到真实 DOM,不用更精确的选择器)
_PHONE_REGISTRATION_MARKER = "会員情報の追加登録"
_PHONE_INPUT_SELECTOR = 'input[type="tel"], input[name*="phone" i], input[name*="tel" i]'
# 收货地址确认页:URL 里带 /ship;默认地址卡片带「デフォルトお届け先」徽章文案,
# 点卡片本身即可继续(实测点击后直接跳到支付方式页,没有单独的「次へ」)
_ADDRESS_STEP_URL_MARKER = "/ship"
_ADDRESS_DEFAULT_BADGE_TEXT = "デフォルトお届け先"
# 支付方式选择页:URL 里带 /pay;radio 按可见文案匹配,选完点「次へ」
_PAYMENT_STEP_URL_MARKER = "/pay"
_PAYMENT_NEXT_BUTTON_SELECTOR = 'button:has-text("次へ")'
# 选中「クレジットカード」后,账号已保存卡的真实信号(2026-08-11 人工在真实页面上
# 核对过一次截图):掩码卡号(如 VISA ●●●● 8476)+「有効期限:MM/YYYY」。
# 页面上「➕ 新しく追加したカードの情報を変更する」这条链接不管有没有已保存卡都会
# 显示,**不能**当「没有已保存卡」的信号用。
_SAVED_CARD_SIGNAL_PATTERN = re.compile(r"(?:[•●\*]{2,}\s*\d{3,4}|有効期限[:\s]*\d{1,2}/\d{4})")
# 掩码卡号的末 4 位数字,单独抽出来跟 account.yaml 配置的卡号做尾号比对,
# 判断已保存卡是不是我们想用的那张——不匹配(比如账号换过默认卡)就当没有可用卡处理。
_MASKED_CARD_LAST4_PATTERN = re.compile(r"[•●\*]{2,}\s*(\d{3,4})")
# ---- 新卡代填(账号无已保存卡 / 已保存卡尾号不匹配时触发):
# 2026-08-13 真实走过一次「新しいカードを追加する」表单(人工在浏览器窗口里用
# OS 级 SendKeys 代填,不是 Playwright .fill(),见下方说明),并从当时的页面
# 快照(data/evidence/checkout-live-20260813/i-final-state.html,url 带
# payment?l2-id=c_reg)里拿到了真实 DOM 结构:
# - 链接文案随状态变化:还没加过卡时是「新しいカードを追加する」(触发新增表单,
# 本模块用这条);已经加过一张后,同一位置的链接会变成「新しく追加したカードの
# 情報を変更する」(改已加的那张),两条文案都真实存在,含义不同,不能混用。
# - 卡号 / 有效期(月) / 有效期(年) 三个字段**分别托管在三个独立的跨域 iframe 里**
# (Rakuten 的 PCI 代付 vault:static-content.payment.global.rakuten.com/
# pv/payvault/...),不是主文档里的 ——之前按 autocomplete/name 属性
# 猜的 selector 在主文档里根本找不到元素,是错的。真实可定位的是外层挂载点
# (见 _CARD_NUMBER_MOUNT_SELECTOR 等),Playwright 用 frame_locator 可以正常
# 跨域访问;至于 iframe **内部**具体是 还是