feat(client): add T-107 Gate3 dry-run observer

This commit is contained in:
QiuSW
2026-08-06 12:10:39 +08:00
parent 665b302a1d
commit 1d60a91ace
9 changed files with 1861 additions and 2 deletions
@@ -0,0 +1,479 @@
"""T-107:合并式最终提交面板的纯 XML Gate3 observer。
本模块不持有设备,也不返回节点、坐标、selector 或可操作对象。页面结构只来自
T-106 在拼多多 8.17.0 / goods_id 937122477375 上由人确认的两态证据。
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
from pathlib import Path
import re
from typing import Iterable
from xml.etree import ElementTree
from ..core.errors import ValidationError
from ..core.validation import require_money
from ..device.baseline import PDD_PACKAGE
from .quantity_gate2 import (
TASK_COLOR,
TASK_SIZE,
UI_COLOR,
UI_SIZE,
Gate2Observation,
)
FINAL_PANEL_QUANTITY = 2
_PDD_ID = "com.xunmeng.pinduoduo:id/pdd"
_QUANTITY_ID = "com.xunmeng.pinduoduo:id/gnl"
_TITLE_ID = "com.xunmeng.pinduoduo:id/tv_title"
_PANEL_BOUNDS = "[0,551][1080,938]"
_PRICE_FRAME_BOUNDS = "[396,575][1053,647]"
_PRICE_ROW_BOUNDS = "[396,575][740,647]"
_PRICE_TEXT_BOUNDS = "[396,580][722,647]"
_SUMMARY_BOUNDS = "[396,659][1053,721]"
_QUANTITY_BOUNDS = "[396,827][645,902]"
_MINUS_BOUNDS = "[396,827][474,902]"
_VALUE_BOUNDS = "[480,827][561,902]"
_PLUS_BOUNDS = "[567,827][645,902]"
_SUBMIT_TEXT_BOUNDS = "[366,2225][714,2284]"
_SUBMIT_TEXT_PARENT_BOUNDS = "[354,2181][726,2328]"
_SUBMIT_ACTION_BOUNDS = "[0,2181][1080,2328]"
_EXPECTED_SUMMARY = f"已选: {UI_COLOR} {UI_SIZE}"
_GATE2_TEXT = re.compile(r"^快卖完 ¥(?P<amount>(?:0|[1-9]\d*)\.\d{2})$")
_GATE3_TEXT = re.compile(r"^提交订单 ¥(?P<amount>(?:0|[1-9]\d*)\.\d{2})$")
_PRODUCT_TITLE = "2026年新款高档重工潮流烫钻中长款T恤显瘦宽松上衣淡人穿搭"
_PRODUCT_TITLE_BOUNDS = "[36,1571][1044,1689]"
_PRODUCT_TITLE_LINES = (
("2026年新款高档重工潮流烫钻中长款T恤显瘦宽松", "[36,1571][1023,1624]"),
("上衣淡人穿搭", "[36,1636][306,1689]"),
)
_ENTRY_DESC = "快要抢光¥12.88"
_ENTRY_BOUNDS = "[446,2166][1080,2328]"
_ENTRY_CHILDREN = (
("快要抢光 ¥ 12.88", "[688,2184][1042,2253]"),
("免拼购买", "[688,2256][856,2305]"),
)
_DANGEROUS_RETURN_TERMS = ("提交订单", "确认订单", "立即支付", "去支付", "付款")
class FinalSubmitPanelError(RuntimeError):
"""最终面板或 Gate3 判据不成立时的脱敏安全停止。"""
class FinalSubmitPanelOverCapError(FinalSubmitPanelError):
"""Gate2/Gate3 金额超过管理员授权最高总价。"""
@dataclass(frozen=True)
class Gate3Observation:
requested_color: str
requested_size: str
actual_color: str
actual_size: str
requested_quantity: int
quantity_read: int
gate2_panel_total_price: str
gate3_submit_amount: str
max_total_price: str
submit_control_text: str
submit_control_match_count: int
submit_control_enabled: bool
nearest_clickable_ancestor_unique: bool
screenshot_path: Path
captured_at: datetime
@dataclass(frozen=True)
class _Node:
element: ElementTree.Element
parent: "_Node | None"
@property
def text(self) -> str:
return self.element.get("text", "")
@property
def desc(self) -> str:
return self.element.get("content-desc", "")
@property
def bounds(self) -> str:
return self.element.get("bounds", "")
@dataclass(frozen=True)
class _VerifiedPanel:
gate2_amount: str
gate3_amount: str
submit_text: str
submit_match_count: int
submit_enabled: bool
nearest_clickable_ancestor_unique: bool
def observe_gate3(
raw_hierarchy: str,
gate2: Gate2Observation,
screenshot_path: Path,
captured_at: datetime,
) -> Gate3Observation:
"""从一棵已读取 XML 中构造不可操作的 Gate3 摘要。"""
_validate_gate2(gate2)
if not isinstance(screenshot_path, Path) or not screenshot_path.name:
raise FinalSubmitPanelError("Gate3 截图路径无效,已停止操作。")
if not isinstance(captured_at, datetime) or captured_at.utcoffset() is None:
raise FinalSubmitPanelError("Gate3 采集时间必须带时区,已停止操作。")
verified = _verified_final_panel(_parse_nodes(raw_hierarchy))
if verified.gate2_amount != gate2.gate2_panel_total_price:
raise FinalSubmitPanelError("当前面板 Gate2 顶部总额与可信观察不一致,已停止操作。")
if verified.gate3_amount != verified.gate2_amount:
raise FinalSubmitPanelError("Gate3 最终控件金额与 Gate2 顶部总额不一致,已停止操作。")
if Decimal(verified.gate3_amount) > Decimal(gate2.max_total_price):
raise FinalSubmitPanelOverCapError("Gate3 金额超过授权最高总价,已停止操作。")
return Gate3Observation(
requested_color=gate2.requested_color,
requested_size=gate2.requested_size,
actual_color=gate2.actual_color,
actual_size=gate2.actual_size,
requested_quantity=gate2.requested_quantity,
quantity_read=gate2.quantity_read,
gate2_panel_total_price=verified.gate2_amount,
gate3_submit_amount=verified.gate3_amount,
max_total_price=gate2.max_total_price,
submit_control_text=verified.submit_text,
submit_control_match_count=verified.submit_match_count,
submit_control_enabled=verified.submit_enabled,
nearest_clickable_ancestor_unique=verified.nearest_clickable_ancestor_unique,
screenshot_path=screenshot_path,
captured_at=captured_at,
)
def returned_product_projection(raw_hierarchy: str) -> tuple[object, ...]:
"""验证 T-106 一次 Back 后的同商品安全页;入口数字只作页面身份,不读价。"""
nodes = _parse_nodes(raw_hierarchy)
if any(
_live_clickable(node)
and any(term in value for value in (node.text, node.desc) for term in _DANGEROUS_RETURN_TERMS)
for node in nodes
):
raise FinalSubmitPanelError("返回后出现危险动作语义,不能确认安全退出。")
title = _one(
node
for node in nodes
if _exact(
node,
"android.view.ViewGroup",
_PRODUCT_TITLE_BOUNDS,
resource_id=_TITLE_ID,
clickable="true",
)
and node.element.get("long-clickable") == "true"
and not node.text
and node.desc == _PRODUCT_TITLE
)
title_children = _direct_children(title)
if len(title_children) != len(_PRODUCT_TITLE_LINES) or any(
not _exact_text(child, text, bounds)
for child, (text, bounds) in zip(title_children, _PRODUCT_TITLE_LINES, strict=True)
):
raise FinalSubmitPanelError("返回后同商品标题子结构漂移。")
entry = _one(
node
for node in nodes
if _exact(
node,
"android.view.ViewGroup",
_ENTRY_BOUNDS,
resource_id=_PDD_ID,
clickable="true",
)
and not node.text
and node.desc == _ENTRY_DESC
)
all_entry_children = _direct_children(entry)
entry_children = [child for child in all_entry_children if child.text]
if len(entry_children) != len(_ENTRY_CHILDREN) or any(
not _exact_text(child, text, bounds, resource_id=_PDD_ID)
for child, (text, bounds) in zip(entry_children, _ENTRY_CHILDREN, strict=True)
) or any(
child not in entry_children
and (child.text or child.desc or _live_clickable(child))
for child in all_entry_children
):
raise FinalSubmitPanelError("返回后商品规格入口结构漂移。")
return (
"final_submit_panel_exit_8_17_0",
_projection(title),
tuple(_projection(child) for child in title_children),
_projection(entry),
tuple(_projection(child) for child in entry_children),
)
def _validate_gate2(gate2: object) -> None:
if not isinstance(gate2, Gate2Observation):
raise FinalSubmitPanelError("缺少可信 Gate2Observation,已停止操作。")
if (
gate2.requested_color != TASK_COLOR
or gate2.actual_color != TASK_COLOR
or gate2.requested_size != TASK_SIZE
or gate2.actual_size != TASK_SIZE
):
raise FinalSubmitPanelError("Gate2 规格不是 T-107 已取证目标,已停止操作。")
if (
type(gate2.requested_quantity) is not int
or type(gate2.quantity_read) is not int
or gate2.requested_quantity != FINAL_PANEL_QUANTITY
or gate2.quantity_read != FINAL_PANEL_QUANTITY
):
raise FinalSubmitPanelError("Gate2 数量不是 T-107 已取证值,已停止操作。")
_money(gate2.gate1_unit_price)
gate2_amount = _money(gate2.gate2_panel_total_price)
maximum = _money(gate2.max_total_price)
if not isinstance(gate2.screenshot_path, Path) or not gate2.screenshot_path.name:
raise FinalSubmitPanelError("Gate2 截图引用无效,已停止操作。")
if not isinstance(gate2.captured_at, datetime) or gate2.captured_at.utcoffset() is None:
raise FinalSubmitPanelError("Gate2 采集时间必须带时区,已停止操作。")
if Decimal(gate2_amount) > Decimal(maximum):
raise FinalSubmitPanelOverCapError("Gate2 顶部总额超过授权最高总价,已停止操作。")
def _verified_final_panel(nodes: list[_Node]) -> _VerifiedPanel:
panel = _one(
node
for node in nodes
if _exact(node, "android.view.ViewGroup", _PANEL_BOUNDS, resource_id=_PDD_ID)
)
price_frame = _one(
node
for node in nodes
if node.parent is panel
and _exact(node, "android.widget.FrameLayout", _PRICE_FRAME_BOUNDS, resource_id=_PDD_ID)
)
price_row = _one(
node
for node in nodes
if node.parent is price_frame
and _exact(node, "android.widget.LinearLayout", _PRICE_ROW_BOUNDS, resource_id=_PDD_ID)
)
price = _one(
node
for node in nodes
if node.parent is price_row
and _exact_text_node(node, "android.widget.TextView", _PRICE_TEXT_BOUNDS, resource_id=_PDD_ID)
and _GATE2_TEXT.fullmatch(node.text) is not None
)
gate2_match = _GATE2_TEXT.fullmatch(price.text)
if gate2_match is None:
raise FinalSubmitPanelError("最终面板 Gate2 顶部金额不可读。")
gate2_amount = _money(gate2_match.group("amount"))
_one(
node
for node in nodes
if node.parent is panel
and _exact_text_node(node, "android.widget.TextView", _SUMMARY_BOUNDS, resource_id=_PDD_ID)
and node.text == _EXPECTED_SUMMARY
)
quantity_outer = _one(
node
for node in nodes
if node.parent is panel
and _exact(node, "android.widget.LinearLayout", _QUANTITY_BOUNDS, resource_id=_QUANTITY_ID)
)
quantity_inner = _one(
node
for node in nodes
if node.parent is quantity_outer
and _exact(node, "android.widget.LinearLayout", _QUANTITY_BOUNDS, resource_id="")
)
quantity_children = _direct_children(quantity_inner)
if len(quantity_children) != 3:
raise FinalSubmitPanelError("最终面板数量子结构漂移。")
_one(
node
for node in quantity_children
if _exact(node, "android.widget.ImageView", _MINUS_BOUNDS, resource_id=_PDD_ID, clickable="true")
and not node.text
and node.desc == "减少数量"
)
_one(
node
for node in quantity_children
if _exact(node, "android.widget.EditText", _VALUE_BOUNDS, resource_id=_PDD_ID, clickable="true")
and node.text == str(FINAL_PANEL_QUANTITY)
and not node.desc
)
_one(
node
for node in quantity_children
if _exact(node, "android.widget.ImageView", _PLUS_BOUNDS, resource_id=_PDD_ID, clickable="true")
and not node.text
and node.desc == "增加数量"
)
submit_like = [
node
for node in nodes
if "提交订单" in node.text or "提交订单" in node.desc
]
structured = [node for node in submit_like if _GATE3_TEXT.fullmatch(node.text) is not None and not node.desc]
if len(submit_like) != 1 or len(structured) != 1:
raise FinalSubmitPanelError("最终提交完整结构化文本匹配数不是 1,已停止操作。")
submit = structured[0]
if not _exact_text_node(submit, "android.widget.TextView", _SUBMIT_TEXT_BOUNDS, resource_id=""):
raise FinalSubmitPanelError("最终提交文本节点结构漂移,已停止操作。")
submit_parent = submit.parent
if (
submit_parent is None
or not _exact(
submit_parent,
"android.widget.LinearLayout",
_SUBMIT_TEXT_PARENT_BOUNDS,
resource_id="",
)
or _direct_children(submit_parent) != [submit]
):
raise FinalSubmitPanelError("最终提交文本父结构漂移,已停止操作。")
nearest = _nearest_clickable_ancestor(submit)
if nearest is None or not _exact(
nearest,
"android.widget.FrameLayout",
_SUBMIT_ACTION_BOUNDS,
resource_id=_PDD_ID,
clickable="true",
):
raise FinalSubmitPanelError("最终提交文本的最近可点击祖先不唯一或结构漂移。")
gate3_match = _GATE3_TEXT.fullmatch(submit.text)
if gate3_match is None:
raise FinalSubmitPanelError("Gate3 最终控件金额不可读。")
return _VerifiedPanel(
gate2_amount=gate2_amount,
gate3_amount=_money(gate3_match.group("amount")),
submit_text=submit.text,
submit_match_count=len(structured),
submit_enabled=submit.element.get("enabled") == "true" and nearest.element.get("enabled") == "true",
nearest_clickable_ancestor_unique=True,
)
def _parse_nodes(raw: object) -> list[_Node]:
if not isinstance(raw, str) or not raw:
raise FinalSubmitPanelError("节点树读取失败,已停止操作。")
try:
root = ElementTree.fromstring(raw)
except ElementTree.ParseError as error:
raise FinalSubmitPanelError("节点树格式无效,已停止操作。") from error
if root.tag != "hierarchy":
raise FinalSubmitPanelError("节点树根节点无效,已停止操作。")
result: list[_Node] = []
def visit(element: ElementTree.Element, parent: _Node | None) -> None:
node = _Node(element, parent)
result.append(node)
for child in element:
visit(child, node)
visit(root, None)
return result
def _money(value: object) -> str:
try:
return require_money(value, "invalid_money")
except ValidationError as error:
raise FinalSubmitPanelError("金额不是规范十进制字符串,已停止操作。") from error
def _one(nodes: Iterable[_Node]) -> _Node:
matches = list(nodes)
if len(matches) != 1:
raise FinalSubmitPanelError("证据绑定页面角色不唯一,已停止操作。")
return matches[0]
def _exact(
node: _Node,
class_name: str,
bounds: str,
*,
resource_id: str,
clickable: str = "false",
) -> bool:
return (
node.element.get("package") == PDD_PACKAGE
and node.element.get("class") == class_name
and node.element.get("resource-id", "") == resource_id
and node.bounds == bounds
and node.element.get("clickable") == clickable
and node.element.get("enabled") == "true"
and node.element.get("visible-to-user") == "true"
and node.element.get("selected") == "false"
and node.element.get("scrollable") == "false"
)
def _exact_text_node(node: _Node, class_name: str, bounds: str, *, resource_id: str) -> bool:
return (
_exact(node, class_name, bounds, resource_id=resource_id)
and not node.desc
)
def _exact_text(node: _Node, text: str, bounds: str, *, resource_id: str = "") -> bool:
return (
_exact_text_node(node, "android.widget.TextView", bounds, resource_id=resource_id)
and node.text == text
)
def _direct_children(node: _Node) -> list[_Node]:
return [_Node(child, node) for child in node.element if child.tag == "node"]
def _nearest_clickable_ancestor(node: _Node) -> _Node | None:
current = node.parent
while current is not None:
if current.element.get("clickable") == "true":
return current
current = current.parent
return None
def _live_clickable(node: _Node) -> bool:
return (
node.element.get("clickable") == "true"
and node.element.get("enabled") == "true"
and node.element.get("visible-to-user") == "true"
)
def _projection(node: _Node) -> tuple[str, ...]:
return (
node.element.tag,
node.element.get("package", ""),
node.element.get("class", ""),
node.element.get("resource-id", ""),
node.bounds,
node.element.get("clickable", ""),
node.element.get("enabled", ""),
node.element.get("visible-to-user", ""),
node.text,
node.desc,
)
@@ -0,0 +1,491 @@
"""T-107 真机边界:零页面控件动作观察 Gate3,然后只尝试一次系统 Back。"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass, replace
from datetime import UTC, datetime
from hashlib import sha256
import json
from math import isfinite
import os
from pathlib import Path
import shutil
from time import monotonic, sleep
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 PDD_PACKAGE, SCREENSHOT_PARAMS, _save_base64_screenshot, _sha256_file
from .final_submit_panel import (
FinalSubmitPanelError,
Gate3Observation,
observe_gate3,
returned_product_projection,
)
from .product_open import EXPECTED_PDD_VERSION
from .quantity_gate2 import (
EXPECTED_ANDROID_VERSION,
EXPECTED_DEVICE_MODEL,
EXPECTED_GOODS_ID,
EXPECTED_SCREEN_SIZE,
Gate2Observation,
)
class FinalSubmitPanelTimeoutError(FinalSubmitPanelError):
"""设备读取或一次 Back 后置条件等待超时。"""
class FinalSubmitPanelAdapterError(FinalSubmitPanelError):
"""第三方设备接口失败后的脱敏映射。"""
class ForegroundReader(Protocol):
def read(self, serial: str) -> dict[str, str]: ...
class FinalSubmitPanelDevice(Protocol):
"""最终面板的窄设备能力;没有任何页面控件动作。"""
def app_info(self, package_name: str) -> dict[str, Any]: ...
def current_foreground(self) -> dict[str, str]: ...
def display_size(self) -> tuple[int, int]: ...
def dump_window_hierarchy(self) -> str: ...
def capture_screenshot(self) -> str: ...
def leave_final_submit_panel_once(self) -> None: ...
@dataclass(frozen=True)
class FinalSubmitPanelRunResult:
output_directory: Path
screenshot_path: Path
manifest_path: Path
observation: Gate3Observation
class FinalSubmitPanelFlow:
"""读取同一最终面板并在成功观察后执行一次安全返回。"""
def __init__(
self,
device: FinalSubmitPanelDevice,
wait_timeout_seconds: float = 1.0,
poll_interval_seconds: float = 0.2,
monotonic_clock: Callable[[], float] = monotonic,
sleep_function: Callable[[float], None] = sleep,
) -> None:
if not _positive_finite(wait_timeout_seconds) or not _positive_finite(poll_interval_seconds):
raise ValueError("等待参数必须是大于 0 的有限数值。")
self._device = device
self._timeout = float(wait_timeout_seconds)
self._poll = float(poll_interval_seconds)
self._clock = monotonic_clock
self._sleep = sleep_function
self._observation: Gate3Observation | None = None
self._gate2: Gate2Observation | None = None
self._terminal = False
self._exited = False
@property
def exited(self) -> bool:
return self._exited
def require_ready_for_read(self) -> None:
self._require_active()
self._require_environment()
def observe(
self,
gate2: Gate2Observation,
screenshot_path: Path,
captured_at: datetime,
) -> Gate3Observation:
self._require_active()
try:
self._require_environment()
observation = observe_gate3(
self._read_hierarchy(),
gate2,
screenshot_path,
captured_at,
)
self._require_environment()
except BaseException:
self._terminal = True
raise
self._gate2 = gate2
self._observation = observation
return observation
def exit_final_submit_panel_safely(self) -> None:
self._require_active()
if self._observation is None or self._gate2 is None:
raise FinalSubmitPanelError("没有可信 Gate3 观察,拒绝执行安全返回。")
try:
self._require_environment()
current = observe_gate3(
self._read_hierarchy(),
self._gate2,
self._observation.screenshot_path,
self._observation.captured_at,
)
self._require_environment()
if current != self._observation:
raise FinalSubmitPanelError("安全返回前最终面板事实漂移,已停止操作。")
self._device.leave_final_submit_panel_once()
except BaseException:
self._terminal = True
raise
deadline = self._clock() + self._timeout
stable: tuple[object, ...] | None = None
try:
while True:
self._require_environment()
raw = self._read_hierarchy()
try:
projection = returned_product_projection(raw)
except FinalSubmitPanelError:
stable = None
else:
self._require_environment()
if stable == projection:
self._exited = True
self._terminal = True
return
stable = projection
remaining = deadline - self._clock()
if remaining <= 0:
raise FinalSubmitPanelTimeoutError("一次 Back 后未达到连续稳定同商品判据,未重试返回。")
self._sleep(min(self._poll, remaining))
except BaseException:
self._terminal = True
raise
def _require_environment(self) -> None:
info = self._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 FinalSubmitPanelError("拼多多版本与 T-106/T-107 证据不一致,已停止操作。")
foreground = self._device.current_foreground()
if (
not isinstance(foreground, dict)
or foreground.get("package") != PDD_PACKAGE
or not isinstance(foreground.get("activity"), str)
or not foreground["activity"].strip()
):
raise FinalSubmitPanelError("拼多多不是唯一前台应用,已停止操作。")
if self._device.display_size() != EXPECTED_SCREEN_SIZE:
raise FinalSubmitPanelError("屏幕坐标空间与 T-106/T-107 证据不一致,已停止操作。")
def _read_hierarchy(self) -> str:
value = self._device.dump_window_hierarchy()
if not isinstance(value, str) or not value:
raise FinalSubmitPanelError("节点树读取失败,已停止操作。")
return value
def _require_active(self) -> None:
if self._terminal:
raise FinalSubmitPanelError("最终面板流程已进入不可重入终止态。")
class UiautomatorFinalSubmitPanelAdapter(FinalSubmitPanelDevice):
"""只暴露截图/XML 读取和一个 Android Back。"""
def __init__(
self,
device: Any,
foreground_reader: ForegroundReader,
serial: str,
timeout_seconds: float,
) -> None:
if type(serial) is not str or not serial.strip() or serial != serial.strip():
raise ValueError("serial 必须显式且非空。")
if not _positive_finite(timeout_seconds):
raise ValueError("timeout_seconds 必须是大于 0 的有限数值。")
self._device = device
self._foreground_reader = foreground_reader
self._serial = serial
self._timeout = float(timeout_seconds)
self._back_attempted = False
self._back_outcome = "not_attempted"
@property
def page_control_action_attempts(self) -> int:
return 0
@property
def back_attempts(self) -> int:
return int(self._back_attempted)
@property
def back_outcome(self) -> str:
return self._back_outcome
def app_info(self, package_name: str) -> dict[str, Any]:
value = self._call("app_info", package_name)
if not isinstance(value, dict):
raise FinalSubmitPanelAdapterError("无法读取应用版本,已停止操作。")
return value
def current_foreground(self) -> dict[str, str]:
try:
value = self._foreground_reader.read(self._serial)
except FinalSubmitPanelError:
raise
except Exception as error:
raise FinalSubmitPanelAdapterError("无法读取 Android 前台摘要,已停止操作。") from error
if not isinstance(value, dict):
raise FinalSubmitPanelAdapterError("Android 前台摘要无效,已停止操作。")
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(type(item) is not int for item in value):
raise FinalSubmitPanelAdapterError("无法读取屏幕坐标空间,已停止操作。")
return value
def dump_window_hierarchy(self) -> str:
value = self._call("jsonrpc_call", "dumpWindowHierarchy", [False, 50], timeout=self._timeout)
if not isinstance(value, str):
raise FinalSubmitPanelAdapterError("节点树读取失败,已停止操作。")
return value
def capture_screenshot(self) -> str:
value = self._call("jsonrpc_call", "takeScreenshot", SCREENSHOT_PARAMS, timeout=self._timeout)
if not isinstance(value, str):
raise FinalSubmitPanelAdapterError("Gate3 原始截图读取失败,已停止操作。")
return value
def leave_final_submit_panel_once(self) -> None:
if self._back_attempted:
raise FinalSubmitPanelAdapterError("安全返回已尝试过,拒绝重试。")
# RPC 超时无法证明 Back 未送达,必须先封存唯一动作机会。
self._back_attempted = True
self._back_outcome = "ambiguous"
self._call("jsonrpc_call", "pressKey", ["back"], timeout=self._timeout)
self._back_outcome = "completed"
def _call(self, method: str, *args: Any, **kwargs: Any) -> Any:
try:
return getattr(self._device, method)(*args, **kwargs)
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
raise FinalSubmitPanelTimeoutError("T-107 设备调用超时,已停止操作。") from error
except FinalSubmitPanelError:
raise
except Exception as error:
raise FinalSubmitPanelAdapterError("T-107 设备调用失败,已停止操作。") from error
class FinalSubmitPanelRunner:
"""从人工停驻的合并式最终面板完成围栏前只读 dry-run。"""
def __init__(
self,
adb_client: AdbClient,
connector: Callable[[str], Any],
foreground_reader: ForegroundReader,
timeout_seconds: float,
monotonic_clock: Callable[[], float] = monotonic,
) -> None:
if not _positive_finite(timeout_seconds):
raise ValueError("timeout_seconds 必须是大于 0 的有限数值。")
self._adb_client = adb_client
self._connector = connector
self._foreground_reader = foreground_reader
self._timeout = float(timeout_seconds)
self._clock = monotonic_clock
def run(
self,
serial: str,
goods_id: str,
gate2: Gate2Observation,
output_directory: Path,
) -> FinalSubmitPanelRunResult:
target = Path(output_directory)
staging: Path | None = None
try:
_validate_preflight(serial, goods_id, gate2, target)
staging = _prepare_staging(target)
inspection = self._adb_client.inspect(serial)
_require_expected_device(inspection)
adapter = UiautomatorFinalSubmitPanelAdapter(
self._connector(serial),
self._foreground_reader,
serial,
self._timeout,
)
flow = FinalSubmitPanelFlow(
adapter,
wait_timeout_seconds=self._timeout,
monotonic_clock=self._clock,
)
flow.require_ready_for_read()
screenshot_path = staging / "gate3_screenshot.png"
_save_base64_screenshot(adapter.capture_screenshot(), screenshot_path)
_require_screenshot(screenshot_path)
captured_at = datetime.now(UTC)
observation = flow.observe(gate2, screenshot_path, captured_at)
flow.exit_final_submit_panel_safely()
_require_action_audit(adapter, flow)
manifest_path = staging / "manifest.json"
manifest_path.write_text(
json.dumps(
_manifest(inspection, serial, gate2, observation, screenshot_path, adapter),
ensure_ascii=False,
indent=2,
sort_keys=True,
)
+ "\n",
encoding="utf-8",
)
os.rename(staging, target)
staging = None
except (DeviceConnectionError, FinalSubmitPanelError):
_clean_staging(staging)
raise
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
_clean_staging(staging)
raise FinalSubmitPanelTimeoutError("T-107 真机运行超时,未发布证据。") from error
except OSError as error:
_clean_staging(staging)
raise FinalSubmitPanelError("T-107 证据无法原子发布。") from error
except Exception as error:
_clean_staging(staging)
raise FinalSubmitPanelError("T-107 真机运行未完成。") from error
published = replace(observation, screenshot_path=target / "gate3_screenshot.png")
return FinalSubmitPanelRunResult(
output_directory=target,
screenshot_path=target / "gate3_screenshot.png",
manifest_path=target / "manifest.json",
observation=published,
)
def _positive_finite(value: object) -> bool:
return isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0 and isfinite(value)
def _validate_preflight(
serial: object,
goods_id: object,
gate2: object,
target: Path,
) -> None:
if type(serial) is not str or not serial.strip() or serial != serial.strip():
raise FinalSubmitPanelError("必须显式提供非空设备通道。")
if type(goods_id) is not str or goods_id != EXPECTED_GOODS_ID:
raise FinalSubmitPanelError("商品不是 T-107 已批准目标。")
if not isinstance(gate2, Gate2Observation) or not gate2.screenshot_path.is_file():
raise FinalSubmitPanelError("Gate2 原始截图不存在,已停止操作。")
if target.exists() or not target.name:
raise FinalSubmitPanelError("输出目录必须是不存在的明确新目录。")
def _prepare_staging(target: Path) -> Path:
staging: Path | None = None
try:
target.parent.mkdir(parents=True, exist_ok=True)
staging = target.parent / f".{target.name}.staging-{uuid4().hex}"
staging.mkdir()
return staging
except OSError as error:
_clean_staging(staging)
raise FinalSubmitPanelError("输出目录不可写,已停止操作。") from error
def _clean_staging(staging: Path | None) -> None:
if staging is not None and staging.exists():
shutil.rmtree(staging)
def _require_expected_device(inspection: DeviceInspection) -> None:
if inspection.model != EXPECTED_DEVICE_MODEL or inspection.android_version != EXPECTED_ANDROID_VERSION:
raise FinalSubmitPanelError("设备型号或 Android 版本与 T-106/T-107 证据不一致。")
def _require_screenshot(path: Path) -> None:
try:
with Image.open(path) as image:
image.load()
if image.size != EXPECTED_SCREEN_SIZE or image.format != "PNG":
raise FinalSubmitPanelError("Gate3 截图尺寸或格式与证据不一致。")
except FinalSubmitPanelError:
raise
except (OSError, UnidentifiedImageError) as error:
raise FinalSubmitPanelError("Gate3 截图不是有效图像。") from error
def _require_action_audit(adapter: UiautomatorFinalSubmitPanelAdapter, flow: FinalSubmitPanelFlow) -> None:
if (
adapter.page_control_action_attempts != 0
or adapter.back_attempts != 1
or adapter.back_outcome != "completed"
or not flow.exited
):
raise FinalSubmitPanelError("T-107 动作审计链不完整,拒绝发布。")
def _manifest(
inspection: DeviceInspection,
serial: str,
gate2: Gate2Observation,
observation: Gate3Observation,
screenshot_path: Path,
adapter: UiautomatorFinalSubmitPanelAdapter,
) -> dict[str, Any]:
return {
"schema_version": 1,
"operation": "t107-final-submit-panel-dry-run",
"captured_at": observation.captured_at.isoformat(),
"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,
},
"selection": {"color": observation.actual_color, "size": observation.actual_size},
"quantity": {"requested": observation.requested_quantity, "read": observation.quantity_read},
"prices": {
"gate2_panel_total_price": observation.gate2_panel_total_price,
"gate3_submit_amount": observation.gate3_submit_amount,
"max_total_price": observation.max_total_price,
},
"submit_control": {
"text": observation.submit_control_text,
"match_count": observation.submit_control_match_count,
"enabled": observation.submit_control_enabled,
"nearest_clickable_ancestor_unique": observation.nearest_clickable_ancestor_unique,
},
"gate2_evidence": {
"captured_at": gate2.captured_at.isoformat(),
"screenshot_sha256": _sha256_file(gate2.screenshot_path),
},
"gate3_evidence": {
"path": screenshot_path.name,
"sha256": _sha256_file(screenshot_path),
},
"action_audit": {
"page_control_action_attempts": adapter.page_control_action_attempts,
"back_attempts": adapter.back_attempts,
"back_rpc_outcome": adapter.back_outcome,
},
"safe_exit": "completed",
"review_status": "human_review_required",
}