feat(client): add T-106 confirmation evidence capture

This commit is contained in:
QiuSW
2026-08-06 10:21:11 +08:00
parent acc9f1d964
commit b0b71d5a29
5 changed files with 1085 additions and 4 deletions
@@ -0,0 +1,345 @@
"""T-106 确认页四态的纯只读取证;页面对应性只由人工确认。"""
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 re
import shutil
from typing import Any, Protocol
from uuid import uuid4
from adbutils.errors import AdbTimeout
from PIL import Image, UnidentifiedImageError
from uiautomator2.exceptions import HTTPTimeoutError
from ..device.adb import AdbClient, CommandRunner, 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 parse_product_url
EXPECTED_GOODS_ID = "937122477375"
EXPECTED_DEVICE_MODEL = "PKG110"
EXPECTED_ANDROID_VERSION = "16"
EXPECTED_SCREEN_SIZE = (1080, 2376)
DECLARED_STATES = (
"gate2-navigation-source",
"confirm-gate3",
"submit-control-visible",
"returned-safe-page",
)
class OrderConfirmEvidenceError(RuntimeError):
"""T-106 四态证据未形成完整原子产物。"""
class OrderConfirmEvidenceTimeoutError(OrderConfirmEvidenceError):
"""只读取证设备调用超时。"""
class OrderConfirmReadDevice(Protocol):
"""T-106 唯一设备边界;故意只有读取能力。"""
def app_info(self, package_name: str) -> dict[str, Any]: ...
def window_size(self) -> tuple[int, int]: ...
def jsonrpc_call(self, method: str, params: Any = None, timeout: float = 10) -> Any: ...
class OrderConfirmForegroundReader(Protocol):
"""读取 Android 前台摘要,不暴露通用命令执行。"""
def read(self, serial: str) -> dict[str, str]: ...
class Android16ForegroundReader:
"""读取 Android 16 的唯一 top-resumed Activity。"""
_TOP_RESUMED_PATTERN = re.compile(
r"(?m)^\s*topResumedActivity=ActivityRecord\{[^\r\n}]*?\s+u\d+\s+"
r"(?P<package>[^/\s]+)/(?P<activity>[^\s}]+)\s+t\d+\}\s*$"
)
def __init__(self, runner: CommandRunner, timeout_seconds: float) -> None:
if not _is_positive_finite(timeout_seconds):
raise ValueError("timeout_seconds 必须是大于 0 的有限数值")
self._runner = runner
self._timeout_seconds = timeout_seconds
def read(self, serial: str) -> dict[str, str]:
if type(serial) is not str or not serial.strip() or serial != serial.strip():
raise OrderConfirmEvidenceError("必须显式提供非空设备通道。")
result = self._runner.run(
("-s", serial, "shell", "dumpsys", "activity", "activities"),
self._timeout_seconds,
)
if result.returncode != 0:
raise OrderConfirmEvidenceError("Android 前台摘要读取失败,未发布证据。")
matches = list(self._TOP_RESUMED_PATTERN.finditer(result.stdout))
if len(matches) != 1:
raise OrderConfirmEvidenceError("Android 前台摘要不唯一,未发布证据。")
match = matches[0]
return {"package": match.group("package"), "activity": match.group("activity")}
@dataclass(frozen=True)
class OrderConfirmEvidenceResult:
output_directory: Path
manifest_path: Path
screenshot_path: Path
hierarchy_path: Path
app_path: Path
class OrderConfirmEvidenceCapturer:
"""采集人工准备的单个稳定状态,不解析页面业务字段。"""
def __init__(
self,
adb_client: AdbClient,
connector: Callable[[str], OrderConfirmReadDevice],
foreground_reader: OrderConfirmForegroundReader,
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._foreground_reader = foreground_reader
self._timeout_seconds = timeout_seconds
self._started = False
def capture(
self,
serial: str,
goods_id: str,
human_declared_state: str,
output_directory: Path,
) -> OrderConfirmEvidenceResult:
if self._started:
raise OrderConfirmEvidenceError("同一取证器不可重复调用。")
self._started = True
_validate_inputs(serial, goods_id, human_declared_state)
target = Path(output_directory)
_validate_new_target(target)
staging: Path | None = None
try:
inspection = self._adb_client.inspect(serial)
_require_expected_device(inspection)
device = self._connector(serial)
initial_app = _require_read_precondition(
device,
self._foreground_reader.read(serial),
)
target.parent.mkdir(parents=True, exist_ok=True)
staging = target.parent / f".{target.name}.staging-{uuid4().hex}"
staging.mkdir()
screenshot_path = staging / "screenshot.png"
screenshot_payload = _read_rpc(
device,
"takeScreenshot",
SCREENSHOT_PARAMS,
self._timeout_seconds,
)
if not isinstance(screenshot_payload, str):
raise OrderConfirmEvidenceError("页面截图无效,未发布证据。")
_save_base64_screenshot(screenshot_payload, screenshot_path)
_require_screenshot(screenshot_path)
hierarchy = _read_rpc(
device,
"dumpWindowHierarchy",
HIERARCHY_PARAMS,
self._timeout_seconds,
)
_validate_hierarchy(hierarchy)
hierarchy_path = staging / "hierarchy.xml"
hierarchy_path.write_text(hierarchy, encoding="utf-8")
final_app = _require_read_precondition(
device,
self._foreground_reader.read(serial),
)
if final_app != initial_app:
raise OrderConfirmEvidenceError("取证期间前台页面漂移,未发布证据。")
app_path = staging / "app.json"
app_path.write_text(
json.dumps(final_app, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
manifest_path = staging / "manifest.json"
manifest_path.write_text(
json.dumps(
_manifest(
inspection,
serial,
goods_id,
human_declared_state,
screenshot_path,
hierarchy_path,
app_path,
),
ensure_ascii=False,
indent=2,
sort_keys=True,
)
+ "\n",
encoding="utf-8",
)
os.rename(staging, target)
staging = None
except (DeviceConnectionError, OrderConfirmEvidenceError):
_clean_staging(staging)
raise
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
_clean_staging(staging)
raise OrderConfirmEvidenceTimeoutError("确认页四态只读取证超时,未发布证据。") from error
except (OSError, UnidentifiedImageError, ValueError) as error:
_clean_staging(staging)
raise OrderConfirmEvidenceError("确认页四态证据无法原子发布,未发布证据。") from error
except Exception as error:
_clean_staging(staging)
raise OrderConfirmEvidenceError("确认页四态只读取证未完成,未发布证据。") from error
return OrderConfirmEvidenceResult(
output_directory=target,
manifest_path=target / "manifest.json",
screenshot_path=target / "screenshot.png",
hierarchy_path=target / "hierarchy.xml",
app_path=target / "app.json",
)
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_inputs(serial: object, goods_id: object, state: object) -> None:
if type(serial) is not str or not serial.strip() or serial != serial.strip():
raise OrderConfirmEvidenceError("必须显式提供非空设备通道。")
if type(goods_id) is not str or goods_id != EXPECTED_GOODS_ID:
raise OrderConfirmEvidenceError("商品不是 T-106 已批准取证目标。")
parse_product_url(f"https://mobile.yangkeduo.com/goods.html?goods_id={goods_id}")
if type(state) is not str or state not in DECLARED_STATES:
raise OrderConfirmEvidenceError("人工声明状态无效。")
def _validate_new_target(target: Path) -> None:
if target.exists() or not target.name:
raise OrderConfirmEvidenceError("输出目录必须是不存在的明确新目录。")
def _require_expected_device(inspection: DeviceInspection) -> None:
if inspection.model != EXPECTED_DEVICE_MODEL or inspection.android_version != EXPECTED_ANDROID_VERSION:
raise OrderConfirmEvidenceError("设备不是已批准取证组合。")
def _require_read_precondition(
device: OrderConfirmReadDevice,
current: object,
) -> dict[str, str]:
info = device.app_info(PDD_PACKAGE)
version = (info.get("versionName") or info.get("version_name")) if isinstance(info, dict) else None
if version != EXPECTED_PDD_VERSION:
raise OrderConfirmEvidenceError("拼多多版本不是已批准取证版本。")
if not isinstance(current, dict) or current.get("package") != PDD_PACKAGE:
raise OrderConfirmEvidenceError("拼多多不在前台。")
activity = current.get("activity")
if not isinstance(activity, str) or not activity.strip():
raise OrderConfirmEvidenceError("前台应用摘要不完整。")
if device.window_size() != EXPECTED_SCREEN_SIZE:
raise OrderConfirmEvidenceError("屏幕坐标空间不是已批准尺寸。")
return {"package": PDD_PACKAGE, "activity": activity, "pdd_version": EXPECTED_PDD_VERSION}
def _read_rpc(
device: OrderConfirmReadDevice,
method: str,
params: object,
timeout_seconds: float,
) -> object:
return device.jsonrpc_call(method, params, timeout=timeout_seconds)
def _require_screenshot(path: Path) -> None:
with Image.open(path) as image:
image.load()
if image.size != EXPECTED_SCREEN_SIZE or image.format != "PNG":
raise OrderConfirmEvidenceError("页面截图格式或尺寸无效。")
def _clean_staging(staging: Path | None) -> None:
if staging is not None and staging.exists():
shutil.rmtree(staging)
def _manifest(
inspection: DeviceInspection,
serial: str,
goods_id: str,
state: str,
screenshot_path: Path,
hierarchy_path: Path,
app_path: Path,
) -> dict[str, Any]:
return {
"schema_version": 1,
"operation": "t106-order-confirm-readonly-evidence",
"captured_at": datetime.now(UTC).isoformat(),
"product": {
"goods_id": goods_id,
"canonical_url": f"https://mobile.yangkeduo.com/goods.html?goods_id={goods_id}",
},
"human_declared_state": state,
"review_status": "human_review_required",
"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": EXPECTED_PDD_VERSION,
},
"artifacts": [
{
"path": screenshot_path.name,
"role": "human_prepared_state_raw_screenshot",
"sha256": _sha256_file(screenshot_path),
},
{
"path": hierarchy_path.name,
"role": "human_prepared_state_raw_hierarchy_local_only",
"sha256": _sha256_file(hierarchy_path),
},
{
"path": app_path.name,
"role": "human_prepared_state_app_identity",
"sha256": _sha256_file(app_path),
},
],
}