Commit Graph
11 Commits
Author SHA1 Message Date
q792602257andClaude Opus 5 3c7618a1d6 feat(observability): 抓取去掉首页预热,交易补齐链路埋点
两个问题一起处理,都与「出站请求与可观测性」有关。

## 抓取:正常路径不再多打一次首页

site_session 原先每条通道每 30 分钟打一次 www.rakuten.co.jp/ 做预热,而且预热
返回非 2xx 时 warmed_at 不置位——那种情况下每个请求前都会再打一次首页。

Akamai 的 cookie 随任意页面响应下发,目标页自己就会带回来,专门先打一次首页除了
多一个出站请求(以及多一次被风控计数的机会)之外没有额外收益:首个请求无论打哪个
URL 都是冷的 ~11s,之后都复用 cookie。

改为 cookie 由目标页响应建立(_note_cookies)、超 TTL 主动清空
(_drop_expired_cookies)。首页只保留在失败修复路径上(_rewarm_on_home):目标页
已经吃了挑战页时,拿首页换一套干净 cookie 比继续撞同一个 URL 更安全。happy path
的出站请求数 2 → 1。

_note_cookies 刻意不在每次响应时刷新时刻:TTL 要从「这套 cookie 第一次出现」算起,
每次都刷新会让一套 cookie 被无限续命,反而绕过了 session_ttl_seconds 的本意。

profile_status() 的 warmed 字段名保留(上游健康检查看板在用),语义改为「当前有
可复用的 Akamai cookie」,不再代表「已专门预热过首页」。

## 交易:此前没有任何有意义的链路数据

根因是 trading 的实际工作两类自动埋点都覆盖不到:站点交互走 Playwright(不经
httpx),worker 主循环是后台 asyncio 任务(没有 HTTP 入口,因此没有根 span)。
于是发给网关的每次 httpx 调用各自成为孤立 trace——观测后台上只剩一堆请求记录。

新增手工埋点:

- order.task:一笔下单的根 span,一个 task_id 一条 trace,带 order.route
  (execute / recovery / already_finished)与终态 order.terminal_status
- order.step.*:清车 → 加购 → 校验 → 确认 → 提交 → 付款,每步一个子 span,
  带 order.evidence_ref,可从 span 直接定位落盘证据
- site.*:12 个 Playwright 交互方法(用 traced 装饰器而非 with 块——这些方法的
  函数体本就很长,再加一层缩进不利于阅读)
- account_query:只读查询单的根 span,带 query.outcome

空转的长轮询(30 秒一次、绝大多数返回空)用 suppressed() 屏蔽:量大且没有信息量,
把观测后台刷满的正是它们。领到任务后的网关调用都在任务根 span 底下,不受影响。

闸门 / 风控拦截会被 _execute_with_renewal 吞掉转 needs_human,异常冒不到根 span,
被拦下的单在 trace 里跟成功下单一模一样。加 _execute_recording_errors 一层统一
记录,比每个 except 分支各写一遍省事,也不会漏掉后续新增的分支。

_report_safe 写 span 属性前判断 is_recording():付款后监控是 create_task 起的,
asyncio 在创建时就把 context 复制了进去,等它真正跑起来根 span 早已结束——
get_current_span() 拿到的仍是那个已结束的 span(不是 INVALID_SPAN),写属性会打
"Setting attribute on ended span"。当前监控路径不传 terminal_status 走不到那里,
这道判断是防以后。

## 顺带修掉:instrument_app 从未生效

instrument_app 用 _provider is None 做前置判断,但三个服务都在模块导入时执行
app = create_app(),而 setup_telemetry 要等 lifespan 才跑——那时 _provider 还是
None,照着判断直接 return。**FastAPI 从来没被打桩过,三个服务一条 server span
都没有。**

实测确认两件事:导入期打桩能出 span,lifespan 内打桩出不来(instrument_app 是加
中间件,应用开始服务后加进去不生效);provider 后设也不影响 ProxyTracer 委托到
真实 provider。所以只能在导入期装,判断条件改为 otel_enabled。

