diff --git a/client/src/cmbuyer_client/pdd/sku_selection.py b/client/src/cmbuyer_client/pdd/sku_selection.py index e69253b..210c6d2 100644 --- a/client/src/cmbuyer_client/pdd/sku_selection.py +++ b/client/src/cmbuyer_client/pdd/sku_selection.py @@ -44,6 +44,43 @@ class SkuSelectionError(RuntimeError): """已取证判据不成立时的脱敏停止。""" +_SKU_ENTRY_FAILURE_STAGES = frozenset( + ( + "sku_entry_pre_intent", + "sku_entry_discovery", + "sku_entry_click", + "sku_entry_panel_verify", + ) +) +_SKU_ENTRY_FAILURE_MARKER = object() + + +def _annotate_sku_entry_failure(error: BaseException, stage: str) -> None: + """把 Flow 实际经过的固定入口子阶段附到原异常,不改变异常类型。""" + + if type(stage) is not str or stage not in _SKU_ENTRY_FAILURE_STAGES: + return + try: + # marker 最后写入:若第三方异常拒绝任一属性写入,就不能形成可信诊断。 + setattr(error, "_cmbuyer_failure_stage", stage) + setattr(error, "_cmbuyer_sku_entry_failure_marker", _SKU_ENTRY_FAILURE_MARKER) + except BaseException: + pass + + +def _safe_sku_entry_failure_stage(error: BaseException) -> str | None: + """只读取由本模块写入的入口子阶段;任意异常自报的值不可信。""" + + try: + marker = getattr(error, "_cmbuyer_sku_entry_failure_marker", None) + stage = getattr(error, "_cmbuyer_failure_stage", None) + if marker is not _SKU_ENTRY_FAILURE_MARKER or type(stage) is not str: + return None + return stage if stage in _SKU_ENTRY_FAILURE_STAGES else None + except BaseException: + return None + + class SkuPanelDevice(Protocol): def app_info(self, package_name: str) -> dict[str, Any]: ... def app_current(self) -> dict[str, Any]: ... @@ -89,19 +126,38 @@ class SkuSelectionFlow: self._pending: tuple[str, Callable[[list[_Node]], Any]] | None = None def open_sku_panel(self, product_url: str, pre_intent_hierarchy: str | None = None) -> None: - if parse_product_url(product_url).goods_id != EXPECTED_GOODS_ID: - raise SkuSelectionError("商品不是已取证目标,已停止操作。") - if pre_intent_hierarchy is not None: - previous_nodes = _parse_nodes(pre_intent_hierarchy) - # intent 前只判断旧页是否已经存在完整入口链;浮层或额外动作节点不能把旧商品 - # 伪装成“不安全所以不存在”,否则 intent 后可能误把旧页当成新目标页。 - if _physical_entries(previous_nodes): - raise SkuSelectionError("intent 前页面已出现规格入口,已拒绝旧商品误点。") - entry, before = self._wait_for_entry(pre_intent_hierarchy) - _action_bounds(entry.bounds) - self._pending = (before, _panel) - self._device.tap_sku_entry(entry.bounds) - self._wait_after_action(before, _panel) + try: + if parse_product_url(product_url).goods_id != EXPECTED_GOODS_ID: + raise SkuSelectionError("商品不是已取证目标,已停止操作。") + if pre_intent_hierarchy is not None: + previous_nodes = _parse_nodes(pre_intent_hierarchy) + # intent 前只判断旧页是否已经存在完整入口链;浮层或额外动作节点不能把旧商品 + # 伪装成“不安全所以不存在”,否则 intent 后可能误把旧页当成新目标页。 + if _physical_entries(previous_nodes): + raise SkuSelectionError("intent 前页面已出现规格入口,已拒绝旧商品误点。") + except BaseException as error: + _annotate_sku_entry_failure(error, "sku_entry_pre_intent") + raise + + try: + entry, before = self._wait_for_entry(pre_intent_hierarchy) + _action_bounds(entry.bounds) + except BaseException as error: + _annotate_sku_entry_failure(error, "sku_entry_discovery") + raise + + try: + self._pending = (before, _panel) + self._device.tap_sku_entry(entry.bounds) + except BaseException as error: + _annotate_sku_entry_failure(error, "sku_entry_click") + raise + + try: + self._wait_after_action(before, _panel) + except BaseException as error: + _annotate_sku_entry_failure(error, "sku_entry_panel_verify") + raise def select_sku_options(self, selection: SkuSelection) -> None: if selection not in {SkuSelection(*item) for item in TASK_TO_UI_SELECTION.values()}: diff --git a/client/src/cmbuyer_client/pdd/sku_selection_runner.py b/client/src/cmbuyer_client/pdd/sku_selection_runner.py index c0f118a..2509143 100644 --- a/client/src/cmbuyer_client/pdd/sku_selection_runner.py +++ b/client/src/cmbuyer_client/pdd/sku_selection_runner.py @@ -30,7 +30,10 @@ from .sku_selection import ( SkuPanelDevice, SkuSelectionError, SkuSelectionFlow, + _SKU_ENTRY_FAILURE_STAGES, _action_bounds, + _annotate_sku_entry_failure, + _safe_sku_entry_failure_stage, resolve_task_selection, ) @@ -48,6 +51,7 @@ _FAILURE_STAGES = frozenset( "device_session", "product_open", "sku_entry", + *_SKU_ENTRY_FAILURE_STAGES, "sku_selection", "price_verification", "screenshot_capture", @@ -85,7 +89,11 @@ def safe_failure_stage(error: BaseException) -> str: stage = getattr(error, "_cmbuyer_failure_stage", None) # exact str 避免恶意 str 子类在 hash/eq 中执行任意异常;诊断路径 # 自己也必须失败闭合,不能让异常正文越过 CLI 的统一脱敏出口。 - return stage if type(stage) is str and stage in _FAILURE_STAGES else "unknown" + if type(stage) is not str or stage not in _FAILURE_STAGES: + return "unknown" + if stage in _SKU_ENTRY_FAILURE_STAGES: + return stage if _safe_sku_entry_failure_stage(error) == stage else "unknown" + return stage except BaseException: return "unknown" @@ -101,6 +109,21 @@ def _annotate_failure(error: BaseException, stage: str) -> None: pass +def _failure_stage_for(error: BaseException, runner_stage: str) -> str: + if runner_stage == "sku_entry": + flow_stage = _safe_sku_entry_failure_stage(error) + if flow_stage is not None: + return flow_stage + return runner_stage + + +def _annotate_mapped_failure(mapped: BaseException, source: BaseException, runner_stage: str) -> None: + stage = _failure_stage_for(source, runner_stage) + if stage in _SKU_ENTRY_FAILURE_STAGES: + _annotate_sku_entry_failure(mapped, stage) + _annotate_failure(mapped, stage) + + @dataclass(frozen=True) class SkuSelectionRunResult: """已发布的截图和无页面正文 manifest 摘要。""" @@ -299,22 +322,22 @@ class SkuSelectionRunner: staging = None except (DeviceConnectionError, ProductUrlError, SkuSelectionRunError, SkuSelectionError) as error: _clean_staging(staging) - _annotate_failure(error, stage) + _annotate_failure(error, _failure_stage_for(error, stage)) raise except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error: _clean_staging(staging) mapped = SkuSelectionRunTimeoutError("规格面板运行超时,未发布任何证据产物。") - _annotate_failure(mapped, stage) + _annotate_mapped_failure(mapped, error, stage) raise mapped from error except OSError as error: _clean_staging(staging) mapped = SkuSelectionRunError("规格面板证据目录无法创建或发布,未发布任何证据产物。") - _annotate_failure(mapped, stage) + _annotate_mapped_failure(mapped, error, stage) raise mapped from error except Exception as error: _clean_staging(staging) mapped = SkuSelectionRunError("规格面板运行未完成,未发布任何证据产物。") - _annotate_failure(mapped, stage) + _annotate_mapped_failure(mapped, error, stage) raise mapped from error finally: # 失败路径只能复用 Flow 的版本、前台和面板证明;证明不了便停止,绝不盲目返回。 diff --git a/client/tests/pdd/test_sku_selection.py b/client/tests/pdd/test_sku_selection.py index b780077..68a5135 100644 --- a/client/tests/pdd/test_sku_selection.py +++ b/client/tests/pdd/test_sku_selection.py @@ -605,14 +605,17 @@ class SkuSelectionRunnerTests(unittest.TestCase): return "" return super().jsonrpc_call(method, params, timeout) + adb = _FakeAdb() + device = NoPanelAfterEntry("") + adb.on_intent = lambda: setattr(device, "hierarchy", _PRODUCT_PAGE) with TemporaryDirectory() as temporary, self.assertRaises(SkuSelectionError) as raised: - self._runner(_FakeAdb(), NoPanelAfterEntry()).run( + SkuSelectionRunner(adb, lambda serial: device, 0.03).run( "device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result" ) - self.assertEqual(safe_failure_stage(raised.exception), "sku_entry") + self.assertEqual(safe_failure_stage(raised.exception), "sku_entry_panel_verify") forged = SkuSelectionError("private") - setattr(forged, "_cmbuyer_failure_stage", "") + setattr(forged, "_cmbuyer_failure_stage", "sku_entry_click") self.assertEqual(safe_failure_stage(forged), "unknown") class HostileSetterError(DeviceConnectionError): @@ -629,6 +632,45 @@ class SkuSelectionRunnerTests(unittest.TestCase): ) self.assertEqual(safe_failure_stage(hostile.exception), "unknown") + def test_failure_stage_identifies_sku_entry_pre_intent(self) -> None: + device = _RawDevice(_PRODUCT_PAGE) + with TemporaryDirectory() as temporary, self.assertRaises(SkuSelectionError) as raised: + SkuSelectionRunner(_FakeAdb(), lambda serial: device, 0.03).run( + "device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result" + ) + + self.assertEqual(safe_failure_stage(raised.exception), "sku_entry_pre_intent") + self.assertEqual(_actions(device, "click"), []) + + def test_failure_stage_identifies_sku_entry_discovery(self) -> None: + device = _RawDevice("") + with TemporaryDirectory() as temporary, self.assertRaises(SkuSelectionError) as raised: + SkuSelectionRunner(_FakeAdb(), lambda serial: device, 0.01).run( + "device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result" + ) + + self.assertEqual(safe_failure_stage(raised.exception), "sku_entry_discovery") + self.assertEqual(_actions(device, "click"), []) + + def test_failure_stage_identifies_sku_entry_click(self) -> None: + class ClickFailureDevice(_RawDevice): + def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str: + if method == "click": + self.calls.append(("jsonrpc", method, params, timeout)) + raise TimeoutError("private device detail") + return super().jsonrpc_call(method, params, timeout) + + adb = _FakeAdb() + device = ClickFailureDevice("") + adb.on_intent = lambda: setattr(device, "hierarchy", _PRODUCT_PAGE) + with TemporaryDirectory() as temporary, self.assertRaises(SkuSelectionRunError) as raised: + SkuSelectionRunner(adb, lambda serial: device, 0.03).run( + "device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result" + ) + + self.assertEqual(safe_failure_stage(raised.exception), "sku_entry_click") + self.assertEqual(len(_actions(device, "click")), 1) + def test_target_created_during_publish_is_preserved_without_staging_residue(self) -> None: with TemporaryDirectory() as temporary: target = Path(temporary) / "result"