feat(client): add T-103 manual SKU evidence capture
This commit is contained in:
@@ -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)},
|
||||
],
|
||||
}
|
||||
Reference in New Issue
Block a user