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:
@@ -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",
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user