Compare commits

...
31 changed files with 2284 additions and 204 deletions
+6 -6
View File
@@ -30,11 +30,11 @@ MVP 只做**任务自带商品链接**的情形:管理员先把任务保存为
“开始采购(只创建待付款订单)”。该点击创建一次性授权,锁定商品、颜色、尺码、数量和最高总价。 “开始采购(只创建待付款订单)”。该点击创建一次性授权,锁定商品、颜色、尺码、数量和最高总价。
采购工具领取后在同一次设备会话中完成:打开商品 → 精确选择规格 → 闸门一读 SKU 单价并校验 采购工具领取后在同一次设备会话中完成:打开商品 → 精确选择规格 → 闸门一读 SKU 单价并校验
上限 → 设置并复核数量 → 闸门二重读规格与同价 → 进入确认页 → 闸门三校验应付总额 → 服务端 上限 → 设置并复核数量 → 闸门二读取顶部总额 → 在同一最终面板读取闸门三最终控件金额 → 服务端
原子建立提交围栏 → 精确点击一次“提交订单” → 转待付款。中间不再回网页端等“机器选对了吗”。 原子建立提交围栏 → 精确点击一次“提交订单” → 转待付款。中间不再回网页端等“机器选对了吗”。
价格**只在规格面板和订单确认页读**——别处的价格文本被拆成多个节点、带券后前缀、 价格只从拼多多 `8.17.0` 已取证合并式最终提交面板的两个独立角色读取:顶部金额承担 Gate1/Gate2,
实付价与原价混在一起,不可靠。 结构化精确文本 `提交订单 ¥{金额}` 中的金额承担 Gate3;后者必须与 Gate2 严格相等且不能回流兜底。
## 现在处于什么阶段 ## 现在处于什么阶段
@@ -62,11 +62,11 @@ MVP 只做**任务自带商品链接**的情形:管理员先把任务保存为
1. 不点击任何支付、免密支付、先用后付或扣款控件 1. 不点击任何支付、免密支付、先用后付或扣款控件
2. **提交订单四条件**:授权未消费且服务端提交围栏已建立 + 闸门二通过 + 闸门三通过 + 2. **提交订单四条件**:授权未消费且服务端提交围栏已建立 + 闸门二通过 + 闸门三通过 +
控件唯一,**只点一次**;围栏后只调和,不释放、不重试 控件唯一,**只点一次**;围栏后只调和,不释放、不重试
3. 订单确认页上除提交与返回外零点击 3. 合并式最终提交面板上除满足四条件后的最终控件与安全返回外零点击
4. 规格按维度精确匹配,防前缀碰撞,找不到即停 4. 规格按维度精确匹配,防前缀碰撞,找不到即停
5. 数量设置后必须读回复核 5. 数量设置后必须读回复核
6. **三道价格闸门**,任一道读不到或不通过即停 6. **三道价格闸门**,任一道读不到或不通过即停
7. **能力分层**——T-103 规格验证路径不得引用数量、确认页或下单函数;后续能力逐段取证 7. **能力分层**——T-103 规格验证路径不得引用数量、最终提交面板或下单函数;后续能力逐段取证
8. 检测到外部支付交接立即停止,不读取不保存凭据 8. 检测到外部支付交接立即停止,不读取不保存凭据
9. 检测到验证码 / 风控 / 人脸 / 短信校验立即停止,不绕过 9. 检测到验证码 / 风控 / 人脸 / 短信校验立即停止,不绕过
10. 只读非敏感摘要,不提取收货地址原文、手机号、支付凭据 10. 只读非敏感摘要,不提取收货地址原文、手机号、支付凭据
@@ -80,7 +80,7 @@ cmbuyer 是 `cmroubao`(Go 后端 + Android AccessibilityService)与 `cmpdd`
(Python + uiautomator2)的合并重启,取各自已验证的一半: (Python + uiautomator2)的合并重启,取各自已验证的一半:
- 保留 cmroubao 的后端任务生命周期、设备侧 API 形状、下单授权状态机、ERP 对接、管理 Web - 保留 cmroubao 的后端任务生命周期、设备侧 API 形状、下单授权状态机、ERP 对接、管理 Web
- 保留 cmpdd 的 uiautomator2 真机自动化、按维度精确选规格、订单确认页读取、付款闸门 - 保留 cmpdd 的 uiautomator2 真机自动化与按维度精确选规格思路;最终提交面板事实在本项目重新取证
- 丢弃自研 Android APK 与 AccessibilityService 感知层 - 丢弃自研 Android APK 与 AccessibilityService 感知层
**前序项目是设计依据,不是事实来源。** 其中的页面判据必须在本项目用真机重新验证。 **前序项目是设计依据,不是事实来源。** 其中的页面判据必须在本项目用真机重新验证。
@@ -1,4 +1,4 @@
"""采集 T-106 人工准备的确认页四态证据;不执行页面操作。""" """采集 T-106 人工准备的最终面板或返回态证据;不执行页面操作。"""
from __future__ import annotations from __future__ import annotations
@@ -23,17 +23,14 @@ from cmbuyer_client.pdd.order_confirm_spike import (
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace: def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="采集 T-106 人工准备的确认页四态本机证据。") parser = argparse.ArgumentParser(description="采集 T-106 人工准备的最终面板或返回态本机证据。")
parser.add_argument("--serial", required=True, help="ADB device serial;禁止自动选择。") parser.add_argument("--serial", required=True, help="ADB device serial;禁止自动选择。")
parser.add_argument("--goods-id", required=True, help="T-106 已批准的目标商品标识。") parser.add_argument("--goods-id", required=True, help="T-106 已批准的目标商品标识。")
parser.add_argument( parser.add_argument(
"--state", "--state",
required=True, required=True,
choices=DECLARED_STATES, choices=DECLARED_STATES,
help=( help="只允许既有提交前来源态,或人工只按一次返回后的安全页状态。",
"人工声明状态:Gate2 后导航来源、确认页 Gate3、最终控件可见、"
"人工一次返回后的安全页。"
),
) )
parser.add_argument("--output-dir", required=True, type=Path, help="全新本机证据目录;不得覆盖。") parser.add_argument("--output-dir", required=True, type=Path, help="全新本机证据目录;不得覆盖。")
parser.add_argument("--timeout", type=float, default=10.0, help="ADB 与只读设备 RPC 超时(秒)。") parser.add_argument("--timeout", type=float, default=10.0, help="ADB 与只读设备 RPC 超时(秒)。")
@@ -97,13 +94,13 @@ def main(argv: list[str] | None = None) -> int:
) )
except (DeviceConnectionError, OrderConfirmEvidenceError): except (DeviceConnectionError, OrderConfirmEvidenceError):
# 第三方异常可能带设备、页面正文或本机目录,命令行只输出固定摘要。 # 第三方异常可能带设备、页面正文或本机目录,命令行只输出固定摘要。
print("确认页四态只读取证失败:已停止,未发布本机证据目录。", file=sys.stderr) print("T-106 只读取证失败:已停止,未发布本机证据目录。", file=sys.stderr)
return 1 return 1
except OSError: except OSError:
print("确认页四态只读取证失败:无法发布本机证据目录。", file=sys.stderr) print("T-106 只读取证失败:无法发布本机证据目录。", file=sys.stderr)
return 1 return 1
print("确认页四态只读取证完成。") print("T-106 只读取证完成。")
print("人工复核:请在指定目录检查截图、XML、应用摘要和 manifest。") print("人工复核:请在指定目录检查截图、XML、应用摘要和 manifest。")
return 0 return 0
@@ -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,518 @@
"""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"
_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 = ("提交订单", "确认订单", "立即支付", "去支付", "付款")
@dataclass(frozen=True)
class _PanelProfile:
evidence_id: str
panel_bounds: str
price_frame_bounds: str
price_row_bounds: str
price_text_bounds: str
summary_bounds: str
quantity_bounds: str
minus_bounds: str
value_bounds: str
plus_bounds: str
# 两个配置分别绑定 T-106 原始证据和 T-107 同版本人工五项确认的新证据。
# 只能整套精确命中其中一个,禁止把 77px 位移改成范围或混搭坐标。
_PANEL_PROFILES = (
_PanelProfile(
evidence_id="t106-final-panel-8.17.0",
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]",
),
_PanelProfile(
evidence_id="t107-final-panel-shifted-8.17.0",
panel_bounds="[0,474][1080,861]",
price_frame_bounds="[396,498][1053,570]",
price_row_bounds="[396,498][740,570]",
price_text_bounds="[396,503][722,570]",
summary_bounds="[396,582][1053,644]",
quantity_bounds="[396,750][645,825]",
minus_bounds="[396,750][474,825]",
value_bounds="[480,750][561,825]",
plus_bounds="[567,750][645,825]",
),
)
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:
profile_matches = [
(profile, node)
for profile in _PANEL_PROFILES
for node in nodes
if _exact(node, "android.view.ViewGroup", profile.panel_bounds, resource_id=_PDD_ID)
]
if len(profile_matches) != 1:
raise FinalSubmitPanelError("最终面板未唯一命中已人工确认的精确布局配置。")
profile, panel = profile_matches[0]
price_frame = _one(
node
for node in nodes
if node.parent is panel
and _exact(node, "android.widget.FrameLayout", profile.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", profile.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", profile.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", profile.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", profile.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", profile.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", profile.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", profile.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", profile.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",
}
@@ -1,4 +1,4 @@
"""T-106 确认页四态的纯只读取证;页面对应性只由人工确认。""" """T-106 合并式最终提交面板与人工返回态的纯只读取证。"""
from __future__ import annotations from __future__ import annotations
@@ -36,16 +36,11 @@ EXPECTED_GOODS_ID = "937122477375"
EXPECTED_DEVICE_MODEL = "PKG110" EXPECTED_DEVICE_MODEL = "PKG110"
EXPECTED_ANDROID_VERSION = "16" EXPECTED_ANDROID_VERSION = "16"
EXPECTED_SCREEN_SIZE = (1080, 2376) EXPECTED_SCREEN_SIZE = (1080, 2376)
DECLARED_STATES = ( DECLARED_STATES = ("gate2-navigation-source", "returned-safe-page")
"gate2-navigation-source",
"confirm-gate3",
"submit-control-visible",
"returned-safe-page",
)
class OrderConfirmEvidenceError(RuntimeError): class OrderConfirmEvidenceError(RuntimeError):
"""T-106 四态证据未形成完整原子产物。""" """T-106 只读证据未形成完整原子产物。"""
class OrderConfirmEvidenceTimeoutError(OrderConfirmEvidenceError): class OrderConfirmEvidenceTimeoutError(OrderConfirmEvidenceError):
@@ -213,13 +208,13 @@ class OrderConfirmEvidenceCapturer:
raise raise
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error: except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
_clean_staging(staging) _clean_staging(staging)
raise OrderConfirmEvidenceTimeoutError("确认页四态只读取证超时,未发布证据。") from error raise OrderConfirmEvidenceTimeoutError("T-106 只读取证超时,未发布证据。") from error
except (OSError, UnidentifiedImageError, ValueError) as error: except (OSError, UnidentifiedImageError, ValueError) as error:
_clean_staging(staging) _clean_staging(staging)
raise OrderConfirmEvidenceError("确认页四态证据无法原子发布,未发布证据。") from error raise OrderConfirmEvidenceError("T-106 证据无法原子发布,未发布证据。") from error
except Exception as error: except Exception as error:
_clean_staging(staging) _clean_staging(staging)
raise OrderConfirmEvidenceError("确认页四态只读取证未完成,未发布证据。") from error raise OrderConfirmEvidenceError("T-106 只读取证未完成,未发布证据。") from error
return OrderConfirmEvidenceResult( return OrderConfirmEvidenceResult(
output_directory=target, output_directory=target,
@@ -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>
@@ -0,0 +1,25 @@
<hierarchy rotation="0">
<!-- T-107 2026-08-06 人工五项确认:同版本面板整体上移 77px;底部最终控件不变。 -->
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[0,474][1080,861]" 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,498][1053,570]" 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,498][740,570]" 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,503][722,570]" 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,582][1053,644]" 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,750][645,825]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[396,750][645,825]" 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,750][474,825]" 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,750][561,825]" 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,750][645,825]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
</node>
</node>
<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>
+706
View File
@@ -0,0 +1,706 @@
"""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"
SHIFTED_PANEL_FIXTURE = FIXTURES / "final_submit_panel_shifted_8_17_0.xml"
EXIT_FIXTURE = FIXTURES / "final_submit_panel_exit_8_17_0.xml"
PANEL = PANEL_FIXTURE.read_text(encoding="utf-8")
SHIFTED_PANEL = SHIFTED_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_each_human_confirmed_exact_panel_profile_is_accepted_independently(self) -> None:
for raw in (PANEL, SHIFTED_PANEL):
with self.subTest(panel_bounds=ElementTree.fromstring(raw)[0].get("bounds")):
observation = _observe(raw)
self.assertEqual(observation.gate2_panel_total_price, "32.76")
self.assertEqual(observation.gate3_submit_amount, "32.76")
def test_panel_profiles_cannot_be_ranged_or_mixed(self) -> None:
cases = (
SHIFTED_PANEL.replace("[0,474][1080,861]", "[0,475][1080,862]"),
SHIFTED_PANEL.replace("[396,582][1053,644]", "[396,659][1053,721]"),
PANEL.replace("[396,827][645,902]", "[396,750][645,825]"),
)
for raw in cases:
with self.subTest(), self.assertRaises(FinalSubmitPanelError):
_observe(raw)
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 = "".join(
path.read_text(encoding="utf-8")
for path in (PANEL_FIXTURE, SHIFTED_PANEL_FIXTURE, EXIT_FIXTURE)
)
for forbidden in ("手机号", "收货地址", "支付密码", "微信支付", "支付宝"):
self.assertNotIn(forbidden, combined)
if __name__ == "__main__":
unittest.main()
+7 -3
View File
@@ -1,4 +1,4 @@
"""T-106 确认页四态纯只读取证的离线测试。""" """T-106 合并式最终提交面板与人工返回态纯只读取证测试。"""
from __future__ import annotations from __future__ import annotations
@@ -229,8 +229,8 @@ class OrderConfirmEvidenceTests(unittest.TestCase):
timeout_seconds=2, timeout_seconds=2,
) )
def test_all_four_human_states_publish_only_raw_read_evidence(self) -> None: def test_only_approved_human_states_publish_raw_read_evidence(self) -> None:
self.assertEqual(len(DECLARED_STATES), 4) self.assertEqual(DECLARED_STATES, ("gate2-navigation-source", "returned-safe-page"))
for state in DECLARED_STATES: for state in DECLARED_STATES:
with self.subTest(state=state), TemporaryDirectory() as temporary: with self.subTest(state=state), TemporaryDirectory() as temporary:
adb = FakeAdbClient() adb = FakeAdbClient()
@@ -276,6 +276,8 @@ class OrderConfirmEvidenceTests(unittest.TestCase):
("", EXPECTED_GOODS_ID, DECLARED_STATES[0]), ("", EXPECTED_GOODS_ID, DECLARED_STATES[0]),
(f" {SERIAL}", EXPECTED_GOODS_ID, DECLARED_STATES[0]), (f" {SERIAL}", EXPECTED_GOODS_ID, DECLARED_STATES[0]),
(SERIAL, "958756616606", DECLARED_STATES[0]), (SERIAL, "958756616606", DECLARED_STATES[0]),
(SERIAL, EXPECTED_GOODS_ID, "confirm-gate3"),
(SERIAL, EXPECTED_GOODS_ID, "submit-control-visible"),
(SERIAL, EXPECTED_GOODS_ID, "unknown"), (SERIAL, EXPECTED_GOODS_ID, "unknown"),
) )
for serial, goods_id, state in scenarios: for serial, goods_id, state in scenarios:
@@ -465,6 +467,8 @@ class OrderConfirmCliAndStaticBoundaryTests(unittest.TestCase):
_namespace(serial=""), _namespace(serial=""),
_namespace(serial=f" {SERIAL}"), _namespace(serial=f" {SERIAL}"),
_namespace(goods_id="958756616606"), _namespace(goods_id="958756616606"),
_namespace(state="confirm-gate3"),
_namespace(state="submit-control-visible"),
_namespace(state="unknown"), _namespace(state="unknown"),
_namespace(output_dir=Path("")), _namespace(output_dir=Path("")),
_namespace(timeout=0), _namespace(timeout=0),
+6 -4
View File
@@ -11,7 +11,7 @@ cmbuyer 是一个自动化采购系统:**采购服务**(网页端,`admin/`
第一版 MVP 只做**任务自带商品链接**的情形。创建任务不授权;管理员勾选 `DRAFT` 并点击 第一版 MVP 只做**任务自带商品链接**的情形。创建任务不授权;管理员勾选 `DRAFT` 并点击
“开始采购(只创建待付款订单)”后,服务端锁定商品、规格、数量和最高总价并签发一次性授权。 “开始采购(只创建待付款订单)”后,服务端锁定商品、规格、数量和最高总价并签发一次性授权。
采购工具领取后在**同一趟**完成精确选规格、三道价格闸门、数量复核、确认页、提交围栏和唯一一次 采购工具领取后在**同一趟**完成精确选规格、三道价格闸门、数量复核、合并式最终提交面板、提交围栏和唯一一次
“提交订单”点击。中间不等待人工确认;订单创建后转待付款,由人核对和付款。 “提交订单”点击。中间不等待人工确认;订单创建后转待付款,由人核对和付款。
## 上下文读取 ## 上下文读取
@@ -66,7 +66,9 @@ cmbuyer 是一个自动化采购系统:**采购服务**(网页端,`admin/`
### 3. 价格只在两个地方读 ### 3. 价格只在两个地方读
**规格面板**(闸门一 / 二)和**订单确认页**(闸门三)。 拼多多 `8.17.0` 合并式最终提交面板中的两个已取证角色:面板顶部金额(闸门一 / 二)和最终红色
控件显示金额(闸门三)。Gate3 只能只读结构化精确文本 `提交订单 ¥{金额}`,且必须与 Gate2 严格
相等;它不得回流充当 Gate1/Gate2。
**不从商品详情页正文、搜索结果卡片或任何其他位置读价格。** 那些位置的价格文本被拆成 **不从商品详情页正文、搜索结果卡片或任何其他位置读价格。** 那些位置的价格文本被拆成
多个节点、带「券后」前缀、实付价与原价混在一起,前序项目在这里耗掉大量时间且无可靠 多个节点、带「券后」前缀、实付价与原价混在一起,前序项目在这里耗掉大量时间且无可靠
@@ -77,7 +79,7 @@ cmbuyer 是一个自动化采购系统:**采购服务**(网页端,`admin/`
### 4. 真机能力逐段取证、隔离开放 ### 4. 真机能力逐段取证、隔离开放
T-103 的 `SkuSelectionFlow` 只允许证据绑定的受控入口、精确选择、读价和安全退出;不得引用 T-103 的 `SkuSelectionFlow` 只允许证据绑定的受控入口、精确选择、读价和安全退出;不得引用
数量、确认页、提交围栏、提交或付款能力。T-105~T-107 分别取证后才可组合。生产单趟只有在全部 数量、最终提交面板、提交围栏、提交或付款能力。T-105~T-107 分别取证后才可组合。生产单趟只有在全部
能力均获证据、三道闸门通过且服务端围栏明确许可后才能点击一次;支付能力永不开放。 能力均获证据、三道闸门通过且服务端围栏明确许可后才能点击一次;支付能力永不开放。
## 当前阶段 ## 当前阶段
@@ -123,7 +125,7 @@ T-103 的 `SkuSelectionFlow` 只允许证据绑定的受控入口、精确选择
- **手工填链接**建单、任务查询、勾选待开始任务后批量“开始采购” - **手工填链接**建单、任务查询、勾选待开始任务后批量“开始采购”
- 点击开始采购即签发锁定商品、规格、数量和最高总价的一次性授权 - 点击开始采购即签发锁定商品、规格、数量和最高总价的一次性授权
- 桌面端定时轮询只领取已授权任务,在同一趟精确选规格、两次读价、复核数量和确认页金额 - 桌面端定时轮询只领取已授权任务,在同一趟精确选规格、复核数量并读取 Gate1/Gate2/Gate3
- 围栏前失败/过期可人工回到待开始;围栏后只能调和 - 围栏前失败/过期可人工回到待开始;围栏后只能调和
- 三道闸门全过、服务端围栏明确许可后提交订单一次 - 三道闸门全过、服务端围栏明确许可后提交订单一次
- 真实点击前服务端原子建立提交围栏;围栏失败不点击,围栏后只调和同一提交记录 - 真实点击前服务端原子建立提交围栏;围栏失败不点击,围栏后只调和同一提交记录
+15 -12
View File
@@ -33,7 +33,7 @@ MVP 只做**任务自带商品链接**的情形。管理员点击“开始采购
| F-001 | 手工建单 | 填写标题、拼多多链接、颜色分类、尺码、数量、最高总价,保存为待开始任务 | P0 | US-001 | | F-001 | 手工建单 | 填写标题、拼多多链接、颜色分类、尺码、数量、最高总价,保存为待开始任务 | P0 | US-001 |
| F-004 | 任务查询 | 按标题关键词、状态、时间范围找到任务,双击行查看详情 | P0 | US-002 | | F-004 | 任务查询 | 按标题关键词、状态、时间范围找到任务,双击行查看详情 | P0 | US-002 |
| F-005 | 桌面端定时领取 | 执行员启动会话后定时领取已获采购授权的任务 | P0 | US-003 | | F-005 | 桌面端定时领取 | 执行员启动会话后定时领取已获采购授权的任务 | P0 | US-003 |
| F-006 | 单趟采购执行 | 打开商品、精确选择规格、校验价格、设置数量、进入确认页并创建待付款订单 | P0 | US-003、US-005 | | F-006 | 单趟采购执行 | 打开商品、精确选择规格、校验价格、设置数量、核对合并式最终提交面板并创建待付款订单 | P0 | US-003、US-005 |
| F-007 | 执行证据回传 | 管理员查看实际规格、三道闸门、原始页面截图和步骤记录 | P0 | US-002、US-008 | | F-007 | 执行证据回传 | 管理员查看实际规格、三道闸门、原始页面截图和步骤记录 | P0 | US-002、US-008 |
| F-008 | 开始采购授权 | 管理员批量点击一次,原子锁定每条任务并签发一次性待付款订单授权 | P0 | US-005、US-010 | | F-008 | 开始采购授权 | 管理员批量点击一次,原子锁定每条任务并签发一次性待付款订单授权 | P0 | US-005、US-010 |
| F-010 | 围栏前过期与重新授权 | 未建立提交围栏的失败或过期任务回到待开始,可由管理员重新点击开始采购 | P0 | US-005 | | F-010 | 围栏前过期与重新授权 | 未建立提交围栏的失败或过期任务回到待开始,可由管理员重新点击开始采购 | P0 | US-005 |
@@ -102,13 +102,15 @@ US-004 的试选后人工确认已从 MVP 删除。US-006 在 MVP 为:系统
拼多多 `8.17.0`、goods_id `937122477375` 的“快要抢光”;其他入口必须分别取证,不按购买语义泛化。 拼多多 `8.17.0`、goods_id `937122477375` 的“快要抢光”;其他入口必须分别取证,不按购买语义泛化。
- **F-006 规格与数量**:颜色分类、尺码必须在各自维度内精确唯一匹配并读回;数量设置后必须读回 - **F-006 规格与数量**:颜色分类、尺码必须在各自维度内精确唯一匹配并读回;数量设置后必须读回
等于任务值。缺失、重复、禁用、前缀碰撞、读回不一致都停止,不选相近项。 等于任务值。缺失、重复、禁用、前缀碰撞、读回不一致都停止,不选相近项。
- **F-006 三道价格闸门**:价格只从规格面板和订单确认页读取,金额使用十进制字符串。 - **F-006 三道价格闸门**:价格只从拼多多 `8.17.0` 已取证的合并式最终提交面板两个独立角色读取,
金额使用十进制字符串。
1. 选中目标规格后读取当前 SKU 单价,计算 `单价 × 数量`,不得超过授权最高总价。 1. 选中目标规格后读取当前 SKU 单价,计算 `单价 × 数量`,不得超过授权最高总价。
2. 设置并复核数量后再次核对同一规格,并唯一读取规格面板的目标数量总额;总额不得超过授权 2. 设置并复核数量后再次核对同一规格,并唯一读取规格面板的目标数量总额;总额不得超过授权
最高总价。多件优惠可能非线性,不用闸门一金额乘数量猜总额,也不要求目标数量总额等于闸门一。 最高总价。多件优惠可能非线性,不用闸门一金额乘数量猜总额,也不要求目标数量总额等于闸门一。
3. 订单确认页核对规格、数量与应付总额;应付总额必须与闸门二面板总额严格相等,且再次不超过 3. 保持同一稳定面板,再次核对规格和数量,并从唯一结构化精确文本 `提交订单 ¥{金额}` 读取最终
授权最高总价。 控件显示金额;它必须与闸门二顶部总额严格相等,且再次不超过授权最高总价。
任一道读不到、有歧义或不通过都停止并转人工,不从详情页、搜索页或按钮文字凑价格。 任一道读不到、有歧义或不通过都停止并转人工,不从详情页、搜索页或其他按钮文字凑价格。Gate3
最终控件金额只承担 Gate3,不得用于 Gate1/Gate2。
- 同一设备会话连续完成上述步骤;不退出商品再等采购服务确认,也不把旧截图或旧页面读数当作 - 同一设备会话连续完成上述步骤;不退出商品再等采购服务确认,也不把旧截图或旧页面读数当作
当前提交依据。 当前提交依据。
@@ -124,18 +126,19 @@ US-004 的试选后人工确认已从 MVP 删除。US-006 在 MVP 为:系统
- 围栏建立前,授权过期、设备失败或人工放弃可使任务回到 `DRAFT`;必须由管理员重新点击“开始采购” - 围栏建立前,授权过期、设备失败或人工放弃可使任务回到 `DRAFT`;必须由管理员重新点击“开始采购”
产生新授权。围栏建立后不得释放、重新授权、重新领取或再次点击。 产生新授权。围栏建立后不得释放、重新授权、重新领取或再次点击。
- 系统任何路径都不点击支付、免密支付、先用后付或扣款控件;提交订单后的代码路径不含支付动作。 - 系统任何路径都不点击支付、免密支付、先用后付或扣款控件;提交订单后的代码路径不含支付动作。
- 发布真实下单能力前必须用独立真机任务完成只读 dry-run:到订单确认页验证规格、数量、金额和提交 - 发布真实下单能力前必须用独立真机任务完成只读 dry-run:在合并式最终提交面板验证规格、数量、
控件唯一但不点击。dry-run 是版本发布门禁,不是每笔订单的第一趟,也不能代替实时三道闸门。 Gate2 顶部总额、Gate3 最终控件金额和最近可点击祖先唯一但不点击。dry-run 是版本发布门禁,不是
每笔订单的第一趟,也不能代替实时三道闸门。
### 结果、证据与异常 ### 结果、证据与异常
- **F-007**:按步骤回传实际规格、闸门一单价、数量读回、闸门二面板总额、确认页总额、判定、时间和截图。 - **F-007**:按步骤回传实际规格、闸门一单价、数量读回、闸门二顶部总额、闸门三最终控件金额、判定、时间和截图。
截图是内部审计证据,不是提交前审批前置。 截图是内部审计证据,不是提交前审批前置。
- 内部系统允许上传规格面板与订单确认页的原始截图,截图中可保留页面已显示的地址和手机号;只允许 - 内部系统允许上传规格面板与合并式最终提交面板的原始截图,截图中可保留页面已显示的地址和手机号;只允许
已认证设备显式上传、已登录管理员查看。完整 XML 只留采购工具本机,不上传;地址/手机号不解析为 已认证设备显式上传、已登录管理员查看。完整 XML 只留采购工具本机,不上传;地址/手机号不解析为
业务字段或日志;外部支付页、支付凭据永不上传。 业务字段或日志;外部支付页、支付凭据永不上传。
- **F-011**:至少区分设备未连接、版本失配、商品打不开、面板打不开、规格不匹配、价格不可读、 - **F-011**:至少区分设备未连接、版本失配、商品打不开、面板打不开、规格不匹配、价格不可读、
规格漂移、数量复核失败、闸门二总额不可读或超上限、确认页与闸门二不一致、提交控件不唯一、围栏失败、页面识别 规格漂移、数量复核失败、闸门二总额不可读或超上限、闸门三与闸门二不一致、提交控件不唯一、围栏失败、页面识别
失败、安全校验、外部支付交接和超时。 失败、安全校验、外部支付交接和超时。
- 运行时拼多多 App 版本与已取证版本不一致时停止领取真机任务,提示重新取证。 - 运行时拼多多 App 版本与已取证版本不一致时停止领取真机任务,提示重新取证。
- 订单明确创建后展示截图、商品、规格、数量和授权金额;人在拼多多付款后手工标记完成。 - 订单明确创建后展示截图、商品、规格、数量和授权金额;人在拼多多付款后手工标记完成。
@@ -169,8 +172,8 @@ US-004 的试选后人工确认已从 MVP 删除。US-006 在 MVP 为:系统
版本;不能从前序项目或旧版本推断。 版本;不能从前序项目或旧版本推断。
- **资金风险**:管理员点击开始采购会授权创建真实待付款订单。按钮必须明确影响范围与最高总额, - **资金风险**:管理员点击开始采购会授权创建真实待付款订单。按钮必须明确影响范围与最高总额,
服务端一次性授权、提交围栏和点击一次共同防止重复订单;付款始终人工。 服务端一次性授权、提交围栏和点击一次共同防止重复订单;付款始终人工。
- **单趟时效风险**:授权到实际执行之间价格可能变化。任务不锁定旧观察价,而是在同一趟内两次读取 - **单趟时效风险**:授权到实际执行之间价格可能变化。任务不锁定旧观察价,而是在同一趟内读取
规格面板价格并在确认页校验总额;任何变化或歧义都停止。 Gate1 单价、Gate2 顶部总额和 Gate3 最终控件金额;后两者不一致或任一歧义都停止。
- **账号与风控风险**:固定机器节奏可能触发平台风控。轮询和动作间隔需可配置,检测到验证码、短信、 - **账号与风控风险**:固定机器节奏可能触发平台风控。轮询和动作间隔需可配置,检测到验证码、短信、
人脸或风险校验立即停止,不绕过。 人脸或风险校验立即停止,不绕过。
- **隐私风险**:内部原始截图可能含地址和手机号。只允许受控内部链路;不提取结构化隐私字段,完整 - **隐私风险**:内部原始截图可能含地址和手机号。只允许受控内部链路;不提取结构化隐私字段,完整
+2 -2
View File
@@ -27,7 +27,7 @@
| 维度 | 选型 | 状态 | 理由 / 说明 | | 维度 | 选型 | 状态 | 理由 / 说明 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 语言 | Python 3.11+ | 已定 | AI 生态原生;`uiautomator2` 官方语言 | | 语言 | Python 3.11+ | 已定 | AI 生态原生;`uiautomator2` 官方语言 |
| 真机控制 | `uiautomator2` | 已定 | 前序项目真机验证过选规格与订单确认页读取 | | 真机控制 | `uiautomator2` | 已定 | 前序项目验证过自动化可行性;本项目已重新取证规格与合并式最终提交面板 |
| 传输 | ADB(USB 或 WiFi) | 已定 | `uiautomator2` 3.x 走 adb 通道,`ip:port` 与 USB serial 同等对待 | | 传输 | ADB(USB 或 WiFi) | 已定 | `uiautomator2` 3.x 走 adb 通道,`ip:port` 与 USB serial 同等对待 |
| 桌面 GUI | `PySide6` | 已定 | 前序项目已验证;执行员需要看设备状态和批次进度 | | 桌面 GUI | `PySide6` | 已定 | 前序项目已验证;执行员需要看设备状态和批次进度 |
| 截图处理 | `Pillow` | 已定 | 判断页面是否渲染完成,避免保存白屏壳层 | | 截图处理 | `Pillow` | 已定 | 判断页面是否渲染完成,避免保存白屏壳层 |
@@ -169,7 +169,7 @@ PDD 规格面板不可避免显示收货区域和手机号。项目所有者确
不上传。T-204 直接接入原始截图上传,资产标记 `privacy_tier=INTERNAL_RAW`,只允许 不上传。T-204 直接接入原始截图上传,资产标记 `privacy_tier=INTERNAL_RAW`,只允许
已认证设备写入、已登录管理员读取;不上传 XML,不允许外部支付页或支付凭据。 已认证设备写入、已登录管理员读取;不上传 XML,不允许外部支付页或支付凭据。
这个范围调整不授权 T-103 调整数量、进入确认页、点击“提交订单”或触碰支付控件。T-103 最终由人 这个范围调整不授权 T-103 调整数量、观察最终提交面板、点击“提交订单”或触碰支付控件。T-103 最终由人
运行受控入口、目标颜色、一次 reveal、目标 M、读价和一次 Back 的完整真机验收;生产业务最终为单趟, 运行受控入口、目标颜色、一次 reveal、目标 M、读价和一次 Back 的完整真机验收;生产业务最终为单趟,
但能力仍按 T-103 / T-105 / T-107 分段取证。 但能力仍按 T-103 / T-105 / T-107 分段取证。
+33 -22
View File
@@ -82,8 +82,8 @@ MVP 只做任务自带商品链接的 A 路径。创建任务与开始采购分
│ 4. 【闸门一】读 SKU 单价;单价×数量不得超过最高总价 │ 4. 【闸门一】读 SKU 单价;单价×数量不得超过最高总价
│ 5. 设置数量并精确读回 │ 5. 设置数量并精确读回
│ 6. 【闸门二】重读规格、数量与面板总额;总额不超最高总价 │ 6. 【闸门二】重读规格、数量与面板总额;总额不超最高总价
│ 7. 进入订单确认页 │ 7. 保持在合并式最终提交面板,不执行页面导航
│ 8. 【闸门三】规格/数量一致,应付总额等于闸门二且不超最高总价 │ 8. 【闸门三】重核规格/数量;最终控件金额等于闸门二且不超最高总价
│ 9. 上传验证摘要并申请服务端提交围栏 │ 9. 上传验证摘要并申请服务端提交围栏
│ 10. 仅在 click_permitted=true 且提交控件唯一时点击一次 │ 10. 仅在 click_permitted=true 且提交控件唯一时点击一次
│ 11. 回传观察结果;不确定时只调和,不重试 │ 11. 回传观察结果;不确定时只调和,不重试
@@ -104,13 +104,14 @@ T-103 最新真机证据表明:拼多多 `8.17.0`、goods_id `937122477375`、
- 只能精确唯一匹配底部非点击文本叶节点、其直接可点击 PDD 父容器及证据绑定结构,并且只点击 - 只能精确唯一匹配底部非点击文本叶节点、其直接可点击 PDD 父容器及证据绑定结构,并且只点击
第一行文本叶节点中心;缺失、重复、版本失配、结构漂移或打开后面板不唯一时零后续点击。 第一行文本叶节点中心;缺失、重复、版本失配、结构漂移或打开后面板不唯一时零后续点击。
- 商品内容区同名促销小字明确禁止作为入口。底部按钮中的金额只参与结构识别,不得解析、返回或 - 商品内容区同名促销小字明确禁止作为入口。底部购买区入口金额只参与结构识别,不得解析、返回或
充当价格来源;价格仍只能从规格面板和订单确认页读取。 充当价格来源;规格选择后的最终红色控件金额也不得用于 Gate1/Gate2,只在 T-106 已确认的合并式
最终提交面板中作为 Gate3 独立金额角色。
- `免拼购买` 只允许作为已取证、不可点击的精确兄弟节点验证底部结构,不能作为选择器、目标或兜底; - `免拼购买` 只允许作为已取证、不可点击的精确兄弟节点验证底部结构,不能作为选择器、目标或兜底;
它变为可点击、位置漂移或出现在其他结构时必须拒绝。“单独购买 / 直接拼成”等其他文案同样不能 它变为可点击、位置漂移或出现在其他结构时必须拒绝。“单独购买 / 直接拼成”等其他文案同样不能
用包含、前缀、同义或坐标兜底。 用包含、前缀、同义或坐标兜底。
- 受控入口只负责进入已取证面板,不等于支付授权,也不能暴露通用任意点击能力。 - 受控入口只负责进入已取证面板,不等于支付授权,也不能暴露通用任意点击能力。
- T-103 的隔离验证 capability 只包含开商品、开面板、选规格、读价和安全退出;数量、确认页、 - T-103 的隔离验证 capability 只包含开商品、开面板、选规格、读价和安全退出;数量、最终提交面板、
提交和支付仍由后续真机任务分别取证后才能接入生产单趟执行器。 提交和支付仍由后续真机任务分别取证后才能接入生产单趟执行器。
2026-08-05 的五组 8.17.0 真机证据进一步证明,面板刚打开时颜色与尺码均未选中,尺码选项尚未 2026-08-05 的五组 8.17.0 真机证据进一步证明,面板刚打开时颜色与尺码均未选中,尺码选项尚未
@@ -136,16 +137,19 @@ T-103 最新真机证据表明:拼多多 `8.17.0`、goods_id `937122477375`、
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 一 | 规格面板,选中目标规格后 | 单价唯一可读;`单价 × 授权数量 <= 最高总价` | 不可读、有歧义或超上限 | | 一 | 规格面板,选中目标规格后 | 单价唯一可读;`单价 × 授权数量 <= 最高总价` | 不可读、有歧义或超上限 |
| 二 | 规格面板,数量读回后 | 颜色、尺码仍正确;目标数量面板总额唯一可读且不超最高总价 | 规格/数量漂移、总额不可读/有歧义或超上限 | | 二 | 规格面板,数量读回后 | 颜色、尺码仍正确;目标数量面板总额唯一可读且不超最高总价 | 规格/数量漂移、总额不可读/有歧义或超上限 |
| 三 | 订单确认页 | 规格、数量正确;应付总额唯一可读、严格等于闸门二且不超最高总价 | 任一不一致、不可读、有歧义或超上限 | | 三 | 同一合并式最终提交面板 | 规格、数量正确;最终红色控件结构化文本中的金额唯一可读、严格等于闸门二且不超最高总价 | 任一不一致、不可读、有歧义或超上限 |
金额一律使用十进制字符串和十进制定点运算,不用浮点数。价格只从规格面板和订单确认页读取; 金额一律使用十进制字符串和十进制定点运算,不用浮点数。Gate1/Gate2 只从规格面板顶部已批准角色
详情正文、搜索卡片、底部购买/提交按钮的数字不作为价格来源。 读取;Gate3 只从同一最终面板唯一结构化精确文本 `提交订单 ¥{规范十进制金额}` 读取。详情正文、
搜索卡片、底部购买入口和其他按钮数字都不是价格来源;Gate3 金额也不得回流充当 Gate1/Gate2。
2026-08-06 的拼多多 `8.17.0` 真机证据证明同一规格的多件优惠是非线性的:数量 1 时规格面板顶部 2026-08-06 的拼多多 `8.17.0` 真机证据证明同一规格的多件优惠是非线性的:数量 1 时规格面板顶部
当前金额为 `12.88`,数量 2 时变为 `32.76`,而不是 `12.88 × 2`。因此闸门一仍是设置数量前的单价 当前金额为 `12.88`,数量 2 时变为 `32.76`,而不是 `12.88 × 2`。因此闸门一仍是设置数量前的单价
预检,闸门二必须读取设置后的**面板总额** `gate2_panel_total_price` 并直接比较最高总价;不得把 预检,闸门二必须读取设置后的**面板总额** `gate2_panel_total_price` 并直接比较最高总价;不得把
Gate1 乘数量、不得要求 Gate2 等于 Gate1,也不得把底部“提交订单 ¥...”金额作为价格候选或兜底。 Gate1 乘数量、不得要求 Gate2 等于 Gate1。2026-08-06 T-106 进一步证明当前版本没有独立确认页:
Gate3 再要求确认页应付总额与 Gate2 面板总额严格相等,这比只检查上限多一道跨页防漂移约束。 同一面板的红色 `提交订单 ¥32.76` 会直接创建待付款订单并进入付款界面。因此 Gate3 改为在不可逆
动作前只读该最终控件金额,并要求它与 Gate2 顶部总额严格相等;这是同一稳定页面两个独立 UI 位置的
交叉校验,不是把按钮金额当 Gate2 兜底。
闸门一与闸门二发生在同一设备会话中,不需要管理员在中间确认。旧截图、缓存值和发布前 dry-run 闸门一与闸门二发生在同一设备会话中,不需要管理员在中间确认。旧截图、缓存值和发布前 dry-run
都不能替代这次实时读取。 都不能替代这次实时读取。
@@ -162,7 +166,7 @@ Gate3 再要求确认页应付总额与 Gate2 面板总额严格相等,这比
| --- | --- | --- | | --- | --- | --- |
| 不付款 | 不点击支付、免密支付、先用后付或任何扣款控件 | 真实资金损失 | | 不付款 | 不点击支付、免密支付、先用后付或任何扣款控件 | 真实资金损失 |
| 提交四条件 | 授权+围栏、闸门二、闸门三、控件唯一同时成立,只点一次 | 误下单 / 重复下单 | | 提交四条件 | 授权+围栏、闸门二、闸门三、控件唯一同时成立,只点一次 | 误下单 / 重复下单 |
| 确认页零点击 | 除返回和满足四条件后的“提交订单”外不点击任何控件 | 未知副作用 | | 最终面板零点击 | 除安全返回和满足四条件后的最终红色控件外不点击任何控件 | 未知副作用 |
| 规格精确匹配 | 维度内等值唯一匹配,防 `红/粉红`、`1/10` 前缀碰撞 | 买错规格 | | 规格精确匹配 | 维度内等值唯一匹配,防 `红/粉红`、`1/10` 前缀碰撞 | 买错规格 |
| 数量读回复核 | 设置后精确读回,不一致即停 | 买错数量 | | 数量读回复核 | 设置后精确读回,不一致即停 | 买错数量 |
| 三道价格闸门 | 任一道不可读、有歧义或不通过都停,不用别处数字凑 | 超预算 | | 三道价格闸门 | 任一道不可读、有歧义或不通过都停,不用别处数字凑 | 超预算 |
@@ -183,8 +187,10 @@ Gate3 再要求确认页应付总额与 Gate2 面板总额严格相等,这比
1. **一次性授权有效且服务端提交围栏已建立**:围栏把授权、任务、领取和本次验证摘要原子绑定到 1. **一次性授权有效且服务端提交围栏已建立**:围栏把授权、任务、领取和本次验证摘要原子绑定到
唯一 `order_submission`;明确响应包含 `click_permitted=true`。 唯一 `order_submission`;明确响应包含 `click_permitted=true`。
2. **闸门二通过**:目标规格和数量未漂移,目标数量面板总额唯一可读且不超过授权最高总价。 2. **闸门二通过**:目标规格和数量未漂移,目标数量面板总额唯一可读且不超过授权最高总价。
3. **闸门三通过**:确认页规格、数量正确,应付总额严格等于闸门二面板总额,且不超过授权最高总价。 3. **闸门三通过**:同一最终面板的规格、数量正确;唯一结构化文本 `提交订单 ¥{金额}` 中金额严格
4. **提交控件唯一**:文本精确等于“提交订单”,可点击祖先唯一。 等于闸门二顶部总额,且不超过授权最高总价。
4. **提交控件唯一**:完整结构化文本唯一、启用,且其最近可点击祖先唯一。不得使用包含、前缀、
OCR、裸坐标或返回通用可点击对象。
围栏请求超时、断网、冲突或响应不明时不得点击。围栏建立后: 围栏请求超时、断网、冲突或响应不明时不得点击。围栏建立后:
@@ -199,8 +205,9 @@ Gate3 再要求确认页应付总额与 Gate2 面板总额严格相等,这比
### 发布前 dry-run 与生产围栏的区别 ### 发布前 dry-run 与生产围栏的区别
首次启用某一 App 版本的真实提交能力前,必须用独立真机任务完成只读 dry-run:进入确认页,验证 首次启用某一 App 版本的真实提交能力前,必须用独立真机任务完成只读 dry-run:在合并式最终提交
规格、数量、金额与提交控件唯一,然后退出且不点击。它用于证明判据和不可达测试,不是每笔采购的 面板验证规格、数量、Gate2 顶部总额、Gate3 最终控件金额与最近可点击祖先唯一,然后退出且不点击。
它用于证明判据和不可达测试,不是每笔采购的
“第一趟”,也不产生可复用页面事实。生产任务仍在同一趟内重新通过三道闸门并申请服务端围栏。 “第一趟”,也不产生可复用页面事实。生产任务仍在同一趟内重新通过三道闸门并申请服务端围栏。
## 五、数据模型 ## 五、数据模型
@@ -254,7 +261,7 @@ CREATE TABLE purchase_attempts (
gate1_unit_price TEXT, gate1_unit_price TEXT,
gate2_panel_total_price TEXT, gate2_panel_total_price TEXT,
quantity_read INTEGER, quantity_read INTEGER,
confirm_amount TEXT, gate3_submit_amount TEXT,
failure_code TEXT, failure_code TEXT,
started_at TEXT NOT NULL, started_at TEXT NOT NULL,
finished_at TEXT, finished_at TEXT,
@@ -304,7 +311,7 @@ CREATE TABLE order_submissions (
gate1_unit_price TEXT NOT NULL, gate1_unit_price TEXT NOT NULL,
gate2_panel_total_price TEXT NOT NULL, gate2_panel_total_price TEXT NOT NULL,
quantity_read INTEGER NOT NULL, quantity_read INTEGER NOT NULL,
confirm_amount TEXT NOT NULL, gate3_submit_amount TEXT NOT NULL,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
resolved_at TEXT, resolved_at TEXT,
UNIQUE (authorization_id), UNIQUE (authorization_id),
@@ -349,6 +356,10 @@ MVP 不再用 `spec_trials` 作为审批记录,也不存在 `authorized_unit_p
`purchase_attempts` / `order_submissions` 的执行与审计事实;管理员授权的资金边界始终是 `purchase_attempts` / `order_submissions` 的执行与审计事实;管理员授权的资金边界始终是
`total_price_cap`。 `total_price_cap`。
上述 `gate3_submit_amount` 是 T-106 真机结论后的目标 schema。当前历史 migration 仍含
`confirm_amount` / `confirm_page_amount`;T-210 必须在 T-205/T-208 消费前用受保护 migration 完成
迁移,旧列存在期间不得开放 submission fence,不能把旧名字继续解释成新事实。
设备凭据由本机管理 CLI 签发、列出和撤销。token 是 32 字节加密随机值,以 64 位小写十六进制 设备凭据由本机管理 CLI 签发、列出和撤销。token 是 32 字节加密随机值,以 64 位小写十六进制
只显示一次;服务端把 token 解码回原始字节后计算 SHA-256,并与按设备 id 查出的 32 字节 BLOB 只显示一次;服务端把 token 解码回原始字节后计算 SHA-256,并与按设备 id 查出的 32 字节 BLOB
恒定时间比较。未知 id 也执行固定宽度 dummy compare。认证逐请求查库,因此撤销事务提交后才开始的 恒定时间比较。未知 id 也执行固定宽度 dummy compare。认证逐请求查库,因此撤销事务提交后才开始的
@@ -432,7 +443,7 @@ DRAFT / PENDING / NEEDS_MANUAL ─管理员取消(围栏前)→ CANCELED
| 数据 | 位置 | 边界 | | 数据 | 位置 | 边界 |
| --- | --- | --- | | --- | --- | --- |
| 商品 / 规格 / 确认页原始 screenshot | 采购工具本机 + 采购服务内部证据存储 | 可含页面已显示地址/手机号;设备鉴权上传、管理员登录查看,不遮罩 | | 商品 / 规格 / 最终提交面板原始 screenshot | 采购工具本机 + 采购服务内部证据存储 | 可含页面已显示地址/手机号;设备鉴权上传、管理员登录查看,不遮罩 |
| 完整 XML | 仅采购工具本机隔离目录 | 可在内存解析页面判据;不上传、不写日志、Git、Vikunja | | 完整 XML | 仅采购工具本机隔离目录 | 可在内存解析页面判据;不上传、不写日志、Git、Vikunja |
| 最小 XML fixture | 采购工具测试 / Git | 只保留判据所需结构,确认无地址、手机号、支付凭据 | | 最小 XML fixture | 采购工具测试 / Git | 只保留判据所需结构,确认无地址、手机号、支付凭据 |
| 外部支付页或支付凭据 | 不保存、不上传 | 检测到交接立即停止 | | 外部支付页或支付凭据 | 不保存、不上传 | 检测到交接立即停止 |
@@ -463,8 +474,8 @@ DRAFT / PENDING / NEEDS_MANUAL ─管理员取消(围栏前)→ CANCELED
| --- | --- | --- | | --- | --- | --- |
| 页面结构随版本变化 | 旧选择器误点新页面 | 每条判据先真机取证,记录 App 版本、截图、XML、goods_id | | 页面结构随版本变化 | 旧选择器误点新页面 | 每条判据先真机取证,记录 App 版本、截图、XML、goods_id |
| 购买语义入口才打开面板 | 能力范围易扩散 | 只批准证据绑定的精确唯一入口;每种文案单独取证 | | 购买语义入口才打开面板 | 能力范围易扩散 | 只批准证据绑定的精确唯一入口;每种文案单独取证 |
| 当前价与原价/按钮价混杂 | 读错价格 | 限定已取证结构和语义;价格只在面板/确认页读,歧义即停 | | 当前价与原价/按钮价混杂 | 读错价格 | Gate1/Gate2 只读顶部角色;Gate3 只读最终控件结构化金额,角色不互相兜底 |
| 单趟页面状态变化 | 数量或促销导致金额非线性变化 | 同一趟读 Gate1 单价与 Gate2 面板总额,再要求确认页总额与 Gate2 严格相等 | | 单趟页面状态变化 | 数量或促销导致金额非线性变化 | 同一趟读 Gate1 单价与 Gate2 顶部总额,再要求最终控件 Gate3 金额与 Gate2 严格相等 |
| WiFi ADB / 双通道 | 断连或操作错设备 | serial 必填;USB/WiFi 同设备或身份不明时 fail closed | | WiFi ADB / 双通道 | 断连或操作错设备 | serial 必填;USB/WiFi 同设备或身份不明时 fail closed |
| 不可逆动作超时 | 可能已创建订单 | 服务端围栏 + 点击一次 + 只调和,不重试 | | 不可逆动作超时 | 可能已创建订单 | 服务端围栏 + 点击一次 + 只调和,不重试 |
| 双端契约漂移 | 静默不兼容 | [api.md](api.md) 唯一权威;契约改动跑完整双端门禁 | | 双端契约漂移 | 静默不兼容 | [api.md](api.md) 唯一权威;契约改动跑完整双端门禁 |
@@ -472,14 +483,14 @@ DRAFT / PENDING / NEEDS_MANUAL ─管理员取消(围栏前)→ CANCELED
## 七、推荐开发顺序 ## 七、推荐开发顺序
1. **Phase 0 地基**:双端骨架、测试命令、初始化脚本和原型。 1. **Phase 0 地基**:双端骨架、测试命令、初始化脚本和原型。
2. **Phase 1 真机取证**:逐段验证商品打开、受控面板入口、精确规格与读价、数量、确认页和提交 2. **Phase 1 真机取证**:逐段验证商品打开、受控面板入口、精确规格与读价、数量、最终提交面板和提交
控件;真实点击前先完成独立 dry-run。每个 spike 的 capability 只覆盖当期动作。 控件;真实点击前先完成独立 dry-run。每个 spike 的 capability 只覆盖当期动作。
3. **Phase 2 采购服务核心**:DRAFT 建单/列表、批量开始采购与授权、状态/证据/围栏/调和接口。 3. **Phase 2 采购服务核心**:DRAFT 建单/列表、批量开始采购与授权、状态/证据/围栏/调和接口。
4. **Phase 3 双端打通**:设备身份、原子领取、单趟执行到围栏前、事件与截图。 4. **Phase 3 双端打通**:设备身份、原子领取、单趟执行到围栏前、事件与截图。
5. **Phase 4 闭环**:经明确真机授权验证一次提交、待付款收口、失败分类、打包。 5. **Phase 4 闭环**:经明确真机授权验证一次提交、待付款收口、失败分类、打包。
6. **V2**:图搜、Excel、ERP、自动核对、AI、多设备。 6. **V2**:图搜、Excel、ERP、自动核对、AI、多设备。
T-103 继续作为“规格选择与读价”的隔离前置,不含数量、确认页或提交。生产单趟并不意味着在一个 T-103 继续作为“规格选择与读价”的隔离前置,不含数量、最终提交面板或提交。生产单趟并不意味着在一个
任务里跳过逐段取证;它只意味着这些已验证能力集成后,每笔业务任务不再等待中途人工确认。 任务里跳过逐段取证;它只意味着这些已验证能力集成后,每笔业务任务不再等待中途人工确认。
## 八、项目结构 ## 八、项目结构
+8 -5
View File
@@ -19,7 +19,7 @@
### 1.1 资金与不可逆动作 ### 1.1 资金与不可逆动作
- **绝不**编写点击支付、免密支付、先用后付或任何扣款控件的代码。 - **绝不**编写点击支付、免密支付、先用后付或任何扣款控件的代码。
- **绝不**在订单确认页上点击除「提交订单」与返回外的任何控件。 - **绝不**在合并式最终提交面板上点击除满足四条件后的最终提交控件与安全返回外的任何控件。
- 「提交订单」只在**四个前置条件**同时满足时点击一次:授权未消费且服务端提交围栏已 - 「提交订单」只在**四个前置条件**同时满足时点击一次:授权未消费且服务端提交围栏已
明确建立、闸门二通过、闸门三通过、控件唯一。 明确建立、闸门二通过、闸门三通过、控件唯一。
- 围栏接口超时、冲突、网络失败或响应不明时不得点击;围栏建立后不得释放授权、重新领取 - 围栏接口超时、冲突、网络失败或响应不明时不得点击;围栏建立后不得释放授权、重新领取
@@ -37,16 +37,18 @@
- 规格按维度**等值**匹配,必须防前缀碰撞(`红`/`粉红`、`1`/`10`)。 - 规格按维度**等值**匹配,必须防前缀碰撞(`红`/`粉红`、`1`/`10`)。
- 找不到精确值就停止转人工,**绝不选相近项、绝不猜测**。 - 找不到精确值就停止转人工,**绝不选相近项、绝不猜测**。
- 「提交订单」控件必须文本精确相等且可点击祖先唯一,否则停止。 - 最终控件必须唯一匹配结构化精确文本 `提交订单 ¥{规范十进制金额}`,且最近可点击祖先唯一;完整
文本中的金额必须成为 Gate3 并严格等于 Gate2,否则停止。不得用包含、前缀或相似匹配。
- 数量设置后必须读回复核精确等于要求值。 - 数量设置后必须读回复核精确等于要求值。
### 1.3 价格读取边界 ### 1.3 价格读取边界
- 价格**只在规格面板(闸门一 / 二)和订单确认页(闸门三)读**。 - 价格只从已取证合并式最终提交面板的两个独立角色读取:顶部金额用于闸门一 / 二,最终控件结构化
精确文本中的金额用于闸门三。
- **不从商品详情页正文、搜索结果卡片或任何其他位置读价格。** - **不从商品详情页正文、搜索结果卡片或任何其他位置读价格。**
- 读不到就转人工,**不用别处的数字凑合**。 - 读不到就转人工,**不用别处的数字凑合**。
- 同一趟设置并复核数量后,闸门二重读的规格必须不变,并唯一读取目标数量的规格面板总额,直接 - 同一趟设置并复核数量后,闸门二重读的规格必须不变,并唯一读取目标数量的规格面板总额,直接
验证不超授权最高总价;后续闸门三应付总额必须与该面板总额严格相等并再次不超上限。2026-08-06 验证不超授权最高总价;后续闸门三最终控件金额必须与该面板总额严格相等并再次不超上限。2026-08-06
真机已证明多件优惠非线性,不得用闸门一金额乘数量猜 Gate2 总额,也不得要求 Gate2 等于 Gate1。 真机已证明多件优惠非线性,不得用闸门一金额乘数量猜 Gate2 总额,也不得要求 Gate2 等于 Gate1。
- 图片搜索(V2)的唯一产出是 `goods_id`,同样不读价。 - 图片搜索(V2)的唯一产出是 `goods_id`,同样不读价。
@@ -64,7 +66,8 @@
### 1.5 页面判据 ### 1.5 页面判据
- **不得从前序项目、旧文档或推理直接写页面判据。** 必须有本项目的真机取证。 - **不得从前序项目、旧文档或推理直接写页面判据。** 必须有本项目的真机取证。2026-08-06 已确认
拼多多 `8.17.0` 不存在独立确认页导航,当前合并式面板的最终红色控件会直接创建待付款订单。
- 每条判据必须记录取证时的**拼多多 App 版本**。 - 每条判据必须记录取证时的**拼多多 App 版本**。
- 购买语义按钮不能按“作用相同”共享判据。`快要抢光`、`免拼购买`、`单独购买`、`直接拼成` 等 - 购买语义按钮不能按“作用相同”共享判据。`快要抢光`、`免拼购买`、`单独购买`、`直接拼成` 等
每个入口文案都必须分别取证;只允许精确唯一匹配,不允许包含、前缀、相似或坐标兜底。 每个入口文案都必须分别取证;只允许精确唯一匹配,不允许包含、前缀、相似或坐标兜底。
+6 -6
View File
@@ -59,10 +59,10 @@ Phase 是里程碑分组,不是整段串行栅栏。推荐波次:
| T-101 | 验证 ADB / uiautomator2(USB + WiFi) | T-002 | 两通道截图和 dump;同手机双通道 fail closed;超时可配置 | | T-101 | 验证 ADB / uiautomator2(USB + WiFi) | T-002 | 两通道截图和 dump;同手机双通道 fail closed;超时可配置 |
| T-102 | 验证按链接打开商品详情页 | T-101 | canonical goods 链接进入对应商品;前台包与 goods_id 有证据 | | T-102 | 验证按链接打开商品详情页 | T-101 | canonical goods 链接进入对应商品;前台包与 goods_id 有证据 |
| T-103 | 验证受控规格面板、精确选择与 SKU 单价 | T-102, T-110, T-111 | 精确唯一入口、维度内等值选择/读回、防前缀碰撞、当前价唯一读取;隔离 capability 不含数量/确认/提交 | | T-103 | 验证受控规格面板、精确选择与 SKU 单价 | T-102, T-110, T-111 | 精确唯一入口、维度内等值选择/读回、防前缀碰撞、当前价唯一读取;隔离 capability 不含数量/确认/提交 |
| T-104 | 验证规格选择能力安全退出 | T-103 | 完成选择/读价/截图后可关闭面板或退出;不进入确认页、不提交 | | T-104 | 验证规格选择能力安全退出 | T-103 | 完成选择/读价/截图后可关闭面板或退出;不接触最终提交控件 |
| T-105 | 验证数量设置、读回与闸门二 | T-104 | 先只读取证并由人确认,再设置数量、精确读回并复核规格/目标数量面板总额;规格或数量不一致、总额不可读/超上限即停 | | T-105 | 验证数量设置、读回与闸门二 | T-104 | 先只读取证并由人确认,再设置数量、精确读回并复核规格/目标数量面板总额;规格或数量不一致、总额不可读/超上限即停 |
| T-106 | dry-run:确认页与闸门三取证 | T-105 | 只读采集导航前、确认页、最终控件及一次人工 Back 状态;agent 不执行页面动作 | | T-106 | dry-run:合并式最终提交面板与闸门三取证 | T-105 | 只读采集 Gate2/Gate3 两个独立金额位置及一次人工 Back 状态;红色最终控件不点击 |
| T-107 | 固化确认页与提交控件安全判据 | T-106 | 只点已取证导航控件一次;最终控件只读观察;一次 Back;静态不可达提交 | | T-107 | 固化最终提交面板与提交控件安全判据 | T-106 | 零页面点击读取 Gate3;结构化最终文本与最近可点击祖先唯一;一次 Back;静态不可达提交 |
| T-110 | 批准证据绑定的受控规格入口 | T-103 no-go 决策 | 仅批准拼多多 8.17.0 已取证的精确唯一“快要抢光”,不泛化 | | T-110 | 批准证据绑定的受控规格入口 | T-103 no-go 决策 | 仅批准拼多多 8.17.0 已取证的精确唯一“快要抢光”,不泛化 |
| T-111 | 调整为开始采购授权的单趟流程 | T-004, T-010, T-110 | 状态/API/原型/任务依赖一致;删除试选后确认但不放宽三闸门和围栏 | | T-111 | 调整为开始采购授权的单趟流程 | T-004, T-010, T-110 | 状态/API/原型/任务依赖一致;删除试选后确认但不放宽三闸门和围栏 |
@@ -78,7 +78,7 @@ T-106 / T-107 是发布前只读验证,不是业务任务的第一趟。首次
| T-209 | 把核心 schema / 状态机迁移为单趟模型 | T-004, T-111 | 新 migration 安全替换旧 `spec_trials` / 授权锁价结构;新增 `purchase_attempts`;删除旧 MVP 状态;无业务数据事实写入迁移前置,遇未知旧数据 fail closed;领域/迁移测试同步 | | T-209 | 把核心 schema / 状态机迁移为单趟模型 | T-004, T-111 | 新 migration 安全替换旧 `spec_trials` / 授权锁价结构;新增 `purchase_attempts`;删除旧 MVP 状态;无业务数据事实写入迁移前置,遇未知旧数据 fail closed;领域/迁移测试同步 |
| T-203 | 表格查询与批量开始采购(F-004、F-008、F-018) | T-202, T-209 | 只选 DRAFT;按钮明确“不付款”;事务内创建锁定任务字段的一次性授权并全批 `DRAFT → PENDING`;重放/冲突测试 | | T-203 | 表格查询与批量开始采购(F-004、F-008、F-018) | T-202, T-209 | 只选 DRAFT;按钮明确“不付款”;事务内创建锁定任务字段的一次性授权并全批 `DRAFT → PENDING`;重放/冲突测试 |
| T-204 | 路由化任务详情与内部截图资产(F-007、F-011) | T-203 | 抽屉/完整页共享 URL;状态详情;受保护 `INTERNAL_RAW` screenshot 上传/读取、SHA-256 与 no-store;不接收 XML | | T-204 | 路由化任务详情与内部截图资产(F-007、F-011) | T-203 | 抽屉/完整页共享 URL;状态详情;受保护 `INTERNAL_RAW` screenshot 上传/读取、SHA-256 与 no-store;不接收 XML |
| T-210 | 扩展闸门二与闸门三证据类型 | T-107, T-204, T-302 | 先精确扩展 Gate2/Gate3 kind;保留 Gate1、归属/FK/trigger/幂等;有新事实时拒绝降级 | | T-210 | 扩展闸门二与闸门三证据类型 | T-107, T-204, T-302 | 扩展 Gate2/最终提交面板 Gate3 kind,并迁移 gate3_submit_amount;保留 Gate1、归属/FK/trigger/幂等;有新事实时拒绝降级 |
| T-205 | purchase attempt、事件与执行摘要 API | T-210 | 三条成功事件分别绑定正确 Gate1/2/3 asset,冻结摘要与围栏前 reason;不再创建 `spec_trials` 审批记录 | | T-205 | purchase attempt、事件与执行摘要 API | T-210 | 三条成功事件分别绑定正确 Gate1/2/3 asset,冻结摘要与围栏前 reason;不再创建 `spec_trials` 审批记录 |
| T-206 | 单趟状态视图与安全主动作 | T-205 | PENDING/CLAIMED/ORDERING/NEEDS_MANUAL/WAITING_PAYMENT/RECONCILIATION_REQUIRED 显示和允许动作一致;无中途确认 | | T-206 | 单趟状态视图与安全主动作 | T-205 | PENDING/CLAIMED/ORDERING/NEEDS_MANUAL/WAITING_PAYMENT/RECONCILIATION_REQUIRED 显示和允许动作一致;无中途确认 |
| T-207 | 授权有效期与围栏前重置(F-010) | T-206 | 围栏前过期/关闭旧授权后回 DRAFT;围栏后拒绝释放、取消、重新授权 | | T-207 | 授权有效期与围栏前重置(F-010) | T-206 | 围栏前过期/关闭旧授权后回 DRAFT;围栏后拒绝释放、取消、重新授权 |
@@ -95,7 +95,7 @@ T-106 / T-107 是发布前只读验证,不是业务任务的第一趟。首次
| T-306 | 规格面板原始截图与可靠证据上传 | T-104, T-303 | 只发布 Gate1 的显式原始 PNG;唯一恢复槽;可含页面地址/手机号;不上传 XML/路径或扩 kind | | T-306 | 规格面板原始截图与可靠证据上传 | T-104, T-303 | 只发布 Gate1 的显式原始 PNG;唯一恢复槽;可含页面地址/手机号;不上传 XML/路径或扩 kind |
| T-307 | 客户端 attempt 事件与失败 sink | T-205, T-303 | 窄 events/fail HTTP 与同键恢复;不实现 ResultSink、围栏、结果、PDD 或 UI | | T-307 | 客户端 attempt 事件与失败 sink | T-205, T-303 | 窄 events/fail HTTP 与同键恢复;不实现 ResultSink、围栏、结果、PDD 或 UI |
| T-308 | 客户端提交围栏与结果 sink | T-208, T-303, T-307 | 复用同一 transport/localstate;首次明确且 durable 的 CREATE_ONCE 才产生当前进程 opaque permit;重启不可重建 | | T-308 | 客户端提交围栏与结果 sink | T-208, T-303, T-307 | 复用同一 transport/localstate;首次明确且 durable 的 CREATE_ONCE 才产生当前进程 opaque permit;重启不可重建 |
| T-305 | **单趟围栏前端到端 dry-run** | T-107, T-304, T-306, T-307, T-210 | 连续跑到三闸门及最终控件只读观察,随后一次安全 Back 并以固定 reason 停在围栏前;不申请许可、不点击提交 | | T-305 | **单趟围栏前端到端 dry-run** | T-107, T-304, T-306, T-307, T-210 | 连续跑到同一最终面板三闸门及最终控件只读观察,随后一次安全 Back 并以固定 reason 停在围栏前;不申请许可、不点击提交 |
### Phase 4 · 真实提交与收尾 ### Phase 4 · 真实提交与收尾
@@ -126,7 +126,7 @@ T-106 / T-107 是发布前只读验证,不是业务任务的第一趟。首次
- **M0**:两端原型确认;单趟主动作、状态、异常和键盘可枚举。(T-005、T-006、T-111) - **M0**:两端原型确认;单趟主动作、状态、异常和键盘可枚举。(T-005、T-006、T-111)
- **M1**:两端骨架、基础模型与验证入口可运行。 - **M1**:两端骨架、基础模型与验证入口可运行。
- **M2**:真机按链接打开商品、精确选择颜色/尺码并读 SKU 单价。(T-103) - **M2**:真机按链接打开商品、精确选择颜色/尺码并读 SKU 单价。(T-103)
- **M3**:真机设置数量、闸门二、确认页闸门三和唯一提交控件 dry-run 通过,不点击。(T-107) - **M3**:真机设置数量、闸门二、同一最终面板闸门三和唯一提交控件 dry-run 通过,不点击。(T-107)
- **M4**:管理员建单并通过批量开始采购创建一次性授权;设备可安全领取。(T-203、T-302) - **M4**:管理员建单并通过批量开始采购创建一次性授权;设备可安全领取。(T-203、T-302)
- **M5**:围栏前 dry-run 跑通,客户端围栏恢复和离线一次性提交安全闭包成立。(T-305、T-308、T-400) - **M5**:围栏前 dry-run 跑通,客户端围栏恢复和离线一次性提交安全闭包成立。(T-305、T-308、T-400)
- **M6**:新真实单趟只创建可能的待付款订单,围栏/调和/人工收口、Windows 交付和只读验收闭环。(T-401~T-405) - **M6**:新真实单趟只创建可能的待付款订单,围栏/调和/人工收口、Windows 交付和只读验收闭环。(T-401~T-405)
+5 -5
View File
@@ -52,13 +52,13 @@
### US-003 让手机逐条执行已授权任务 ### US-003 让手机逐条执行已授权任务
作为采购执行员,我希望配置服务、设备 serial 和轮询间隔后,点击一次开始轮询,让采购工具逐条领取 作为采购执行员,我希望配置服务、设备 serial 和轮询间隔后,点击一次开始轮询,让采购工具逐条领取
已授权任务并在同一趟中完成选规格、价格校验、数量、确认页和提交围栏。 已授权任务并在同一趟中完成选规格、价格校验、数量、合并式最终提交面板和提交围栏。
验收场景: 验收场景:
- 启动前同时校验采购服务、ADB 设备和已取证拼多多版本;任一不满足则不启动。 - 启动前同时校验采购服务、ADB 设备和已取证拼多多版本;任一不满足则不启动。
- 顶部始终显示会话状态;当前任务、图片、滚动日志和倒序采购记录实时更新。 - 顶部始终显示会话状态;当前任务、图片、滚动日志和倒序采购记录实时更新。
- 精确规格、闸门一单价、数量读回、闸门二面板总额、确认页同额校验或提交控件任一不合格时停止该任务。 - 精确规格、闸门一单价、数量读回、闸门二顶部总额、闸门三最终控件金额或提交控件任一不合格时停止该任务。
- 围栏明确成功后只点一次“提交订单”;系统绝不点击付款。 - 围栏明确成功后只点一次“提交订单”;系统绝不点击付款。
- 连续轮询失败达到阈值自动停止;停止轮询不取消正在执行或已围栏的任务。 - 连续轮询失败达到阈值自动停止;停止轮询不取消正在执行或已围栏的任务。
@@ -69,7 +69,7 @@
验收场景: 验收场景:
- 证据按 attempt 和时间排序,区分规格面板、确认页、提交结果。 - 证据按 attempt 和时间排序,区分规格面板 Gate1/Gate2、最终提交面板 Gate3;不保存付款页结果图。
- 原始截图仅登录管理员可看,响应不缓存;完整 XML 不出现在页面或接口中。 - 原始截图仅登录管理员可看,响应不缓存;完整 XML 不出现在页面或接口中。
- 地址/手机号不提取为字段或日志,外部支付页和支付凭据不存储。 - 地址/手机号不提取为字段或日志,外部支付页和支付凭据不存储。
- 执行中没有“确认机器选对了吗”或“确认后继续第二趟”按钮。 - 执行中没有“确认机器选对了吗”或“确认后继续第二趟”按钮。
@@ -89,7 +89,7 @@
### US-006 付款前核对订单 ### US-006 付款前核对订单
作为采购管理员,我希望任务转为待付款后看到商品、规格、数量、授权上限、确认页金额与订单截图, 作为采购管理员,我希望任务转为待付款后看到商品、规格、数量、授权上限、Gate3 最终控件金额与订单截图,
在拼多多核对后由人决定是否付款。 在拼多多核对后由人决定是否付款。
验收场景: 验收场景:
@@ -114,7 +114,7 @@
验收场景: 验收场景:
- 至少区分设备、App 版本、商品、面板、规格、价格、数量、确认页、围栏、安全校验和网络错误。 - 至少区分设备、App 版本、商品、面板、规格、价格、数量、Gate3、围栏、安全校验和网络错误。
- 围栏前错误提示停止、留证并等待人工处理;不会自动重新开始。 - 围栏前错误提示停止、留证并等待人工处理;不会自动重新开始。
- 围栏后错误明确写“可能已创建订单,只能调和,禁止再次点击”。 - 围栏后错误明确写“可能已创建订单,只能调和,禁止再次点击”。
- 桌面端详情切换不改变服务端状态;Esc 只回到当前任务视图,不取消业务流程。 - 桌面端详情切换不改变服务端状态;Esc 只回到当前任务视图,不取消业务流程。
+5 -5
View File
@@ -57,9 +57,9 @@
### IX-006 待付款核对与标记完成 ### IX-006 待付款核对与标记完成
- 页面显示商品、目标与实际规格、数量、授权上限、闸门一单价、闸门二面板总额、确认页同额校验和 - 页面显示商品、目标与实际规格、数量、授权上限、闸门一单价、闸门二顶部总额、闸门三最终控件同额校验和
三闸门内部证据;MVP 三闸门内部证据;MVP
没有提交后结果截图类型,不把确认页图片冒充订单结果图。 没有提交后结果截图类型,不把 Gate3 最终面板图片冒充订单结果图。
- 首屏警示:“订单已创建,系统尚未付款。请在拼多多人工核对并付款。” - 首屏警示:“订单已创建,系统尚未付款。请在拼多多人工核对并付款。”
- “标记为已付款并完成”只写系统状态,不打开支付页面、不触发手机操作。 - “标记为已付款并完成”只写系统状态,不打开支付页面、不触发手机操作。
- 提交前用简短确认对话框说明这是人工事实记录;成功转 `SUCCEEDED`,重复提交幂等。 - 提交前用简短确认对话框说明这是人工事实记录;成功转 `SUCCEEDED`,重复提交幂等。
@@ -82,7 +82,7 @@
```text ```text
待领取 → 已领取 → 打开商品 → 打开规格面板 → 选择规格 待领取 → 已领取 → 打开商品 → 打开规格面板 → 选择规格
→ 闸门一 → 数量复核 → 闸门二 → 确认页 / 闸门三 → 闸门一 → 数量复核 → 闸门二 → 同一最终面板 / 闸门三
→ 申请围栏 → 已发出唯一提交 → 待付款 / 待调和 → 申请围栏 → 已发出唯一提交 → 待付款 / 待调和
``` ```
@@ -90,7 +90,7 @@
| --- | --- | | --- | --- |
| 服务/设备/App 未就绪 | 开始轮询禁用;状态区给出具体修复入口 | | 服务/设备/App 未就绪 | 开始轮询禁用;状态区给出具体修复入口 |
| 正在领取或执行 | 显示阶段与任务;停止轮询只阻止下一次领取,不中断当前安全步骤 | | 正在领取或执行 | 显示阶段与任务;停止轮询只阻止下一次领取,不中断当前安全步骤 |
| 规格、价格、数量或确认页失败 | 记录固定 reason code,停止点击并进入待人工 | | 规格、价格、数量或闸门三失败 | 记录固定 reason code,停止点击并进入待人工 |
| 正在申请围栏 | 禁用停止当前任务、退出和任何提交动作;界面只显示进度 | | 正在申请围栏 | 禁用停止当前任务、退出和任何提交动作;界面只显示进度 |
| 围栏响应不明 | 不点击,进入待调和 | | 围栏响应不明 | 不点击,进入待调和 |
| 已围栏 / 点击结果不明 | 红色安全横幅“可能已创建订单,禁止再次提交”;只能调和 | | 已围栏 / 点击结果不明 | 红色安全横幅“可能已创建订单,禁止再次提交”;只能调和 |
@@ -104,7 +104,7 @@
围栏由后台流程自动申请,不向执行员提供“提交订单”按钮。 围栏由后台流程自动申请,不向执行员提供“提交订单”按钮。
1. 当前任务在同一趟内完成闸门二和闸门三,并确认提交控件精确唯一。 1. 当前任务在同一合并式最终提交面板完成闸门二和闸门三,并确认结构化文本及最近可点击祖先唯一。
2. 客户端显示“正在申请提交围栏”,提交当前验证摘要。 2. 客户端显示“正在申请提交围栏”,提交当前验证摘要。
3. 只有服务端首次明确返回 `click_permitted=true`,客户端才先持久化唯一点击意图,再精确点击一次。 3. 只有服务端首次明确返回 `click_permitted=true`,客户端才先持久化唯一点击意图,再精确点击一次。
4. MVP 首次真实点击后因没有已取证的提交后页面判据,无条件上报 `UNKNOWN` 并转待调和;只有管理员 4. MVP 首次真实点击后因没有已取证的提交后页面判据,无条件上报 `UNKNOWN` 并转待调和;只有管理员
+2 -2
View File
@@ -46,12 +46,12 @@ cmbuyer 是一个自动化采购系统:**采购服务**(网页端,`admin/`
新接手时如果只看四个地方,看这四个: 新接手时如果只看四个地方,看这四个:
1. [`04-architecture.md`](04-architecture.md) **第三节单趟执行与三道价格闸门**——开始采购 1. [`04-architecture.md`](04-architecture.md) **第三节单趟执行与三道价格闸门**——开始采购
如何授权、两次面板价与确认页金额如何实时保护上限。 如何授权、Gate1/Gate2 顶部金额与 Gate3 最终控件金额如何实时保护上限。
2. [`04-architecture.md`](04-architecture.md) **第四节安全边界与提交订单四条件**——每条 2. [`04-architecture.md`](04-architecture.md) **第四节安全边界与提交订单四条件**——每条
都要有测试。 都要有测试。
3. [`05-coding-rules.md`](05-coding-rules.md) **第 1 节红线**——违反即拒绝。 3. [`05-coding-rules.md`](05-coding-rules.md) **第 1 节红线**——违反即拒绝。
4. [`00-ai-start-here.md`](00-ai-start-here.md) **四条特有纪律**——先取证、只收紧、 4. [`00-ai-start-here.md`](00-ai-start-here.md) **四条特有纪律**——先取证、只收紧、
只在两处读价、隔离验证能力不越界。 只在已取证价格角色读价、隔离验证能力不越界。
## 维护原则 ## 维护原则
+24 -8
View File
@@ -294,7 +294,7 @@ claim/renew 的格式错误固定为 `400 {"error":"invalid_request"}`,超限
} }
``` ```
- 允许规格面板和确认页截图保留页面已显示的地址/手机号;不要求遮罩或裁剪。 - 允许规格面板和合并式最终提交面板截图保留页面已显示的地址/手机号;不要求遮罩或裁剪。
- 不接受 XML、目录、manifest、本机绝对路径、外部支付页截图或支付凭据。 - 不接受 XML、目录、manifest、本机绝对路径、外部支付页截图或支付凭据。
- T-204 只开放 `kind=SKU_PANEL_GATE_1`;后续 kind 必须由对应真机证据任务收紧扩展。 - T-204 只开放 `kind=SKU_PANEL_GATE_1`;后续 kind 必须由对应真机证据任务收紧扩展。
- `privacy_tier` 只能是 `INTERNAL_RAW`;时间必须是以 `Z` 结尾的 UTC RFC 3339。 - `privacy_tier` 只能是 `INTERNAL_RAW`;时间必须是以 `Z` 结尾的 UTC RFC 3339。
@@ -328,19 +328,26 @@ claim/renew 的格式错误固定为 `400 {"error":"invalid_request"}`,超限
"gate1_unit_price": "12.88", "gate1_unit_price": "12.88",
"gate2_panel_total_price": "32.76", "gate2_panel_total_price": "32.76",
"quantity_read": 2, "quantity_read": 2,
"confirm_page_amount": "32.76", "gate3_submit_amount": "32.76",
"submit_control_match_count": 1 "submit_control_text": "提交订单 ¥32.76",
"submit_control_match_count": 1,
"nearest_clickable_ancestor_unique": true
} }
``` ```
`gate3_submit_amount` 是目标数量下合并式最终提交面板红色控件显示的金额;旧
`confirm_page_amount` / `confirm_amount` 语义已被 T-106 真机事实否定。当前仓库历史 migration 仍含旧列,
T-210/T-208 消费前必须用受保护 migration 改为 `gate3_submit_amount`;迁移未完成不得开放 submission fence。
服务端在一个事务中校验:任务/版本/claim/attempt 一致;授权有效未消费且字段等于任务快照; 服务端在一个事务中校验:任务/版本/claim/attempt 一致;授权有效未消费且字段等于任务快照;
规格与授权相等;数量相等;闸门一单价预检通过;闸门二目标数量面板总额不超过 `total_price_cap`; 规格与授权相等;数量相等;闸门一单价预检通过;闸门二目标数量面板总额不超过 `total_price_cap`;
确认页金额严格等于闸门二面板总额且再次不超过上限;提交控件计数为一;此前不存在该授权或 attempt Gate3 最终控件金额严格等于闸门二面板总额且再次不超过上限;完整结构化文本必须唯一、启用且最近
可点击祖先唯一;此前不存在该授权或 attempt
的 submission。随后创建唯一 `order_submission`,授权转 `FENCED`,任务保持不可重领。 的 submission。随后创建唯一 `order_submission`,授权转 `FENCED`,任务保持不可重领。
`gate2_panel_total_price` 是目标数量下规格面板顶部证据角色的总额,不是单价。拼多多多件优惠可能 `gate2_panel_total_price` 是目标数量下规格面板顶部证据角色的总额,不是单价。拼多多多件优惠可能
非线性,禁止用 `gate1_unit_price × quantity_read` 推导它,也禁止继续用旧字段名 非线性,禁止用 `gate1_unit_price × quantity_read` 推导它,也禁止继续用旧字段名
`gate2_unit_price` 承载总额。底部“提交订单 ¥...”文字不属于价格来源。 `gate2_unit_price` 承载总额。最终红色控件金额只能作为 Gate3,绝不能用于 Gate1/Gate2 或为顶部金额兜底。
首次明确成功响应: 首次明确成功响应:
@@ -349,7 +356,7 @@ claim/renew 的格式错误固定为 `400 {"error":"invalid_request"}`,超限
"submission_id": "f3c9f507-7473-4fa6-8d71-8786c34c6301", "submission_id": "f3c9f507-7473-4fa6-8d71-8786c34c6301",
"status": "FENCED", "status": "FENCED",
"click_permitted": true, "click_permitted": true,
"submit_text": "提交订单" "submit_control_text": "提交订单 ¥32.76"
} }
``` ```
@@ -448,12 +455,21 @@ events/fail/fence/result 或完整 `ResultSink`。T-304/T-306 必须通过 `Dura
| `read_sku_unit_price()` | 已确认规格面板 | 十进制单价 | 排除原价、按钮价和歧义候选 | | `read_sku_unit_price()` | 已确认规格面板 | 十进制单价 | 排除原价、按钮价和歧义候选 |
| `set_quantity_and_readback()` | 授权数量 | 实际数量 | 精确读回,否则停 | | `set_quantity_and_readback()` | 授权数量 | 实际数量 | 精确读回,否则停 |
| `read_sku_panel_total_price()` | 已读回目标数量 | 十进制面板总额 | 只读证据绑定的顶部当前金额;排除底部提交按钮金额和歧义候选 | | `read_sku_panel_total_price()` | 已读回目标数量 | 十进制面板总额 | 只读证据绑定的顶部当前金额;排除底部提交按钮金额和歧义候选 |
| `go_to_order_confirm()` | 已通过闸门二 | 确认页摘要 | 后续真机任务取证后才实现 | | `observe_final_submit_panel()` | 已通过闸门二且仍在同一面板 | Gate3 摘要 | 复核规格/数量;只读最终控件结构化金额;零页面点击 |
| `submit_order_once()` | 不可伪造的首次 `SubmissionPermit` | 观察结果 | 许可、闸门、唯一控件全校验;点前持久化;绝不重试 | | `submit_order_once()` | 不可伪造的首次 `SubmissionPermit` | 观察结果 | 许可、闸门、唯一控件全校验;点前持久化;绝不重试 |
T-103 只实现隔离的 `SkuSelectionFlow`:前四项加安全退出。它的模块和静态依赖不得引用数量、确认页、 T-103 只实现隔离的 `SkuSelectionFlow`:前四项加安全退出。它的模块和静态依赖不得引用数量、最终提交面板、
围栏、提交或支付能力。后续任务按取证顺序组合成生产 `SinglePassPurchaseFlow`。 围栏、提交或支付能力。后续任务按取证顺序组合成生产 `SinglePassPurchaseFlow`。
T-107 已实现的 `observe_gate3()` 是无 device 的纯 XML observer,只接受可信 `Gate2Observation`、当前
XML、显式截图路径和带时区采集时间。它分别重读当前面板顶部 `gate2_panel_total_price` 与最终完整文本
`提交订单 ¥{gate3_submit_amount}`,要求两者唯一、规范、严格相等且不超 `max_total_price`;同时精确
复核目标规格摘要和数量 2。面板坐标只接受 T-106 原态或 T-107 人工五项确认的整体上移 77px 态,
两套配置互斥且所有角色必须整套精确命中,不接受范围匹配或坐标混搭。成功 DTO 只包含规格/数量、两个独立金额、完整文本、匹配数、启用态、
“最近可点击祖先唯一”布尔值、截图路径和采集时间,不包含 selector、bounds、XML 节点、祖先句柄或
可操作对象。`FinalSubmitPanelRunner` 永久是围栏前 dry-run:RPC 仅允许截图、XML 和一次 `pressKey Back`,
不接受提交开关,不上传证据;Back 结果不明或返回页未连续两帧命中 T-106 同商品正判据时不重试。
## 四、实现前仍需定值 ## 四、实现前仍需定值
- 授权有效期、领取租约时长、心跳/轮询间隔和连续失败停止阈值; - 授权有效期、领取租约时长、心跳/轮询间隔和连续失败停止阈值;
+49 -25
View File
@@ -12,7 +12,7 @@
## 当前快照 ## 当前快照
- 日期:2026-08-06 - 日期:2026-08-06
- 阶段:**Phase 1 · T-106 确认页只读取证与 Phase 2 安全服务端任务并行** - 阶段:**Phase 1 · T-107 已完成;Phase 2 安全服务端任务继续并行**
- MVP 形态:手工填链接建单 → 批量“开始采购”并签发一次性授权 → 定时领取 → 同一趟精确 - MVP 形态:手工填链接建单 → 批量“开始采购”并签发一次性授权 → 定时领取 → 同一趟精确
选规格 / 三道价格闸门 / 提交围栏 / 点击一次 → 待付款。管理员点击“开始采购”即授权桌面端 选规格 / 三道价格闸门 / 提交围栏 / 点击一次 → 待付款。管理员点击“开始采购”即授权桌面端
创建待付款订单;不再有试选后确认,系统绝不自动付款。 创建待付款订单;不再有试选后确认,系统绝不自动付款。
@@ -28,12 +28,15 @@
凭据保护、SQLite append-only 恢复状态图、Global named mutex 与 durable gateway。原生桌面主界面、 凭据保护、SQLite append-only 恢复状态图、Global named mutex 与 durable gateway。原生桌面主界面、
显式本地配置、停止/重启会话边界和安全轮询协调器已实现;未注入单趟执行 consumer 时保持零领取。 显式本地配置、停止/重启会话边界和安全轮询协调器已实现;未注入单趟执行 consumer 时保持零领取。
已取证的固定尺码 reveal、精确 M 选择、规格面板单价 `12.88` 与一次 Back 已进入生产 Flow 并完成 已取证的固定尺码 reveal、精确 M 选择、规格面板单价 `12.88` 与一次 Back 已进入生产 Flow 并完成
真机人工验收;T-104 已把同商品详情页安全退出收紧为版本绑定、连续两帧稳定判据,数量/确认页/提交仍未开放。 真机人工验收;T-104 已把同商品详情页安全退出收紧为版本绑定、连续两帧稳定判据,数量/Gate2 已开放,
合并式最终提交面板 Gate3 与最终提交仍未开放。
- 测试:采购服务已覆盖登录、建单、授权、详情/证据、设备身份、迁移、原子 claim/renew 与竞态; - 测试:采购服务已覆盖登录、建单、授权、详情/证据、设备身份、迁移、原子 claim/renew 与竞态;
采购工具 346 项离线单元测试(全部 mock,不连接真机)。 采购工具 346 项离线单元测试(全部 mock,不连接真机)。
- 数据:受保护迁移已落到 `00005_task_claims.sql`。旧两趟模型已移除;开始采购会锁定任务快照并签发 - 数据:受保护迁移已落到 `00005_task_claims.sql`。旧两趟模型已移除;开始采购会锁定任务快照并签发
一次性授权,领取会创建唯一 attempt/claim、HMAC claim token 与有界租约。T-211 已统一 title、SKU、 一次性授权,领取会创建唯一 attempt/claim、HMAC claim token 与有界租约。T-211 已统一 title、SKU、
goods_id 与金额的双端 wire 上限,并证明最坏合法 claim 响应小于 32 KiB。仓库不含业务实例数据。 goods_id 与金额的双端 wire 上限,并证明最坏合法 claim 响应小于 32 KiB。历史 migration 中的
`confirm_amount` / `confirm_page_amount` 尚待 T-210 迁移为 `gate3_submit_amount`,完成前不得开放围栏;
仓库不含业务实例数据。
- 标准启动路径:Windows PowerShell 运行 `./init.ps1`,Unix shell 运行 `./init.sh`。Windows 入口 - 标准启动路径:Windows PowerShell 运行 `./init.ps1`,Unix shell 运行 `./init.sh`。Windows 入口
优先使用合规的既有 venv;仅在其缺失时才从 Python Launcher 已安装版本中选择最高的 Python 3.11+, 优先使用合规的既有 venv;仅在其缺失时才从 Python Launcher 已安装版本中选择最高的 Python 3.11+,
并且不覆盖低版本环境;成功后打印真实启动命令。 并且不覆盖低版本环境;成功后打印真实启动命令。
@@ -41,10 +44,11 @@
editable install / 包导入 / unittest / compileall,以及仓库上下文校验。可单独运行两端命令诊断。 editable install / 包导入 / unittest / compileall,以及仓库上下文校验。可单独运行两端命令诊断。
- 当前设备门禁:人工已确认拼多多 8.17.0、goods_id `937122477375` 的衣服商品只能通过“快要抢光” - 当前设备门禁:人工已确认拼多多 8.17.0、goods_id `937122477375` 的衣服商品只能通过“快要抢光”
打开规格面板;T-110 已获项目所有者批准,只把该证据/版本绑定的精确唯一入口作为受控导航。 打开规格面板;T-110 已获项目所有者批准,只把该证据/版本绑定的精确唯一入口作为受控导航。
T-103 的隔离 capability 不包含数量、确认页、提交订单、付款或通用点击;这些动作由后续任务 T-103 的隔离 capability 不包含数量、最终提交面板、提交订单、付款或通用点击;这些动作由后续任务
分别取证后才接入生产单趟流程。T-103 旧 v5 派生 XML 中的“打开即目标预选”已被 2026-08-05 分别取证后才接入生产单趟流程。T-103 旧 v5 派生 XML 中的“打开即目标预选”已被 2026-08-05
五组最新 live 证据纠正:真实初态为颜色与尺码均未选,目标颜色选中后尺码仍未选且不在视口; 五组最新 live 证据纠正:真实初态为颜色与尺码均未选,目标颜色选中后尺码仍未选且不在视口;
S 非目标值与恢复目标 M 的前后态已由人确认。底部“提交订单 ¥12.88”继续属于硬拒绝区。项目已停止 S 非目标值与恢复目标 M 的前后态已由人确认。底部“提交订单 ¥12.88”在 T-103/Gate1/Gate2
继续属于硬拒绝区;只有 T-106/T-107 最终面板 Gate3 可只读其结构化金额。项目已停止
遮罩器开发,T-204 已允许认证设备上传内部原始截图供管理员查看。入口父链证据已由人确认并 遮罩器开发,T-204 已允许认证设备上传内部原始截图供管理员查看。入口父链证据已由人确认并
最新失败证据已纠正入口绑定:此前实现误点商品内容区同名促销小字;真实入口是底部购买区第一行 最新失败证据已纠正入口绑定:此前实现误点商品内容区同名促销小字;真实入口是底部购买区第一行
`快要抢光 + 金额` 的精确文本叶节点中心。第二行“免拼购买”只能作为不可点击的证据绑定兄弟节点 `快要抢光 + 金额` 的精确文本叶节点中心。第二行“免拼购买”只能作为不可点击的证据绑定兄弟节点
@@ -62,11 +66,11 @@
| 路径 | 状态 | 说明 | | 路径 | 状态 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| `docs/` | 已有 | 项目规范化文档,本次已完整生成 | | `docs/` | 已有 | 项目规范化文档,本次已完整生成 |
| `docs/tasks/` | 已有(含 T-001~T-111、T-201~T-211、T-301~T-308、T-400~T-405) | T-103~T-105、T-303、T-211 已完成;T-304 代码完成后保持 DOING 等人工 UI 复核;T-106 已领取并完成只读取证代码,等待四态真机人工验收;双端围栏、真实提交、调和、打包与只读验收均已落成任务 | | `docs/tasks/` | 已有(含 T-001~T-111、T-201~T-211、T-301~T-308、T-400~T-405) | T-103~T-107、T-303、T-211 已完成;T-304 代码完成后保持 DOING 等人工 UI 复核;双端围栏、真实提交、调和、打包与只读验收均已落成任务 |
| `docs/design/` | 已有(6 个原型) | web 登录 / 建单 / 工作台 / 详情,desk 采购执行 / 配置;均已人工确认 | | `docs/design/` | 已有(6 个原型) | web 登录 / 建单 / 工作台 / 详情,desk 采购执行 / 配置;均已人工确认 |
| `scripts/` | 已有 | 上下文门禁、Vikunja 单向导出与 MCP 启动包装 | | `scripts/` | 已有 | 上下文门禁、Vikunja 单向导出与 MCP 启动包装 |
| `admin/` | 已初始化 | Go 1.23+ / gin / SQLite,含建单/授权/详情/证据/设备身份与原子 claim/renew;不执行真机动作 | | `admin/` | 已初始化 | Go 1.23+ / gin / SQLite,含建单/授权/详情/证据/设备身份与原子 claim/renew;不执行真机动作 |
| `client/` | 已初始化 | Python 3.11+、PySide6/uiautomator2、固定双 Tab 主界面、安全轮询、严格 HTTP/DPAPI/SQLite 恢复底座及受控规格选择/读价/数量 Gate2/安全退出;T-106 确认页四态仅开放纯只读取证,确认页生产导航和提交仍未开放 | | `client/` | 已初始化 | Python 3.11+、PySide6/uiautomator2、固定双 Tab 主界面、安全轮询、严格 HTTP/DPAPI/SQLite 恢复底座及受控规格选择/读价/数量 Gate2/安全退出;T-107 Gate3 纯 observer 与一次 Back runner 已完成真机人工验收,最终提交仍未开放 |
| `init.ps1` / `init.sh` | 已完成 | 统一安装与离线验证入口;PowerShell 优先复用合规 venv,缺失时自动选择最高的 Python 3.11+,Unix 缺工具链明确失败 | | `init.ps1` / `init.sh` | 已完成 | 统一安装与离线验证入口;PowerShell 优先复用合规 venv,缺失时自动选择最高的 Python 3.11+,Unix 缺工具链明确失败 |
## 任务状态 ## 任务状态
@@ -85,7 +89,8 @@
- 已完成 T-110、T-111、T-203、T-204、T-301 与 T-302。服务端已能授权、认证设备、原子领取、 - 已完成 T-110、T-111、T-203、T-204、T-301 与 T-302。服务端已能授权、认证设备、原子领取、
稳定重放和续租;claim token 不是提交许可,尚无事件、提交围栏或结果 API。 稳定重放和续租;claim token 不是提交许可,尚无事件、提交围栏或结果 API。
- T-103 已完成受控入口、精确颜色、固定 reveal、exact M、规格面板 `12.88` 读价、一次 Back 和最终 - T-103 已完成受控入口、精确颜色、固定 reveal、exact M、规格面板 `12.88` 读价、一次 Back 和最终
人工真机验收;T-104 已完成同商品安全退出,T-105 已完成数量/Gate2,T-106~T-107 继续按确认页风险逐段开放。真机运行要求 人工真机验收;T-104 已完成同商品安全退出,T-105 已完成数量/Gate2,T-106 已完成合并式最终面板
与一次 Back 返回两态只读取证,T-107 已据此固化并真机验收 Gate3 判据。真机运行要求
同一物理手机只保留一个 ADB 通道;CLI 已增加不回显页面 同一物理手机只保留一个 ADB 通道;CLI 已增加不回显页面
正文的固定白名单失败阶段码;`sku_entry` 动态页面误判已按最新真机证据修复,覆盖点击中心的浮窗 正文的固定白名单失败阶段码;`sku_entry` 动态页面误判已按最新真机证据修复,覆盖点击中心的浮窗
会保持零点击,入口失败也已细分为前置、发现、点击和面板后置验证四个固定子阶段。服务端后续顺序已收紧为 会保持零点击,入口失败也已细分为前置、发现、点击和面板后置验证四个固定子阶段。服务端后续顺序已收紧为
@@ -95,7 +100,7 @@
面板总额并直接比较最高总价,后续 Gate3 必须与其严格相等。阶段二的一次加号、精确读回、Gate2 面板总额并直接比较最高总价,后续 Gate3 必须与其严格相等。阶段二的一次加号、精确读回、Gate2
原始截图和 T-104 一次安全退出已实现并通过 330 项离线测试及完整 `init.ps1`。最终生产运行精确 原始截图和 T-104 一次安全退出已实现并通过 330 项离线测试及完整 `init.ps1`。最终生产运行精确
完成一次加号与一次 Back,项目所有者确认数量 2、目标规格、`12.88/32.76`、同商品安全退出,且未进入 完成一次加号与一次 Back,项目所有者确认数量 2、目标规格、`12.88/32.76`、同商品安全退出,且未进入
确认/提交/支付页、未创建订单。底部“提交订单”金额继续排除且禁止点击;T-106 依赖已解除。 提交/付款页、未创建订单。最终控件金额仍禁止用于 Gate1/Gate2;T-106 依赖已解除。
- T-303 已完成并通过三重审查;T-304 的轮询会话、配置页和固定双 Tab 主界面已通过两轮独立代码审计, - T-303 已完成并通过三重审查;T-304 的轮询会话、配置页和固定双 Tab 主界面已通过两轮独立代码审计,
当前 255 项客户端测试与根门禁通过,继续保持 `DOING` 等原生 Windows DPI、主题、高对比度、 当前 255 项客户端测试与根门禁通过,继续保持 `DOING` 等原生 Windows DPI、主题、高对比度、
Narrator 和纯键盘人工验收。客户端后续按依赖推进 T-306/T-307/T-308,再与 T-305 汇合到 Narrator 和纯键盘人工验收。客户端后续按依赖推进 T-306/T-307/T-308,再与 T-305 汇合到
@@ -225,27 +230,45 @@ T-105 阶段二生产验收已在人工确认的同一手机、拼多多 `8.17.0
项目所有者已确认数量精确为 2、颜色尺码未变、Gate1/Gate2 分别为 `12.88/32.76`、Gate2 截图正确、 项目所有者已确认数量精确为 2、颜色尺码未变、Gate1/Gate2 分别为 `12.88/32.76`、Gate2 截图正确、
一次 Back 回到同一商品详情,且未进入确认/提交/支付页、未创建订单。该命令只作已验运行记录,不再重跑。 一次 Back 回到同一商品详情,且未进入确认/提交/支付页、未创建订单。该命令只作已验运行记录,不再重跑。
T-106 已提供四态纯只读取证命令。每次运行都会重新核对设备、拼多多 `8.17.0`、前台包和 T-106 第一态只读取证已于 2026-08-06 完成。脚本重新核对设备、拼多多 `8.17.0`、前台包和
1080×2376 屏幕,只调用截图与 `compressed=false` XML;不会打开页面、点击、返回、滑动、输入或识别 1080×2376 屏幕,只调用截图与 `compressed=false` XML;不会打开页面、点击、返回、滑动或输入:
确认页业务字段。四个输出目录必须均不存在,完整 XML 只留本机:
```powershell ```powershell
# 1. 人工准备:Gate2 已通过、尚未进入确认页;运行后再由人记录实际进入确认页的精确控件文本/容器/匹配数
.\client\.venv\Scripts\python.exe client\scripts\capture_order_confirm_spike.py --serial 192.168.0.173:5555 --goods-id 937122477375 --state gate2-navigation-source --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-106\gate2-navigation-source-937122477375" --timeout 10 --adb D:\Portable\adb\adb.exe .\client\.venv\Scripts\python.exe client\scripts\capture_order_confirm_spike.py --serial 192.168.0.173:5555 --goods-id 937122477375 --state gate2-navigation-source --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-106\gate2-navigation-source-937122477375" --timeout 10 --adb D:\Portable\adb\adb.exe
```
# 2. 人工只点击已核对的确认页入口并停在 Gate3 页面;绝不点击最终“提交订单” 证据与人工确认表明:该规格面板已经显示地址、支付方式、目标规格、数量 2、顶部 `32.76`,以及唯一
.\client\.venv\Scripts\python.exe client\scripts\capture_order_confirm_spike.py --serial 192.168.0.173:5555 --goods-id 937122477375 --state confirm-gate3 --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-106\confirm-gate3-937122477375" --timeout 10 --adb D:\Portable\adb\adb.exe “提交订单 ¥32.76”;点击该红色控件会立刻创建待付款订单并进入付款界面,并不存在原计划中可安全
先进入的独立确认页。项目所有者确认本次未点击、未创建订单、未进入付款,并批准“同一最终面板、
两个独立金额位置”方案:顶部金额继续承担 Gate2,最终控件结构化金额承担 Gate3,二者必须分别唯一、
严格相等且不超上限;Gate3 不得回流 Gate1/Gate2。一次人工 Back 后的返回态只读命令也已完成:
# 3. 人工把最终“提交订单”控件准备为可见但不点击;记录精确文案、匹配数和启用态 ```powershell
.\client\.venv\Scripts\python.exe client\scripts\capture_order_confirm_spike.py --serial 192.168.0.173:5555 --goods-id 937122477375 --state submit-control-visible --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-106\submit-control-visible-937122477375" --timeout 10 --adb D:\Portable\adb\adb.exe
# 4. 人工只返回一次并停手;结果不明时不再返回,直接停止
.\client\.venv\Scripts\python.exe client\scripts\capture_order_confirm_spike.py --serial 192.168.0.173:5555 --goods-id 937122477375 --state returned-safe-page --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-106\returned-safe-page-937122477375" --timeout 10 --adb D:\Portable\adb\adb.exe .\client\.venv\Scripts\python.exe client\scripts\capture_order_confirm_spike.py --serial 192.168.0.173:5555 --goods-id 937122477375 --state returned-safe-page --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-106\returned-safe-page-937122477375" --timeout 10 --adb D:\Portable\adb\adb.exe
``` ```
四态都必须由项目所有者本地核对截图/XML:规格、数量、Gate3 应付总额、最终控件精确文案/匹配数/ 返回态 screenshot/XML/App/manifest 的 SHA-256 均与 manifest 一致;截图与 XML 显示同一商品详情
启用态、一次返回后的页面身份;同时确认未点击最终提交、未创建订单、未进入支付或安全验证页面。 安全页,恢复“快要抢光 ¥12.88 / 免拼购买”,不再出现“提交订单”。项目所有者确认只按一次系统
满足全部人工验收前 T-106 保持 `DOING`,这些人工观察也不能在 T-106 中直接变成生产 selector。 Back、未点击提交订单、未创建待付款订单、未进入付款/安全验证/外部页面。T-106 已完成;随后 T-107
固化零页面点击的 Gate3 observer 和一次安全 Back。红色控件仍不得点击。
T-107 代码已从上述两态证据提取最小 fixture:纯 XML observer 分别复核顶部 Gate2、规格摘要、数量 2、
最终完整文本 Gate3 及最近可点击祖先;DTO 不暴露节点/坐标/selector。runner 在观察成功前零页面动作,
成功后只发送一次 Android Back,并要求返回页连续两帧命中完整商品标题和底部入口正锚;结果不明不重试。
2026-08-06 首次真机 dry-run 先因 Wi-Fi ADB 断开停止;恢复连接后发现目标五项均正确,但面板受当前内容
高度影响整体上移 77px。新增截图/XML 在
`%LOCALAPPDATA%\cmbuyer\artifacts\T-107\gate3-layout-shift-937122477375-20260806\raw`,项目所有者已人工确认
颜色、尺码、数量和两处金额。实现仅把该状态加入第二套互斥精确配置,不放宽坐标范围,也不允许跨配置混搭。
真机人工准备同一最终面板后,从仓库根运行:
```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
```
该命令会重新核对当前拼多多版本、前台应用、设备和屏幕,不会点击红色“提交订单”,也不会申请围栏、
创建待付款订单或进入付款。2026-08-06 真机结果发布于同名输出目录:Gate2/Gate3 均为 `32.76`、
完整文本 match count=1、最近可点击祖先唯一、页面控件动作 0、一次 Back 安全返回。项目所有者最终确认
规格面板已关闭,未创建待付款订单,未进入付款或安全验证页面;T-107 已完成。
## 关键背景 ## 关键背景
@@ -253,7 +276,7 @@ T-106 已提供四态纯只读取证命令。每次运行都会重新核对设
(Python + uiautomator2)两个前序项目的合并重启。 (Python + uiautomator2)两个前序项目的合并重启。
- 取 `cmroubao` 的后端任务生命周期、设备侧 API 形状、下单授权状态机、ERP 对接、管理 Web。 - 取 `cmroubao` 的后端任务生命周期、设备侧 API 形状、下单授权状态机、ERP 对接、管理 Web。
- 取 `cmpdd` 的 uiautomator2 真机自动化、按维度精确选规格、订单确认页读取、付款闸门。 - 取 `cmpdd` 的 uiautomator2 真机自动化与按维度精确选规格思路;最终面板与付款闸门在本项目重新取证。
- 丢弃自研 Android APK 与 AccessibilityService 感知层。 - 丢弃自研 Android APK 与 AccessibilityService 感知层。
**前序项目是设计依据,不是事实来源。** 其中的结论(尤其是拼多多页面判据)必须在本项目 **前序项目是设计依据,不是事实来源。** 其中的结论(尤其是拼多多页面判据)必须在本项目
@@ -266,8 +289,9 @@ T-106 已提供四态纯只读取证命令。每次运行都会重新核对设
2. **拼多多页面结构随版本变化**,已观察到详情页无独立规格入口、价格节点被拆分等情况。 2. **拼多多页面结构随版本变化**,已观察到详情页无独立规格入口、价格节点被拆分等情况。
3. **授权卡死**:前序项目出现过 `EXECUTING` 授权永不推进导致任务锁死。本项目在 T-207 3. **授权卡死**:前序项目出现过 `EXECUTING` 授权永不推进导致任务锁死。本项目在 T-207
实现围栏前超时 / 放弃,在 T-208 实现围栏后调和;围栏后不得释放或重试。 实现围栏前超时 / 放弃,在 T-208 实现围栏后调和;围栏后不得释放或重试。
4. **确认页能力仍未开放**:T-105 已按本项目真机证据开放数量 1→2、readback 与 Gate2;确认页导航、 4. **独立确认页前提已被推翻并重定值**:T-106 第一态证明“提交订单 ¥32.76”会直接创建待付款订单。
Gate3 和最终控件仍必须依次等待 T-106/T-107 取证,不能从旧项目或推理直接实现。 已批准 Gate2 顶部总额与 Gate3 最终控件金额的同面板独立交叉校验;一次人工 Back 返回证据已完成,
控件仍禁止点击。历史 `confirm_amount/confirm_page_amount` 必须在 T-210/T-208 前迁移。
5. **MVP 已收窄**:只做手工填链接、批量开始采购和单趟创建待付款订单。Excel、ERP、图搜、 5. **MVP 已收窄**:只做手工填链接、批量开始采购和单趟创建待付款订单。Excel、ERP、图搜、
批量顺序编排 / 暂停接管、订单自动核对、AI 辅助全部推到 V2(见 `06-tasks.md` 的 批量顺序编排 / 暂停接管、订单自动核对、AI 辅助全部推到 V2(见 `06-tasks.md` 的
T-501~T-508)。 T-501~T-508)。
+3 -3
View File
@@ -75,8 +75,8 @@
1. 任务要求:商品链接 / goods_id、颜色、尺码、数量、最高总价、版本。 1. 任务要求:商品链接 / goods_id、颜色、尺码、数量、最高总价、版本。
2. 开始采购授权:授权 id、授权人、锁定任务版本、创建/有效期、当前状态。 2. 开始采购授权:授权 id、授权人、锁定任务版本、创建/有效期、当前状态。
3. 设备执行:attempt、设备、App 版本、步骤时间线和失败 code。 3. 设备执行:attempt、设备、App 版本、步骤时间线和失败 code。
4. 三道闸门:闸门一规格面板单价、规格/数量读回、闸门二面板总额、确认页同额校验与判定。 4. 三道闸门:闸门一规格面板单价、规格/数量读回、闸门二顶部总额、闸门三最终控件金额同额校验。
5. 内部截图:三道闸门已批准的规格面板/确认页证据;只经受保护端点读取。MVP 不存在提交后结果 5. 内部截图:三道闸门已批准的规格面板/合并式最终提交面板证据;只经受保护端点读取。MVP 不存在提交后结果
截图类型,不展示外部支付页,也不把 Gate3 图片冒充结果图。 截图类型,不展示外部支付页,也不把 Gate3 图片冒充结果图。
6. 提交围栏:submission id、是否首次明确许可、唯一点击和调和记录。 6. 提交围栏:submission id、是否首次明确许可、唯一点击和调和记录。
7. 待付款收口:明确写系统尚未付款;人工核对后记录完成。 7. 待付款收口:明确写系统尚未付款;人工核对后记录完成。
@@ -118,7 +118,7 @@ T-204 只显示数据库中当前实际存在的任务、授权、attempt、subm
- 双击或 Enter 记录:左侧原位切换到记录详情,不弹窗;上方文字/图片,下方执行结果。 - 双击或 Enter 记录:左侧原位切换到记录详情,不弹窗;上方文字/图片,下方执行结果。
- Esc 或“返回当前任务”:恢复当前任务视图;不暂停执行、不释放围栏。 - Esc 或“返回当前任务”:恢复当前任务视图;不暂停执行、不释放围栏。
单趟状态:待领取、已领取、打开商品、选择规格、闸门一、数量复核、闸门二、确认页/闸门三、 单趟状态:待领取、已领取、打开商品、选择规格、闸门一、数量复核、闸门二、最终提交面板/闸门三、
申请围栏、已发出唯一提交、待付款或待调和。桌面端没有让用户手工点击“提交订单”的按钮。 申请围栏、已发出唯一提交、待付款或待调和。桌面端没有让用户手工点击“提交订单”的按钮。
### 配置 tab ### 配置 tab
+74 -26
View File
@@ -1,9 +1,9 @@
--- ---
id: T-106 id: T-106
title: 确认页、闸门三与返回路径真机取证 title: 合并式最终提交面板、闸门三与返回路径真机取证
phase: 1 phase: 1
deps: [T-105] deps: [T-105]
status: DOING status: DONE
created: 2026-08-04 created: 2026-08-04
vikunja_task_id: 41 vikunja_task_id: 41
context_ref: db2b781 context_ref: db2b781
@@ -11,42 +11,62 @@ work_branch: task/t-106-confirm-evidence
needs_device: true needs_device: true
needs_human_review: true needs_human_review: true
write_paths: write_paths:
- README.md
- docs/00-ai-start-here.md
- docs/02-requirements.md
- docs/03-tech-stack.md
- docs/04-architecture.md
- docs/05-coding-rules.md
- docs/06-tasks.md
- docs/07-user-stories.md
- docs/08-interaction-checklist.md
- docs/README.md
- docs/api.md
- docs/routes.md
- docs/tasks/T-106.md - docs/tasks/T-106.md
- docs/tasks/T-107.md
- docs/tasks/T-208.md
- docs/tasks/T-210.md
- docs/tasks/T-305.md
- docs/tasks/T-400.md
- docs/tasks/T-401.md
- docs/tasks/T-403.md
- client/src/cmbuyer_client/pdd/order_confirm_spike.py - client/src/cmbuyer_client/pdd/order_confirm_spike.py
- client/tests/pdd/test_order_confirm_spike.py - client/tests/pdd/test_order_confirm_spike.py
- client/scripts/capture_order_confirm_spike.py - client/scripts/capture_order_confirm_spike.py
- docs/current-state.md - docs/current-state.md
--- ---
<!-- BEGIN VIKUNJA EXPORT id=41 synced=2026-08-06T02:20:32Z sha256=3e585159a2607c63f54d72b84d9779699cce3cc79805ebc7c2bbd08cb80b815f --> <!-- BEGIN VIKUNJA EXPORT id=41 synced=2026-08-06T03:22:51Z sha256=316f8f716ed7b247a22092ec211d38e60cefee9235d6ba456286ccde1ede53e8 -->
## 问题 / 背景 ## 问题 / 背景
T-105 验证规格面板数量与闸门二后,下一步会进入更高风险的订单确认页。T-106 只由人手工准备确认页、最终提交控件可见态和一次返回后的页面,再用完全只读脚本采集真机事实;本任务不自动导航、不写确认页判据。 2026-08-06 真机证据证明:拼多多 8.17.0 的当前采购界面不存在“规格面板 → 独立订单确认页”导航。目标规格与数量设置后,同一合并式最终提交面板已经显示地址、支付方式、顶部 Gate2 总额和红色“提交订单 ¥金额”;点击该红色控件会立即创建待付款订单并进入付款界面。原四态方案已安全停止。
## 关联需求与交互 ## 关联需求与交互
- 功能:F-011、F-016。 - 功能:F-011、F-016。
- 用户故事:US-004、US-005。 - 用户故事:US-004、US-005。
- 依赖:T-105。 - 依赖:T-105。
- 后续消费者:T-107。 - 后续消费者:T-107、T-210、T-305。
- 需要真机和人工页面对应性、安全结果确认。 - 需要真机和人工页面对应性、安全结果确认。
## 方案 ## 方案
1. 提供只读取证 CLI,唯一能力为核对指定设备/App 版本、app_current、screenshot 与 compressed=false XML、hash 和原子 manifest;静态禁止 click、press/back、swipe、input、intent 和任意 UI 修改。 1. 保留第一态原始截图/XML/App/manifest 与哈希:目标颜色/M、数量 2、面板顶部 Gate2 总额 32.76、唯一可见文本“提交订单 ¥32.76”;项目所有者确认该控件会直接创建待付款订单。
2. 人先手工完成已取证规格/数量并进入确认页,停在规格、数量、应付总额及最终“提交订单”控件可见的安全状态;绝不能点击最终提交控件。 2. 把页面模型改为“合并式最终提交面板”:Gate2 仍只读面板顶部目标数量总额;Gate3 是同一稳定面板中最终红色控件显示的金额。两者是独立 UI 位置,必须分别唯一读取、严格相等且都不超上限。
3. 只读采集确认页主体与最终控件状态,记录准确规格、数量、应付总额、控件精确文案/匹配数/启用态;这些只是人工观察,不转成生产 selector。 3. Gate3 仅批准结构化精确文本“提交订单 ¥{规范十进制金额}”;不得包含/前缀猜测、OCR 或坐标兜底。它只能作为 Gate3 只读金额与最终控件身份,不得回流充当 Gate1/Gate2。
4. 随后由人只按一次返回并停手,再用同一只读取证 CLI 采集返回后的页面。Back 结果不明不再次按;记录页面身份与是否回到证据允许的安全页面。 4. 最终控件观察记录完整文本、匹配数、启用态、最近可点击祖先是否唯一;不返回 selector、坐标、节点或可点击对象。当前第一态观察到文本匹配数为 1,祖先结构留给 T-107 从最小 fixture 固化。
5. 三组证据都记录设备型号、Android、USB/WiFi、serial、PDD 版本、goods_id、截图/XML 路径和 SHA-256;完整 XML 仅留本机。 5. 取消确认页导航动作。T-106/T-107 都不得点击最终红色控件;生产真实点击仍只允许在授权、服务端围栏、Gate2、Gate3、唯一控件四条件同时满足后执行一次。
6. 如果人误触最终提交、产生待付款订单、进入支付/安全验证/外部页面或页面结果不明,立即停止、不重试、不继续取证,并如实记录路径为不可用;绝不操作付款。 6. 人从合并式最终提交面板只按一次系统返回并停手,再运行纯只读 CLI 采集返回后状态;结果不明不得再次返回。
7. 当前规格面板中出现“提交订单 ¥...”不能仅凭文案推断为确认页导航或最终控件;T-107 只能消费本任务由人确认的结构事实。 7. 同步架构、API、编码规则、路线图和下游任务。旧 confirm_page_amount/confirm_amount 语义废止;T-210/T-208 消费前必须迁移为 gate3_submit_amount,迁移完成前不得建立提交围栏。
8. 若误触最终提交、产生待付款订单、进入付款/安全验证/外部页面或结果不明,立即停止、不重试、不继续取证;绝不付款。
## 验收要点 ## 验收要点
- 静态证明 spike/CLI 没有任何 UI 修改 API;mock 覆盖设备/版本/包/截图/XML/超时/原子发布/异常脱敏。 - 第一态证据哈希完整,人工确认合并式面板、目标规格、数量 2、Gate2 顶部 32.76、最终文本“提交订单 ¥32.76”匹配数 1,以及点击语义为直接创建待付款订单。
- 人工确认三组截图/XML对应:确认页规格数量金额、最终控件可见态、一次返回后页面。 - 一次人工返回后的截图/XML/App/manifest 完整,人工确认回到同一商品安全页;返回结果不明不重试。
- 执行记录列出准确控件文案与匹配数,但不把坐标/selector 作为结论;完整 XML、地址、手机号、支付信息不进 Git/Vikunja/日志。 - 静态证明 spike/CLI 只有 app/截图/XML 读取能力,没有任何页面修改 API;只允许合并式面板历史态与人工返回态。
- 明确记录未点击最终提交、未创建订单、未进入支付;若相反则任务保持 DOING/受阻且不自动处置。 - 架构保留三道逻辑闸门:Gate3 使用同一最终面板的独立最终控件金额,不删除 Gate3、不把其金额用于 Gate1/Gate2、不放宽提交四条件。
- client 全量 unittest、compileall、完整 init、上下文校验、Vikunja 导出与 diff-check 通过;agent 不得自行标 DONE。 - client 全量 unittest、compileall、完整 init、上下文校验、Vikunja 导出与 diff-check 通过;agent 不得自行标 DONE。
## 执行记录 ## 执行记录
@@ -68,25 +88,53 @@ T-105 验证规格面板数量与闸门二后,下一步会进入更高风险
2026-08-06T02:20:23.748Z · Codex 2026-08-06T02:20:23.748Z · Codex
T-106 四态纯只读取证代码已完成:新增 order_confirm_spike、capture_order_confirm_spike.py 与 16 项专项测试。静态 AST 证明生产模块/CLI 只允许 takeScreenshot、dumpWindowHierarchy,且不可达页面修改、确认页导航、提交、围栏或付款能力。client 全量 346 tests、compileall、validate_agent_context 与完整 init.ps1 均通过。任务保持 DOING,等待项目所有者依次人工准备并确认 Gate2 导航前、Gate3、最终控件可见、一次返回后四态;绝不点击最终提交。 T-106 四态纯只读取证代码已完成:新增 order_confirm_spike、capture_order_confirm_spike.py 与 16 项专项测试。静态 AST 证明生产模块/CLI 只允许 takeScreenshot、dumpWindowHierarchy,且不可达页面修改、确认页导航、提交、围栏或付款能力。client 全量 346 tests、compileall、validate_agent_context 与完整 init.ps1 均通过。任务保持 DOING,等待项目所有者依次人工准备并确认 Gate2 导航前、Gate3、最终控件可见、一次返回后四态;绝不点击最终提交。
### 2026-08-06T02:41:41Z · ila
2026-08-06T02:41:40.982Z · Codex
T-106 第一态真机事实(拼多多 8.17.0、PKG110/Android 16、goods_id 937122477375、WiFi):截图/XML/应用摘要与 manifest 完整,三项 SHA-256 均核对一致;页面显示目标颜色/M、数量 2、当前金额 32.76,并有唯一可见文本“提交订单 ¥32.76”。项目所有者确认该页仍是规格面板,点击红色“提交订单 ¥32.76”会立刻创建待付款订单并进入付款界面。因此原“四态”方案中独立确认页导航前提不成立;未点击该控件、未创建订单、未进入付款。立即禁止第二至第四态及任何该控件点击,T-106 保持 DOING,先重新定值 Gate3 架构,不能把底部按钮金额直接当 Gate3 或放宽提交四条件。截图 SHA-256=05531e020ac93048432d51554137fecf0879868ebad126771a041492cadca790;XML SHA-256=5ab31b45772e1b4e93976da5f029eeec1f5152207c2b8a75ee05ae5ce5673a7d;app 摘要 SHA-256=dcfd282da23bc5dee08e25446ed395c300c5ec9eb4d27d3c0b231a306641db50。
### 2026-08-06T02:53:01Z · ila
2026-08-06T02:53:00.755Z · Codex
项目所有者明确同意“同一最终面板、两个独立金额位置”的 Gate3 方案。T-106/T-107 已重规划:取消确认页导航;Gate2 只读顶部总额,Gate3 只读最终控件结构化金额,两者必须分别唯一、严格相等且不超上限。T-106/T-107 不点击红色控件;只补一次人工 Back 返回证据。旧 confirm_page_amount/confirm_amount 语义将在 T-210/T-208 消费前迁移为 gate3_submit_amount,迁移前禁止提交围栏。
### 2026-08-06T03:01:42Z · ila
2026-08-06T03:01:42.237Z · Codex
合并式最终提交面板 Gate3 契约已同步到架构、需求、API、编码规则、路线图、用户故事/交互/路由及下游 T-107/T-210/T-208/T-305/T-400/T-401/T-403。T-106 只读 CLI 现仅接受既有最终面板态与人工一次 Back 返回态;确认页导航与红色控件点击均不可达。专项 16 tests、client 全量 346 tests、compileall、validate_agent_context 与完整 init.ps1 均通过。尚待项目所有者只按一次 Back、运行 returned-safe-page 取证并确认安全返回;T-106 保持 DOING。
### 2026-08-06T03:18:45Z · ila
2026-08-06T03:07:33Z 返回态真机证据已完成并复核(拼多多 8.17.0、PKG110/Android 16、WiFi、goods_id 937122477375):screenshot/XML/app/manifest 完整,三项 SHA-256 与 manifest 一致。截图与 XML 显示一次 Back 后回到同一商品详情安全页,恢复“快要抢光 ¥12.88 / 免拼购买”,不存在“提交订单”“立即支付”“确认支付”等控件;前台仍为 com.xunmeng.pinduoduo/.activity.NewPageActivity。screenshot SHA-256=370a447f1e4f9ab848fea81b735081d62524a3db8ae100dfd94f96c6abeff38f;XML SHA-256=ab8f4a060d7180b1f231131095fdf4c07dacac68e47eaec9b5804090557eec6c;app SHA-256=dcfd282da23bc5dee08e25446ed395c300c5ec9eb4d27d3c0b231a306641db50。项目所有者“四项确认”:只按一次系统返回;未点击提交订单;未创建待付款订单;未进入付款、安全验证或外部页面。T-106 两态真机与人工验收全集满足。
### 2026-08-06T03:22:42Z · ila
T-106 收尾同步:本地 frontmatter 已改为 DONE,current-state 已切换到 T-107,旧四态边界残留已收紧为已批准的两态证据模型。Vikunja 完成更新时发现 MCP 部分更新会清空 description;已由 Git 权威副本恢复线上完整问题/方案/验收要点,并通过重新导出读回确认。按 docs-only 验证矩阵,validate_agent_context、Vikunja export check、git diff --check 通过;业务代码未变化,不重复运行两端测试,沿用本任务代码落地时已通过的 client 346 tests、compileall 与完整 init.ps1。
<!-- END VIKUNJA EXPORT --> <!-- END VIKUNJA EXPORT -->
## 边界 ## 边界
- 2026-08-06 第一态真机证据与项目所有者确认推翻了“规格面板后还有独立确认页”的前提:当前规格
面板已经显示地址、支付方式和唯一“提交订单 ¥32.76”,点击会立刻创建待付款订单并进入付款界面。
项目所有者随后批准“同一最终面板、两个独立金额位置”方案:Gate2 仍是顶部总额,Gate3 只读最终
控件金额,二者必须唯一、严格相等且不超上限。原四态方案已被真机事实终止;现在只允许既有
合并式最终面板态和一次人工 Back 后的返回态,红色控件仍绝对禁止点击,不放宽提交四条件。
- 本任务只实现和运行只读取证能力:设备/App 摘要、`app_current`、screenshot、`compressed=false` XML、 - 本任务只实现和运行只读取证能力:设备/App 摘要、`app_current`、screenshot、`compressed=false` XML、
hash 与原子 manifest。代码中不得出现 click、press/back、swipe、input、intent 或其他 UI 修改 API, hash 与原子 manifest。代码中不得出现 click、press/back、swipe、input、intent 或其他 UI 修改 API,
不得自动进入或退出确认页,也不得从前序项目、旧 XML 或推理写生产判据。 不得自动进入或退出确认页,也不得从前序项目、旧 XML 或推理写生产判据。
- 确认页、最终提交控件可见态和一次返回后的页面都由人手工准备;人不得点击最终“提交订单”控件。 - 合并式最终面板与一次返回后的页面都由人手工准备;人不得点击最终“提交订单”控件。一次返回
一次返回结果不明时不得再次按 Back。agent 只采集已准备状态,不远程替人执行这些动作。 结果不明时不得再次按 Back。agent 只采集已准备状态,不远程替人执行这些动作。
- 人工验收必须明确规格、数量、应付总额、最终控件精确文案/匹配数/启用态和返回后的页面身份; - 人工验收必须明确合并式最终面板的规格、数量、Gate2/Gate3 金额、最终控件精确文案/匹配数/
本任务只记录真机事实,不把文案、坐标、节点或 selector 批准为生产能力。 启用态,以及返回后的页面身份;本任务只记录真机事实,不把坐标、节点或 selector 批准为生产能力。
- 取证必须包含四个稳定状态:闸门二通过后的**确认页导航前**页面、确认页闸门三、最终提交控件 - 取证只包含两个稳定状态:既有合并式最终面板历史态,以及人工只按一次系统 Back 后的商品详情
可见态、一次返回后页面。人还需记录从第一态进入第二态时实际只点击的精确控件文本/容器及当时 安全页。不得再采集或声称存在独立确认页导航态、确认页态或额外最终控件态。
匹配数量;agent 不执行该点击。缺少来源态时,T-107 不得仅凭“已到确认页”反推导航控件。 - 合并式最终面板中的“提交订单 ¥...”已经由项目所有者确认是直接创建待付款订单的最终控件;
- 规格面板中出现的“提交订单 ¥...”不得凭文案推断为确认页导航或最终提交控件。任何确认页入口、 T-107 只能从本任务已确认的证据中最小化固化 Gate3 只读判据和返回后置条件,不得实现点击。
金额节点、最终控件和返回后置条件都必须等 T-107 从本任务已确认的证据中最小化固化。
- 若人工误触最终提交、产生待付款订单、进入支付/安全验证/外部页面或结果不明,立即停止、不重试、 - 若人工误触最终提交、产生待付款订单、进入支付/安全验证/外部页面或结果不明,立即停止、不重试、
不继续取证并如实记录;绝不点击支付、免密支付、先用后付或任何扣款控件。 不继续取证并如实记录;绝不点击支付、免密支付、先用后付或任何扣款控件。
- 原始 screenshot/XML 只留 `%LOCALAPPDATA%\cmbuyer\artifacts\T-106\...`,不提交 Git、不上传采购服务, - 原始 screenshot/XML 只留 `%LOCALAPPDATA%\cmbuyer\artifacts\T-106\...`,不提交 Git、不上传采购服务,
不把完整 XML、地址、手机号、支付信息、页面全文或本机路径写入普通日志/Vikunja/fixture。 不把完整 XML、地址、手机号、支付信息、页面全文或本机路径写入普通日志/Vikunja/fixture。
- `needs_device: true`:agent 不得自行标 DONE。只有人确认四组证据对应、一次返回安全、未点击最终提交、 - `needs_device: true`:agent 不得自行标 DONE。只有人确认两组证据对应、一次返回安全、未点击最终提交、
未创建订单且未进入支付相关页面,任务才可完成。 未创建订单且未进入支付相关页面,任务才可完成。
+58 -39
View File
@@ -1,33 +1,32 @@
--- ---
id: T-107 id: T-107
title: 固化确认页、闸门三与提交控件只读判据 title: 固化合并式最终提交面板闸门三与提交控件只读判据
phase: 1 phase: 1
deps: [T-106] deps: [T-106]
status: TODO status: DONE
created: 2026-08-04 created: 2026-08-04
vikunja_task_id: 42 vikunja_task_id: 42
context_ref: 83b244f context_ref: dbf9b8c
work_branch: task/t-107-confirm-gate3 work_branch: task/t-107-confirm-gate3
needs_device: true needs_device: true
needs_human_review: true needs_human_review: true
write_paths: write_paths:
- docs/tasks/T-107.md - docs/tasks/T-107.md
- client/src/cmbuyer_client/pdd/order_confirm.py - client/src/cmbuyer_client/pdd/final_submit_panel.py
- client/src/cmbuyer_client/pdd/order_confirm_runner.py - client/src/cmbuyer_client/pdd/final_submit_panel_runner.py
- client/tests/pdd/test_order_confirm.py - client/tests/pdd/test_final_submit_panel.py
- client/tests/pdd/fixtures/order_confirm_source_8_17_0.xml - client/tests/pdd/fixtures/final_submit_panel_8_17_0.xml
- client/tests/pdd/fixtures/order_confirm_gate3_8_17_0.xml - client/tests/pdd/fixtures/final_submit_panel_shifted_8_17_0.xml
- client/tests/pdd/fixtures/order_submit_control_8_17_0.xml - client/tests/pdd/fixtures/final_submit_panel_exit_8_17_0.xml
- client/tests/pdd/fixtures/order_confirm_exit_8_17_0.xml - client/scripts/run_t107_final_submit_panel_dry_run.py
- client/scripts/run_t107_order_confirm_dry_run.py
- docs/api.md - docs/api.md
- docs/current-state.md - docs/current-state.md
--- ---
<!-- BEGIN VIKUNJA EXPORT id=42 synced=2026-08-04T14:05:17Z sha256=56bb3d2575e60ac2ede9ba9c86178f5eefc4470d06b973d9f701fbc8c91635da --> <!-- BEGIN VIKUNJA EXPORT id=42 synced=2026-08-06T07:36:32Z sha256=0dc3e6197aaad376f4b2fa344a78d255caabeb82eda17e56f8b9643fad164ff4 -->
## 问题 / 背景 ## 问题 / 背景
T-106 将形成确认页导航前、闸门三、最终提交控件可见态和一次返回后的四组真机证据。T-107 只从这些已由人确认的本项目事实固化一次性确认页导航、闸门三读取、最终控件纯观察与一次安全返回;绝不点击最终提交。 T-106 真机证据证明当前拼多多 8.17.0 使用合并式最终提交面板:没有独立确认页导航;面板顶部是 Gate2 总额,红色“提交订单 ¥金额”会直接创建待付款订单。T-107 只固化该稳定面板的 Gate3 只读金额、最终控件观察与一次安全返回;绝不点击最终提交。
## 关联需求与交互 ## 关联需求与交互
@@ -39,22 +38,22 @@ T-106 将形成确认页导航前、闸门三、最终提交控件可见态和
## 方案 ## 方案
1. 从 T-106 四态证据提取只含必要节点且去隐私的最小 fixture;页面版本、前台包、来源页面、导航控件或目标页面任一漂移都零点击。 1. 从 T-106 合并式面板和人工一次返回证据提取不含个人信息的最小 fixture;页面版本、前台包、规格、数量、两个金额角色或结构任一漂移都零提交动作。
2. go_to_order_confirm 只允许点击 T-106 人工证明的导航前状态中精确唯一的已批准控件一次;点击后等待确认页精确后置条件。超时或结果不明不重试,不使用相似文案、OCR、裸坐标或其他购买按钮兜底。 2. 删除 go_to_order_confirm 导航能力。观察入口只接受已通过 Gate2 且仍停留在证据绑定合并式面板的实时状态;T-107 成功路径不点击任何页面控件。
3. observe_gate3 只从确认页读取颜色、尺码、数量和应付总额;必须等于目标规格/数量且金额为规范十进制、不超最高总价。不得从详情页、规格面板或其他数字补值。 3. observe_gate3 分别复核目标颜色、尺码、数量、Gate2 顶部总额,并从唯一结构化文本“提交订单 ¥{金额}”读取 gate3_submit_amount;两金额必须规范、严格相等且不超最高总价。
4. 最终“提交订单”控件由纯 XML observer 只读:记录精确文本、匹配数、启用态和是否存在唯一可点击祖先,但不返回 selector、坐标、节点、祖先句柄或任何可点击对象;observer 不持有 device。 4. 最终控件纯 XML observer 记录完整文本、匹配数、启用态以及最近可点击祖先是否唯一;不返回 selector、坐标、节点、祖先句柄或可点击对象,observer 不持有 device。
5. Gate3Observation 包含规格、数量、确认页金额、最高总价、submit_control_text/match_count/enabled、确认页原始 screenshot_path 和 UTC captured_at。 5. Gate3Observation 包含规格、数量、gate2_panel_total_price、gate3_submit_amount、最高总价、submit_control_text/match_count/enabled/nearest_clickable_ancestor_unique、原始 screenshot_path 和 UTC captured_at。
6. 观察完成后 exit_order_confirm_safely 只发送一次 Android Back,并稳定验证 T-106 post-back 正判据;超时/结果不明不重试,SystemUI/锁屏/其他 PDD 页/外部页都不是成功。 6. 观察完成后 exit_final_submit_panel_safely 只发送一次 Android Back,并稳定验证 T-106 返回后正判据;超时或结果不明不重试。
7. runner 是永久围栏前 dry-run capability,不接受 allow_submit/dry_run=False 开关;composition/import 静态不可达 fence、SubmissionPermit、submit_order_once、payment。 7. runner 是永久围栏前 dry-run capability,不接受 allow_submit/dry_run=False;composition/import 静态不可达 fence、SubmissionPermit、submit_order_once、payment。
8. T-107 不上传证据、不定义 T-205 事件码、不申请围栏、不创建待付款订单。任何失败只停止并等待人工,不自动回 DRAFT/释放/转领。 8. T-107 不上传证据、不申请围栏、不创建待付款订单。任何失败只停止并等待人工。
## 验收要点 ## 验收要点
- fixture 正反例覆盖来源页、唯一导航控件、确认页、规格/数量/金额、最终控件 0/1/2 个、一次返回后置条件与版本漂移。 - fixture 正反例覆盖合并式面板、规格/数量、Gate2 顶部金额、Gate3 最终控件金额、0/1/2 个文本、最近可点击祖先、一次返回和版本漂移。
- 动作轨迹证明成功路径仅一个已批准导航点击和一次 Back;导航结果不明与 Back 结果不明均不重试。 - 成功轨迹证明页面控件点击数为 0,仅一次 Back;Back 结果不明不重试。
- Gate3 金额用 Decimal,规格/数量/上限严格;最终控件观察 DTO 不含 selector/坐标/节点/可点击对象。 - 金额使用 Decimal,Gate3 严格等于 Gate2且不超上限;按钮金额绝不用于 Gate1/Gate2。
- AST/import closure 证明没有 fence/result/SubmissionPermit/click_permitted/submit_order_once/payment,最终控件 observer 无 device/click。 - Gate3 DTO 不含 selector/坐标/节点/可点击对象;AST/import closure 不含 fence/result/permit/submit/payment。
- 真机人工确认闸门三、submit match count=1、一次安全返回、未点击最终提交、未创建订单/进入支付。 - 真机人工确认两金额一致、submit match count=1、最近可点击祖先唯一、一次安全返回,未点击最终提交、未创建订单/进入付款。
- client 全量 unittest、compileall、完整 init、上下文校验、Vikunja 导出与 diff-check 通过;agent 保持 DOING 等人工验收。 - client 全量 unittest、compileall、完整 init、上下文校验、Vikunja 导出与 diff-check 通过;agent 保持 DOING 等人工验收。
## 执行记录 ## 执行记录
@@ -62,26 +61,46 @@ T-106 将形成确认页导航前、闸门三、最终提交控件可见态和
### 2026-08-04T14:04:47Z · ila ### 2026-08-04T14:04:47Z · ila
2026-08-04 预研定值:T-107 只消费 T-106 四态证据;确认页导航最多一次,最终提交控件由无 device 的纯 observer 读取且 DTO 不暴露节点/坐标/selector,随后最多一次 Back 并验证返回后置条件。全路径静态不可达提交与付款。 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,且未创建订单/进入付款。
### 2026-08-06T07:32:24Z · ila
2026-08-06 T-107 真机修复与运行记录:首次 dry-run 的首个真实错误为 Wi-Fi ADB 未连接,恢复后只读发现已选规格曾为“黑色 连线星星(纯棉)”,人工改回目标后,五项(黑色 CHA(纯棉)、M、数量2、顶部32.76、底部提交订单32.76)由项目所有者确认。新只读取证保存于 C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-107\gate3-layout-shift-937122477375-20260806\raw,PDD 8.17.0、1080x2376,0 页面动作/0 Back/0 submit;该态相对 T-106 面板整体上移77px。实现增加第二套互斥完整精确配置,禁止范围匹配和跨配置混搭。38项专项测试、client全量384项、compileall、admin test/vet/build、完整init.ps1、上下文校验通过;T-106原XML与新XML均离线通过。随后唯一一次真机dry-run成功,输出 C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-107\final-submit-panel-dry-run-937122477375;manifest:Gate2=Gate3=32.76、match_count=1、enabled=true、nearest_clickable_ancestor_unique=true、page_control_action_attempts=0、back_attempts=1、safe_exit=completed。保持DOING,等待项目所有者最终确认手机未创建订单、未进入付款/安全验证页面。
### 2026-08-06T07:35:47Z · ila
2026-08-06 项目所有者最终三项确认:手机已返回商品详情页且规格面板关闭;未创建待付款订单;未进入付款或安全验证页面。结合 manifest 的 Gate2=Gate3=32.76、match_count=1、最近可点击祖先唯一、页面控件动作0、Back恰好1次,以及完整门禁通过,T-107 全部验收要点满足,批准完成。
<!-- END VIKUNJA EXPORT --> <!-- END VIKUNJA EXPORT -->
## 边界 ## 边界
- 任何确认页来源判据、导航控件、目标页结构、闸门三节点、最终提交控件和返回后置条件都只能来自 - 合并式最终提交面板、Gate2 顶部金额、Gate3 最终控件金额、控件结构和返回后置条件只能来自 T-106
T-106 已由人确认的四态真机证据,并与拼多多 8.17.0/goods_id 绑定。不得从前序项目、旧 XML、 已由人确认的真机证据,以及 2026-08-06 T-107 人工五项确认的同版本精确布局证据
Activity 名、相似文案、OCR、裸坐标或推理补判据。 `C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-107\gate3-layout-shift-937122477375-20260806\raw`;
- `go_to_order_confirm()` 只允许在精确来源页点击 T-106 已证明的唯一导航控件一次;超时或结果不明 两者都必须与拼多多 8.17.0/goods_id 绑定并作为互斥完整配置精确命中。不得从前序项目、旧 XML、
不重试,不尝试其他购买按钮,不把规格面板中任意“提交订单 ¥...”文本直接当成已批准入口。 Activity 名、相似文案、OCR、坐标范围或推理补判据,也不得混搭两组坐标。
- 闸门三只从确认页读取颜色、尺码、数量和应付总额;必须严格等于目标规格/数量且金额为规范十进制、 - 不存在也不得重新引入 `go_to_order_confirm()` 或任何确认页导航点击。T-107 成功路径对页面控件的
不超最高总价。不得用详情页、规格面板或其他位置数字凑齐,不得使用浮点数。 点击数必须为 0;只有观察完成后的一次 Android Back。
- 最终“提交订单”控件只能由不持有 device/click 能力的纯 XML observer 只读。结果可含精确文本、 - Gate2 仍只从面板顶部已批准角色读取目标数量总额;Gate3 只从最终控件的结构化精确文本
匹配数、启用态和“唯一可点击祖先是否存在”的布尔值,但不得返回 selector、坐标、节点、祖先句柄 `提交订单 ¥{规范十进制金额}` 读取。两个候选必须分别唯一、严格相等且不超最高总价;最终控件金额
或任何可点击对象;runner 不得对该控件或祖先调用 click。 绝不能充当 Gate1/Gate2,详情页和其他数字仍全部禁止。
- 最终控件只能由不持有 device/click 能力的纯 XML observer 只读。结果可含完整文本、匹配数、启用态
和“最近可点击祖先是否唯一”的布尔值,但不得返回 selector、坐标、节点、祖先句柄或任何可点击对象;
runner 不得对该控件或祖先调用 click。
- 观察完成后只允许一次 Android Back,并必须命中 T-106 固化的稳定 post-back 正判据;超时或结果 - 观察完成后只允许一次 Android Back,并必须命中 T-106 固化的稳定 post-back 正判据;超时或结果
不明不重试。SystemUI/锁屏、其他 PDD 页面、外部支付/安全验证页或仅“确认页消失”都不是退出成功。 不明不重试。SystemUI/锁屏、其他 PDD 页面、付款/安全验证页或仅“面板消失”都不是退出成功。
- Runner 是永久围栏前 dry-run capability,不接受 `allow_submit`、`dry_run=False` 等开关;构造和 - Runner 是永久围栏前 dry-run capability,不接受 `allow_submit`、`dry_run=False` 等开关;构造和
import 闭包不得引用 submission fence、SubmissionPermit、`click_permitted`、`submit_order_once()`、 import 闭包不得引用 submission fence、SubmissionPermit、`click_permitted`、`submit_order_once()`、
结果调和、支付、免密支付、先用后付或任何扣款能力。 结果调和、支付、免密支付、先用后付或任何扣款能力。
- T-107 不上传证据、不定义 T-205 事件、不申请围栏、不创建待付款订单。任何失败只停止并等待人工, - T-107 不上传证据、不定义 T-205 事件、不申请围栏、不创建待付款订单。任何失败只停止并等待人工,
不自动释放 claim/授权、不回 DRAFT、不转领或重试页面动作。 不自动释放 claim/授权、不回 DRAFT、不转领或重试页面动作。
- `needs_device: true`:agent 不得自行标 DONE。只有人确认闸门三、最终控件 match count=1、一次安全 - `needs_device: true`:agent 不得自行标 DONE。只有人确认 Gate2/Gate3 两位置金额严格相等、最终控件
返回,且未点击最终提交、未创建订单、未进入支付相关页面,任务才可完成。 match count=1、最近可点击祖先唯一、一次安全返回,且未点击最终提交、未创建订单、未进入付款页面,
任务才可完成。
+9 -3
View File
@@ -32,7 +32,7 @@ write_paths:
- docs/current-state.md - docs/current-state.md
--- ---
<!-- BEGIN VIKUNJA EXPORT id=47 synced=2026-08-04T14:41:55Z sha256=a1109c837b096ecebfbda2200aa3c95c84b31ac2ff8e52404acb95060f148b3b --> <!-- BEGIN VIKUNJA EXPORT id=47 synced=2026-08-06T02:59:05Z sha256=87afa6087dff4263ac10766eb18682ad596f9ca845fee4706f80838d0843a33c -->
## 问题 / 背景 ## 问题 / 背景
T-205 将保存同一 attempt 的 append-only 三闸门事件和执行摘要,T-210 将开放 Gate1/Gate2/Gate3 三种固定证据资产,T-207 只允许围栏前人工恢复。真实点击“提交订单”前仍缺少服务端唯一提交围栏、首次一次性许可、围栏响应不明处理、点击后一次性结果以及同一 submission 的人工调和。T-208 只实现采购服务端围栏与调和;不连接真机、不点击提交订单、更不付款。 T-205 将保存同一 attempt 的 append-only 三闸门事件和执行摘要,T-210 将开放 Gate1/Gate2/Gate3 三种固定证据资产,T-207 只允许围栏前人工恢复。真实点击“提交订单”前仍缺少服务端唯一提交围栏、首次一次性许可、围栏响应不明处理、点击后一次性结果以及同一 submission 的人工调和。T-208 只实现采购服务端围栏与调和;不连接真机、不点击提交订单、更不付款。
@@ -77,11 +77,18 @@ T-205 将保存同一 attempt 的 append-only 三闸门事件和执行摘要,T
## 执行记录 ## 执行记录
(暂无) ### 2026-08-06T02:58:55Z · ila
2026-08-06T02:58:54.857Z · Codex
2026-08-06 合并式最终提交面板契约:submission-fence 请求使用 gate3_submit_amount、完整 submit_control_text、match_count 与 nearest_clickable_ancestor_unique;不得继续使用 confirm_page_amount/confirm_amount。服务端要求 Gate3 严格等于 Gate2 且不超上限。
<!-- END VIKUNJA EXPORT --> <!-- END VIKUNJA EXPORT -->
## 边界 ## 边界
- 2026-08-06 起 Gate3 是合并式最终提交面板唯一结构化最终控件金额;围栏请求/存储必须使用
`gate3_submit_amount`、完整控件文本、匹配数和最近可点击祖先唯一布尔值。旧
`confirm_page_amount` / `confirm_amount` 不得继续消费;T-210 迁移未完成时禁止建立围栏。
- `CREATE_ONCE` 建立围栏时必须复用 T-302 的 **current claim validator**,严格要求当前设备、session、 - `CREATE_ONCE` 建立围栏时必须复用 T-302 的 **current claim validator**,严格要求当前设备、session、
task、authorization、attempt、generation、claim token、未过期 lease/authorization 与当前状态 task、authorization、attempt、generation、claim token、未过期 lease/authorization 与当前状态
全部一致。不得为了让围栏或结果流程跑通而放宽、复活或绕过该 validator。 全部一致。不得为了让围栏或结果流程跑通而放宽、复活或绕过该 validator。
@@ -104,4 +111,3 @@ T-205 将保存同一 attempt 的 append-only 三闸门事件和执行摘要,T
`RECONCILIATION_REQUIRED`,绝不能回到围栏前状态。 `RECONCILIATION_REQUIRED`,绝不能回到围栏前状态。
- 本任务只实现服务端围栏、一次性结果和同一 submission 调和;不修改 client/PDD 页面判据,不连接 - 本任务只实现服务端围栏、一次性结果和同一 submission 调和;不修改 client/PDD 页面判据,不连接
真机,不实现 `submit_order_once()`,不点击“提交订单”,不编写支付、免密支付、先用后付或扣款能力。 真机,不实现 `submit_order_once()`,不点击“提交订单”,不编写支付、免密支付、先用后付或扣款能力。
+10 -1
View File
@@ -22,7 +22,7 @@ write_paths:
- docs/06-tasks.md - docs/06-tasks.md
--- ---
<!-- BEGIN VIKUNJA EXPORT id=43 synced=2026-08-04T14:39:13Z sha256=db95cfcdb109c264b188336cbf1625411d3457baf8525532613aac7e7487c3fd --> <!-- BEGIN VIKUNJA EXPORT id=43 synced=2026-08-06T02:59:09Z sha256=091bf2685c5caa42d62bc9229f55787e6418d7f67b8c237c32b640c8b4891d06 -->
## 问题 / 背景 ## 问题 / 背景
T-204 当前数据库和 API 只允许 SKU_PANEL_GATE_1;T-107 将以真机证据批准闸门二规格面板和闸门三确认页截图。T-210 在不改变上传身份、大小、隐私或幂等边界的前提下,安全扩展两个固定证据类型,为 T-305 提供完整三闸门审计。 T-204 当前数据库和 API 只允许 SKU_PANEL_GATE_1;T-107 将以真机证据批准闸门二规格面板和闸门三确认页截图。T-210 在不改变上传身份、大小、隐私或幂等边界的前提下,安全扩展两个固定证据类型,为 T-305 提供完整三闸门审计。
@@ -61,10 +61,19 @@ T-204 当前数据库和 API 只允许 SKU_PANEL_GATE_1;T-107 将以真机证
### 2026-08-04T14:39:05Z · ila ### 2026-08-04T14:39:05Z · ila
2026-08-04 依赖审计反转 T-210/T-205:三种 evidence kind 必须先进入真实 SQLite schema,T-205 才能对 Gate1/2/3 成功事件做真实正向与错 kind/跨归属测试。T-210 改为 00006,依赖 T-107/T-204/T-302。 2026-08-04 依赖审计反转 T-210/T-205:三种 evidence kind 必须先进入真实 SQLite schema,T-205 才能对 Gate1/2/3 成功事件做真实正向与错 kind/跨归属测试。T-210 改为 00006,依赖 T-107/T-204/T-302。
### 2026-08-06T02:58:55Z · ila
2026-08-06T02:58:54.857Z · Codex
2026-08-06 T-106 真机结论覆盖旧“Gate3=独立订单确认页”前提。T-210 必须把 Gate3 evidence kind 固定为合并式最终提交面板,并用受保护 migration 把历史 confirm_amount/confirm_page_amount 迁移为 gate3_submit_amount;Gate3 资产记录最终控件结构化金额,必须与 Gate2 顶部总额严格相等。迁移未完成不得让 T-205/T-208 消费或建立围栏。
<!-- END VIKUNJA EXPORT --> <!-- END VIKUNJA EXPORT -->
## 边界 ## 边界
- 2026-08-06 起 Gate3 kind 固定映射到合并式最终提交面板,不再映射独立订单确认页;本任务必须用
受保护 migration 把旧 `confirm_amount` / `confirm_page_amount` 迁移为 `gate3_submit_amount`。
Gate3 金额只来自结构化最终控件,必须严格等于 Gate2 顶部总额且不得回流 Gate1/Gate2。
- 证据类型只允许精确的 `SKU_PANEL_GATE_1`、`SKU_PANEL_GATE_2`、 - 证据类型只允许精确的 `SKU_PANEL_GATE_1`、`SKU_PANEL_GATE_2`、
`ORDER_CONFIRM_GATE_3`;不得接受自由 kind、别名、大小写变体或客户端自定义值。 `ORDER_CONFIRM_GATE_3`;不得接受自由 kind、别名、大小写变体或客户端自定义值。
- 升级必须保留既有闸门一 asset、哈希、attempt/task/authorization/generation 归属、append-only - 升级必须保留既有闸门一 asset、哈希、attempt/task/authorization/generation 归属、append-only
+9 -1
View File
@@ -32,7 +32,7 @@ write_paths:
- docs/current-state.md - docs/current-state.md
--- ---
<!-- BEGIN VIKUNJA EXPORT id=44 synced=2026-08-04T14:13:33Z sha256=01cdc47330b88791356618a90caed5e884666ad1b153ed136e6d3aacf51d8faf --> <!-- BEGIN VIKUNJA EXPORT id=44 synced=2026-08-06T02:59:04Z sha256=3ed2370d6d82623384b4ea5ecdbadf9aa9e8a82f89aeb2e32a153c5888c9ec86 -->
## 问题 / 背景 ## 问题 / 背景
T-103~T-107 分段固化真机页面能力,T-303/T-304 提供本地恢复与桌面执行壳,T-306/T-307 提供三闸门证据上传和 attempt 事件/失败 sink,T-210 扩展完整三闸门证据类型。T-305 只把这些已完成能力组合为围栏前 dry-run,验证领取后同一趟执行可以安全到达最终提交控件观察点,并在任何不可逆动作前停止。 T-103~T-107 分段固化真机页面能力,T-303/T-304 提供本地恢复与桌面执行壳,T-306/T-307 提供三闸门证据上传和 attempt 事件/失败 sink,T-210 扩展完整三闸门证据类型。T-305 只把这些已完成能力组合为围栏前 dry-run,验证领取后同一趟执行可以安全到达最终提交控件观察点,并在任何不可逆动作前停止。
@@ -71,10 +71,18 @@ T-103~T-107 分段固化真机页面能力,T-303/T-304 提供本地恢复与
### 2026-08-04T14:11:20Z · ila ### 2026-08-04T14:11:20Z · ila
2026-08-04 任务定值:T-305 只做围栏前单趟 dry-run,总装已验收的 T-107/T-304/T-306/T-307/T-210 能力;正常路径最终写 DRY_RUN_COMPLETED_BEFORE_FENCE 并安全返回,不请求围栏、不创建 order_submission、不点击提交订单、不涉及付款。 2026-08-04 任务定值:T-305 只做围栏前单趟 dry-run,总装已验收的 T-107/T-304/T-306/T-307/T-210 能力;正常路径最终写 DRY_RUN_COMPLETED_BEFORE_FENCE 并安全返回,不请求围栏、不创建 order_submission、不点击提交订单、不涉及付款。
### 2026-08-06T02:58:55Z · ila
2026-08-06T02:58:54.857Z · Codex
2026-08-06 T-305 顺序改为 Gate1 → 数量/Gate2 → 保持同一合并式最终提交面板零页面点击观察 Gate3/最终控件 → 一次安全 Back。删除确认页导航动作;Gate3 最终控件金额不得用于 Gate1/Gate2。
<!-- END VIKUNJA EXPORT --> <!-- END VIKUNJA EXPORT -->
## 边界 ## 边界
- 2026-08-06 起删除确认页导航步骤:Gate2 后保持同一合并式最终提交面板,以零页面点击观察 Gate3
最终控件金额和最近可点击祖先唯一性,然后只执行一次安全 Back。任何旧导航判据都不得实现或调用。
- 只实现 `PreFenceDryRunOrchestrator` 与围栏前 UI 组合;不得在本任务的构造、import 闭包或运行时 - 只实现 `PreFenceDryRunOrchestrator` 与围栏前 UI 组合;不得在本任务的构造、import 闭包或运行时
引入 submission fence/result client、`SubmissionPermit`、`click_permitted`、submitter、 引入 submission fence/result client、`SubmissionPermit`、`click_permitted`、submitter、
`submit_order_once()`、`order_submission` 或任何付款能力。 `submit_order_once()`、`order_submission` 或任何付款能力。
+8 -2
View File
@@ -16,7 +16,7 @@ write_paths:
- client/tests/execution/test_submission_orchestrator.py - client/tests/execution/test_submission_orchestrator.py
--- ---
<!-- BEGIN VIKUNJA EXPORT id=50 synced=2026-08-04T15:04:36Z sha256=4f27bf3ca322f467cb742da1b9159cbb976f6939c0cd804021323a24fa4cc735 --> <!-- BEGIN VIKUNJA EXPORT id=50 synced=2026-08-06T02:59:09Z sha256=06eb0eef25f8c77b7b3a97f1fc0d0b018aa2e3c912ed329aa18fef9c57ce125f -->
## 问题 / 背景 ## 问题 / 背景
T-305 的正常 dry-run 会在同一设备会话完成三闸门与最终控件只读观察,随后执行一次安全 Back,并以固定 reason DRY_RUN_COMPLETED_BEFORE_FENCE 终止 attempt。它只证明围栏前代码和判据契约已验收,绝不产生可供真实提交复用的运行时 ReadyForSubmission。T-308 将提供严格 fence/result sink、当前进程唯一 permit 和不可恢复的点击意图能力。T-400 只用测试 fixture 验证纯 SinglePassSubmissionOrchestrator、四条件、一次性调用和崩溃闭包;不读取或复用 T-305 的对象、截图、证据、journal、缓存或旧 proof,不提供生产 ReadyForSubmission 构造入口,也不接入 app/UI/PDD/真机。 T-305 的正常 dry-run 会在同一设备会话完成三闸门与最终控件只读观察,随后执行一次安全 Back,并以固定 reason DRY_RUN_COMPLETED_BEFORE_FENCE 终止 attempt。它只证明围栏前代码和判据契约已验收,绝不产生可供真实提交复用的运行时 ReadyForSubmission。T-308 将提供严格 fence/result sink、当前进程唯一 permit 和不可恢复的点击意图能力。T-400 只用测试 fixture 验证纯 SinglePassSubmissionOrchestrator、四条件、一次性调用和崩溃闭包;不读取或复用 T-305 的对象、截图、证据、journal、缓存或旧 proof,不提供生产 ReadyForSubmission 构造入口,也不接入 app/UI/PDD/真机。
@@ -58,11 +58,17 @@ T-305 的正常 dry-run 会在同一设备会话完成三闸门与最终控件
## 执行记录 ## 执行记录
(暂无) ### 2026-08-06T02:58:55Z · ila
2026-08-06T02:58:54.857Z · Codex
2026-08-06 离线 fixture/编排中的 Gate3 字段改为 gate3_submit_amount,提交控件必须是唯一结构化完整文本“提交订单 ¥{金额}”且最近可点击祖先唯一。Gate3 必须严格等于 Gate2;不再表达独立确认页。
<!-- END VIKUNJA EXPORT --> <!-- END VIKUNJA EXPORT -->
## 边界 ## 边界
- 2026-08-06 起离线事实使用 `gate3_submit_amount` 和结构化完整控件文本,不再使用确认页金额语义。
Gate3 必须严格等于 Gate2,最近可点击祖先必须唯一;fixture 不表达独立确认页或导航动作。
- T-305 正常 dry-run 已执行一次安全 Back,并以 `DRY_RUN_COMPLETED_BEFORE_FENCE` 终止 attempt。 - T-305 正常 dry-run 已执行一次安全 Back,并以 `DRY_RUN_COMPLETED_BEFORE_FENCE` 终止 attempt。
本任务只能复用其已验收的代码/判据契约;不得接收或复用该次/任何 dry-run 的结果对象、attempt、 本任务只能复用其已验收的代码/判据契约;不得接收或复用该次/任何 dry-run 的结果对象、attempt、
screenshot、AssetRef、action journal、缓存、页面对象、本地快照或旧 proof 来申请围栏或提交。 screenshot、AssetRef、action journal、缓存、页面对象、本地快照或旧 proof 来申请围栏或提交。
+9 -2
View File
@@ -22,7 +22,7 @@ write_paths:
- client/tests/test_app.py - client/tests/test_app.py
--- ---
<!-- BEGIN VIKUNJA EXPORT id=52 synced=2026-08-04T15:06:03Z sha256=4ec2adedf5a6ab06ece40e32e6619992fbf8cc071a95977a80a796b6b1cd9d98 --> <!-- BEGIN VIKUNJA EXPORT id=52 synced=2026-08-06T02:59:12Z sha256=e388ed4e91052ce9d8a0a7b5ac0aa55f7721a3133c17cc5eff948dae4167ce21 -->
## 问题 / 背景 ## 问题 / 背景
T-107 已把订单确认页、闸门三与精确唯一“提交订单”控件限制为证据/App 版本绑定的只读能力,T-308 将只在当前进程首次明确围栏许可后产生不可恢复 permit,T-400 只用测试 fixture 证明四条件与一次性编排。T-305 的真实 dry-run 已安全 Back 并以 DRY_RUN_COMPLETED_BEFORE_FENCE 终止 attempt,只能提供已验收代码/判据契约,任何运行对象、截图、缓存或 proof 都不能提交。T-401 必须领取一条新的已授权真实任务,在全新 attempt 的同一设备会话中从打开商品开始实时重跑 Gate1、数量/Gate2、确认页/Gate3,并在仍停留于当前确认页时即时构造生产 ReadyForSubmission,随后才可申请围栏和精确点击一次。系统只创建可能的待付款订单,绝不付款;首次提交后页面无本项目证据,因此点击后无条件上报 UNKNOWN 并由 T-403 调和。 T-107 已把订单确认页、闸门三与精确唯一“提交订单”控件限制为证据/App 版本绑定的只读能力,T-308 将只在当前进程首次明确围栏许可后产生不可恢复 permit,T-400 只用测试 fixture 证明四条件与一次性编排。T-305 的真实 dry-run 已安全 Back 并以 DRY_RUN_COMPLETED_BEFORE_FENCE 终止 attempt,只能提供已验收代码/判据契约,任何运行对象、截图、缓存或 proof 都不能提交。T-401 必须领取一条新的已授权真实任务,在全新 attempt 的同一设备会话中从打开商品开始实时重跑 Gate1、数量/Gate2、确认页/Gate3,并在仍停留于当前确认页时即时构造生产 ReadyForSubmission,随后才可申请围栏和精确点击一次。系统只创建可能的待付款订单,绝不付款;首次提交后页面无本项目证据,因此点击后无条件上报 UNKNOWN 并由 T-403 调和。
@@ -67,11 +67,18 @@ T-107 已把订单确认页、闸门三与精确唯一“提交订单”控件
## 执行记录 ## 执行记录
(暂无) ### 2026-08-06T02:58:55Z · ila
2026-08-06T02:58:54.857Z · Codex
2026-08-06 生产单趟删除“进入确认页”步骤:数量/Gate2 后保持同一最终面板,实时读取结构化最终控件金额作为 Gate3,再申请围栏。真实点击仍必须四条件全过且只一次;点击会直接创建待付款订单并进入付款界面,随后立即停止、绝不付款。
<!-- END VIKUNJA EXPORT --> <!-- END VIKUNJA EXPORT -->
## 边界 ## 边界
- 2026-08-06 起生产单趟在数量/Gate2 后保持同一合并式最终提交面板,读取结构化最终控件金额作为
Gate3;不存在确认页导航。最终点击会直接创建待付款订单并进入付款界面,仍须四条件全过、只点
一次,点击后立即停止并调和,绝不执行付款。
- 本任务只把 T-107 已取证且绑定拼多多 App 版本的精确唯一最终控件、T-308 当前进程首次明确 - 本任务只把 T-107 已取证且绑定拼多多 App 版本的精确唯一最终控件、T-308 当前进程首次明确
`SubmissionPermit` / 一次性 `ArmedSubmission` 和 T-400 离线编排接入生产。不得接收服务端 `SubmissionPermit` / 一次性 `ArmedSubmission` 和 T-400 离线编排接入生产。不得接收服务端
selector/坐标/动作脚本,不得使用相似文案、OCR、通用 click、旧项目常量或未取证页面能力。 selector/坐标/动作脚本,不得使用相似文案、OCR、通用 click、旧项目常量或未取证页面能力。
+8 -2
View File
@@ -24,7 +24,7 @@ write_paths:
- docs/current-state.md - docs/current-state.md
--- ---
<!-- BEGIN VIKUNJA EXPORT id=48 synced=2026-08-04T14:51:01Z sha256=f3ddd60f4b212c01b488f92a91148bfcb299c20cc8f8084f198971c18b3a4b49 --> <!-- BEGIN VIKUNJA EXPORT id=48 synced=2026-08-06T02:59:07Z sha256=9a6d7f96c42df181cf6204acdd1d5d179136df36221c1a40517f0d8906a562b9 -->
## 问题 / 背景 ## 问题 / 背景
T-208 将冻结三闸门、submission fence、一次性 result 和管理员调和事务,T-206 提供状态详情底座。采购管理员仍需要在同一任务详情中看懂围栏前固定失败、围栏后结果不明、已有证据和人工调和历史,并只调用 T-208 已有的同一 submission 调和接口。T-403 只完成失败分类/证据归档事实的展示和调和 UI,不新增失败码、数据库事实或第二套调和事务。 T-208 将冻结三闸门、submission fence、一次性 result 和管理员调和事务,T-206 提供状态详情底座。采购管理员仍需要在同一任务详情中看懂围栏前固定失败、围栏后结果不明、已有证据和人工调和历史,并只调用 T-208 已有的同一 submission 调和接口。T-403 只完成失败分类/证据归档事实的展示和调和 UI,不新增失败码、数据库事实或第二套调和事务。
@@ -62,11 +62,17 @@ T-208 将冻结三闸门、submission fence、一次性 result 和管理员调
## 执行记录 ## 执行记录
(暂无) ### 2026-08-06T02:58:55Z · ila
2026-08-06T02:58:54.857Z · Codex
2026-08-06 管理视图/证据文案把 Gate3 解释为合并式最终提交面板的最终控件金额,不再称确认页金额;历史数据库字段在 T-210 迁移为 gate3_submit_amount 后再消费。外部付款页仍不保存、不上传。
<!-- END VIKUNJA EXPORT --> <!-- END VIKUNJA EXPORT -->
## 边界 ## 边界
- 2026-08-06 起管理视图把 Gate3 展示为合并式最终提交面板的最终控件金额,不再称确认页金额;
只消费 T-210 迁移后的 `gate3_submit_amount`,不读取旧 `confirm_amount` / `confirm_page_amount`。
- 本任务只展示 T-205/T-208 已有失败、事件、三闸门 evidence、submission 与 reconciliation 事实, - 本任务只展示 T-205/T-208 已有失败、事件、三闸门 evidence、submission 与 reconciliation 事实,
并把管理员表单提交到 T-208 既有 reconcile handler/store;不得新增或改写 failure/reason code、 并把管理员表单提交到 T-208 既有 reconcile handler/store;不得新增或改写 failure/reason code、
migration、状态机、调和事务或直接数据库写入。 migration、状态机、调和事务或直接数据库写入。