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,138 @@
"""运行 T-107 最终面板 Gate3 只读观察与一次安全返回。"""
from __future__ import annotations
import argparse
from datetime import datetime
from math import isfinite
from pathlib import Path
import sys
CLIENT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(CLIENT_ROOT / "src"))
from cmbuyer_client.device.adb import AdbClient, DeviceConnectionError, SubprocessAdbRunner
from cmbuyer_client.device.baseline import NoReconnectUiautomatorConnector
from cmbuyer_client.pdd.final_submit_panel import FinalSubmitPanelError
from cmbuyer_client.pdd.final_submit_panel_runner import FinalSubmitPanelRunner
from cmbuyer_client.pdd.quantity_gate2 import (
EXPECTED_GATE1_UNIT_PRICE,
EXPECTED_GOODS_ID,
TASK_COLOR,
TASK_SIZE,
Gate2Observation,
)
from cmbuyer_client.pdd.quantity_gate2_spike import Android16TopResumedForegroundReader
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="运行 T-107 围栏前最终面板只读 dry-run。")
parser.add_argument("--serial", required=True, help="显式 ADB serial;禁止自动选择。")
parser.add_argument("--goods-id", required=True)
parser.add_argument("--color", required=True)
parser.add_argument("--size", required=True)
parser.add_argument("--quantity", required=True, type=int)
parser.add_argument("--gate1-unit-price", required=True)
parser.add_argument("--gate2-panel-total-price", required=True)
parser.add_argument("--gate2-screenshot", required=True, type=Path)
parser.add_argument("--gate2-captured-at", required=True)
parser.add_argument("--max-total-price", required=True)
parser.add_argument("--output-dir", required=True, type=Path)
parser.add_argument("--timeout", type=float, default=10.0)
parser.add_argument("--adb", default="adb")
return parser.parse_args(argv)
def validate_arguments(arguments: argparse.Namespace) -> datetime:
if type(arguments.serial) is not str or not arguments.serial.strip() or arguments.serial != arguments.serial.strip():
raise ValueError("必须显式提供非空 --serial。")
if arguments.goods_id != EXPECTED_GOODS_ID:
raise ValueError("--goods-id 不是 T-107 已批准目标。")
if arguments.color != TASK_COLOR or arguments.size != TASK_SIZE:
raise ValueError("颜色或尺码不是 T-107 已批准目标。")
if type(arguments.quantity) is not int or arguments.quantity != 2:
raise ValueError("本次真机验收只批准 --quantity 2。")
if arguments.gate1_unit_price != EXPECTED_GATE1_UNIT_PRICE:
raise ValueError("--gate1-unit-price 与已确认 Gate1 不一致。")
if arguments.gate2_panel_total_price != "32.76":
raise ValueError("--gate2-panel-total-price 与 T-106 已确认值不一致。")
if not isinstance(arguments.gate2_screenshot, Path) or not arguments.gate2_screenshot.is_file():
raise ValueError("--gate2-screenshot 必须是现有显式文件。")
try:
captured_at = datetime.fromisoformat(arguments.gate2_captured_at)
except (TypeError, ValueError) as error:
raise ValueError("--gate2-captured-at 必须是带时区 ISO 时间。") from error
if captured_at.utcoffset() is None:
raise ValueError("--gate2-captured-at 必须带时区。")
if arguments.max_total_price != "40.00":
raise ValueError("本次真机验收固定 --max-total-price 40.00。")
if not isinstance(arguments.output_dir, Path) or not arguments.output_dir.name or arguments.output_dir.exists():
raise ValueError("--output-dir 必须是不存在的明确新目录。")
if (
not isinstance(arguments.timeout, (int, float))
or isinstance(arguments.timeout, bool)
or arguments.timeout <= 0
or not isfinite(arguments.timeout)
):
raise ValueError("--timeout 必须是大于 0 的有限数值。")
return captured_at
def main(argv: list[str] | None = None) -> int:
arguments = parse_arguments(argv)
try:
captured_at = validate_arguments(arguments)
except ValueError as error:
print(f"失败:{error}", file=sys.stderr)
return 2
try:
import adbutils
import uiautomator2 as u2
except ImportError:
print("失败:缺少采购工具真机依赖。", file=sys.stderr)
return 2
adb_runner = SubprocessAdbRunner(arguments.adb)
runner = FinalSubmitPanelRunner(
AdbClient(adb_runner, timeout_seconds=arguments.timeout),
NoReconnectUiautomatorConnector(
adbutils.AdbClient(socket_timeout=arguments.timeout).device_list,
u2.connect,
),
Android16TopResumedForegroundReader(adb_runner, arguments.timeout),
timeout_seconds=arguments.timeout,
)
gate2 = Gate2Observation(
requested_color=arguments.color,
requested_size=arguments.size,
actual_color=arguments.color,
actual_size=arguments.size,
requested_quantity=arguments.quantity,
quantity_read=arguments.quantity,
gate1_unit_price=arguments.gate1_unit_price,
gate2_panel_total_price=arguments.gate2_panel_total_price,
max_total_price=arguments.max_total_price,
screenshot_path=arguments.gate2_screenshot,
captured_at=captured_at,
)
try:
runner.run(
arguments.serial,
arguments.goods_id,
gate2,
arguments.output_dir,
)
except (DeviceConnectionError, FinalSubmitPanelError, OSError):
# 不回显第三方异常、serial、本机路径或页面正文。
print("T-107 最终面板 dry-run 失败:已停止,未发布证据目录。", file=sys.stderr)
return 1
print("T-107 最终面板 dry-run 完成。")
print("人工复核:Gate2/Gate3、最终控件唯一、一次安全返回,且未创建订单或进入付款。")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -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",
}
@@ -0,0 +1,26 @@
<hierarchy rotation="0">
<!-- T-106 合并式最终面板证据:只保留规格摘要、顶部 Gate2、数量和 Gate3 最终文本。 -->
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[0,551][1080,938]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[396,575][1053,647]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[396,575][740,647]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="快卖完 ¥32.76" package="com.xunmeng.pinduoduo" class="android.widget.TextView" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[396,580][722,647]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
</node>
<node text="已选: 黑色 CHA (纯棉) M(建议100-115)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[396,659][1053,721]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" resource-id="com.xunmeng.pinduoduo:id/gnl" bounds="[396,827][645,902]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[396,827][645,902]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node content-desc="减少数量" package="com.xunmeng.pinduoduo" class="android.widget.ImageView" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[396,827][474,902]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
<node text="2" package="com.xunmeng.pinduoduo" class="android.widget.EditText" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[480,827][561,902]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
<node content-desc="增加数量" package="com.xunmeng.pinduoduo" class="android.widget.ImageView" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[567,827][645,902]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
</node>
</node>
<!-- 最近可点击祖先是 FrameLayout;更宽的可点击祖先允许存在,但不是返回对象。 -->
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[0,366][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[0,2181][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[354,2181][726,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="提交订单 ¥32.76" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[366,2225][714,2284]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
</node>
</node>
</hierarchy>
@@ -0,0 +1,12 @@
<hierarchy rotation="0">
<!-- T-106 人工一次 Back 后的同商品标题正锚。 -->
<node resource-id="com.xunmeng.pinduoduo:id/tv_title" package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[36,1571][1044,1689]" content-desc="2026年新款高档重工潮流烫钻中长款T恤显瘦宽松上衣淡人穿搭" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" long-clickable="true">
<node text="2026年新款高档重工潮流烫钻中长款T恤显瘦宽松" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,1571][1023,1624]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
<node text="上衣淡人穿搭" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,1636][306,1689]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
<!-- 底部入口只作同商品页面身份;其中数字不解析、不返回、不充当任何价格闸门。 -->
<node content-desc="快要抢光¥12.88" package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[446,2166][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="快要抢光 ¥ 12.88" package="com.xunmeng.pinduoduo" class="android.widget.TextView" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[688,2184][1042,2253]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
<node text="免拼购买" package="com.xunmeng.pinduoduo" class="android.widget.TextView" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[688,2256][856,2305]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
</hierarchy>
+684
View File
@@ -0,0 +1,684 @@
"""T-107 合并式最终提交面板 Gate3 与一次安全返回测试。"""
from __future__ import annotations
import argparse
import ast
import base64
from contextlib import redirect_stderr
from dataclasses import FrozenInstanceError, replace
from datetime import UTC, datetime
from functools import lru_cache
from importlib.util import module_from_spec, spec_from_file_location
from io import BytesIO, StringIO
import inspect
import json
from pathlib import Path
import sys
from tempfile import TemporaryDirectory
import unittest
from unittest.mock import patch
from xml.etree import ElementTree
from PIL import Image
CLIENT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(CLIENT_ROOT / "src"))
from cmbuyer_client.device.adb import AdbDevice, DeviceInspection
from cmbuyer_client.pdd.final_submit_panel import (
FinalSubmitPanelError,
FinalSubmitPanelOverCapError,
Gate3Observation,
observe_gate3,
returned_product_projection,
)
from cmbuyer_client.pdd.final_submit_panel_runner import (
FinalSubmitPanelDevice,
FinalSubmitPanelFlow,
FinalSubmitPanelRunner,
FinalSubmitPanelTimeoutError,
UiautomatorFinalSubmitPanelAdapter,
)
from cmbuyer_client.pdd.quantity_gate2 import (
EXPECTED_GOODS_ID,
TASK_COLOR,
TASK_SIZE,
Gate2Observation,
)
SERIAL = "192.168.0.173:5555"
FIXTURES = Path(__file__).with_name("fixtures")
PANEL_FIXTURE = FIXTURES / "final_submit_panel_8_17_0.xml"
EXIT_FIXTURE = FIXTURES / "final_submit_panel_exit_8_17_0.xml"
PANEL = PANEL_FIXTURE.read_text(encoding="utf-8")
EXIT = EXIT_FIXTURE.read_text(encoding="utf-8")
def _gate2(screenshot_path: Path = Path("gate2.png"), **changes: object) -> Gate2Observation:
value = Gate2Observation(
requested_color=TASK_COLOR,
requested_size=TASK_SIZE,
actual_color=TASK_COLOR,
actual_size=TASK_SIZE,
requested_quantity=2,
quantity_read=2,
gate1_unit_price="12.88",
gate2_panel_total_price="32.76",
max_total_price="40.00",
screenshot_path=screenshot_path,
captured_at=datetime(2026, 8, 6, 3, 7, 33, tzinfo=UTC),
)
return replace(value, **changes)
def _observe(raw: str = PANEL, gate2: Gate2Observation | None = None) -> Gate3Observation:
return observe_gate3(
raw,
_gate2() if gate2 is None else gate2,
Path("gate3.png"),
datetime(2026, 8, 6, 3, 40, tzinfo=UTC),
)
def _edit_one(raw: str, predicate: object, **attributes: str) -> str:
root = ElementTree.fromstring(raw)
matches = [node for node in root.iter("node") if predicate(node)] # type: ignore[operator]
if len(matches) != 1:
raise AssertionError(f"expected one node, got {len(matches)}")
for key, value in attributes.items():
matches[0].set(key.replace("_", "-"), value)
return ElementTree.tostring(root, encoding="unicode")
def _duplicate_submit(raw: str) -> str:
root = ElementTree.fromstring(raw)
submit = next(node for node in root.iter("node") if node.get("text") == "提交订单 ¥32.76")
parent = next(node for node in root.iter() if submit in list(node))
parent.append(ElementTree.fromstring(ElementTree.tostring(submit, encoding="unicode")))
return ElementTree.tostring(root, encoding="unicode")
@lru_cache(maxsize=1)
def _png_base64() -> str:
raw = BytesIO()
Image.new("RGB", (1080, 2376), color="white").save(raw, format="PNG")
return base64.b64encode(raw.getvalue()).decode("ascii")
class FinalSubmitPanelObserverTests(unittest.TestCase):
def test_observer_reads_two_independent_amount_roles_and_safe_control_summary(self) -> None:
observation = _observe()
self.assertEqual(observation.gate2_panel_total_price, "32.76")
self.assertEqual(observation.gate3_submit_amount, "32.76")
self.assertEqual(observation.submit_control_text, "提交订单 ¥32.76")
self.assertEqual(observation.submit_control_match_count, 1)
self.assertTrue(observation.submit_control_enabled)
self.assertTrue(observation.nearest_clickable_ancestor_unique)
self.assertEqual(observation.quantity_read, 2)
def test_dynamic_canonical_amount_is_allowed_only_when_both_roles_and_gate2_agree(self) -> None:
raw = PANEL.replace("快卖完 ¥32.76", "快卖完 ¥39.99").replace(
"提交订单 ¥32.76", "提交订单 ¥39.99"
)
observation = _observe(raw, _gate2(gate2_panel_total_price="39.99"))
self.assertEqual(observation.gate2_panel_total_price, "39.99")
self.assertEqual(observation.gate3_submit_amount, "39.99")
def test_gate2_top_role_must_match_trusted_gate2_observation(self) -> None:
raw = PANEL.replace("快卖完 ¥32.76", "快卖完 ¥32.77")
with self.assertRaises(FinalSubmitPanelError):
_observe(raw)
def test_gate3_must_strictly_equal_current_gate2_top_role(self) -> None:
raw = PANEL.replace("提交订单 ¥32.76", "提交订单 ¥32.77")
with self.assertRaises(FinalSubmitPanelError):
_observe(raw)
def test_over_cap_gate2_fails_before_page_result(self) -> None:
raw = PANEL.replace("快卖完 ¥32.76", "快卖完 ¥40.01").replace(
"提交订单 ¥32.76", "提交订单 ¥40.01"
)
with self.assertRaises(FinalSubmitPanelOverCapError):
_observe(raw, _gate2(gate2_panel_total_price="40.01"))
def test_noncanonical_money_never_becomes_a_candidate(self) -> None:
for text in ("提交订单 ¥032.76", "提交订单 ¥32.7", "提交订单 ¥ 32.76", "提交订单 32.76"):
with self.subTest(text=text), self.assertRaises(FinalSubmitPanelError):
_observe(PANEL.replace("提交订单 ¥32.76", text))
with self.assertRaises(FinalSubmitPanelError):
_observe(gate2=_gate2(gate2_panel_total_price="032.76"))
def test_submit_text_requires_exactly_one_complete_match(self) -> None:
with self.assertRaises(FinalSubmitPanelError):
_observe(PANEL.replace("提交订单 ¥32.76", "创建订单 ¥32.76"))
with self.assertRaises(FinalSubmitPanelError):
_observe(_duplicate_submit(PANEL))
def test_submit_leaf_must_be_inert_enabled_and_evidence_bound(self) -> None:
leaf = lambda node: node.get("text") == "提交订单 ¥32.76"
for change in (
{"clickable": "true"},
{"enabled": "false"},
{"bounds": "[365,2225][714,2284]"},
{"class": "android.widget.Button"},
):
with self.subTest(change=change), self.assertRaises(FinalSubmitPanelError):
_observe(_edit_one(PANEL, leaf, **change))
def test_nearest_clickable_ancestor_is_exact_but_broader_clickable_ancestor_is_allowed(self) -> None:
self.assertTrue(_observe().nearest_clickable_ancestor_unique)
frame = lambda node: node.get("class") == "android.widget.FrameLayout" and node.get("bounds") == "[0,2181][1080,2328]"
for change in ({"clickable": "false"}, {"enabled": "false"}, {"bounds": "[1,2181][1080,2328]"}):
with self.subTest(change=change), self.assertRaises(FinalSubmitPanelError):
_observe(_edit_one(PANEL, frame, **change))
def test_specs_quantity_and_panel_structure_are_exact(self) -> None:
cases = (
PANEL.replace("黑色 CHA (纯棉)", "白色 CHA (纯棉)"),
PANEL.replace("M(建议100-115)", "S(建议80-100)"),
_edit_one(PANEL, lambda node: node.get("class") == "android.widget.EditText", text="1"),
_edit_one(PANEL, lambda node: node.get("content-desc") == "增加数量", bounds="[568,827][645,902]"),
)
for raw in cases:
with self.subTest(), self.assertRaises(FinalSubmitPanelError):
_observe(raw)
def test_trusted_gate2_specs_quantity_and_evidence_metadata_are_required(self) -> None:
changes = (
{"actual_color": "白色"},
{"actual_size": "S"},
{"requested_quantity": 1},
{"quantity_read": 1},
{"screenshot_path": Path()},
{"captured_at": datetime(2026, 8, 6, 3, 7, 33)},
)
for change in changes:
with self.subTest(change=change), self.assertRaises(FinalSubmitPanelError):
_observe(gate2=_gate2(**change))
with self.assertRaises(FinalSubmitPanelError):
observe_gate3(PANEL, object(), Path("gate3.png"), datetime.now(UTC)) # type: ignore[arg-type]
def test_invalid_xml_and_root_fail_closed(self) -> None:
for raw in ("", "<hierarchy>", "<root />"):
with self.subTest(raw=raw), self.assertRaises(FinalSubmitPanelError):
_observe(raw)
def test_gate3_metadata_must_be_timezone_aware_and_named(self) -> None:
with self.assertRaises(FinalSubmitPanelError):
observe_gate3(PANEL, _gate2(), Path(), datetime.now(UTC))
with self.assertRaises(FinalSubmitPanelError):
observe_gate3(PANEL, _gate2(), Path("gate3.png"), datetime(2026, 8, 6))
def test_observation_is_frozen_and_contains_no_action_material(self) -> None:
observation = _observe()
expected = {
"requested_color", "requested_size", "actual_color", "actual_size",
"requested_quantity", "quantity_read", "gate2_panel_total_price",
"gate3_submit_amount", "max_total_price", "submit_control_text",
"submit_control_match_count", "submit_control_enabled",
"nearest_clickable_ancestor_unique", "screenshot_path", "captured_at",
}
self.assertEqual(set(observation.__dict__), expected)
for forbidden in ("selector", "bounds", "node", "ancestor_handle", "clickable_object"):
self.assertNotIn(forbidden, observation.__dict__)
with self.assertRaises(FrozenInstanceError):
observation.gate3_submit_amount = "1.00" # type: ignore[misc]
class ReturnedProductProjectionTests(unittest.TestCase):
def test_t106_return_fixture_matches_stable_same_product_projection(self) -> None:
first = returned_product_projection(EXIT)
second = returned_product_projection(EXIT)
self.assertEqual(first, second)
self.assertEqual(first[0], "final_submit_panel_exit_8_17_0")
def test_title_or_entry_drift_fails_closed(self) -> None:
cases = (
EXIT.replace("上衣淡人穿搭", "其他商品"),
EXIT.replace("免拼购买", "单独购买"),
EXIT.replace("快要抢光¥12.88", "快要抢光¥12.89"),
)
for raw in cases:
with self.subTest(), self.assertRaises(FinalSubmitPanelError):
returned_product_projection(raw)
def test_dangerous_live_action_rejects_return_page(self) -> None:
dangerous = (
'<node text="立即支付" package="com.xunmeng.pinduoduo" class="android.widget.TextView" '
'resource-id="" bounds="[0,0][1,1]" clickable="true" enabled="true" '
'visible-to-user="true" selected="false" scrollable="false" />'
)
raw = EXIT.replace("</hierarchy>", dangerous + "</hierarchy>")
with self.assertRaises(FinalSubmitPanelError):
returned_product_projection(raw)
class FakeClock:
def __init__(self) -> None:
self.value = 0.0
def __call__(self) -> float:
return self.value
def sleep(self, duration: float) -> None:
self.value += duration
class FakePanelDevice:
def __init__(
self,
*,
panel: str = PANEL,
exit_page: str = EXIT,
version: str = "8.17.0",
package: str = "com.xunmeng.pinduoduo",
size: tuple[int, int] = (1080, 2376),
back_error: Exception | None = None,
) -> None:
self.panel = panel
self.exit_page = exit_page
self.version = version
self.package = package
self.size = size
self.back_error = back_error
self.after_back = False
self.back_attempts = 0
self.calls: list[str] = []
def app_info(self, package_name: str) -> dict[str, str]:
self.calls.append("app_info")
return {"versionName": self.version}
def current_foreground(self) -> dict[str, str]:
self.calls.append("foreground")
return {"package": self.package, "activity": ".activity.NewPageActivity"}
def display_size(self) -> tuple[int, int]:
self.calls.append("size")
return self.size
def dump_window_hierarchy(self) -> str:
self.calls.append("dump")
return self.exit_page if self.after_back else self.panel
def capture_screenshot(self) -> str:
self.calls.append("screenshot")
return _png_base64()
def leave_final_submit_panel_once(self) -> None:
self.calls.append("back")
self.back_attempts += 1
self.after_back = True
if self.back_error is not None:
raise self.back_error
def _flow(device: FakePanelDevice, timeout: float = 0.02) -> FinalSubmitPanelFlow:
clock = FakeClock()
return FinalSubmitPanelFlow(
device,
wait_timeout_seconds=timeout,
poll_interval_seconds=0.005,
monotonic_clock=clock,
sleep_function=clock.sleep,
)
class FinalSubmitPanelFlowTests(unittest.TestCase):
def test_success_has_zero_page_control_actions_and_one_back(self) -> None:
device = FakePanelDevice()
flow = _flow(device)
observation = flow.observe(_gate2(), Path("gate3.png"), datetime.now(UTC))
flow.exit_final_submit_panel_safely()
self.assertEqual(observation.gate3_submit_amount, "32.76")
self.assertEqual(device.back_attempts, 1)
self.assertEqual(device.calls.count("back"), 1)
self.assertTrue(flow.exited)
self.assertFalse(hasattr(device, "click"))
def test_observation_failure_never_attempts_back(self) -> None:
device = FakePanelDevice(panel=PANEL.replace("提交订单 ¥32.76", "提交订单 ¥32.77"))
flow = _flow(device)
with self.assertRaises(FinalSubmitPanelError):
flow.observe(_gate2(), Path("gate3.png"), datetime.now(UTC))
self.assertEqual(device.back_attempts, 0)
with self.assertRaises(FinalSubmitPanelError):
flow.exit_final_submit_panel_safely()
def test_environment_drift_fails_before_back(self) -> None:
devices = (
FakePanelDevice(version="8.18.0"),
FakePanelDevice(package="com.android.systemui"),
FakePanelDevice(size=(1080, 2400)),
)
for device in devices:
with self.subTest(), self.assertRaises(FinalSubmitPanelError):
_flow(device).observe(_gate2(), Path("gate3.png"), datetime.now(UTC))
self.assertEqual(device.back_attempts, 0)
def test_back_result_unknown_is_not_retried(self) -> None:
device = FakePanelDevice(back_error=TimeoutError("unknown"))
flow = _flow(device)
flow.observe(_gate2(), Path("gate3.png"), datetime.now(UTC))
with self.assertRaises(TimeoutError):
flow.exit_final_submit_panel_safely()
self.assertEqual(device.back_attempts, 1)
with self.assertRaises(FinalSubmitPanelError):
flow.exit_final_submit_panel_safely()
self.assertEqual(device.back_attempts, 1)
def test_unconfirmed_return_times_out_after_exactly_one_back(self) -> None:
device = FakePanelDevice(exit_page=PANEL)
flow = _flow(device)
flow.observe(_gate2(), Path("gate3.png"), datetime.now(UTC))
with self.assertRaises(FinalSubmitPanelTimeoutError):
flow.exit_final_submit_panel_safely()
self.assertEqual(device.back_attempts, 1)
def test_single_positive_return_frame_then_drift_never_succeeds(self) -> None:
class AlternatingDevice(FakePanelDevice):
def dump_window_hierarchy(self) -> str:
self.calls.append("dump")
if not self.after_back:
return self.panel
return EXIT if self.calls.count("dump") % 2 == 1 else PANEL
device = AlternatingDevice()
flow = _flow(device)
flow.observe(_gate2(), Path("gate3.png"), datetime.now(UTC))
with self.assertRaises(FinalSubmitPanelTimeoutError):
flow.exit_final_submit_panel_safely()
self.assertEqual(device.back_attempts, 1)
class FakeForegroundReader:
def __init__(self, package: str = "com.xunmeng.pinduoduo") -> None:
self.package = package
self.calls: list[str] = []
def read(self, serial: str) -> dict[str, str]:
self.calls.append(serial)
return {"package": self.package, "activity": ".activity.NewPageActivity"}
class FakeRawDevice:
def __init__(self, *, version: str = "8.17.0", back_timeout: bool = False) -> None:
self.version = version
self.current = PANEL
self.calls: list[tuple[object, ...]] = []
self.back_timeout = back_timeout
def app_info(self, package: str) -> dict[str, str]:
self.calls.append(("app_info", package))
return {"versionName": self.version}
def window_size(self) -> tuple[int, int]:
self.calls.append(("window_size",))
return (1080, 2376)
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> object:
self.calls.append(("jsonrpc", method, params, timeout))
if method == "dumpWindowHierarchy":
return self.current
if method == "takeScreenshot":
return _png_base64()
if method == "pressKey":
self.current = EXIT
if self.back_timeout:
raise TimeoutError("may have been delivered")
return None
raise AssertionError(method)
class FakeAdbClient:
def __init__(self, *, model: str = "PKG110", android_version: str = "16") -> None:
self.inspection = DeviceInspection(
device=AdbDevice(serial=SERIAL, state="device", model=model),
model=model,
android_version=android_version,
)
self.calls: list[str] = []
def inspect(self, serial: str) -> DeviceInspection:
self.calls.append(serial)
return self.inspection
class FinalSubmitPanelRunnerTests(unittest.TestCase):
def test_runner_publishes_gate3_evidence_after_zero_control_actions_and_one_back(self) -> None:
with TemporaryDirectory() as temporary:
base = Path(temporary)
gate2_path = base / "gate2.png"
gate2_path.write_bytes(base64.b64decode(_png_base64()))
target = base / "gate3"
raw = FakeRawDevice()
result = FinalSubmitPanelRunner(
FakeAdbClient(),
lambda serial: raw,
FakeForegroundReader(),
timeout_seconds=0.2,
).run(SERIAL, EXPECTED_GOODS_ID, _gate2(gate2_path), target)
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
methods = [call[1] for call in raw.calls if call[0] == "jsonrpc"]
self.assertNotIn("click", methods)
self.assertEqual(methods.count("pressKey"), 1)
self.assertEqual(methods.count("takeScreenshot"), 1)
self.assertEqual(manifest["prices"]["gate2_panel_total_price"], "32.76")
self.assertEqual(manifest["prices"]["gate3_submit_amount"], "32.76")
self.assertEqual(manifest["submit_control"]["match_count"], 1)
self.assertTrue(manifest["submit_control"]["nearest_clickable_ancestor_unique"])
self.assertEqual(manifest["action_audit"]["page_control_action_attempts"], 0)
self.assertEqual(manifest["action_audit"]["back_attempts"], 1)
self.assertEqual(manifest["safe_exit"], "completed")
self.assertEqual(manifest["review_status"], "human_review_required")
serialized = result.manifest_path.read_text(encoding="utf-8")
self.assertNotIn(SERIAL, serialized)
self.assertNotIn(str(gate2_path), serialized)
def test_gate3_mismatch_publishes_nothing_and_never_attempts_back(self) -> None:
with TemporaryDirectory() as temporary:
base = Path(temporary)
gate2_path = base / "gate2.png"
gate2_path.write_bytes(base64.b64decode(_png_base64()))
raw = FakeRawDevice()
raw.current = PANEL.replace("提交订单 ¥32.76", "提交订单 ¥32.77")
target = base / "gate3"
with self.assertRaises(FinalSubmitPanelError):
FinalSubmitPanelRunner(
FakeAdbClient(), lambda serial: raw, FakeForegroundReader(), timeout_seconds=0.1
).run(SERIAL, EXPECTED_GOODS_ID, _gate2(gate2_path), target)
methods = [call[1] for call in raw.calls if call[0] == "jsonrpc"]
self.assertEqual(methods.count("pressKey"), 0)
self.assertFalse(target.exists())
self.assertEqual(list(base.glob(".gate3.staging-*")), [])
def test_ambiguous_back_is_attempted_once_and_never_published(self) -> None:
with TemporaryDirectory() as temporary:
base = Path(temporary)
gate2_path = base / "gate2.png"
gate2_path.write_bytes(base64.b64decode(_png_base64()))
raw = FakeRawDevice(back_timeout=True)
target = base / "gate3"
with self.assertRaises(FinalSubmitPanelError):
FinalSubmitPanelRunner(
FakeAdbClient(), lambda serial: raw, FakeForegroundReader(), timeout_seconds=0.1
).run(SERIAL, EXPECTED_GOODS_ID, _gate2(gate2_path), target)
methods = [call[1] for call in raw.calls if call[0] == "jsonrpc"]
self.assertEqual(methods.count("pressKey"), 1)
self.assertFalse(target.exists())
def test_version_drift_stops_before_screenshot_or_back(self) -> None:
with TemporaryDirectory() as temporary:
base = Path(temporary)
gate2_path = base / "gate2.png"
gate2_path.write_bytes(base64.b64decode(_png_base64()))
raw = FakeRawDevice(version="8.18.0")
with self.assertRaises(FinalSubmitPanelError):
FinalSubmitPanelRunner(
FakeAdbClient(), lambda serial: raw, FakeForegroundReader(), timeout_seconds=0.1
).run(SERIAL, EXPECTED_GOODS_ID, _gate2(gate2_path), base / "gate3")
methods = [call[1] for call in raw.calls if call[0] == "jsonrpc"]
self.assertNotIn("takeScreenshot", methods)
self.assertNotIn("pressKey", methods)
class FinalSubmitPanelAdapterTests(unittest.TestCase):
def test_adapter_rpc_surface_is_read_only_plus_one_back(self) -> None:
raw = FakeRawDevice()
adapter = UiautomatorFinalSubmitPanelAdapter(raw, FakeForegroundReader(), SERIAL, 0.1)
adapter.dump_window_hierarchy()
adapter.capture_screenshot()
adapter.leave_final_submit_panel_once()
methods = [call[1] for call in raw.calls if call[0] == "jsonrpc"]
self.assertEqual(methods, ["dumpWindowHierarchy", "takeScreenshot", "pressKey"])
self.assertEqual(adapter.page_control_action_attempts, 0)
with self.assertRaises(FinalSubmitPanelError):
adapter.leave_final_submit_panel_once()
self.assertEqual(methods.count("pressKey"), 1)
def test_adapter_seals_back_before_timeout(self) -> None:
raw = FakeRawDevice(back_timeout=True)
adapter = UiautomatorFinalSubmitPanelAdapter(raw, FakeForegroundReader(), SERIAL, 0.1)
with self.assertRaises(FinalSubmitPanelTimeoutError):
adapter.leave_final_submit_panel_once()
self.assertEqual(adapter.back_attempts, 1)
self.assertEqual(adapter.back_outcome, "ambiguous")
with self.assertRaises(FinalSubmitPanelError):
adapter.leave_final_submit_panel_once()
methods = [call[1] for call in raw.calls if call[0] == "jsonrpc"]
self.assertEqual(methods.count("pressKey"), 1)
@lru_cache(maxsize=1)
def _load_cli() -> object:
path = CLIENT_ROOT / "scripts" / "run_t107_final_submit_panel_dry_run.py"
spec = spec_from_file_location("run_t107_final_submit_panel_for_test", path)
if spec is None or spec.loader is None:
raise AssertionError("cannot load CLI")
module = module_from_spec(spec)
spec.loader.exec_module(module)
return module
class FinalSubmitPanelCliTests(unittest.TestCase):
def test_cli_has_no_submit_or_mode_switch(self) -> None:
module = _load_cli()
parser_args = module.parse_arguments(
[
"--serial", SERIAL,
"--goods-id", EXPECTED_GOODS_ID,
"--color", TASK_COLOR,
"--size", TASK_SIZE,
"--quantity", "2",
"--gate1-unit-price", "12.88",
"--gate2-panel-total-price", "32.76",
"--gate2-screenshot", "gate2.png",
"--gate2-captured-at", "2026-08-06T00:53:00+00:00",
"--max-total-price", "40.00",
"--output-dir", "gate3-output",
]
)
self.assertFalse(hasattr(parser_args, "allow_submit"))
self.assertFalse(hasattr(parser_args, "dry_run"))
def test_cli_failure_is_fixed_and_does_not_echo_sensitive_inputs(self) -> None:
module = _load_cli()
secret = "private-serial"
stream = StringIO()
with redirect_stderr(stream):
code = module.main(
[
"--serial", secret,
"--goods-id", "1",
"--color", TASK_COLOR,
"--size", TASK_SIZE,
"--quantity", "2",
"--gate1-unit-price", "12.88",
"--gate2-panel-total-price", "32.76",
"--gate2-screenshot", "missing.png",
"--gate2-captured-at", "2026-08-06T00:53:00+00:00",
"--max-total-price", "40.00",
"--output-dir", "gate3-output",
]
)
self.assertEqual(code, 2)
self.assertNotIn(secret, stream.getvalue())
class FinalSubmitPanelStaticBoundaryTests(unittest.TestCase):
def test_protocol_exposes_only_reads_and_named_single_back(self) -> None:
public = {name for name in FinalSubmitPanelDevice.__dict__ if not name.startswith("_")}
self.assertEqual(
public,
{
"app_info",
"current_foreground",
"display_size",
"dump_window_hierarchy",
"capture_screenshot",
"leave_final_submit_panel_once",
},
)
def test_observer_is_pure_and_accepts_no_device(self) -> None:
parameters = set(inspect.signature(observe_gate3).parameters)
self.assertEqual(parameters, {"raw_hierarchy", "gate2", "screenshot_path", "captured_at"})
source = (CLIENT_ROOT / "src/cmbuyer_client/pdd/final_submit_panel.py").read_text(encoding="utf-8")
self.assertNotIn("uiautomator", source)
self.assertNotIn("jsonrpc", source)
def test_production_rpc_literals_are_reads_plus_back_only(self) -> None:
path = CLIENT_ROOT / "src/cmbuyer_client/pdd/final_submit_panel_runner.py"
tree = ast.parse(path.read_text(encoding="utf-8"))
methods: set[str] = set()
for node in ast.walk(tree):
if not isinstance(node, ast.Call) or len(node.args) < 2:
continue
if not isinstance(node.func, ast.Attribute) or node.func.attr != "_call":
continue
first, second = node.args[:2]
if isinstance(first, ast.Constant) and first.value == "jsonrpc_call" and isinstance(second, ast.Constant):
methods.add(second.value)
self.assertEqual(methods, {"dumpWindowHierarchy", "takeScreenshot", "pressKey"})
def test_imports_and_sources_have_no_forbidden_submission_capabilities(self) -> None:
paths = (
CLIENT_ROOT / "src/cmbuyer_client/pdd/final_submit_panel.py",
CLIENT_ROOT / "src/cmbuyer_client/pdd/final_submit_panel_runner.py",
CLIENT_ROOT / "scripts/run_t107_final_submit_panel_dry_run.py",
)
forbidden = (
"SubmissionPermit",
"submit_order_once",
"click_permitted",
"go_to_order_confirm",
"allow_submit",
"dry_run=False",
"submission_fence",
)
for path in paths:
source = path.read_text(encoding="utf-8")
tree = ast.parse(source)
imported = " ".join(
alias.name
for node in ast.walk(tree)
if isinstance(node, (ast.Import, ast.ImportFrom))
for alias in node.names
)
with self.subTest(path=path):
self.assertFalse(any(token.lower() in imported.lower() for token in ("fence", "payment", "result_sink")))
for token in forbidden:
self.assertNotIn(token, source)
def test_fixtures_contain_no_unrelated_personal_or_payment_data(self) -> None:
combined = PANEL_FIXTURE.read_text(encoding="utf-8") + EXIT_FIXTURE.read_text(encoding="utf-8")
for forbidden in ("手机号", "收货地址", "支付密码", "微信支付", "支付宝"):
self.assertNotIn(forbidden, combined)
if __name__ == "__main__":
unittest.main()
+8
View File
@@ -461,6 +461,14 @@ events/fail/fence/result 或完整 `ResultSink`。T-304/T-306 必须通过 `Dura
T-103 只实现隔离的 `SkuSelectionFlow`:前四项加安全退出。它的模块和静态依赖不得引用数量、最终提交面板、
围栏、提交或支付能力。后续任务按取证顺序组合成生产 `SinglePassPurchaseFlow`。
T-107 已实现的 `observe_gate3()` 是无 device 的纯 XML observer,只接受可信 `Gate2Observation`、当前
XML、显式截图路径和带时区采集时间。它分别重读当前面板顶部 `gate2_panel_total_price` 与最终完整文本
`提交订单 ¥{gate3_submit_amount}`,要求两者唯一、规范、严格相等且不超 `max_total_price`;同时精确
复核目标规格摘要和数量 2。成功 DTO 只包含规格/数量、两个独立金额、完整文本、匹配数、启用态、
“最近可点击祖先唯一”布尔值、截图路径和采集时间,不包含 selector、bounds、XML 节点、祖先句柄或
可操作对象。`FinalSubmitPanelRunner` 永久是围栏前 dry-run:RPC 仅允许截图、XML 和一次 `pressKey Back`,
不接受提交开关,不上传证据;Back 结果不明或返回页未连续两帧命中 T-106 同商品正判据时不重试。
## 四、实现前仍需定值
- 授权有效期、领取租约时长、心跳/轮询间隔和连续失败停止阈值;
+14 -1
View File
@@ -70,7 +70,7 @@
| `docs/design/` | 已有(6 个原型) | web 登录 / 建单 / 工作台 / 详情,desk 采购执行 / 配置;均已人工确认 |
| `scripts/` | 已有 | 上下文门禁、Vikunja 单向导出与 MCP 启动包装 |
| `admin/` | 已初始化 | Go 1.23+ / gin / SQLite,含建单/授权/详情/证据/设备身份与原子 claim/renew;不执行真机动作 |
| `client/` | 已初始化 | Python 3.11+、PySide6/uiautomator2、固定双 Tab 主界面、安全轮询、严格 HTTP/DPAPI/SQLite 恢复底座及受控规格选择/读价/数量 Gate2/安全退出;T-106 合并式最终面板与一次 Back 返回两态只读取证已完成,最终提交仍未开放 |
| `client/` | 已初始化 | Python 3.11+、PySide6/uiautomator2、固定双 Tab 主界面、安全轮询、严格 HTTP/DPAPI/SQLite 恢复底座及受控规格选择/读价/数量 Gate2/安全退出;T-106 两态取证已完成,T-107 Gate3 纯 observer 与一次 Back runner 已实现并等待真机人工验收,最终提交仍未开放 |
| `init.ps1` / `init.sh` | 已完成 | 统一安装与离线验证入口;PowerShell 优先复用合规 venv,缺失时自动选择最高的 Python 3.11+,Unix 缺工具链明确失败 |
## 任务状态
@@ -252,6 +252,19 @@ T-106 第一态只读取证已于 2026-08-06 完成。脚本重新核对设备
Back、未点击提交订单、未创建待付款订单、未进入付款/安全验证/外部页面。T-106 已完成;随后 T-107
固化零页面点击的 Gate3 observer 和一次安全 Back。红色控件仍不得点击。
T-107 代码已从上述两态证据提取最小 fixture:纯 XML observer 分别复核顶部 Gate2、规格摘要、数量 2、
最终完整文本 Gate3 及最近可点击祖先;DTO 不暴露节点/坐标/selector。runner 在观察成功前零页面动作,
成功后只发送一次 Android Back,并要求返回页连续两帧命中完整商品标题和底部入口正锚;结果不明不重试。
真机人工准备同一最终面板后,从仓库根运行:
```powershell
.\client\.venv\Scripts\python.exe client\scripts\run_t107_final_submit_panel_dry_run.py --serial 192.168.0.173:5555 --goods-id 937122477375 --color "黑色CHA(纯棉)" --size "M(建议100-115)" --quantity 2 --gate1-unit-price 12.88 --gate2-panel-total-price 32.76 --gate2-screenshot "$env:LOCALAPPDATA\cmbuyer\artifacts\T-105\quantity-gate2-live-937122477375-20260806\gate2_screenshot.png" --gate2-captured-at "2026-08-06T01:33:33.818765+00:00" --max-total-price 40.00 --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-107\final-submit-panel-dry-run-937122477375" --timeout 10 --adb D:\Portable\adb\adb.exe
```
该命令会重新核对当前拼多多版本、前台应用、设备和屏幕,不会点击红色“提交订单”,也不会申请围栏、
创建待付款订单或进入付款。T-107 保持 `DOING`,等待项目所有者确认 Gate2/Gate3 均为 `32.76`、
完整文本 match count=1、最近可点击祖先唯一、一次 Back 安全,且未创建订单/进入付款。
## 关键背景
本项目是 `cmroubao`(Go 后端 + Android AccessibilityService)与 `cmpdd`
+9 -1
View File
@@ -22,7 +22,7 @@ write_paths:
- docs/current-state.md
---
<!-- BEGIN VIKUNJA EXPORT id=42 synced=2026-08-06T02:52:05Z sha256=fa2a65bb48ad33c6179d848e4265fb1db8584d73b3cf986b71cc38cec05a00d6 -->
<!-- BEGIN VIKUNJA EXPORT id=42 synced=2026-08-06T04:08:33Z sha256=abf49f164d13d9ff9707ef41e4e55b11a4e267d7accfe6f8c177e0bf92373309 -->
## 问题 / 背景
T-106 真机证据证明当前拼多多 8.17.0 使用合并式最终提交面板:没有独立确认页导航;面板顶部是 Gate2 总额,红色“提交订单 ¥金额”会直接创建待付款订单。T-107 只固化该稳定面板的 Gate3 只读金额、最终控件观察与一次安全返回;绝不点击最终提交。
@@ -60,6 +60,14 @@ T-106 真机证据证明当前拼多多 8.17.0 使用合并式最终提交面板
### 2026-08-04T14:04:47Z · ila
2026-08-04 预研定值:T-107 只消费 T-106 四态证据;确认页导航最多一次,最终提交控件由无 device 的纯 observer 读取且 DTO 不暴露节点/坐标/selector,随后最多一次 Back 并验证返回后置条件。全路径静态不可达提交与付款。
### 2026-08-06T03:26:59Z · ila
2026-08-06 · Codex 已领取 T-107,基线 commit=dbf9b8c,独立 worktree/branch=task/t-107-confirm-gate3。完整 init.ps1 基线通过(admin test/vet/build、client 346 tests、compileall、上下文门禁)。本任务只从 T-106 两态真机证据固化 Gate3 纯 XML observer 与一次安全 Back;成功轨迹页面控件点击数必须为 0,绝不点击“提交订单”,不接触围栏、订单创建或付款能力。代码完成后保持 DOING,等待项目所有者真机人工验收。
### 2026-08-06T04:08:25Z · ila
2026-08-06 T-107 实现候选完成:基于 T-106 人工确认的拼多多 8.17.0 / goods_id 937122477375 两态原始 XML,实现纯 XML Gate3 observer、零页面控件动作 dry-run runner、成功后唯一一次 Android Back、连续两帧同商品返回正判据及本地原子证据。最终提交控件只读,未引入 fence/permit/submit/payment 能力。fixture/静态边界/金额/结构/一次返回等 36 项专项测试通过;client 全量 382 项 unittest、compileall、admin go test/vet/build、完整 init.ps1、agent-context 校验均通过;两份 T-106 原始 XML 离线复核通过。任务因 needs_device/needs_human_review 保持 DOING,等待项目所有者按任务命令确认 Gate2=Gate3=32.76、match_count=1、最近可点击祖先唯一、仅一次 Back,且未创建订单/进入付款。
<!-- END VIKUNJA EXPORT -->
## 边界