From df93bc1e633803f574b4828898289c3091d234d6 Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Mon, 17 Aug 2026 00:33:29 +0800 Subject: [PATCH] =?UTF-8?q?fix(trading):=20=E6=AF=8F=E5=8D=95=E5=BC=80?= =?UTF-8?q?=E8=B7=91=E5=89=8D=E5=85=88=E6=B8=85=E7=90=86=E8=B4=AD=E7=89=A9?= =?UTF-8?q?=E8=BD=A6=EF=BC=8C=E6=9D=9C=E7=BB=9D=E4=B8=8A=E4=B8=80=E5=8D=95?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E6=AE=8B=E7=95=99=E8=A2=AB=E6=96=B0=E5=8D=95?= =?UTF-8?q?=E4=B8=80=E8=B5=B7=E4=B9=B0=E8=B5=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一单若在提交(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) --- app/trading/worker/runner.py | 27 ++++++- tests/test_worker_runner.py | 135 +++++++++++++++++++++++++++++++++++ 2 files changed, 159 insertions(+), 3 deletions(-) diff --git a/app/trading/worker/runner.py b/app/trading/worker/runner.py index 3a5678e..96d4e30 100644 --- a/app/trading/worker/runner.py +++ b/app/trading/worker/runner.py @@ -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", diff --git a/tests/test_worker_runner.py b/tests/test_worker_runner.py index 1caea2d..802516c 100644 --- a/tests/test_worker_runner.py +++ b/tests/test_worker_runner.py @@ -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 "checkout" + + 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"))