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
This commit is contained in:
@@ -259,9 +259,9 @@ class WorkerRunner:
|
||||
"""站点交互的实际调度:清购物车 → 加购 → 校验 → 确认页 → 金额守卫 → 提交 → 付款
|
||||
|
||||
每一步的顺序:动作 → 落证据 → 写本地 SQLite → 回报 gateway。
|
||||
开单前的「清购物车」是本机侧卫生步骤(step 0,见下方代码注释),不走
|
||||
证据与上报;站点交互失败会转 _execute_with_renewal 的 except 分支上报
|
||||
needs_human / failed。
|
||||
开单前的「清购物车」是本机侧卫生步骤(step 0,见下方代码注释):落本地
|
||||
步骤证据(页面 + meta)但不上报 gateway、不记状态事件;站点交互失败会转
|
||||
_execute_with_renewal 的 except 分支上报 needs_human / failed。
|
||||
"""
|
||||
await self._db.ensure_started(task.task_id, task.site, task.intent)
|
||||
|
||||
@@ -276,11 +276,24 @@ class WorkerRunner:
|
||||
# 购物车,保证「一单 = 只买本单商品」的不变式——无论上一单是怎么失败的
|
||||
# (包括进程中途崩溃,残留都没人清),这里都从空车起步。
|
||||
#
|
||||
# 这条不在 gateway 上报,也不写步骤证据:它是本机侧的卫生操作,不是订单
|
||||
# 进度的状态迁移。清理后 count 非 0(含 count API 拿不到结果返回 -1)说明
|
||||
# 清理没跑干净,**不能**带着残留往下加购——有把上一单买走的真实风险,
|
||||
# 按闸门语义拦截转 needs_human,宁可卡住等人核对,也不赌「残留不会被买走」。
|
||||
# 这条不在 gateway 上报、不记 order_events:它是本机侧的卫生操作,不是订单
|
||||
# 进度的状态迁移。但清理后的页面快照与 meta 要落本地证据并登记
|
||||
# evidence_index——清理没跑干净被下面的闸门拦下转 needs_human 时,这份现场
|
||||
# 就是排查依据,所以证据必须先于闸门判断落盘。清理后 count 非 0(含
|
||||
# count API 拿不到结果返回 -1)说明清理没跑干净,**不能**带着残留往下
|
||||
# 加购——有把上一单买走的真实风险,按闸门语义拦截转 needs_human,
|
||||
# 宁可卡住等人核对,也不赌「残留不会被买走」。
|
||||
cleared = await self._site.clear_cart()
|
||||
evidence_ref = self._evidence.write_step(
|
||||
task.task_id, 0, "cart-clear",
|
||||
html=cleared.get("html") or None,
|
||||
meta={
|
||||
"step": "cart-clear",
|
||||
"removed_count": cleared.get("removed_count"),
|
||||
"cart_count": cleared.get("cart_count"),
|
||||
},
|
||||
)
|
||||
await self._db.index_evidence(task.task_id, 0, "cart-clear", evidence_ref)
|
||||
if cleared.get("cart_count", -1) != 0:
|
||||
raise OrderGuardError(
|
||||
"开单前清理购物车后仍未清空"
|
||||
|
||||
@@ -1010,8 +1010,11 @@ class SiteInteractor:
|
||||
- 若 SPA 把按钮渲染在 iframe 里,selector 失败需实测后调整
|
||||
- Rakuten cart item 卡片无 data-testid,本方法不依赖 DOM 结构定位
|
||||
|
||||
返回 {removed_count, cart_count};cart_count=-1 表示末尾 count API 调用失败
|
||||
(空车属正常结果返回 0,见 _query_cart_count 的 status=101 处理)。
|
||||
返回 {removed_count, cart_count, html};cart_count=-1 表示末尾 count API
|
||||
调用失败(空车属正常结果返回 0,见 _query_cart_count 的 status=101 处理)。
|
||||
html 是清理结束后 cart 页的最终渲染结果,供调用方落证据排查——清理没跑干净
|
||||
被 runner 闸门拦下时这份现场就是排查依据;HTTP /api/cart/clear 入口的响应
|
||||
模型不携带它。
|
||||
"""
|
||||
async with self._lock:
|
||||
await self._auth_session.require_logged_in("rakuten")
|
||||
@@ -1019,6 +1022,7 @@ class SiteInteractor:
|
||||
|
||||
page = await self._context.new_page()
|
||||
removed = 0
|
||||
html = ""
|
||||
try:
|
||||
await page.goto(_CART_PAGE, wait_until="domcontentloaded", timeout=30_000)
|
||||
await self._wait_cart_rendered(page, label="clear_cart")
|
||||
@@ -1047,6 +1051,16 @@ class SiteInteractor:
|
||||
"clear_cart 触发安全上限 %s,可能有删除失败或 SPA 异常",
|
||||
_CLEAR_CART_MAX_ITER,
|
||||
)
|
||||
|
||||
# 清理结束后的最终页面留给调用方落证据(worker runner step 0);
|
||||
# 抓取失败只记日志,不把排查用的副作用变成新的失败源
|
||||
try:
|
||||
html = await page.content()
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"clear_cart:抓取最终页面 HTML 失败(不影响清理结果)",
|
||||
exc_info=True,
|
||||
)
|
||||
finally:
|
||||
await page.close()
|
||||
|
||||
@@ -1058,7 +1072,7 @@ class SiteInteractor:
|
||||
cart_count = -1
|
||||
|
||||
logger.info("clear_cart 完成:removed=%s cart_count=%s", removed, cart_count)
|
||||
return {"removed_count": removed, "cart_count": cart_count}
|
||||
return {"removed_count": removed, "cart_count": cart_count, "html": html}
|
||||
|
||||
async def remove_item(self, item_id: str) -> dict:
|
||||
"""删除购物车里指定 item_id 的商品
|
||||
|
||||
Reference in New Issue
Block a user