feat(client): add T-107 Gate3 dry-run observer
This commit is contained in:
@@ -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",
|
||||
}
|
||||
Reference in New Issue
Block a user