app/gateway/main.py 此前完全没接 telemetry,worker 出站请求带过来的 traceparent
没人接上,一条下单链路在网关这里断掉,只看得到 worker 侧那半截。补上
setup_telemetry(service_name="rakuten-gateway") 与 instrument_app / shutdown。

## 验证

新增 8 个用例:首页零请求、cookie 复用与过期清空、失败后用首页换 cookie、一任务
一 trace 的父子结构、闸门失败标 ERROR、空转不埋点,以及 instrument_app 调用顺序
的回归测试。全量 526 passed。

Playwright 那些 site.* 埋点只做了静态验证(测试用桩替换站点方法),没有跑真实
浏览器下单确认 span 真的落地。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 14:56:49 +08:00
q792602257andClaude Opus 5 3284e086ad feat(trading): 浏览器掉线兜底——任务边界自愈重建,中途掉线转 needs_human
SiteInteractor 的 Chromium 是进程级单例,此前启动后即假定永远活着:全仓唯一的
is_connected() 探活在 scraping 侧,交易侧既不探活也不重启。容器里 Chromium 崩溃
是有真实前提的(/dev/shm 不足、OOM kill、seccomp 挡 sandbox,docker-compose.yml
里已有相关注释),一旦发生,进程还活着但之后每一单都会失败,且 /health 恒返回
ok,restart: unless-stopped 永远不会被触发。

更隐蔽的一条:clear_cart / enter_checkout / pay 等处的 new_page()、context.request
写在 try 之外,掉线抛的 TargetClosedError 不是 AppError,会穿过 runner 的
except AppError 落到主循环那个只记日志的兜底里——任务一次都不上报,网关侧要干等
整个 lease_ttl(默认 300s)才被 sweep 置 stale。clear_cart 是 execute() 的 step 0,
浏览器死时最先撞上的正是它。

分三层处理:

- 任务边界自愈。_launch() 从 start() 抽出,_context_options() 统一 context 参数
  (重建必须与启动完全一致,指纹漂移就是一次风控事件);_refresh_context_if_stale
  改名 _ensure_context_ready(),先探活重建再做原有的 storage_state mtime 检查
  (顺序不可换,mtime 重建要用 self._browser)。重建前丢弃 _checkout_pages 里的
  残留确认页并记 warning——那些 Page 已随浏览器一起没了。

- 中途掉线不重建,抛 BrowserDeadError(新增,5006)。新增 _new_page() 与
  _request() 两个壳收口裸异常;_request() 只在确认浏览器真死了时才改写异常,
  站点 5xx 这类正常业务失败原样抛出。submit_order / pay 入口用
  _require_live_browser() 直接拒绝:这两步复用 enter_checkout 留存的 Page,
  重建救不回服务端订单草稿,而 pay 跑的时候订单已经真的提交了。顺带修掉一个
  误诊——浏览器死时 submit_order 原先报「未找到确认按钮」,把「浏览器崩了」
  说成「站点改版了」,两者的处置方式完全不同。

- runner 把 BrowserDeadError 转 needs_human 而非 failed,except 分支排在
  except AppError 之前(子类,顺序反了就报 failed)。掉线发生在动作中途,
  站点侧生效与否无从判断,不能给上游「明确失败」的结论。

/health 暴露 browser 状态,掉线时 degraded + HTTP 503 + code 5006,让
Dockerfile.trading 的 HEALTHCHECK 探到并重启容器。重启不会导致重复下单:网关侧
任务绝不自动重投,租约过期只置 stale 等人工 reclaim(docs/order-gateway.md §5),
重启只是恢复领新任务的能力。启动窗口期 started=False 不算掉线。

README 错误码表补 5005(此前遗漏)与 5006。

