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
+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"))