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>
This commit is contained in:
2026-08-17 00:33:42 +08:00
co-authored by Claude Opus 5
parent f6c3976c0a
commit df93bc1e63
2 changed files with 159 additions and 3 deletions
+24 -3
View File
@@ -256,11 +256,12 @@ class WorkerRunner:
)
async def execute(self, task: LeaseTask) -> None:
"""站点交互的实际调度:加购 → 校验 → 确认页 → 金额守卫 → 提交 → 付款
"""站点交互的实际调度:清购物车 → 加购 → 校验 → 确认页 → 金额守卫 → 提交 → 付款
每一步的顺序:动作 → 落证据 → 写本地 SQLite → 回报 gateway。
站点交互当前未实现(site_interact 抛 NotImplementedError),第一步就会
_execute_with_renewal 的 except 分支上报 needs_human。
开单前的「清购物车」是本机侧卫生步骤(step 0,见下方代码注释),不走
证据与上报;站点交互失败会转 _execute_with_renewal 的 except 分支上报
needs_human / failed。
"""
await self._db.ensure_started(task.task_id, task.site, task.intent)
@@ -268,6 +269,26 @@ class WorkerRunner:
if task.site != "rakuten":
raise NotImplementedError(f"site={task.site} 暂不在交易服务范围内(仅 rakuten)")
# 步骤 0:开单前清理购物车。上一单若在提交(submit_order)之前失败——比如
# 加购成功但 enter_checkout 被 session upgrade 拦截、或金额守卫拦下——残留的
# 商品不会自己消失,会一直留在购物车里;下一次 add_to_cart 把新商品叠加在旧
# 商品上,结算时会把上一单的一起买走(已实测踩到过)。所以每单开跑前先清空
# 购物车,保证「一单 = 只买本单商品」的不变式——无论上一单是怎么失败的
# (包括进程中途崩溃,残留都没人清),这里都从空车起步。
#
# 这条不在 gateway 上报,也不写步骤证据:它是本机侧的卫生操作,不是订单
# 进度的状态迁移。清理后 count 非 0(含 count API 拿不到结果返回 -1)说明
# 清理没跑干净,**不能**带着残留往下加购——有把上一单买走的真实风险,
# 按闸门语义拦截转 needs_human,宁可卡住等人核对,也不赌「残留不会被买走」。
cleared = await self._site.clear_cart()
if cleared.get("cart_count", -1) != 0:
raise OrderGuardError(
"开单前清理购物车后仍未清空"
f"(cart_count={cleared.get('cart_count')},removed_count="
f"{cleared.get('removed_count')}):残留商品可能随本次下单一起被买走,"
"中止转人工核对"
)
# 步骤 1:加购
await self._run_step(
task, step_no=1, step_name="cart-add",
+135
View File
@@ -171,6 +171,18 @@ def _make_task(
)
def _set_site_clear(site, result: dict) -> None:
"""把 site.clear_cart 替换成返回指定结果的桩
runner.execute() 现在开单一律先 clear_cart(step 0,见 runner 里注释),
测试若不复用它,site 桩会真的去调未启动的 SiteInteractor 而崩。
"""
async def _f() -> dict:
return result
site.clear_cart = _f # type: ignore[assignment]
# ---- 已完成的任务:补报而不重新执行(§9 第 9 条)----
@@ -292,6 +304,7 @@ async def test_unimplemented_site_interaction_becomes_needs_human(
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]
_set_site_clear(runner._site, {"removed_count": 0, "cart_count": 0})
await runner.handle(_make_task(task_id="t1"))
@@ -322,6 +335,7 @@ async def test_checkout_blocked_becomes_needs_human(
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]
_set_site_clear(runner._site, {"removed_count": 0, "cart_count": 0})
await runner.handle(_make_task(task_id="t1"))
@@ -377,6 +391,7 @@ async def test_amount_guard_blocks_when_payable_exceeds_limit(
runner._site.enter_checkout = _checkout_html # type: ignore[assignment]
runner._site.parse_checkout = _parse # type: ignore[assignment]
runner._site.submit_order = _submit # type: ignore[assignment]
_set_site_clear(runner._site, {"removed_count": 0, "cart_count": 0})
# intent.max_total_yen 缺省,回落到 settings.order_max_total_yen=30000,
# 实际 50000 > 30000 → 拦截
@@ -416,6 +431,7 @@ async def test_amount_guard_honors_intent_max_total_yen(
runner._site.enter_checkout = _checkout_html # type: ignore[assignment]
runner._site.parse_checkout = _parse # type: ignore[assignment]
runner._site.submit_order = _submit # type: ignore[assignment]
_set_site_clear(runner._site, {"removed_count": 0, "cart_count": 0})
# 全局上限 30000,但 intent 给 5000 → 实际 8000 > 5000 → 拦截
await runner.handle(_make_task(task_id="t1", intent={"max_total_yen": 5000}))
@@ -426,6 +442,123 @@ async def test_amount_guard_honors_intent_max_total_yen(
assert terminal["terminal_status"] == TaskStatus.NEEDS_HUMAN
# ---- 开单前清理购物车(step 0):上一单失败残留不被新单买走 ----
async def test_execute_clears_cart_before_adding(
runner: WorkerRunner, local_db: LocalDB
):
"""开单一律先 clear_cart,且必须先于 add_to_cart 发生
上一单若在下单前失败(加购后、提交前),残留不会自动消失;不清掉就直接
加购会把新商品叠在旧商品上,结算时把上一单的一起买走。这里断言顺序:
clear 在 add_to_cart 之前,且 clear 返回 count=0 才继续往下加购。
"""
call_order: list[str] = []
async def _clear(): # noqa: ANN001
call_order.append("clear")
return {"removed_count": 0, "cart_count": 0}
async def _noop(task): # noqa: ANN001
call_order.append("add")
return None
async def _verify(task): # noqa: ANN001
return None
async def _checkout_html(task): # noqa: ANN001
return "<html>checkout</html>"
async def _parse(html: str):
return CheckoutSummary(payable_yen=297)
async def _submit(task): # noqa: ANN001
return "ord-1"
async def _pay(task, site_order_id): # noqa: ANN001
return None
async def _unchanged(order_id: str): # noqa: ANN001
# 不要让后台监控真的落到未启动的 SiteInteractor 上:found=True 但状态无
# 变化(order_state=None),监控循环会一直轮询到上限退出,不污染断言
return OrderStatusSnapshot(found=True, order_state=None)
runner._site.clear_cart = _clear # type: ignore[assignment]
runner._site.add_to_cart = _noop # type: ignore[assignment]
runner._site.verify_cart = _verify # type: ignore[assignment]
runner._site.enter_checkout = _checkout_html # type: ignore[assignment]
runner._site.parse_checkout = _parse # type: ignore[assignment]
runner._site.submit_order = _submit # type: ignore[assignment]
runner._site.pay = _pay # type: ignore[assignment]
runner._site.check_order_status = _unchanged # type: ignore[assignment]
await runner.handle(_make_task(task_id="t1"))
assert call_order == ["clear", "add"], f"clear 必须在 add 之前:{call_order}"
async def test_execute_blocks_when_cart_not_empty_after_clear(
runner: WorkerRunner, local_db: LocalDB
):
"""清理后 count 仍非 0 → 视为清理没跑干净,转 needs_human,绝不往下加购
残留可能属于上一单失败留下的商品,带着它加购/结算会把上一单的一起买走。
按闸门语义宁可卡住等人核对,也不赌「残留不会被买走」;关键断言是
add_to_cart/submit_order 都未被调用。
"""
any_add_or_submit = {"called": False}
async def _clear(): # noqa: ANN001
return {"removed_count": 2, "cart_count": 2} # 清理后仍剩 2 件
async def _add(task): # noqa: ANN001
any_add_or_submit["called"] = True
return None
runner._site.clear_cart = _clear # type: ignore[assignment]
runner._site.add_to_cart = _add # type: ignore[assignment]
await runner.handle(_make_task(task_id="t1"))
assert any_add_or_submit["called"] is False # 关键:没有加购、更没有提交
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"]
async def test_execute_blocks_when_clear_count_unknown(
runner: WorkerRunner, local_db: LocalDB
):
"""清理后 count API 拿不到结果(cart_count=-1)→ 同样按未清空处理
无法确认购物车已空,就不能带着残留往下加购。clear_cart 在末尾 count API
失败时返回 cart_count=-1——此时不能默认它是 0(那等于把「确认已空」当成
「可能非空」),转 needs_human 让现场可查。
"""
add_called = False
async def _clear(): # noqa: ANN001
return {"removed_count": 0, "cart_count": -1}
async def _add(task): # noqa: ANN001
nonlocal add_called
add_called = True
return None
runner._site.clear_cart = _clear # type: ignore[assignment]
runner._site.add_to_cart = _add # type: ignore[assignment]
await runner.handle(_make_task(task_id="t1"))
assert add_called is False
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"]
# ---- 证据在 report 之前落盘(§9 第 11 条)----
@@ -466,6 +599,7 @@ async def test_evidence_files_exist_before_each_report(
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]
_set_site_clear(runner._site, {"removed_count": 0, "cart_count": 0})
await runner.handle(_make_task(task_id="t1"))
@@ -514,6 +648,7 @@ async def test_successful_order_spawns_monitor_task_and_cancel_monitors_cleans_u
runner._site.submit_order = _submit # type: ignore[assignment]
runner._site.pay = _pay # type: ignore[assignment]
runner._site.check_order_status = _hang_check # type: ignore[assignment]
_set_site_clear(runner._site, {"removed_count": 0, "cart_count": 0})
await runner.handle(_make_task(task_id="t1"))