From 1d60a91ace74fa795c25e20f6ef60ec5e33d93bd Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Thu, 6 Aug 2026 12:10:39 +0800 Subject: [PATCH] feat(client): add T-107 Gate3 dry-run observer --- .../run_t107_final_submit_panel_dry_run.py | 138 ++++ .../cmbuyer_client/pdd/final_submit_panel.py | 479 ++++++++++++ .../pdd/final_submit_panel_runner.py | 491 +++++++++++++ .../fixtures/final_submit_panel_8_17_0.xml | 26 + .../final_submit_panel_exit_8_17_0.xml | 12 + client/tests/pdd/test_final_submit_panel.py | 684 ++++++++++++++++++ docs/api.md | 8 + docs/current-state.md | 15 +- docs/tasks/T-107.md | 10 +- 9 files changed, 1861 insertions(+), 2 deletions(-) create mode 100644 client/scripts/run_t107_final_submit_panel_dry_run.py create mode 100644 client/src/cmbuyer_client/pdd/final_submit_panel.py create mode 100644 client/src/cmbuyer_client/pdd/final_submit_panel_runner.py create mode 100644 client/tests/pdd/fixtures/final_submit_panel_8_17_0.xml create mode 100644 client/tests/pdd/fixtures/final_submit_panel_exit_8_17_0.xml create mode 100644 client/tests/pdd/test_final_submit_panel.py diff --git a/client/scripts/run_t107_final_submit_panel_dry_run.py b/client/scripts/run_t107_final_submit_panel_dry_run.py new file mode 100644 index 0000000..d59004c --- /dev/null +++ b/client/scripts/run_t107_final_submit_panel_dry_run.py @@ -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()) diff --git a/client/src/cmbuyer_client/pdd/final_submit_panel.py b/client/src/cmbuyer_client/pdd/final_submit_panel.py new file mode 100644 index 0000000..7ff39ed --- /dev/null +++ b/client/src/cmbuyer_client/pdd/final_submit_panel.py @@ -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(?:0|[1-9]\d*)\.\d{2})$") +_GATE3_TEXT = re.compile(r"^提交订单 ¥(?P(?: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, + ) diff --git a/client/src/cmbuyer_client/pdd/final_submit_panel_runner.py b/client/src/cmbuyer_client/pdd/final_submit_panel_runner.py new file mode 100644 index 0000000..4916408 --- /dev/null +++ b/client/src/cmbuyer_client/pdd/final_submit_panel_runner.py @@ -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", + } diff --git a/client/tests/pdd/fixtures/final_submit_panel_8_17_0.xml b/client/tests/pdd/fixtures/final_submit_panel_8_17_0.xml new file mode 100644 index 0000000..b870a2c --- /dev/null +++ b/client/tests/pdd/fixtures/final_submit_panel_8_17_0.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/tests/pdd/fixtures/final_submit_panel_exit_8_17_0.xml b/client/tests/pdd/fixtures/final_submit_panel_exit_8_17_0.xml new file mode 100644 index 0000000..37b1cb6 --- /dev/null +++ b/client/tests/pdd/fixtures/final_submit_panel_exit_8_17_0.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/client/tests/pdd/test_final_submit_panel.py b/client/tests/pdd/test_final_submit_panel.py new file mode 100644 index 0000000..5a849b6 --- /dev/null +++ b/client/tests/pdd/test_final_submit_panel.py @@ -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 ("", "", ""): + 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 = ( + '' + ) + raw = EXIT.replace("", dangerous + "") + 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() diff --git a/docs/api.md b/docs/api.md index 0d2dc03..32dfdd9 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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 同商品正判据时不重试。 + ## 四、实现前仍需定值 - 授权有效期、领取租约时长、心跳/轮询间隔和连续失败停止阈值; diff --git a/docs/current-state.md b/docs/current-state.md index 43b12af..b0d7b52 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -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` diff --git a/docs/tasks/T-107.md b/docs/tasks/T-107.md index d05f596..0eae61c 100644 --- a/docs/tasks/T-107.md +++ b/docs/tasks/T-107.md @@ -22,7 +22,7 @@ write_paths: - docs/current-state.md --- - + ## 问题 / 背景 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,且未创建订单/进入付款。 ## 边界