From 7040bb61d8a2eaa92f8ad6b70076cb51776c6522 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Tue, 4 Aug 2026 09:26:37 +0800 Subject: [PATCH] feat(client): add guarded product link capture --- client/scripts/capture_product_open.py | 75 ++++++ client/src/cmbuyer_client/device/adb.py | 48 ++++ client/src/cmbuyer_client/pdd/__init__.py | 15 ++ client/src/cmbuyer_client/pdd/product_open.py | 234 ++++++++++++++++++ client/src/cmbuyer_client/pdd/product_url.py | 61 +++++ client/tests/device/test_adb.py | 95 ++++++- client/tests/pdd/__init__.py | 1 + client/tests/pdd/test_product_open.py | 186 ++++++++++++++ client/tests/pdd/test_product_url.py | 49 ++++ docs/03-tech-stack.md | 20 ++ docs/current-state.md | 37 ++- 11 files changed, 808 insertions(+), 13 deletions(-) create mode 100644 client/scripts/capture_product_open.py create mode 100644 client/src/cmbuyer_client/pdd/__init__.py create mode 100644 client/src/cmbuyer_client/pdd/product_open.py create mode 100644 client/src/cmbuyer_client/pdd/product_url.py create mode 100644 client/tests/pdd/__init__.py create mode 100644 client/tests/pdd/test_product_open.py create mode 100644 client/tests/pdd/test_product_url.py diff --git a/client/scripts/capture_product_open.py b/client/scripts/capture_product_open.py new file mode 100644 index 0000000..a20b5ac --- /dev/null +++ b/client/scripts/capture_product_open.py @@ -0,0 +1,75 @@ +"""打开已验证的拼多多商品直链并采集只读本地证据。""" + +from __future__ import annotations + +import argparse +from pathlib import Path +import sys + + +CLIENT_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(CLIENT_ROOT / "src")) + +from cmbuyer_client.device.adb import AdbClient, DeviceConnectionError, SubprocessAdbRunner +from cmbuyer_client.device.baseline import NoReconnectUiautomatorConnector +from cmbuyer_client.pdd.product_open import ProductOpenCapturer, ProductOpenError +from cmbuyer_client.pdd.product_url import ProductUrlError, parse_product_url + + +def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="打开 canonical 拼多多商品链接并采集只读证据。") + parser.add_argument("--serial", required=True, help="ADB device serial;禁止自动选择。") + parser.add_argument("--url", required=True, help="唯一允许的 goods.html?goods_id= 直链。") + parser.add_argument("--output-dir", required=True, type=Path, help="新建的本地证据目录;不得覆盖已有目录。") + parser.add_argument("--timeout", type=float, default=10.0, help="ADB 和只读 RPC 超时(秒)。") + parser.add_argument("--adb", default="adb", help="adb 可执行文件路径。") + return parser.parse_args(argv) + + +def validate_arguments(arguments: argparse.Namespace) -> None: + if not arguments.serial.strip(): + raise ValueError("必须显式提供非空 --serial。") + if arguments.timeout <= 0: + raise ValueError("--timeout 必须大于 0。") + parse_product_url(arguments.url) + + +def main(argv: list[str] | None = None) -> int: + arguments = parse_arguments(argv) + try: + validate_arguments(arguments) + link = parse_product_url(arguments.url) + except (ValueError, ProductUrlError) as error: + print(f"失败:{error}", file=sys.stderr) + return 2 + + try: + import adbutils + import uiautomator2 as u2 + except ImportError: + print("失败:缺少 uiautomator2;请在采购工具虚拟环境中运行。", file=sys.stderr) + return 2 + + client = AdbClient(SubprocessAdbRunner(arguments.adb), timeout_seconds=arguments.timeout) + connector = NoReconnectUiautomatorConnector( + adbutils.AdbClient(socket_timeout=arguments.timeout).device_list, + u2.connect, + ) + capturer = ProductOpenCapturer(client, connector, timeout_seconds=arguments.timeout) + try: + result = capturer.open_and_capture(arguments.serial, link.canonical_url, arguments.output_dir) + except (DeviceConnectionError, ProductOpenError) as error: + # 不打印 ADB 输出、serial、Activity、XML 或页面正文。 + print(f"商品打开取证失败:{error}", file=sys.stderr) + return 1 + except OSError: + print("商品打开取证失败:无法创建或发布本地证据目录。", file=sys.stderr) + return 1 + + print(f"商品打开取证完成:{result.output_directory}") + print(f"manifest:{result.manifest_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/client/src/cmbuyer_client/device/adb.py b/client/src/cmbuyer_client/device/adb.py index a4d4f04..2a95886 100644 --- a/client/src/cmbuyer_client/device/adb.py +++ b/client/src/cmbuyer_client/device/adb.py @@ -48,6 +48,10 @@ class DuplicatePhysicalDeviceError(DeviceConnectionError): """同一物理手机通过多个 ADB 通道同时在线。""" +class IntentLaunchUnconfirmedError(DeviceConnectionError): + """`am start -W` 没有给出可确认的启动成功结果。""" + + @dataclass(frozen=True) class CommandResult: """可注入命令执行器的最小、可离线构造结果。""" @@ -57,6 +61,14 @@ class CommandResult: returncode: int = 0 +@dataclass(frozen=True) +class IntentLaunchSummary: + """不含 Activity、页面内容或 ADB 输出的受限启动摘要。""" + + status: str + returncode: int + + class CommandRunner(Protocol): """运行 ADB 子命令的可替换边界。""" @@ -195,6 +207,42 @@ class AdbClient: result = self._run_checked(("devices", "-l")) return parse_adb_devices(result.stdout) + def start_pdd_view_intent(self, serial: str, goods_id: str) -> IntentLaunchSummary: + """以参数数组启动唯一允许的拼多多 ACTION_VIEW Intent。 + + 这里刻意不提供任意 shell 或任意 package 的执行接口。调用方必须先完成 + ``inspect`` 和应用版本核验;本方法在本层从纯数字 ``goods_id`` 重建 URL,调用方不能 + 把另一个 URL 直接交给 ADB。本方法既不点击控件,也不解析 Activity 或页面文本。 + """ + + selected_serial = _require_serial(serial) + if ( + not isinstance(goods_id, str) + or not goods_id + or any(character < "0" or character > "9" for character in goods_id) + ): + raise ValueError("goods_id 必须是纯数字") + canonical_url = f"https://mobile.yangkeduo.com/goods.html?goods_id={goods_id}" + result = self._run_checked( + ( + "-s", + selected_serial, + "shell", + "am", + "start", + "-W", + "-a", + "android.intent.action.VIEW", + "-d", + canonical_url, + "-p", + "com.xunmeng.pinduoduo", + ) + ) + if not any(line.strip() == "Status: ok" for line in result.stdout.splitlines()): + raise IntentLaunchUnconfirmedError("商品链接启动结果无法确认,已停止后续取证。") + return IntentLaunchSummary(status="ok", returncode=result.returncode) + def _physical_identity(self, device: AdbDevice) -> frozenset[str]: serialno = self._getprop(device.serial, "ro.serialno") boot_serialno = self._getprop(device.serial, "ro.boot.serialno") diff --git a/client/src/cmbuyer_client/pdd/__init__.py b/client/src/cmbuyer_client/pdd/__init__.py new file mode 100644 index 0000000..389db9b --- /dev/null +++ b/client/src/cmbuyer_client/pdd/__init__.py @@ -0,0 +1,15 @@ +"""拼多多链接的受限打开与只读取证。 + +此包不提供页面选择器、输入、滑动、下单或支付能力。 +""" + +from .product_open import ProductOpenCapturer, ProductOpenResult +from .product_url import ProductUrl, ProductUrlError, parse_product_url + +__all__ = [ + "ProductOpenCapturer", + "ProductOpenResult", + "ProductUrl", + "ProductUrlError", + "parse_product_url", +] diff --git a/client/src/cmbuyer_client/pdd/product_open.py b/client/src/cmbuyer_client/pdd/product_open.py new file mode 100644 index 0000000..db12e45 --- /dev/null +++ b/client/src/cmbuyer_client/pdd/product_open.py @@ -0,0 +1,234 @@ +"""安全打开 canonical 商品链接后的只读取证。""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import UTC, datetime +from hashlib import sha256 +import json +import os +from pathlib import Path +import shutil +from typing import Any, Protocol +from uuid import uuid4 + +from adbutils.errors import AdbTimeout +from uiautomator2.exceptions import HTTPTimeoutError + +from ..device.adb import AdbClient, DeviceConnectionError, DeviceInspection, IntentLaunchSummary +from ..device.baseline import ( + HIERARCHY_PARAMS, + PDD_PACKAGE, + SCREENSHOT_PARAMS, + _save_base64_screenshot, + _sha256_file, + _validate_hierarchy, +) +from .product_url import ProductUrl, parse_product_url + + +EXPECTED_PDD_VERSION = "8.17.0" + + +class ProductOpenError(RuntimeError): + """商品打开或证据发布未完整完成。""" + + +class ProductVersionMismatchError(ProductOpenError): + """运行时拼多多版本不是经取证允许的版本。""" + + +class ProductPackageMismatchError(ProductOpenError): + """Intent 后当前前台包不是拼多多。""" + + +class ProductOpenTimeoutError(ProductOpenError): + """商品打开后的只读取证超时。""" + + +class ProductScreenshotCaptureError(ProductOpenError): + """Intent 后截图不能作为完整 PNG 证据保存。""" + + +class ProductHierarchyCaptureError(ProductOpenError): + """Intent 后完整节点树不能作为有效 XML 证据保存。""" + + +class ProductOpenUiDevice(Protocol): + """本任务所需的只读 uiautomator2 接口;故意没有任何 UI 操作方法。""" + + def app_info(self, package_name: str) -> dict[str, Any]: + """读取应用元数据。""" + + def app_current(self) -> dict[str, Any]: + """读取当前前台应用元数据。""" + + def jsonrpc_call(self, method: str, params: Any = None, timeout: float = 10) -> Any: + """调用只读取证所需的公开 JSON-RPC 方法。""" + + +@dataclass(frozen=True) +class ProductOpenResult: + """已原子发布的商品打开证据位置。""" + + output_directory: Path + manifest_path: Path + screenshot_path: Path + hierarchy_path: Path + + +class ProductOpenCapturer: + """以 fail-closed 顺序打开已重建链接,并在打开后只读留证。 + + 本类不判断商品页、Activity、文案或控件;打开后只确认当前 package,随后采集截图与 + 完整节点树。任何失败都不会发布半成品证据目录。 + """ + + def __init__( + self, + adb_client: AdbClient, + connector: Callable[[str], ProductOpenUiDevice], + timeout_seconds: float, + ) -> None: + if timeout_seconds <= 0: + raise ValueError("timeout_seconds 必须大于 0") + self._adb_client = adb_client + self._connector = connector + self._timeout_seconds = timeout_seconds + + def open_and_capture(self, serial: str, product_url: str, output_directory: Path) -> ProductOpenResult: + """完成唯一允许的 Intent 打开及其后的只读取证。""" + + # 公共入口只接收原始字符串并每次重新解析,不能由调用方构造不一致的值对象伪造 manifest。 + link = parse_product_url(product_url) + target = Path(output_directory) + _validate_new_target(target) + + staging: Path | None = None + try: + # inspect 必须先于连接和 Intent,复用 T-101 的显式 serial、重复物理设备拒绝逻辑。 + inspection = self._adb_client.inspect(serial) + device = self._connector(serial) + pdd_version = _require_expected_version(device.app_info(PDD_PACKAGE)) + + # 版本精确匹配是 Intent 的前置条件,失败时绝不调用 start_pdd_view_intent。 + intent = self._adb_client.start_pdd_view_intent(serial, link.goods_id) + _require_pdd_foreground(device.app_current()) + + target.parent.mkdir(parents=True, exist_ok=True) + staging = target.parent / f".{target.name}.staging-{uuid4().hex}" + staging.mkdir() + screenshot_path = staging / "screenshot.png" + try: + _save_base64_screenshot( + device.jsonrpc_call("takeScreenshot", SCREENSHOT_PARAMS, timeout=self._timeout_seconds), + screenshot_path, + ) + except (AdbTimeout, HTTPTimeoutError, TimeoutError): + raise + except Exception as error: + raise ProductScreenshotCaptureError("商品打开后截图取证失败,未发布任何证据产物。") from error + + try: + hierarchy = device.jsonrpc_call( + "dumpWindowHierarchy", + HIERARCHY_PARAMS, + timeout=self._timeout_seconds, + ) + _validate_hierarchy(hierarchy) + except (AdbTimeout, HTTPTimeoutError, TimeoutError): + raise + except Exception as error: + raise ProductHierarchyCaptureError("商品打开后节点树取证失败,未发布任何证据产物。") from error + hierarchy_path = staging / "hierarchy.xml" + hierarchy_path.write_text(hierarchy, encoding="utf-8") + + manifest_path = staging / "manifest.json" + manifest_path.write_text( + json.dumps( + _manifest(inspection, serial, link, pdd_version, intent, screenshot_path, hierarchy_path), + ensure_ascii=False, + indent=2, + sort_keys=True, + ) + + "\n", + encoding="utf-8", + ) + os.replace(staging, target) + except (ProductOpenError, DeviceConnectionError): + _clean_staging(staging) + raise + except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error: + _clean_staging(staging) + raise ProductOpenTimeoutError("商品打开后的只读取证超时,未发布任何证据产物。") from error + except Exception as error: + _clean_staging(staging) + # 底层异常可能含 serial、路径或远端页面内容,不能直接向 CLI 或日志传播。 + raise ProductOpenError("商品打开或只读取证未完成,未发布任何证据产物。") from error + + return ProductOpenResult( + output_directory=target, + manifest_path=target / "manifest.json", + screenshot_path=target / "screenshot.png", + hierarchy_path=target / "hierarchy.xml", + ) + + +def _validate_new_target(target: Path) -> None: + if target.exists(): + raise ProductOpenError("输出目录已存在;为防止混入旧证据,拒绝覆盖。") + if not target.name: + raise ProductOpenError("输出目录必须是明确的新目录。") + + +def _clean_staging(staging: Path | None) -> None: + if staging is not None and staging.exists(): + # staging 仅在本次调用中创建,删除前不解析或扩展任何调用方提供的路径。 + shutil.rmtree(staging) + + +def _require_expected_version(app_info: dict[str, Any]) -> str: + if not isinstance(app_info, dict): + raise ProductVersionMismatchError("拼多多版本与已取证版本不一致,已停止打开商品链接。") + version = app_info.get("versionName") or app_info.get("version_name") + if not isinstance(version, str) or version != EXPECTED_PDD_VERSION: + raise ProductVersionMismatchError("拼多多版本与已取证版本不一致,已停止打开商品链接。") + return version + + +def _require_pdd_foreground(current: dict[str, Any]) -> None: + if not isinstance(current, dict) or current.get("package") != PDD_PACKAGE: + raise ProductPackageMismatchError("商品链接打开后前台应用不是拼多多,已停止后续取证。") + + +def _manifest( + inspection: DeviceInspection, + serial: str, + link: ProductUrl, + pdd_version: str, + intent: IntentLaunchSummary, + screenshot_path: Path, + hierarchy_path: Path, +) -> dict[str, Any]: + """只写审计摘要;原始 serial、Activity、ADB 输出和页面正文均不进入 manifest。""" + + return { + "schema_version": 1, + "captured_at": datetime.now(UTC).isoformat(), + "product": {"goods_id": link.goods_id, "canonical_url": link.canonical_url}, + "channel": "wifi" if ":" in serial else "usb", + "serial_sha256": sha256(serial.encode("utf-8")).hexdigest(), + "device": { + "model": inspection.model, + "android_version": inspection.android_version, + "pdd_package": PDD_PACKAGE, + "pdd_version": pdd_version, + }, + "intent": {"status": intent.status, "returncode": intent.returncode}, + "current_package": PDD_PACKAGE, + "artifacts": [ + {"path": screenshot_path.name, "sha256": _sha256_file(screenshot_path)}, + {"path": hierarchy_path.name, "sha256": _sha256_file(hierarchy_path)}, + ], + } diff --git a/client/src/cmbuyer_client/pdd/product_url.py b/client/src/cmbuyer_client/pdd/product_url.py new file mode 100644 index 0000000..f413cba --- /dev/null +++ b/client/src/cmbuyer_client/pdd/product_url.py @@ -0,0 +1,61 @@ +"""唯一允许交给 Android Intent 的商品链接。""" + +from __future__ import annotations + +from dataclasses import dataclass +from urllib.parse import parse_qsl, urlsplit + + +_SCHEME = "https" +_HOST = "mobile.yangkeduo.com" +_PATH = "/goods.html" + + +class ProductUrlError(ValueError): + """输入不是可安全重建的 canonical 商品链接。""" + + +@dataclass(frozen=True) +class ProductUrl: + """经验证的商品标识及由它重建的 canonical URL。""" + + goods_id: str + canonical_url: str + + +def parse_product_url(value: str) -> ProductUrl: + """只接受一个 ASCII 数字 ``goods_id`` 的拼多多商品直链。 + + 解析结果绝不原样透传:Intent 使用的 URL 必须从 ``goods_id`` 重新构建,以排除 + 短链、额外参数、userinfo、fragment 和 URL 解析器的边缘表示。 + """ + + if not isinstance(value, str): + raise ProductUrlError("商品链接必须是字符串。") + try: + parsed = urlsplit(value) + port = parsed.port + query_pairs = parse_qsl(parsed.query, keep_blank_values=True, strict_parsing=True) + except ValueError as error: + raise ProductUrlError("商品链接格式无效。") from error + + if ( + parsed.scheme != _SCHEME + or parsed.hostname != _HOST + or parsed.username is not None + or parsed.password is not None + or port is not None + or parsed.path != _PATH + or parsed.fragment + ): + raise ProductUrlError("商品链接不是允许的拼多多商品直链。") + if len(query_pairs) != 1 or query_pairs[0][0] != "goods_id": + raise ProductUrlError("商品链接必须且只能包含一个 goods_id 参数。") + + goods_id = query_pairs[0][1] + if not goods_id or any(character < "0" or character > "9" for character in goods_id): + raise ProductUrlError("goods_id 必须是纯数字。") + canonical_url = f"{_SCHEME}://{_HOST}{_PATH}?goods_id={goods_id}" + if value != canonical_url: + raise ProductUrlError("商品链接必须使用唯一 canonical 表示。") + return ProductUrl(goods_id=goods_id, canonical_url=canonical_url) diff --git a/client/tests/device/test_adb.py b/client/tests/device/test_adb.py index 814b784..63678c5 100644 --- a/client/tests/device/test_adb.py +++ b/client/tests/device/test_adb.py @@ -16,11 +16,14 @@ from cmbuyer_client.device.adb import ( AdbClient, CommandResult, DeviceIdentityUnconfirmedError, + DeviceCommandError, + DeviceCommandTimeoutError, DeviceNotFoundError, DeviceOfflineError, DeviceStateError, DeviceUnauthorizedError, DuplicatePhysicalDeviceError, + IntentLaunchUnconfirmedError, SerialRequiredError, ) @@ -176,7 +179,95 @@ class AdbClientTests(unittest.TestCase): def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult: raise subprocess.TimeoutExpired(arguments, timeout_seconds) - from cmbuyer_client.device.adb import DeviceCommandTimeoutError - with self.assertRaises(DeviceCommandTimeoutError): AdbClient(TimeoutRunner()).inspect(USB_SERIAL) + + def test_product_intent_is_fixed_to_action_view_and_pdd_package(self) -> None: + class IntentRunner: + def __init__(self) -> None: + self.calls: list[tuple[str, ...]] = [] + + def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult: + self.calls.append(tuple(arguments)) + return CommandResult(stdout="Status: ok\n") + + runner = IntentRunner() + summary = AdbClient(runner).start_pdd_view_intent( + USB_SERIAL, + "123", + ) + + self.assertEqual(summary.status, "ok") + self.assertEqual( + runner.calls, + [ + ( + "-s", + USB_SERIAL, + "shell", + "am", + "start", + "-W", + "-a", + "android.intent.action.VIEW", + "-d", + "https://mobile.yangkeduo.com/goods.html?goods_id=123", + "-p", + "com.xunmeng.pinduoduo", + ) + ], + ) + + def test_product_intent_without_explicit_success_is_rejected(self) -> None: + class UnknownIntentRunner: + def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult: + return CommandResult(stdout="Starting: Intent { ... }\n") + + with self.assertRaises(IntentLaunchUnconfirmedError): + AdbClient(UnknownIntentRunner()).start_pdd_view_intent( + USB_SERIAL, + "123", + ) + + def test_product_intent_rejects_invalid_goods_id_before_runner(self) -> None: + class RecordingRunner: + def __init__(self) -> None: + self.calls: list[tuple[str, ...]] = [] + + def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult: + self.calls.append(tuple(arguments)) + return CommandResult(stdout="Status: ok\n") + + invalid_values: tuple[object, ...] = ( + "", + "12a", + "123", + " 123", + "123 ", + "https://mobile.yangkeduo.com/goods.html?goods_id=123", + "am start -W -d anything", + 123, + None, + ) + for value in invalid_values: + with self.subTest(value=repr(value)): + runner = RecordingRunner() + with self.assertRaises(ValueError): + AdbClient(runner).start_pdd_view_intent(USB_SERIAL, value) # type: ignore[arg-type] + self.assertEqual(runner.calls, []) + + def test_product_intent_nonzero_and_timeout_remain_distinct(self) -> None: + class FailedIntentRunner: + def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult: + return CommandResult(stdout="sensitive command output", returncode=1) + + class TimeoutIntentRunner: + def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult: + raise subprocess.TimeoutExpired(arguments, timeout_seconds) + + with self.assertRaises(DeviceCommandError) as command_error: + AdbClient(FailedIntentRunner()).start_pdd_view_intent(USB_SERIAL, "123") + self.assertNotIn("sensitive command output", str(command_error.exception)) + + with self.assertRaises(DeviceCommandTimeoutError): + AdbClient(TimeoutIntentRunner()).start_pdd_view_intent(USB_SERIAL, "123") diff --git a/client/tests/pdd/__init__.py b/client/tests/pdd/__init__.py new file mode 100644 index 0000000..544684c --- /dev/null +++ b/client/tests/pdd/__init__.py @@ -0,0 +1 @@ +"""拼多多受限打开模块的离线测试。""" diff --git a/client/tests/pdd/test_product_open.py b/client/tests/pdd/test_product_open.py new file mode 100644 index 0000000..29c12f7 --- /dev/null +++ b/client/tests/pdd/test_product_open.py @@ -0,0 +1,186 @@ +"""商品打开围栏的离线测试;所有设备和命令均为 fake。""" + +from __future__ import annotations + +import base64 +from io import BytesIO +from pathlib import Path +import sys +from tempfile import TemporaryDirectory +import unittest + +from PIL import Image + + +CLIENT_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(CLIENT_ROOT / "src")) + +from cmbuyer_client.device.adb import AdbDevice, DeviceInspection, IntentLaunchSummary +from cmbuyer_client.pdd.product_open import ( + ProductOpenCapturer, + ProductOpenTimeoutError, + ProductOpenUiDevice, + ProductHierarchyCaptureError, + ProductPackageMismatchError, + ProductScreenshotCaptureError, + ProductVersionMismatchError, +) +from cmbuyer_client.pdd.product_url import ProductUrl, ProductUrlError + + +SERIAL = "192.168.0.173:5555" +URL = "https://mobile.yangkeduo.com/goods.html?goods_id=123" +HIERARCHY = "" + + +def _png_base64() -> str: + image_data = BytesIO() + Image.new("RGB", (1, 1), color="white").save(image_data, format="PNG") + return base64.b64encode(image_data.getvalue()).decode("ascii") + + +class FakeAdbClient: + def __init__(self) -> None: + self.calls: list[tuple[str, str | None]] = [] + self.inspection = DeviceInspection( + device=AdbDevice(serial=SERIAL, state="device", model="PKG110"), + model="PKG110", + android_version="16", + ) + + def inspect(self, serial: str) -> DeviceInspection: + self.calls.append(("inspect", serial)) + return self.inspection + + def start_pdd_view_intent(self, serial: str, goods_id: str) -> IntentLaunchSummary: + self.calls.append(("intent", goods_id)) + return IntentLaunchSummary(status="ok", returncode=0) + + +class FakeUiDevice: + def __init__( + self, + *, + version: str = "8.17.0", + current_package: str = "com.xunmeng.pinduoduo", + hierarchy: str = HIERARCHY, + timeout_on_screenshot: bool = False, + ) -> None: + self.version = version + self.current_package = current_package + self.hierarchy = hierarchy + self.timeout_on_screenshot = timeout_on_screenshot + self.calls: list[str] = [] + + def app_info(self, package_name: str) -> dict[str, str]: + self.calls.append("app_info") + return {"versionName": self.version} + + def app_current(self) -> dict[str, str]: + self.calls.append("app_current") + return {"package": self.current_package, "activity": "sensitive.activity.name"} + + def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str: + self.calls.append(method) + if method == "takeScreenshot": + if self.timeout_on_screenshot: + raise TimeoutError("raw remote detail") + return _png_base64() + if method == "dumpWindowHierarchy": + return self.hierarchy + raise AssertionError(f"unexpected RPC {method}") + + +class ProductOpenTests(unittest.TestCase): + def _capturer(self, adb: FakeAdbClient, device: FakeUiDevice) -> ProductOpenCapturer: + return ProductOpenCapturer(adb, lambda serial: device, timeout_seconds=2) + + def test_success_uses_canonical_url_and_redacted_atomic_manifest(self) -> None: + adb = FakeAdbClient() + device = FakeUiDevice() + with TemporaryDirectory() as temporary: + target = Path(temporary) / "evidence" + result = self._capturer(adb, device).open_and_capture(SERIAL, URL, target) + manifest = result.manifest_path.read_text(encoding="utf-8") + + self.assertTrue(result.screenshot_path.exists()) + self.assertTrue(result.hierarchy_path.exists()) + self.assertEqual(adb.calls, [("inspect", SERIAL), ("intent", "123")]) + self.assertEqual(device.calls, ["app_info", "app_current", "takeScreenshot", "dumpWindowHierarchy"]) + self.assertIn('"goods_id": "123"', manifest) + self.assertIn('"canonical_url": "https://mobile.yangkeduo.com/goods.html?goods_id=123"', manifest) + self.assertNotIn(SERIAL, manifest) + self.assertNotIn("sensitive.activity.name", manifest) + self.assertNotIn(HIERARCHY, manifest) + + def test_version_mismatch_halts_before_intent(self) -> None: + adb = FakeAdbClient() + for version in ("8.17.1", " 8.17.0 "): + with self.subTest(version=version), TemporaryDirectory() as temporary: + target = Path(temporary) / "evidence" + with self.assertRaises(ProductVersionMismatchError): + self._capturer(adb, FakeUiDevice(version=version)).open_and_capture(SERIAL, URL, target) + + self.assertEqual(adb.calls[-1:], [("inspect", SERIAL)]) + self.assertFalse(target.exists()) + + def test_public_entry_rejects_caller_constructed_url_value_object(self) -> None: + adb = FakeAdbClient() + with TemporaryDirectory() as temporary: + with self.assertRaises(ProductUrlError): + self._capturer(adb, FakeUiDevice()).open_and_capture( + SERIAL, + ProductUrl(goods_id="123", canonical_url="https://example.invalid/"), # type: ignore[arg-type] + Path(temporary) / "evidence", + ) + + self.assertEqual(adb.calls, []) + + def test_foreground_package_mismatch_halts_before_capture(self) -> None: + adb = FakeAdbClient() + device = FakeUiDevice(current_package="com.example.other") + with TemporaryDirectory() as temporary: + target = Path(temporary) / "evidence" + with self.assertRaises(ProductPackageMismatchError): + self._capturer(adb, device).open_and_capture(SERIAL, URL, target) + + self.assertEqual(adb.calls, [("inspect", SERIAL), ("intent", "123")]) + self.assertEqual(device.calls, ["app_info", "app_current"]) + self.assertFalse(target.exists()) + + def test_timeout_and_invalid_hierarchy_leave_no_partial_evidence(self) -> None: + scenarios = ( + (FakeUiDevice(timeout_on_screenshot=True), ProductOpenTimeoutError), + (FakeUiDevice(hierarchy=""), ProductHierarchyCaptureError), + ) + for device, error_type in scenarios: + with self.subTest(error_type=error_type.__name__), TemporaryDirectory() as temporary: + target = Path(temporary) / "evidence" + with self.assertRaises(error_type): + self._capturer(FakeAdbClient(), device).open_and_capture(SERIAL, URL, target) + + self.assertFalse(target.exists()) + self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), []) + + def test_invalid_screenshot_is_a_distinct_redacted_failure(self) -> None: + class InvalidScreenshotDevice(FakeUiDevice): + def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str: + if method == "takeScreenshot": + self.calls.append(method) + return "not valid base64!" + return super().jsonrpc_call(method, params, timeout) + + with TemporaryDirectory() as temporary: + target = Path(temporary) / "evidence" + with self.assertRaises(ProductScreenshotCaptureError) as raised: + self._capturer(FakeAdbClient(), InvalidScreenshotDevice()).open_and_capture(SERIAL, URL, target) + + self.assertNotIn("base64", str(raised.exception).lower()) + self.assertFalse(target.exists()) + self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), []) + + def test_read_only_protocol_has_no_ui_operation_methods(self) -> None: + forbidden = {"click", "swipe", "send_keys", "set_text", "press", "long_click"} + + self.assertTrue(forbidden.isdisjoint(ProductOpenUiDevice.__dict__)) + self.assertEqual(base64.b64decode(_png_base64())[:8], b"\x89PNG\r\n\x1a\n") diff --git a/client/tests/pdd/test_product_url.py b/client/tests/pdd/test_product_url.py new file mode 100644 index 0000000..b7dcc33 --- /dev/null +++ b/client/tests/pdd/test_product_url.py @@ -0,0 +1,49 @@ +"""canonical 商品 URL 的离线解析测试。""" + +from __future__ import annotations + +from pathlib import Path +import sys +import unittest + + +CLIENT_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(CLIENT_ROOT / "src")) + +from cmbuyer_client.pdd.product_url import ProductUrlError, parse_product_url + + +class ProductUrlTests(unittest.TestCase): + def test_rebuilds_url_from_goods_id(self) -> None: + link = parse_product_url("https://mobile.yangkeduo.com/goods.html?goods_id=00123") + + self.assertEqual(link.goods_id, "00123") + self.assertEqual( + link.canonical_url, + "https://mobile.yangkeduo.com/goods.html?goods_id=00123", + ) + + def test_rejects_noncanonical_and_ambiguous_urls(self) -> None: + rejected = ( + "http://mobile.yangkeduo.com/goods.html?goods_id=123", + "https://other.example/goods.html?goods_id=123", + "https://mobile.yangkeduo.com/other.html?goods_id=123", + "https://user@mobile.yangkeduo.com/goods.html?goods_id=123", + "https://mobile.yangkeduo.com:8443/goods.html?goods_id=123", + "https://mobile.yangkeduo.com:443/goods.html?goods_id=123", + "https://mobile.yangkeduo.com/goods.html?goods_id=123#fragment", + "https://mobile.yangkeduo.com/goods.html", + "https://mobile.yangkeduo.com/goods.html?goods_id=123&goods_id=456", + "https://mobile.yangkeduo.com/goods.html?goods_id=123&source=share", + "https://mobile.yangkeduo.com/goods.html?goods_id=12a", + "https://mobile.yangkeduo.com/goods.html?goods_id=%EF%BC%91%EF%BC%92%EF%BC%93", + "https://mobile.yangkeduo.com/goods.html?goods_id=", + " https://mobile.yangkeduo.com/goods.html?goods_id=123", + "https://MOBILE.YANGKEDUO.COM/goods.html?goods_id=123", + "https://mobile.yangkeduo.com/goods.html?goods_id=%31%32%33", + ) + + for value in rejected: + with self.subTest(value=value): + with self.assertRaises(ProductUrlError): + parse_product_url(value) diff --git a/docs/03-tech-stack.md b/docs/03-tech-stack.md index bfce14a..7612884 100644 --- a/docs/03-tech-stack.md +++ b/docs/03-tech-stack.md @@ -103,6 +103,26 @@ D:\Portable\adb\adb.exe devices -l 和哈希,不得把原始证据提交 Git。USB 与 WiFi 已分别由人完成取证和隐私检查,设备型号、Android、 拼多多版本、产物路径及 SHA-256 已记录到 T-101;上述命令保留用于可审计的复现与排障。 +### T-102 按链接打开商品取证(离线实现已通过,等待人工真机验收) + +`client/scripts/capture_product_open.py` 只接受 +`https://mobile.yangkeduo.com/goods.html?goods_id=<纯数字>` 的唯一 canonical 表示。解析后由 +`goods_id` 再次重建 URL,并以参数数组执行显式限定 `com.xunmeng.pinduoduo` 的 Android `VIEW` +intent;没有任意 URL、任意 shell 或其他 App 控件操作入口。运行拼多多版本必须精确等于 T-101 已 +取证的 `8.17.0`,版本失配会在 intent 前停止。 + +```powershell +# 仓库根目录;USB 或 WiFi 每次只保留一个通道在线,再手工复制该次 serial +D:\Portable\adb\adb.exe devices -l +.\client\.venv\Scripts\python.exe client\scripts\capture_product_open.py --serial --url "https://mobile.yangkeduo.com/goods.html?goods_id=" --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-102\product-open-" --timeout 10 --adb D:\Portable\adb\adb.exe +``` + +脚本只在 intent 成功且前台 package 为拼多多后采集截图与完整 XML,不根据 Activity、节点文本或 +旧项目常量声称已到详情页。成功目录以原子方式发布,manifest 仅记录 `goods_id`、canonical URL、 +设备/App 非敏感元数据、受限命令摘要、文件路径和 SHA-256,不含原始 serial、Activity 或页面正文。 +必须由人本地确认截图对应目标商品并检查截图/XML 无地址、手机号、支付信息或其他无关隐私;原始 +证据不得提交 Git,人工确认前 T-102 保持 `DOING`。 + Windows 的标准入口是仓库根 `./init.ps1`。它要求 Go、两端目录及其哨兵文件存在;已有合规 `client/.venv` 时,所有采购工具检查与 validator 都使用该解释器。只有 venv 不存在时,才从 `py -0p` 枚举的版本中确定性选择最高的 Python 3.11+ 创建它;没有合规版本时明确失败,绝不回退默认 `python`。 diff --git a/docs/current-state.md b/docs/current-state.md index 14bcbf5..a3baba5 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -11,16 +11,16 @@ ## 当前快照 -- 日期:2026-08-03 -- 阶段:**Phase 0 · 地基(两端骨架、核心数据模型与统一入口已完成,尚无真机采购业务代码)** +- 日期:2026-08-04 +- 阶段:**Phase 1 · 真机可行性(T-101 已完成,T-102 离线实现已通过,等待人工真机验收)** - MVP 形态:手工填链接建单 → 批量开始试选 → 定时轮询 → **第一趟试选** → 人工确认 → **第二趟下单** → 待付款 - 技术栈:已定。采购服务(`admin/`)使用 Go 1.23+ / gin / SQLite;采购工具(`client/`) 使用 Python 3.11+ / uiautomator2 / PySide6。 详见 [`03-tech-stack.md`](03-tech-stack.md) - 生产代码:`admin/` 已有最小 Go 服务、健康检查、核心领域模型、SQLite 迁移与任务状态机; `client/` 已有 Python 包、PySide6 最小入口、运行目录与日志脱敏策略,以及显式 serial 的 ADB - 连接边界与本地基线取证 CLI;尚无真机采购流程 -- 测试:采购服务已覆盖健康检查、核心模型、迁移与状态机等离线包级测试;采购工具 30 项离线单元测试 + 连接边界、本地基线取证 CLI 和受限商品链接打开取证 CLI;尚无规格选择、价格读取或下单流程 +- 测试:采购服务已覆盖健康检查、核心模型、迁移与状态机等离线包级测试;采购工具 43 项离线单元测试 (全部 mock,不连接真机) - 数据:SQLite 核心表与迁移已落成;无业务实例数据 - 标准启动路径:Windows PowerShell 运行 `./init.ps1`,Unix shell 运行 `./init.sh`。Windows 入口 @@ -28,19 +28,19 @@ 并且不覆盖低版本环境;成功后打印真实启动命令。 - 标准验证路径:`./init.ps1` 已实际跑通 admin 的 mod download / test / vet / build、client 的 editable install / 包导入 / unittest / compileall,以及仓库上下文校验。可单独运行两端命令诊断。 -- 当前 blocker:无外部 blocker。T-101 已完成人工 USB/WiFi 双通道验收;下一步落成并领取 T-102, - 验证按链接打开商品详情页。桌面 GUI 与后续真机采购流程尚未验收。 +- 当前 blocker:T-102 离线代码与构建门禁已通过,等待人使用明确的 canonical 商品链接执行真机取证, + 确认截图对应目标商品并完成隐私检查。T-102 在人工验收前保持 `DOING`;T-103 与 Phase 2 均不能抢跑。 ## 当前目录要点 | 路径 | 状态 | 说明 | | --- | --- | --- | | `docs/` | 已有 | 项目规范化文档,本次已完整生成 | -| `docs/tasks/` | 已有(T-001~T-004、T-005~T-009、T-101) | T-001~T-004、T-101 已完成;下一任务为 T-102 | +| `docs/tasks/` | 已有(T-001~T-004、T-005~T-009、T-101~T-102) | T-001~T-004、T-101 已完成;T-102 正在人工验收 | | `docs/design/` | 已有(6 个原型) | web 登录 / 建单 / 工作台 / 详情,desk 采购执行 / 配置;均已人工确认 | | `scripts/` | 已有 | 上下文门禁、Vikunja 单向导出与 MCP 启动包装 | | `admin/` | 已初始化 | Go 1.23+ / gin / SQLite,含核心模型、迁移与状态机;无真机采购执行 | -| `client/` | 已初始化 | Python 3.11+ 包、依赖源、PySide6 最小入口、显式 serial 的设备基线取证、离线测试与 wheel 元数据检查;无采购流程 | +| `client/` | 已初始化 | Python 3.11+ 包、依赖源、PySide6 最小入口、显式 serial 的基线/商品打开取证、离线测试与 wheel 元数据检查;无规格选择、价格读取或下单流程 | | `init.ps1` / `init.sh` | 已完成 | 统一安装与离线验证入口;PowerShell 优先复用合规 venv,缺失时自动选择最高的 Python 3.11+,Unix 缺工具链明确失败 | ## 任务状态 @@ -51,9 +51,10 @@ 源码目录契约)、T-008(Vikunja 任务权威与单向导出)、T-009(MVP 关键路径与并行波次), 以及 T-001(采购服务 Go 骨架)。 - 已完成:T-002(采购工具 Python 骨架)、T-003(双端统一初始化与验证入口)、 - T-004(核心数据模型)、T-101(真机环境盘点与 USB/WiFi 双通道人工验收)。下一步推进 - T-102 → T-103。 -- T-103 是当前最高优先级和 MVP 生死线。通过前不开发依赖真机可读字段的 Phase 2 生产页面。 + T-004(核心数据模型)、T-101(真机环境盘点与 USB/WiFi 双通道人工验收)。T-102 离线实现 + 已通过,等待人工真机验收;通过后推进 T-103。 +- T-102 → T-103 是当前最高优先级和 MVP 生死线。T-103 通过前不开发依赖真机可读字段的 + Phase 2 生产页面。 - 已确认原型继续只作信息架构依据;原型假数据不调用真实接口、不驱动真机。真机结论改变 可读字段时必须先回修原型与交互清单。 @@ -117,6 +118,20 @@ D:\Portable\adb\adb.exe devices -l 公开 JSON-RPC 调用,uiautomator2 初始化仍有上游固定启动上限。截图/XML 只保留在本地,执行记录只写 路径和 SHA-256,原始证据不得提交 Git。 +T-102 的人工真机验收命令(只接受唯一 canonical 链接;USB 或 WiFi 每次只保留一个通道在线): + +```powershell +# 仓库根目录;从输出中手工复制本次在线 serial +D:\Portable\adb\adb.exe devices -l +.\client\.venv\Scripts\python.exe client\scripts\capture_product_open.py --serial --url "https://mobile.yangkeduo.com/goods.html?goods_id=" --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-102\product-open-" --timeout 10 --adb D:\Portable\adb\adb.exe +``` + +脚本只允许 Android `VIEW` intent,并把 package 固定为 `com.xunmeng.pinduoduo`;它不点击、滑动、 +输入或判断商品页节点,也不打开规格、读取价格、进入下单或支付。运行拼多多版本必须精确为 +`8.17.0`,否则在 intent 前停止。成功后由人本地查看截图/XML,确认页面确为该 `goods_id` 对应商品并 +检查无地址、手机号、支付信息或其他无关隐私;只回报 manifest 路径及截图/XML SHA-256,原始证据 +不得提交 Git。人工确认前 T-102 必须保持 `DOING`。 + ## 关键背景 本项目是 `cmroubao`(Go 后端 + Android AccessibilityService)与 `cmpdd`