feat(client): add T-103 manual SKU evidence capture
This commit is contained in:
@@ -0,0 +1,103 @@
|
|||||||
|
"""采集人工已停在规格面板的三种状态证据;不执行任何页面操作。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from math import isfinite
|
||||||
|
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_url import ProductUrl, ProductUrlError, parse_product_url
|
||||||
|
from cmbuyer_client.pdd.sku_panel_spike import (
|
||||||
|
HUMAN_DECLARED_STATES,
|
||||||
|
SkuPanelEvidenceCapturer,
|
||||||
|
SkuPanelEvidenceError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description="采集人工已打开的拼多多规格面板状态证据。")
|
||||||
|
parser.add_argument("--serial", required=True, help="ADB device serial;禁止自动选择。")
|
||||||
|
product_source = parser.add_mutually_exclusive_group(required=True)
|
||||||
|
product_source.add_argument("--url", help="唯一 canonical goods.html?goods_id= 直链。")
|
||||||
|
product_source.add_argument("--goods-id", help="纯数字商品标识;仅用于记录证据归属。")
|
||||||
|
parser.add_argument("--state", required=True, choices=sorted(HUMAN_DECLARED_STATES), help="人工声明的面板状态。")
|
||||||
|
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 resolve_product_url(arguments: argparse.Namespace) -> ProductUrl:
|
||||||
|
if isinstance(arguments.url, str):
|
||||||
|
return parse_product_url(arguments.url)
|
||||||
|
if isinstance(arguments.goods_id, str):
|
||||||
|
# 仅使用严格 parser 重新验证并构建,不把输入交给 ADB 或页面。
|
||||||
|
return parse_product_url(f"https://mobile.yangkeduo.com/goods.html?goods_id={arguments.goods_id}")
|
||||||
|
raise ValueError("必须提供 --url 或 --goods-id。")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_arguments(arguments: argparse.Namespace) -> ProductUrl:
|
||||||
|
if not isinstance(arguments.serial, str) or not arguments.serial.strip():
|
||||||
|
raise ValueError("必须显式提供非空 --serial。")
|
||||||
|
if (
|
||||||
|
not isinstance(arguments.timeout, (int, float))
|
||||||
|
or isinstance(arguments.timeout, bool)
|
||||||
|
or arguments.timeout <= 0
|
||||||
|
or not isfinite(arguments.timeout)
|
||||||
|
):
|
||||||
|
raise ValueError("--timeout 必须是大于 0 的有限数值。")
|
||||||
|
if arguments.state not in HUMAN_DECLARED_STATES:
|
||||||
|
raise ValueError("--state 必须是允许的人工声明状态。")
|
||||||
|
return resolve_product_url(arguments)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
arguments = parse_arguments(argv)
|
||||||
|
try:
|
||||||
|
link = validate_arguments(arguments)
|
||||||
|
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 = SkuPanelEvidenceCapturer(client, connector, timeout_seconds=arguments.timeout)
|
||||||
|
try:
|
||||||
|
result = capturer.capture(
|
||||||
|
arguments.serial,
|
||||||
|
link.canonical_url,
|
||||||
|
arguments.state,
|
||||||
|
arguments.output_dir,
|
||||||
|
)
|
||||||
|
except (DeviceConnectionError, SkuPanelEvidenceError) 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())
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
"""人工停留在规格面板后的只读取证。
|
||||||
|
|
||||||
|
本模块不识别规格面板,不打开商品链接,也不读取价格;三种面板状态完全由现场人员声明。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from hashlib import sha256
|
||||||
|
import json
|
||||||
|
from math import isfinite
|
||||||
|
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
|
||||||
|
from ..device.baseline import (
|
||||||
|
HIERARCHY_PARAMS,
|
||||||
|
PDD_PACKAGE,
|
||||||
|
SCREENSHOT_PARAMS,
|
||||||
|
_save_base64_screenshot,
|
||||||
|
_sha256_file,
|
||||||
|
_validate_hierarchy,
|
||||||
|
)
|
||||||
|
from .product_open import EXPECTED_PDD_VERSION
|
||||||
|
from .product_url import ProductUrl, parse_product_url
|
||||||
|
|
||||||
|
|
||||||
|
HUMAN_DECLARED_STATES = frozenset(
|
||||||
|
{
|
||||||
|
"initial",
|
||||||
|
"one-dimension-selected",
|
||||||
|
"all-dimensions-selected",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SkuPanelEvidenceError(RuntimeError):
|
||||||
|
"""人工规格面板证据无法完整发布。"""
|
||||||
|
|
||||||
|
|
||||||
|
class SkuPanelDeclaredStateError(SkuPanelEvidenceError):
|
||||||
|
"""调用方没有提供允许的人工声明状态。"""
|
||||||
|
|
||||||
|
|
||||||
|
class SkuPanelVersionMismatchError(SkuPanelEvidenceError):
|
||||||
|
"""运行时拼多多版本不是已取证版本。"""
|
||||||
|
|
||||||
|
|
||||||
|
class SkuPanelPackageMismatchError(SkuPanelEvidenceError):
|
||||||
|
"""人工声明前台不是拼多多时仍试图留证。"""
|
||||||
|
|
||||||
|
|
||||||
|
class SkuPanelEvidenceTimeoutError(SkuPanelEvidenceError):
|
||||||
|
"""只读截图或节点树取证超时。"""
|
||||||
|
|
||||||
|
|
||||||
|
class SkuPanelScreenshotError(SkuPanelEvidenceError):
|
||||||
|
"""截图不能保存为严格有效的 PNG。"""
|
||||||
|
|
||||||
|
|
||||||
|
class SkuPanelHierarchyError(SkuPanelEvidenceError):
|
||||||
|
"""节点树不能保存为严格有效的 XML。"""
|
||||||
|
|
||||||
|
|
||||||
|
class SkuPanelUiDevice(Protocol):
|
||||||
|
"""人工面板证据所需的只读接口,故意没有任何页面操作方法。"""
|
||||||
|
|
||||||
|
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 SkuPanelEvidenceResult:
|
||||||
|
"""已原子发布的本地证据目录。"""
|
||||||
|
|
||||||
|
output_directory: Path
|
||||||
|
manifest_path: Path
|
||||||
|
screenshot_path: Path
|
||||||
|
hierarchy_path: Path
|
||||||
|
|
||||||
|
|
||||||
|
class SkuPanelEvidenceCapturer:
|
||||||
|
"""把人工已停留的面板状态留证,不对页面作任何自动结论。
|
||||||
|
|
||||||
|
状态字段命名为 ``human_declared_state``,防止消费者把本模块误解为自动面板/规格/价格识别。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
adb_client: AdbClient,
|
||||||
|
connector: Callable[[str], SkuPanelUiDevice],
|
||||||
|
timeout_seconds: float,
|
||||||
|
) -> None:
|
||||||
|
if not _is_positive_finite(timeout_seconds):
|
||||||
|
raise ValueError("timeout_seconds 必须是大于 0 的有限数值")
|
||||||
|
self._adb_client = adb_client
|
||||||
|
self._connector = connector
|
||||||
|
self._timeout_seconds = timeout_seconds
|
||||||
|
|
||||||
|
def capture(
|
||||||
|
self,
|
||||||
|
serial: str,
|
||||||
|
product_url: str,
|
||||||
|
human_declared_state: str,
|
||||||
|
output_directory: Path,
|
||||||
|
) -> SkuPanelEvidenceResult:
|
||||||
|
"""采集人工已准备的状态;不会打开链接、面板或执行任何 UI 操作。"""
|
||||||
|
|
||||||
|
link = parse_product_url(product_url)
|
||||||
|
state = _validate_human_declared_state(human_declared_state)
|
||||||
|
target = Path(output_directory)
|
||||||
|
_validate_new_target(target)
|
||||||
|
|
||||||
|
staging: Path | None = None
|
||||||
|
try:
|
||||||
|
# 沿用 T-101 的显式 serial、在线状态与重复物理设备 fail-closed 核验。
|
||||||
|
inspection = self._adb_client.inspect(serial)
|
||||||
|
device = self._connector(serial)
|
||||||
|
pdd_version = _require_expected_version(device.app_info(PDD_PACKAGE))
|
||||||
|
_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 SkuPanelScreenshotError("规格面板截图取证失败,未发布任何证据产物。") 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 SkuPanelHierarchyError("规格面板节点树取证失败,未发布任何证据产物。") 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, state, pdd_version, screenshot_path, hierarchy_path),
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
sort_keys=True,
|
||||||
|
)
|
||||||
|
+ "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
os.replace(staging, target)
|
||||||
|
except (SkuPanelEvidenceError, DeviceConnectionError):
|
||||||
|
_clean_staging(staging)
|
||||||
|
raise
|
||||||
|
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
|
||||||
|
_clean_staging(staging)
|
||||||
|
raise SkuPanelEvidenceTimeoutError("规格面板只读取证超时,未发布任何证据产物。") from error
|
||||||
|
except Exception as error:
|
||||||
|
_clean_staging(staging)
|
||||||
|
# 第三方异常可能含 serial、Activity 或页面正文,不能直接向 CLI/日志传播。
|
||||||
|
raise SkuPanelEvidenceError("规格面板只读取证未完成,未发布任何证据产物。") from error
|
||||||
|
|
||||||
|
return SkuPanelEvidenceResult(
|
||||||
|
output_directory=target,
|
||||||
|
manifest_path=target / "manifest.json",
|
||||||
|
screenshot_path=target / "screenshot.png",
|
||||||
|
hierarchy_path=target / "hierarchy.xml",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_positive_finite(value: object) -> bool:
|
||||||
|
return isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0 and isfinite(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_human_declared_state(value: object) -> str:
|
||||||
|
if not isinstance(value, str) or value not in HUMAN_DECLARED_STATES:
|
||||||
|
raise SkuPanelDeclaredStateError("必须提供允许的人工声明规格面板状态。")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_new_target(target: Path) -> None:
|
||||||
|
if target.exists():
|
||||||
|
raise SkuPanelEvidenceError("输出目录已存在;为防止混入旧证据,拒绝覆盖。")
|
||||||
|
if not target.name:
|
||||||
|
raise SkuPanelEvidenceError("输出目录必须是明确的新目录。")
|
||||||
|
|
||||||
|
|
||||||
|
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: object) -> str:
|
||||||
|
if not isinstance(app_info, dict):
|
||||||
|
raise SkuPanelVersionMismatchError("拼多多版本与已取证版本不一致,已停止取证。")
|
||||||
|
version = app_info.get("versionName") or app_info.get("version_name")
|
||||||
|
if not isinstance(version, str) or version != EXPECTED_PDD_VERSION:
|
||||||
|
raise SkuPanelVersionMismatchError("拼多多版本与已取证版本不一致,已停止取证。")
|
||||||
|
return version
|
||||||
|
|
||||||
|
|
||||||
|
def _require_pdd_foreground(current: object) -> None:
|
||||||
|
if not isinstance(current, dict) or current.get("package") != PDD_PACKAGE:
|
||||||
|
raise SkuPanelPackageMismatchError("当前前台应用不是拼多多,已停止取证。")
|
||||||
|
|
||||||
|
|
||||||
|
def _manifest(
|
||||||
|
inspection: DeviceInspection,
|
||||||
|
serial: str,
|
||||||
|
link: ProductUrl,
|
||||||
|
human_declared_state: str,
|
||||||
|
pdd_version: str,
|
||||||
|
screenshot_path: Path,
|
||||||
|
hierarchy_path: Path,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""仅记录人工声明与非敏感审计摘要,不写入 Activity 或页面内容。"""
|
||||||
|
|
||||||
|
return {
|
||||||
|
"schema_version": 1,
|
||||||
|
"captured_at": datetime.now(UTC).isoformat(),
|
||||||
|
"product": {"goods_id": link.goods_id, "canonical_url": link.canonical_url},
|
||||||
|
"human_declared_state": human_declared_state,
|
||||||
|
"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,
|
||||||
|
},
|
||||||
|
"artifacts": [
|
||||||
|
{"path": screenshot_path.name, "sha256": _sha256_file(screenshot_path)},
|
||||||
|
{"path": hierarchy_path.name, "sha256": _sha256_file(hierarchy_path)},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
"""人工声明规格面板状态的离线只读取证测试。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
from importlib.util import module_from_spec, spec_from_file_location
|
||||||
|
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
|
||||||
|
from cmbuyer_client.pdd.product_url import ProductUrlError
|
||||||
|
from cmbuyer_client.pdd.sku_panel_spike import (
|
||||||
|
HUMAN_DECLARED_STATES,
|
||||||
|
SkuPanelDeclaredStateError,
|
||||||
|
SkuPanelEvidenceCapturer,
|
||||||
|
SkuPanelEvidenceError,
|
||||||
|
SkuPanelEvidenceTimeoutError,
|
||||||
|
SkuPanelHierarchyError,
|
||||||
|
SkuPanelPackageMismatchError,
|
||||||
|
SkuPanelScreenshotError,
|
||||||
|
SkuPanelUiDevice,
|
||||||
|
SkuPanelVersionMismatchError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
SERIAL = "192.168.0.173:5555"
|
||||||
|
URL = "https://mobile.yangkeduo.com/goods.html?goods_id=123"
|
||||||
|
HIERARCHY = "<?xml version='1.0' encoding='UTF-8'?><hierarchy><node text='sensitive page text' /></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[str] = []
|
||||||
|
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(serial)
|
||||||
|
return self.inspection
|
||||||
|
|
||||||
|
|
||||||
|
class FakeUiDevice:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
version: str = "8.17.0",
|
||||||
|
package: str = "com.xunmeng.pinduoduo",
|
||||||
|
screenshot: str | None = None,
|
||||||
|
hierarchy: str = HIERARCHY,
|
||||||
|
) -> None:
|
||||||
|
self.version = version
|
||||||
|
self.package = package
|
||||||
|
self.screenshot = screenshot if screenshot is not None else _png_base64()
|
||||||
|
self.hierarchy = hierarchy
|
||||||
|
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.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":
|
||||||
|
return self.screenshot
|
||||||
|
if method == "dumpWindowHierarchy":
|
||||||
|
return self.hierarchy
|
||||||
|
raise AssertionError(f"unexpected RPC {method}")
|
||||||
|
|
||||||
|
|
||||||
|
def _load_spike_script() -> object:
|
||||||
|
script_path = CLIENT_ROOT / "scripts" / "capture_sku_panel_spike.py"
|
||||||
|
spec = spec_from_file_location("capture_sku_panel_spike_for_test", script_path)
|
||||||
|
assert spec is not None and spec.loader is not None
|
||||||
|
module = module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
class SkuPanelEvidenceTests(unittest.TestCase):
|
||||||
|
def _capturer(self, adb: FakeAdbClient, device: FakeUiDevice) -> SkuPanelEvidenceCapturer:
|
||||||
|
return SkuPanelEvidenceCapturer(adb, lambda serial: device, timeout_seconds=2)
|
||||||
|
|
||||||
|
def test_all_human_declared_states_publish_redacted_manifest(self) -> None:
|
||||||
|
for state in sorted(HUMAN_DECLARED_STATES):
|
||||||
|
with self.subTest(state=state), TemporaryDirectory() as temporary:
|
||||||
|
adb = FakeAdbClient()
|
||||||
|
device = FakeUiDevice()
|
||||||
|
target = Path(temporary) / "evidence"
|
||||||
|
result = self._capturer(adb, device).capture(SERIAL, URL, state, target)
|
||||||
|
manifest = result.manifest_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertEqual(adb.calls, [SERIAL])
|
||||||
|
self.assertEqual(device.calls, ["app_info", "app_current", "takeScreenshot", "dumpWindowHierarchy"])
|
||||||
|
self.assertIn(f'"human_declared_state": "{state}"', manifest)
|
||||||
|
self.assertIn('"goods_id": "123"', manifest)
|
||||||
|
self.assertIn('"canonical_url": "https://mobile.yangkeduo.com/goods.html?goods_id=123"', manifest)
|
||||||
|
self.assertNotIn("detected_state", manifest)
|
||||||
|
self.assertNotIn(SERIAL, manifest)
|
||||||
|
self.assertNotIn("sensitive.activity.name", manifest)
|
||||||
|
self.assertNotIn(HIERARCHY, manifest)
|
||||||
|
|
||||||
|
def test_invalid_state_and_url_fail_before_device_access(self) -> None:
|
||||||
|
adb = FakeAdbClient()
|
||||||
|
with TemporaryDirectory() as temporary:
|
||||||
|
with self.assertRaises(SkuPanelDeclaredStateError):
|
||||||
|
self._capturer(adb, FakeUiDevice()).capture(SERIAL, URL, "guessed", Path(temporary) / "state")
|
||||||
|
with self.assertRaises(ProductUrlError):
|
||||||
|
self._capturer(adb, FakeUiDevice()).capture(
|
||||||
|
SERIAL,
|
||||||
|
"https://mobile.yangkeduo.com/goods.html?goods_id=12x",
|
||||||
|
"initial",
|
||||||
|
Path(temporary) / "url",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(adb.calls, [])
|
||||||
|
|
||||||
|
def test_version_or_foreground_package_mismatch_stops_before_artifacts(self) -> None:
|
||||||
|
scenarios = (
|
||||||
|
(FakeUiDevice(version="8.17.1"), SkuPanelVersionMismatchError, ["app_info"]),
|
||||||
|
(FakeUiDevice(package="com.example.other"), SkuPanelPackageMismatchError, ["app_info", "app_current"]),
|
||||||
|
)
|
||||||
|
for device, error_type, expected_calls 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).capture(SERIAL, URL, "initial", target)
|
||||||
|
|
||||||
|
self.assertEqual(device.calls, expected_calls)
|
||||||
|
self.assertFalse(target.exists())
|
||||||
|
|
||||||
|
def test_screenshot_and_xml_failure_leave_no_partial_evidence(self) -> None:
|
||||||
|
scenarios = (
|
||||||
|
(FakeUiDevice(screenshot="not valid base64!"), SkuPanelScreenshotError),
|
||||||
|
(FakeUiDevice(hierarchy="<not-hierarchy />"), SkuPanelHierarchyError),
|
||||||
|
)
|
||||||
|
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).capture(SERIAL, URL, "initial", target)
|
||||||
|
|
||||||
|
self.assertFalse(target.exists())
|
||||||
|
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
|
||||||
|
|
||||||
|
def test_screenshot_timeout_is_redacted_and_leaves_no_partial_evidence(self) -> None:
|
||||||
|
class TimeoutScreenshotDevice(FakeUiDevice):
|
||||||
|
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||||
|
if method == "takeScreenshot":
|
||||||
|
self.calls.append(method)
|
||||||
|
raise TimeoutError("adb 192.168.0.173:5555 raw detail")
|
||||||
|
return super().jsonrpc_call(method, params, timeout)
|
||||||
|
|
||||||
|
with TemporaryDirectory() as temporary:
|
||||||
|
target = Path(temporary) / "evidence"
|
||||||
|
with self.assertRaises(SkuPanelEvidenceTimeoutError) as raised:
|
||||||
|
self._capturer(FakeAdbClient(), TimeoutScreenshotDevice()).capture(SERIAL, URL, "initial", target)
|
||||||
|
|
||||||
|
self.assertNotIn(SERIAL, str(raised.exception))
|
||||||
|
self.assertNotIn("adb", str(raised.exception).lower())
|
||||||
|
self.assertFalse(target.exists())
|
||||||
|
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
|
||||||
|
|
||||||
|
def test_existing_output_directory_is_not_overwritten_or_connected(self) -> None:
|
||||||
|
adb = FakeAdbClient()
|
||||||
|
device = FakeUiDevice()
|
||||||
|
with TemporaryDirectory() as temporary:
|
||||||
|
target = Path(temporary) / "evidence"
|
||||||
|
target.mkdir()
|
||||||
|
sentinel = target / "sentinel.txt"
|
||||||
|
sentinel.write_text("keep", encoding="utf-8")
|
||||||
|
|
||||||
|
with self.assertRaises(SkuPanelEvidenceError):
|
||||||
|
self._capturer(adb, device).capture(SERIAL, URL, "initial", target)
|
||||||
|
|
||||||
|
self.assertEqual(adb.calls, [])
|
||||||
|
self.assertEqual(device.calls, [])
|
||||||
|
self.assertEqual(sentinel.read_text(encoding="utf-8"), "keep")
|
||||||
|
|
||||||
|
def test_protocol_has_no_ui_or_purchase_operation_methods(self) -> None:
|
||||||
|
forbidden = {
|
||||||
|
"click",
|
||||||
|
"swipe",
|
||||||
|
"send_keys",
|
||||||
|
"set_text",
|
||||||
|
"press",
|
||||||
|
"open_product",
|
||||||
|
"open_sku_panel",
|
||||||
|
"set_quantity",
|
||||||
|
"go_to_order_confirm",
|
||||||
|
"submit_order",
|
||||||
|
"pay",
|
||||||
|
}
|
||||||
|
self.assertTrue(forbidden.isdisjoint(SkuPanelUiDevice.__dict__))
|
||||||
|
self.assertEqual({name for name in SkuPanelEvidenceCapturer.__dict__ if not name.startswith("_")}, {"capture"})
|
||||||
|
|
||||||
|
|
||||||
|
class SkuPanelSpikeCliTests(unittest.TestCase):
|
||||||
|
def test_validate_arguments_rejects_invalid_serial_timeout_state_and_url(self) -> None:
|
||||||
|
script = _load_spike_script()
|
||||||
|
valid = {
|
||||||
|
"serial": SERIAL,
|
||||||
|
"url": URL,
|
||||||
|
"goods_id": None,
|
||||||
|
"state": "initial",
|
||||||
|
"output_dir": Path("evidence"),
|
||||||
|
"timeout": 10.0,
|
||||||
|
"adb": "adb",
|
||||||
|
}
|
||||||
|
invalid_values = (
|
||||||
|
("serial", ""),
|
||||||
|
("timeout", 0),
|
||||||
|
("timeout", float("inf")),
|
||||||
|
("state", "not-declared"),
|
||||||
|
("url", "https://mobile.yangkeduo.com/goods.html?goods_id=bad"),
|
||||||
|
)
|
||||||
|
for field, value in invalid_values:
|
||||||
|
with self.subTest(field=field, value=value):
|
||||||
|
arguments = argparse.Namespace(**(valid | {field: value}))
|
||||||
|
with self.assertRaises((ValueError, ProductUrlError)):
|
||||||
|
script.validate_arguments(arguments) # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
def test_goods_id_is_rebuilt_as_canonical_url(self) -> None:
|
||||||
|
script = _load_spike_script()
|
||||||
|
arguments = argparse.Namespace(
|
||||||
|
serial=SERIAL,
|
||||||
|
url=None,
|
||||||
|
goods_id="00123",
|
||||||
|
state="all-dimensions-selected",
|
||||||
|
output_dir=Path("evidence"),
|
||||||
|
timeout=10.0,
|
||||||
|
adb="adb",
|
||||||
|
)
|
||||||
|
|
||||||
|
link = script.validate_arguments(arguments) # type: ignore[attr-defined]
|
||||||
|
self.assertEqual(link.canonical_url, "https://mobile.yangkeduo.com/goods.html?goods_id=00123")
|
||||||
@@ -125,6 +125,28 @@ D:\Portable\adb\adb.exe devices -l
|
|||||||
证据不得提交 Git。T-102 已由人确认 goods_id `958756616606` 对应目标商品并完成截图/XML 隐私检查,
|
证据不得提交 Git。T-102 已由人确认 goods_id `958756616606` 对应目标商品并完成截图/XML 隐私检查,
|
||||||
设备、版本、证据路径与 SHA-256 已记录到任务执行记录。
|
设备、版本、证据路径与 SHA-256 已记录到任务执行记录。
|
||||||
|
|
||||||
|
### T-103 规格面板三状态只读取证(等待人工真机验收)
|
||||||
|
|
||||||
|
`client/scripts/capture_sku_panel_spike.py` 只采集人已在手机上准备好的规格面板截图和完整 XML。
|
||||||
|
它不打开链接或规格面板,不识别面板,不点击、滑动、输入或选择规格,也不读取价格;接口只暴露
|
||||||
|
`app_info`、`app_current` 和两个只读 JSON-RPC 方法。`initial`、`one-dimension-selected`、
|
||||||
|
`all-dimensions-selected` 三种值写入 manifest 的字段名是 `human_declared_state`,明确表示人工声明,
|
||||||
|
不得将其当作自动识别结果。运行拼多多版本必须精确为 `8.17.0`,前台 package 必须是拼多多,否则
|
||||||
|
fail closed;目标目录已存在、截图/XML 无效或超时均不得覆盖已有内容或发布半成品。
|
||||||
|
|
||||||
|
人工只能通过商品页上独立且安全的「规格/已选」入口打开面板。不得点击或借用「单独购买」「免拼购买」
|
||||||
|
「直接拼成」「现在买」「先用后付」「0 元下单」等购买、下单或支付入口;没有独立入口即记录 no-go。
|
||||||
|
三种状态分别使用一个全新证据目录:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\client\.venv\Scripts\python.exe client\scripts\capture_sku_panel_spike.py --serial <SERIAL> --url "https://mobile.yangkeduo.com/goods.html?goods_id=<GOODS_ID>" --state initial --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-initial-<GOODS_ID>" --timeout 10 --adb D:\Portable\adb\adb.exe
|
||||||
|
.\client\.venv\Scripts\python.exe client\scripts\capture_sku_panel_spike.py --serial <SERIAL> --url "https://mobile.yangkeduo.com/goods.html?goods_id=<GOODS_ID>" --state one-dimension-selected --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-one-dimension-<GOODS_ID>" --timeout 10 --adb D:\Portable\adb\adb.exe
|
||||||
|
.\client\.venv\Scripts\python.exe client\scripts\capture_sku_panel_spike.py --serial <SERIAL> --url "https://mobile.yangkeduo.com/goods.html?goods_id=<GOODS_ID>" --state all-dimensions-selected --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-all-dimensions-<GOODS_ID>" --timeout 10 --adb D:\Portable\adb\adb.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
原始截图/XML 只能留在本机。人先核对三次截图的声明状态并完成隐私检查,再回报 manifest 路径、精确
|
||||||
|
选择值和面板可见价格文本;agent 在此之前不得读取原始页面内容或编写任何规格面板判据。
|
||||||
|
|
||||||
Windows 的标准入口是仓库根 `./init.ps1`。它要求 Go、两端目录及其哨兵文件存在;已有合规
|
Windows 的标准入口是仓库根 `./init.ps1`。它要求 Go、两端目录及其哨兵文件存在;已有合规
|
||||||
`client/.venv` 时,所有采购工具检查与 validator 都使用该解释器。只有 venv 不存在时,才从 `py -0p`
|
`client/.venv` 时,所有采购工具检查与 validator 都使用该解释器。只有 venv 不存在时,才从 `py -0p`
|
||||||
枚举的版本中确定性选择最高的 Python 3.11+ 创建它;没有合规版本时明确失败,绝不回退默认 `python`。
|
枚举的版本中确定性选择最高的 Python 3.11+ 创建它;没有合规版本时明确失败,绝不回退默认 `python`。
|
||||||
|
|||||||
+24
-6
@@ -19,8 +19,9 @@
|
|||||||
详见 [`03-tech-stack.md`](03-tech-stack.md)
|
详见 [`03-tech-stack.md`](03-tech-stack.md)
|
||||||
- 生产代码:`admin/` 已有最小 Go 服务、健康检查、核心领域模型、SQLite 迁移与任务状态机;
|
- 生产代码:`admin/` 已有最小 Go 服务、健康检查、核心领域模型、SQLite 迁移与任务状态机;
|
||||||
`client/` 已有 Python 包、PySide6 最小入口、运行目录与日志脱敏策略,以及显式 serial 的 ADB
|
`client/` 已有 Python 包、PySide6 最小入口、运行目录与日志脱敏策略,以及显式 serial 的 ADB
|
||||||
连接边界、本地基线取证 CLI 和受限商品链接打开取证 CLI;尚无规格选择、价格读取或下单流程
|
连接边界、本地基线取证 CLI、受限商品链接打开取证 CLI 和人工声明规格面板状态的只读取证 CLI;
|
||||||
- 测试:采购服务已覆盖健康检查、核心模型、迁移与状态机等离线包级测试;采购工具 46 项离线单元测试
|
尚无规格选择、价格读取或下单流程
|
||||||
|
- 测试:采购服务已覆盖健康检查、核心模型、迁移与状态机等离线包级测试;采购工具 55 项离线单元测试
|
||||||
(全部 mock,不连接真机)
|
(全部 mock,不连接真机)
|
||||||
- 数据:SQLite 核心表与迁移已落成;无业务实例数据
|
- 数据:SQLite 核心表与迁移已落成;无业务实例数据
|
||||||
- 标准启动路径:Windows PowerShell 运行 `./init.ps1`,Unix shell 运行 `./init.sh`。Windows 入口
|
- 标准启动路径:Windows PowerShell 运行 `./init.ps1`,Unix shell 运行 `./init.sh`。Windows 入口
|
||||||
@@ -28,19 +29,20 @@
|
|||||||
并且不覆盖低版本环境;成功后打印真实启动命令。
|
并且不覆盖低版本环境;成功后打印真实启动命令。
|
||||||
- 标准验证路径:`./init.ps1` 已实际跑通 admin 的 mod download / test / vet / build、client 的
|
- 标准验证路径:`./init.ps1` 已实际跑通 admin 的 mod download / test / vet / build、client 的
|
||||||
editable install / 包导入 / unittest / compileall,以及仓库上下文校验。可单独运行两端命令诊断。
|
editable install / 包导入 / unittest / compileall,以及仓库上下文校验。可单独运行两端命令诊断。
|
||||||
- 当前 blocker:无外部 blocker。T-102 已证明 canonical 链接可在拼多多 8.17.0 打开到目标商品并完成
|
- 当前设备门禁:T-102 已证明 canonical 链接可在拼多多 8.17.0 打开到目标商品并完成人工隐私验收;
|
||||||
人工隐私验收;下一步落成 T-103 的规格面板证据与试选验证。T-103 通过前 Phase 2 仍不能抢跑。
|
T-103 的只读取证脚手架已落成,等待人工分别把安全的独立规格面板停在初始、已选一个维度、已选全部
|
||||||
|
维度三种状态并采集证据。T-103 通过前 Phase 2 仍不能抢跑。
|
||||||
|
|
||||||
## 当前目录要点
|
## 当前目录要点
|
||||||
|
|
||||||
| 路径 | 状态 | 说明 |
|
| 路径 | 状态 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `docs/` | 已有 | 项目规范化文档,本次已完整生成 |
|
| `docs/` | 已有 | 项目规范化文档,本次已完整生成 |
|
||||||
| `docs/tasks/` | 已有(T-001~T-004、T-005~T-009、T-101~T-102) | T-001~T-004、T-101~T-102 已完成;下一任务为 T-103 |
|
| `docs/tasks/` | 已有(T-001~T-004、T-005~T-009、T-101~T-103) | T-001~T-004、T-101~T-102 已完成;T-103 正在执行 |
|
||||||
| `docs/design/` | 已有(6 个原型) | web 登录 / 建单 / 工作台 / 详情,desk 采购执行 / 配置;均已人工确认 |
|
| `docs/design/` | 已有(6 个原型) | web 登录 / 建单 / 工作台 / 详情,desk 采购执行 / 配置;均已人工确认 |
|
||||||
| `scripts/` | 已有 | 上下文门禁、Vikunja 单向导出与 MCP 启动包装 |
|
| `scripts/` | 已有 | 上下文门禁、Vikunja 单向导出与 MCP 启动包装 |
|
||||||
| `admin/` | 已初始化 | Go 1.23+ / gin / SQLite,含核心模型、迁移与状态机;无真机采购执行 |
|
| `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 缺工具链明确失败 |
|
| `init.ps1` / `init.sh` | 已完成 | 统一安装与离线验证入口;PowerShell 优先复用合规 venv,缺失时自动选择最高的 Python 3.11+,Unix 缺工具链明确失败 |
|
||||||
|
|
||||||
## 任务状态
|
## 任务状态
|
||||||
@@ -133,6 +135,22 @@ D:\Portable\adb\adb.exe devices -l
|
|||||||
检查无地址、手机号、支付信息或其他无关隐私;只回报 manifest 路径及截图/XML SHA-256,原始证据
|
检查无地址、手机号、支付信息或其他无关隐私;只回报 manifest 路径及截图/XML SHA-256,原始证据
|
||||||
不得提交 Git。T-102 已由人确认 goods_id `958756616606` 的目标商品及截图/XML 隐私,并完成验收。
|
不得提交 Git。T-102 已由人确认 goods_id `958756616606` 的目标商品及截图/XML 隐私,并完成验收。
|
||||||
|
|
||||||
|
T-103 的规格面板只读取证命令要求人先在手机上打开**独立且安全的规格/已选入口**,并手工停在待采集
|
||||||
|
状态。不得点击或借用「单独购买」「免拼购买」「直接拼成」「现在买」「先用后付」「0 元下单」等购买、
|
||||||
|
下单或支付入口来打开规格面板;商品没有独立规格入口时必须报告 no-go,不得绕过。脚本本身不打开链接、
|
||||||
|
不识别面板、不点击或选择规格,也不读取价格;`--state` 只是人工声明,不能作为自动判据:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 仓库根目录;三次采集使用三个全新的 output-dir
|
||||||
|
.\client\.venv\Scripts\python.exe client\scripts\capture_sku_panel_spike.py --serial <SERIAL> --url "https://mobile.yangkeduo.com/goods.html?goods_id=<GOODS_ID>" --state initial --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-initial-<GOODS_ID>" --timeout 10 --adb D:\Portable\adb\adb.exe
|
||||||
|
.\client\.venv\Scripts\python.exe client\scripts\capture_sku_panel_spike.py --serial <SERIAL> --url "https://mobile.yangkeduo.com/goods.html?goods_id=<GOODS_ID>" --state one-dimension-selected --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-one-dimension-<GOODS_ID>" --timeout 10 --adb D:\Portable\adb\adb.exe
|
||||||
|
.\client\.venv\Scripts\python.exe client\scripts\capture_sku_panel_spike.py --serial <SERIAL> --url "https://mobile.yangkeduo.com/goods.html?goods_id=<GOODS_ID>" --state all-dimensions-selected --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-all-dimensions-<GOODS_ID>" --timeout 10 --adb D:\Portable\adb\adb.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
每次成功后,人必须本地核对截图确为所声明状态,并检查截图/XML 不含地址、手机号、支付信息或其他无关
|
||||||
|
隐私,再只回报三个 manifest 路径、人工选择的精确规格值及面板可见价格文本;原始证据不得提交 Git。
|
||||||
|
三组证据均通过人工检查后,agent 才能读取它们并提取经脱敏的测试 fixture、编写页面判据与精确选择逻辑。
|
||||||
|
|
||||||
## 关键背景
|
## 关键背景
|
||||||
|
|
||||||
本项目是 `cmroubao`(Go 后端 + Android AccessibilityService)与 `cmpdd`
|
本项目是 `cmroubao`(Go 后端 + Android AccessibilityService)与 `cmpdd`
|
||||||
|
|||||||
Reference in New Issue
Block a user