新增 15 个用例覆盖探活三态、is_connected() 自身抛错、边界重建/不重建/丢弃残留页/
重建失败、两个包装壳的分支、submit/pay 拒绝、runner 转 needs_human、/health 503。
真实 Chromium 崩溃无法在离线测试里制造,用例模拟的是 is_connected() 返回 False
这个唯一可观测信号,覆盖的是代码对该信号的反应而非崩溃本身;容器 HEALTHCHECK
真的触发重启这条链路尚未实跑验证。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 16:01:47 +08:00
q792602257 f31e127ba7 feat(trading): 下单流程每步证据带整页截图(html + png + meta 三件套)
- site_interact 新增 PageSnapshot{html, screenshot} 证据载体:
  add_to_cart 截商品页、verify_cart 截 cart 页、enter_checkout 截落地
  确认页、pay 截付款检查后的完成页;截图全部 best-effort,失败记
  warning 留空,不掩盖动作结果
- submit_order 改返回 SubmitOutcome{site_order_id, evidence}:完成页
  HTML+截图首次随 step 4 落盘(此前只有 meta)
- fetch_order_detail 详情页截图挂在 OrderStatusSnapshot.screenshot,
  监控步骤 write_step 带 png;只读查询通道按字段挑出,不受影响
- runner._run_step 认 PageSnapshot(旧 str 契约保留兼容),step 0/3/4
  显式传 png
- 测试:桩同步新契约;新增每步三件套齐全的全流程断言;clear_cart
  场景单测补截图断言
- 真账号复验 verify_cart_clear.py:两场景截图均合法 PNG,落盘
  .probe/cart_clear/ 并目检为空车页渲染
2026-08-17 08:56:11 +08:00
q792602257 086ab82614 feat(trading): 清空购物车落步骤证据,补空车/有商品两场景测试
- clear_cart 返回清理后 cart 页最终 HTML(best-effort,抓取失败不掩盖清理结果)
- runner step 0 落 00-cart-clear.{html,meta.json} 并登记 evidence_index,
  证据先于闸门判断落盘(清理未净被拦时这份现场就是排查依据);
  仍是本机卫生步骤:不上报 gateway、不记 order_events
- /api/cart/clear 响应不携带 html(路由显式挑字段,与 cart_add 同风格)
- 单测:fake page 覆盖空车/有商品/HTML 抓取失败;runner 层覆盖证据落盘
  与闸门拦截场景
- 真账号复验 scripts/verify_cart_clear.py:空车 removed=0/count=0;
  加购 1 件后 clear removed=1/count=0,status 复核 101
2026-08-17 08:32:11 +08:00
q792602257andClaude Opus 5 df93bc1e63 fix(trading): 每单开跑前先清理购物车,杜绝上一单失败残留被新单一起买走
上一单若在提交(submit_order)之前失败(如 enter_checkout 被 session upgrade
拦截、金额守卫拦下),残留商品会留在购物车里;下一单 add_to_cart 把新商品叠
加在旧商品上,结算时会把上一单的一起买走(已实测踩到过)。

在 execute() 加购前先 clear_cart,从空车起步,保证「一单 = 只买本单商品」——
不依赖上一次失败路径是否完整执行清理,进程中途崩溃残留也没人清也能兜住。
清理后 cart_count 仍未清空(含 count API 失败返回 -1)按闸门语义转 needs_human,
绝不带残留往下加购。清理本身是本机侧卫生操作,不走证据与上报。

补 3 个用例:clear 先于 add、清理后非空/未知 → 转 needs_human 且不加购;
并为走 execute 的既有测试补 clear_cart 桩。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 00:33:42 +08:00
q792602257 93c1a83406 实现租约恢复核对:verify_on_site 接真实订单列表反查
新增 SiteInteractor.list_recent_orders(分页拉 order.my.rakuten.co.jp 订单列表,
按商品 URL 反查)+ GatewayClient.get_task,替换掉恒返回 UNKNOWN 的桩。
NOT_ORDERED 分支目前只有逻辑验证、没有真实多单数据支撑,刻意仍路由到
needs_human,不自动重新下单。全程只读查询,未触发任何真实付款操作。
2026-08-13 23:58:54 +08:00
q792602257andClaude Sonnet 5 51e2538438 实现付款后订单监控:真实探测 order.my.rakuten.co.jp 配送阶段
用真实订单号 306087-20260813-0863947697 探测 order.my.rakuten.co.jp(订单列表/
详情页),拿到真实 DOM 结构后实现 SiteInteractor.check_order_status:

