diff --git a/client/scripts/run_t103_sku_selection.py b/client/scripts/run_t103_sku_selection.py index 7438acf..695813d 100644 --- a/client/scripts/run_t103_sku_selection.py +++ b/client/scripts/run_t103_sku_selection.py @@ -15,7 +15,7 @@ from cmbuyer_client.device.adb import AdbClient, DeviceConnectionError, Subproce from cmbuyer_client.device.baseline import NoReconnectUiautomatorConnector from cmbuyer_client.pdd.product_url import ProductUrlError, parse_product_url from cmbuyer_client.pdd.sku_selection import EXPECTED_GOODS_ID, SkuSelectionError, TASK_TO_UI_SELECTION -from cmbuyer_client.pdd.sku_selection_runner import SkuSelectionRunError, SkuSelectionRunner +from cmbuyer_client.pdd.sku_selection_runner import SkuSelectionRunError, SkuSelectionRunner, safe_failure_stage def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace: @@ -66,7 +66,10 @@ def main(argv: list[str] | None = None) -> int: result = runner.run(arguments.serial, arguments.url, arguments.color, arguments.size, arguments.output_dir) except (DeviceConnectionError, SkuSelectionRunError, SkuSelectionError) as error: # Flow 可能来自测试替身或未来实现;CLI 不回显任何异常正文,避免泄露节点树或页面文本。 - print("规格恢复失败:已停止,未发布本地证据目录。", file=sys.stderr) + print( + f"规格恢复失败:stage={safe_failure_stage(error)};已停止,未发布本地证据目录。", + file=sys.stderr, + ) return 1 except OSError: print("规格恢复失败:无法创建或发布本地证据目录。", file=sys.stderr) diff --git a/client/src/cmbuyer_client/pdd/sku_selection_runner.py b/client/src/cmbuyer_client/pdd/sku_selection_runner.py index 5f6600e..c0f118a 100644 --- a/client/src/cmbuyer_client/pdd/sku_selection_runner.py +++ b/client/src/cmbuyer_client/pdd/sku_selection_runner.py @@ -23,7 +23,7 @@ from uiautomator2.exceptions import HTTPTimeoutError from ..device.adb import AdbClient, DeviceConnectionError, DeviceInspection from ..device.baseline import PDD_PACKAGE, SCREENSHOT_PARAMS, _save_base64_screenshot, _sha256_file from .product_open import EXPECTED_PDD_VERSION -from .product_url import ProductUrl, parse_product_url +from .product_url import ProductUrl, ProductUrlError, parse_product_url from .sku_selection import ( EXPECTED_GOODS_ID, EXPECTED_UNIT_PRICE, @@ -39,6 +39,24 @@ EXPECTED_DEVICE_MODEL = "PKG110" EXPECTED_ANDROID_VERSION = "16" EXPECTED_SCREEN_SIZE = (1080, 2376) +# CLI 只允许输出这些固定阶段码。阶段码描述运行器自己的控制流,不包含页面 +# 文本、节点属性、serial、路径或第三方异常;未知/伪造值统一降级为 unknown。 +_FAILURE_STAGES = frozenset( + ( + "precheck", + "device_inspection", + "device_session", + "product_open", + "sku_entry", + "sku_selection", + "price_verification", + "screenshot_capture", + "screenshot_reverify", + "safe_exit", + "publish", + ) +) + class SkuSelectionRunError(RuntimeError): """T-103 运行未完整完成;错误文本不携带设备或页面原文。""" @@ -60,6 +78,29 @@ class SkuSelectionDeviceAdapterError(SkuSelectionRunError): """第三方设备接口失败的脱敏映射。""" +def safe_failure_stage(error: BaseException) -> str: + """返回允许公开的固定阶段码,绝不回显异常正文。""" + + try: + 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" + except BaseException: + return "unknown" + + +def _annotate_failure(error: BaseException, stage: str) -> None: + """只给本次异常附加白名单控制流事实;原异常文本仍不对外输出。""" + + safe_stage = stage if stage in _FAILURE_STAGES else "unknown" + try: + setattr(error, "_cmbuyer_failure_stage", safe_stage) + except BaseException: + # 极端第三方异常不允许写属性时仍保持原失败闭合语义。 + pass + + @dataclass(frozen=True) class SkuSelectionRunResult: """已发布的截图和无页面正文 manifest 摘要。""" @@ -183,38 +224,52 @@ class SkuSelectionRunner: task_size: str, output_directory: Path, ) -> SkuSelectionRunResult: - link = parse_product_url(product_url) - if link.goods_id != EXPECTED_GOODS_ID: - raise SkuSelectionRunError("商品不是 T-103 已取证目标,已停止操作。") - selection = resolve_task_selection(task_color, task_size) - target = Path(output_directory) - _validate_new_target(target) - + stage = "precheck" + staging: Path | None = None adapter: UiautomatorSkuPanelAdapter | None = None flow: SkuSelectionFlow | None = None - staging = _prepare_staging(target) - deadline = self._monotonic_clock() + self._timeout_seconds try: + link = parse_product_url(product_url) + if link.goods_id != EXPECTED_GOODS_ID: + raise SkuSelectionRunError("商品不是 T-103 已取证目标,已停止操作。") + selection = resolve_task_selection(task_color, task_size) + target = Path(output_directory) + _validate_new_target(target) + staging = _prepare_staging(target) + deadline = self._monotonic_clock() + self._timeout_seconds + + stage = "device_inspection" inspection = self._adb_client.inspect(serial) _require_expected_device(inspection) + + stage = "device_session" adapter = UiautomatorSkuPanelAdapter(self._connector(serial), self._timeout_seconds) _require_expected_version(adapter.app_info(PDD_PACKAGE)) if adapter.display_size() != EXPECTED_SCREEN_SIZE: raise SkuSelectionRunError("设备不是已取证的竖屏坐标空间,已停止操作。") pre_intent_hierarchy = adapter.dump_window_hierarchy() + # 固定 ACTION_VIEW、固定 PDD package 和 canonical goods_id;不接受任意 URL 或 shell。 + stage = "product_open" self._adb_client.start_pdd_view_intent(serial, link.goods_id) remaining = deadline - self._monotonic_clock() if remaining <= 0: raise SkuSelectionRunTimeoutError("等待规格入口超时,未执行点击。") + + stage = "sku_entry" flow = SkuSelectionFlow(adapter, entry_wait_timeout_seconds=remaining) flow.open_sku_panel(link.canonical_url, pre_intent_hierarchy) + + stage = "sku_selection" flow.select_sku_options(selection) + + stage = "price_verification" unit_price = flow.verify_target_selection_and_read_price(selection) if unit_price != EXPECTED_UNIT_PRICE: raise SkuSelectionUnexpectedPriceError("规格面板现价不是本任务已确认值,已停止操作。") + stage = "screenshot_capture" screenshot_path = staging / "screenshot.png" try: _save_base64_screenshot(adapter.capture_screenshot(), screenshot_path) @@ -226,11 +281,15 @@ class SkuSelectionRunner: manifest_path = staging / "manifest.json" # 截图可能落在动态页面切换边界;发布前必须用一棵更新节点树同时重证两维和现价。 + stage = "screenshot_reverify" final_price = flow.verify_target_selection_and_read_price(selection) if final_price != EXPECTED_UNIT_PRICE: raise SkuSelectionUnexpectedPriceError("截图后规格面板现价不是本任务已确认值,已停止操作。") # 正常路径仍经 Flow 做最后一次前台和面板判定;返回操作只发生一次。 + stage = "safe_exit" flow.exit_sku_panel_safely() + + stage = "publish" manifest_path.write_text( json.dumps(_manifest(inspection, serial, link, screenshot_path, task_color, task_size), ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8", @@ -238,18 +297,25 @@ class SkuSelectionRunner: # Windows 的 rename 不替换既有目标;并发创建 target 时保留其内容并把本次运行判失败。 os.rename(staging, target) staging = None - except (DeviceConnectionError, SkuSelectionRunError, SkuSelectionError): + except (DeviceConnectionError, ProductUrlError, SkuSelectionRunError, SkuSelectionError) as error: _clean_staging(staging) + _annotate_failure(error, stage) raise except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error: _clean_staging(staging) - raise SkuSelectionRunTimeoutError("规格面板运行超时,未发布任何证据产物。") from error + mapped = SkuSelectionRunTimeoutError("规格面板运行超时,未发布任何证据产物。") + _annotate_failure(mapped, stage) + raise mapped from error except OSError as error: _clean_staging(staging) - raise SkuSelectionRunError("规格面板证据目录无法创建或发布,未发布任何证据产物。") from error + mapped = SkuSelectionRunError("规格面板证据目录无法创建或发布,未发布任何证据产物。") + _annotate_failure(mapped, stage) + raise mapped from error except Exception as error: _clean_staging(staging) - raise SkuSelectionRunError("规格面板运行未完成,未发布任何证据产物。") from error + mapped = SkuSelectionRunError("规格面板运行未完成,未发布任何证据产物。") + _annotate_failure(mapped, stage) + raise mapped from error finally: # 失败路径只能复用 Flow 的版本、前台和面板证明;证明不了便停止,绝不盲目返回。 if flow is not None and adapter is not None and adapter.entry_was_tapped and not adapter.left_panel: diff --git a/client/tests/pdd/test_sku_selection.py b/client/tests/pdd/test_sku_selection.py index a0f7f0c..6b28bbb 100644 --- a/client/tests/pdd/test_sku_selection.py +++ b/client/tests/pdd/test_sku_selection.py @@ -15,7 +15,7 @@ from PIL import Image import cmbuyer_client.pdd as pdd import cmbuyer_client.pdd.sku_selection_runner as runner_module -from cmbuyer_client.device.adb import AdbDevice, DeviceInspection +from cmbuyer_client.device.adb import AdbDevice, DeviceConnectionError, DeviceInspection from cmbuyer_client.pdd import SkuSelectionError, SkuSelectionFlow, SkuSelectionRunner from cmbuyer_client.pdd.sku_selection import SkuPanelDevice, _action_bounds, resolve_task_selection from cmbuyer_client.pdd.sku_selection_runner import ( @@ -23,6 +23,7 @@ from cmbuyer_client.pdd.sku_selection_runner import ( SkuSelectionRunError, SkuSelectionScreenshotError, UiautomatorSkuPanelAdapter, + safe_failure_stage, ) @@ -383,6 +384,38 @@ class SkuSelectionRunnerTests(unittest.TestCase): self.assertFalse((target / "hierarchy.xml").exists()) self.assertEqual(_actions(device, "pressKey"), [("jsonrpc", "pressKey", ["back"], 10)]) + def test_failure_stage_is_fixed_control_flow_metadata_without_error_text(self) -> None: + class NoPanelAfterEntry(_RawDevice): + def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str: + if method == "click": + self.calls.append(("jsonrpc", method, params, timeout)) + return "" + return super().jsonrpc_call(method, params, timeout) + + with TemporaryDirectory() as temporary, self.assertRaises(SkuSelectionError) as raised: + self._runner(_FakeAdb(), NoPanelAfterEntry()).run( + "device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result" + ) + self.assertEqual(safe_failure_stage(raised.exception), "sku_entry") + + forged = SkuSelectionError("private") + setattr(forged, "_cmbuyer_failure_stage", "") + self.assertEqual(safe_failure_stage(forged), "unknown") + + class HostileSetterError(DeviceConnectionError): + def __setattr__(self, name: str, value: object) -> None: + raise KeyboardInterrupt("SERIAL=private secret") + + class FailingAdb(_FakeAdb): + def inspect(self, serial: str) -> DeviceInspection: + raise HostileSetterError("private") + + with TemporaryDirectory() as temporary, self.assertRaises(HostileSetterError) as hostile: + SkuSelectionRunner(FailingAdb(), lambda serial: _RawDevice(), 10).run( + "device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result" + ) + self.assertEqual(safe_failure_stage(hostile.exception), "unknown") + def test_target_created_during_publish_is_preserved_without_staging_residue(self) -> None: with TemporaryDirectory() as temporary: target = Path(temporary) / "result" @@ -401,8 +434,9 @@ class SkuSelectionRunnerTests(unittest.TestCase): def test_bad_screenshot_or_existing_target_never_publishes_manifest(self) -> None: with TemporaryDirectory() as temporary: target = Path(temporary) / "result" - with self.assertRaises(SkuSelectionScreenshotError): + with self.assertRaises(SkuSelectionScreenshotError) as screenshot_failure: self._runner(_FakeAdb(), _RawDevice(screenshot="not-image")).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target) + self.assertEqual(safe_failure_stage(screenshot_failure.exception), "screenshot_capture") self.assertFalse(target.exists()) self.assertEqual(list(Path(temporary).glob(".result.staging-*")), []) @@ -418,8 +452,9 @@ class SkuSelectionRunnerTests(unittest.TestCase): target.mkdir() sentinel = target / "keep" sentinel.write_text("keep", encoding="utf-8") - with self.assertRaises(SkuSelectionRunError): + with self.assertRaises(SkuSelectionRunError) as existing_target_failure: self._runner(adb, device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target) + self.assertEqual(safe_failure_stage(existing_target_failure.exception), "precheck") self.assertEqual(adb.calls, []) self.assertEqual(device.calls, []) self.assertEqual(sentinel.read_text(encoding="utf-8"), "keep") @@ -654,25 +689,43 @@ class SkuSelectionCliTests(unittest.TestCase): def test_cli_main_catches_flow_error_without_traceback_or_page_body(self) -> None: script = _load_runner_script() - class FlowFailingRunner: - def __init__(self, *args: object, **kwargs: object) -> None: pass - def run(self, *args: object, **kwargs: object) -> object: - raise SkuSelectionError("page-body") + secret = "SERIAL=192.168.0.173:5555 PATH=C:/private page-body" - stderr = BytesIO() - # TextIOWrapper keeps the assertion independent from host console encoding. - import io - text_stderr = io.TextIOWrapper(stderr, encoding="utf-8") - with patch.object(script, "SkuSelectionRunner", FlowFailingRunner), redirect_stderr(text_stderr): - status = script.main([ - "--serial", "device-1", "--url", _TARGET_URL, "--color", _TASK_COLOR, - "--size", _TASK_SIZE, "--output-dir", "evidence", - ]) - text_stderr.flush() - output = stderr.getvalue().decode("utf-8") - self.assertEqual(status, 1) - self.assertNotIn("Traceback", output) - self.assertNotIn("page-body", output) + class HostileGetterError(SkuSelectionError): + def __getattribute__(self, name: str) -> object: + if name == "_cmbuyer_failure_stage": + raise RuntimeError(secret) + return super().__getattribute__(name) + + class HostileStage(str): + def __hash__(self) -> int: + raise RuntimeError(secret) + + hostile_string = SkuSelectionError(secret) + setattr(hostile_string, "_cmbuyer_failure_stage", HostileStage("sku_entry")) + + for failure in (SkuSelectionError(secret), HostileGetterError(secret), hostile_string): + class FlowFailingRunner: + def __init__(self, *args: object, **kwargs: object) -> None: pass + def run(self, *args: object, **kwargs: object) -> object: + raise failure + + stderr = BytesIO() + # TextIOWrapper keeps the assertion independent from host console encoding. + import io + text_stderr = io.TextIOWrapper(stderr, encoding="utf-8") + with patch.object(script, "SkuSelectionRunner", FlowFailingRunner), redirect_stderr(text_stderr): + status = script.main([ + "--serial", "device-1", "--url", _TARGET_URL, "--color", _TASK_COLOR, + "--size", _TASK_SIZE, "--output-dir", "evidence", + ]) + text_stderr.flush() + output = stderr.getvalue().decode("utf-8") + self.assertEqual(status, 1) + self.assertIn("stage=unknown", output) + self.assertNotIn("Traceback", output) + self.assertNotIn(secret, output) + self.assertNotIn("page-body", output) def _load_runner_script() -> object: