feat(client): capture T-104 safe exit evidence

This commit is contained in:
QiuSW
2026-08-05 17:25:27 +08:00
parent 44a207df7f
commit 43fd8ce668
3 changed files with 830 additions and 3 deletions
@@ -21,7 +21,13 @@ from adbutils.errors import AdbTimeout
from uiautomator2.exceptions import HTTPTimeoutError
from ..device.adb import AdbClient, DeviceConnectionError, DeviceInspection
from ..device.baseline import PDD_PACKAGE, SCREENSHOT_PARAMS, _save_base64_screenshot, _sha256_file
from ..device.baseline import (
PDD_PACKAGE,
SCREENSHOT_PARAMS,
_save_base64_screenshot,
_sha256_file,
_validate_hierarchy,
)
from .product_open import EXPECTED_PDD_VERSION
from .product_url import ProductUrl, ProductUrlError, parse_product_url
from .sku_selection import (
@@ -37,7 +43,9 @@ from .sku_selection import (
_TARGET_SIZE_BOUNDS,
_action_bounds,
_annotate_sku_entry_failure,
_parse_nodes,
_safe_sku_entry_failure_stage,
_unit_price,
resolve_task_selection,
)
@@ -86,6 +94,10 @@ class SkuSelectionDeviceAdapterError(SkuSelectionRunError):
"""第三方设备接口失败的脱敏映射。"""
class SkuExitSpikeError(SkuSelectionRunError):
"""T-104 阶段 A 未形成完整的本机退出证据。"""
def safe_failure_stage(error: BaseException) -> str:
"""返回允许公开的固定阶段码,绝不回显异常正文。"""
@@ -138,6 +150,14 @@ class SkuSelectionRunResult:
unit_price: str
@dataclass(frozen=True)
class SkuExitSpikeResult:
"""已原子发布、仍需人工判断页面身份的 T-104 证据。"""
output_directory: Path
manifest_path: Path
class UiautomatorSkuPanelAdapter(SkuPanelDevice):
"""把 uiautomator2 缩为 T-103 所需的读取与四种命名操作。
@@ -292,6 +312,210 @@ class UiautomatorSkuPanelAdapter(SkuPanelDevice):
raise SkuSelectionDeviceAdapterError("规格面板设备操作失败,已停止操作。") from error
class UiautomatorSkuExitAdapter:
"""T-104 阶段 A 的窄设备边界:只读能力加唯一一次命名 Back。
取证脚本不能取得 T-103 的入口、规格选项或 reveal 方法。Back 的唯一机会在
JSON-RPC 前封存,因为超时无法证明事件没有送达,任何结果不明都不得重发。
"""
def __init__(self, device: Any, timeout_seconds: float) -> None:
if not _is_positive_finite(timeout_seconds):
raise ValueError("timeout_seconds 必须是大于 0 的有限数值")
self._device = device
self._timeout_seconds = timeout_seconds
self._back_attempted = False
self._back_rpc_outcome = "not_attempted"
@property
def back_attempts(self) -> int:
return int(self._back_attempted)
@property
def back_rpc_outcome(self) -> str:
return self._back_rpc_outcome
def app_info(self, package_name: str) -> dict[str, Any]:
value = self._call("app_info", package_name)
if not isinstance(value, dict):
raise SkuSelectionDeviceAdapterError("无法读取应用版本,已停止取证。")
return value
def app_current(self) -> dict[str, Any]:
value = self._call("app_current")
if not isinstance(value, dict):
raise SkuSelectionDeviceAdapterError("无法读取前台应用,已停止取证。")
return value
def dump_window_hierarchy(self) -> str:
value = self._call(
"jsonrpc_call",
"dumpWindowHierarchy",
[False, 50],
timeout=self._timeout_seconds,
)
if not isinstance(value, str):
raise SkuSelectionDeviceAdapterError("节点树读取失败,已停止取证。")
return value
def capture_screenshot(self) -> str:
value = self._call(
"jsonrpc_call",
"takeScreenshot",
SCREENSHOT_PARAMS,
timeout=self._timeout_seconds,
)
if not isinstance(value, str):
raise SkuSelectionScreenshotError("退出后截图读取失败,未发布任何证据产物。")
return value
def display_size(self) -> tuple[int, int]:
value = self._call("window_size")
if (
not isinstance(value, tuple)
or len(value) != 2
or any(not isinstance(item, int) for item in value)
):
raise SkuSelectionDeviceAdapterError("无法读取屏幕坐标空间,已停止取证。")
return value
def leave_sku_panel(self) -> None:
if self._back_attempted:
raise SkuSelectionDeviceAdapterError("本次取证已经尝试过返回,拒绝重试。")
self._back_attempted = True
self._back_rpc_outcome = "ambiguous"
self._call(
"jsonrpc_call",
"pressKey",
["back"],
timeout=self._timeout_seconds,
)
self._back_rpc_outcome = "completed"
def _call(self, method: str, *args: Any, **kwargs: Any) -> Any:
try:
operation = getattr(self._device, method)
return operation(*args, **kwargs)
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
raise SkuSelectionRunTimeoutError("T-104 设备操作超时,已停止操作。") from error
except SkuSelectionRunError:
raise
except Exception as error:
raise SkuSelectionDeviceAdapterError("T-104 设备操作失败,已停止操作。") from error
class SkuExitSpikeCapturer:
"""从人工停驻的已验证目标面板执行一次 Back,再只读采集阶段 A 证据。"""
def __init__(
self,
adb_client: AdbClient,
connector: Callable[[str], Any],
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, output_directory: Path) -> SkuExitSpikeResult:
if self._started:
raise SkuExitSpikeError("同一取证器不可重复调用。")
self._started = True
staging: Path | None = None
adapter: UiautomatorSkuExitAdapter | None = None
try:
if type(serial) is not str or not serial.strip():
raise SkuExitSpikeError("必须显式提供非空设备通道。")
target = Path(output_directory)
_validate_new_target(target)
staging = _prepare_staging(target)
inspection = self._adb_client.inspect(serial)
_require_expected_device(inspection)
adapter = UiautomatorSkuExitAdapter(
self._connector(serial),
self._timeout_seconds,
)
# 第一次只读核验拒绝把任意页面带入 Back 边界;第二次紧邻 Back,覆盖核验期间漂移。
_require_t104_exit_precondition(adapter)
_require_t104_exit_precondition(adapter)
rpc_outcome = "completed"
try:
adapter.leave_sku_panel()
except SkuSelectionRunError:
if adapter.back_attempts != 1 or adapter.back_rpc_outcome != "ambiguous":
raise
# RPC 失败不能证明 Back 未送达。只读采集可供人调和,但绝不再发第二次动作。
rpc_outcome = "ambiguous_reconciled"
if adapter.back_attempts != 1:
raise SkuExitSpikeError("返回动作审计不完整,未发布任何证据产物。")
if rpc_outcome == "completed" and adapter.back_rpc_outcome != "completed":
raise SkuExitSpikeError("返回动作结果不完整,未发布任何证据产物。")
# Back 后先确认仍是已取证版本的 PDD;不能先把其他前台应用写进本地证据。
initial_app = _require_t104_post_app(adapter)
screenshot_path = staging / "post_exit_screenshot.png"
_save_base64_screenshot(adapter.capture_screenshot(), screenshot_path)
_require_screenshot_size(screenshot_path)
hierarchy = adapter.dump_window_hierarchy()
_validate_hierarchy(hierarchy)
hierarchy_path = staging / "post_exit_hierarchy.xml"
hierarchy_path.write_text(hierarchy, encoding="utf-8")
current_app = _require_t104_post_app(adapter)
if current_app != initial_app:
raise SkuExitSpikeError("退出后应用摘要在采集期间漂移,未发布任何证据产物。")
app_path = staging / "post_exit_app.json"
app_path.write_text(
json.dumps(current_app, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
manifest_path = staging / "manifest.json"
manifest_path.write_text(
json.dumps(
_t104_exit_manifest(
inspection,
serial,
rpc_outcome,
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, SkuSelectionError, SkuSelectionRunError):
_clean_staging(staging)
raise
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
_clean_staging(staging)
raise SkuExitSpikeError("T-104 取证超时,未发布任何证据产物。") from error
except OSError as error:
_clean_staging(staging)
raise SkuExitSpikeError("T-104 证据目录无法发布,未发布任何证据产物。") from error
except Exception as error:
_clean_staging(staging)
raise SkuExitSpikeError("T-104 取证未完成,未发布任何证据产物。") from error
return SkuExitSpikeResult(target, target / "manifest.json")
class SkuSelectionRunner:
"""只运行 T-103 目标规格恢复、价格确认、原始截图和一次安全退出。"""
@@ -470,6 +694,87 @@ def _require_expected_device(inspection: DeviceInspection) -> None:
raise SkuSelectionRunError("设备型号或 Android 版本不是已取证组合,已停止操作。")
def _require_t104_exit_precondition(adapter: UiautomatorSkuExitAdapter) -> None:
"""fresh 核验 T-103 已人验的唯一 Back 前置,不产生任何页面动作。"""
_require_expected_version(adapter.app_info(PDD_PACKAGE))
current = adapter.app_current()
if current.get("package") != PDD_PACKAGE:
raise SkuExitSpikeError("Back 前拼多多不在前台,已停止取证。")
if adapter.display_size() != EXPECTED_SCREEN_SIZE:
raise SkuExitSpikeError("屏幕坐标空间不是已取证尺寸,已停止取证。")
if _unit_price(_parse_nodes(adapter.dump_window_hierarchy())) != EXPECTED_UNIT_PRICE:
raise SkuExitSpikeError("Back 前目标规格或当前价不匹配,已停止取证。")
def _post_exit_app_evidence(value: object) -> dict[str, str]:
"""只保留页面身份复核需要的应用字段,不把第三方返回整体写盘。"""
if not isinstance(value, dict):
raise SkuExitSpikeError("退出后应用摘要无效,未发布任何证据产物。")
package = value.get("package")
activity = value.get("activity")
if not isinstance(package, str) or not package.strip():
raise SkuExitSpikeError("退出后应用包名缺失,未发布任何证据产物。")
if not isinstance(activity, str) or not activity.strip():
raise SkuExitSpikeError("退出后 Activity 缺失,未发布任何证据产物。")
return {"package": package, "activity": activity}
def _require_t104_post_app(adapter: UiautomatorSkuExitAdapter) -> dict[str, str]:
current = _post_exit_app_evidence(adapter.app_current())
if current["package"] != PDD_PACKAGE:
raise SkuExitSpikeError("Back 后拼多多不在前台,未采集页面证据。")
_require_expected_version(adapter.app_info(PDD_PACKAGE))
return current
def _t104_exit_manifest(
inspection: DeviceInspection,
serial: str,
rpc_outcome: str,
screenshot_path: Path,
hierarchy_path: Path,
app_path: Path,
) -> dict[str, Any]:
"""阶段 A 只写事实与哈希;不声明退出成功,也不授予价格证据角色。"""
return {
"schema_version": 1,
"captured_at": datetime.now(UTC).isoformat(),
"operation": "t104-sku-exit-evidence",
"product": {"goods_id": EXPECTED_GOODS_ID},
"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,
},
"back_attempts": 1,
"rpc_outcome": rpc_outcome,
"post_exit_status": "human_review_required",
"artifacts": [
{
"path": screenshot_path.name,
"role": "post_exit_human_review_only",
"sha256": _sha256_file(screenshot_path),
},
{
"path": hierarchy_path.name,
"role": "post_exit_raw_hierarchy_local_only",
"sha256": _sha256_file(hierarchy_path),
},
{
"path": app_path.name,
"role": "post_exit_app_identity_human_review_only",
"sha256": _sha256_file(app_path),
},
],
}
def _require_screenshot_size(screenshot_path: Path) -> None:
try:
with Image.open(screenshot_path) as image: