feat(client): capture T-105 quantity states

This commit is contained in:
QiuSW
2026-08-05 18:08:11 +08:00
parent 16b2487a2a
commit 994b6c8054
3 changed files with 872 additions and 0 deletions
@@ -0,0 +1,303 @@
"""T-105 数量两态的纯只读取证;人工确认前不识别或操作数量控件。"""
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 PIL import Image, UnidentifiedImageError
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 parse_product_url
EXPECTED_GOODS_ID = "937122477375"
EXPECTED_DEVICE_MODEL = "PKG110"
EXPECTED_ANDROID_VERSION = "16"
EXPECTED_SCREEN_SIZE = (1080, 2376)
TARGET_SELECTION = {"color": "黑色CHA(纯棉)", "size": "M(建议100-115)"}
DECLARED_QUANTITIES = {"initial": 1, "target": 2}
class QuantityGate2EvidenceError(RuntimeError):
"""T-105 两态证据未形成完整原子产物。"""
class QuantityGate2EvidenceTimeoutError(QuantityGate2EvidenceError):
"""只读取证设备调用超时。"""
class QuantityGate2ReadDevice(Protocol):
"""阶段一唯一设备边界;故意不暴露任何页面动作。"""
def app_info(self, package_name: str) -> dict[str, Any]: ...
def app_current(self) -> dict[str, Any]: ...
def window_size(self) -> tuple[int, int]: ...
def jsonrpc_call(self, method: str, params: Any = None, timeout: float = 10) -> Any: ...
@dataclass(frozen=True)
class QuantityGate2EvidenceResult:
output_directory: Path
manifest_path: Path
screenshot_path: Path
hierarchy_path: Path
app_path: Path
class QuantityGate2EvidenceCapturer:
"""记录人工准备的数量状态,不从页面推断声明是否正确。"""
def __init__(
self,
adb_client: AdbClient,
connector: Callable[[str], QuantityGate2ReadDevice],
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
self._started = False
def capture(
self,
serial: str,
goods_id: str,
human_declared_state: str,
human_declared_quantity: int,
output_directory: Path,
) -> QuantityGate2EvidenceResult:
if self._started:
raise QuantityGate2EvidenceError("同一取证器不可重复调用。")
self._started = True
_validate_inputs(serial, goods_id, human_declared_state, human_declared_quantity)
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)
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 QuantityGate2EvidenceError("数量状态截图无效,未发布证据。")
_save_base64_screenshot(screenshot_payload, screenshot_path)
_require_screenshot_size(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)
if final_app != initial_app:
raise QuantityGate2EvidenceError("数量状态取证期间前台页面漂移,未发布证据。")
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,
human_declared_quantity,
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, QuantityGate2EvidenceError):
_clean_staging(staging)
raise
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
_clean_staging(staging)
raise QuantityGate2EvidenceTimeoutError("数量两态只读取证超时,未发布证据。") from error
except (OSError, UnidentifiedImageError, ValueError) as error:
_clean_staging(staging)
raise QuantityGate2EvidenceError("数量两态证据无法原子发布,未发布证据。") from error
except Exception as error:
_clean_staging(staging)
raise QuantityGate2EvidenceError("数量两态只读取证未完成,未发布证据。") from error
return QuantityGate2EvidenceResult(
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, quantity: object) -> None:
if type(serial) is not str or not serial.strip() or serial != serial.strip():
raise QuantityGate2EvidenceError("必须显式提供非空设备通道。")
if type(goods_id) is not str or goods_id != EXPECTED_GOODS_ID:
raise QuantityGate2EvidenceError("商品不是 T-105 已批准取证目标。")
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_QUANTITIES:
raise QuantityGate2EvidenceError("人工声明状态无效。")
if type(quantity) is not int or quantity != DECLARED_QUANTITIES[state]:
raise QuantityGate2EvidenceError("人工声明数量与批准状态不一致。")
def _validate_new_target(target: Path) -> None:
if target.exists() or not target.name:
raise QuantityGate2EvidenceError("输出目录必须是不存在的明确新目录。")
def _require_expected_device(inspection: DeviceInspection) -> None:
if inspection.model != EXPECTED_DEVICE_MODEL or inspection.android_version != EXPECTED_ANDROID_VERSION:
raise QuantityGate2EvidenceError("设备不是已批准取证组合。")
def _require_read_precondition(device: QuantityGate2ReadDevice) -> 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 QuantityGate2EvidenceError("拼多多版本不是已批准取证版本。")
current = device.app_current()
if not isinstance(current, dict) or current.get("package") != PDD_PACKAGE:
raise QuantityGate2EvidenceError("拼多多不在前台。")
activity = current.get("activity")
if not isinstance(activity, str) or not activity.strip():
raise QuantityGate2EvidenceError("前台应用摘要不完整。")
if device.window_size() != EXPECTED_SCREEN_SIZE:
raise QuantityGate2EvidenceError("屏幕坐标空间不是已批准尺寸。")
return {"package": PDD_PACKAGE, "activity": activity, "pdd_version": EXPECTED_PDD_VERSION}
def _read_rpc(
device: QuantityGate2ReadDevice,
method: str,
params: object,
timeout_seconds: float,
) -> object:
return device.jsonrpc_call(method, params, timeout=timeout_seconds)
def _require_screenshot_size(path: Path) -> None:
with Image.open(path) as image:
image.load()
if image.size != EXPECTED_SCREEN_SIZE or image.format != "PNG":
raise QuantityGate2EvidenceError("数量状态截图格式或尺寸无效。")
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,
quantity: int,
screenshot_path: Path,
hierarchy_path: Path,
app_path: Path,
) -> dict[str, Any]:
return {
"schema_version": 1,
"operation": "t105-quantity-gate2-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,
"human_declared_quantity": quantity,
"human_declared_selection": TARGET_SELECTION,
"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": "quantity_state_raw_screenshot",
"sha256": _sha256_file(screenshot_path),
},
{
"path": hierarchy_path.name,
"role": "quantity_state_raw_hierarchy_local_only",
"sha256": _sha256_file(hierarchy_path),
},
{
"path": app_path.name,
"role": "quantity_state_app_identity",
"sha256": _sha256_file(app_path),
},
],
}