- 详情页 URL 可直接从 site_order_id 构造(shop_id 是订单号第一段)
- 配送阶段用「进度条」组件的 4 个固定阶段(ショップ/出荷/配達店/配達完了),
  当前阶段的 class 带 -active-- 中缀,映射到 OrderState.SHIPPED/DELIVERED
- 查不到订单号、进度条解析不出新阶段都不算错误,交给轮询循环继续重试

runner.WorkerRunner 新增 _monitor_order 后台轮询:付款成功上报后以
asyncio.create_task 起后台任务(不阻塞主循环领下一单,因为 SiteInteractor 的
Playwright 操作全程持锁串行化),状态变化时用 terminal=False 追加 report;
新增 cancel_monitors() 在服务关闭时于 SiteInteractor.close() 之前收尾。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 23:01:35 +08:00
q792602257andClaude Sonnet 5 5f7921b788 结算流程用真实下单证据修正:新卡代填 iframe vault、订单号正则、登录态判据
- 新卡代填改用真实 DOM 结构:卡号/有效期分别托管在 Rakuten PCI 代付 vault 的
  跨域 iframe 里,此前按 autocomplete/name 猜的 selector 在主文档里根本找不到
  元素;持卡人姓名字段改用「名義人」标签相对定位,不用 placeholder 示例文案
  (TARO RAKUTEN 只是示例用户名,不是稳定标识)
- 订单号正则修正 &nbsp; 实体导致的分隔符匹配失败(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>
2026-08-13 22:36:49 +08:00
q792602257andClaude Opus 4.6 65e3ed31f8 交易服务收敛为仅 rakuten:移除 rakuma 登录态与下单入口
trading 不再管理 ラクマ 的购物车购买、支付与订单监控。删除 auth_site /
auth_session / login_runner / models.AuthSite 中的 rakuma 分支与常量、
account.yaml.example 的 rakuma 段,并清理相关测试。scraping 侧的
ラクマ 抓取 API(/api/rakuma/*)保留不动。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-28 11:50:07 +08:00
q792602257andClaude Opus 4.6 18cf7ae079 实现 site_interact 加购与购物车校验(Playwright 通道)
探针实测发现 httpx 因 TLS/HTTP2 指纹被 Rakuten 拒认(cookie 有效但
站点不识别 session),site_interact 改为全程 Playwright + storage_state。
add_to_cart 与 verify_cart 已实测可用:从商品页 __INITIAL_STATE__ 抽
purchase 块、调用方补 variant_id 与 choice、POST basketDomain、用 cart
count JSONP API 与 SPA 渲染后的 cart 页校验。enter_checkout 及之后仍
NotImplementedError:Rakuten 对 checkout 要求 session upgrade(重输密码),
是自动 checkout 的硬墙,未实测过墙方案前留接口缝。

SiteInteractor 类持有 Playwright BrowserContext,由 WorkerRunner 实例
持有、container/main 接线、lifespan 管理生命周期。测试侧 23 个用例,
全套件 333 passed,架构边界守住。

附 5 个探针脚本(scripts/probe_*.py),记录实测路径与响应结构。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-27 17:59:30 +08:00
q792602257andClaude Opus 4.6 07107094a7 实现下单任务网关与本地 worker
按 docs/order-gateway.md 落地:第三个部署单元 app.gateway(:31109)承担任务队列
+ 状态镜像;本地 worker 在 app.trading.worker 内,按 RAKUTEN_ORDER_GATEWAY_URL
决定是否启动。规格 §5 最关键约束已守:租约过期绝不自动重投,恢复只能 reclaim,
worker 收到 lease_count>1 时先核对站点订单。

站点交互(加购/下单/付款/订单列表反查)按规格 §10 留接口缝,site_interact.py
全部 NotImplementedError,verify.py 恒返回 unknown——等真实账号实测后再填,
不写猜测的提交逻辑。

310 个测试全绿,覆盖规格 §9 验收清单 12 条;架构测试守住三方互不 import。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-27 16:25:20 +08:00