Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db7b61b90b | ||
|
|
50a572f3e3 | ||
|
|
1d60a91ace | ||
|
|
665b302a1d | ||
|
|
dbf9b8c902 | ||
|
|
23445d0c4b | ||
|
|
3abbe1040d | ||
|
|
e93e2e8202 | ||
|
|
b0b71d5a29 | ||
|
|
acc9f1d964 | ||
|
|
db2b781c8c | ||
|
|
3f09fbff66 | ||
|
|
21628a4dc5 | ||
|
|
1d726dd364 | ||
|
|
e18f7983fd | ||
|
|
ad80a7f23c | ||
|
|
994b6c8054 | ||
|
|
16b2487a2a | ||
|
|
67b427864a | ||
|
|
1bb54927e1 | ||
|
|
0afe9a53b3 | ||
|
|
63bd873c26 | ||
|
|
2e303f3829 | ||
|
|
f97dca95ae | ||
|
|
43fd8ce668 | ||
|
|
44a207df7f | ||
|
|
29fb528823 | ||
|
|
537159445e | ||
|
|
91071d4013 | ||
|
|
60cf05b08b | ||
|
|
fec21ae857 | ||
|
|
3984f399b0 | ||
|
|
251f3d92dd | ||
|
|
5fa8ac6ff2 | ||
|
|
a8570f59af | ||
|
|
ee56249014 | ||
|
|
e552e2e2ca | ||
|
|
e1e534c5d3 | ||
|
|
5a4633fb64 | ||
|
|
5d0fc81891 | ||
|
|
230cfdc697 |
@@ -30,11 +30,11 @@ MVP 只做**任务自带商品链接**的情形:管理员先把任务保存为
|
||||
“开始采购(只创建待付款订单)”。该点击创建一次性授权,锁定商品、颜色、尺码、数量和最高总价。
|
||||
|
||||
采购工具领取后在同一次设备会话中完成:打开商品 → 精确选择规格 → 闸门一读 SKU 单价并校验
|
||||
上限 → 设置并复核数量 → 闸门二重读规格与同价 → 进入确认页 → 闸门三校验应付总额 → 服务端
|
||||
上限 → 设置并复核数量 → 闸门二读取顶部总额 → 在同一最终面板读取闸门三最终控件金额 → 服务端
|
||||
原子建立提交围栏 → 精确点击一次“提交订单” → 转待付款。中间不再回网页端等“机器选对了吗”。
|
||||
|
||||
价格**只在规格面板和订单确认页读**——别处的价格文本被拆成多个节点、带券后前缀、
|
||||
实付价与原价混在一起,不可靠。
|
||||
价格只从拼多多 `8.17.0` 已取证合并式最终提交面板的两个独立角色读取:顶部金额承担 Gate1/Gate2,
|
||||
结构化精确文本 `提交订单 ¥{金额}` 中的金额承担 Gate3;后者必须与 Gate2 严格相等且不能回流兜底。
|
||||
|
||||
## 现在处于什么阶段
|
||||
|
||||
@@ -62,11 +62,11 @@ MVP 只做**任务自带商品链接**的情形:管理员先把任务保存为
|
||||
1. 不点击任何支付、免密支付、先用后付或扣款控件
|
||||
2. **提交订单四条件**:授权未消费且服务端提交围栏已建立 + 闸门二通过 + 闸门三通过 +
|
||||
控件唯一,**只点一次**;围栏后只调和,不释放、不重试
|
||||
3. 订单确认页上除提交与返回外零点击
|
||||
3. 合并式最终提交面板上除满足四条件后的最终控件与安全返回外零点击
|
||||
4. 规格按维度精确匹配,防前缀碰撞,找不到即停
|
||||
5. 数量设置后必须读回复核
|
||||
6. **三道价格闸门**,任一道读不到或不通过即停
|
||||
7. **能力分层**——T-103 规格验证路径不得引用数量、确认页或下单函数;后续能力逐段取证
|
||||
7. **能力分层**——T-103 规格验证路径不得引用数量、最终提交面板或下单函数;后续能力逐段取证
|
||||
8. 检测到外部支付交接立即停止,不读取不保存凭据
|
||||
9. 检测到验证码 / 风控 / 人脸 / 短信校验立即停止,不绕过
|
||||
10. 只读非敏感摘要,不提取收货地址原文、手机号、支付凭据
|
||||
@@ -80,7 +80,7 @@ cmbuyer 是 `cmroubao`(Go 后端 + Android AccessibilityService)与 `cmpdd`
|
||||
(Python + uiautomator2)的合并重启,取各自已验证的一半:
|
||||
|
||||
- 保留 cmroubao 的后端任务生命周期、设备侧 API 形状、下单授权状态机、ERP 对接、管理 Web
|
||||
- 保留 cmpdd 的 uiautomator2 真机自动化、按维度精确选规格、订单确认页读取、付款闸门
|
||||
- 保留 cmpdd 的 uiautomator2 真机自动化与按维度精确选规格思路;最终提交面板事实在本项目重新取证
|
||||
- 丢弃自研 Android APK 与 AccessibilityService 感知层
|
||||
|
||||
**前序项目是设计依据,不是事实来源。** 其中的页面判据必须在本项目用真机重新验证。
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""采集 T-106 人工准备的最终面板或返回态证据;不执行页面操作。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
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.order_confirm_spike import (
|
||||
Android16ForegroundReader,
|
||||
DECLARED_STATES,
|
||||
EXPECTED_GOODS_ID,
|
||||
OrderConfirmEvidenceCapturer,
|
||||
OrderConfirmEvidenceError,
|
||||
)
|
||||
|
||||
|
||||
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="采集 T-106 人工准备的最终面板或返回态本机证据。")
|
||||
parser.add_argument("--serial", required=True, help="ADB device serial;禁止自动选择。")
|
||||
parser.add_argument("--goods-id", required=True, help="T-106 已批准的目标商品标识。")
|
||||
parser.add_argument(
|
||||
"--state",
|
||||
required=True,
|
||||
choices=DECLARED_STATES,
|
||||
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("--adb", default="adb", help="adb 可执行文件路径。")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def validate_arguments(arguments: argparse.Namespace) -> None:
|
||||
if (
|
||||
type(arguments.serial) is not str
|
||||
or not arguments.serial.strip()
|
||||
or arguments.serial != arguments.serial.strip()
|
||||
):
|
||||
raise ValueError("必须显式提供非空 --serial。")
|
||||
if type(arguments.goods_id) is not str or arguments.goods_id != EXPECTED_GOODS_ID:
|
||||
raise ValueError("--goods-id 不是 T-106 已批准目标。")
|
||||
if type(arguments.state) is not str or arguments.state not in DECLARED_STATES:
|
||||
raise ValueError("--state 必须是批准的人工声明状态。")
|
||||
if not isinstance(arguments.output_dir, Path) or not arguments.output_dir.name:
|
||||
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 的有限数值。")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
arguments = parse_arguments(argv)
|
||||
try:
|
||||
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("失败:缺少 uiautomator2;请在采购工具虚拟环境中运行。", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
adb_runner = SubprocessAdbRunner(arguments.adb)
|
||||
capturer = OrderConfirmEvidenceCapturer(
|
||||
AdbClient(adb_runner, timeout_seconds=arguments.timeout),
|
||||
NoReconnectUiautomatorConnector(
|
||||
adbutils.AdbClient(socket_timeout=arguments.timeout).device_list,
|
||||
u2.connect,
|
||||
),
|
||||
Android16ForegroundReader(adb_runner, arguments.timeout),
|
||||
timeout_seconds=arguments.timeout,
|
||||
)
|
||||
try:
|
||||
capturer.capture(
|
||||
arguments.serial,
|
||||
arguments.goods_id,
|
||||
arguments.state,
|
||||
arguments.output_dir,
|
||||
)
|
||||
except (DeviceConnectionError, OrderConfirmEvidenceError):
|
||||
# 第三方异常可能带设备、页面正文或本机目录,命令行只输出固定摘要。
|
||||
print("T-106 只读取证失败:已停止,未发布本机证据目录。", file=sys.stderr)
|
||||
return 1
|
||||
except OSError:
|
||||
print("T-106 只读取证失败:无法发布本机证据目录。", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print("T-106 只读取证完成。")
|
||||
print("人工复核:请在指定目录检查截图、XML、应用摘要和 manifest。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,120 @@
|
||||
"""采集 T-105 人工准备的数量 1/2 两态证据;不执行页面操作。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
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.quantity_gate2_spike import (
|
||||
Android16TopResumedForegroundReader,
|
||||
DECLARED_QUANTITIES,
|
||||
EXPECTED_GOODS_ID,
|
||||
QuantityGate2EvidenceCapturer,
|
||||
QuantityGate2EvidenceError,
|
||||
)
|
||||
|
||||
|
||||
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="采集 T-105 人工准备的数量两态本机证据。")
|
||||
parser.add_argument("--serial", required=True, help="ADB device serial;禁止自动选择。")
|
||||
parser.add_argument("--goods-id", required=True, help="T-105 已批准的目标商品标识。")
|
||||
parser.add_argument(
|
||||
"--state",
|
||||
required=True,
|
||||
choices=sorted(DECLARED_QUANTITIES),
|
||||
help="人工声明状态:initial=数量1,target=数量2。",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--declared-quantity",
|
||||
required=True,
|
||||
type=int,
|
||||
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("--adb", default="adb", help="adb 可执行文件路径。")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def validate_arguments(arguments: argparse.Namespace) -> None:
|
||||
if (
|
||||
type(arguments.serial) is not str
|
||||
or not arguments.serial.strip()
|
||||
or arguments.serial != arguments.serial.strip()
|
||||
):
|
||||
raise ValueError("必须显式提供非空 --serial。")
|
||||
if type(arguments.goods_id) is not str or arguments.goods_id != EXPECTED_GOODS_ID:
|
||||
raise ValueError("--goods-id 不是 T-105 已批准目标。")
|
||||
if type(arguments.state) is not str or arguments.state not in DECLARED_QUANTITIES:
|
||||
raise ValueError("--state 必须是批准的人工声明状态。")
|
||||
if type(arguments.declared_quantity) is not int or (
|
||||
arguments.declared_quantity != DECLARED_QUANTITIES[arguments.state]
|
||||
):
|
||||
raise ValueError("--declared-quantity 必须与人工声明状态一致。")
|
||||
if not isinstance(arguments.output_dir, Path) or not arguments.output_dir.name:
|
||||
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 的有限数值。")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
arguments = parse_arguments(argv)
|
||||
try:
|
||||
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("失败:缺少 uiautomator2;请在采购工具虚拟环境中运行。", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
adb_runner = SubprocessAdbRunner(arguments.adb)
|
||||
capturer = QuantityGate2EvidenceCapturer(
|
||||
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,
|
||||
)
|
||||
try:
|
||||
capturer.capture(
|
||||
arguments.serial,
|
||||
arguments.goods_id,
|
||||
arguments.state,
|
||||
arguments.declared_quantity,
|
||||
arguments.output_dir,
|
||||
)
|
||||
except (DeviceConnectionError, QuantityGate2EvidenceError):
|
||||
# 第三方异常可能含 serial、Activity、本机路径或页面正文,CLI 只输出固定摘要。
|
||||
print("数量两态只读取证失败:已停止,未发布本机证据目录。", file=sys.stderr)
|
||||
return 1
|
||||
except OSError:
|
||||
print("数量两态只读取证失败:无法发布本机证据目录。", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print("数量两态只读取证完成。")
|
||||
print("人工复核:请在指定目录检查截图、XML、应用摘要和 manifest。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,85 @@
|
||||
"""采集 T-104 阶段 A 的一次 Back 后本机证据。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
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.sku_selection import SkuSelectionError
|
||||
from cmbuyer_client.pdd.sku_selection_runner import (
|
||||
SkuExitSpikeCapturer,
|
||||
SkuSelectionRunError,
|
||||
)
|
||||
|
||||
|
||||
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="采集 T-104 阶段 A 的一次 Back 后本机证据。")
|
||||
parser.add_argument("--serial", required=True, help="ADB device serial;禁止自动选择。")
|
||||
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("--adb", default="adb", help="adb 可执行文件路径。")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def validate_arguments(arguments: argparse.Namespace) -> None:
|
||||
if type(arguments.serial) is not str or not arguments.serial.strip():
|
||||
raise ValueError("必须显式提供非空 --serial。")
|
||||
if not isinstance(arguments.output_dir, Path) or not arguments.output_dir.name:
|
||||
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 的有限数值。")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
arguments = parse_arguments(argv)
|
||||
try:
|
||||
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("失败:缺少 uiautomator2;请在采购工具虚拟环境中运行。", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
capturer = SkuExitSpikeCapturer(
|
||||
AdbClient(SubprocessAdbRunner(arguments.adb), timeout_seconds=arguments.timeout),
|
||||
NoReconnectUiautomatorConnector(
|
||||
adbutils.AdbClient(socket_timeout=arguments.timeout).device_list,
|
||||
u2.connect,
|
||||
),
|
||||
timeout_seconds=arguments.timeout,
|
||||
)
|
||||
try:
|
||||
capturer.capture(arguments.serial, arguments.output_dir)
|
||||
except (DeviceConnectionError, SkuSelectionError, SkuSelectionRunError):
|
||||
# 不回显第三方异常、serial、页面正文或本机路径。
|
||||
print("规格安全退出取证失败:已停止,未发布本地证据目录。", file=sys.stderr)
|
||||
return 1
|
||||
except OSError:
|
||||
print("规格安全退出取证失败:无法发布本地证据目录。", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print("规格安全退出取证完成。")
|
||||
print("人工复核:请在指定目录检查退出后截图、XML、应用摘要和 manifest。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -13,8 +13,16 @@ 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.sku_reveal_spike import SkuRevealSpikeCapturer, SkuRevealSpikeError
|
||||
from cmbuyer_client.pdd.sku_selection import EXPECTED_GOODS_ID, SkuSelectionError
|
||||
from cmbuyer_client.pdd.sku_reveal_spike import (
|
||||
safe_reveal_failure_stage,
|
||||
SkuRevealSpikeCapturer,
|
||||
SkuRevealSpikeError,
|
||||
)
|
||||
from cmbuyer_client.pdd.sku_selection import (
|
||||
EXPECTED_GOODS_ID,
|
||||
_safe_sku_entry_failure_stage,
|
||||
SkuSelectionError,
|
||||
)
|
||||
from cmbuyer_client.pdd.sku_selection_runner import SkuSelectionRunError
|
||||
|
||||
|
||||
@@ -42,6 +50,14 @@ def validate_arguments(arguments: argparse.Namespace) -> None:
|
||||
raise ValueError("--timeout 必须是大于 0 的有限数值。")
|
||||
|
||||
|
||||
def _safe_capture_failure_stage(error: BaseException) -> str:
|
||||
# 入口与 reveal 各有不可伪造的正式 marker;入口优先,不能被外层 reveal 标注覆盖。
|
||||
entry_stage = _safe_sku_entry_failure_stage(error)
|
||||
if entry_stage is not None:
|
||||
return entry_stage
|
||||
return safe_reveal_failure_stage(error) or "unknown"
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
arguments = parse_arguments(argv)
|
||||
try:
|
||||
@@ -71,9 +87,12 @@ def main(argv: list[str] | None = None) -> int:
|
||||
arguments.goods_id,
|
||||
arguments.output_dir,
|
||||
)
|
||||
except (DeviceConnectionError, SkuSelectionError, SkuSelectionRunError, SkuRevealSpikeError):
|
||||
except (DeviceConnectionError, SkuSelectionError, SkuSelectionRunError, SkuRevealSpikeError) as error:
|
||||
# 不回显页面正文、节点、serial、坐标、路径或第三方异常。
|
||||
print("规格 reveal 取证失败:已停止,未发布本地证据目录。", file=sys.stderr)
|
||||
print(
|
||||
f"规格 reveal 取证失败:stage={_safe_capture_failure_stage(error)};已停止,未发布本地证据目录。",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
except OSError:
|
||||
print("规格 reveal 取证失败:无法创建或发布本地证据目录。", file=sys.stderr)
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"""运行 T-105 数量 1→2、Gate2 截图与一次安全退出。"""
|
||||
|
||||
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.quantity_gate2 import (
|
||||
EXPECTED_GATE1_UNIT_PRICE,
|
||||
EXPECTED_GOODS_ID,
|
||||
TASK_COLOR,
|
||||
TASK_SIZE,
|
||||
Gate1Observation,
|
||||
QuantityGate2Error,
|
||||
)
|
||||
from cmbuyer_client.pdd.quantity_gate2_runner import QuantityGate2Runner
|
||||
from cmbuyer_client.pdd.quantity_gate2_spike import Android16TopResumedForegroundReader
|
||||
|
||||
|
||||
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="运行 T-105 已取证数量与 Gate2 闭环。")
|
||||
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("--target-quantity", required=True, type=int)
|
||||
parser.add_argument("--gate1-unit-price", required=True)
|
||||
parser.add_argument("--gate1-screenshot", required=True, type=Path)
|
||||
parser.add_argument("--gate1-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-105 已批准目标。")
|
||||
if arguments.color != TASK_COLOR or arguments.size != TASK_SIZE:
|
||||
raise ValueError("颜色或尺码不是 T-105 已批准目标。")
|
||||
if type(arguments.target_quantity) is not int or arguments.target_quantity != 2:
|
||||
raise ValueError("本次真机验收只批准 --target-quantity 2。")
|
||||
if arguments.gate1_unit_price != EXPECTED_GATE1_UNIT_PRICE:
|
||||
raise ValueError("--gate1-unit-price 与已确认 Gate1 不一致。")
|
||||
if not isinstance(arguments.gate1_screenshot, Path) or not arguments.gate1_screenshot.is_file():
|
||||
raise ValueError("--gate1-screenshot 必须是现有显式文件。")
|
||||
try:
|
||||
captured_at = datetime.fromisoformat(arguments.gate1_captured_at)
|
||||
except (TypeError, ValueError) as error:
|
||||
raise ValueError("--gate1-captured-at 必须是带时区 ISO 时间。") from error
|
||||
if captured_at.utcoffset() is None:
|
||||
raise ValueError("--gate1-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 = QuantityGate2Runner(
|
||||
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,
|
||||
)
|
||||
gate1 = Gate1Observation(
|
||||
color=arguments.color,
|
||||
size=arguments.size,
|
||||
quantity=1,
|
||||
gate1_unit_price=arguments.gate1_unit_price,
|
||||
screenshot_path=arguments.gate1_screenshot,
|
||||
captured_at=captured_at,
|
||||
)
|
||||
try:
|
||||
runner.run(
|
||||
arguments.serial,
|
||||
arguments.goods_id,
|
||||
gate1,
|
||||
arguments.target_quantity,
|
||||
arguments.max_total_price,
|
||||
arguments.output_dir,
|
||||
)
|
||||
except (DeviceConnectionError, QuantityGate2Error, OSError):
|
||||
# 不回显第三方异常、serial、本机路径或页面正文。
|
||||
print("T-105 数量/Gate2 运行失败:已停止,未发布证据目录。", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print("T-105 数量/Gate2 运行完成。")
|
||||
print("人工复核:数量 2、目标规格、12.88/32.76、原始截图和一次安全退出。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -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",
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
"""T-106 合并式最终提交面板与人工返回态的纯只读取证。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from math import isfinite
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
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, CommandRunner, DeviceConnectionError, DeviceInspection
|
||||
from ..device.baseline import (
|
||||
HIERARCHY_PARAMS,
|
||||
PDD_PACKAGE,
|
||||
SCREENSHOT_PARAMS,
|
||||
_save_base64_screenshot,
|
||||
_sha256_file,
|
||||
_validate_hierarchy,
|
||||
)
|
||||
from .product_open import EXPECTED_PDD_VERSION
|
||||
from .product_url import parse_product_url
|
||||
|
||||
|
||||
EXPECTED_GOODS_ID = "937122477375"
|
||||
EXPECTED_DEVICE_MODEL = "PKG110"
|
||||
EXPECTED_ANDROID_VERSION = "16"
|
||||
EXPECTED_SCREEN_SIZE = (1080, 2376)
|
||||
DECLARED_STATES = ("gate2-navigation-source", "returned-safe-page")
|
||||
|
||||
|
||||
class OrderConfirmEvidenceError(RuntimeError):
|
||||
"""T-106 只读证据未形成完整原子产物。"""
|
||||
|
||||
|
||||
class OrderConfirmEvidenceTimeoutError(OrderConfirmEvidenceError):
|
||||
"""只读取证设备调用超时。"""
|
||||
|
||||
|
||||
class OrderConfirmReadDevice(Protocol):
|
||||
"""T-106 唯一设备边界;故意只有读取能力。"""
|
||||
|
||||
def app_info(self, package_name: str) -> dict[str, Any]: ...
|
||||
|
||||
def window_size(self) -> tuple[int, int]: ...
|
||||
|
||||
def jsonrpc_call(self, method: str, params: Any = None, timeout: float = 10) -> Any: ...
|
||||
|
||||
|
||||
class OrderConfirmForegroundReader(Protocol):
|
||||
"""读取 Android 前台摘要,不暴露通用命令执行。"""
|
||||
|
||||
def read(self, serial: str) -> dict[str, str]: ...
|
||||
|
||||
|
||||
class Android16ForegroundReader:
|
||||
"""读取 Android 16 的唯一 top-resumed Activity。"""
|
||||
|
||||
_TOP_RESUMED_PATTERN = re.compile(
|
||||
r"(?m)^\s*topResumedActivity=ActivityRecord\{[^\r\n}]*?\s+u\d+\s+"
|
||||
r"(?P<package>[^/\s]+)/(?P<activity>[^\s}]+)\s+t\d+\}\s*$"
|
||||
)
|
||||
|
||||
def __init__(self, runner: CommandRunner, timeout_seconds: float) -> None:
|
||||
if not _is_positive_finite(timeout_seconds):
|
||||
raise ValueError("timeout_seconds 必须是大于 0 的有限数值")
|
||||
self._runner = runner
|
||||
self._timeout_seconds = timeout_seconds
|
||||
|
||||
def read(self, serial: str) -> dict[str, str]:
|
||||
if type(serial) is not str or not serial.strip() or serial != serial.strip():
|
||||
raise OrderConfirmEvidenceError("必须显式提供非空设备通道。")
|
||||
result = self._runner.run(
|
||||
("-s", serial, "shell", "dumpsys", "activity", "activities"),
|
||||
self._timeout_seconds,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise OrderConfirmEvidenceError("Android 前台摘要读取失败,未发布证据。")
|
||||
matches = list(self._TOP_RESUMED_PATTERN.finditer(result.stdout))
|
||||
if len(matches) != 1:
|
||||
raise OrderConfirmEvidenceError("Android 前台摘要不唯一,未发布证据。")
|
||||
match = matches[0]
|
||||
return {"package": match.group("package"), "activity": match.group("activity")}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OrderConfirmEvidenceResult:
|
||||
output_directory: Path
|
||||
manifest_path: Path
|
||||
screenshot_path: Path
|
||||
hierarchy_path: Path
|
||||
app_path: Path
|
||||
|
||||
|
||||
class OrderConfirmEvidenceCapturer:
|
||||
"""采集人工准备的单个稳定状态,不解析页面业务字段。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
adb_client: AdbClient,
|
||||
connector: Callable[[str], OrderConfirmReadDevice],
|
||||
foreground_reader: OrderConfirmForegroundReader,
|
||||
timeout_seconds: float,
|
||||
) -> None:
|
||||
if not _is_positive_finite(timeout_seconds):
|
||||
raise ValueError("timeout_seconds 必须是大于 0 的有限数值")
|
||||
self._adb_client = adb_client
|
||||
self._connector = connector
|
||||
self._foreground_reader = foreground_reader
|
||||
self._timeout_seconds = timeout_seconds
|
||||
self._started = False
|
||||
|
||||
def capture(
|
||||
self,
|
||||
serial: str,
|
||||
goods_id: str,
|
||||
human_declared_state: str,
|
||||
output_directory: Path,
|
||||
) -> OrderConfirmEvidenceResult:
|
||||
if self._started:
|
||||
raise OrderConfirmEvidenceError("同一取证器不可重复调用。")
|
||||
self._started = True
|
||||
_validate_inputs(serial, goods_id, human_declared_state)
|
||||
target = Path(output_directory)
|
||||
_validate_new_target(target)
|
||||
|
||||
staging: Path | None = None
|
||||
try:
|
||||
inspection = self._adb_client.inspect(serial)
|
||||
_require_expected_device(inspection)
|
||||
device = self._connector(serial)
|
||||
initial_app = _require_read_precondition(
|
||||
device,
|
||||
self._foreground_reader.read(serial),
|
||||
)
|
||||
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
staging = target.parent / f".{target.name}.staging-{uuid4().hex}"
|
||||
staging.mkdir()
|
||||
|
||||
screenshot_path = staging / "screenshot.png"
|
||||
screenshot_payload = _read_rpc(
|
||||
device,
|
||||
"takeScreenshot",
|
||||
SCREENSHOT_PARAMS,
|
||||
self._timeout_seconds,
|
||||
)
|
||||
if not isinstance(screenshot_payload, str):
|
||||
raise OrderConfirmEvidenceError("页面截图无效,未发布证据。")
|
||||
_save_base64_screenshot(screenshot_payload, screenshot_path)
|
||||
_require_screenshot(screenshot_path)
|
||||
|
||||
hierarchy = _read_rpc(
|
||||
device,
|
||||
"dumpWindowHierarchy",
|
||||
HIERARCHY_PARAMS,
|
||||
self._timeout_seconds,
|
||||
)
|
||||
_validate_hierarchy(hierarchy)
|
||||
hierarchy_path = staging / "hierarchy.xml"
|
||||
hierarchy_path.write_text(hierarchy, encoding="utf-8")
|
||||
|
||||
final_app = _require_read_precondition(
|
||||
device,
|
||||
self._foreground_reader.read(serial),
|
||||
)
|
||||
if final_app != initial_app:
|
||||
raise OrderConfirmEvidenceError("取证期间前台页面漂移,未发布证据。")
|
||||
|
||||
app_path = staging / "app.json"
|
||||
app_path.write_text(
|
||||
json.dumps(final_app, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
manifest_path = staging / "manifest.json"
|
||||
manifest_path.write_text(
|
||||
json.dumps(
|
||||
_manifest(
|
||||
inspection,
|
||||
serial,
|
||||
goods_id,
|
||||
human_declared_state,
|
||||
screenshot_path,
|
||||
hierarchy_path,
|
||||
app_path,
|
||||
),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.rename(staging, target)
|
||||
staging = None
|
||||
except (DeviceConnectionError, OrderConfirmEvidenceError):
|
||||
_clean_staging(staging)
|
||||
raise
|
||||
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
|
||||
_clean_staging(staging)
|
||||
raise OrderConfirmEvidenceTimeoutError("T-106 只读取证超时,未发布证据。") from error
|
||||
except (OSError, UnidentifiedImageError, ValueError) as error:
|
||||
_clean_staging(staging)
|
||||
raise OrderConfirmEvidenceError("T-106 证据无法原子发布,未发布证据。") from error
|
||||
except Exception as error:
|
||||
_clean_staging(staging)
|
||||
raise OrderConfirmEvidenceError("T-106 只读取证未完成,未发布证据。") from error
|
||||
|
||||
return OrderConfirmEvidenceResult(
|
||||
output_directory=target,
|
||||
manifest_path=target / "manifest.json",
|
||||
screenshot_path=target / "screenshot.png",
|
||||
hierarchy_path=target / "hierarchy.xml",
|
||||
app_path=target / "app.json",
|
||||
)
|
||||
|
||||
|
||||
def _is_positive_finite(value: object) -> bool:
|
||||
return (
|
||||
isinstance(value, (int, float))
|
||||
and not isinstance(value, bool)
|
||||
and value > 0
|
||||
and isfinite(value)
|
||||
)
|
||||
|
||||
|
||||
def _validate_inputs(serial: object, goods_id: object, state: object) -> None:
|
||||
if type(serial) is not str or not serial.strip() or serial != serial.strip():
|
||||
raise OrderConfirmEvidenceError("必须显式提供非空设备通道。")
|
||||
if type(goods_id) is not str or goods_id != EXPECTED_GOODS_ID:
|
||||
raise OrderConfirmEvidenceError("商品不是 T-106 已批准取证目标。")
|
||||
parse_product_url(f"https://mobile.yangkeduo.com/goods.html?goods_id={goods_id}")
|
||||
if type(state) is not str or state not in DECLARED_STATES:
|
||||
raise OrderConfirmEvidenceError("人工声明状态无效。")
|
||||
|
||||
|
||||
def _validate_new_target(target: Path) -> None:
|
||||
if target.exists() or not target.name:
|
||||
raise OrderConfirmEvidenceError("输出目录必须是不存在的明确新目录。")
|
||||
|
||||
|
||||
def _require_expected_device(inspection: DeviceInspection) -> None:
|
||||
if inspection.model != EXPECTED_DEVICE_MODEL or inspection.android_version != EXPECTED_ANDROID_VERSION:
|
||||
raise OrderConfirmEvidenceError("设备不是已批准取证组合。")
|
||||
|
||||
|
||||
def _require_read_precondition(
|
||||
device: OrderConfirmReadDevice,
|
||||
current: object,
|
||||
) -> dict[str, str]:
|
||||
info = 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 OrderConfirmEvidenceError("拼多多版本不是已批准取证版本。")
|
||||
if not isinstance(current, dict) or current.get("package") != PDD_PACKAGE:
|
||||
raise OrderConfirmEvidenceError("拼多多不在前台。")
|
||||
activity = current.get("activity")
|
||||
if not isinstance(activity, str) or not activity.strip():
|
||||
raise OrderConfirmEvidenceError("前台应用摘要不完整。")
|
||||
if device.window_size() != EXPECTED_SCREEN_SIZE:
|
||||
raise OrderConfirmEvidenceError("屏幕坐标空间不是已批准尺寸。")
|
||||
return {"package": PDD_PACKAGE, "activity": activity, "pdd_version": EXPECTED_PDD_VERSION}
|
||||
|
||||
|
||||
def _read_rpc(
|
||||
device: OrderConfirmReadDevice,
|
||||
method: str,
|
||||
params: object,
|
||||
timeout_seconds: float,
|
||||
) -> object:
|
||||
return device.jsonrpc_call(method, params, timeout=timeout_seconds)
|
||||
|
||||
|
||||
def _require_screenshot(path: Path) -> None:
|
||||
with Image.open(path) as image:
|
||||
image.load()
|
||||
if image.size != EXPECTED_SCREEN_SIZE or image.format != "PNG":
|
||||
raise OrderConfirmEvidenceError("页面截图格式或尺寸无效。")
|
||||
|
||||
|
||||
def _clean_staging(staging: Path | None) -> None:
|
||||
if staging is not None and staging.exists():
|
||||
shutil.rmtree(staging)
|
||||
|
||||
|
||||
def _manifest(
|
||||
inspection: DeviceInspection,
|
||||
serial: str,
|
||||
goods_id: str,
|
||||
state: str,
|
||||
screenshot_path: Path,
|
||||
hierarchy_path: Path,
|
||||
app_path: Path,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"operation": "t106-order-confirm-readonly-evidence",
|
||||
"captured_at": datetime.now(UTC).isoformat(),
|
||||
"product": {
|
||||
"goods_id": goods_id,
|
||||
"canonical_url": f"https://mobile.yangkeduo.com/goods.html?goods_id={goods_id}",
|
||||
},
|
||||
"human_declared_state": state,
|
||||
"review_status": "human_review_required",
|
||||
"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,
|
||||
},
|
||||
"artifacts": [
|
||||
{
|
||||
"path": screenshot_path.name,
|
||||
"role": "human_prepared_state_raw_screenshot",
|
||||
"sha256": _sha256_file(screenshot_path),
|
||||
},
|
||||
{
|
||||
"path": hierarchy_path.name,
|
||||
"role": "human_prepared_state_raw_hierarchy_local_only",
|
||||
"sha256": _sha256_file(hierarchy_path),
|
||||
},
|
||||
{
|
||||
"path": app_path.name,
|
||||
"role": "human_prepared_state_app_identity",
|
||||
"sha256": _sha256_file(app_path),
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,605 @@
|
||||
"""T-105:证据绑定的数量读回与 Gate2 面板总额。
|
||||
|
||||
本模块只允许数量 1 保持不变,或从数量 1 对唯一加号点击一次到数量 2。
|
||||
它不包含确认页、提交围栏、提交订单或付款能力。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from functools import partial
|
||||
from math import isfinite
|
||||
from pathlib import Path
|
||||
import re
|
||||
from time import monotonic, sleep
|
||||
from typing import Any, Callable, Protocol
|
||||
from xml.etree import ElementTree
|
||||
|
||||
from ..core.errors import ValidationError
|
||||
from ..core.validation import require_money
|
||||
from ..device.baseline import PDD_PACKAGE
|
||||
from .product_open import EXPECTED_PDD_VERSION
|
||||
from .sku_selection import SkuSelectionError, _parse_nodes as _parse_sku_nodes
|
||||
from .sku_selection import _product_exit_projection
|
||||
|
||||
|
||||
EXPECTED_DEVICE_MODEL = "PKG110"
|
||||
EXPECTED_ANDROID_VERSION = "16"
|
||||
EXPECTED_SCREEN_SIZE = (1080, 2376)
|
||||
EXPECTED_GOODS_ID = "937122477375"
|
||||
TASK_COLOR = "黑色CHA(纯棉)"
|
||||
TASK_SIZE = "M(建议100-115)"
|
||||
UI_COLOR = "黑色 CHA (纯棉)"
|
||||
UI_SIZE = "M(建议100-115)"
|
||||
EXPECTED_GATE1_UNIT_PRICE = "12.88"
|
||||
|
||||
_PDD_ID = "com.xunmeng.pinduoduo:id/pdd"
|
||||
_QUANTITY_CONTAINER_ID = "com.xunmeng.pinduoduo:id/gnl"
|
||||
_COLOR_ID = "com.xunmeng.pinduoduo:id/tv_content"
|
||||
_TARGET_SUMMARY = f"已选: {UI_COLOR} {UI_SIZE}"
|
||||
_AMOUNT_TEXT = re.compile(r"^快卖完 ¥(?P<amount>(?:0|[1-9]\d*)\.\d{2})$")
|
||||
_BOUNDS = re.compile(r"^\[(\d+),(\d+)\]\[(\d+),(\d+)\]$")
|
||||
|
||||
|
||||
class QuantityGate2Error(RuntimeError):
|
||||
"""数量/Gate2 判据不成立时的脱敏安全停止。"""
|
||||
|
||||
|
||||
class QuantityGate2TimeoutError(QuantityGate2Error):
|
||||
"""设备调用或后置条件等待超时。"""
|
||||
|
||||
|
||||
class QuantityGate2OverCapError(QuantityGate2Error):
|
||||
"""目标数量面板总额超过管理员授权上限。"""
|
||||
|
||||
|
||||
class QuantityGate2Device(Protocol):
|
||||
"""T-105 的窄设备能力;没有通用页面动作。"""
|
||||
|
||||
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 increment_quantity_once(self, bounds: str) -> None: ...
|
||||
|
||||
def capture_screenshot(self) -> str: ...
|
||||
|
||||
def leave_sku_panel_once(self) -> None: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Gate1Observation:
|
||||
color: str
|
||||
size: str
|
||||
quantity: int
|
||||
gate1_unit_price: str
|
||||
screenshot_path: Path
|
||||
captured_at: datetime
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.color != TASK_COLOR or self.size != TASK_SIZE or type(self.quantity) is not int or self.quantity != 1:
|
||||
raise QuantityGate2Error("Gate1 规格或数量不是已取证前置,已停止操作。")
|
||||
if _money(self.gate1_unit_price) != EXPECTED_GATE1_UNIT_PRICE:
|
||||
raise QuantityGate2Error("Gate1 单价不是已取证值,已停止操作。")
|
||||
if not isinstance(self.screenshot_path, Path) or not self.screenshot_path.name:
|
||||
raise QuantityGate2Error("Gate1 截图路径无效,已停止操作。")
|
||||
if not isinstance(self.captured_at, datetime) or self.captured_at.utcoffset() is None:
|
||||
raise QuantityGate2Error("Gate1 采集时间必须带时区,已停止操作。")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Gate2Observation:
|
||||
requested_color: str
|
||||
requested_size: str
|
||||
actual_color: str
|
||||
actual_size: str
|
||||
requested_quantity: int
|
||||
quantity_read: int
|
||||
gate1_unit_price: str
|
||||
gate2_panel_total_price: str
|
||||
max_total_price: str
|
||||
screenshot_path: Path
|
||||
captured_at: datetime
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _PanelProfile:
|
||||
quantity: int
|
||||
panel_bounds: str
|
||||
price_row_bounds: str
|
||||
price_bounds: str
|
||||
price_text: str
|
||||
summary_bounds: str
|
||||
quantity_bounds: str
|
||||
minus_bounds: str
|
||||
value_bounds: str
|
||||
plus_bounds: str
|
||||
color_bounds: str
|
||||
|
||||
|
||||
_INITIAL = _PanelProfile(
|
||||
1,
|
||||
"[0,474][1080,863]",
|
||||
"[396,498][895,570]",
|
||||
"[396,503][712,570]",
|
||||
"快卖完 ¥12.88",
|
||||
"[396,654][1053,716]",
|
||||
"[396,752][645,827]",
|
||||
"[396,752][474,827]",
|
||||
"[480,752][561,827]",
|
||||
"[567,752][645,827]",
|
||||
"[126,1000][438,1024]",
|
||||
)
|
||||
_TARGET = _PanelProfile(
|
||||
2,
|
||||
"[0,474][1080,861]",
|
||||
"[396,498][740,570]",
|
||||
"[396,503][722,570]",
|
||||
"快卖完 ¥32.76",
|
||||
"[396,582][1053,644]",
|
||||
"[396,750][645,825]",
|
||||
"[396,750][474,825]",
|
||||
"[480,750][561,825]",
|
||||
"[567,750][645,825]",
|
||||
"[126,998][438,1024]",
|
||||
)
|
||||
_PROFILES = {1: _INITIAL, 2: _TARGET}
|
||||
|
||||
|
||||
@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:
|
||||
quantity: int
|
||||
panel_total_price: str
|
||||
plus_bounds: str
|
||||
projection: tuple[object, ...]
|
||||
|
||||
|
||||
class QuantityGate2Flow:
|
||||
"""从已确认数量 1 面板推进至获准数量,并安全退出同一商品。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device: QuantityGate2Device,
|
||||
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._increment_attempted = False
|
||||
self._pending: tuple[str, Callable[[list[_Node]], _VerifiedPanel]] | None = None
|
||||
self._terminal = False
|
||||
self._verified_quantity: int | None = None
|
||||
self._verified_total: str | None = None
|
||||
self._exited = False
|
||||
|
||||
@property
|
||||
def increment_attempts(self) -> int:
|
||||
return int(self._increment_attempted)
|
||||
|
||||
@property
|
||||
def exited(self) -> bool:
|
||||
return self._exited
|
||||
|
||||
@property
|
||||
def can_exit_safely(self) -> bool:
|
||||
return self._pending is None and self._verified_quantity in _PROFILES and not self._exited
|
||||
|
||||
def set_quantity_and_verify(
|
||||
self,
|
||||
gate1: Gate1Observation,
|
||||
target_quantity: int,
|
||||
max_total_price: str,
|
||||
) -> _VerifiedPanel:
|
||||
self._require_active()
|
||||
_validate_request(gate1, target_quantity, max_total_price)
|
||||
initial = self._read_verified(_INITIAL)
|
||||
if initial.panel_total_price != gate1.gate1_unit_price:
|
||||
raise QuantityGate2Error("Gate1 当前面板事实已漂移,已停止操作。")
|
||||
|
||||
if target_quantity == 1:
|
||||
verified = initial
|
||||
else:
|
||||
# 动作前 fresh 读取,不能使用上一次节点或缓存 bounds。
|
||||
before = self._read_hierarchy()
|
||||
precondition = _verified_panel(_parse_nodes(before), _INITIAL)
|
||||
_require_unique_action_occupants(_parse_nodes(before), precondition.plus_bounds)
|
||||
self._pending = (before, partial(_verified_panel, profile=_TARGET))
|
||||
self._increment_attempted = True
|
||||
try:
|
||||
self._device.increment_quantity_once(precondition.plus_bounds)
|
||||
verified = self._wait_for_pending()
|
||||
except BaseException:
|
||||
# 点击超时可能已送达;封存后不允许本 Flow 重试或继续。
|
||||
self._terminal = True
|
||||
raise
|
||||
|
||||
self._verified_quantity = verified.quantity
|
||||
self._verified_total = verified.panel_total_price
|
||||
if Decimal(verified.panel_total_price) > Decimal(max_total_price):
|
||||
raise QuantityGate2OverCapError("Gate2 面板总额超过授权最高总价,已停止操作。")
|
||||
return verified
|
||||
|
||||
def build_observation(
|
||||
self,
|
||||
gate1: Gate1Observation,
|
||||
target_quantity: int,
|
||||
max_total_price: str,
|
||||
screenshot_path: Path,
|
||||
captured_at: datetime,
|
||||
) -> Gate2Observation:
|
||||
self._require_active()
|
||||
_validate_request(gate1, target_quantity, max_total_price)
|
||||
if not isinstance(screenshot_path, Path) or not screenshot_path.name:
|
||||
raise QuantityGate2Error("Gate2 截图路径无效,已停止操作。")
|
||||
if not isinstance(captured_at, datetime) or captured_at.utcoffset() is None:
|
||||
raise QuantityGate2Error("Gate2 采集时间必须带时区,已停止操作。")
|
||||
verified = self._read_verified(_PROFILES[target_quantity])
|
||||
if (
|
||||
self._verified_quantity != verified.quantity
|
||||
or self._verified_total != verified.panel_total_price
|
||||
or Decimal(verified.panel_total_price) > Decimal(max_total_price)
|
||||
):
|
||||
raise QuantityGate2Error("截图后 Gate2 事实漂移,已停止操作。")
|
||||
return Gate2Observation(
|
||||
requested_color=gate1.color,
|
||||
requested_size=gate1.size,
|
||||
actual_color=TASK_COLOR,
|
||||
actual_size=TASK_SIZE,
|
||||
requested_quantity=target_quantity,
|
||||
quantity_read=verified.quantity,
|
||||
gate1_unit_price=gate1.gate1_unit_price,
|
||||
gate2_panel_total_price=verified.panel_total_price,
|
||||
max_total_price=max_total_price,
|
||||
screenshot_path=screenshot_path,
|
||||
captured_at=captured_at,
|
||||
)
|
||||
|
||||
def exit_sku_panel_safely(self) -> None:
|
||||
self._require_active()
|
||||
if self._verified_quantity not in _PROFILES:
|
||||
raise QuantityGate2Error("没有可用于安全退出的数量事实,已停止操作。")
|
||||
current = self._read_verified(_PROFILES[self._verified_quantity])
|
||||
if current.panel_total_price != self._verified_total:
|
||||
raise QuantityGate2Error("安全退出前 Gate2 事实漂移,已停止操作。")
|
||||
try:
|
||||
self._device.leave_sku_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 = _product_exit_projection(_parse_sku_nodes(raw))
|
||||
except SkuSelectionError:
|
||||
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 QuantityGate2TimeoutError("安全退出未达到连续稳定同商品判据,未重试返回。")
|
||||
self._sleep(min(self._poll, remaining))
|
||||
except BaseException:
|
||||
self._terminal = True
|
||||
raise
|
||||
|
||||
def reconcile_pending_action(self) -> _VerifiedPanel | None:
|
||||
"""结果不明时只读一次待定后置条件;绝不重发加号。"""
|
||||
|
||||
if self._pending is None:
|
||||
return None
|
||||
return self._wait_for_pending()
|
||||
|
||||
def _wait_for_pending(self) -> _VerifiedPanel:
|
||||
if self._pending is None:
|
||||
raise QuantityGate2Error("没有可调和的数量动作。")
|
||||
previous, condition = self._pending
|
||||
deadline = self._clock() + self._timeout
|
||||
while True:
|
||||
self._require_environment()
|
||||
raw = self._read_hierarchy()
|
||||
if raw != previous:
|
||||
try:
|
||||
verified = condition(_parse_nodes(raw))
|
||||
except QuantityGate2Error:
|
||||
pass
|
||||
else:
|
||||
self._pending = None
|
||||
return verified
|
||||
remaining = deadline - self._clock()
|
||||
if remaining <= 0:
|
||||
raise QuantityGate2TimeoutError("数量动作后置条件未确认,未重试加号。")
|
||||
self._sleep(min(self._poll, remaining))
|
||||
|
||||
def _read_verified(self, profile: _PanelProfile) -> _VerifiedPanel:
|
||||
self._require_environment()
|
||||
return _verified_panel(_parse_nodes(self._read_hierarchy()), profile)
|
||||
|
||||
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 QuantityGate2Error("拼多多版本与 T-105 证据不一致,已停止操作。")
|
||||
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 QuantityGate2Error("拼多多不是唯一前台应用,已停止操作。")
|
||||
if self._device.display_size() != EXPECTED_SCREEN_SIZE:
|
||||
raise QuantityGate2Error("屏幕坐标空间与 T-105 证据不一致,已停止操作。")
|
||||
|
||||
def _read_hierarchy(self) -> str:
|
||||
value = self._device.dump_window_hierarchy()
|
||||
if not isinstance(value, str) or not value:
|
||||
raise QuantityGate2Error("节点树读取失败,已停止操作。")
|
||||
return value
|
||||
|
||||
def _require_active(self) -> None:
|
||||
if self._terminal:
|
||||
raise QuantityGate2Error("数量流程已进入不可重入终止态。")
|
||||
|
||||
|
||||
def _positive_finite(value: object) -> bool:
|
||||
return isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0 and isfinite(value)
|
||||
|
||||
|
||||
def _validate_request(gate1: object, target_quantity: object, max_total_price: object) -> None:
|
||||
if not isinstance(gate1, Gate1Observation):
|
||||
raise QuantityGate2Error("缺少可信 Gate1Observation,已停止操作。")
|
||||
if type(target_quantity) is not int or target_quantity not in _PROFILES:
|
||||
raise QuantityGate2Error("目标数量没有本项目真机证据,已停止操作。")
|
||||
_money(max_total_price)
|
||||
|
||||
|
||||
def _money(value: object) -> str:
|
||||
try:
|
||||
return require_money(value, "invalid_money")
|
||||
except ValidationError as error:
|
||||
raise QuantityGate2Error("金额不是规范十进制字符串,已停止操作。") from error
|
||||
|
||||
|
||||
def _parse_nodes(raw: str) -> list[_Node]:
|
||||
try:
|
||||
root = ElementTree.fromstring(raw)
|
||||
except ElementTree.ParseError as error:
|
||||
raise QuantityGate2Error("节点树格式无效,已停止操作。") from error
|
||||
if root.tag != "hierarchy":
|
||||
raise QuantityGate2Error("节点树根节点无效,已停止操作。")
|
||||
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 _verified_panel(nodes: list[_Node], profile: _PanelProfile) -> _VerifiedPanel:
|
||||
panel = _one(
|
||||
node for node in nodes
|
||||
if _exact(node, "android.view.ViewGroup", profile.panel_bounds, resource_id=_PDD_ID, clickable="false")
|
||||
)
|
||||
price_frame = _one(
|
||||
node for node in nodes
|
||||
if node.parent is panel and _exact(node, "android.widget.FrameLayout", "[396,498][1053,570]", resource_id=_PDD_ID, clickable="false")
|
||||
)
|
||||
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, clickable="false")
|
||||
)
|
||||
price = _one(
|
||||
node for node in nodes
|
||||
if node.parent is price_row
|
||||
and _exact(node, "android.widget.TextView", profile.price_bounds, resource_id=_PDD_ID, clickable="false")
|
||||
and node.text == profile.price_text
|
||||
and not node.desc
|
||||
)
|
||||
match = _AMOUNT_TEXT.fullmatch(price.text)
|
||||
if match is None:
|
||||
raise QuantityGate2Error("Gate2 面板总额角色不可读。")
|
||||
amount = _money(match.group("amount"))
|
||||
|
||||
_one(
|
||||
node for node in nodes
|
||||
if node.parent is panel
|
||||
and _exact(node, "android.widget.TextView", profile.summary_bounds, resource_id=_PDD_ID, clickable="false")
|
||||
and node.text == _TARGET_SUMMARY
|
||||
and not node.desc
|
||||
)
|
||||
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_CONTAINER_ID, clickable="false")
|
||||
)
|
||||
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="", clickable="false")
|
||||
)
|
||||
_one(
|
||||
node for node in nodes
|
||||
if node.parent is quantity_inner
|
||||
and _exact(node, "android.widget.ImageView", profile.minus_bounds, resource_id=_PDD_ID, clickable="true")
|
||||
and node.desc == "减少数量"
|
||||
and not node.text
|
||||
)
|
||||
quantity = _one(
|
||||
node for node in nodes
|
||||
if node.parent is quantity_inner
|
||||
and _exact(node, "android.widget.EditText", profile.value_bounds, resource_id=_PDD_ID, clickable="true")
|
||||
and node.text == str(profile.quantity)
|
||||
and not node.desc
|
||||
)
|
||||
plus = _one(
|
||||
node for node in nodes
|
||||
if node.parent is quantity_inner
|
||||
and _exact(node, "android.widget.ImageView", profile.plus_bounds, resource_id=_PDD_ID, clickable="true")
|
||||
and node.desc == "增加数量"
|
||||
and not node.text
|
||||
)
|
||||
if quantity.element.get("selected") != "false" or plus.element.get("selected") != "false":
|
||||
raise QuantityGate2Error("数量控件选中属性漂移。")
|
||||
|
||||
_require_selected_target(nodes, profile)
|
||||
projection = (
|
||||
"quantity_gate2_8_17_0",
|
||||
profile.quantity,
|
||||
amount,
|
||||
tuple(_projection(node) for node in (panel, price_frame, price_row, price, quantity_outer, quantity_inner, quantity, plus)),
|
||||
)
|
||||
return _VerifiedPanel(profile.quantity, amount, plus.bounds, projection)
|
||||
|
||||
|
||||
def _require_selected_target(nodes: list[_Node], profile: _PanelProfile) -> None:
|
||||
color = _one(
|
||||
node for node in nodes
|
||||
if _exact(node, "android.widget.TextView", profile.color_bounds, resource_id=_COLOR_ID, clickable="true", selected="true")
|
||||
and node.text == UI_COLOR
|
||||
and not node.desc
|
||||
)
|
||||
if color.parent is None or color.parent.element.get("selected") != "true":
|
||||
raise QuantityGate2Error("目标颜色没有精确选中。")
|
||||
size = _one(
|
||||
node for node in nodes
|
||||
if _exact(node, "android.widget.TextView", "[439,1582][831,1667]", resource_id=_PDD_ID, clickable="true", selected="true")
|
||||
and node.text == UI_SIZE
|
||||
and not node.desc
|
||||
)
|
||||
if size.parent is None or size.parent.element.get("clickable") != "true":
|
||||
raise QuantityGate2Error("目标尺码结构漂移。")
|
||||
|
||||
|
||||
def _exact(
|
||||
node: _Node,
|
||||
class_name: str,
|
||||
bounds: str,
|
||||
*,
|
||||
resource_id: str,
|
||||
clickable: str,
|
||||
selected: str = "false",
|
||||
) -> bool:
|
||||
element = node.element
|
||||
return (
|
||||
element.get("package") == PDD_PACKAGE
|
||||
and element.get("class") == class_name
|
||||
and node.bounds == bounds
|
||||
and element.get("resource-id", "") == resource_id
|
||||
and element.get("clickable") == clickable
|
||||
and element.get("selected") == selected
|
||||
and element.get("enabled") == "true"
|
||||
and element.get("visible-to-user") == "true"
|
||||
and element.get("scrollable") == "false"
|
||||
)
|
||||
|
||||
|
||||
def _one(values: Any) -> _Node:
|
||||
matches = list(values)
|
||||
if len(matches) != 1:
|
||||
raise QuantityGate2Error("T-105 页面证据角色缺失或不唯一。")
|
||||
return matches[0]
|
||||
|
||||
|
||||
def _projection(node: _Node) -> tuple[str, ...]:
|
||||
return (
|
||||
node.element.tag,
|
||||
node.element.get("package", ""),
|
||||
node.element.get("class", ""),
|
||||
node.bounds,
|
||||
node.element.get("resource-id", ""),
|
||||
node.element.get("clickable", ""),
|
||||
node.element.get("selected", ""),
|
||||
node.text,
|
||||
node.desc,
|
||||
)
|
||||
|
||||
|
||||
def _bounds_center(bounds: str) -> tuple[int, int]:
|
||||
match = _BOUNDS.fullmatch(bounds)
|
||||
if match is None:
|
||||
raise QuantityGate2Error("数量控件坐标无效。")
|
||||
left, top, right, bottom = (int(value) for value in match.groups())
|
||||
if not (0 <= left < right <= EXPECTED_SCREEN_SIZE[0] and 0 <= top < bottom <= EXPECTED_SCREEN_SIZE[1]):
|
||||
raise QuantityGate2Error("数量控件坐标超出已取证屏幕。")
|
||||
return left + (right - left) // 2, top + (bottom - top) // 2
|
||||
|
||||
|
||||
def _require_unique_action_occupants(nodes: list[_Node], bounds: str) -> None:
|
||||
target = _one(node for node in nodes if node.bounds == bounds and node.desc == "增加数量")
|
||||
x, y = _bounds_center(bounds)
|
||||
occupants = [
|
||||
node for node in nodes
|
||||
if node.element.get("clickable") == "true"
|
||||
and node.element.get("enabled") == "true"
|
||||
and node.element.get("visible-to-user") == "true"
|
||||
and _contains(node.bounds, x, y)
|
||||
]
|
||||
if not occupants or any(not _same_branch(node, target) for node in occupants):
|
||||
raise QuantityGate2Error("数量加号中心存在未知可点击覆盖层,已停止操作。")
|
||||
|
||||
|
||||
def _contains(bounds: str, x: int, y: int) -> bool:
|
||||
match = _BOUNDS.fullmatch(bounds)
|
||||
if match is None:
|
||||
return False
|
||||
left, top, right, bottom = (int(value) for value in match.groups())
|
||||
return left <= x < right and top <= y < bottom
|
||||
|
||||
|
||||
def _same_branch(candidate: _Node, target: _Node) -> bool:
|
||||
current: _Node | None = target
|
||||
while current is not None:
|
||||
if current.element is candidate.element:
|
||||
return True
|
||||
current = current.parent
|
||||
current = candidate
|
||||
while current is not None:
|
||||
if current.element is target.element:
|
||||
return True
|
||||
current = current.parent
|
||||
return False
|
||||
@@ -0,0 +1,397 @@
|
||||
"""T-105 真机运行边界:一次数量加号、Gate2 原图与一次安全退出。"""
|
||||
|
||||
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
|
||||
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 .product_open import EXPECTED_PDD_VERSION
|
||||
from .quantity_gate2 import (
|
||||
EXPECTED_ANDROID_VERSION,
|
||||
EXPECTED_DEVICE_MODEL,
|
||||
EXPECTED_GOODS_ID,
|
||||
EXPECTED_SCREEN_SIZE,
|
||||
Gate1Observation,
|
||||
Gate2Observation,
|
||||
QuantityGate2Device,
|
||||
QuantityGate2Error,
|
||||
QuantityGate2Flow,
|
||||
QuantityGate2TimeoutError,
|
||||
_bounds_center,
|
||||
_money,
|
||||
)
|
||||
|
||||
|
||||
class ForegroundReader(Protocol):
|
||||
def read(self, serial: str) -> dict[str, str]: ...
|
||||
|
||||
|
||||
class QuantityGate2AdapterError(QuantityGate2Error):
|
||||
"""第三方设备接口失败后的脱敏映射。"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class QuantityGate2RunResult:
|
||||
output_directory: Path
|
||||
screenshot_path: Path
|
||||
manifest_path: Path
|
||||
observation: Gate2Observation
|
||||
|
||||
|
||||
class UiautomatorQuantityGate2Adapter(QuantityGate2Device):
|
||||
"""只暴露 T-105 已批准的一个加号和一个 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._increment_attempted = False
|
||||
self._increment_bounds: str | None = None
|
||||
self._increment_outcome = "not_attempted"
|
||||
self._back_attempted = False
|
||||
self._back_outcome = "not_attempted"
|
||||
|
||||
@property
|
||||
def increment_attempts(self) -> int:
|
||||
return int(self._increment_attempted)
|
||||
|
||||
@property
|
||||
def increment_bounds(self) -> str | None:
|
||||
return self._increment_bounds
|
||||
|
||||
@property
|
||||
def increment_outcome(self) -> str:
|
||||
return self._increment_outcome
|
||||
|
||||
@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 QuantityGate2AdapterError("无法读取应用版本,已停止操作。")
|
||||
return value
|
||||
|
||||
def current_foreground(self) -> dict[str, str]:
|
||||
try:
|
||||
value = self._foreground_reader.read(self._serial)
|
||||
except QuantityGate2Error:
|
||||
raise
|
||||
except Exception as error:
|
||||
raise QuantityGate2AdapterError("无法读取 Android 前台摘要,已停止操作。") from error
|
||||
if not isinstance(value, dict):
|
||||
raise QuantityGate2AdapterError("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 QuantityGate2AdapterError("无法读取屏幕坐标空间,已停止操作。")
|
||||
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 QuantityGate2AdapterError("节点树读取失败,已停止操作。")
|
||||
return value
|
||||
|
||||
def increment_quantity_once(self, bounds: str) -> None:
|
||||
if self._increment_attempted:
|
||||
raise QuantityGate2AdapterError("数量加号已尝试过,拒绝重试。")
|
||||
center_x, center_y = _bounds_center(bounds)
|
||||
# RPC 超时无法证明事件未送达,动作机会必须先持久在内存审计状态中。
|
||||
self._increment_attempted = True
|
||||
self._increment_bounds = bounds
|
||||
self._increment_outcome = "ambiguous"
|
||||
self._call("jsonrpc_call", "click", [center_x, center_y], timeout=self._timeout)
|
||||
self._increment_outcome = "completed"
|
||||
|
||||
def capture_screenshot(self) -> str:
|
||||
value = self._call("jsonrpc_call", "takeScreenshot", SCREENSHOT_PARAMS, timeout=self._timeout)
|
||||
if not isinstance(value, str):
|
||||
raise QuantityGate2AdapterError("Gate2 原始截图读取失败,已停止操作。")
|
||||
return value
|
||||
|
||||
def leave_sku_panel_once(self) -> None:
|
||||
if self._back_attempted:
|
||||
raise QuantityGate2AdapterError("安全返回已尝试过,拒绝重试。")
|
||||
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 QuantityGate2TimeoutError("T-105 设备调用超时,已停止操作。") from error
|
||||
except QuantityGate2Error:
|
||||
raise
|
||||
except Exception as error:
|
||||
raise QuantityGate2AdapterError("T-105 设备调用失败,已停止操作。") from error
|
||||
|
||||
|
||||
class QuantityGate2Runner:
|
||||
"""从人工停驻的数量 1 目标面板执行 T-105 已取证闭环。"""
|
||||
|
||||
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,
|
||||
gate1: Gate1Observation,
|
||||
target_quantity: int,
|
||||
max_total_price: str,
|
||||
output_directory: Path,
|
||||
) -> QuantityGate2RunResult:
|
||||
target = Path(output_directory)
|
||||
staging: Path | None = None
|
||||
adapter: UiautomatorQuantityGate2Adapter | None = None
|
||||
flow: QuantityGate2Flow | None = None
|
||||
try:
|
||||
_validate_preflight(serial, goods_id, gate1, target_quantity, max_total_price, target)
|
||||
staging = _prepare_staging(target)
|
||||
inspection = self._adb_client.inspect(serial)
|
||||
_require_expected_device(inspection)
|
||||
adapter = UiautomatorQuantityGate2Adapter(
|
||||
self._connector(serial),
|
||||
self._foreground_reader,
|
||||
serial,
|
||||
self._timeout,
|
||||
)
|
||||
flow = QuantityGate2Flow(
|
||||
adapter,
|
||||
wait_timeout_seconds=self._timeout,
|
||||
monotonic_clock=self._clock,
|
||||
)
|
||||
flow.set_quantity_and_verify(gate1, target_quantity, max_total_price)
|
||||
|
||||
screenshot_path = staging / "gate2_screenshot.png"
|
||||
_save_base64_screenshot(adapter.capture_screenshot(), screenshot_path)
|
||||
captured_at = datetime.now(UTC)
|
||||
_require_screenshot_size(screenshot_path)
|
||||
observation = flow.build_observation(
|
||||
gate1,
|
||||
target_quantity,
|
||||
max_total_price,
|
||||
screenshot_path,
|
||||
captured_at,
|
||||
)
|
||||
flow.exit_sku_panel_safely()
|
||||
_require_action_audit(adapter, target_quantity)
|
||||
|
||||
manifest_path = staging / "manifest.json"
|
||||
manifest_path.write_text(
|
||||
json.dumps(
|
||||
_manifest(inspection, serial, gate1, observation, screenshot_path, adapter),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.rename(staging, target)
|
||||
staging = None
|
||||
except (DeviceConnectionError, QuantityGate2Error):
|
||||
_attempt_known_safe_exit(flow)
|
||||
_clean_staging(staging)
|
||||
raise
|
||||
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
|
||||
_attempt_known_safe_exit(flow)
|
||||
_clean_staging(staging)
|
||||
raise QuantityGate2TimeoutError("T-105 真机运行超时,未发布证据。") from error
|
||||
except OSError as error:
|
||||
_attempt_known_safe_exit(flow)
|
||||
_clean_staging(staging)
|
||||
raise QuantityGate2Error("T-105 证据无法原子发布。") from error
|
||||
except Exception as error:
|
||||
_attempt_known_safe_exit(flow)
|
||||
_clean_staging(staging)
|
||||
raise QuantityGate2Error("T-105 真机运行未完成。") from error
|
||||
|
||||
published_observation = replace(
|
||||
observation,
|
||||
screenshot_path=target / "gate2_screenshot.png",
|
||||
)
|
||||
return QuantityGate2RunResult(
|
||||
output_directory=target,
|
||||
screenshot_path=target / "gate2_screenshot.png",
|
||||
manifest_path=target / "manifest.json",
|
||||
observation=published_observation,
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
gate1: object,
|
||||
target_quantity: object,
|
||||
max_total_price: object,
|
||||
target: Path,
|
||||
) -> None:
|
||||
if type(serial) is not str or not serial.strip() or serial != serial.strip():
|
||||
raise QuantityGate2Error("必须显式提供非空设备通道。")
|
||||
if type(goods_id) is not str or goods_id != EXPECTED_GOODS_ID:
|
||||
raise QuantityGate2Error("商品不是 T-105 已批准目标。")
|
||||
if not isinstance(gate1, Gate1Observation) or not gate1.screenshot_path.is_file():
|
||||
raise QuantityGate2Error("Gate1 原始截图不存在,已停止操作。")
|
||||
if type(target_quantity) is not int or target_quantity not in {1, 2}:
|
||||
raise QuantityGate2Error("目标数量没有 T-105 真机证据。")
|
||||
_money(max_total_price)
|
||||
if target.exists() or not target.name:
|
||||
raise QuantityGate2Error("输出目录必须是不存在的明确新目录。")
|
||||
|
||||
|
||||
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 QuantityGate2Error("输出目录不可写,已停止操作。") 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 QuantityGate2Error("设备型号或 Android 版本与 T-105 证据不一致。")
|
||||
|
||||
|
||||
def _require_screenshot_size(path: Path) -> None:
|
||||
try:
|
||||
with Image.open(path) as image:
|
||||
image.load()
|
||||
if image.size != EXPECTED_SCREEN_SIZE:
|
||||
raise QuantityGate2Error("Gate2 截图尺寸与 T-105 证据不一致。")
|
||||
except QuantityGate2Error:
|
||||
raise
|
||||
except (OSError, UnidentifiedImageError) as error:
|
||||
raise QuantityGate2Error("Gate2 截图不是有效图像。") from error
|
||||
|
||||
|
||||
def _require_action_audit(adapter: UiautomatorQuantityGate2Adapter, target_quantity: int) -> None:
|
||||
expected_increment = int(target_quantity == 2)
|
||||
if (
|
||||
adapter.increment_attempts != expected_increment
|
||||
or (expected_increment and adapter.increment_outcome != "completed")
|
||||
or (expected_increment and adapter.increment_bounds != "[567,752][645,827]")
|
||||
or (not expected_increment and adapter.increment_outcome != "not_attempted")
|
||||
or adapter.back_attempts != 1
|
||||
or adapter.back_outcome != "completed"
|
||||
):
|
||||
raise QuantityGate2Error("T-105 动作审计链不完整,拒绝发布。")
|
||||
|
||||
|
||||
def _attempt_known_safe_exit(flow: QuantityGate2Flow | None) -> None:
|
||||
if flow is None or not flow.can_exit_safely:
|
||||
return
|
||||
try:
|
||||
flow.exit_sku_panel_safely()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _manifest(
|
||||
inspection: DeviceInspection,
|
||||
serial: str,
|
||||
gate1: Gate1Observation,
|
||||
observation: Gate2Observation,
|
||||
screenshot_path: Path,
|
||||
adapter: UiautomatorQuantityGate2Adapter,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"operation": "t105-quantity-gate2",
|
||||
"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": {
|
||||
"gate1_unit_price": observation.gate1_unit_price,
|
||||
"gate2_panel_total_price": observation.gate2_panel_total_price,
|
||||
"max_total_price": observation.max_total_price,
|
||||
},
|
||||
"gate1_evidence": {
|
||||
"captured_at": gate1.captured_at.isoformat(),
|
||||
"screenshot_sha256": _sha256_file(gate1.screenshot_path),
|
||||
},
|
||||
"gate2_evidence": {
|
||||
"path": screenshot_path.name,
|
||||
"sha256": _sha256_file(screenshot_path),
|
||||
},
|
||||
"action_audit": {
|
||||
"increment_attempts": adapter.increment_attempts,
|
||||
"increment_rpc_outcome": adapter.increment_outcome,
|
||||
"back_attempts": adapter.back_attempts,
|
||||
"back_rpc_outcome": adapter.back_outcome,
|
||||
},
|
||||
"safe_exit": "completed",
|
||||
"review_status": "human_review_required",
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
"""T-105 数量两态的纯只读取证;人工确认前不识别或操作数量控件。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from math import isfinite
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
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, CommandRunner, DeviceConnectionError, DeviceInspection
|
||||
from ..device.baseline import (
|
||||
HIERARCHY_PARAMS,
|
||||
PDD_PACKAGE,
|
||||
SCREENSHOT_PARAMS,
|
||||
_save_base64_screenshot,
|
||||
_sha256_file,
|
||||
_validate_hierarchy,
|
||||
)
|
||||
from .product_open import EXPECTED_PDD_VERSION
|
||||
from .product_url import parse_product_url
|
||||
|
||||
|
||||
EXPECTED_GOODS_ID = "937122477375"
|
||||
EXPECTED_DEVICE_MODEL = "PKG110"
|
||||
EXPECTED_ANDROID_VERSION = "16"
|
||||
EXPECTED_SCREEN_SIZE = (1080, 2376)
|
||||
TARGET_SELECTION = {"color": "黑色CHA(纯棉)", "size": "M(建议100-115)"}
|
||||
DECLARED_QUANTITIES = {"initial": 1, "target": 2}
|
||||
|
||||
|
||||
class QuantityGate2EvidenceError(RuntimeError):
|
||||
"""T-105 两态证据未形成完整原子产物。"""
|
||||
|
||||
|
||||
class QuantityGate2EvidenceTimeoutError(QuantityGate2EvidenceError):
|
||||
"""只读取证设备调用超时。"""
|
||||
|
||||
|
||||
class QuantityGate2ReadDevice(Protocol):
|
||||
"""阶段一唯一设备边界;故意不暴露任何页面动作。"""
|
||||
|
||||
def app_info(self, package_name: str) -> dict[str, Any]: ...
|
||||
|
||||
def window_size(self) -> tuple[int, int]: ...
|
||||
|
||||
def jsonrpc_call(self, method: str, params: Any = None, timeout: float = 10) -> Any: ...
|
||||
|
||||
|
||||
class QuantityGate2ForegroundReader(Protocol):
|
||||
"""只读取 Android 16 的唯一 resumed activity,不暴露通用 shell。"""
|
||||
|
||||
def read(self, serial: str) -> dict[str, str]: ...
|
||||
|
||||
|
||||
class Android16TopResumedForegroundReader:
|
||||
"""绕开 adbutils 2.12.0 对 Android 16 ``topResumedActivity`` 的误解析。"""
|
||||
|
||||
_TOP_RESUMED_PATTERN = re.compile(
|
||||
r"(?m)^\s*topResumedActivity=ActivityRecord\{[^\r\n}]*?\s+u\d+\s+"
|
||||
r"(?P<package>[^/\s]+)/(?P<activity>[^\s}]+)\s+t\d+\}\s*$"
|
||||
)
|
||||
|
||||
def __init__(self, runner: CommandRunner, timeout_seconds: float) -> None:
|
||||
if not _is_positive_finite(timeout_seconds):
|
||||
raise ValueError("timeout_seconds 必须是大于 0 的有限数值")
|
||||
self._runner = runner
|
||||
self._timeout_seconds = timeout_seconds
|
||||
|
||||
def read(self, serial: str) -> dict[str, str]:
|
||||
if type(serial) is not str or not serial.strip() or serial != serial.strip():
|
||||
raise QuantityGate2EvidenceError("必须显式提供非空设备通道。")
|
||||
result = self._runner.run(
|
||||
("-s", serial, "shell", "dumpsys", "activity", "activities"),
|
||||
self._timeout_seconds,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise QuantityGate2EvidenceError("Android 前台摘要读取失败,未发布证据。")
|
||||
matches = list(self._TOP_RESUMED_PATTERN.finditer(result.stdout))
|
||||
if len(matches) != 1:
|
||||
raise QuantityGate2EvidenceError("Android 前台摘要不唯一,未发布证据。")
|
||||
match = matches[0]
|
||||
return {
|
||||
"package": match.group("package"),
|
||||
"activity": match.group("activity"),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class QuantityGate2EvidenceResult:
|
||||
output_directory: Path
|
||||
manifest_path: Path
|
||||
screenshot_path: Path
|
||||
hierarchy_path: Path
|
||||
app_path: Path
|
||||
|
||||
|
||||
class QuantityGate2EvidenceCapturer:
|
||||
"""记录人工准备的数量状态,不从页面推断声明是否正确。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
adb_client: AdbClient,
|
||||
connector: Callable[[str], QuantityGate2ReadDevice],
|
||||
foreground_reader: QuantityGate2ForegroundReader,
|
||||
timeout_seconds: float,
|
||||
) -> None:
|
||||
if not _is_positive_finite(timeout_seconds):
|
||||
raise ValueError("timeout_seconds 必须是大于 0 的有限数值")
|
||||
self._adb_client = adb_client
|
||||
self._connector = connector
|
||||
self._foreground_reader = foreground_reader
|
||||
self._timeout_seconds = timeout_seconds
|
||||
self._started = False
|
||||
|
||||
def capture(
|
||||
self,
|
||||
serial: str,
|
||||
goods_id: str,
|
||||
human_declared_state: str,
|
||||
human_declared_quantity: int,
|
||||
output_directory: Path,
|
||||
) -> QuantityGate2EvidenceResult:
|
||||
if self._started:
|
||||
raise QuantityGate2EvidenceError("同一取证器不可重复调用。")
|
||||
self._started = True
|
||||
_validate_inputs(serial, goods_id, human_declared_state, human_declared_quantity)
|
||||
target = Path(output_directory)
|
||||
_validate_new_target(target)
|
||||
|
||||
staging: Path | None = None
|
||||
try:
|
||||
inspection = self._adb_client.inspect(serial)
|
||||
_require_expected_device(inspection)
|
||||
device = self._connector(serial)
|
||||
initial_app = _require_read_precondition(
|
||||
device,
|
||||
self._foreground_reader.read(serial),
|
||||
)
|
||||
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
staging = target.parent / f".{target.name}.staging-{uuid4().hex}"
|
||||
staging.mkdir()
|
||||
|
||||
screenshot_path = staging / "screenshot.png"
|
||||
screenshot_payload = _read_rpc(
|
||||
device,
|
||||
"takeScreenshot",
|
||||
SCREENSHOT_PARAMS,
|
||||
self._timeout_seconds,
|
||||
)
|
||||
if not isinstance(screenshot_payload, str):
|
||||
raise QuantityGate2EvidenceError("数量状态截图无效,未发布证据。")
|
||||
_save_base64_screenshot(screenshot_payload, screenshot_path)
|
||||
_require_screenshot_size(screenshot_path)
|
||||
|
||||
hierarchy = _read_rpc(
|
||||
device,
|
||||
"dumpWindowHierarchy",
|
||||
HIERARCHY_PARAMS,
|
||||
self._timeout_seconds,
|
||||
)
|
||||
_validate_hierarchy(hierarchy)
|
||||
hierarchy_path = staging / "hierarchy.xml"
|
||||
hierarchy_path.write_text(hierarchy, encoding="utf-8")
|
||||
|
||||
final_app = _require_read_precondition(
|
||||
device,
|
||||
self._foreground_reader.read(serial),
|
||||
)
|
||||
if final_app != initial_app:
|
||||
raise QuantityGate2EvidenceError("数量状态取证期间前台页面漂移,未发布证据。")
|
||||
app_path = staging / "app.json"
|
||||
app_path.write_text(
|
||||
json.dumps(final_app, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
manifest_path = staging / "manifest.json"
|
||||
manifest_path.write_text(
|
||||
json.dumps(
|
||||
_manifest(
|
||||
inspection,
|
||||
serial,
|
||||
goods_id,
|
||||
human_declared_state,
|
||||
human_declared_quantity,
|
||||
screenshot_path,
|
||||
hierarchy_path,
|
||||
app_path,
|
||||
),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.rename(staging, target)
|
||||
staging = None
|
||||
except (DeviceConnectionError, QuantityGate2EvidenceError):
|
||||
_clean_staging(staging)
|
||||
raise
|
||||
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
|
||||
_clean_staging(staging)
|
||||
raise QuantityGate2EvidenceTimeoutError("数量两态只读取证超时,未发布证据。") from error
|
||||
except (OSError, UnidentifiedImageError, ValueError) as error:
|
||||
_clean_staging(staging)
|
||||
raise QuantityGate2EvidenceError("数量两态证据无法原子发布,未发布证据。") from error
|
||||
except Exception as error:
|
||||
_clean_staging(staging)
|
||||
raise QuantityGate2EvidenceError("数量两态只读取证未完成,未发布证据。") from error
|
||||
|
||||
return QuantityGate2EvidenceResult(
|
||||
output_directory=target,
|
||||
manifest_path=target / "manifest.json",
|
||||
screenshot_path=target / "screenshot.png",
|
||||
hierarchy_path=target / "hierarchy.xml",
|
||||
app_path=target / "app.json",
|
||||
)
|
||||
|
||||
|
||||
def _is_positive_finite(value: object) -> bool:
|
||||
return (
|
||||
isinstance(value, (int, float))
|
||||
and not isinstance(value, bool)
|
||||
and value > 0
|
||||
and isfinite(value)
|
||||
)
|
||||
|
||||
|
||||
def _validate_inputs(serial: object, goods_id: object, state: object, quantity: object) -> None:
|
||||
if type(serial) is not str or not serial.strip() or serial != serial.strip():
|
||||
raise QuantityGate2EvidenceError("必须显式提供非空设备通道。")
|
||||
if type(goods_id) is not str or goods_id != EXPECTED_GOODS_ID:
|
||||
raise QuantityGate2EvidenceError("商品不是 T-105 已批准取证目标。")
|
||||
parse_product_url(f"https://mobile.yangkeduo.com/goods.html?goods_id={goods_id}")
|
||||
if type(state) is not str or state not in DECLARED_QUANTITIES:
|
||||
raise QuantityGate2EvidenceError("人工声明状态无效。")
|
||||
if type(quantity) is not int or quantity != DECLARED_QUANTITIES[state]:
|
||||
raise QuantityGate2EvidenceError("人工声明数量与批准状态不一致。")
|
||||
|
||||
|
||||
def _validate_new_target(target: Path) -> None:
|
||||
if target.exists() or not target.name:
|
||||
raise QuantityGate2EvidenceError("输出目录必须是不存在的明确新目录。")
|
||||
|
||||
|
||||
def _require_expected_device(inspection: DeviceInspection) -> None:
|
||||
if inspection.model != EXPECTED_DEVICE_MODEL or inspection.android_version != EXPECTED_ANDROID_VERSION:
|
||||
raise QuantityGate2EvidenceError("设备不是已批准取证组合。")
|
||||
|
||||
|
||||
def _require_read_precondition(
|
||||
device: QuantityGate2ReadDevice,
|
||||
current: object,
|
||||
) -> dict[str, str]:
|
||||
info = 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 QuantityGate2EvidenceError("拼多多版本不是已批准取证版本。")
|
||||
if not isinstance(current, dict) or current.get("package") != PDD_PACKAGE:
|
||||
raise QuantityGate2EvidenceError("拼多多不在前台。")
|
||||
activity = current.get("activity")
|
||||
if not isinstance(activity, str) or not activity.strip():
|
||||
raise QuantityGate2EvidenceError("前台应用摘要不完整。")
|
||||
if device.window_size() != EXPECTED_SCREEN_SIZE:
|
||||
raise QuantityGate2EvidenceError("屏幕坐标空间不是已批准尺寸。")
|
||||
return {"package": PDD_PACKAGE, "activity": activity, "pdd_version": EXPECTED_PDD_VERSION}
|
||||
|
||||
|
||||
def _read_rpc(
|
||||
device: QuantityGate2ReadDevice,
|
||||
method: str,
|
||||
params: object,
|
||||
timeout_seconds: float,
|
||||
) -> object:
|
||||
return device.jsonrpc_call(method, params, timeout=timeout_seconds)
|
||||
|
||||
|
||||
def _require_screenshot_size(path: Path) -> None:
|
||||
with Image.open(path) as image:
|
||||
image.load()
|
||||
if image.size != EXPECTED_SCREEN_SIZE or image.format != "PNG":
|
||||
raise QuantityGate2EvidenceError("数量状态截图格式或尺寸无效。")
|
||||
|
||||
|
||||
def _clean_staging(staging: Path | None) -> None:
|
||||
if staging is not None and staging.exists():
|
||||
shutil.rmtree(staging)
|
||||
|
||||
|
||||
def _manifest(
|
||||
inspection: DeviceInspection,
|
||||
serial: str,
|
||||
goods_id: str,
|
||||
state: str,
|
||||
quantity: int,
|
||||
screenshot_path: Path,
|
||||
hierarchy_path: Path,
|
||||
app_path: Path,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"operation": "t105-quantity-gate2-readonly-evidence",
|
||||
"captured_at": datetime.now(UTC).isoformat(),
|
||||
"product": {
|
||||
"goods_id": goods_id,
|
||||
"canonical_url": f"https://mobile.yangkeduo.com/goods.html?goods_id={goods_id}",
|
||||
},
|
||||
"human_declared_state": state,
|
||||
"human_declared_quantity": quantity,
|
||||
"human_declared_selection": TARGET_SELECTION,
|
||||
"review_status": "human_review_required",
|
||||
"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,
|
||||
},
|
||||
"artifacts": [
|
||||
{
|
||||
"path": screenshot_path.name,
|
||||
"role": "quantity_state_raw_screenshot",
|
||||
"sha256": _sha256_file(screenshot_path),
|
||||
},
|
||||
{
|
||||
"path": hierarchy_path.name,
|
||||
"role": "quantity_state_raw_hierarchy_local_only",
|
||||
"sha256": _sha256_file(hierarchy_path),
|
||||
},
|
||||
{
|
||||
"path": app_path.name,
|
||||
"role": "quantity_state_app_identity",
|
||||
"sha256": _sha256_file(app_path),
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
"""T-103 尺码显示动作的一次性真机取证;不属于生产采购 Flow。"""
|
||||
"""T-103 尺码显示动作的一次性真机证据采集。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -20,32 +20,10 @@ from ..device.baseline import PDD_PACKAGE, _save_base64_screenshot, _sha256_file
|
||||
from .product_url import parse_product_url
|
||||
from .sku_selection import (
|
||||
EXPECTED_GOODS_ID,
|
||||
_COLOR_ONLY_SUMMARY,
|
||||
_PANEL_SURFACE,
|
||||
_PanelProfile,
|
||||
_REVEAL_NOT_PROVEN,
|
||||
_S_SIZE_UI,
|
||||
_TARGET_COLOR_UI,
|
||||
_TARGET_SIZE_UI,
|
||||
_action_bounds,
|
||||
_clickable_before,
|
||||
_descendant,
|
||||
_exact_color_action,
|
||||
_exact_inert,
|
||||
_exact_live_layout,
|
||||
_exact_readonly_text,
|
||||
_exact_recycler,
|
||||
_is_size_action_text,
|
||||
_one,
|
||||
_parse_nodes,
|
||||
_require_color_only_panel,
|
||||
_require_color_action_chain,
|
||||
_require_color_subtree,
|
||||
_require_exact_selected_set,
|
||||
_require_panel_chain,
|
||||
_require_size_wrapper,
|
||||
_require_size_action_chain,
|
||||
_spec_for,
|
||||
_require_safe_reveal_path,
|
||||
_revealed_unselected_projection,
|
||||
resolve_task_selection,
|
||||
SkuSelectionError,
|
||||
SkuSelectionFlow,
|
||||
@@ -63,45 +41,54 @@ from .sku_selection_runner import (
|
||||
|
||||
|
||||
_TARGET_URL = f"https://mobile.yangkeduo.com/goods.html?goods_id={EXPECTED_GOODS_ID}"
|
||||
_REVEAL_START = (360, 1900)
|
||||
_REVEAL_END = (360, 1300)
|
||||
_REVEAL_STEPS = 30
|
||||
_REVEAL_FAILURE_STAGES = frozenset(
|
||||
(
|
||||
"reveal_precondition",
|
||||
"reveal_attempted",
|
||||
"reveal_candidate",
|
||||
"reveal_after",
|
||||
"reveal_publish",
|
||||
)
|
||||
)
|
||||
_REVEAL_FAILURE_MARKER = object()
|
||||
|
||||
|
||||
class SkuRevealSpikeError(RuntimeError):
|
||||
"""一次性 reveal 取证未形成可发布证据。"""
|
||||
|
||||
|
||||
def _annotate_reveal_failure(error: BaseException, stage: str) -> None:
|
||||
"""只记录本模块实际走到的固定阶段;入口阶段使用独立 marker,不会被覆盖。"""
|
||||
|
||||
if type(stage) is not str or stage not in _REVEAL_FAILURE_STAGES:
|
||||
return
|
||||
try:
|
||||
setattr(error, "_cmbuyer_reveal_failure_stage", stage)
|
||||
# marker 最后写入,任一 setter 失败都不能形成可信阶段。
|
||||
setattr(error, "_cmbuyer_reveal_failure_marker", _REVEAL_FAILURE_MARKER)
|
||||
except BaseException:
|
||||
pass
|
||||
|
||||
|
||||
def safe_reveal_failure_stage(error: BaseException) -> str | None:
|
||||
"""读取可公开的 reveal 控制流阶段;伪造属性或 hostile getter 均失败闭合。"""
|
||||
|
||||
try:
|
||||
marker = getattr(error, "_cmbuyer_reveal_failure_marker", None)
|
||||
stage = getattr(error, "_cmbuyer_reveal_failure_stage", None)
|
||||
if marker is not _REVEAL_FAILURE_MARKER or type(stage) is not str:
|
||||
return None
|
||||
return stage if stage in _REVEAL_FAILURE_STAGES else None
|
||||
except BaseException:
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SkuRevealSpikeResult:
|
||||
output_directory: Path
|
||||
manifest_path: Path
|
||||
|
||||
|
||||
class _RevealEvidenceAdapter(UiautomatorSkuPanelAdapter):
|
||||
"""只为 spike 增加一个无参数、固定 profile 的一次性手势。"""
|
||||
|
||||
def __init__(self, device: Any, timeout_seconds: float) -> None:
|
||||
super().__init__(device, timeout_seconds)
|
||||
self._reveal_attempted = False
|
||||
|
||||
@property
|
||||
def reveal_attempted(self) -> bool:
|
||||
return self._reveal_attempted
|
||||
|
||||
def reveal_size_options_once(self) -> None:
|
||||
if self._reveal_attempted:
|
||||
raise SkuRevealSpikeError("规格显示动作已经尝试过,拒绝重试。")
|
||||
# RPC 超时也可能表示手势已经送达,必须在调用前封存唯一机会。
|
||||
self._reveal_attempted = True
|
||||
self._call(
|
||||
"jsonrpc_call",
|
||||
"swipe",
|
||||
[*_REVEAL_START, *_REVEAL_END, _REVEAL_STEPS],
|
||||
timeout=self._timeout_seconds,
|
||||
)
|
||||
|
||||
|
||||
class SkuRevealSpikeCapturer:
|
||||
"""打开已取证面板、选一次目标颜色,再采集唯一 reveal 的前后证据。"""
|
||||
|
||||
@@ -127,18 +114,20 @@ class SkuRevealSpikeCapturer:
|
||||
goods_id: str,
|
||||
output_directory: Path,
|
||||
) -> SkuRevealSpikeResult:
|
||||
stage = "reveal_precondition"
|
||||
staging: Path | None = None
|
||||
adapter: UiautomatorSkuPanelAdapter | None = None
|
||||
try:
|
||||
if type(goods_id) is not str or goods_id != EXPECTED_GOODS_ID:
|
||||
raise SkuRevealSpikeError("商品不是 T-103 已取证目标,已停止取证。")
|
||||
link = parse_product_url(_TARGET_URL)
|
||||
target = Path(output_directory)
|
||||
_validate_new_target(target)
|
||||
staging: Path | None = None
|
||||
try:
|
||||
staging = _prepare_staging(target)
|
||||
deadline = self._clock() + self._timeout_seconds
|
||||
inspection = self._adb_client.inspect(serial)
|
||||
_require_expected_device(inspection)
|
||||
adapter = _RevealEvidenceAdapter(self._connector(serial), self._timeout_seconds)
|
||||
adapter = UiautomatorSkuPanelAdapter(self._connector(serial), self._timeout_seconds)
|
||||
_require_expected_version(adapter.app_info(PDD_PACKAGE))
|
||||
if adapter.display_size() != EXPECTED_SCREEN_SIZE:
|
||||
raise SkuRevealSpikeError("设备不是已取证的竖屏坐标空间,已停止取证。")
|
||||
@@ -155,13 +144,10 @@ class SkuRevealSpikeCapturer:
|
||||
sleep_function=self._sleep,
|
||||
)
|
||||
flow.open_sku_panel(link.canonical_url, pre_intent)
|
||||
try:
|
||||
flow.select_sku_options(resolve_task_selection("黑色CHA(纯棉)", "M(建议100-115)"))
|
||||
except SkuSelectionError as error:
|
||||
if error.args != (_REVEAL_NOT_PROVEN,):
|
||||
raise
|
||||
else:
|
||||
raise SkuRevealSpikeError("规格流程未停在已取证的仅颜色状态。")
|
||||
# spike 只复用生产 Flow 的私有前置准备,不调用完整选择流程,避免重复 reveal/M。
|
||||
flow._prepare_reveal_precondition(
|
||||
resolve_task_selection("黑色CHA(纯棉)", "M(建议100-115)")
|
||||
)
|
||||
|
||||
before_hierarchy = adapter.dump_window_hierarchy()
|
||||
before_nodes = _parse_nodes(before_hierarchy)
|
||||
@@ -180,19 +166,24 @@ class SkuRevealSpikeCapturer:
|
||||
(staging / "before" / "hierarchy.xml").write_text(before_hierarchy, encoding="utf-8")
|
||||
|
||||
rpc_outcome = "completed"
|
||||
# 此后即属于 attempted:adapter 会在 RPC 前封存唯一机会,结果不明也只能调和。
|
||||
stage = "reveal_attempted"
|
||||
try:
|
||||
adapter.reveal_size_options_once()
|
||||
except SkuSelectionRunError:
|
||||
rpc_outcome = "ambiguous_reconciled"
|
||||
|
||||
stage = "reveal_candidate"
|
||||
projection, after_hierarchy = self._wait_for_candidate(adapter, deadline)
|
||||
stage = "reveal_after"
|
||||
after_directory = staging / "after"
|
||||
_capture_frame(adapter, after_directory, after_hierarchy)
|
||||
reverified = adapter.dump_window_hierarchy()
|
||||
if _candidate_projection(_parse_nodes(reverified)) != projection:
|
||||
if _revealed_unselected_projection(_parse_nodes(reverified)) != projection:
|
||||
raise SkuRevealSpikeError("截图后候选状态漂移,未发布证据。")
|
||||
(after_directory / "hierarchy.xml").write_text(reverified, encoding="utf-8")
|
||||
|
||||
stage = "reveal_publish"
|
||||
manifest = _manifest(inspection, serial, rpc_outcome, staging)
|
||||
manifest_path = staging / "manifest.json"
|
||||
manifest_path.write_text(
|
||||
@@ -201,18 +192,28 @@ class SkuRevealSpikeCapturer:
|
||||
)
|
||||
os.rename(staging, target)
|
||||
staging = None
|
||||
except (DeviceConnectionError, SkuSelectionError, SkuSelectionRunError, SkuRevealSpikeError):
|
||||
except (DeviceConnectionError, SkuSelectionError, SkuSelectionRunError, SkuRevealSpikeError) as error:
|
||||
_clean_staging(staging)
|
||||
failure_stage = stage
|
||||
if stage == "reveal_attempted" and (adapter is None or not adapter.reveal_attempted):
|
||||
# attempted 只能在 adapter 已于 RPC 前封存唯一机会后成立。
|
||||
failure_stage = "reveal_precondition"
|
||||
_annotate_reveal_failure(error, failure_stage)
|
||||
raise
|
||||
except Exception as error:
|
||||
_clean_staging(staging)
|
||||
raise SkuRevealSpikeError("规格 reveal 取证未完成,未发布本地证据目录。") from error
|
||||
mapped = SkuRevealSpikeError("规格 reveal 取证未完成,未发布本地证据目录。")
|
||||
failure_stage = stage
|
||||
if stage == "reveal_attempted" and (adapter is None or not adapter.reveal_attempted):
|
||||
failure_stage = "reveal_precondition"
|
||||
_annotate_reveal_failure(mapped, failure_stage)
|
||||
raise mapped from error
|
||||
|
||||
return SkuRevealSpikeResult(target, target / "manifest.json")
|
||||
|
||||
def _wait_for_candidate(
|
||||
self,
|
||||
adapter: _RevealEvidenceAdapter,
|
||||
adapter: UiautomatorSkuPanelAdapter,
|
||||
deadline: float,
|
||||
) -> tuple[tuple[tuple[str, ...], ...], str]:
|
||||
stable: tuple[tuple[str, ...], ...] | None = None
|
||||
@@ -223,7 +224,7 @@ class SkuRevealSpikeCapturer:
|
||||
raise SkuRevealSpikeError("reveal 后拼多多不在前台,未发布证据。")
|
||||
hierarchy = adapter.dump_window_hierarchy()
|
||||
try:
|
||||
projection = _candidate_projection(_parse_nodes(hierarchy))
|
||||
projection = _revealed_unselected_projection(_parse_nodes(hierarchy))
|
||||
except SkuSelectionError:
|
||||
projection = None
|
||||
if projection is not None and projection == stable:
|
||||
@@ -235,137 +236,7 @@ class SkuRevealSpikeCapturer:
|
||||
self._sleep(min(0.2, remaining))
|
||||
|
||||
|
||||
def _require_safe_reveal_path(nodes: list[Any]) -> None:
|
||||
# 当前真机证据证明 x=360 是两列规格卡之间的空隙;必须验证完整线段,离散采样会漏掉窄浮层。
|
||||
surface = _one(
|
||||
[node for node in nodes if _exact_inert(node, "android.view.ViewGroup", _PANEL_SURFACE)],
|
||||
"固定 reveal 通道无法绑定面板内容面。",
|
||||
)
|
||||
_require_panel_chain(surface)
|
||||
content = surface.parent
|
||||
action_root = content.parent if content is not None else None
|
||||
action_parent = action_root.parent if action_root is not None else None
|
||||
if (
|
||||
content is None
|
||||
or action_root is None
|
||||
or action_parent is None
|
||||
or not _exact_live_layout(action_root, "android.view.ViewGroup", "[0,366][1080,2328]")
|
||||
or not _exact_live_layout(action_parent, "android.widget.LinearLayout", "[0,120][1080,2328]")
|
||||
):
|
||||
raise SkuRevealSpikeError("固定 reveal 通道祖先身份漂移,未执行手势。")
|
||||
allowed = {id(action_root.element), id(action_parent.element)}
|
||||
occupants: set[int] = set()
|
||||
for node in nodes:
|
||||
if node.element.get("clickable") != "true":
|
||||
continue
|
||||
try:
|
||||
left, top, right, bottom = _action_bounds(node.bounds)
|
||||
except SkuSelectionError as error:
|
||||
raise SkuRevealSpikeError("可点击节点坐标不可验证,未执行手势。") from error
|
||||
if (
|
||||
left <= _REVEAL_START[0] < right
|
||||
and top <= _REVEAL_START[1]
|
||||
and bottom > _REVEAL_END[1]
|
||||
):
|
||||
occupants.add(id(node.element))
|
||||
if occupants != allowed:
|
||||
raise SkuRevealSpikeError("固定 reveal 通道被未取证可点击节点占用,未执行手势。")
|
||||
if _REVEAL_START[1] >= 2079 or _REVEAL_END[1] >= 2079:
|
||||
raise SkuRevealSpikeError("固定 reveal 通道越过规格内容区,未执行手势。")
|
||||
|
||||
|
||||
def _candidate_projection(nodes: list[Any]) -> tuple[tuple[str, ...], ...]:
|
||||
surface = _one(
|
||||
[node for node in nodes if _exact_inert(node, "android.view.ViewGroup", _PANEL_SURFACE)],
|
||||
"候选面板内容面不唯一。",
|
||||
)
|
||||
_require_panel_chain(surface)
|
||||
header = _one(
|
||||
[node for node in nodes if node.parent is surface and _exact_inert(node, "android.widget.LinearLayout", "[0,366][1080,1000]")],
|
||||
"候选面板头部不唯一。",
|
||||
)
|
||||
outer = _one(
|
||||
[node for node in nodes if node.parent is surface and _exact_recycler(node, "[0,1000][1080,2079]")],
|
||||
"候选维度容器不唯一。",
|
||||
)
|
||||
price_row = _one(
|
||||
[node for node in nodes if _descendant(node, header) and _exact_inert(node, "android.widget.LinearLayout", "[396,498][895,570]")],
|
||||
"候选价格行不唯一。",
|
||||
)
|
||||
current = _one(
|
||||
[node for node in nodes if node.parent is price_row and _exact_readonly_text(node, "快卖完 ¥12.88", "[396,503][712,570]")],
|
||||
"候选当前价不唯一。",
|
||||
)
|
||||
original = _one(
|
||||
[node for node in nodes if node.parent is price_row and _exact_readonly_text(node, "¥29.88", "[730,503][895,570]")],
|
||||
"候选原价不唯一。",
|
||||
)
|
||||
if _clickable_before(current, surface):
|
||||
raise SkuSelectionError("候选当前价位于可点击内容祖先下。")
|
||||
summary = _one(
|
||||
[node for node in nodes if _descendant(node, header) and _exact_readonly_text(node, _COLOR_ONLY_SUMMARY, "[396,654][1053,716]")],
|
||||
"候选摘要不唯一。",
|
||||
)
|
||||
color_region = _one(
|
||||
[node for node in nodes if _descendant(node, outer) and _exact_recycler(node, "[36,1000][1080,1483]")],
|
||||
"候选颜色容器不唯一。",
|
||||
)
|
||||
color = _one(
|
||||
[node for node in nodes if node.parent is color_region and _exact_color_action(node, "[372,1000][684,1024]", True)],
|
||||
"候选目标颜色不唯一。",
|
||||
)
|
||||
rolled_spec = _spec_for(_PanelProfile.TARGETS_SELECTED)
|
||||
selected_color_nodes = _require_color_subtree(color, rolled_spec)
|
||||
_require_color_action_chain(color, color_region, outer, surface, rolled_spec)
|
||||
size_label = _one(
|
||||
[node for node in nodes if _descendant(node, outer) and _exact_readonly_text(node, "尺码", "[36,1506][114,1552]")],
|
||||
"候选尺码标题不唯一。",
|
||||
)
|
||||
size_header = size_label.parent
|
||||
if size_header is None or not _exact_live_layout(size_header, "android.widget.LinearLayout", "[36,1489][1044,1570]"):
|
||||
raise SkuSelectionError("候选尺码标题父结构漂移。")
|
||||
size_options = _one(
|
||||
[node for node in nodes if _descendant(node, outer) and _exact_inert(node, "android.view.ViewGroup", "[36,1582][1044,1897]")],
|
||||
"候选尺码 options 根不唯一。",
|
||||
)
|
||||
actions = [node for node in nodes if _descendant(node, size_options) and _is_size_action_text(node)]
|
||||
s_action = _one(
|
||||
[node for node in actions if node.text == _S_SIZE_UI and node.bounds == "[36,1582][409,1667]"],
|
||||
"候选 S action 不唯一。",
|
||||
)
|
||||
m_action = _one(
|
||||
[node for node in actions if node.text == _TARGET_SIZE_UI and node.bounds == "[439,1582][831,1667]"],
|
||||
"候选 M action 不唯一。",
|
||||
)
|
||||
_require_size_wrapper(s_action, size_options)
|
||||
_require_size_wrapper(m_action, size_options)
|
||||
_require_size_action_chain(s_action, size_options, outer, surface)
|
||||
_require_size_action_chain(m_action, size_options, outer, surface)
|
||||
if any(node.element.get("selected") == "true" for node in actions):
|
||||
raise SkuSelectionError("reveal 候选态已有尺码被选中。")
|
||||
_require_exact_selected_set(nodes, surface, selected_color_nodes)
|
||||
dangerous = [
|
||||
node for node in nodes
|
||||
if "提交订单" in node.text
|
||||
and node.element.get("package") == PDD_PACKAGE
|
||||
and node.element.get("class") == "android.widget.TextView"
|
||||
]
|
||||
if len(dangerous) != 1 or _action_bounds(dangerous[0].bounds)[1] < 2079:
|
||||
raise SkuSelectionError("提交硬拒绝区位置不唯一。")
|
||||
return tuple(
|
||||
(
|
||||
node.element.get("class", ""),
|
||||
node.bounds,
|
||||
node.text,
|
||||
node.desc,
|
||||
node.element.get("selected", ""),
|
||||
node.element.get("clickable", ""),
|
||||
)
|
||||
for node in (surface, header, outer, price_row, current, original, summary, color_region, color, size_label, size_options, s_action, m_action, dangerous[0])
|
||||
)
|
||||
|
||||
|
||||
def _capture_frame(adapter: _RevealEvidenceAdapter, directory: Path, hierarchy: str) -> None:
|
||||
def _capture_frame(adapter: UiautomatorSkuPanelAdapter, directory: Path, hierarchy: str) -> None:
|
||||
directory.mkdir()
|
||||
hierarchy_path = directory / "hierarchy.xml"
|
||||
hierarchy_path.write_text(hierarchy, encoding="utf-8")
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from functools import partial
|
||||
import re
|
||||
from time import monotonic, sleep
|
||||
from typing import Any, Callable, Protocol
|
||||
@@ -17,6 +18,9 @@ EXPECTED_UNIT_PRICE = "12.88"
|
||||
# 任务值不是页面判据;右侧是 v5 取证的唯一 accessibility 文案(空格/全角括号均有意义)。
|
||||
TASK_TO_UI_SELECTION = {("黑色CHA(纯棉)", "M(建议100-115)"): ("黑色 CHA (纯棉)", "M(建议100-115)")}
|
||||
_TARGET_COLOR_UI, _TARGET_SIZE_UI = next(iter(TASK_TO_UI_SELECTION.values()))
|
||||
_TARGET_COLOR_UNROLLED_BOUNDS = "[372,1188][684,1587]"
|
||||
_TARGET_COLOR_ROLLED_ALT_BOUNDS = "[126,1000][438,1024]"
|
||||
_TARGET_SIZE_BOUNDS = "[439,1582][831,1667]"
|
||||
_ENTRY = "快要抢光 ¥ 12.88"
|
||||
_ENTRY_PROMOTION_LABEL = "快要抢光"
|
||||
_ENTRY_TEXT_BOUNDS = "[688,2184][1042,2253]"
|
||||
@@ -24,6 +28,12 @@ _ENTRY_ACTION_DESC = "快要抢光¥12.88"
|
||||
_ENTRY_ACTION_BOUNDS = "[446,2166][1080,2328]"
|
||||
_ENTRY_SIBLING = "免拼购买"
|
||||
_ENTRY_SIBLING_BOUNDS = "[688,2256][856,2305]"
|
||||
_PRODUCT_TITLE_DESC = "2026年新款高档重工潮流烫钻中长款T恤显瘦宽松上衣淡人穿搭"
|
||||
_PRODUCT_TITLE_FIRST_LINE = "2026年新款高档重工潮流烫钻中长款T恤显瘦宽松"
|
||||
_PRODUCT_TITLE_SECOND_LINE = "上衣淡人穿搭"
|
||||
_PRODUCT_TITLE_RESOURCE_ID = "com.xunmeng.pinduoduo:id/tv_title"
|
||||
_PRODUCT_TITLE_BOUNDS = "[36,1571][1044,1689]"
|
||||
_DANGEROUS_EXIT_ACTION_TERMS = ("提交订单", "确认订单", "立即支付", "去支付", "付款")
|
||||
_FORBIDDEN_ENTRY_ACTION_DESC = (
|
||||
"购买", "下单", "付款", "订单", "单独购买", "直接拼成", "提交订单", "支付",
|
||||
"先用后付", "0元下单", "0 元下单",
|
||||
@@ -36,10 +46,11 @@ _COLOR_ONLY_SUMMARY = "请选择: 尺码"
|
||||
_S_SIZE_UI = "S(建议80-100)"
|
||||
_S_SUMMARY = f"已选: {_TARGET_COLOR_UI} {_S_SIZE_UI}"
|
||||
_TARGET_SUMMARY = f"已选: {_TARGET_COLOR_UI} {_TARGET_SIZE_UI}"
|
||||
_REVEAL_NOT_PROVEN = "尺码仍在已取证视口外;受控显示动作尚未取证,已停止后续点击。"
|
||||
_BOUNDS = re.compile(r"^\[(\d+),(\d+)\]\[(\d+),(\d+)\]$")
|
||||
_ROLLED_CURRENT_PRICE = "快卖完 ¥12.88"
|
||||
_ROLLED_ORIGINAL_PRICE = "¥29.88"
|
||||
_REVEALED_CURRENT_PRICE = "限1件 ¥12.88 "
|
||||
_REVEAL_GESTURE = (360, 1900, 360, 1300, 30)
|
||||
_BAD_PRICE_ROLE = ("提交订单", "支付", "优惠", "券", "会员", "补贴", "区间", "实付", "到手", "原价", "划线价", "最低", "低至", "起价", "下单", "先用后付", "预估")
|
||||
|
||||
|
||||
@@ -90,6 +101,7 @@ class SkuPanelDevice(Protocol):
|
||||
def dump_window_hierarchy(self) -> str: ...
|
||||
def tap_sku_entry(self, bounds: str) -> None: ...
|
||||
def tap_sku_option(self, bounds: str) -> None: ...
|
||||
def reveal_size_options_once(self) -> None: ...
|
||||
def leave_sku_panel(self) -> None: ...
|
||||
|
||||
|
||||
@@ -121,20 +133,38 @@ class _Node:
|
||||
class _PanelProfile(Enum):
|
||||
PANEL_OPEN_EMPTY = "panel_open_empty"
|
||||
COLOR_SELECTED_SIZE_HIDDEN = "color_selected_size_hidden"
|
||||
SIZE_VISIBLE_UNSELECTED = "size_visible_unselected"
|
||||
SIZE_VISIBLE_NON_TARGET = "size_visible_non_target"
|
||||
TARGETS_SELECTED = "targets_selected"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _DualPriceLayout:
|
||||
row_bounds: str
|
||||
current_text: str
|
||||
current_bounds: str
|
||||
original_text: str
|
||||
original_bounds: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _CurrentOnlyPriceLayout:
|
||||
# 同一 App 版本会把原价整段移除;这是独立证据布局,不是“原价可选”。
|
||||
frame_bounds: str
|
||||
row_bounds: str
|
||||
current_text: str
|
||||
current_bounds: str
|
||||
|
||||
|
||||
_PriceLayout = _DualPriceLayout | _CurrentOnlyPriceLayout
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _PanelSpec:
|
||||
profile: _PanelProfile
|
||||
header_bounds: str
|
||||
outer_bounds: str
|
||||
price_row_bounds: str
|
||||
current_text: str
|
||||
current_bounds: str
|
||||
original_text: str
|
||||
original_bounds: str
|
||||
price_layout: _PriceLayout
|
||||
summary_text: str
|
||||
summary_bounds: str
|
||||
color_label_bounds: str | None
|
||||
@@ -149,26 +179,54 @@ _PANEL_SPECS = (
|
||||
_PanelSpec(
|
||||
_PanelProfile.PANEL_OPEN_EMPTY,
|
||||
"[0,366][1080,1077]", "[0,1077][1080,2079]",
|
||||
_DualPriceLayout(
|
||||
"[396,575][912,647]", "限1件 ¥12.88 ", "[396,580][675,647]",
|
||||
"券前¥29.88", "[693,580][912,647]",
|
||||
),
|
||||
_EMPTY_SUMMARY, "[396,731][1053,793]", "[36,1106][192,1159]",
|
||||
"[36,1188][1080,2046]", "[372,1188][684,1587]", False,
|
||||
"[36,1188][1080,2046]", _TARGET_COLOR_UNROLLED_BOUNDS, False,
|
||||
"[36,2069][114,2079]", None,
|
||||
),
|
||||
_PanelSpec(
|
||||
_PanelProfile.COLOR_SELECTED_SIZE_HIDDEN,
|
||||
"[0,366][1080,1077]", "[0,1077][1080,2079]",
|
||||
_DualPriceLayout(
|
||||
"[396,575][912,647]", "限1件 ¥12.88 ", "[396,580][675,647]",
|
||||
"券前¥29.88", "[693,580][912,647]",
|
||||
),
|
||||
_COLOR_ONLY_SUMMARY, "[396,731][1053,793]", "[36,1106][192,1159]",
|
||||
"[36,1188][1080,2046]", "[372,1188][684,1587]", True,
|
||||
"[36,1188][1080,2046]", _TARGET_COLOR_UNROLLED_BOUNDS, True,
|
||||
"[36,2069][114,2079]", None,
|
||||
),
|
||||
_PanelSpec(
|
||||
_PanelProfile.PANEL_OPEN_EMPTY,
|
||||
"[0,366][1080,1077]", "[0,1077][1080,2079]",
|
||||
_CurrentOnlyPriceLayout(
|
||||
"[396,575][1053,647]", "[396,575][693,647]",
|
||||
"限1件 ¥12.88 ", "[396,580][675,647]",
|
||||
),
|
||||
_EMPTY_SUMMARY, "[396,731][1053,793]", "[36,1106][192,1159]",
|
||||
"[36,1188][1080,2046]", _TARGET_COLOR_UNROLLED_BOUNDS, False,
|
||||
"[36,2069][114,2079]", None,
|
||||
),
|
||||
_PanelSpec(
|
||||
_PanelProfile.COLOR_SELECTED_SIZE_HIDDEN,
|
||||
"[0,366][1080,1077]", "[0,1077][1080,2079]",
|
||||
_CurrentOnlyPriceLayout(
|
||||
"[396,575][1053,647]", "[396,575][693,647]",
|
||||
"限1件 ¥12.88 ", "[396,580][675,647]",
|
||||
),
|
||||
_COLOR_ONLY_SUMMARY, "[396,731][1053,793]", "[36,1106][192,1159]",
|
||||
"[36,1188][1080,2046]", _TARGET_COLOR_UNROLLED_BOUNDS, True,
|
||||
"[36,2069][114,2079]", None,
|
||||
),
|
||||
_PanelSpec(
|
||||
_PanelProfile.SIZE_VISIBLE_NON_TARGET,
|
||||
"[0,366][1080,1000]", "[0,1000][1080,2079]",
|
||||
_DualPriceLayout(
|
||||
"[396,498][895,570]", _ROLLED_CURRENT_PRICE, "[396,503][712,570]",
|
||||
_ROLLED_ORIGINAL_PRICE, "[730,503][895,570]",
|
||||
),
|
||||
_S_SUMMARY, "[396,654][1053,716]", None,
|
||||
"[36,1000][1080,1483]", "[372,1000][684,1024]", True,
|
||||
"[36,1506][114,1552]", _S_SIZE_UI,
|
||||
@@ -176,12 +234,40 @@ _PANEL_SPECS = (
|
||||
_PanelSpec(
|
||||
_PanelProfile.TARGETS_SELECTED,
|
||||
"[0,366][1080,1000]", "[0,1000][1080,2079]",
|
||||
_DualPriceLayout(
|
||||
"[396,498][895,570]", _ROLLED_CURRENT_PRICE, "[396,503][712,570]",
|
||||
_ROLLED_ORIGINAL_PRICE, "[730,503][895,570]",
|
||||
),
|
||||
_TARGET_SUMMARY, "[396,654][1053,716]", None,
|
||||
"[36,1000][1080,1483]", "[372,1000][684,1024]", True,
|
||||
"[36,1506][114,1552]", _TARGET_SIZE_UI,
|
||||
),
|
||||
# T-104 真机证据证明:人工重新打开已选面板时,颜色横向列表可把同一目标卡片精确滚到
|
||||
# 左侧;只增加该证据坐标的完整 profile,不放宽文字、selected、价格或其余结构。
|
||||
_PanelSpec(
|
||||
_PanelProfile.TARGETS_SELECTED,
|
||||
"[0,366][1080,1000]", "[0,1000][1080,2079]",
|
||||
_DualPriceLayout(
|
||||
"[396,498][895,570]", _ROLLED_CURRENT_PRICE, "[396,503][712,570]",
|
||||
_ROLLED_ORIGINAL_PRICE, "[730,503][895,570]",
|
||||
),
|
||||
_TARGET_SUMMARY, "[396,654][1053,716]", None,
|
||||
"[36,1000][1080,1483]", _TARGET_COLOR_ROLLED_ALT_BOUNDS, True,
|
||||
"[36,1506][114,1552]", _TARGET_SIZE_UI,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
_REVEALED_UNSELECTED_SPEC = _PanelSpec(
|
||||
_PanelProfile.SIZE_VISIBLE_UNSELECTED,
|
||||
"[0,366][1080,1000]", "[0,1000][1080,2079]",
|
||||
_CurrentOnlyPriceLayout(
|
||||
"[396,498][1053,570]", "[396,498][693,570]",
|
||||
_REVEALED_CURRENT_PRICE, "[396,503][675,570]",
|
||||
),
|
||||
_COLOR_ONLY_SUMMARY, "[396,654][1053,716]", None,
|
||||
"[36,1000][1080,1483]", "[372,1000][684,1024]", True,
|
||||
"[36,1506][114,1552]", None,
|
||||
)
|
||||
|
||||
|
||||
@@ -193,9 +279,12 @@ class SkuSelectionFlow:
|
||||
raise ValueError("入口等待参数无效。")
|
||||
self._device, self._entry_timeout, self._poll = device, entry_wait_timeout_seconds, entry_poll_interval_seconds
|
||||
self._clock, self._sleep = monotonic_clock, sleep_function
|
||||
self._pending: tuple[str, Callable[[list[_Node]], Any]] | None = None
|
||||
self._pending: tuple[str, Callable[[list[_Node]], Any], bool] | None = None
|
||||
self._terminal = False
|
||||
self._color_selected_by_flow = False
|
||||
|
||||
def open_sku_panel(self, product_url: str, pre_intent_hierarchy: str | None = None) -> None:
|
||||
self._require_active()
|
||||
try:
|
||||
if parse_product_url(product_url).goods_id != EXPECTED_GOODS_ID:
|
||||
raise SkuSelectionError("商品不是已取证目标,已停止操作。")
|
||||
@@ -217,84 +306,181 @@ class SkuSelectionFlow:
|
||||
raise
|
||||
|
||||
try:
|
||||
self._pending = (before, _require_empty_panel)
|
||||
self._pending = (before, _require_empty_panel, False)
|
||||
self._device.tap_sku_entry(entry.bounds)
|
||||
except BaseException as error:
|
||||
self._terminal = True
|
||||
_annotate_sku_entry_failure(error, "sku_entry_click")
|
||||
raise
|
||||
|
||||
try:
|
||||
self._wait_after_action(before, _require_empty_panel)
|
||||
except BaseException as error:
|
||||
self._terminal = True
|
||||
_annotate_sku_entry_failure(error, "sku_entry_panel_verify")
|
||||
raise
|
||||
|
||||
def select_sku_options(self, selection: SkuSelection) -> None:
|
||||
self._require_active()
|
||||
if selection != SkuSelection(_TARGET_COLOR_UI, _TARGET_SIZE_UI):
|
||||
raise SkuSelectionError("规格 UI 文案不是获准目标,已停止操作。")
|
||||
nodes = self._verified_nodes()
|
||||
initial_profile = _classify_panel(nodes).profile
|
||||
if initial_profile is _PanelProfile.TARGETS_SELECTED:
|
||||
return
|
||||
if initial_profile is _PanelProfile.SIZE_VISIBLE_NON_TARGET:
|
||||
self._require_foreground()
|
||||
before = self._read_hierarchy()
|
||||
nodes = _parse_nodes(before)
|
||||
target = _target_size_action(nodes, selected=False)
|
||||
_action_bounds(target.bounds)
|
||||
_require_action_occupants(nodes, target)
|
||||
self._perform_mutation(
|
||||
before,
|
||||
_require_target_panel,
|
||||
partial(self._device.tap_sku_option, target.bounds),
|
||||
)
|
||||
return
|
||||
|
||||
self._prepare_reveal_precondition(selection)
|
||||
self._require_foreground()
|
||||
before = self._read_hierarchy()
|
||||
nodes = _parse_nodes(before)
|
||||
spec = _require_profile(nodes, _PanelProfile.COLOR_SELECTED_SIZE_HIDDEN)
|
||||
if not isinstance(spec.price_layout, _CurrentOnlyPriceLayout):
|
||||
# 真机动作证据只覆盖 current-only;dual 仅颜色态必须在 reveal 前零动作停止。
|
||||
raise SkuSelectionError("规格显示前置不是已取证单价格布局,已停止操作。")
|
||||
_require_safe_reveal_path(nodes)
|
||||
self._perform_mutation(
|
||||
before,
|
||||
_revealed_unselected_projection,
|
||||
self._device.reveal_size_options_once,
|
||||
require_stable=True,
|
||||
)
|
||||
|
||||
self._require_foreground()
|
||||
before = self._read_hierarchy()
|
||||
nodes = _parse_nodes(before)
|
||||
_revealed_unselected_projection(nodes)
|
||||
target = _target_size_action(nodes, selected=False)
|
||||
_action_bounds(target.bounds)
|
||||
_require_action_occupants(nodes, target)
|
||||
self._perform_mutation(
|
||||
before,
|
||||
_require_target_panel,
|
||||
partial(self._device.tap_sku_option, target.bounds),
|
||||
)
|
||||
|
||||
def _prepare_reveal_precondition(self, selection: SkuSelection) -> None:
|
||||
"""只把 current-only 初态推进到仅颜色态,供生产 Flow 与证据 spike 共用。"""
|
||||
|
||||
self._require_active()
|
||||
if selection != SkuSelection(_TARGET_COLOR_UI, _TARGET_SIZE_UI):
|
||||
raise SkuSelectionError("规格 UI 文案不是获准目标,已停止操作。")
|
||||
self._require_foreground()
|
||||
before = self._read_hierarchy()
|
||||
nodes = _parse_nodes(before)
|
||||
profile = _classify_panel(nodes)
|
||||
|
||||
if profile is _PanelProfile.PANEL_OPEN_EMPTY:
|
||||
spec = _classify_panel(nodes)
|
||||
if spec.profile is _PanelProfile.COLOR_SELECTED_SIZE_HIDDEN:
|
||||
if not isinstance(spec.price_layout, _CurrentOnlyPriceLayout):
|
||||
raise SkuSelectionError("规格显示前置不是已取证单价格布局,已停止操作。")
|
||||
if not self._color_selected_by_flow:
|
||||
raise SkuSelectionError("目标颜色不是本次 Flow 精确选中,未执行 reveal。")
|
||||
return
|
||||
if spec.profile is not _PanelProfile.PANEL_OPEN_EMPTY or not isinstance(
|
||||
spec.price_layout, _CurrentOnlyPriceLayout
|
||||
):
|
||||
raise SkuSelectionError("规格面板不属于获准的单价格初态,已停止操作。")
|
||||
target = _target_color_action(nodes, selected=False)
|
||||
_action_bounds(target.bounds)
|
||||
_require_action_occupants(nodes, target)
|
||||
self._pending = (before, _require_color_only_panel)
|
||||
self._device.tap_sku_option(target.bounds)
|
||||
self._wait_after_action(before, _require_color_only_panel)
|
||||
# 当前证据只证明颜色选择;尺码仍在视口外。没有动作证据时必须在此停住,
|
||||
# 不能把一次通用 swipe 或下一次规格点击伪装成已验证流程。
|
||||
raise SkuSelectionError(_REVEAL_NOT_PROVEN)
|
||||
if profile is _PanelProfile.COLOR_SELECTED_SIZE_HIDDEN:
|
||||
raise SkuSelectionError(_REVEAL_NOT_PROVEN)
|
||||
if profile is _PanelProfile.SIZE_VISIBLE_NON_TARGET:
|
||||
target = _target_size_action(nodes, selected=False)
|
||||
_action_bounds(target.bounds)
|
||||
_require_action_occupants(nodes, target)
|
||||
self._pending = (before, _require_target_panel)
|
||||
self._device.tap_sku_option(target.bounds)
|
||||
self._wait_after_action(before, _require_target_panel)
|
||||
return
|
||||
if profile is _PanelProfile.TARGETS_SELECTED:
|
||||
return
|
||||
raise SkuSelectionError("规格面板状态不属于已取证 profile,已停止操作。")
|
||||
color_postcondition = partial(
|
||||
_require_profile_with_price_layout,
|
||||
expected=_PanelProfile.COLOR_SELECTED_SIZE_HIDDEN,
|
||||
expected_price_layout=spec.price_layout,
|
||||
)
|
||||
self._perform_mutation(
|
||||
before,
|
||||
color_postcondition,
|
||||
partial(self._device.tap_sku_option, target.bounds),
|
||||
)
|
||||
self._color_selected_by_flow = True
|
||||
|
||||
def read_sku_unit_price(self) -> str:
|
||||
self._require_active()
|
||||
return _unit_price(self._verified_nodes())
|
||||
|
||||
def verify_target_selection_and_read_price(self, selection: SkuSelection) -> str:
|
||||
self._require_active()
|
||||
if selection != SkuSelection(_TARGET_COLOR_UI, _TARGET_SIZE_UI):
|
||||
raise SkuSelectionError("规格 UI 文案不是获准目标,已停止读取。")
|
||||
nodes = self._verified_nodes()
|
||||
return _unit_price(nodes)
|
||||
|
||||
def exit_sku_panel_safely(self) -> None:
|
||||
self._require_active()
|
||||
self._require_foreground()
|
||||
before = self._read_hierarchy()
|
||||
_classify_panel(_parse_nodes(before))
|
||||
# 截图重证与 Back 之间仍可能漂移;只有目标双规格和已验单价同时保持时才允许返回。
|
||||
_unit_price(_parse_nodes(before))
|
||||
try:
|
||||
self._device.leave_sku_panel()
|
||||
except BaseException:
|
||||
self._terminal = True
|
||||
raise
|
||||
deadline = self._clock() + self._entry_timeout
|
||||
stable: tuple[object, ...] | None = None
|
||||
try:
|
||||
while True:
|
||||
self._require_foreground()
|
||||
raw = self._read_hierarchy()
|
||||
if raw != before:
|
||||
try:
|
||||
_classify_panel(_parse_nodes(raw))
|
||||
projection = _product_exit_projection(_parse_nodes(raw))
|
||||
except SkuSelectionError:
|
||||
stable = None
|
||||
else:
|
||||
# 用前台/版本检查夹住第二棵候选树;页面在 dump 后漂移时不能成功。
|
||||
self._require_foreground()
|
||||
if stable == projection:
|
||||
self._terminal = True
|
||||
return
|
||||
stable = projection
|
||||
remaining = deadline - self._clock()
|
||||
if remaining <= 0:
|
||||
raise SkuSelectionError("安全退出后未确认离开规格面板,未重试返回。")
|
||||
self._sleep(min(self._poll, remaining))
|
||||
except BaseException:
|
||||
self._terminal = True
|
||||
raise
|
||||
|
||||
def reconcile_pending_action(self) -> None:
|
||||
"""仅只读调和一次已发出但尚未得到后置条件确认的动作。"""
|
||||
if self._pending is None:
|
||||
return
|
||||
before, condition = self._pending
|
||||
self._wait_after_action(before, condition)
|
||||
before, condition, require_stable = self._pending
|
||||
self._wait_after_action(before, condition, require_stable=require_stable)
|
||||
|
||||
def _perform_mutation(
|
||||
self,
|
||||
before: str,
|
||||
condition: Callable[[list[_Node]], Any],
|
||||
mutation: Callable[[], None],
|
||||
*,
|
||||
require_stable: bool = False,
|
||||
) -> list[_Node]:
|
||||
# 后置条件连同唯一动作机会必须在 RPC 前封存;超时可能表示已送达,不能重发。
|
||||
self._pending = (before, condition, require_stable)
|
||||
try:
|
||||
mutation()
|
||||
return self._wait_after_action(before, condition, require_stable=require_stable)
|
||||
except BaseException:
|
||||
# 结果不明时只允许 reconcile_pending_action 读回;即使调和成功,本 Flow 也不再继续。
|
||||
self._terminal = True
|
||||
raise
|
||||
|
||||
def _require_active(self) -> None:
|
||||
if self._terminal:
|
||||
raise SkuSelectionError("规格流程已进入不可重入终止态,已停止操作。")
|
||||
|
||||
def _wait_for_entry(self, previous: str | None) -> tuple[_Node, str]:
|
||||
deadline, stable = self._clock() + self._entry_timeout, None
|
||||
@@ -326,19 +512,31 @@ class SkuSelectionFlow:
|
||||
raise SkuSelectionError("等待已取证规格入口超时,未执行点击。")
|
||||
self._sleep(min(self._poll, remaining))
|
||||
|
||||
def _wait_after_action(self, previous: str, condition: Callable[[list[_Node]], Any]) -> list[_Node]:
|
||||
def _wait_after_action(
|
||||
self,
|
||||
previous: str,
|
||||
condition: Callable[[list[_Node]], Any],
|
||||
*,
|
||||
require_stable: bool = False,
|
||||
) -> list[_Node]:
|
||||
deadline = self._clock() + self._entry_timeout
|
||||
stable: Any = None
|
||||
has_stable = False
|
||||
while True:
|
||||
self._require_foreground()
|
||||
raw = self._read_hierarchy()
|
||||
if raw != previous:
|
||||
nodes = _parse_nodes(raw)
|
||||
try:
|
||||
condition(nodes)
|
||||
projection = condition(nodes)
|
||||
if not require_stable or (has_stable and projection == stable):
|
||||
self._pending = None
|
||||
return nodes
|
||||
stable = projection
|
||||
has_stable = True
|
||||
except SkuSelectionError:
|
||||
pass
|
||||
stable = None
|
||||
has_stable = False
|
||||
remaining = deadline - self._clock()
|
||||
if remaining <= 0:
|
||||
raise SkuSelectionError("动作后页面未在限定时间内满足已取证后置条件,未重试动作。")
|
||||
@@ -383,14 +581,20 @@ def _parse_nodes(raw: str) -> list[_Node]:
|
||||
return result
|
||||
|
||||
|
||||
def _classify_panel(nodes: list[_Node]) -> _PanelProfile:
|
||||
matches: list[_PanelProfile] = []
|
||||
def _classify_panel(nodes: list[_Node]) -> _PanelSpec:
|
||||
matches: list[_PanelSpec] = []
|
||||
for spec in _PANEL_SPECS:
|
||||
try:
|
||||
_match_panel_profile(nodes, spec)
|
||||
except SkuSelectionError:
|
||||
continue
|
||||
matches.append(spec.profile)
|
||||
matches.append(spec)
|
||||
try:
|
||||
_revealed_unselected_projection(nodes)
|
||||
except SkuSelectionError:
|
||||
pass
|
||||
else:
|
||||
matches.append(_REVEALED_UNSELECTED_SPEC)
|
||||
if len(matches) != 1:
|
||||
raise SkuSelectionError("规格面板不符合唯一完整取证 profile,已停止操作。")
|
||||
return matches[0]
|
||||
@@ -408,9 +612,23 @@ def _require_target_panel(nodes: list[_Node]) -> None:
|
||||
_require_profile(nodes, _PanelProfile.TARGETS_SELECTED)
|
||||
|
||||
|
||||
def _require_profile(nodes: list[_Node], expected: _PanelProfile) -> None:
|
||||
if _classify_panel(nodes) is not expected:
|
||||
def _require_profile(nodes: list[_Node], expected: _PanelProfile) -> _PanelSpec:
|
||||
spec = _classify_panel(nodes)
|
||||
if spec.profile is not expected:
|
||||
raise SkuSelectionError("规格面板动作后状态与已取证 profile 不一致,已停止操作。")
|
||||
return spec
|
||||
|
||||
|
||||
def _require_profile_with_price_layout(
|
||||
nodes: list[_Node],
|
||||
*,
|
||||
expected: _PanelProfile,
|
||||
expected_price_layout: _PriceLayout,
|
||||
) -> None:
|
||||
spec = _require_profile(nodes, expected)
|
||||
# 同一次颜色点击不得跨价格证据 variant;完整 dataclass identity 同时绑定类型与全部 exact 字段。
|
||||
if spec.price_layout != expected_price_layout:
|
||||
raise SkuSelectionError("规格面板动作后价格布局发生跨变体漂移,已停止操作。")
|
||||
|
||||
|
||||
def _match_panel_profile(nodes: list[_Node], spec: _PanelSpec) -> None:
|
||||
@@ -427,24 +645,24 @@ def _match_panel_profile(nodes: list[_Node], spec: _PanelSpec) -> None:
|
||||
[node for node in nodes if node.parent is surface and _exact_recycler(node, spec.outer_bounds)],
|
||||
"规格面板维度容器不唯一。",
|
||||
)
|
||||
price_row = _one(
|
||||
[node for node in nodes if _descendant(node, header) and _exact_inert(node, "android.widget.LinearLayout", spec.price_row_bounds)],
|
||||
"规格面板价格行不唯一。",
|
||||
)
|
||||
if len([child for child in price_row.element if child.tag == "node"]) != 2:
|
||||
raise SkuSelectionError("规格面板价格行子节点数量漂移。")
|
||||
current = _one(
|
||||
[node for node in nodes if node.parent is price_row and _exact_readonly_text(node, spec.current_text, spec.current_bounds)],
|
||||
"规格面板当前价角色不唯一。",
|
||||
)
|
||||
_one(
|
||||
[node for node in nodes if node.parent is price_row and _exact_readonly_text(node, spec.original_text, spec.original_bounds)],
|
||||
"规格面板原价角色不唯一。",
|
||||
current, summary_anchor, summary_is_direct = _match_price_layout(
|
||||
nodes,
|
||||
header,
|
||||
spec.price_layout,
|
||||
)
|
||||
if _clickable_before(current, surface):
|
||||
raise SkuSelectionError("规格面板价格角色位于可点击内容祖先下。")
|
||||
_one(
|
||||
[node for node in nodes if _descendant(node, header) and _exact_readonly_text(node, spec.summary_text, spec.summary_bounds)],
|
||||
[
|
||||
node
|
||||
for node in nodes
|
||||
if (
|
||||
node.parent is summary_anchor
|
||||
if summary_is_direct
|
||||
else _descendant(node, summary_anchor)
|
||||
)
|
||||
and _exact_readonly_text(node, spec.summary_text, spec.summary_bounds)
|
||||
],
|
||||
"规格面板摘要不唯一。",
|
||||
)
|
||||
|
||||
@@ -506,12 +724,185 @@ def _match_panel_profile(nodes: list[_Node], spec: _PanelSpec) -> None:
|
||||
_require_exact_selected_set(nodes, surface, [*selected_nodes, expected_action])
|
||||
|
||||
|
||||
def _require_safe_reveal_path(nodes: list[_Node]) -> None:
|
||||
"""证明固定手势整条线段仍是已取证的两列卡片间安全通道。"""
|
||||
|
||||
surface = _one(
|
||||
[node for node in nodes if _exact_inert(node, "android.view.ViewGroup", _PANEL_SURFACE)],
|
||||
"固定 reveal 通道无法绑定面板内容面。",
|
||||
)
|
||||
_require_panel_chain(surface)
|
||||
content = surface.parent
|
||||
action_root = content.parent if content is not None else None
|
||||
action_parent = action_root.parent if action_root is not None else None
|
||||
if (
|
||||
content is None
|
||||
or action_root is None
|
||||
or action_parent is None
|
||||
or not _exact_live_layout(action_root, "android.view.ViewGroup", "[0,366][1080,2328]")
|
||||
or not _exact_live_layout(action_parent, "android.widget.LinearLayout", "[0,120][1080,2328]")
|
||||
):
|
||||
raise SkuSelectionError("固定 reveal 通道祖先身份漂移,未执行手势。")
|
||||
start_x, start_y, end_x, end_y, _steps = _REVEAL_GESTURE
|
||||
allowed = {id(action_root.element), id(action_parent.element)}
|
||||
occupants: set[int] = set()
|
||||
for node in nodes:
|
||||
if node.element.get("clickable") != "true":
|
||||
continue
|
||||
try:
|
||||
left, top, right, bottom = _action_bounds(node.bounds)
|
||||
except SkuSelectionError as error:
|
||||
raise SkuSelectionError("可点击节点坐标不可验证,未执行手势。") from error
|
||||
if (
|
||||
left <= start_x < right
|
||||
and top <= start_y
|
||||
and bottom > end_y
|
||||
):
|
||||
occupants.add(id(node.element))
|
||||
if occupants != allowed:
|
||||
raise SkuSelectionError("固定 reveal 通道被未取证可点击节点占用,未执行手势。")
|
||||
if start_y >= 2079 or end_y >= 2079:
|
||||
raise SkuSelectionError("固定 reveal 通道越过规格内容区,未执行手势。")
|
||||
|
||||
|
||||
def _revealed_unselected_projection(nodes: list[_Node]) -> tuple[tuple[str, ...], ...]:
|
||||
"""唯一生产判据:current-only reveal 后,目标颜色已选且 S/M 均未选。"""
|
||||
|
||||
surface = _one(
|
||||
[node for node in nodes if _exact_inert(node, "android.view.ViewGroup", _PANEL_SURFACE)],
|
||||
"reveal 后面板内容面不唯一。",
|
||||
)
|
||||
_require_panel_chain(surface)
|
||||
header = _one(
|
||||
[node for node in nodes if node.parent is surface and _exact_inert(node, "android.widget.LinearLayout", "[0,366][1080,1000]")],
|
||||
"reveal 后面板头部不唯一。",
|
||||
)
|
||||
if len([child for child in header.element if child.tag == "node"]) != 4:
|
||||
raise SkuSelectionError("reveal 后面板头部子节点数量漂移。")
|
||||
outer = _one(
|
||||
[node for node in nodes if node.parent is surface and _exact_recycler(node, "[0,1000][1080,2079]")],
|
||||
"reveal 后维度容器不唯一。",
|
||||
)
|
||||
content_frame = _one(
|
||||
[node for node in nodes if node.parent is header and _exact_inert(node, "android.widget.FrameLayout", "[0,474][1080,863]")],
|
||||
"reveal 后价格内容外框不唯一。",
|
||||
)
|
||||
if len([child for child in content_frame.element if child.tag == "node"]) != 1:
|
||||
raise SkuSelectionError("reveal 后价格内容外框子节点数量漂移。")
|
||||
relative = _one(
|
||||
[node for node in nodes if node.parent is content_frame and _exact_inert(node, "android.widget.RelativeLayout", "[0,474][1080,863]")],
|
||||
"reveal 后价格 RelativeLayout 父子关系漂移。",
|
||||
)
|
||||
if len([child for child in relative.element if child.tag == "node"]) != 1:
|
||||
raise SkuSelectionError("reveal 后价格 RelativeLayout 子节点数量漂移。")
|
||||
content = _one(
|
||||
[node for node in nodes if node.parent is relative and _exact_inert(node, "android.view.ViewGroup", "[0,474][1080,863]")],
|
||||
"reveal 后价格内容 ViewGroup 父子关系漂移。",
|
||||
)
|
||||
if len([child for child in content.element if child.tag == "node"]) != 8:
|
||||
raise SkuSelectionError("reveal 后价格内容 ViewGroup 子节点数量漂移。")
|
||||
price_frame = _one(
|
||||
[node for node in nodes if node.parent is content and _exact_inert(node, "android.widget.FrameLayout", "[396,498][1053,570]")],
|
||||
"reveal 后价格 FrameLayout 父子关系漂移。",
|
||||
)
|
||||
if len([child for child in price_frame.element if child.tag == "node"]) != 1:
|
||||
raise SkuSelectionError("reveal 后价格 FrameLayout 子节点数量漂移。")
|
||||
price_row = _one(
|
||||
[node for node in nodes if node.parent is price_frame and _exact_inert(node, "android.widget.LinearLayout", "[396,498][693,570]")],
|
||||
"reveal 后单价格行父子关系漂移。",
|
||||
)
|
||||
if len([child for child in price_row.element if child.tag == "node"]) != 1:
|
||||
raise SkuSelectionError("reveal 后单价格行子节点数量漂移。")
|
||||
current = _one(
|
||||
[node for node in nodes if node.parent is price_row and _exact_readonly_text(node, _REVEALED_CURRENT_PRICE, "[396,503][675,570]") and len(node.element) == 0],
|
||||
"reveal 后单价格当前价不唯一。",
|
||||
)
|
||||
if _clickable_before(current, surface):
|
||||
raise SkuSelectionError("reveal 后单价格当前价位于可点击内容祖先下。")
|
||||
if any(
|
||||
node.text in {_ROLLED_CURRENT_PRICE, _ROLLED_ORIGINAL_PRICE, "券前¥29.88"}
|
||||
or (node.bounds == "[396,498][895,570]" and node.element.get("class") == "android.widget.LinearLayout")
|
||||
for node in nodes
|
||||
):
|
||||
raise SkuSelectionError("reveal 后单价格布局混入双价格角色。")
|
||||
summary = _one(
|
||||
[node for node in nodes if node.parent is content and _exact_readonly_text(node, _COLOR_ONLY_SUMMARY, "[396,654][1053,716]") and len(node.element) == 0],
|
||||
"reveal 后摘要不唯一。",
|
||||
)
|
||||
color_region = _one(
|
||||
[node for node in nodes if _descendant(node, outer) and _exact_recycler(node, "[36,1000][1080,1483]")],
|
||||
"reveal 后颜色容器不唯一。",
|
||||
)
|
||||
color = _one(
|
||||
[node for node in nodes if node.parent is color_region and _exact_color_action(node, "[372,1000][684,1024]", True)],
|
||||
"reveal 后目标颜色不唯一。",
|
||||
)
|
||||
selected_color_nodes = _require_color_subtree(color, _REVEALED_UNSELECTED_SPEC)
|
||||
_require_color_action_chain(color, color_region, outer, surface, _REVEALED_UNSELECTED_SPEC)
|
||||
size_label = _one(
|
||||
[node for node in nodes if _descendant(node, outer) and _exact_readonly_text(node, _SIZE, "[36,1506][114,1552]")],
|
||||
"reveal 后尺码标题不唯一。",
|
||||
)
|
||||
size_header = size_label.parent
|
||||
if size_header is None or not _exact_live_layout(size_header, "android.widget.LinearLayout", "[36,1489][1044,1570]"):
|
||||
raise SkuSelectionError("reveal 后尺码标题父结构漂移。")
|
||||
size_options = _one(
|
||||
[node for node in nodes if _descendant(node, outer) and _exact_inert(node, "android.view.ViewGroup", "[36,1582][1044,1897]")],
|
||||
"reveal 后尺码 options 根不唯一。",
|
||||
)
|
||||
actions = [node for node in nodes if _descendant(node, size_options) and _is_size_action_text(node)]
|
||||
s_action = _one(
|
||||
[node for node in actions if node.text == _S_SIZE_UI and node.bounds == "[36,1582][409,1667]" and node.element.get("selected") == "false"],
|
||||
"reveal 后 S action 不唯一。",
|
||||
)
|
||||
m_action = _one(
|
||||
[node for node in actions if node.text == _TARGET_SIZE_UI and node.bounds == _TARGET_SIZE_BOUNDS and node.element.get("selected") == "false"],
|
||||
"reveal 后 M action 不唯一。",
|
||||
)
|
||||
_require_size_wrapper(s_action, size_options)
|
||||
_require_size_wrapper(m_action, size_options)
|
||||
_require_size_action_chain(s_action, size_options, outer, surface)
|
||||
_require_size_action_chain(m_action, size_options, outer, surface)
|
||||
if any(node.element.get("selected") == "true" for node in actions):
|
||||
raise SkuSelectionError("reveal 后已有尺码被选中。")
|
||||
_require_exact_selected_set(nodes, surface, selected_color_nodes)
|
||||
dangerous = [
|
||||
node
|
||||
for node in nodes
|
||||
if node.element.get("package") == PDD_PACKAGE
|
||||
and node.element.get("class") == "android.widget.TextView"
|
||||
and "提交订单" in node.text
|
||||
]
|
||||
danger = _one(dangerous, "提交硬拒绝区位置不唯一。")
|
||||
if (
|
||||
not _exact_readonly_text(danger, "选择尺码后,提交订单", "[285,2225][795,2284]")
|
||||
or len(danger.element) != 0
|
||||
):
|
||||
raise SkuSelectionError("提交硬拒绝区结构漂移。")
|
||||
return tuple(
|
||||
(
|
||||
node.element.get("class", ""),
|
||||
node.bounds,
|
||||
node.text,
|
||||
node.desc,
|
||||
node.element.get("selected", ""),
|
||||
node.element.get("clickable", ""),
|
||||
)
|
||||
for node in (
|
||||
surface, header, outer, price_frame, price_row, current, summary,
|
||||
color_region, color, size_label, size_options, s_action, m_action, danger,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _unit_price(nodes: list[_Node]) -> str:
|
||||
_require_profile(nodes, _PanelProfile.TARGETS_SELECTED)
|
||||
spec = _spec_for(_PanelProfile.TARGETS_SELECTED)
|
||||
spec = _classify_panel(nodes)
|
||||
if spec.profile is not _PanelProfile.TARGETS_SELECTED:
|
||||
raise SkuSelectionError("规格面板动作后状态与已取证 profile 不一致,已停止操作。")
|
||||
current_bounds = spec.price_layout.current_bounds
|
||||
candidates = [
|
||||
node for node in nodes
|
||||
if _exact_readonly_text(node, _ROLLED_CURRENT_PRICE, spec.current_bounds)
|
||||
if _exact_readonly_text(node, _ROLLED_CURRENT_PRICE, current_bounds)
|
||||
]
|
||||
current = _one(candidates, "规格面板现价不唯一,已停止读取。")
|
||||
surface = _one([node for node in nodes if _exact_inert(node, "android.view.ViewGroup", _PANEL_SURFACE)], "规格面板内容面不唯一。")
|
||||
@@ -521,27 +912,126 @@ def _unit_price(nodes: list[_Node]) -> str:
|
||||
|
||||
|
||||
def _target_color_action(nodes: list[_Node], *, selected: bool) -> _Node:
|
||||
profile = _classify_panel(nodes)
|
||||
spec = _classify_panel(nodes)
|
||||
profile = spec.profile
|
||||
expected_profile = _PanelProfile.COLOR_SELECTED_SIZE_HIDDEN if selected else _PanelProfile.PANEL_OPEN_EMPTY
|
||||
if profile is not expected_profile:
|
||||
raise SkuSelectionError("目标颜色 action 不属于预期 profile。")
|
||||
spec = _spec_for(profile)
|
||||
return _one([node for node in nodes if _exact_color_action(node, spec.color_bounds, selected)], "目标颜色 action 不唯一。")
|
||||
|
||||
|
||||
def _target_size_action(nodes: list[_Node], *, selected: bool) -> _Node:
|
||||
profile = _classify_panel(nodes)
|
||||
expected_profile = _PanelProfile.TARGETS_SELECTED if selected else _PanelProfile.SIZE_VISIBLE_NON_TARGET
|
||||
if profile is not expected_profile:
|
||||
profile = _classify_panel(nodes).profile
|
||||
expected_profiles = (
|
||||
{_PanelProfile.TARGETS_SELECTED}
|
||||
if selected
|
||||
else {_PanelProfile.SIZE_VISIBLE_UNSELECTED, _PanelProfile.SIZE_VISIBLE_NON_TARGET}
|
||||
)
|
||||
if profile not in expected_profiles:
|
||||
raise SkuSelectionError("目标尺码 action 不属于预期 profile。")
|
||||
return _one(
|
||||
[node for node in nodes if _is_size_action_text(node) and node.text == _TARGET_SIZE_UI and node.bounds == "[439,1582][831,1667]" and node.element.get("selected") == str(selected).lower()],
|
||||
[node for node in nodes if _is_size_action_text(node) and node.text == _TARGET_SIZE_UI and node.bounds == _TARGET_SIZE_BOUNDS and node.element.get("selected") == str(selected).lower()],
|
||||
"目标尺码 action 不唯一。",
|
||||
)
|
||||
|
||||
|
||||
def _spec_for(profile: _PanelProfile) -> _PanelSpec:
|
||||
return next(spec for spec in _PANEL_SPECS if spec.profile is profile)
|
||||
matches = [spec for spec in _PANEL_SPECS if spec.profile is profile]
|
||||
if len(matches) != 1:
|
||||
raise SkuSelectionError("该 profile 没有唯一静态 spec。")
|
||||
return matches[0]
|
||||
|
||||
|
||||
def _match_price_layout(
|
||||
nodes: list[_Node],
|
||||
header: _Node,
|
||||
layout: _PriceLayout,
|
||||
) -> tuple[_Node, _Node, bool]:
|
||||
if isinstance(layout, _DualPriceLayout):
|
||||
price_row = _one(
|
||||
[
|
||||
node
|
||||
for node in nodes
|
||||
if _descendant(node, header)
|
||||
and _exact_inert(node, "android.widget.LinearLayout", layout.row_bounds)
|
||||
],
|
||||
"规格面板双价格行不唯一。",
|
||||
)
|
||||
if len([child for child in price_row.element if child.tag == "node"]) != 2:
|
||||
raise SkuSelectionError("规格面板双价格行子节点数量漂移。")
|
||||
current = _one(
|
||||
[
|
||||
node
|
||||
for node in nodes
|
||||
if node.parent is price_row
|
||||
and _exact_readonly_text(node, layout.current_text, layout.current_bounds)
|
||||
],
|
||||
"规格面板双价格当前价角色不唯一。",
|
||||
)
|
||||
_one(
|
||||
[
|
||||
node
|
||||
for node in nodes
|
||||
if node.parent is price_row
|
||||
and _exact_readonly_text(node, layout.original_text, layout.original_bounds)
|
||||
],
|
||||
"规格面板双价格原价角色不唯一。",
|
||||
)
|
||||
# 历史已批准 raw 的双价格行和摘要位于 header 内的嵌套内容容器,
|
||||
# 旧证据只允许后代关系,不能套用 current-only 的直接父链。
|
||||
return current, header, False
|
||||
|
||||
frame = _one(
|
||||
[
|
||||
node
|
||||
for node in nodes
|
||||
if _descendant(node, header)
|
||||
and _exact_inert(node, "android.widget.FrameLayout", layout.frame_bounds)
|
||||
],
|
||||
"规格面板单价格外层不唯一。",
|
||||
)
|
||||
if len([child for child in frame.element if child.tag == "node"]) != 1:
|
||||
raise SkuSelectionError("规格面板单价格外层子节点数量漂移。")
|
||||
content = frame.parent
|
||||
relative = content.parent if content is not None else None
|
||||
content_frame = relative.parent if relative is not None else None
|
||||
if (
|
||||
content is None
|
||||
or relative is None
|
||||
or content_frame is None
|
||||
or content_frame.parent is not header
|
||||
or not _exact_inert(content, "android.view.ViewGroup", "[0,551][1080,940]")
|
||||
or not _exact_inert(relative, "android.widget.RelativeLayout", "[0,551][1080,940]")
|
||||
or not _exact_inert(content_frame, "android.widget.FrameLayout", "[0,551][1080,940]")
|
||||
):
|
||||
raise SkuSelectionError("规格面板单价格外层父链漂移。")
|
||||
price_row = _one(
|
||||
[
|
||||
node
|
||||
for node in nodes
|
||||
if node.parent is frame
|
||||
and _exact_inert(node, "android.widget.LinearLayout", layout.row_bounds)
|
||||
],
|
||||
"规格面板单价格行父子关系漂移。",
|
||||
)
|
||||
if len([child for child in price_row.element if child.tag == "node"]) != 1:
|
||||
raise SkuSelectionError("规格面板单价格行子节点数量漂移。")
|
||||
current = _one(
|
||||
[
|
||||
node
|
||||
for node in nodes
|
||||
if node.parent is price_row
|
||||
and _exact_readonly_text(node, layout.current_text, layout.current_bounds)
|
||||
],
|
||||
"规格面板单价格当前价角色不唯一。",
|
||||
)
|
||||
if any(
|
||||
_descendant(node, header)
|
||||
and node.text == "券前¥29.88"
|
||||
for node in nodes
|
||||
):
|
||||
raise SkuSelectionError("规格面板单价格变体出现原价角色。")
|
||||
return current, content, True
|
||||
|
||||
|
||||
def _exact_inert(node: _Node, class_name: str, bounds: str) -> bool:
|
||||
@@ -660,7 +1150,7 @@ def _require_color_subtree(color: _Node, spec: _PanelSpec) -> list[_Node]:
|
||||
selected = str(spec.color_selected).lower()
|
||||
children = [child for child in color.element if child.tag == "node"]
|
||||
expected_selected: list[_Node] = [color] if spec.color_selected else []
|
||||
if spec.color_bounds == "[372,1188][684,1587]":
|
||||
if spec.color_bounds == _TARGET_COLOR_UNROLLED_BOUNDS:
|
||||
if len(children) != 4:
|
||||
raise SkuSelectionError("目标颜色完整卡片子树数量漂移。")
|
||||
child_nodes = [node for node in _walk_direct_children(color)]
|
||||
@@ -704,7 +1194,7 @@ def _require_color_action_chain(
|
||||
) -> None:
|
||||
if color.parent is not color_region:
|
||||
raise SkuSelectionError("目标颜色 action 不属于已取证颜色容器。")
|
||||
if spec.color_bounds == "[372,1188][684,1587]":
|
||||
if spec.color_bounds == _TARGET_COLOR_UNROLLED_BOUNDS:
|
||||
expected = (
|
||||
("android.widget.FrameLayout", "[0,1188][1080,2046]"),
|
||||
("android.widget.LinearLayout", "[0,1188][1080,2052]"),
|
||||
@@ -916,6 +1406,62 @@ def _entry_projection(node: _Node, nodes: list[_Node]) -> tuple[object, ...] | N
|
||||
)
|
||||
|
||||
|
||||
def _product_exit_projection(nodes: list[_Node]) -> tuple[object, ...]:
|
||||
"""返回 T-104 人验同商品详情页的最小稳定投影;不读取页面价格。"""
|
||||
|
||||
if any(
|
||||
_is_live_clickable(node)
|
||||
and any(term in value for value in (node.text, node.desc) for term in _DANGEROUS_EXIT_ACTION_TERMS)
|
||||
for node in nodes
|
||||
):
|
||||
# T-106/T-107 前只把这些结构当保守拒绝,不宣称它们是完整确认/支付页分类器。
|
||||
raise SkuSelectionError("退出后出现危险动作语义,不能确认安全退出。")
|
||||
|
||||
entries = _eligible_entries(nodes)
|
||||
if len(entries) != 1:
|
||||
raise SkuSelectionError("退出后商品规格入口不唯一。")
|
||||
entry = _entry_projection(entries[0], nodes)
|
||||
if entry is None:
|
||||
raise SkuSelectionError("退出后商品规格入口结构失效。")
|
||||
|
||||
titles = [
|
||||
node
|
||||
for node in nodes
|
||||
if _exact_common(
|
||||
node,
|
||||
"android.view.ViewGroup",
|
||||
_PRODUCT_TITLE_BOUNDS,
|
||||
clickable="true",
|
||||
selected="false",
|
||||
scrollable="false",
|
||||
)
|
||||
and node.element.get("resource-id") == _PRODUCT_TITLE_RESOURCE_ID
|
||||
and node.element.get("long-clickable") == "true"
|
||||
and not node.text
|
||||
and node.desc == _PRODUCT_TITLE_DESC
|
||||
]
|
||||
title = _one(titles, "退出后同商品标题正锚不唯一。")
|
||||
children = _walk_direct_children(title)
|
||||
expected_children = (
|
||||
(_PRODUCT_TITLE_FIRST_LINE, "[36,1571][1023,1624]"),
|
||||
(_PRODUCT_TITLE_SECOND_LINE, "[36,1636][306,1689]"),
|
||||
)
|
||||
if len(children) != len(expected_children) or any(
|
||||
not _exact_readonly_text(child, text, bounds)
|
||||
for child, (text, bounds) in zip(children, expected_children, strict=True)
|
||||
):
|
||||
raise SkuSelectionError("退出后同商品标题子结构漂移。")
|
||||
|
||||
return (
|
||||
"product_exit_8_17_0",
|
||||
entry,
|
||||
_entry_node_projection(title),
|
||||
tuple(_entry_node_projection(child) for child in children),
|
||||
title.element.get("resource-id", ""),
|
||||
title.element.get("long-clickable", ""),
|
||||
)
|
||||
|
||||
|
||||
def _entry_node_projection(node: _Node) -> tuple[str, ...]:
|
||||
return (
|
||||
node.element.tag,
|
||||
|
||||
@@ -21,7 +21,13 @@ from adbutils.errors import AdbTimeout
|
||||
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 ..device.baseline import (
|
||||
PDD_PACKAGE,
|
||||
SCREENSHOT_PARAMS,
|
||||
_save_base64_screenshot,
|
||||
_sha256_file,
|
||||
_validate_hierarchy,
|
||||
)
|
||||
from .product_open import EXPECTED_PDD_VERSION
|
||||
from .product_url import ProductUrl, ProductUrlError, parse_product_url
|
||||
from .sku_selection import (
|
||||
@@ -31,9 +37,15 @@ from .sku_selection import (
|
||||
SkuSelectionError,
|
||||
SkuSelectionFlow,
|
||||
_SKU_ENTRY_FAILURE_STAGES,
|
||||
_REVEAL_GESTURE,
|
||||
_ENTRY_TEXT_BOUNDS,
|
||||
_TARGET_COLOR_UNROLLED_BOUNDS,
|
||||
_TARGET_SIZE_BOUNDS,
|
||||
_action_bounds,
|
||||
_annotate_sku_entry_failure,
|
||||
_parse_nodes,
|
||||
_safe_sku_entry_failure_stage,
|
||||
_unit_price,
|
||||
resolve_task_selection,
|
||||
)
|
||||
|
||||
@@ -82,6 +94,10 @@ class SkuSelectionDeviceAdapterError(SkuSelectionRunError):
|
||||
"""第三方设备接口失败的脱敏映射。"""
|
||||
|
||||
|
||||
class SkuExitSpikeError(SkuSelectionRunError):
|
||||
"""T-104 阶段 A 未形成完整的本机退出证据。"""
|
||||
|
||||
|
||||
def safe_failure_stage(error: BaseException) -> str:
|
||||
"""返回允许公开的固定阶段码,绝不回显异常正文。"""
|
||||
|
||||
@@ -132,12 +148,21 @@ class SkuSelectionRunResult:
|
||||
screenshot_path: Path
|
||||
manifest_path: Path
|
||||
unit_price: str
|
||||
captured_at: datetime
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SkuExitSpikeResult:
|
||||
"""已原子发布、仍需人工判断页面身份的 T-104 证据。"""
|
||||
|
||||
output_directory: Path
|
||||
manifest_path: Path
|
||||
|
||||
|
||||
class UiautomatorSkuPanelAdapter(SkuPanelDevice):
|
||||
"""把 uiautomator2 缩为 T-103 所需的读取与三种命名操作。
|
||||
"""把 uiautomator2 缩为 T-103 所需的读取与四种命名操作。
|
||||
|
||||
``tap_sku_entry``、``tap_sku_option`` 和 ``leave_sku_panel`` 是仅有的状态改变方法;
|
||||
四个命名方法是仅有的状态改变入口;reveal 的手势参数固定且不向 Flow 暴露;
|
||||
坐标由 Flow 和本类双重检查后才计算中心点,每次调用只执行一次底层动作。
|
||||
"""
|
||||
|
||||
@@ -147,7 +172,14 @@ class UiautomatorSkuPanelAdapter(SkuPanelDevice):
|
||||
self._device = device
|
||||
self._timeout_seconds = timeout_seconds
|
||||
self._entry_was_tapped = False
|
||||
self._entry_bounds_attempted: str | None = None
|
||||
self._entry_rpc_outcome = "not_attempted"
|
||||
self._option_bounds_attempted: set[str] = set()
|
||||
self._option_rpc_outcomes: dict[str, str] = {}
|
||||
self._reveal_attempted = False
|
||||
self._reveal_rpc_outcome = "not_attempted"
|
||||
self._left_panel = False
|
||||
self._back_rpc_outcome = "not_attempted"
|
||||
|
||||
@property
|
||||
def entry_was_tapped(self) -> bool:
|
||||
@@ -159,6 +191,38 @@ class UiautomatorSkuPanelAdapter(SkuPanelDevice):
|
||||
def left_panel(self) -> bool:
|
||||
return self._left_panel
|
||||
|
||||
@property
|
||||
def reveal_attempted(self) -> bool:
|
||||
return self._reveal_attempted
|
||||
|
||||
@property
|
||||
def entry_rpc_outcome(self) -> str:
|
||||
return self._entry_rpc_outcome
|
||||
|
||||
@property
|
||||
def entry_bounds_attempted(self) -> str | None:
|
||||
return self._entry_bounds_attempted
|
||||
|
||||
@property
|
||||
def option_rpc_outcomes(self) -> tuple[tuple[str, str], ...]:
|
||||
return tuple(sorted(self._option_rpc_outcomes.items()))
|
||||
|
||||
@property
|
||||
def reveal_rpc_outcome(self) -> str:
|
||||
return self._reveal_rpc_outcome
|
||||
|
||||
@property
|
||||
def option_attempts(self) -> int:
|
||||
return len(self._option_bounds_attempted)
|
||||
|
||||
@property
|
||||
def back_attempts(self) -> int:
|
||||
return int(self._left_panel)
|
||||
|
||||
@property
|
||||
def back_rpc_outcome(self) -> str:
|
||||
return self._back_rpc_outcome
|
||||
|
||||
def app_info(self, package_name: str) -> dict[str, Any]:
|
||||
value = self._call("app_info", package_name)
|
||||
if not isinstance(value, dict):
|
||||
@@ -178,19 +242,46 @@ class UiautomatorSkuPanelAdapter(SkuPanelDevice):
|
||||
return value
|
||||
|
||||
def tap_sku_entry(self, bounds: str) -> None:
|
||||
if self._entry_was_tapped:
|
||||
raise SkuSelectionDeviceAdapterError("规格入口已经尝试过,拒绝重试。")
|
||||
# 超时也可能表示底层事件已经送达;必须先封存 attempt,后续绝不重试该入口。
|
||||
self._entry_was_tapped = True
|
||||
self._entry_bounds_attempted = bounds
|
||||
self._entry_rpc_outcome = "ambiguous"
|
||||
self._tap_bounds_once(bounds)
|
||||
self._entry_rpc_outcome = "completed"
|
||||
|
||||
def tap_sku_option(self, bounds: str) -> None:
|
||||
if bounds in self._option_bounds_attempted:
|
||||
raise SkuSelectionDeviceAdapterError("同一规格选项已经尝试过,拒绝重试。")
|
||||
# 规格 RPC 也可能送达后超时;按 exact bounds 封存本次唯一机会。
|
||||
self._option_bounds_attempted.add(bounds)
|
||||
self._option_rpc_outcomes[bounds] = "ambiguous"
|
||||
self._tap_bounds_once(bounds)
|
||||
self._option_rpc_outcomes[bounds] = "completed"
|
||||
|
||||
def reveal_size_options_once(self) -> None:
|
||||
if self._reveal_attempted:
|
||||
raise SkuSelectionDeviceAdapterError("规格显示动作已经尝试过,拒绝重试。")
|
||||
# 手势唯一机会在 RPC 前封存;生产 API 不接受方向、坐标或步数参数。
|
||||
self._reveal_attempted = True
|
||||
self._reveal_rpc_outcome = "ambiguous"
|
||||
self._call(
|
||||
"jsonrpc_call",
|
||||
"swipe",
|
||||
list(_REVEAL_GESTURE),
|
||||
timeout=self._timeout_seconds,
|
||||
)
|
||||
self._reveal_rpc_outcome = "completed"
|
||||
|
||||
def leave_sku_panel(self) -> None:
|
||||
if self._left_panel:
|
||||
raise SkuSelectionDeviceAdapterError("规格面板已经执行过返回,已停止操作。")
|
||||
# 底层调用即使报错也可能已把返回事件送达;先封存本次机会,finally 不得再次返回。
|
||||
self._left_panel = True
|
||||
self._back_rpc_outcome = "ambiguous"
|
||||
self._call("jsonrpc_call", "pressKey", ["back"], timeout=self._timeout_seconds)
|
||||
self._back_rpc_outcome = "completed"
|
||||
|
||||
def capture_screenshot(self) -> str:
|
||||
value = self._call("jsonrpc_call", "takeScreenshot", SCREENSHOT_PARAMS, timeout=self._timeout_seconds)
|
||||
@@ -222,6 +313,210 @@ class UiautomatorSkuPanelAdapter(SkuPanelDevice):
|
||||
raise SkuSelectionDeviceAdapterError("规格面板设备操作失败,已停止操作。") from error
|
||||
|
||||
|
||||
class UiautomatorSkuExitAdapter:
|
||||
"""T-104 阶段 A 的窄设备边界:只读能力加唯一一次命名 Back。
|
||||
|
||||
取证脚本不能取得 T-103 的入口、规格选项或 reveal 方法。Back 的唯一机会在
|
||||
JSON-RPC 前封存,因为超时无法证明事件没有送达,任何结果不明都不得重发。
|
||||
"""
|
||||
|
||||
def __init__(self, device: Any, timeout_seconds: float) -> None:
|
||||
if not _is_positive_finite(timeout_seconds):
|
||||
raise ValueError("timeout_seconds 必须是大于 0 的有限数值")
|
||||
self._device = device
|
||||
self._timeout_seconds = timeout_seconds
|
||||
self._back_attempted = False
|
||||
self._back_rpc_outcome = "not_attempted"
|
||||
|
||||
@property
|
||||
def back_attempts(self) -> int:
|
||||
return int(self._back_attempted)
|
||||
|
||||
@property
|
||||
def back_rpc_outcome(self) -> str:
|
||||
return self._back_rpc_outcome
|
||||
|
||||
def app_info(self, package_name: str) -> dict[str, Any]:
|
||||
value = self._call("app_info", package_name)
|
||||
if not isinstance(value, dict):
|
||||
raise SkuSelectionDeviceAdapterError("无法读取应用版本,已停止取证。")
|
||||
return value
|
||||
|
||||
def app_current(self) -> dict[str, Any]:
|
||||
value = self._call("app_current")
|
||||
if not isinstance(value, dict):
|
||||
raise SkuSelectionDeviceAdapterError("无法读取前台应用,已停止取证。")
|
||||
return value
|
||||
|
||||
def dump_window_hierarchy(self) -> str:
|
||||
value = self._call(
|
||||
"jsonrpc_call",
|
||||
"dumpWindowHierarchy",
|
||||
[False, 50],
|
||||
timeout=self._timeout_seconds,
|
||||
)
|
||||
if not isinstance(value, str):
|
||||
raise SkuSelectionDeviceAdapterError("节点树读取失败,已停止取证。")
|
||||
return value
|
||||
|
||||
def capture_screenshot(self) -> str:
|
||||
value = self._call(
|
||||
"jsonrpc_call",
|
||||
"takeScreenshot",
|
||||
SCREENSHOT_PARAMS,
|
||||
timeout=self._timeout_seconds,
|
||||
)
|
||||
if not isinstance(value, str):
|
||||
raise SkuSelectionScreenshotError("退出后截图读取失败,未发布任何证据产物。")
|
||||
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(not isinstance(item, int) for item in value)
|
||||
):
|
||||
raise SkuSelectionDeviceAdapterError("无法读取屏幕坐标空间,已停止取证。")
|
||||
return value
|
||||
|
||||
def leave_sku_panel(self) -> None:
|
||||
if self._back_attempted:
|
||||
raise SkuSelectionDeviceAdapterError("本次取证已经尝试过返回,拒绝重试。")
|
||||
self._back_attempted = True
|
||||
self._back_rpc_outcome = "ambiguous"
|
||||
self._call(
|
||||
"jsonrpc_call",
|
||||
"pressKey",
|
||||
["back"],
|
||||
timeout=self._timeout_seconds,
|
||||
)
|
||||
self._back_rpc_outcome = "completed"
|
||||
|
||||
def _call(self, method: str, *args: Any, **kwargs: Any) -> Any:
|
||||
try:
|
||||
operation = getattr(self._device, method)
|
||||
return operation(*args, **kwargs)
|
||||
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
|
||||
raise SkuSelectionRunTimeoutError("T-104 设备操作超时,已停止操作。") from error
|
||||
except SkuSelectionRunError:
|
||||
raise
|
||||
except Exception as error:
|
||||
raise SkuSelectionDeviceAdapterError("T-104 设备操作失败,已停止操作。") from error
|
||||
|
||||
|
||||
class SkuExitSpikeCapturer:
|
||||
"""从人工停驻的已验证目标面板执行一次 Back,再只读采集阶段 A 证据。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
adb_client: AdbClient,
|
||||
connector: Callable[[str], Any],
|
||||
timeout_seconds: float,
|
||||
) -> None:
|
||||
if not _is_positive_finite(timeout_seconds):
|
||||
raise ValueError("timeout_seconds 必须是大于 0 的有限数值")
|
||||
self._adb_client = adb_client
|
||||
self._connector = connector
|
||||
self._timeout_seconds = timeout_seconds
|
||||
self._started = False
|
||||
|
||||
def capture(self, serial: str, output_directory: Path) -> SkuExitSpikeResult:
|
||||
if self._started:
|
||||
raise SkuExitSpikeError("同一取证器不可重复调用。")
|
||||
self._started = True
|
||||
|
||||
staging: Path | None = None
|
||||
adapter: UiautomatorSkuExitAdapter | None = None
|
||||
try:
|
||||
if type(serial) is not str or not serial.strip():
|
||||
raise SkuExitSpikeError("必须显式提供非空设备通道。")
|
||||
target = Path(output_directory)
|
||||
_validate_new_target(target)
|
||||
staging = _prepare_staging(target)
|
||||
|
||||
inspection = self._adb_client.inspect(serial)
|
||||
_require_expected_device(inspection)
|
||||
adapter = UiautomatorSkuExitAdapter(
|
||||
self._connector(serial),
|
||||
self._timeout_seconds,
|
||||
)
|
||||
|
||||
# 第一次只读核验拒绝把任意页面带入 Back 边界;第二次紧邻 Back,覆盖核验期间漂移。
|
||||
_require_t104_exit_precondition(adapter)
|
||||
_require_t104_exit_precondition(adapter)
|
||||
|
||||
rpc_outcome = "completed"
|
||||
try:
|
||||
adapter.leave_sku_panel()
|
||||
except SkuSelectionRunError:
|
||||
if adapter.back_attempts != 1 or adapter.back_rpc_outcome != "ambiguous":
|
||||
raise
|
||||
# RPC 失败不能证明 Back 未送达。只读采集可供人调和,但绝不再发第二次动作。
|
||||
rpc_outcome = "ambiguous_reconciled"
|
||||
|
||||
if adapter.back_attempts != 1:
|
||||
raise SkuExitSpikeError("返回动作审计不完整,未发布任何证据产物。")
|
||||
if rpc_outcome == "completed" and adapter.back_rpc_outcome != "completed":
|
||||
raise SkuExitSpikeError("返回动作结果不完整,未发布任何证据产物。")
|
||||
|
||||
# Back 后先确认仍是已取证版本的 PDD;不能先把其他前台应用写进本地证据。
|
||||
initial_app = _require_t104_post_app(adapter)
|
||||
|
||||
screenshot_path = staging / "post_exit_screenshot.png"
|
||||
_save_base64_screenshot(adapter.capture_screenshot(), screenshot_path)
|
||||
_require_screenshot_size(screenshot_path)
|
||||
|
||||
hierarchy = adapter.dump_window_hierarchy()
|
||||
_validate_hierarchy(hierarchy)
|
||||
hierarchy_path = staging / "post_exit_hierarchy.xml"
|
||||
hierarchy_path.write_text(hierarchy, encoding="utf-8")
|
||||
|
||||
current_app = _require_t104_post_app(adapter)
|
||||
if current_app != initial_app:
|
||||
raise SkuExitSpikeError("退出后应用摘要在采集期间漂移,未发布任何证据产物。")
|
||||
app_path = staging / "post_exit_app.json"
|
||||
app_path.write_text(
|
||||
json.dumps(current_app, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
manifest_path = staging / "manifest.json"
|
||||
manifest_path.write_text(
|
||||
json.dumps(
|
||||
_t104_exit_manifest(
|
||||
inspection,
|
||||
serial,
|
||||
rpc_outcome,
|
||||
screenshot_path,
|
||||
hierarchy_path,
|
||||
app_path,
|
||||
),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.rename(staging, target)
|
||||
staging = None
|
||||
except (DeviceConnectionError, SkuSelectionError, SkuSelectionRunError):
|
||||
_clean_staging(staging)
|
||||
raise
|
||||
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
|
||||
_clean_staging(staging)
|
||||
raise SkuExitSpikeError("T-104 取证超时,未发布任何证据产物。") from error
|
||||
except OSError as error:
|
||||
_clean_staging(staging)
|
||||
raise SkuExitSpikeError("T-104 证据目录无法发布,未发布任何证据产物。") from error
|
||||
except Exception as error:
|
||||
_clean_staging(staging)
|
||||
raise SkuExitSpikeError("T-104 取证未完成,未发布任何证据产物。") from error
|
||||
|
||||
return SkuExitSpikeResult(target, target / "manifest.json")
|
||||
|
||||
|
||||
class SkuSelectionRunner:
|
||||
"""只运行 T-103 目标规格恢复、价格确认、原始截图和一次安全退出。"""
|
||||
|
||||
@@ -295,7 +590,9 @@ class SkuSelectionRunner:
|
||||
stage = "screenshot_capture"
|
||||
screenshot_path = staging / "screenshot.png"
|
||||
try:
|
||||
_save_base64_screenshot(adapter.capture_screenshot(), screenshot_path)
|
||||
screenshot_payload = adapter.capture_screenshot()
|
||||
captured_at = datetime.now(UTC)
|
||||
_save_base64_screenshot(screenshot_payload, screenshot_path)
|
||||
_require_screenshot_size(screenshot_path)
|
||||
except SkuSelectionRunError:
|
||||
raise
|
||||
@@ -312,9 +609,26 @@ class SkuSelectionRunner:
|
||||
stage = "safe_exit"
|
||||
flow.exit_sku_panel_safely()
|
||||
|
||||
_require_completed_action_audit(adapter)
|
||||
|
||||
stage = "publish"
|
||||
manifest_path.write_text(
|
||||
json.dumps(_manifest(inspection, serial, link, screenshot_path, task_color, task_size), ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
||||
json.dumps(
|
||||
_manifest(
|
||||
inspection,
|
||||
serial,
|
||||
link,
|
||||
screenshot_path,
|
||||
task_color,
|
||||
task_size,
|
||||
adapter,
|
||||
captured_at,
|
||||
),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
# Windows 的 rename 不替换既有目标;并发创建 target 时保留其内容并把本次运行判失败。
|
||||
@@ -340,11 +654,10 @@ class SkuSelectionRunner:
|
||||
_annotate_mapped_failure(mapped, error, stage)
|
||||
raise mapped from error
|
||||
finally:
|
||||
# 失败路径只能复用 Flow 的版本、前台和面板证明;证明不了便停止,绝不盲目返回。
|
||||
if flow is not None and adapter is not None and adapter.entry_was_tapped and not adapter.left_panel:
|
||||
# 结果不明只允许读回 pending;失败路径绝不继续后续动作或自动 Back。
|
||||
if flow is not None:
|
||||
try:
|
||||
flow.reconcile_pending_action()
|
||||
flow.exit_sku_panel_safely()
|
||||
except (SkuSelectionRunError, SkuSelectionError):
|
||||
pass
|
||||
|
||||
@@ -353,6 +666,7 @@ class SkuSelectionRunner:
|
||||
screenshot_path=target / "screenshot.png",
|
||||
manifest_path=target / "manifest.json",
|
||||
unit_price=EXPECTED_UNIT_PRICE,
|
||||
captured_at=captured_at,
|
||||
)
|
||||
|
||||
|
||||
@@ -399,6 +713,87 @@ def _require_expected_device(inspection: DeviceInspection) -> None:
|
||||
raise SkuSelectionRunError("设备型号或 Android 版本不是已取证组合,已停止操作。")
|
||||
|
||||
|
||||
def _require_t104_exit_precondition(adapter: UiautomatorSkuExitAdapter) -> None:
|
||||
"""fresh 核验 T-103 已人验的唯一 Back 前置,不产生任何页面动作。"""
|
||||
|
||||
_require_expected_version(adapter.app_info(PDD_PACKAGE))
|
||||
current = adapter.app_current()
|
||||
if current.get("package") != PDD_PACKAGE:
|
||||
raise SkuExitSpikeError("Back 前拼多多不在前台,已停止取证。")
|
||||
if adapter.display_size() != EXPECTED_SCREEN_SIZE:
|
||||
raise SkuExitSpikeError("屏幕坐标空间不是已取证尺寸,已停止取证。")
|
||||
if _unit_price(_parse_nodes(adapter.dump_window_hierarchy())) != EXPECTED_UNIT_PRICE:
|
||||
raise SkuExitSpikeError("Back 前目标规格或当前价不匹配,已停止取证。")
|
||||
|
||||
|
||||
def _post_exit_app_evidence(value: object) -> dict[str, str]:
|
||||
"""只保留页面身份复核需要的应用字段,不把第三方返回整体写盘。"""
|
||||
|
||||
if not isinstance(value, dict):
|
||||
raise SkuExitSpikeError("退出后应用摘要无效,未发布任何证据产物。")
|
||||
package = value.get("package")
|
||||
activity = value.get("activity")
|
||||
if not isinstance(package, str) or not package.strip():
|
||||
raise SkuExitSpikeError("退出后应用包名缺失,未发布任何证据产物。")
|
||||
if not isinstance(activity, str) or not activity.strip():
|
||||
raise SkuExitSpikeError("退出后 Activity 缺失,未发布任何证据产物。")
|
||||
return {"package": package, "activity": activity}
|
||||
|
||||
|
||||
def _require_t104_post_app(adapter: UiautomatorSkuExitAdapter) -> dict[str, str]:
|
||||
current = _post_exit_app_evidence(adapter.app_current())
|
||||
if current["package"] != PDD_PACKAGE:
|
||||
raise SkuExitSpikeError("Back 后拼多多不在前台,未采集页面证据。")
|
||||
_require_expected_version(adapter.app_info(PDD_PACKAGE))
|
||||
return current
|
||||
|
||||
|
||||
def _t104_exit_manifest(
|
||||
inspection: DeviceInspection,
|
||||
serial: str,
|
||||
rpc_outcome: str,
|
||||
screenshot_path: Path,
|
||||
hierarchy_path: Path,
|
||||
app_path: Path,
|
||||
) -> dict[str, Any]:
|
||||
"""阶段 A 只写事实与哈希;不声明退出成功,也不授予价格证据角色。"""
|
||||
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"captured_at": datetime.now(UTC).isoformat(),
|
||||
"operation": "t104-sku-exit-evidence",
|
||||
"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,
|
||||
},
|
||||
"back_attempts": 1,
|
||||
"rpc_outcome": rpc_outcome,
|
||||
"post_exit_status": "human_review_required",
|
||||
"artifacts": [
|
||||
{
|
||||
"path": screenshot_path.name,
|
||||
"role": "post_exit_human_review_only",
|
||||
"sha256": _sha256_file(screenshot_path),
|
||||
},
|
||||
{
|
||||
"path": hierarchy_path.name,
|
||||
"role": "post_exit_raw_hierarchy_local_only",
|
||||
"sha256": _sha256_file(hierarchy_path),
|
||||
},
|
||||
{
|
||||
"path": app_path.name,
|
||||
"role": "post_exit_app_identity_human_review_only",
|
||||
"sha256": _sha256_file(app_path),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _require_screenshot_size(screenshot_path: Path) -> None:
|
||||
try:
|
||||
with Image.open(screenshot_path) as image:
|
||||
@@ -411,20 +806,83 @@ def _require_screenshot_size(screenshot_path: Path) -> None:
|
||||
raise SkuSelectionScreenshotError("原始截图无效,未发布任何证据产物。") from error
|
||||
|
||||
|
||||
def _manifest(inspection: DeviceInspection, serial: str, link: ProductUrl, screenshot_path: Path, task_color: str, task_size: str) -> dict[str, Any]:
|
||||
def _require_completed_action_audit(adapter: UiautomatorSkuPanelAdapter) -> None:
|
||||
"""发布只接受完整正常链;测试替身或缺动作结果不能伪造真机闭环。"""
|
||||
|
||||
if (
|
||||
not adapter.entry_was_tapped
|
||||
or adapter.entry_bounds_attempted != _ENTRY_TEXT_BOUNDS
|
||||
or adapter.entry_rpc_outcome != "completed"
|
||||
or adapter.option_rpc_outcomes
|
||||
!= tuple(
|
||||
sorted(
|
||||
(
|
||||
(_TARGET_COLOR_UNROLLED_BOUNDS, "completed"),
|
||||
(_TARGET_SIZE_BOUNDS, "completed"),
|
||||
)
|
||||
)
|
||||
)
|
||||
or not adapter.reveal_attempted
|
||||
or adapter.reveal_rpc_outcome != "completed"
|
||||
or adapter.back_attempts != 1
|
||||
or adapter.back_rpc_outcome != "completed"
|
||||
):
|
||||
raise SkuSelectionRunError("规格动作审计链不完整,未发布任何证据产物。")
|
||||
|
||||
|
||||
def _manifest(
|
||||
inspection: DeviceInspection,
|
||||
serial: str,
|
||||
link: ProductUrl,
|
||||
screenshot_path: Path,
|
||||
task_color: str,
|
||||
task_size: str,
|
||||
adapter: UiautomatorSkuPanelAdapter,
|
||||
captured_at: datetime,
|
||||
) -> dict[str, Any]:
|
||||
"""仅写可审计摘要;原始 serial、节点树、页面文案和实际截图内容均不写入 manifest。"""
|
||||
|
||||
option_outcomes = dict(adapter.option_rpc_outcomes)
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"captured_at": datetime.now(UTC).isoformat(),
|
||||
"captured_at": captured_at.isoformat(),
|
||||
"operation": "t103-sku-selection",
|
||||
"product": {"goods_id": link.goods_id, "canonical_url": link.canonical_url},
|
||||
"target_selection": {"color": task_color, "size": task_size},
|
||||
"unit_price": EXPECTED_UNIT_PRICE,
|
||||
"selection_status": "restored",
|
||||
"panel_status": "verified",
|
||||
"panel_status": "verified_before_back",
|
||||
"back_attempts": adapter.back_attempts,
|
||||
"back_rpc_outcome": adapter.back_rpc_outcome,
|
||||
"actions": {
|
||||
"sku_entry": {
|
||||
"attempts": int(adapter.entry_was_tapped),
|
||||
"rpc_outcome": adapter.entry_rpc_outcome,
|
||||
},
|
||||
"target_color": {
|
||||
"attempts": int(_TARGET_COLOR_UNROLLED_BOUNDS in option_outcomes),
|
||||
"rpc_outcome": option_outcomes.get(
|
||||
_TARGET_COLOR_UNROLLED_BOUNDS, "not_attempted"
|
||||
),
|
||||
},
|
||||
"size_reveal": {
|
||||
"attempts": int(adapter.reveal_attempted),
|
||||
"rpc_outcome": adapter.reveal_rpc_outcome,
|
||||
},
|
||||
"target_size": {
|
||||
"attempts": int(_TARGET_SIZE_BOUNDS in option_outcomes),
|
||||
"rpc_outcome": option_outcomes.get(
|
||||
_TARGET_SIZE_BOUNDS, "not_attempted"
|
||||
),
|
||||
},
|
||||
"back": {
|
||||
"attempts": adapter.back_attempts,
|
||||
"rpc_outcome": adapter.back_rpc_outcome,
|
||||
},
|
||||
},
|
||||
"post_exit_status": "same_product_verified",
|
||||
"safe_exit": "completed",
|
||||
"page_identity": "human_review_required",
|
||||
"page_identity": "same_goods_evidence_bound",
|
||||
"channel": "wifi" if ":" in serial else "usb",
|
||||
"serial_sha256": sha256(serial.encode("utf-8")).hexdigest(),
|
||||
"device": {
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,12 @@
|
||||
<hierarchy rotation="0">
|
||||
<!-- T-104 同商品正锚:只保留人工确认 post-exit XML 中的标题容器及两个直接文本子节点。 -->
|
||||
<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>
|
||||
<!-- 同一份 post-exit XML 中的底部规格入口;只证明页面身份,不在安全退出中点击。 -->
|
||||
<node content-desc="快要抢光¥12.88" package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" 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" 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" bounds="[688,2256][856,2305]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
</hierarchy>
|
||||
@@ -0,0 +1,30 @@
|
||||
<hierarchy rotation="0">
|
||||
<!-- T-105 只保留人工确认数量 1 证据中的规格、顶部金额与数量控件。 -->
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[0,474][1080,863]" 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][895,570]" clickable="false" 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="[396,503][712,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,654][1053,716]" 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,752][645,827]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[396,752][645,827]" 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,752][474,827]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
<node text="1" package="com.xunmeng.pinduoduo" class="android.widget.EditText" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[480,752][561,827]" 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,752][645,827]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[126,1000][438,1024]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[126,1000][438,1024]" clickable="false" enabled="true" visible-to-user="true" selected="true" scrollable="false">
|
||||
<node text="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" resource-id="com.xunmeng.pinduoduo:id/tv_content" bounds="[126,1000][438,1024]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
|
||||
</node>
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[439,1582][831,1667]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="M(建议100-115)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[439,1582][831,1667]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
|
||||
</node>
|
||||
<!-- 底部金额故意保留为反例;生产读取器只认上方证据角色。 -->
|
||||
<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 text="提交订单 ¥12.88" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[369,2225][710,2284]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
</hierarchy>
|
||||
@@ -0,0 +1,30 @@
|
||||
<hierarchy rotation="0">
|
||||
<!-- T-105 只保留人工确认数量 2 证据中的规格、顶部金额与数量控件。 -->
|
||||
<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" bounds="[126,998][438,1024]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[126,998][438,1024]" clickable="false" enabled="true" visible-to-user="true" selected="true" scrollable="false">
|
||||
<node text="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" resource-id="com.xunmeng.pinduoduo:id/tv_content" bounds="[126,998][438,1024]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
|
||||
</node>
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[439,1582][831,1667]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="M(建议100-115)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[439,1582][831,1667]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
|
||||
</node>
|
||||
<!-- 与 Gate2 值相同也不能成为读取候选。 -->
|
||||
<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 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>
|
||||
</hierarchy>
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<hierarchy>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,0][1080,2376]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,0][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,120][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,120][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,120][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" 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.LinearLayout" bounds="[0,366][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,366][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,366][1080,1077]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,551][1080,940]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.RelativeLayout" bounds="[0,551][1080,940]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,551][1080,940]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" 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" bounds="[396,575][693,647]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="限1件 ¥12.88 " package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[396,580][675,647]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
</node>
|
||||
<node text="请选择: 尺码" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[396,731][1053,793]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="androidx.recyclerview.widget.RecyclerView" bounds="[0,1077][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="true">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1077][1080,2052]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1077][1080,1188]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="颜色分类" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,1106][192,1159]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1188][1080,2052]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,1188][1080,2046]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="androidx.recyclerview.widget.RecyclerView" bounds="[36,1188][1080,2046]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="true">
|
||||
<node content-desc="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[372,1188][684,1587]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.View" bounds="[372,1188][684,1587]" clickable="false" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
|
||||
<node content-desc="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.widget.ImageView" bounds="[372,1188][684,1500]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
|
||||
<node content-desc="打开大图" package="com.xunmeng.pinduoduo" class="android.widget.ImageView" bounds="[372,1188][483,1299]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[372,1479][684,1587]" clickable="false" enabled="true" visible-to-user="true" selected="true" scrollable="false">
|
||||
<node text="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[372,1479][684,1587]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,2052][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[36,2052][1044,2079]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="尺码" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,2069][114,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</hierarchy>
|
||||
@@ -0,0 +1,59 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<hierarchy>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,0][1080,2376]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,0][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,120][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,120][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,120][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" 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.LinearLayout" bounds="[0,366][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,366][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,366][1080,1077]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,551][1080,940]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.RelativeLayout" bounds="[0,551][1080,940]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,551][1080,940]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" 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" bounds="[396,575][693,647]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="限1件 ¥12.88 " package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[396,580][675,647]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
</node>
|
||||
<node text="请选择: 颜色分类 尺码" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[396,731][1053,793]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="androidx.recyclerview.widget.RecyclerView" bounds="[0,1077][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="true">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1077][1080,2052]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1077][1080,1188]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="颜色分类" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,1106][192,1159]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1188][1080,2052]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,1188][1080,2046]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="androidx.recyclerview.widget.RecyclerView" bounds="[36,1188][1080,2046]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="true">
|
||||
<node content-desc="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[372,1188][684,1587]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.view.View" bounds="[372,1188][684,1587]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
<node content-desc="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.widget.ImageView" bounds="[372,1188][684,1500]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
<node content-desc="打开大图" package="com.xunmeng.pinduoduo" class="android.widget.ImageView" bounds="[372,1188][483,1299]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[372,1479][684,1587]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[372,1479][684,1587]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,2052][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[36,2052][1044,2079]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
|
||||
<node text="尺码" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,2069][114,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</hierarchy>
|
||||
@@ -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()
|
||||
@@ -0,0 +1,601 @@
|
||||
"""T-106 合并式最终提交面板与人工返回态纯只读取证测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import base64
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
from functools import lru_cache
|
||||
from importlib.util import module_from_spec, spec_from_file_location
|
||||
from io import BytesIO, StringIO
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from tempfile import TemporaryDirectory
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
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, CommandResult, DeviceInspection
|
||||
from cmbuyer_client.pdd.order_confirm_spike import (
|
||||
Android16ForegroundReader,
|
||||
DECLARED_STATES,
|
||||
EXPECTED_GOODS_ID,
|
||||
OrderConfirmEvidenceCapturer,
|
||||
OrderConfirmEvidenceError,
|
||||
OrderConfirmEvidenceTimeoutError,
|
||||
OrderConfirmForegroundReader,
|
||||
OrderConfirmReadDevice,
|
||||
)
|
||||
|
||||
|
||||
SERIAL = "192.168.0.173:5555"
|
||||
HIERARCHY = (
|
||||
"<?xml version='1.0' encoding='UTF-8'?><hierarchy rotation='0'>"
|
||||
"<node text='local raw page' /></hierarchy>"
|
||||
)
|
||||
TOP_RESUMED = (
|
||||
" topResumedActivity=ActivityRecord{101034589 u0 "
|
||||
"com.xunmeng.pinduoduo/.activity.NewPageActivity t1816}\n"
|
||||
)
|
||||
|
||||
|
||||
@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 FakeAdbClient:
|
||||
def __init__(self, *, model: str = "PKG110", android_version: str = "16") -> None:
|
||||
self.calls: list[str] = []
|
||||
self.inspection = DeviceInspection(
|
||||
device=AdbDevice(serial=SERIAL, state="device", model=model),
|
||||
model=model,
|
||||
android_version=android_version,
|
||||
)
|
||||
|
||||
def inspect(self, serial: str) -> DeviceInspection:
|
||||
self.calls.append(serial)
|
||||
return self.inspection
|
||||
|
||||
|
||||
class FakeReadDevice:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
version: str = "8.17.0",
|
||||
screen_size: tuple[int, int] = (1080, 2376),
|
||||
screenshot: object | None = None,
|
||||
hierarchy: object = HIERARCHY,
|
||||
) -> None:
|
||||
self.version = version
|
||||
self.screen_size = screen_size
|
||||
self.screenshot = _png_base64() if screenshot is None else screenshot
|
||||
self.hierarchy = hierarchy
|
||||
self.calls: list[tuple[object, ...]] = []
|
||||
self.app_info_reads = 0
|
||||
self.window_reads = 0
|
||||
self.post_version: str | None = None
|
||||
self.post_screen_size: tuple[int, int] | None = None
|
||||
|
||||
def app_info(self, package_name: str) -> dict[str, str]:
|
||||
self.calls.append(("app_info", package_name))
|
||||
self.app_info_reads += 1
|
||||
version = self.post_version if self.app_info_reads > 1 and self.post_version else self.version
|
||||
return {"versionName": version}
|
||||
|
||||
def window_size(self) -> tuple[int, int]:
|
||||
self.calls.append(("window_size",))
|
||||
self.window_reads += 1
|
||||
if self.window_reads > 1 and self.post_screen_size is not None:
|
||||
return self.post_screen_size
|
||||
return self.screen_size
|
||||
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> object:
|
||||
self.calls.append(("jsonrpc", method, params, timeout))
|
||||
if method == "takeScreenshot":
|
||||
return self.screenshot
|
||||
if method == "dumpWindowHierarchy":
|
||||
return self.hierarchy
|
||||
raise AssertionError("unexpected read RPC")
|
||||
|
||||
|
||||
class FakeForegroundReader:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
package: str = "com.xunmeng.pinduoduo",
|
||||
activity: str = ".activity.NewPageActivity",
|
||||
) -> None:
|
||||
self.package = package
|
||||
self.activity = activity
|
||||
self.post_package: str | None = None
|
||||
self.post_activity: str | None = None
|
||||
self.calls: list[str] = []
|
||||
|
||||
def read(self, serial: str) -> dict[str, str]:
|
||||
self.calls.append(serial)
|
||||
package = self.post_package if len(self.calls) > 1 and self.post_package else self.package
|
||||
activity = self.post_activity if len(self.calls) > 1 and self.post_activity else self.activity
|
||||
return {"package": package, "activity": activity}
|
||||
|
||||
|
||||
class FakeCommandRunner:
|
||||
def __init__(self, result: CommandResult) -> None:
|
||||
self.result = result
|
||||
self.calls: list[tuple[tuple[str, ...], float]] = []
|
||||
|
||||
def run(self, arguments: tuple[str, ...], timeout_seconds: float) -> CommandResult:
|
||||
self.calls.append((arguments, timeout_seconds))
|
||||
return self.result
|
||||
|
||||
|
||||
def _load_script() -> object:
|
||||
path = CLIENT_ROOT / "scripts" / "capture_order_confirm_spike.py"
|
||||
specification = spec_from_file_location("capture_order_confirm_spike_for_test", path)
|
||||
assert specification is not None and specification.loader is not None
|
||||
module = module_from_spec(specification)
|
||||
specification.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _namespace(**changes: object) -> argparse.Namespace:
|
||||
values: dict[str, object] = {
|
||||
"serial": SERIAL,
|
||||
"goods_id": EXPECTED_GOODS_ID,
|
||||
"state": DECLARED_STATES[0],
|
||||
"output_dir": Path("evidence"),
|
||||
"timeout": 10.0,
|
||||
"adb": "adb",
|
||||
}
|
||||
values.update(changes)
|
||||
return argparse.Namespace(**values)
|
||||
|
||||
|
||||
class Android16ForegroundReaderTests(unittest.TestCase):
|
||||
def test_reads_exact_unique_top_resumed_activity(self) -> None:
|
||||
runner = FakeCommandRunner(CommandResult(stdout=TOP_RESUMED))
|
||||
reader = Android16ForegroundReader(runner, timeout_seconds=7)
|
||||
|
||||
self.assertEqual(
|
||||
reader.read(SERIAL),
|
||||
{
|
||||
"package": "com.xunmeng.pinduoduo",
|
||||
"activity": ".activity.NewPageActivity",
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
runner.calls,
|
||||
[
|
||||
(
|
||||
("-s", SERIAL, "shell", "dumpsys", "activity", "activities"),
|
||||
7,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
def test_missing_legacy_duplicate_or_failed_summary_is_rejected(self) -> None:
|
||||
outputs = (
|
||||
"",
|
||||
"mResumedActivity: ActivityRecord{1 u0 com.xunmeng.pinduoduo/.Main t1}\n",
|
||||
TOP_RESUMED + TOP_RESUMED,
|
||||
"topResumedActivity=null\n",
|
||||
)
|
||||
for output in outputs:
|
||||
with self.subTest(output=output):
|
||||
reader = Android16ForegroundReader(
|
||||
FakeCommandRunner(CommandResult(stdout=output)),
|
||||
timeout_seconds=7,
|
||||
)
|
||||
with self.assertRaises(OrderConfirmEvidenceError):
|
||||
reader.read(SERIAL)
|
||||
|
||||
reader = Android16ForegroundReader(
|
||||
FakeCommandRunner(CommandResult(stdout=TOP_RESUMED, returncode=1)),
|
||||
timeout_seconds=7,
|
||||
)
|
||||
with self.assertRaises(OrderConfirmEvidenceError):
|
||||
reader.read(SERIAL)
|
||||
with self.assertRaises(OrderConfirmEvidenceError):
|
||||
reader.read(f" {SERIAL}")
|
||||
|
||||
|
||||
class OrderConfirmEvidenceTests(unittest.TestCase):
|
||||
def _capturer(
|
||||
self,
|
||||
adb: FakeAdbClient,
|
||||
device: FakeReadDevice,
|
||||
*,
|
||||
foreground: FakeForegroundReader | None = None,
|
||||
connector_calls: list[str] | None = None,
|
||||
) -> OrderConfirmEvidenceCapturer:
|
||||
def connect(serial: str) -> FakeReadDevice:
|
||||
if connector_calls is not None:
|
||||
connector_calls.append(serial)
|
||||
return device
|
||||
|
||||
return OrderConfirmEvidenceCapturer(
|
||||
adb, # type: ignore[arg-type]
|
||||
connect,
|
||||
foreground or FakeForegroundReader(),
|
||||
timeout_seconds=2,
|
||||
)
|
||||
|
||||
def test_only_approved_human_states_publish_raw_read_evidence(self) -> None:
|
||||
self.assertEqual(DECLARED_STATES, ("gate2-navigation-source", "returned-safe-page"))
|
||||
for state in DECLARED_STATES:
|
||||
with self.subTest(state=state), TemporaryDirectory() as temporary:
|
||||
adb = FakeAdbClient()
|
||||
device = FakeReadDevice()
|
||||
foreground = FakeForegroundReader()
|
||||
connector_calls: list[str] = []
|
||||
target = Path(temporary) / state
|
||||
|
||||
result = self._capturer(
|
||||
adb,
|
||||
device,
|
||||
foreground=foreground,
|
||||
connector_calls=connector_calls,
|
||||
).capture(SERIAL, EXPECTED_GOODS_ID, state, target)
|
||||
|
||||
manifest_text = result.manifest_path.read_text(encoding="utf-8")
|
||||
manifest = json.loads(manifest_text)
|
||||
app = json.loads(result.app_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(adb.calls, [SERIAL])
|
||||
self.assertEqual(connector_calls, [SERIAL])
|
||||
self.assertEqual(foreground.calls, [SERIAL, SERIAL])
|
||||
self.assertEqual(
|
||||
[call[1] for call in device.calls if call[0] == "jsonrpc"],
|
||||
["takeScreenshot", "dumpWindowHierarchy"],
|
||||
)
|
||||
self.assertEqual(manifest["operation"], "t106-order-confirm-readonly-evidence")
|
||||
self.assertEqual(manifest["human_declared_state"], state)
|
||||
self.assertEqual(manifest["review_status"], "human_review_required")
|
||||
self.assertEqual(manifest["product"]["goods_id"], EXPECTED_GOODS_ID)
|
||||
self.assertEqual(app["package"], "com.xunmeng.pinduoduo")
|
||||
self.assertEqual(
|
||||
{path.name for path in target.iterdir()},
|
||||
{"screenshot.png", "hierarchy.xml", "app.json", "manifest.json"},
|
||||
)
|
||||
self.assertNotIn(SERIAL, manifest_text)
|
||||
self.assertNotIn("NewPageActivity", manifest_text)
|
||||
self.assertNotIn("local raw page", manifest_text)
|
||||
for artifact in manifest["artifacts"]:
|
||||
self.assertEqual(len(artifact["sha256"]), 64)
|
||||
|
||||
def test_invalid_inputs_and_existing_target_stop_before_device_access(self) -> None:
|
||||
scenarios = (
|
||||
("", EXPECTED_GOODS_ID, DECLARED_STATES[0]),
|
||||
(f" {SERIAL}", EXPECTED_GOODS_ID, 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"),
|
||||
)
|
||||
for serial, goods_id, state in scenarios:
|
||||
with self.subTest(state=state), TemporaryDirectory() as temporary:
|
||||
adb = FakeAdbClient()
|
||||
connector_calls: list[str] = []
|
||||
with self.assertRaises(OrderConfirmEvidenceError):
|
||||
self._capturer(
|
||||
adb,
|
||||
FakeReadDevice(),
|
||||
connector_calls=connector_calls,
|
||||
).capture(serial, goods_id, state, Path(temporary) / "evidence")
|
||||
self.assertEqual(adb.calls, [])
|
||||
self.assertEqual(connector_calls, [])
|
||||
|
||||
with TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "evidence"
|
||||
target.mkdir()
|
||||
sentinel = target / "sentinel.txt"
|
||||
sentinel.write_text("keep", encoding="utf-8")
|
||||
adb = FakeAdbClient()
|
||||
with self.assertRaises(OrderConfirmEvidenceError):
|
||||
self._capturer(adb, FakeReadDevice()).capture(
|
||||
SERIAL, EXPECTED_GOODS_ID, DECLARED_STATES[0], target
|
||||
)
|
||||
self.assertEqual(adb.calls, [])
|
||||
self.assertEqual(sentinel.read_text(encoding="utf-8"), "keep")
|
||||
|
||||
def test_device_version_package_activity_and_screen_mismatch_fail_closed(self) -> None:
|
||||
scenarios = (
|
||||
(FakeAdbClient(model="OTHER"), FakeReadDevice(), FakeForegroundReader()),
|
||||
(FakeAdbClient(android_version="15"), FakeReadDevice(), FakeForegroundReader()),
|
||||
(FakeAdbClient(), FakeReadDevice(version="8.17.1"), FakeForegroundReader()),
|
||||
(FakeAdbClient(), FakeReadDevice(), FakeForegroundReader(package="com.example.other")),
|
||||
(FakeAdbClient(), FakeReadDevice(), FakeForegroundReader(activity="")),
|
||||
(FakeAdbClient(), FakeReadDevice(screen_size=(1080, 2400)), FakeForegroundReader()),
|
||||
)
|
||||
for adb, device, foreground in scenarios:
|
||||
with self.subTest(device=device.__dict__), TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "evidence"
|
||||
with self.assertRaises(OrderConfirmEvidenceError):
|
||||
self._capturer(adb, device, foreground=foreground).capture(
|
||||
SERIAL, EXPECTED_GOODS_ID, DECLARED_STATES[0], target
|
||||
)
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
|
||||
self.assertEqual([call for call in device.calls if call[0] == "jsonrpc"], [])
|
||||
|
||||
def test_invalid_screenshot_or_hierarchy_never_publishes(self) -> None:
|
||||
devices = (
|
||||
FakeReadDevice(screenshot="not base64"),
|
||||
FakeReadDevice(screenshot=123),
|
||||
FakeReadDevice(hierarchy="<not-hierarchy />"),
|
||||
FakeReadDevice(hierarchy=123),
|
||||
)
|
||||
for device in devices:
|
||||
with self.subTest(value=device.screenshot), TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "evidence"
|
||||
with self.assertRaises(OrderConfirmEvidenceError):
|
||||
self._capturer(FakeAdbClient(), device).capture(
|
||||
SERIAL, EXPECTED_GOODS_ID, DECLARED_STATES[0], target
|
||||
)
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
|
||||
|
||||
def test_timeout_and_unexpected_failure_are_redacted(self) -> None:
|
||||
class BrokenDevice(FakeReadDevice):
|
||||
def __init__(self, failure: BaseException) -> None:
|
||||
super().__init__()
|
||||
self.failure = failure
|
||||
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> object:
|
||||
raise self.failure
|
||||
|
||||
failures = (
|
||||
(TimeoutError(f"secret {SERIAL} C:\\private\\raw.xml"), OrderConfirmEvidenceTimeoutError),
|
||||
(RuntimeError(f"secret {SERIAL} C:\\private\\raw.xml"), OrderConfirmEvidenceError),
|
||||
)
|
||||
for failure, expected in failures:
|
||||
with self.subTest(expected=expected), TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "evidence"
|
||||
with self.assertRaises(expected) as raised:
|
||||
self._capturer(FakeAdbClient(), BrokenDevice(failure)).capture(
|
||||
SERIAL, EXPECTED_GOODS_ID, DECLARED_STATES[0], target
|
||||
)
|
||||
self.assertNotIn(SERIAL, str(raised.exception))
|
||||
self.assertNotIn("private", str(raised.exception))
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
|
||||
|
||||
def test_post_capture_identity_drift_never_publishes(self) -> None:
|
||||
mutators = (
|
||||
lambda device, foreground: setattr(device, "post_version", "8.17.1"),
|
||||
lambda device, foreground: setattr(foreground, "post_package", "com.example.other"),
|
||||
lambda device, foreground: setattr(foreground, "post_activity", "OtherActivity"),
|
||||
lambda device, foreground: setattr(device, "post_screen_size", (1080, 2400)),
|
||||
)
|
||||
for mutate in mutators:
|
||||
with TemporaryDirectory() as temporary:
|
||||
device = FakeReadDevice()
|
||||
foreground = FakeForegroundReader()
|
||||
mutate(device, foreground)
|
||||
target = Path(temporary) / "evidence"
|
||||
with self.assertRaises(OrderConfirmEvidenceError):
|
||||
self._capturer(
|
||||
FakeAdbClient(), device, foreground=foreground
|
||||
).capture(SERIAL, EXPECTED_GOODS_ID, DECLARED_STATES[0], target)
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
|
||||
|
||||
def test_atomic_publish_failure_cleans_staging(self) -> None:
|
||||
with TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "evidence"
|
||||
with (
|
||||
patch(
|
||||
"cmbuyer_client.pdd.order_confirm_spike.os.rename",
|
||||
side_effect=OSError(f"private {SERIAL}"),
|
||||
),
|
||||
self.assertRaises(OrderConfirmEvidenceError) as raised,
|
||||
):
|
||||
self._capturer(FakeAdbClient(), FakeReadDevice()).capture(
|
||||
SERIAL, EXPECTED_GOODS_ID, DECLARED_STATES[0], target
|
||||
)
|
||||
self.assertNotIn(SERIAL, str(raised.exception))
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
|
||||
|
||||
def test_capturer_is_one_shot_even_after_rejection(self) -> None:
|
||||
capturer = self._capturer(FakeAdbClient(), FakeReadDevice())
|
||||
with TemporaryDirectory() as temporary:
|
||||
with self.assertRaises(OrderConfirmEvidenceError):
|
||||
capturer.capture("", EXPECTED_GOODS_ID, DECLARED_STATES[0], Path(temporary) / "bad")
|
||||
with self.assertRaises(OrderConfirmEvidenceError):
|
||||
capturer.capture(
|
||||
SERIAL, EXPECTED_GOODS_ID, DECLARED_STATES[0], Path(temporary) / "good"
|
||||
)
|
||||
|
||||
def test_public_boundaries_expose_reading_only(self) -> None:
|
||||
forbidden = {
|
||||
"click",
|
||||
"swipe",
|
||||
"press",
|
||||
"pressKey",
|
||||
"send_keys",
|
||||
"set_text",
|
||||
"start_pdd_view_intent",
|
||||
"open_product",
|
||||
"open_sku_panel",
|
||||
"set_quantity",
|
||||
"go_to_order_confirm",
|
||||
"submit_order_once",
|
||||
"pay",
|
||||
}
|
||||
self.assertTrue(forbidden.isdisjoint(OrderConfirmReadDevice.__dict__))
|
||||
self.assertTrue(forbidden.isdisjoint(OrderConfirmForegroundReader.__dict__))
|
||||
self.assertEqual(
|
||||
{name for name in Android16ForegroundReader.__dict__ if not name.startswith("_")},
|
||||
{"read"},
|
||||
)
|
||||
self.assertEqual(
|
||||
{name for name in OrderConfirmEvidenceCapturer.__dict__ if not name.startswith("_")},
|
||||
{"capture"},
|
||||
)
|
||||
|
||||
|
||||
class OrderConfirmCliAndStaticBoundaryTests(unittest.TestCase):
|
||||
def test_cli_parses_each_approved_state(self) -> None:
|
||||
script = _load_script()
|
||||
for state in DECLARED_STATES:
|
||||
arguments = script.parse_arguments( # type: ignore[attr-defined]
|
||||
[
|
||||
"--serial",
|
||||
SERIAL,
|
||||
"--goods-id",
|
||||
EXPECTED_GOODS_ID,
|
||||
"--state",
|
||||
state,
|
||||
"--output-dir",
|
||||
f"evidence-{state}",
|
||||
]
|
||||
)
|
||||
script.validate_arguments(arguments) # type: ignore[attr-defined]
|
||||
|
||||
def test_cli_validation_rejects_unapproved_values(self) -> None:
|
||||
script = _load_script()
|
||||
invalid = (
|
||||
_namespace(serial=""),
|
||||
_namespace(serial=f" {SERIAL}"),
|
||||
_namespace(goods_id="958756616606"),
|
||||
_namespace(state="confirm-gate3"),
|
||||
_namespace(state="submit-control-visible"),
|
||||
_namespace(state="unknown"),
|
||||
_namespace(output_dir=Path("")),
|
||||
_namespace(timeout=0),
|
||||
_namespace(timeout=float("inf")),
|
||||
)
|
||||
for arguments in invalid:
|
||||
with self.subTest(arguments=arguments), self.assertRaises(ValueError):
|
||||
script.validate_arguments(arguments) # type: ignore[attr-defined]
|
||||
|
||||
def test_cli_runtime_failure_never_echoes_sensitive_values(self) -> None:
|
||||
script = _load_script()
|
||||
fake_capturer = unittest.mock.Mock()
|
||||
fake_capturer.capture.side_effect = OrderConfirmEvidenceError(
|
||||
f"secret {SERIAL} C:\\private\\raw.xml body"
|
||||
)
|
||||
stderr = StringIO()
|
||||
with (
|
||||
patch.object(script, "OrderConfirmEvidenceCapturer", return_value=fake_capturer),
|
||||
patch.dict(
|
||||
sys.modules,
|
||||
{"adbutils": unittest.mock.Mock(), "uiautomator2": unittest.mock.Mock()},
|
||||
),
|
||||
redirect_stderr(stderr),
|
||||
):
|
||||
result = script.main( # type: ignore[attr-defined]
|
||||
[
|
||||
"--serial",
|
||||
SERIAL,
|
||||
"--goods-id",
|
||||
EXPECTED_GOODS_ID,
|
||||
"--state",
|
||||
DECLARED_STATES[0],
|
||||
"--output-dir",
|
||||
"C:\\private\\evidence",
|
||||
]
|
||||
)
|
||||
self.assertEqual(result, 1)
|
||||
self.assertNotIn(SERIAL, stderr.getvalue())
|
||||
self.assertNotIn("private", stderr.getvalue())
|
||||
self.assertNotIn("body", stderr.getvalue())
|
||||
|
||||
def test_cli_success_does_not_echo_local_path_or_device(self) -> None:
|
||||
script = _load_script()
|
||||
fake_capturer = unittest.mock.Mock()
|
||||
stdout = StringIO()
|
||||
with (
|
||||
patch.object(script, "OrderConfirmEvidenceCapturer", return_value=fake_capturer),
|
||||
patch.dict(
|
||||
sys.modules,
|
||||
{"adbutils": unittest.mock.Mock(), "uiautomator2": unittest.mock.Mock()},
|
||||
),
|
||||
redirect_stdout(stdout),
|
||||
):
|
||||
result = script.main( # type: ignore[attr-defined]
|
||||
[
|
||||
"--serial",
|
||||
SERIAL,
|
||||
"--goods-id",
|
||||
EXPECTED_GOODS_ID,
|
||||
"--state",
|
||||
DECLARED_STATES[0],
|
||||
"--output-dir",
|
||||
"C:\\private\\evidence",
|
||||
]
|
||||
)
|
||||
self.assertEqual(result, 0)
|
||||
self.assertNotIn(SERIAL, stdout.getvalue())
|
||||
self.assertNotIn("private", stdout.getvalue())
|
||||
fake_capturer.capture.assert_called_once()
|
||||
|
||||
def test_sources_have_only_approved_read_rpc_literals_and_no_ui_mutators(self) -> None:
|
||||
expected_by_source = {
|
||||
Path("src/cmbuyer_client/pdd/order_confirm_spike.py"): {
|
||||
"takeScreenshot",
|
||||
"dumpWindowHierarchy",
|
||||
},
|
||||
Path("scripts/capture_order_confirm_spike.py"): set(),
|
||||
}
|
||||
forbidden_attributes = {
|
||||
"click",
|
||||
"swipe",
|
||||
"press",
|
||||
"pressKey",
|
||||
"send_keys",
|
||||
"set_text",
|
||||
"start_pdd_view_intent",
|
||||
"open_product",
|
||||
"open_sku_panel",
|
||||
"set_quantity_and_readback",
|
||||
"go_to_order_confirm",
|
||||
"submit_order_once",
|
||||
}
|
||||
forbidden_import_fragments = {
|
||||
"quantity_gate2_runner",
|
||||
"sku_selection_runner",
|
||||
"submission",
|
||||
"payment",
|
||||
}
|
||||
for relative, expected_rpc in expected_by_source.items():
|
||||
source = (CLIENT_ROOT / relative).read_text(encoding="utf-8")
|
||||
tree = ast.parse(source)
|
||||
rpc_literals = {
|
||||
node.args[1].value
|
||||
for node in ast.walk(tree)
|
||||
if isinstance(node, ast.Call)
|
||||
and isinstance(node.func, ast.Name)
|
||||
and node.func.id == "_read_rpc"
|
||||
and len(node.args) > 1
|
||||
and isinstance(node.args[1], ast.Constant)
|
||||
and isinstance(node.args[1].value, str)
|
||||
}
|
||||
self.assertEqual(rpc_literals, expected_rpc)
|
||||
observed_attributes = {
|
||||
node.attr
|
||||
for node in ast.walk(tree)
|
||||
if isinstance(node, ast.Attribute) and node.attr in forbidden_attributes
|
||||
}
|
||||
self.assertEqual(observed_attributes, set())
|
||||
imported_modules = {
|
||||
alias.name
|
||||
for node in ast.walk(tree)
|
||||
if isinstance(node, (ast.Import, ast.ImportFrom))
|
||||
for alias in node.names
|
||||
}
|
||||
for fragment in forbidden_import_fragments:
|
||||
self.assertTrue(all(fragment not in module for module in imported_modules))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,944 @@
|
||||
"""T-105 数量两态只读取证与后续生产边界的离线测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import base64
|
||||
from contextlib import redirect_stderr
|
||||
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 json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from tempfile import TemporaryDirectory
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
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, CommandResult, DeviceInspection
|
||||
from cmbuyer_client.pdd.quantity_gate2_spike import (
|
||||
Android16TopResumedForegroundReader,
|
||||
DECLARED_QUANTITIES,
|
||||
EXPECTED_GOODS_ID,
|
||||
QuantityGate2EvidenceCapturer,
|
||||
QuantityGate2EvidenceError,
|
||||
QuantityGate2EvidenceTimeoutError,
|
||||
QuantityGate2ForegroundReader,
|
||||
QuantityGate2ReadDevice,
|
||||
)
|
||||
from cmbuyer_client.pdd.quantity_gate2 import (
|
||||
Gate1Observation,
|
||||
QuantityGate2Device,
|
||||
QuantityGate2Error,
|
||||
QuantityGate2Flow,
|
||||
QuantityGate2OverCapError,
|
||||
)
|
||||
from cmbuyer_client.pdd.quantity_gate2_runner import QuantityGate2Runner
|
||||
|
||||
|
||||
SERIAL = "192.168.0.173:5555"
|
||||
HIERARCHY = "<?xml version='1.0' encoding='UTF-8'?><hierarchy rotation='0'><node text='local raw page' /></hierarchy>"
|
||||
TOP_RESUMED = (
|
||||
" topResumedActivity=ActivityRecord{101034589 u0 "
|
||||
"com.xunmeng.pinduoduo/.activity.NewPageActivity t1816}\n"
|
||||
)
|
||||
|
||||
|
||||
@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 FakeAdbClient:
|
||||
def __init__(self, *, model: str = "PKG110", android_version: str = "16") -> None:
|
||||
self.calls: list[str] = []
|
||||
self.inspection = DeviceInspection(
|
||||
device=AdbDevice(serial=SERIAL, state="device", model=model),
|
||||
model=model,
|
||||
android_version=android_version,
|
||||
)
|
||||
|
||||
def inspect(self, serial: str) -> DeviceInspection:
|
||||
self.calls.append(serial)
|
||||
return self.inspection
|
||||
|
||||
|
||||
class FakeReadDevice:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
version: str = "8.17.0",
|
||||
screen_size: tuple[int, int] = (1080, 2376),
|
||||
screenshot: object | None = None,
|
||||
hierarchy: object = HIERARCHY,
|
||||
) -> None:
|
||||
self.version = version
|
||||
self.screen_size = screen_size
|
||||
self.screenshot = _png_base64() if screenshot is None else screenshot
|
||||
self.hierarchy = hierarchy
|
||||
self.calls: list[tuple[object, ...]] = []
|
||||
self.app_info_reads = 0
|
||||
self.window_reads = 0
|
||||
self.post_version: str | None = None
|
||||
self.post_screen_size: tuple[int, int] | None = None
|
||||
|
||||
def app_info(self, package_name: str) -> dict[str, str]:
|
||||
self.calls.append(("app_info", package_name))
|
||||
self.app_info_reads += 1
|
||||
version = self.post_version if self.app_info_reads > 1 and self.post_version is not None else self.version
|
||||
return {"versionName": version}
|
||||
|
||||
def window_size(self) -> tuple[int, int]:
|
||||
self.calls.append(("window_size",))
|
||||
self.window_reads += 1
|
||||
if self.window_reads > 1 and self.post_screen_size is not None:
|
||||
return self.post_screen_size
|
||||
return self.screen_size
|
||||
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> object:
|
||||
self.calls.append(("jsonrpc", method, params, timeout))
|
||||
if method == "takeScreenshot":
|
||||
return self.screenshot
|
||||
if method == "dumpWindowHierarchy":
|
||||
return self.hierarchy
|
||||
raise AssertionError(f"unexpected RPC {method}")
|
||||
|
||||
|
||||
class FakeForegroundReader:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
package: str = "com.xunmeng.pinduoduo",
|
||||
activity: str = ".activity.NewPageActivity",
|
||||
) -> None:
|
||||
self.package = package
|
||||
self.activity = activity
|
||||
self.post_package: str | None = None
|
||||
self.post_activity: str | None = None
|
||||
self.calls: list[str] = []
|
||||
|
||||
def read(self, serial: str) -> dict[str, str]:
|
||||
self.calls.append(serial)
|
||||
package = self.post_package if len(self.calls) > 1 and self.post_package is not None else self.package
|
||||
activity = self.post_activity if len(self.calls) > 1 and self.post_activity is not None else self.activity
|
||||
return {"package": package, "activity": activity}
|
||||
|
||||
|
||||
class FakeCommandRunner:
|
||||
def __init__(self, result: CommandResult) -> None:
|
||||
self.result = result
|
||||
self.calls: list[tuple[tuple[str, ...], float]] = []
|
||||
|
||||
def run(self, arguments: tuple[str, ...], timeout_seconds: float) -> CommandResult:
|
||||
self.calls.append((arguments, timeout_seconds))
|
||||
return self.result
|
||||
|
||||
|
||||
def _load_script() -> object:
|
||||
script_path = CLIENT_ROOT / "scripts" / "capture_quantity_gate2_spike.py"
|
||||
spec = spec_from_file_location("capture_quantity_gate2_spike_for_test", script_path)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _load_run_script() -> object:
|
||||
script_path = CLIENT_ROOT / "scripts" / "run_t105_quantity_gate2.py"
|
||||
spec = spec_from_file_location("run_t105_quantity_gate2_for_test", script_path)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _namespace(**changes: object) -> argparse.Namespace:
|
||||
values: dict[str, object] = {
|
||||
"serial": SERIAL,
|
||||
"goods_id": EXPECTED_GOODS_ID,
|
||||
"state": "initial",
|
||||
"declared_quantity": 1,
|
||||
"output_dir": Path("evidence"),
|
||||
"timeout": 10.0,
|
||||
"adb": "adb",
|
||||
}
|
||||
values.update(changes)
|
||||
return argparse.Namespace(**values)
|
||||
|
||||
|
||||
class Android16TopResumedForegroundReaderTests(unittest.TestCase):
|
||||
def test_reads_exact_unique_android16_top_resumed_activity(self) -> None:
|
||||
runner = FakeCommandRunner(CommandResult(stdout=TOP_RESUMED))
|
||||
reader = Android16TopResumedForegroundReader(runner, timeout_seconds=7)
|
||||
|
||||
self.assertEqual(
|
||||
reader.read(SERIAL),
|
||||
{
|
||||
"package": "com.xunmeng.pinduoduo",
|
||||
"activity": ".activity.NewPageActivity",
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
runner.calls,
|
||||
[
|
||||
(
|
||||
(
|
||||
"-s",
|
||||
SERIAL,
|
||||
"shell",
|
||||
"dumpsys",
|
||||
"activity",
|
||||
"activities",
|
||||
),
|
||||
7,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
def test_missing_legacy_or_duplicate_top_resumed_activity_is_rejected(self) -> None:
|
||||
rejected_outputs = (
|
||||
"",
|
||||
(
|
||||
"mResumedActivity: ActivityRecord{101034589 u0 "
|
||||
"com.xunmeng.pinduoduo/.activity.NewPageActivity t1816}\n"
|
||||
),
|
||||
TOP_RESUMED + TOP_RESUMED,
|
||||
"topResumedActivity=null\n",
|
||||
)
|
||||
for output in rejected_outputs:
|
||||
with self.subTest(output=output):
|
||||
reader = Android16TopResumedForegroundReader(
|
||||
FakeCommandRunner(CommandResult(stdout=output)),
|
||||
timeout_seconds=7,
|
||||
)
|
||||
with self.assertRaises(QuantityGate2EvidenceError):
|
||||
reader.read(SERIAL)
|
||||
|
||||
def test_command_failure_and_invalid_serial_are_rejected(self) -> None:
|
||||
runner = FakeCommandRunner(CommandResult(stdout=TOP_RESUMED, returncode=1))
|
||||
reader = Android16TopResumedForegroundReader(runner, timeout_seconds=7)
|
||||
with self.assertRaises(QuantityGate2EvidenceError):
|
||||
reader.read(SERIAL)
|
||||
with self.assertRaises(QuantityGate2EvidenceError):
|
||||
reader.read(f" {SERIAL}")
|
||||
self.assertEqual(len(runner.calls), 1)
|
||||
|
||||
|
||||
class QuantityGate2EvidenceTests(unittest.TestCase):
|
||||
def _capturer(
|
||||
self,
|
||||
adb: FakeAdbClient,
|
||||
device: FakeReadDevice,
|
||||
*,
|
||||
foreground: FakeForegroundReader | None = None,
|
||||
connector_calls: list[str] | None = None,
|
||||
) -> QuantityGate2EvidenceCapturer:
|
||||
def connect(serial: str) -> FakeReadDevice:
|
||||
if connector_calls is not None:
|
||||
connector_calls.append(serial)
|
||||
return device
|
||||
|
||||
return QuantityGate2EvidenceCapturer(
|
||||
adb,
|
||||
connect,
|
||||
foreground or FakeForegroundReader(),
|
||||
timeout_seconds=2,
|
||||
)
|
||||
|
||||
def test_initial_and_target_states_publish_only_raw_read_evidence(self) -> None:
|
||||
for state, quantity in DECLARED_QUANTITIES.items():
|
||||
with self.subTest(state=state), TemporaryDirectory() as temporary:
|
||||
adb = FakeAdbClient()
|
||||
device = FakeReadDevice()
|
||||
foreground = FakeForegroundReader()
|
||||
connector_calls: list[str] = []
|
||||
target = Path(temporary) / state
|
||||
|
||||
result = self._capturer(
|
||||
adb,
|
||||
device,
|
||||
foreground=foreground,
|
||||
connector_calls=connector_calls,
|
||||
).capture(
|
||||
SERIAL,
|
||||
EXPECTED_GOODS_ID,
|
||||
state,
|
||||
quantity,
|
||||
target,
|
||||
)
|
||||
|
||||
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
|
||||
app = json.loads(result.app_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(adb.calls, [SERIAL])
|
||||
self.assertEqual(connector_calls, [SERIAL])
|
||||
self.assertEqual(foreground.calls, [SERIAL, SERIAL])
|
||||
self.assertEqual(
|
||||
[call[1] for call in device.calls if call[0] == "jsonrpc"],
|
||||
["takeScreenshot", "dumpWindowHierarchy"],
|
||||
)
|
||||
self.assertEqual(manifest["operation"], "t105-quantity-gate2-readonly-evidence")
|
||||
self.assertEqual(manifest["human_declared_state"], state)
|
||||
self.assertEqual(manifest["human_declared_quantity"], quantity)
|
||||
self.assertEqual(
|
||||
manifest["human_declared_selection"],
|
||||
{"color": "黑色CHA(纯棉)", "size": "M(建议100-115)"},
|
||||
)
|
||||
self.assertEqual(manifest["review_status"], "human_review_required")
|
||||
self.assertEqual(manifest["product"]["goods_id"], EXPECTED_GOODS_ID)
|
||||
self.assertEqual(app["package"], "com.xunmeng.pinduoduo")
|
||||
self.assertEqual(
|
||||
{path.name for path in target.iterdir()},
|
||||
{"screenshot.png", "hierarchy.xml", "app.json", "manifest.json"},
|
||||
)
|
||||
serialized = result.manifest_path.read_text(encoding="utf-8")
|
||||
self.assertNotIn(SERIAL, serialized)
|
||||
self.assertNotIn("NewPageActivity", serialized)
|
||||
self.assertNotIn("local raw page", serialized)
|
||||
for artifact in manifest["artifacts"]:
|
||||
self.assertEqual(len(artifact["sha256"]), 64)
|
||||
|
||||
def test_invalid_inputs_and_existing_target_fail_before_device_access(self) -> None:
|
||||
scenarios = (
|
||||
("", EXPECTED_GOODS_ID, "initial", 1),
|
||||
(f" {SERIAL}", EXPECTED_GOODS_ID, "initial", 1),
|
||||
(SERIAL, "958756616606", "initial", 1),
|
||||
(SERIAL, EXPECTED_GOODS_ID, "unknown", 1),
|
||||
(SERIAL, EXPECTED_GOODS_ID, "initial", 2),
|
||||
(SERIAL, EXPECTED_GOODS_ID, "target", 1),
|
||||
(SERIAL, EXPECTED_GOODS_ID, "initial", True),
|
||||
)
|
||||
for serial, goods_id, state, quantity in scenarios:
|
||||
with self.subTest(state=state, quantity=quantity), TemporaryDirectory() as temporary:
|
||||
adb = FakeAdbClient()
|
||||
connector_calls: list[str] = []
|
||||
with self.assertRaises(QuantityGate2EvidenceError):
|
||||
self._capturer(adb, FakeReadDevice(), connector_calls=connector_calls).capture(
|
||||
serial,
|
||||
goods_id,
|
||||
state,
|
||||
quantity,
|
||||
Path(temporary) / "evidence",
|
||||
)
|
||||
self.assertEqual(adb.calls, [])
|
||||
self.assertEqual(connector_calls, [])
|
||||
|
||||
with TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "evidence"
|
||||
target.mkdir()
|
||||
sentinel = target / "sentinel.txt"
|
||||
sentinel.write_text("keep", encoding="utf-8")
|
||||
adb = FakeAdbClient()
|
||||
with self.assertRaises(QuantityGate2EvidenceError):
|
||||
self._capturer(adb, FakeReadDevice()).capture(
|
||||
SERIAL, EXPECTED_GOODS_ID, "initial", 1, target
|
||||
)
|
||||
self.assertEqual(adb.calls, [])
|
||||
self.assertEqual(sentinel.read_text(encoding="utf-8"), "keep")
|
||||
|
||||
def test_device_version_package_activity_and_screen_mismatch_fail_closed(self) -> None:
|
||||
scenarios = (
|
||||
(FakeAdbClient(model="OTHER"), FakeReadDevice(), FakeForegroundReader()),
|
||||
(FakeAdbClient(android_version="15"), FakeReadDevice(), FakeForegroundReader()),
|
||||
(FakeAdbClient(), FakeReadDevice(version="8.17.1"), FakeForegroundReader()),
|
||||
(FakeAdbClient(), FakeReadDevice(), FakeForegroundReader(package="com.example.other")),
|
||||
(FakeAdbClient(), FakeReadDevice(), FakeForegroundReader(activity="")),
|
||||
(FakeAdbClient(), FakeReadDevice(screen_size=(1080, 2400)), FakeForegroundReader()),
|
||||
)
|
||||
for adb, device, foreground in scenarios:
|
||||
with self.subTest(device=device.__dict__), TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "evidence"
|
||||
with self.assertRaises(QuantityGate2EvidenceError):
|
||||
self._capturer(adb, device, foreground=foreground).capture(
|
||||
SERIAL, EXPECTED_GOODS_ID, "initial", 1, target
|
||||
)
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
|
||||
self.assertEqual(
|
||||
[call for call in device.calls if call[0] == "jsonrpc"],
|
||||
[],
|
||||
)
|
||||
|
||||
def test_invalid_screenshot_or_hierarchy_leaves_no_partial_evidence(self) -> None:
|
||||
scenarios = (
|
||||
FakeReadDevice(screenshot="not base64"),
|
||||
FakeReadDevice(screenshot=123),
|
||||
FakeReadDevice(hierarchy="<not-hierarchy />"),
|
||||
FakeReadDevice(hierarchy=123),
|
||||
)
|
||||
for device in scenarios:
|
||||
with self.subTest(value=device.screenshot), TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "evidence"
|
||||
with self.assertRaises(QuantityGate2EvidenceError):
|
||||
self._capturer(FakeAdbClient(), device).capture(
|
||||
SERIAL, EXPECTED_GOODS_ID, "initial", 1, target
|
||||
)
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
|
||||
|
||||
def test_timeout_is_redacted_and_cleans_staging(self) -> None:
|
||||
class TimeoutDevice(FakeReadDevice):
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> object:
|
||||
self.calls.append(("jsonrpc", method, params, timeout))
|
||||
if method == "takeScreenshot":
|
||||
raise TimeoutError(f"secret {SERIAL} C:\\private\\evidence")
|
||||
return super().jsonrpc_call(method, params, timeout)
|
||||
|
||||
with TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "evidence"
|
||||
with self.assertRaises(QuantityGate2EvidenceTimeoutError) as raised:
|
||||
self._capturer(FakeAdbClient(), TimeoutDevice()).capture(
|
||||
SERIAL, EXPECTED_GOODS_ID, "initial", 1, target
|
||||
)
|
||||
self.assertNotIn(SERIAL, str(raised.exception))
|
||||
self.assertNotIn("private", str(raised.exception))
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
|
||||
|
||||
def test_post_capture_app_or_screen_drift_does_not_publish(self) -> None:
|
||||
drift_mutators = (
|
||||
lambda device, foreground: setattr(device, "post_version", "8.17.1"),
|
||||
lambda device, foreground: setattr(foreground, "post_package", "com.example.other"),
|
||||
lambda device, foreground: setattr(foreground, "post_activity", "OtherActivity"),
|
||||
lambda device, foreground: setattr(device, "post_screen_size", (1080, 2400)),
|
||||
)
|
||||
for mutate in drift_mutators:
|
||||
with TemporaryDirectory() as temporary:
|
||||
device = FakeReadDevice()
|
||||
foreground = FakeForegroundReader()
|
||||
mutate(device, foreground)
|
||||
target = Path(temporary) / "evidence"
|
||||
with self.assertRaises(QuantityGate2EvidenceError):
|
||||
self._capturer(
|
||||
FakeAdbClient(),
|
||||
device,
|
||||
foreground=foreground,
|
||||
).capture(
|
||||
SERIAL, EXPECTED_GOODS_ID, "initial", 1, target
|
||||
)
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
|
||||
|
||||
def test_capturer_is_one_shot_even_after_rejection(self) -> None:
|
||||
capturer = self._capturer(FakeAdbClient(), FakeReadDevice())
|
||||
with TemporaryDirectory() as temporary:
|
||||
with self.assertRaises(QuantityGate2EvidenceError):
|
||||
capturer.capture("", EXPECTED_GOODS_ID, "initial", 1, Path(temporary) / "bad")
|
||||
with self.assertRaises(QuantityGate2EvidenceError):
|
||||
capturer.capture(SERIAL, EXPECTED_GOODS_ID, "initial", 1, Path(temporary) / "good")
|
||||
|
||||
def test_read_protocol_and_capturer_expose_no_page_actions(self) -> None:
|
||||
forbidden = {
|
||||
"click",
|
||||
"swipe",
|
||||
"press",
|
||||
"pressKey",
|
||||
"send_keys",
|
||||
"set_text",
|
||||
"open_product",
|
||||
"open_sku_panel",
|
||||
"set_quantity",
|
||||
"go_to_order_confirm",
|
||||
"submit_order_once",
|
||||
"pay",
|
||||
}
|
||||
self.assertTrue(forbidden.isdisjoint(QuantityGate2ReadDevice.__dict__))
|
||||
self.assertTrue(forbidden.isdisjoint(QuantityGate2ForegroundReader.__dict__))
|
||||
self.assertEqual(
|
||||
{
|
||||
name
|
||||
for name in Android16TopResumedForegroundReader.__dict__
|
||||
if not name.startswith("_")
|
||||
},
|
||||
{"read"},
|
||||
)
|
||||
self.assertEqual(
|
||||
{name for name in QuantityGate2EvidenceCapturer.__dict__ if not name.startswith("_")},
|
||||
{"capture"},
|
||||
)
|
||||
|
||||
|
||||
class QuantityGate2CliAndStaticBoundaryTests(unittest.TestCase):
|
||||
def test_cli_validation_rejects_all_unapproved_combinations(self) -> None:
|
||||
script = _load_script()
|
||||
invalid = (
|
||||
_namespace(serial=""),
|
||||
_namespace(serial=f" {SERIAL}"),
|
||||
_namespace(goods_id="958756616606"),
|
||||
_namespace(state="unknown"),
|
||||
_namespace(declared_quantity=2),
|
||||
_namespace(state="target", declared_quantity=1),
|
||||
_namespace(output_dir=Path("")),
|
||||
_namespace(timeout=0),
|
||||
_namespace(timeout=float("inf")),
|
||||
)
|
||||
for arguments in invalid:
|
||||
with self.subTest(arguments=arguments), self.assertRaises(ValueError):
|
||||
script.validate_arguments(arguments) # type: ignore[attr-defined]
|
||||
|
||||
def test_cli_parses_exact_initial_and_target_arguments(self) -> None:
|
||||
script = _load_script()
|
||||
for state, quantity in DECLARED_QUANTITIES.items():
|
||||
arguments = script.parse_arguments( # type: ignore[attr-defined]
|
||||
[
|
||||
"--serial",
|
||||
SERIAL,
|
||||
"--goods-id",
|
||||
EXPECTED_GOODS_ID,
|
||||
"--state",
|
||||
state,
|
||||
"--declared-quantity",
|
||||
str(quantity),
|
||||
"--output-dir",
|
||||
f"evidence-{state}",
|
||||
]
|
||||
)
|
||||
script.validate_arguments(arguments) # type: ignore[attr-defined]
|
||||
|
||||
def test_cli_runtime_failure_does_not_echo_sensitive_values(self) -> None:
|
||||
script = _load_script()
|
||||
error = QuantityGate2EvidenceError(f"secret {SERIAL} C:\\private\\evidence raw body")
|
||||
stderr = StringIO()
|
||||
fake_capturer = unittest.mock.Mock()
|
||||
fake_capturer.capture.side_effect = error
|
||||
with (
|
||||
patch.object(script, "QuantityGate2EvidenceCapturer", return_value=fake_capturer),
|
||||
patch.dict(sys.modules, {"adbutils": unittest.mock.Mock(), "uiautomator2": unittest.mock.Mock()}),
|
||||
redirect_stderr(stderr),
|
||||
):
|
||||
result = script.main(
|
||||
[
|
||||
"--serial",
|
||||
SERIAL,
|
||||
"--goods-id",
|
||||
EXPECTED_GOODS_ID,
|
||||
"--state",
|
||||
"initial",
|
||||
"--declared-quantity",
|
||||
"1",
|
||||
"--output-dir",
|
||||
"C:\\private\\evidence",
|
||||
]
|
||||
)
|
||||
self.assertEqual(result, 1)
|
||||
self.assertNotIn(SERIAL, stderr.getvalue())
|
||||
self.assertNotIn("private", stderr.getvalue())
|
||||
self.assertNotIn("raw body", stderr.getvalue())
|
||||
|
||||
def test_stage_one_sources_contain_only_approved_rpc_method_literals(self) -> None:
|
||||
expected_by_source = {
|
||||
Path("src/cmbuyer_client/pdd/quantity_gate2_spike.py"): {
|
||||
"takeScreenshot",
|
||||
"dumpWindowHierarchy",
|
||||
},
|
||||
Path("scripts/capture_quantity_gate2_spike.py"): set(),
|
||||
}
|
||||
for relative, expected in expected_by_source.items():
|
||||
tree = ast.parse((CLIENT_ROOT / relative).read_text(encoding="utf-8"))
|
||||
rpc_literals = {
|
||||
node.args[1].value
|
||||
for node in ast.walk(tree)
|
||||
if isinstance(node, ast.Call)
|
||||
and isinstance(node.func, ast.Name)
|
||||
and node.func.id == "_read_rpc"
|
||||
and len(node.args) > 1
|
||||
and isinstance(node.args[1], ast.Constant)
|
||||
and isinstance(node.args[1].value, str)
|
||||
}
|
||||
self.assertEqual(rpc_literals, expected)
|
||||
forbidden_attributes = {
|
||||
node.attr
|
||||
for node in ast.walk(tree)
|
||||
if isinstance(node, ast.Attribute)
|
||||
and node.attr in {"click", "swipe", "press", "pressKey", "set_text", "send_keys", "start_pdd_view_intent"}
|
||||
}
|
||||
self.assertEqual(forbidden_attributes, set())
|
||||
|
||||
def test_t103_and_t104_sources_do_not_import_quantity_gate2(self) -> None:
|
||||
forbidden_import = "quantity_gate2"
|
||||
sources = (
|
||||
CLIENT_ROOT / "src/cmbuyer_client/pdd/sku_selection.py",
|
||||
CLIENT_ROOT / "src/cmbuyer_client/pdd/sku_selection_runner.py",
|
||||
)
|
||||
for source in sources:
|
||||
self.assertNotIn(forbidden_import, source.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
_INITIAL_FIXTURE = CLIENT_ROOT / "tests" / "pdd" / "fixtures" / "quantity_gate2_initial_8_17_0.xml"
|
||||
_TARGET_FIXTURE = CLIENT_ROOT / "tests" / "pdd" / "fixtures" / "quantity_gate2_target_8_17_0.xml"
|
||||
_EXIT_FIXTURE = CLIENT_ROOT / "tests" / "pdd" / "fixtures" / "product_exit_8_17_0.xml"
|
||||
|
||||
|
||||
def _gate1(path: Path = Path("gate1.png")) -> Gate1Observation:
|
||||
return Gate1Observation(
|
||||
color="黑色CHA(纯棉)",
|
||||
size="M(建议100-115)",
|
||||
quantity=1,
|
||||
gate1_unit_price="12.88",
|
||||
screenshot_path=path,
|
||||
captured_at=datetime(2026, 8, 6, 0, 53, tzinfo=UTC),
|
||||
)
|
||||
|
||||
|
||||
class FakeQuantityFlowDevice:
|
||||
def __init__(self, initial: str | None = None, target: str | None = None) -> None:
|
||||
self.initial = initial or _INITIAL_FIXTURE.read_text(encoding="utf-8")
|
||||
self.target = target or _TARGET_FIXTURE.read_text(encoding="utf-8")
|
||||
self.exit = _EXIT_FIXTURE.read_text(encoding="utf-8")
|
||||
self.current = self.initial
|
||||
self.version = "8.17.0"
|
||||
self.foreground = {"package": "com.xunmeng.pinduoduo", "activity": ".activity.NewPageActivity"}
|
||||
self.screen_size = (1080, 2376)
|
||||
self.increment_calls: list[str] = []
|
||||
self.back_calls = 0
|
||||
self.dump_calls = 0
|
||||
self.raise_increment: BaseException | None = None
|
||||
|
||||
def app_info(self, package_name: str) -> dict[str, str]:
|
||||
return {"versionName": self.version}
|
||||
|
||||
def current_foreground(self) -> dict[str, str]:
|
||||
return dict(self.foreground)
|
||||
|
||||
def display_size(self) -> tuple[int, int]:
|
||||
return self.screen_size
|
||||
|
||||
def dump_window_hierarchy(self) -> str:
|
||||
self.dump_calls += 1
|
||||
return self.current
|
||||
|
||||
def increment_quantity_once(self, bounds: str) -> None:
|
||||
self.increment_calls.append(bounds)
|
||||
if self.raise_increment is not None:
|
||||
raise self.raise_increment
|
||||
self.current = self.target
|
||||
|
||||
def capture_screenshot(self) -> str:
|
||||
return _png_base64()
|
||||
|
||||
def leave_sku_panel_once(self) -> None:
|
||||
self.back_calls += 1
|
||||
self.current = self.exit
|
||||
|
||||
|
||||
class QuantityGate2FlowTests(unittest.TestCase):
|
||||
def test_quantity_two_reads_nonlinear_panel_total_and_exits_once(self) -> None:
|
||||
device = FakeQuantityFlowDevice()
|
||||
flow = QuantityGate2Flow(device, wait_timeout_seconds=0.1, poll_interval_seconds=0.01)
|
||||
verified = flow.set_quantity_and_verify(_gate1(), 2, "40.00")
|
||||
observation = flow.build_observation(
|
||||
_gate1(),
|
||||
2,
|
||||
"40.00",
|
||||
Path("gate2.png"),
|
||||
datetime(2026, 8, 6, 1, 10, tzinfo=UTC),
|
||||
)
|
||||
flow.exit_sku_panel_safely()
|
||||
|
||||
self.assertEqual(verified.quantity, 2)
|
||||
self.assertEqual(observation.quantity_read, 2)
|
||||
self.assertEqual(observation.gate1_unit_price, "12.88")
|
||||
self.assertEqual(observation.gate2_panel_total_price, "32.76")
|
||||
self.assertEqual(observation.max_total_price, "40.00")
|
||||
self.assertEqual(device.increment_calls, ["[567,752][645,827]"])
|
||||
self.assertEqual(device.back_calls, 1)
|
||||
self.assertTrue(flow.exited)
|
||||
|
||||
def test_quantity_one_is_zero_click_and_still_uses_panel_amount(self) -> None:
|
||||
device = FakeQuantityFlowDevice()
|
||||
flow = QuantityGate2Flow(device)
|
||||
verified = flow.set_quantity_and_verify(_gate1(), 1, "12.88")
|
||||
self.assertEqual((verified.quantity, verified.panel_total_price), (1, "12.88"))
|
||||
self.assertEqual(device.increment_calls, [])
|
||||
|
||||
def test_only_evidenced_quantities_and_canonical_decimal_cap_are_allowed(self) -> None:
|
||||
for quantity in (0, 3, -1, True, "2"):
|
||||
with self.subTest(quantity=quantity):
|
||||
device = FakeQuantityFlowDevice()
|
||||
with self.assertRaises(QuantityGate2Error):
|
||||
QuantityGate2Flow(device).set_quantity_and_verify(_gate1(), quantity, "40.00") # type: ignore[arg-type]
|
||||
self.assertEqual(device.increment_calls, [])
|
||||
for cap in ("0.00", "40", "040.00", 40.0, "NaN"):
|
||||
with self.subTest(cap=cap):
|
||||
device = FakeQuantityFlowDevice()
|
||||
with self.assertRaises(QuantityGate2Error):
|
||||
QuantityGate2Flow(device).set_quantity_and_verify(_gate1(), 2, cap) # type: ignore[arg-type]
|
||||
self.assertEqual(device.increment_calls, [])
|
||||
|
||||
def test_over_cap_stops_after_exact_readback_without_fabricated_math(self) -> None:
|
||||
device = FakeQuantityFlowDevice()
|
||||
flow = QuantityGate2Flow(device)
|
||||
with self.assertRaises(QuantityGate2OverCapError):
|
||||
flow.set_quantity_and_verify(_gate1(), 2, "30.00")
|
||||
self.assertEqual(device.increment_calls, ["[567,752][645,827]"])
|
||||
self.assertTrue(flow.can_exit_safely)
|
||||
|
||||
def test_increment_timeout_is_sealed_and_cannot_be_retried(self) -> None:
|
||||
device = FakeQuantityFlowDevice()
|
||||
device.raise_increment = TimeoutError("ambiguous delivery")
|
||||
flow = QuantityGate2Flow(device, wait_timeout_seconds=0.01, poll_interval_seconds=0.005)
|
||||
with self.assertRaises(TimeoutError):
|
||||
flow.set_quantity_and_verify(_gate1(), 2, "40.00")
|
||||
with self.assertRaises(QuantityGate2Error):
|
||||
flow.set_quantity_and_verify(_gate1(), 2, "40.00")
|
||||
self.assertEqual(len(device.increment_calls), 1)
|
||||
self.assertFalse(flow.can_exit_safely)
|
||||
|
||||
def test_missing_duplicate_drift_and_overlay_fail_before_click(self) -> None:
|
||||
initial = _INITIAL_FIXTURE.read_text(encoding="utf-8")
|
||||
plus = '<node content-desc="增加数量" package="com.xunmeng.pinduoduo" class="android.widget.ImageView" resource-id="com.xunmeng.pinduoduo:id/pdd" bounds="[567,752][645,827]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />'
|
||||
variants = (
|
||||
initial.replace('content-desc="增加数量"', 'content-desc="数量加一"', 1),
|
||||
initial.replace(plus, plus + plus),
|
||||
initial.replace('text="M(建议100-115)"', 'text="S(建议80-100)"', 1),
|
||||
initial.replace(
|
||||
"</hierarchy>",
|
||||
'<node package="com.example.overlay" class="android.view.ViewGroup" bounds="[560,740][660,840]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" /></hierarchy>',
|
||||
),
|
||||
)
|
||||
for hierarchy in variants:
|
||||
with self.subTest(marker=hierarchy[-180:]):
|
||||
device = FakeQuantityFlowDevice(initial=hierarchy)
|
||||
with self.assertRaises(QuantityGate2Error):
|
||||
QuantityGate2Flow(device).set_quantity_and_verify(_gate1(), 2, "40.00")
|
||||
self.assertEqual(device.increment_calls, [])
|
||||
|
||||
def test_foreground_version_and_screen_drift_are_zero_click(self) -> None:
|
||||
devices = (FakeQuantityFlowDevice(), FakeQuantityFlowDevice(), FakeQuantityFlowDevice())
|
||||
devices[0].foreground = {"package": "com.android.systemui", "activity": ".Keyguard"}
|
||||
devices[1].version = "8.17.1"
|
||||
devices[2].screen_size = (1080, 2400)
|
||||
for device in devices:
|
||||
with self.subTest(device=device):
|
||||
with self.assertRaises(QuantityGate2Error):
|
||||
QuantityGate2Flow(device).set_quantity_and_verify(_gate1(), 2, "40.00")
|
||||
self.assertEqual(device.increment_calls, [])
|
||||
|
||||
def test_bottom_submit_amount_is_never_a_gate2_candidate(self) -> None:
|
||||
target = _TARGET_FIXTURE.read_text(encoding="utf-8").replace("提交订单 ¥32.76", "提交订单 ¥999.99")
|
||||
device = FakeQuantityFlowDevice(target=target)
|
||||
result = QuantityGate2Flow(device).set_quantity_and_verify(_gate1(), 2, "40.00")
|
||||
self.assertEqual(result.panel_total_price, "32.76")
|
||||
|
||||
def test_gate1_target_spec_and_timestamp_are_strict(self) -> None:
|
||||
invalid = (
|
||||
{"color": "白色"},
|
||||
{"size": "L"},
|
||||
{"quantity": 2},
|
||||
{"gate1_unit_price": "12.89"},
|
||||
{"captured_at": datetime(2026, 8, 6, 0, 53)},
|
||||
)
|
||||
base = _gate1().__dict__
|
||||
for change in invalid:
|
||||
with self.subTest(change=change), self.assertRaises(QuantityGate2Error):
|
||||
Gate1Observation(**{**base, **change})
|
||||
|
||||
|
||||
class FakeRawQuantityDevice:
|
||||
def __init__(self, *, click_timeout: bool = False) -> None:
|
||||
self.current = _INITIAL_FIXTURE.read_text(encoding="utf-8")
|
||||
self.target = _TARGET_FIXTURE.read_text(encoding="utf-8")
|
||||
self.exit = _EXIT_FIXTURE.read_text(encoding="utf-8")
|
||||
self.calls: list[tuple[object, ...]] = []
|
||||
self.click_timeout = click_timeout
|
||||
|
||||
def app_info(self, package: str) -> dict[str, str]:
|
||||
self.calls.append(("app_info", package))
|
||||
return {"versionName": "8.17.0"}
|
||||
|
||||
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 == "click":
|
||||
self.current = self.target
|
||||
if self.click_timeout:
|
||||
raise TimeoutError("may have been delivered")
|
||||
return None
|
||||
if method == "takeScreenshot":
|
||||
return _png_base64()
|
||||
if method == "pressKey":
|
||||
self.current = self.exit
|
||||
return None
|
||||
raise AssertionError(method)
|
||||
|
||||
|
||||
class QuantityGate2RunnerTests(unittest.TestCase):
|
||||
def test_runner_publishes_gate2_evidence_then_one_safe_exit(self) -> None:
|
||||
with TemporaryDirectory() as temporary:
|
||||
base = Path(temporary)
|
||||
gate1_path = base / "gate1.png"
|
||||
gate1_path.write_bytes(base64.b64decode(_png_base64()))
|
||||
target = base / "gate2"
|
||||
raw = FakeRawQuantityDevice()
|
||||
result = QuantityGate2Runner(
|
||||
FakeAdbClient(),
|
||||
lambda serial: raw,
|
||||
FakeForegroundReader(),
|
||||
timeout_seconds=0.1,
|
||||
).run(SERIAL, EXPECTED_GOODS_ID, _gate1(gate1_path), 2, "40.00", 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.assertEqual(methods.count("click"), 1)
|
||||
self.assertEqual(methods.count("pressKey"), 1)
|
||||
self.assertEqual(methods.count("takeScreenshot"), 1)
|
||||
self.assertEqual(result.observation.gate2_panel_total_price, "32.76")
|
||||
self.assertEqual(manifest["prices"]["gate1_unit_price"], "12.88")
|
||||
self.assertEqual(manifest["prices"]["gate2_panel_total_price"], "32.76")
|
||||
self.assertEqual(manifest["prices"]["max_total_price"], "40.00")
|
||||
self.assertEqual(manifest["action_audit"]["increment_attempts"], 1)
|
||||
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(gate1_path), serialized)
|
||||
|
||||
def test_over_cap_attempts_one_safe_exit_and_publishes_nothing(self) -> None:
|
||||
with TemporaryDirectory() as temporary:
|
||||
base = Path(temporary)
|
||||
gate1_path = base / "gate1.png"
|
||||
gate1_path.write_bytes(base64.b64decode(_png_base64()))
|
||||
raw = FakeRawQuantityDevice()
|
||||
target = base / "gate2"
|
||||
with self.assertRaises(QuantityGate2OverCapError):
|
||||
QuantityGate2Runner(
|
||||
FakeAdbClient(), lambda serial: raw, FakeForegroundReader(), timeout_seconds=0.1
|
||||
).run(SERIAL, EXPECTED_GOODS_ID, _gate1(gate1_path), 2, "30.00", target)
|
||||
methods = [call[1] for call in raw.calls if call[0] == "jsonrpc"]
|
||||
self.assertEqual(methods.count("click"), 1)
|
||||
self.assertEqual(methods.count("pressKey"), 1)
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(list(base.glob(".gate2.staging-*")), [])
|
||||
|
||||
def test_ambiguous_increment_is_never_retried_or_followed_by_back(self) -> None:
|
||||
with TemporaryDirectory() as temporary:
|
||||
base = Path(temporary)
|
||||
gate1_path = base / "gate1.png"
|
||||
gate1_path.write_bytes(base64.b64decode(_png_base64()))
|
||||
raw = FakeRawQuantityDevice(click_timeout=True)
|
||||
with self.assertRaises(QuantityGate2Error):
|
||||
QuantityGate2Runner(
|
||||
FakeAdbClient(), lambda serial: raw, FakeForegroundReader(), timeout_seconds=0.1
|
||||
).run(SERIAL, EXPECTED_GOODS_ID, _gate1(gate1_path), 2, "40.00", base / "gate2")
|
||||
methods = [call[1] for call in raw.calls if call[0] == "jsonrpc"]
|
||||
self.assertEqual(methods.count("click"), 1)
|
||||
self.assertEqual(methods.count("pressKey"), 0)
|
||||
|
||||
|
||||
class QuantityGate2ProductionStaticBoundaryTests(unittest.TestCase):
|
||||
def test_protocol_has_only_named_t105_actions(self) -> None:
|
||||
public = {name for name in QuantityGate2Device.__dict__ if not name.startswith("_")}
|
||||
self.assertEqual(
|
||||
public,
|
||||
{
|
||||
"app_info",
|
||||
"current_foreground",
|
||||
"display_size",
|
||||
"dump_window_hierarchy",
|
||||
"increment_quantity_once",
|
||||
"capture_screenshot",
|
||||
"leave_sku_panel_once",
|
||||
},
|
||||
)
|
||||
|
||||
def test_production_sources_have_no_old_gate2_field_or_forbidden_capability(self) -> None:
|
||||
paths = (
|
||||
CLIENT_ROOT / "src/cmbuyer_client/pdd/quantity_gate2.py",
|
||||
CLIENT_ROOT / "src/cmbuyer_client/pdd/quantity_gate2_runner.py",
|
||||
CLIENT_ROOT / "scripts/run_t105_quantity_gate2.py",
|
||||
)
|
||||
forbidden_attributes = {
|
||||
"go_to_order_confirm",
|
||||
"submit_order_once",
|
||||
"start_pdd_view_intent",
|
||||
"pay",
|
||||
"swipe",
|
||||
"set_text",
|
||||
"send_keys",
|
||||
}
|
||||
for path in paths:
|
||||
source = path.read_text(encoding="utf-8")
|
||||
tree = ast.parse(source)
|
||||
self.assertNotIn("gate2_unit_price", source)
|
||||
self.assertFalse(
|
||||
{
|
||||
node.attr
|
||||
for node in ast.walk(tree)
|
||||
if isinstance(node, ast.Attribute) and node.attr in forbidden_attributes
|
||||
}
|
||||
)
|
||||
quantity_tree = ast.parse(paths[0].read_text(encoding="utf-8"))
|
||||
self.assertFalse(any(isinstance(node, ast.Mult) for node in ast.walk(quantity_tree)))
|
||||
|
||||
def test_t103_and_t104_still_do_not_import_t105(self) -> None:
|
||||
for relative in (
|
||||
"src/cmbuyer_client/pdd/sku_selection.py",
|
||||
"src/cmbuyer_client/pdd/sku_selection_runner.py",
|
||||
):
|
||||
self.assertNotIn("quantity_gate2", (CLIENT_ROOT / relative).read_text(encoding="utf-8"))
|
||||
|
||||
def test_live_cli_fixes_the_human_review_case_and_redacts_runtime_failure(self) -> None:
|
||||
script = _load_run_script()
|
||||
with TemporaryDirectory() as temporary:
|
||||
screenshot = Path(temporary) / "gate1.png"
|
||||
screenshot.write_bytes(base64.b64decode(_png_base64()))
|
||||
arguments = script.parse_arguments( # type: ignore[attr-defined]
|
||||
[
|
||||
"--serial", SERIAL,
|
||||
"--goods-id", EXPECTED_GOODS_ID,
|
||||
"--color", "黑色CHA(纯棉)",
|
||||
"--size", "M(建议100-115)",
|
||||
"--target-quantity", "2",
|
||||
"--gate1-unit-price", "12.88",
|
||||
"--gate1-screenshot", str(screenshot),
|
||||
"--gate1-captured-at", "2026-08-06T00:53:00.023935+00:00",
|
||||
"--max-total-price", "40.00",
|
||||
"--output-dir", str(Path(temporary) / "gate2"),
|
||||
]
|
||||
)
|
||||
script.validate_arguments(arguments) # type: ignore[attr-defined]
|
||||
|
||||
stderr = StringIO()
|
||||
fake_runner = unittest.mock.Mock()
|
||||
fake_runner.run.side_effect = QuantityGate2Error(f"secret {SERIAL} {temporary}")
|
||||
with (
|
||||
patch.object(script, "QuantityGate2Runner", return_value=fake_runner),
|
||||
patch.dict(sys.modules, {"adbutils": unittest.mock.Mock(), "uiautomator2": unittest.mock.Mock()}),
|
||||
redirect_stderr(stderr),
|
||||
):
|
||||
result = script.main( # type: ignore[attr-defined]
|
||||
[
|
||||
"--serial", SERIAL,
|
||||
"--goods-id", EXPECTED_GOODS_ID,
|
||||
"--color", "黑色CHA(纯棉)",
|
||||
"--size", "M(建议100-115)",
|
||||
"--target-quantity", "2",
|
||||
"--gate1-unit-price", "12.88",
|
||||
"--gate1-screenshot", str(screenshot),
|
||||
"--gate1-captured-at", "2026-08-06T00:53:00.023935+00:00",
|
||||
"--max-total-price", "40.00",
|
||||
"--output-dir", str(Path(temporary) / "gate2"),
|
||||
]
|
||||
)
|
||||
self.assertEqual(result, 1)
|
||||
self.assertNotIn(SERIAL, stderr.getvalue())
|
||||
self.assertNotIn(temporary, stderr.getvalue())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -14,22 +14,51 @@ from xml.etree import ElementTree
|
||||
|
||||
from PIL import Image
|
||||
|
||||
import cmbuyer_client.pdd.sku_reveal_spike as reveal_module
|
||||
from cmbuyer_client.device.adb import AdbDevice, DeviceInspection
|
||||
from cmbuyer_client.pdd.sku_reveal_spike import (
|
||||
_annotate_reveal_failure,
|
||||
safe_reveal_failure_stage,
|
||||
SkuRevealSpikeCapturer,
|
||||
SkuRevealSpikeError,
|
||||
_require_safe_reveal_path,
|
||||
)
|
||||
from cmbuyer_client.pdd.sku_selection import SkuSelectionError, _parse_nodes
|
||||
from cmbuyer_client.pdd.sku_selection import (
|
||||
SkuSelectionError,
|
||||
_annotate_sku_entry_failure,
|
||||
_parse_nodes,
|
||||
_require_safe_reveal_path,
|
||||
_revealed_unselected_projection as _candidate_projection,
|
||||
)
|
||||
from cmbuyer_client.pdd.sku_selection_runner import (
|
||||
UiautomatorSkuPanelAdapter as _RevealEvidenceAdapter,
|
||||
)
|
||||
|
||||
|
||||
_FIXTURES = Path(__file__).with_name("fixtures")
|
||||
_PRODUCT = (_FIXTURES / "product_entry_8_17_0.xml").read_text(encoding="utf-8")
|
||||
_EMPTY = (_FIXTURES / "sku_panel_empty_8_17_0.xml").read_text(encoding="utf-8")
|
||||
_COLOR_ONLY = (_FIXTURES / "sku_panel_color_selected_size_hidden_8_17_0.xml").read_text(encoding="utf-8")
|
||||
_CURRENT_ONLY_EMPTY = (_FIXTURES / "sku_panel_empty_current_only_8_17_0.xml").read_text(encoding="utf-8")
|
||||
_CURRENT_ONLY_COLOR = (_FIXTURES / "sku_panel_color_selected_size_hidden_current_only_8_17_0.xml").read_text(encoding="utf-8")
|
||||
_M_SELECTED = (_FIXTURES / "sku_panel_size_m_restored_8_17_0.xml").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _inert_node(class_name: str, bounds: str) -> ElementTree.Element:
|
||||
return ElementTree.Element(
|
||||
"node",
|
||||
{
|
||||
"package": "com.xunmeng.pinduoduo",
|
||||
"class": class_name,
|
||||
"bounds": bounds,
|
||||
"clickable": "false",
|
||||
"enabled": "true",
|
||||
"visible-to-user": "true",
|
||||
"selected": "false",
|
||||
"scrollable": "false",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _candidate_unselected() -> str:
|
||||
root = ElementTree.fromstring(_M_SELECTED)
|
||||
parents = {child: parent for parent in root.iter() for child in parent}
|
||||
@@ -42,6 +71,38 @@ def _candidate_unselected() -> str:
|
||||
for child in list(wrapper):
|
||||
if child.get("class") == "android.view.View":
|
||||
wrapper.remove(child)
|
||||
parents = {child: parent for parent in root.iter() for child in parent}
|
||||
header = next(
|
||||
node for node in root.iter("node")
|
||||
if node.get("class") == "android.widget.LinearLayout"
|
||||
and node.get("bounds") == "[0,366][1080,1000]"
|
||||
)
|
||||
price_row = next(
|
||||
node for node in root.iter("node")
|
||||
if node.get("class") == "android.widget.LinearLayout"
|
||||
and node.get("bounds") == "[396,498][895,570]"
|
||||
)
|
||||
summary = next(
|
||||
node for node in root.iter("node")
|
||||
if node.get("class") == "android.widget.TextView"
|
||||
and node.get("bounds") == "[396,654][1053,716]"
|
||||
)
|
||||
parents[price_row].remove(price_row)
|
||||
parents[summary].remove(summary)
|
||||
content_frame = _inert_node("android.widget.FrameLayout", "[0,474][1080,863]")
|
||||
relative = _inert_node("android.widget.RelativeLayout", "[0,474][1080,863]")
|
||||
content = _inert_node("android.view.ViewGroup", "[0,474][1080,863]")
|
||||
price_frame = _inert_node("android.widget.FrameLayout", "[396,498][1053,570]")
|
||||
header.append(content_frame)
|
||||
content_frame.append(relative)
|
||||
relative.append(content)
|
||||
content.append(price_frame)
|
||||
price_frame.append(price_row)
|
||||
content.append(summary)
|
||||
for index in range(6):
|
||||
content.append(_inert_node("android.view.View", f"[{index},474][{index + 1},475]"))
|
||||
for index in range(3):
|
||||
header.append(_inert_node("android.view.View", f"[{index},366][{index + 1},367]"))
|
||||
action_root = next(
|
||||
node
|
||||
for node in root.iter("node")
|
||||
@@ -100,6 +161,238 @@ def _candidate_unselected() -> str:
|
||||
return ElementTree.tostring(root, encoding="unicode")
|
||||
|
||||
|
||||
def _candidate_current_only() -> str:
|
||||
root = ElementTree.fromstring(_candidate_unselected())
|
||||
parents = {child: parent for parent in root.iter() for child in parent}
|
||||
dual_row = next(
|
||||
node for node in root.iter("node")
|
||||
if node.get("class") == "android.widget.LinearLayout"
|
||||
and node.get("bounds") == "[396,498][895,570]"
|
||||
)
|
||||
price_frame = parents[dual_row]
|
||||
price_frame.remove(dual_row)
|
||||
row = _inert_node("android.widget.LinearLayout", "[396,498][693,570]")
|
||||
current = _inert_node("android.widget.TextView", "[396,503][675,570]")
|
||||
current.set("text", "限1件 ¥12.88 ")
|
||||
price_frame.append(row)
|
||||
row.append(current)
|
||||
return ElementTree.tostring(root, encoding="unicode")
|
||||
|
||||
|
||||
def _candidate_price_drift(variant: str, kind: str) -> str:
|
||||
root = ElementTree.fromstring(
|
||||
_candidate_unselected() if variant == "dual" else _candidate_current_only()
|
||||
)
|
||||
parents = {child: parent for parent in root.iter() for child in parent}
|
||||
if variant == "dual":
|
||||
row = next(
|
||||
node for node in root.iter("node")
|
||||
if node.get("class") == "android.widget.LinearLayout"
|
||||
and node.get("bounds") == "[396,498][895,570]"
|
||||
)
|
||||
current = next(node for node in row if node.get("text") == "快卖完 ¥12.88")
|
||||
original = next(node for node in row if node.get("text") == "¥29.88")
|
||||
if kind == "hybrid":
|
||||
current.set("text", "限1件 ¥12.88 ")
|
||||
current.set("bounds", "[396,503][675,570]")
|
||||
row.remove(original)
|
||||
elif kind == "duplicate":
|
||||
row.append(ElementTree.fromstring(ElementTree.tostring(current, encoding="unicode")))
|
||||
elif kind == "unknown":
|
||||
current.set("text", "未知候选价格")
|
||||
elif kind == "unknown_child":
|
||||
row.append(ElementTree.Element("node", dict(current.attrib) | {"text": "未知子节点"}))
|
||||
elif kind == "parent_drift":
|
||||
row.remove(current)
|
||||
parents[row].append(current)
|
||||
else:
|
||||
raise AssertionError(kind)
|
||||
elif variant == "current_only":
|
||||
frame = next(
|
||||
node for node in root.iter("node")
|
||||
if node.get("class") == "android.widget.FrameLayout"
|
||||
and node.get("bounds") == "[396,498][1053,570]"
|
||||
)
|
||||
row = next(node for node in frame if node.get("bounds") == "[396,498][693,570]")
|
||||
current = next(node for node in row if node.get("text") == "限1件 ¥12.88 ")
|
||||
if kind == "hybrid":
|
||||
current.set("text", "快卖完 ¥12.88")
|
||||
current.set("bounds", "[396,503][712,570]")
|
||||
original = ElementTree.Element(
|
||||
"node",
|
||||
dict(current.attrib)
|
||||
| {
|
||||
"text": "¥29.88",
|
||||
"bounds": "[730,503][895,570]",
|
||||
},
|
||||
)
|
||||
row.append(original)
|
||||
elif kind == "duplicate":
|
||||
parents[frame].append(
|
||||
ElementTree.fromstring(ElementTree.tostring(frame, encoding="unicode"))
|
||||
)
|
||||
elif kind == "unknown":
|
||||
current.set("bounds", "[396,502][675,570]")
|
||||
elif kind == "unknown_child":
|
||||
row.append(ElementTree.Element("node", dict(current.attrib) | {"text": "未知子节点"}))
|
||||
elif kind in {"original", "coupon_original"}:
|
||||
original = _inert_node("android.widget.TextView", "[730,503][895,570]")
|
||||
original.set("text", "¥29.88" if kind == "original" else "券前¥29.88")
|
||||
# 放在候选 header 之外,证明单价格分支检查整棵树,而非只看价格容器。
|
||||
root.append(original)
|
||||
elif kind == "parent_drift":
|
||||
frame.remove(row)
|
||||
parents[frame].append(row)
|
||||
else:
|
||||
raise AssertionError(kind)
|
||||
else:
|
||||
raise AssertionError(variant)
|
||||
return ElementTree.tostring(root, encoding="unicode")
|
||||
|
||||
|
||||
def _candidate_chain_drift(role: str, kind: str) -> str:
|
||||
root = ElementTree.fromstring(_candidate_current_only())
|
||||
parents = {child: parent for parent in root.iter() for child in parent}
|
||||
header = next(
|
||||
node for node in root.iter("node")
|
||||
if node.get("class") == "android.widget.LinearLayout"
|
||||
and node.get("bounds") == "[0,366][1080,1000]"
|
||||
)
|
||||
surface = parents[header]
|
||||
content_frame = next(
|
||||
node for node in header
|
||||
if node.get("class") == "android.widget.FrameLayout"
|
||||
and node.get("bounds") == "[0,474][1080,863]"
|
||||
)
|
||||
relative = next(iter(content_frame))
|
||||
content = next(iter(relative))
|
||||
price_frame = next(
|
||||
node for node in content
|
||||
if node.get("class") == "android.widget.FrameLayout"
|
||||
and node.get("bounds") == "[396,498][1053,570]"
|
||||
)
|
||||
row = next(iter(price_frame))
|
||||
current = next(iter(row))
|
||||
summary = next(
|
||||
node for node in content
|
||||
if node.get("class") == "android.widget.TextView"
|
||||
and node.get("bounds") == "[396,654][1053,716]"
|
||||
)
|
||||
roles = {
|
||||
"header": header,
|
||||
"content_frame": content_frame,
|
||||
"relative": relative,
|
||||
"content": content,
|
||||
"price_frame": price_frame,
|
||||
"row": row,
|
||||
"current": current,
|
||||
"summary": summary,
|
||||
}
|
||||
target = roles[role]
|
||||
if kind == "attrs":
|
||||
target.set("enabled", "false")
|
||||
elif kind == "children":
|
||||
target.append(_inert_node("android.view.View", "[1,1][2,2]"))
|
||||
elif kind == "parent":
|
||||
new_parents = {
|
||||
"header": next(
|
||||
node for node in surface
|
||||
if node.get("class") == "androidx.recyclerview.widget.RecyclerView"
|
||||
),
|
||||
"content_frame": surface,
|
||||
"relative": header,
|
||||
"content": content_frame,
|
||||
"price_frame": header,
|
||||
"row": content,
|
||||
"current": price_frame,
|
||||
"summary": header,
|
||||
}
|
||||
parents[target].remove(target)
|
||||
new_parents[role].append(target)
|
||||
else:
|
||||
raise AssertionError(kind)
|
||||
return ElementTree.tostring(root, encoding="unicode")
|
||||
|
||||
|
||||
def _candidate_cross_variant_residue(variant: str, kind: str) -> str:
|
||||
root = ElementTree.fromstring(
|
||||
_candidate_unselected() if variant == "dual" else _candidate_current_only()
|
||||
)
|
||||
content = next(
|
||||
node for node in root.iter("node")
|
||||
if node.get("class") == "android.view.ViewGroup"
|
||||
and node.get("bounds") == "[0,474][1080,863]"
|
||||
)
|
||||
filler = next(node for node in content if node.get("class") == "android.view.View")
|
||||
content.remove(filler)
|
||||
if variant == "dual":
|
||||
row = _inert_node("android.widget.LinearLayout", "[396,498][693,570]")
|
||||
current = _inert_node("android.widget.TextView", "[396,503][675,570]")
|
||||
current.set("text", "限1件 ¥12.88 ")
|
||||
row.append(current)
|
||||
fragments = {"row": row, "current": current}
|
||||
elif variant == "current_only":
|
||||
row = _inert_node("android.widget.LinearLayout", "[396,498][895,570]")
|
||||
current = _inert_node("android.widget.TextView", "[396,503][712,570]")
|
||||
current.set("text", "快卖完 ¥12.88")
|
||||
original = _inert_node("android.widget.TextView", "[730,503][895,570]")
|
||||
original.set("text", "¥29.88")
|
||||
row.extend((current, original))
|
||||
fragments = {"row": row, "current": current, "original": original}
|
||||
else:
|
||||
raise AssertionError(variant)
|
||||
if kind == "complete":
|
||||
frame = _inert_node("android.widget.FrameLayout", "[396,498][1053,570]")
|
||||
frame.append(row)
|
||||
content.append(frame)
|
||||
else:
|
||||
# 只插入一段精确 opposing role,且维持 content 原 child-count,防止由计数检查掩盖残片检查。
|
||||
content.append(ElementTree.fromstring(ElementTree.tostring(fragments[kind], encoding="unicode")))
|
||||
return ElementTree.tostring(root, encoding="unicode")
|
||||
|
||||
|
||||
def _candidate_with_color_unselected() -> str:
|
||||
root = ElementTree.fromstring(_candidate_current_only())
|
||||
target = next(
|
||||
node
|
||||
for node in root.iter("node")
|
||||
if node.get("class") == "android.view.ViewGroup"
|
||||
and node.get("content-desc") == "黑色 CHA (纯棉)"
|
||||
and node.get("bounds") == "[372,1000][684,1024]"
|
||||
)
|
||||
for node in target.iter("node"):
|
||||
node.set("selected", "false")
|
||||
return ElementTree.tostring(root, encoding="unicode")
|
||||
|
||||
|
||||
def _candidate_with_submit_risk(kind: str) -> str:
|
||||
root = ElementTree.fromstring(_candidate_current_only())
|
||||
parents = {child: parent for parent in root.iter() for child in parent}
|
||||
submit = next(
|
||||
node
|
||||
for node in root.iter("node")
|
||||
if "提交订单" in node.get("text", "")
|
||||
and node.get("class") == "android.widget.TextView"
|
||||
)
|
||||
parent = parents[submit]
|
||||
if kind == "missing":
|
||||
parent.remove(submit)
|
||||
elif kind == "duplicate":
|
||||
parent.append(ElementTree.fromstring(ElementTree.tostring(submit, encoding="unicode")))
|
||||
elif kind == "moved_up":
|
||||
submit.set("bounds", "[285,1900][795,1959]")
|
||||
elif kind in {"extra_altered", "extra_moved_up"}:
|
||||
extra = ElementTree.fromstring(ElementTree.tostring(submit, encoding="unicode"))
|
||||
if kind == "extra_altered":
|
||||
extra.set("text", "异常提交订单文案")
|
||||
else:
|
||||
extra.set("bounds", "[285,1900][795,1959]")
|
||||
parent.append(extra)
|
||||
else:
|
||||
raise AssertionError(kind)
|
||||
return ElementTree.tostring(root, encoding="unicode")
|
||||
|
||||
|
||||
def _with_clickable_overlay(package: str, class_name: str, bounds: str) -> str:
|
||||
root = ElementTree.fromstring(_COLOR_ONLY)
|
||||
container = next(root.iter("node"))
|
||||
@@ -132,7 +425,9 @@ def _png() -> str:
|
||||
class _FakeDevice:
|
||||
def __init__(self) -> None:
|
||||
self.hierarchy = "<hierarchy />"
|
||||
self.after_hierarchy = _candidate_unselected()
|
||||
self.empty_hierarchy = _CURRENT_ONLY_EMPTY
|
||||
self.color_hierarchy = _CURRENT_ONLY_COLOR
|
||||
self.after_hierarchy = _candidate_current_only()
|
||||
self.calls: list[tuple[object, ...]] = []
|
||||
self.swipe_error = False
|
||||
|
||||
@@ -156,10 +451,10 @@ class _FakeDevice:
|
||||
return _png()
|
||||
if method == "click":
|
||||
if params == [865, 2218]:
|
||||
self.hierarchy = _EMPTY
|
||||
self.hierarchy = self.empty_hierarchy
|
||||
return ""
|
||||
if params == [528, 1387]:
|
||||
self.hierarchy = _COLOR_ONLY
|
||||
self.hierarchy = self.color_hierarchy
|
||||
return ""
|
||||
raise AssertionError(params)
|
||||
if method == "swipe":
|
||||
@@ -200,6 +495,22 @@ class SkuRevealSpikeTests(unittest.TestCase):
|
||||
sleep_function=lambda seconds: now.__setitem__(0, now[0] + seconds),
|
||||
)
|
||||
|
||||
def _assert_after_rejected_once(self, after_hierarchy: str, *, ambiguous: bool = False) -> None:
|
||||
device = _FakeDevice()
|
||||
device.after_hierarchy = after_hierarchy
|
||||
device.swipe_error = ambiguous
|
||||
with TemporaryDirectory() as directory:
|
||||
target = Path(directory) / "evidence"
|
||||
with self.assertRaises(SkuRevealSpikeError):
|
||||
self._capturer(device).capture(
|
||||
"192.168.0.173:5555",
|
||||
"937122477375",
|
||||
target,
|
||||
)
|
||||
self.assertEqual(len(_swipes(device)), 1)
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(list(Path(directory).glob(".*.staging-*")), [])
|
||||
|
||||
def test_success_publishes_before_after_and_exactly_one_reveal(self) -> None:
|
||||
device = _FakeDevice()
|
||||
with TemporaryDirectory() as directory:
|
||||
@@ -227,6 +538,107 @@ class SkuRevealSpikeTests(unittest.TestCase):
|
||||
self.assertNotIn("start", manifest)
|
||||
self.assertNotIn("end", manifest)
|
||||
|
||||
def test_current_only_candidate_publishes_without_changing_other_candidate_rules(self) -> None:
|
||||
device = _FakeDevice()
|
||||
device.after_hierarchy = _candidate_current_only()
|
||||
with TemporaryDirectory() as directory:
|
||||
target = Path(directory) / "evidence"
|
||||
result = self._capturer(device).capture(
|
||||
"192.168.0.173:5555",
|
||||
"937122477375",
|
||||
target,
|
||||
)
|
||||
|
||||
self.assertEqual(result.output_directory, target)
|
||||
self.assertEqual(len(_swipes(device)), 1)
|
||||
self.assertTrue((target / "after" / "hierarchy.xml").is_file())
|
||||
|
||||
def test_candidate_price_variants_reject_hybrid_duplicate_unknown_and_parent_drift(self) -> None:
|
||||
# 生产只放行 current-only;历史 dual 及任意混搭、额外节点、父链漂移均拒绝。
|
||||
with self.assertRaises(SkuSelectionError):
|
||||
_candidate_projection(_parse_nodes(_candidate_unselected()))
|
||||
_candidate_projection(_parse_nodes(_candidate_current_only()))
|
||||
for variant in ("dual", "current_only"):
|
||||
kinds = ["hybrid", "duplicate", "unknown", "unknown_child", "parent_drift"]
|
||||
if variant == "current_only":
|
||||
kinds.extend(("original", "coupon_original"))
|
||||
for kind in kinds:
|
||||
with self.subTest(variant=variant, kind=kind), self.assertRaises(SkuSelectionError):
|
||||
_candidate_projection(
|
||||
_parse_nodes(_candidate_price_drift(variant, kind))
|
||||
)
|
||||
|
||||
def test_candidate_real_container_chain_rejects_every_parent_attribute_and_child_drift(self) -> None:
|
||||
roles = (
|
||||
"header",
|
||||
"content_frame",
|
||||
"relative",
|
||||
"content",
|
||||
"price_frame",
|
||||
"row",
|
||||
"current",
|
||||
"summary",
|
||||
)
|
||||
for role in roles:
|
||||
for kind in ("parent", "attrs", "children"):
|
||||
with self.subTest(role=role, kind=kind), self.assertRaises(SkuSelectionError):
|
||||
_candidate_projection(_parse_nodes(_candidate_chain_drift(role, kind)))
|
||||
|
||||
def test_candidate_price_variants_reject_complete_and_partial_opposing_shapes(self) -> None:
|
||||
for variant, kinds in (
|
||||
("dual", ("complete", "row", "current")),
|
||||
("current_only", ("complete", "row", "current", "original")),
|
||||
):
|
||||
for kind in kinds:
|
||||
with self.subTest(variant=variant, kind=kind), self.assertRaises(SkuSelectionError):
|
||||
_candidate_projection(
|
||||
_parse_nodes(_candidate_cross_variant_residue(variant, kind))
|
||||
)
|
||||
|
||||
def test_current_only_empty_and_color_only_are_accepted_before_reveal(self) -> None:
|
||||
device = _FakeDevice()
|
||||
device.empty_hierarchy = _CURRENT_ONLY_EMPTY
|
||||
device.color_hierarchy = _CURRENT_ONLY_COLOR
|
||||
with TemporaryDirectory() as directory:
|
||||
target = Path(directory) / "evidence"
|
||||
result = self._capturer(device).capture(
|
||||
"192.168.0.173:5555",
|
||||
"937122477375",
|
||||
target,
|
||||
)
|
||||
|
||||
self.assertEqual(result.output_directory, target)
|
||||
self.assertEqual(len(_swipes(device)), 1)
|
||||
self.assertEqual(
|
||||
(target / "before" / "hierarchy.xml").read_text(encoding="utf-8"),
|
||||
_CURRENT_ONLY_COLOR,
|
||||
)
|
||||
|
||||
def test_cross_price_variant_after_color_click_is_precondition_failure_without_reveal(self) -> None:
|
||||
for empty_hierarchy, color_hierarchy in (
|
||||
(_CURRENT_ONLY_EMPTY, _COLOR_ONLY),
|
||||
(_EMPTY, _CURRENT_ONLY_COLOR),
|
||||
):
|
||||
with self.subTest():
|
||||
device = _FakeDevice()
|
||||
device.empty_hierarchy = empty_hierarchy
|
||||
device.color_hierarchy = color_hierarchy
|
||||
with TemporaryDirectory() as directory:
|
||||
target = Path(directory) / "evidence"
|
||||
with self.assertRaises(SkuSelectionError) as raised:
|
||||
self._capturer(device).capture(
|
||||
"192.168.0.173:5555",
|
||||
"937122477375",
|
||||
target,
|
||||
)
|
||||
self.assertEqual(
|
||||
safe_reveal_failure_stage(raised.exception),
|
||||
"reveal_precondition",
|
||||
)
|
||||
self.assertEqual(_swipes(device), [])
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(list(Path(directory).glob(".*.staging-*")), [])
|
||||
|
||||
def test_ambiguous_rpc_is_read_only_reconciled_without_retry(self) -> None:
|
||||
device = _FakeDevice()
|
||||
device.swipe_error = True
|
||||
@@ -274,6 +686,20 @@ class SkuRevealSpikeTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(_swipes(device), [])
|
||||
|
||||
def test_hidden_sizes_after_swipe_are_not_published_or_retried(self) -> None:
|
||||
self._assert_after_rejected_once(_COLOR_ONLY)
|
||||
|
||||
def test_post_reveal_color_selected_drift_is_not_published_or_retried(self) -> None:
|
||||
self._assert_after_rejected_once(_candidate_with_color_unselected())
|
||||
|
||||
def test_submit_hard_reject_zone_risk_is_not_published_or_retried(self) -> None:
|
||||
for kind in ("missing", "duplicate", "moved_up", "extra_altered", "extra_moved_up"):
|
||||
with self.subTest(kind=kind):
|
||||
self._assert_after_rejected_once(_candidate_with_submit_risk(kind))
|
||||
|
||||
def test_ambiguous_rpc_with_unchanged_page_is_not_published_or_retried(self) -> None:
|
||||
self._assert_after_rejected_once(_COLOR_ONLY, ambiguous=True)
|
||||
|
||||
def test_invalid_goods_and_existing_target_are_zero_action(self) -> None:
|
||||
device = _FakeDevice()
|
||||
with TemporaryDirectory() as directory:
|
||||
@@ -298,7 +724,7 @@ class SkuRevealSpikeTests(unittest.TestCase):
|
||||
device.jsonrpc_call = drift # type: ignore[method-assign]
|
||||
with TemporaryDirectory() as directory:
|
||||
target = Path(directory) / "evidence"
|
||||
with self.assertRaises(SkuSelectionError):
|
||||
with self.assertRaises(SkuSelectionError) as raised:
|
||||
self._capturer(device).capture(
|
||||
"192.168.0.173:5555",
|
||||
"937122477375",
|
||||
@@ -306,6 +732,115 @@ class SkuRevealSpikeTests(unittest.TestCase):
|
||||
)
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(_swipes(device), [])
|
||||
self.assertEqual(safe_reveal_failure_stage(raised.exception), "reveal_precondition")
|
||||
|
||||
def test_after_screenshot_current_only_to_dual_drift_is_not_published(self) -> None:
|
||||
device = _FakeDevice()
|
||||
current_only = _candidate_current_only()
|
||||
device.after_hierarchy = current_only
|
||||
original_call = device.jsonrpc_call
|
||||
|
||||
def drift(method: str, params: object = None, timeout: float = 10) -> str:
|
||||
value = original_call(method, params, timeout)
|
||||
if method == "takeScreenshot" and device.hierarchy == current_only:
|
||||
device.hierarchy = _candidate_unselected()
|
||||
return value
|
||||
|
||||
device.jsonrpc_call = drift # type: ignore[method-assign]
|
||||
with TemporaryDirectory() as directory:
|
||||
target = Path(directory) / "evidence"
|
||||
with self.assertRaises((SkuRevealSpikeError, SkuSelectionError)) as raised:
|
||||
self._capturer(device).capture(
|
||||
"192.168.0.173:5555",
|
||||
"937122477375",
|
||||
target,
|
||||
)
|
||||
self.assertEqual(safe_reveal_failure_stage(raised.exception), "reveal_after")
|
||||
self.assertEqual(len(_swipes(device)), 1)
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(list(Path(directory).glob(".*.staging-*")), [])
|
||||
|
||||
def test_reveal_failure_stages_bind_attempt_candidate_after_and_publish(self) -> None:
|
||||
device = _FakeDevice()
|
||||
|
||||
def fail_before_seal(adapter: _RevealEvidenceAdapter) -> None:
|
||||
raise SkuRevealSpikeError("private pre-seal detail")
|
||||
|
||||
with TemporaryDirectory() as directory:
|
||||
target = Path(directory) / "pre-seal"
|
||||
with (
|
||||
patch.object(_RevealEvidenceAdapter, "reveal_size_options_once", fail_before_seal),
|
||||
self.assertRaises(SkuRevealSpikeError) as pre_seal,
|
||||
):
|
||||
self._capturer(device).capture("192.168.0.173:5555", "937122477375", target)
|
||||
self.assertEqual(safe_reveal_failure_stage(pre_seal.exception), "reveal_precondition")
|
||||
self.assertEqual(_swipes(device), [])
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(list(Path(directory).glob(".*.staging-*")), [])
|
||||
|
||||
# attempted 只可能在 adapter 已封存并发送唯一手势后报告。
|
||||
device = _FakeDevice()
|
||||
original_reveal = _RevealEvidenceAdapter.reveal_size_options_once
|
||||
|
||||
def fail_after_attempt(adapter: _RevealEvidenceAdapter) -> None:
|
||||
original_reveal(adapter)
|
||||
raise SkuRevealSpikeError("private attempted detail")
|
||||
|
||||
with TemporaryDirectory() as directory:
|
||||
target = Path(directory) / "attempted"
|
||||
with (
|
||||
patch.object(_RevealEvidenceAdapter, "reveal_size_options_once", fail_after_attempt),
|
||||
self.assertRaises(SkuRevealSpikeError) as attempted,
|
||||
):
|
||||
self._capturer(device).capture("192.168.0.173:5555", "937122477375", target)
|
||||
self.assertEqual(safe_reveal_failure_stage(attempted.exception), "reveal_attempted")
|
||||
self.assertEqual(len(_swipes(device)), 1)
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(list(Path(directory).glob(".*.staging-*")), [])
|
||||
|
||||
device = _FakeDevice()
|
||||
device.after_hierarchy = _COLOR_ONLY
|
||||
with TemporaryDirectory() as directory:
|
||||
target = Path(directory) / "candidate"
|
||||
with self.assertRaises(SkuRevealSpikeError) as candidate:
|
||||
self._capturer(device).capture("192.168.0.173:5555", "937122477375", target)
|
||||
self.assertEqual(safe_reveal_failure_stage(candidate.exception), "reveal_candidate")
|
||||
self.assertEqual(len(_swipes(device)), 1)
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(list(Path(directory).glob(".*.staging-*")), [])
|
||||
|
||||
device = _FakeDevice()
|
||||
original_capture = reveal_module._capture_frame
|
||||
|
||||
def fail_after_capture(adapter: object, directory: Path, hierarchy: str) -> None:
|
||||
if directory.name == "after":
|
||||
raise OSError("private after path")
|
||||
original_capture(adapter, directory, hierarchy)
|
||||
|
||||
with TemporaryDirectory() as directory:
|
||||
target = Path(directory) / "after"
|
||||
with (
|
||||
patch.object(reveal_module, "_capture_frame", fail_after_capture),
|
||||
self.assertRaises(SkuRevealSpikeError) as after,
|
||||
):
|
||||
self._capturer(device).capture("192.168.0.173:5555", "937122477375", target)
|
||||
self.assertEqual(safe_reveal_failure_stage(after.exception), "reveal_after")
|
||||
self.assertEqual(len(_swipes(device)), 1)
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(list(Path(directory).glob(".*.staging-*")), [])
|
||||
|
||||
device = _FakeDevice()
|
||||
with TemporaryDirectory() as directory:
|
||||
target = Path(directory) / "publish"
|
||||
with (
|
||||
patch.object(reveal_module.os, "rename", side_effect=OSError("private publish path")),
|
||||
self.assertRaises(SkuRevealSpikeError) as publish,
|
||||
):
|
||||
self._capturer(device).capture("192.168.0.173:5555", "937122477375", target)
|
||||
self.assertEqual(safe_reveal_failure_stage(publish.exception), "reveal_publish")
|
||||
self.assertEqual(len(_swipes(device)), 1)
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(list(Path(directory).glob(".*.staging-*")), [])
|
||||
|
||||
def test_complete_reveal_segment_rejects_narrow_impostor_and_invalid_bounds(self) -> None:
|
||||
for hierarchy in (
|
||||
@@ -325,7 +860,7 @@ class SkuRevealSpikeTests(unittest.TestCase):
|
||||
"not-a-bound",
|
||||
),
|
||||
):
|
||||
with self.subTest(), self.assertRaises(SkuRevealSpikeError):
|
||||
with self.subTest(), self.assertRaises((SkuRevealSpikeError, SkuSelectionError)):
|
||||
_require_safe_reveal_path(_parse_nodes(hierarchy))
|
||||
|
||||
|
||||
@@ -377,10 +912,93 @@ class SkuRevealSpikeCliTests(unittest.TestCase):
|
||||
)
|
||||
output = stderr.getvalue()
|
||||
self.assertEqual(status, 1)
|
||||
self.assertIn("stage=unknown", output)
|
||||
self.assertNotIn("Traceback", output)
|
||||
self.assertNotIn("192.168.0.173:5555", output)
|
||||
self.assertNotIn("private", output)
|
||||
|
||||
def test_cli_reports_only_formally_annotated_entry_stage(self) -> None:
|
||||
script = _load_reveal_script()
|
||||
secret = "SERIAL=192.168.0.173:5555 <hierarchy>private</hierarchy>"
|
||||
|
||||
def run_with(error: BaseException) -> str:
|
||||
class FailingCapturer:
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
return None
|
||||
|
||||
def capture(self, *args: object, **kwargs: object) -> object:
|
||||
raise error
|
||||
|
||||
stderr = io.StringIO()
|
||||
with patch.object(script, "SkuRevealSpikeCapturer", FailingCapturer), redirect_stderr(stderr):
|
||||
status = script.main(
|
||||
[
|
||||
"--serial", "192.168.0.173:5555",
|
||||
"--goods-id", "937122477375",
|
||||
"--output-dir", "evidence",
|
||||
]
|
||||
)
|
||||
self.assertEqual(status, 1)
|
||||
output = stderr.getvalue()
|
||||
self.assertNotIn("Traceback", output)
|
||||
self.assertNotIn("192.168.0.173:5555", output)
|
||||
self.assertNotIn("private", output)
|
||||
return output
|
||||
|
||||
annotated = SkuSelectionError(secret)
|
||||
_annotate_sku_entry_failure(annotated, "sku_entry_panel_verify")
|
||||
_annotate_reveal_failure(annotated, "reveal_precondition")
|
||||
self.assertIn("stage=sku_entry_panel_verify", run_with(annotated))
|
||||
|
||||
spoofed = SkuSelectionError(secret)
|
||||
setattr(spoofed, "_cmbuyer_failure_stage", "sku_entry_panel_verify")
|
||||
self.assertIn("stage=unknown", run_with(spoofed))
|
||||
|
||||
def test_cli_reports_only_formally_annotated_reveal_stage(self) -> None:
|
||||
script = _load_reveal_script()
|
||||
secret = "SERIAL=192.168.0.173:5555 <hierarchy>private</hierarchy>"
|
||||
|
||||
def run_with(error: BaseException) -> str:
|
||||
class FailingCapturer:
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
return None
|
||||
|
||||
def capture(self, *args: object, **kwargs: object) -> object:
|
||||
raise error
|
||||
|
||||
stderr = io.StringIO()
|
||||
with patch.object(script, "SkuRevealSpikeCapturer", FailingCapturer), redirect_stderr(stderr):
|
||||
self.assertEqual(
|
||||
script.main(
|
||||
[
|
||||
"--serial", "192.168.0.173:5555",
|
||||
"--goods-id", "937122477375",
|
||||
"--output-dir", "evidence",
|
||||
]
|
||||
),
|
||||
1,
|
||||
)
|
||||
output = stderr.getvalue()
|
||||
self.assertNotIn("192.168.0.173:5555", output)
|
||||
self.assertNotIn("private", output)
|
||||
return output
|
||||
|
||||
annotated = SkuRevealSpikeError(secret)
|
||||
_annotate_reveal_failure(annotated, "reveal_candidate")
|
||||
self.assertIn("stage=reveal_candidate", run_with(annotated))
|
||||
|
||||
spoofed = SkuRevealSpikeError(secret)
|
||||
setattr(spoofed, "_cmbuyer_reveal_failure_stage", "reveal_candidate")
|
||||
self.assertIn("stage=unknown", run_with(spoofed))
|
||||
|
||||
class HostileGetterError(SkuRevealSpikeError):
|
||||
def __getattribute__(self, name: str) -> object:
|
||||
if name.startswith("_cmbuyer_reveal_"):
|
||||
raise RuntimeError(secret)
|
||||
return super().__getattribute__(name)
|
||||
|
||||
self.assertIn("stage=unknown", run_with(HostileGetterError(secret)))
|
||||
|
||||
|
||||
def _load_reveal_script() -> object:
|
||||
path = Path(__file__).resolve().parents[2] / "scripts" / "capture_sku_reveal_spike.py"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,7 @@ cmbuyer 是一个自动化采购系统:**采购服务**(网页端,`admin/`
|
||||
|
||||
第一版 MVP 只做**任务自带商品链接**的情形。创建任务不授权;管理员勾选 `DRAFT` 并点击
|
||||
“开始采购(只创建待付款订单)”后,服务端锁定商品、规格、数量和最高总价并签发一次性授权。
|
||||
采购工具领取后在**同一趟**完成精确选规格、三道价格闸门、数量复核、确认页、提交围栏和唯一一次
|
||||
采购工具领取后在**同一趟**完成精确选规格、三道价格闸门、数量复核、合并式最终提交面板、提交围栏和唯一一次
|
||||
“提交订单”点击。中间不等待人工确认;订单创建后转待付款,由人核对和付款。
|
||||
|
||||
## 上下文读取
|
||||
@@ -66,7 +66,9 @@ cmbuyer 是一个自动化采购系统:**采购服务**(网页端,`admin/`
|
||||
|
||||
### 3. 价格只在两个地方读
|
||||
|
||||
**规格面板**(闸门一 / 二)和**订单确认页**(闸门三)。
|
||||
拼多多 `8.17.0` 合并式最终提交面板中的两个已取证角色:面板顶部金额(闸门一 / 二)和最终红色
|
||||
控件显示金额(闸门三)。Gate3 只能只读结构化精确文本 `提交订单 ¥{金额}`,且必须与 Gate2 严格
|
||||
相等;它不得回流充当 Gate1/Gate2。
|
||||
|
||||
**不从商品详情页正文、搜索结果卡片或任何其他位置读价格。** 那些位置的价格文本被拆成
|
||||
多个节点、带「券后」前缀、实付价与原价混在一起,前序项目在这里耗掉大量时间且无可靠
|
||||
@@ -77,7 +79,7 @@ cmbuyer 是一个自动化采购系统:**采购服务**(网页端,`admin/`
|
||||
### 4. 真机能力逐段取证、隔离开放
|
||||
|
||||
T-103 的 `SkuSelectionFlow` 只允许证据绑定的受控入口、精确选择、读价和安全退出;不得引用
|
||||
数量、确认页、提交围栏、提交或付款能力。T-105~T-107 分别取证后才可组合。生产单趟只有在全部
|
||||
数量、最终提交面板、提交围栏、提交或付款能力。T-105~T-107 分别取证后才可组合。生产单趟只有在全部
|
||||
能力均获证据、三道闸门通过且服务端围栏明确许可后才能点击一次;支付能力永不开放。
|
||||
|
||||
## 当前阶段
|
||||
@@ -123,7 +125,7 @@ T-103 的 `SkuSelectionFlow` 只允许证据绑定的受控入口、精确选择
|
||||
|
||||
- **手工填链接**建单、任务查询、勾选待开始任务后批量“开始采购”
|
||||
- 点击开始采购即签发锁定商品、规格、数量和最高总价的一次性授权
|
||||
- 桌面端定时轮询只领取已授权任务,在同一趟精确选规格、两次读价、复核数量和确认页金额
|
||||
- 桌面端定时轮询只领取已授权任务,在同一趟精确选规格、复核数量并读取 Gate1/Gate2/Gate3
|
||||
- 围栏前失败/过期可人工回到待开始;围栏后只能调和
|
||||
- 三道闸门全过、服务端围栏明确许可后提交订单一次
|
||||
- 真实点击前服务端原子建立提交围栏;围栏失败不点击,围栏后只调和同一提交记录
|
||||
|
||||
+17
-12
@@ -33,7 +33,7 @@ MVP 只做**任务自带商品链接**的情形。管理员点击“开始采购
|
||||
| F-001 | 手工建单 | 填写标题、拼多多链接、颜色分类、尺码、数量、最高总价,保存为待开始任务 | P0 | US-001 |
|
||||
| F-004 | 任务查询 | 按标题关键词、状态、时间范围找到任务,双击行查看详情 | P0 | US-002 |
|
||||
| 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-008 | 开始采购授权 | 管理员批量点击一次,原子锁定每条任务并签发一次性待付款订单授权 | P0 | US-005、US-010 |
|
||||
| F-010 | 围栏前过期与重新授权 | 未建立提交围栏的失败或过期任务回到待开始,可由管理员重新点击开始采购 | P0 | US-005 |
|
||||
@@ -102,11 +102,15 @@ US-004 的试选后人工确认已从 MVP 删除。US-006 在 MVP 为:系统
|
||||
拼多多 `8.17.0`、goods_id `937122477375` 的“快要抢光”;其他入口必须分别取证,不按购买语义泛化。
|
||||
- **F-006 规格与数量**:颜色分类、尺码必须在各自维度内精确唯一匹配并读回;数量设置后必须读回
|
||||
等于任务值。缺失、重复、禁用、前缀碰撞、读回不一致都停止,不选相近项。
|
||||
- **F-006 三道价格闸门**:价格只从规格面板和订单确认页读取,金额使用十进制字符串。
|
||||
- **F-006 三道价格闸门**:价格只从拼多多 `8.17.0` 已取证的合并式最终提交面板两个独立角色读取,
|
||||
金额使用十进制字符串。
|
||||
1. 选中目标规格后读取当前 SKU 单价,计算 `单价 × 数量`,不得超过授权最高总价。
|
||||
2. 设置并复核数量后再次读取同一规格与单价;规格必须仍相同,单价必须与闸门一完全相等。
|
||||
3. 订单确认页核对规格、数量与应付总额;总额不得超过授权最高总价。
|
||||
任一道读不到、有歧义或不通过都停止并转人工,不从详情页、搜索页或按钮文字凑价格。
|
||||
2. 设置并复核数量后再次核对同一规格,并唯一读取规格面板的目标数量总额;总额不得超过授权
|
||||
最高总价。多件优惠可能非线性,不用闸门一金额乘数量猜总额,也不要求目标数量总额等于闸门一。
|
||||
3. 保持同一稳定面板,再次核对规格和数量,并从唯一结构化精确文本 `提交订单 ¥{金额}` 读取最终
|
||||
控件显示金额;它必须与闸门二顶部总额严格相等,且再次不超过授权最高总价。
|
||||
任一道读不到、有歧义或不通过都停止并转人工,不从详情页、搜索页或其他按钮文字凑价格。Gate3
|
||||
最终控件金额只承担 Gate3,不得用于 Gate1/Gate2。
|
||||
- 同一设备会话连续完成上述步骤;不退出商品再等采购服务确认,也不把旧截图或旧页面读数当作
|
||||
当前提交依据。
|
||||
|
||||
@@ -122,18 +126,19 @@ US-004 的试选后人工确认已从 MVP 删除。US-006 在 MVP 为:系统
|
||||
- 围栏建立前,授权过期、设备失败或人工放弃可使任务回到 `DRAFT`;必须由管理员重新点击“开始采购”
|
||||
产生新授权。围栏建立后不得释放、重新授权、重新领取或再次点击。
|
||||
- 系统任何路径都不点击支付、免密支付、先用后付或扣款控件;提交订单后的代码路径不含支付动作。
|
||||
- 发布真实下单能力前必须用独立真机任务完成只读 dry-run:到订单确认页验证规格、数量、金额和提交
|
||||
控件唯一但不点击。dry-run 是版本发布门禁,不是每笔订单的第一趟,也不能代替实时三道闸门。
|
||||
- 发布真实下单能力前必须用独立真机任务完成只读 dry-run:在合并式最终提交面板验证规格、数量、
|
||||
Gate2 顶部总额、Gate3 最终控件金额和最近可点击祖先唯一但不点击。dry-run 是版本发布门禁,不是
|
||||
每笔订单的第一趟,也不能代替实时三道闸门。
|
||||
|
||||
### 结果、证据与异常
|
||||
|
||||
- **F-007**:按步骤回传实际规格、两次规格面板价格、数量读回、确认页总额、判定、时间和截图。
|
||||
- **F-007**:按步骤回传实际规格、闸门一单价、数量读回、闸门二顶部总额、闸门三最终控件金额、判定、时间和截图。
|
||||
截图是内部审计证据,不是提交前审批前置。
|
||||
- 内部系统允许上传规格面板与订单确认页的原始截图,截图中可保留页面已显示的地址和手机号;只允许
|
||||
- 内部系统允许上传规格面板与合并式最终提交面板的原始截图,截图中可保留页面已显示的地址和手机号;只允许
|
||||
已认证设备显式上传、已登录管理员查看。完整 XML 只留采购工具本机,不上传;地址/手机号不解析为
|
||||
业务字段或日志;外部支付页、支付凭据永不上传。
|
||||
- **F-011**:至少区分设备未连接、版本失配、商品打不开、面板打不开、规格不匹配、价格不可读、
|
||||
两次价格不一致、数量复核失败、金额超上限、确认页不一致、提交控件不唯一、围栏失败、页面识别
|
||||
规格漂移、数量复核失败、闸门二总额不可读或超上限、闸门三与闸门二不一致、提交控件不唯一、围栏失败、页面识别
|
||||
失败、安全校验、外部支付交接和超时。
|
||||
- 运行时拼多多 App 版本与已取证版本不一致时停止领取真机任务,提示重新取证。
|
||||
- 订单明确创建后展示截图、商品、规格、数量和授权金额;人在拼多多付款后手工标记完成。
|
||||
@@ -167,8 +172,8 @@ US-004 的试选后人工确认已从 MVP 删除。US-006 在 MVP 为:系统
|
||||
版本;不能从前序项目或旧版本推断。
|
||||
- **资金风险**:管理员点击开始采购会授权创建真实待付款订单。按钮必须明确影响范围与最高总额,
|
||||
服务端一次性授权、提交围栏和点击一次共同防止重复订单;付款始终人工。
|
||||
- **单趟时效风险**:授权到实际执行之间价格可能变化。任务不锁定旧观察价,而是在同一趟内两次读取
|
||||
规格面板价格并在确认页校验总额;任何变化或歧义都停止。
|
||||
- **单趟时效风险**:授权到实际执行之间价格可能变化。任务不锁定旧观察价,而是在同一趟内读取
|
||||
Gate1 单价、Gate2 顶部总额和 Gate3 最终控件金额;后两者不一致或任一歧义都停止。
|
||||
- **账号与风控风险**:固定机器节奏可能触发平台风控。轮询和动作间隔需可配置,检测到验证码、短信、
|
||||
人脸或风险校验立即停止,不绕过。
|
||||
- **隐私风险**:内部原始截图可能含地址和手机号。只允许受控内部链路;不提取结构化隐私字段,完整
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
| 维度 | 选型 | 状态 | 理由 / 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 语言 | Python 3.11+ | 已定 | AI 生态原生;`uiautomator2` 官方语言 |
|
||||
| 真机控制 | `uiautomator2` | 已定 | 前序项目真机验证过选规格与订单确认页读取 |
|
||||
| 真机控制 | `uiautomator2` | 已定 | 前序项目验证过自动化可行性;本项目已重新取证规格与合并式最终提交面板 |
|
||||
| 传输 | ADB(USB 或 WiFi) | 已定 | `uiautomator2` 3.x 走 adb 通道,`ip:port` 与 USB serial 同等对待 |
|
||||
| 桌面 GUI | `PySide6` | 已定 | 前序项目已验证;执行员需要看设备状态和批次进度 |
|
||||
| 截图处理 | `Pillow` | 已定 | 判断页面是否渲染完成,避免保存白屏壳层 |
|
||||
@@ -169,7 +169,7 @@ PDD 规格面板不可避免显示收货区域和手机号。项目所有者确
|
||||
不上传。T-204 直接接入原始截图上传,资产标记 `privacy_tier=INTERNAL_RAW`,只允许
|
||||
已认证设备写入、已登录管理员读取;不上传 XML,不允许外部支付页或支付凭据。
|
||||
|
||||
这个范围调整不授权 T-103 调整数量、进入确认页、点击“提交订单”或触碰支付控件。T-103 最终由人
|
||||
这个范围调整不授权 T-103 调整数量、观察最终提交面板、点击“提交订单”或触碰支付控件。T-103 最终由人
|
||||
运行受控入口、目标颜色、一次 reveal、目标 M、读价和一次 Back 的完整真机验收;生产业务最终为单趟,
|
||||
但能力仍按 T-103 / T-105 / T-107 分段取证。
|
||||
|
||||
|
||||
+44
-27
@@ -81,9 +81,9 @@ MVP 只做任务自带商品链接的 A 路径。创建任务与开始采购分
|
||||
│ 3. 按维度精确选择并读回颜色、尺码
|
||||
│ 4. 【闸门一】读 SKU 单价;单价×数量不得超过最高总价
|
||||
│ 5. 设置数量并精确读回
|
||||
│ 6. 【闸门二】重读规格与单价;规格不变且价格等于闸门一
|
||||
│ 7. 进入订单确认页
|
||||
│ 8. 【闸门三】规格/数量一致,应付总额不超最高总价
|
||||
│ 6. 【闸门二】重读规格、数量与面板总额;总额不超最高总价
|
||||
│ 7. 保持在合并式最终提交面板,不执行页面导航
|
||||
│ 8. 【闸门三】重核规格/数量;最终控件金额等于闸门二且不超最高总价
|
||||
│ 9. 上传验证摘要并申请服务端提交围栏
|
||||
│ 10. 仅在 click_permitted=true 且提交控件唯一时点击一次
|
||||
│ 11. 回传观察结果;不确定时只调和,不重试
|
||||
@@ -104,13 +104,14 @@ T-103 最新真机证据表明:拼多多 `8.17.0`、goods_id `937122477375`、
|
||||
|
||||
- 只能精确唯一匹配底部非点击文本叶节点、其直接可点击 PDD 父容器及证据绑定结构,并且只点击
|
||||
第一行文本叶节点中心;缺失、重复、版本失配、结构漂移或打开后面板不唯一时零后续点击。
|
||||
- 商品内容区同名促销小字明确禁止作为入口。底部按钮中的金额只参与结构识别,不得解析、返回或
|
||||
充当价格来源;价格仍只能从规格面板和订单确认页读取。
|
||||
- 商品内容区同名促销小字明确禁止作为入口。底部购买区入口金额只参与结构识别,不得解析、返回或
|
||||
充当价格来源;规格选择后的最终红色控件金额也不得用于 Gate1/Gate2,只在 T-106 已确认的合并式
|
||||
最终提交面板中作为 Gate3 独立金额角色。
|
||||
- `免拼购买` 只允许作为已取证、不可点击的精确兄弟节点验证底部结构,不能作为选择器、目标或兜底;
|
||||
它变为可点击、位置漂移或出现在其他结构时必须拒绝。“单独购买 / 直接拼成”等其他文案同样不能
|
||||
用包含、前缀、同义或坐标兜底。
|
||||
- 受控入口只负责进入已取证面板,不等于支付授权,也不能暴露通用任意点击能力。
|
||||
- T-103 的隔离验证 capability 只包含开商品、开面板、选规格、读价和安全退出;数量、确认页、
|
||||
- T-103 的隔离验证 capability 只包含开商品、开面板、选规格、读价和安全退出;数量、最终提交面板、
|
||||
提交和支付仍由后续真机任务分别取证后才能接入生产单趟执行器。
|
||||
|
||||
2026-08-05 的五组 8.17.0 真机证据进一步证明,面板刚打开时颜色与尺码均未选中,尺码选项尚未
|
||||
@@ -135,14 +136,23 @@ T-103 最新真机证据表明:拼多多 `8.17.0`、goods_id `937122477375`、
|
||||
| 闸门 | 当前页面 | 判据 | 拒绝条件 |
|
||||
| --- | --- | --- | --- |
|
||||
| 一 | 规格面板,选中目标规格后 | 单价唯一可读;`单价 × 授权数量 <= 最高总价` | 不可读、有歧义或超上限 |
|
||||
| 二 | 规格面板,数量读回后 | 颜色、尺码仍正确;重读单价与闸门一完全相等 | 规格漂移或价格变化 |
|
||||
| 三 | 订单确认页 | 规格、数量正确;应付总额唯一可读且不超最高总价 | 任一不一致、不可读或超上限 |
|
||||
| 二 | 规格面板,数量读回后 | 颜色、尺码仍正确;目标数量面板总额唯一可读且不超最高总价 | 规格/数量漂移、总额不可读/有歧义或超上限 |
|
||||
| 三 | 同一合并式最终提交面板 | 规格、数量正确;最终红色控件结构化文本中的金额唯一可读、严格等于闸门二且不超最高总价 | 任一不一致、不可读、有歧义或超上限 |
|
||||
|
||||
金额一律使用十进制字符串和十进制定点运算,不用浮点数。价格只从规格面板和订单确认页读取;
|
||||
详情正文、搜索卡片、底部购买/提交按钮的数字不作为价格来源。
|
||||
金额一律使用十进制字符串和十进制定点运算,不用浮点数。Gate1/Gate2 只从规格面板顶部已批准角色
|
||||
读取;Gate3 只从同一最终面板唯一结构化精确文本 `提交订单 ¥{规范十进制金额}` 读取。详情正文、
|
||||
搜索卡片、底部购买入口和其他按钮数字都不是价格来源;Gate3 金额也不得回流充当 Gate1/Gate2。
|
||||
|
||||
闸门一与闸门二发生在同一设备会话中。它们用于发现选择或设置数量造成的页面变化,不需要管理员
|
||||
在中间确认。旧截图、缓存值和发布前 dry-run 都不能替代这次实时读取。
|
||||
2026-08-06 的拼多多 `8.17.0` 真机证据证明同一规格的多件优惠是非线性的:数量 1 时规格面板顶部
|
||||
当前金额为 `12.88`,数量 2 时变为 `32.76`,而不是 `12.88 × 2`。因此闸门一仍是设置数量前的单价
|
||||
预检,闸门二必须读取设置后的**面板总额** `gate2_panel_total_price` 并直接比较最高总价;不得把
|
||||
Gate1 乘数量、不得要求 Gate2 等于 Gate1。2026-08-06 T-106 进一步证明当前版本没有独立确认页:
|
||||
同一面板的红色 `提交订单 ¥32.76` 会直接创建待付款订单并进入付款界面。因此 Gate3 改为在不可逆
|
||||
动作前只读该最终控件金额,并要求它与 Gate2 顶部总额严格相等;这是同一稳定页面两个独立 UI 位置的
|
||||
交叉校验,不是把按钮金额当 Gate2 兜底。
|
||||
|
||||
闸门一与闸门二发生在同一设备会话中,不需要管理员在中间确认。旧截图、缓存值和发布前 dry-run
|
||||
都不能替代这次实时读取。
|
||||
|
||||
### B 路径(V2)
|
||||
|
||||
@@ -156,7 +166,7 @@ T-103 最新真机证据表明:拼多多 `8.17.0`、goods_id `937122477375`、
|
||||
| --- | --- | --- |
|
||||
| 不付款 | 不点击支付、免密支付、先用后付或任何扣款控件 | 真实资金损失 |
|
||||
| 提交四条件 | 授权+围栏、闸门二、闸门三、控件唯一同时成立,只点一次 | 误下单 / 重复下单 |
|
||||
| 确认页零点击 | 除返回和满足四条件后的“提交订单”外不点击任何控件 | 未知副作用 |
|
||||
| 最终面板零点击 | 除安全返回和满足四条件后的最终红色控件外不点击任何控件 | 未知副作用 |
|
||||
| 规格精确匹配 | 维度内等值唯一匹配,防 `红/粉红`、`1/10` 前缀碰撞 | 买错规格 |
|
||||
| 数量读回复核 | 设置后精确读回,不一致即停 | 买错数量 |
|
||||
| 三道价格闸门 | 任一道不可读、有歧义或不通过都停,不用别处数字凑 | 超预算 |
|
||||
@@ -176,9 +186,11 @@ T-103 最新真机证据表明:拼多多 `8.17.0`、goods_id `937122477375`、
|
||||
|
||||
1. **一次性授权有效且服务端提交围栏已建立**:围栏把授权、任务、领取和本次验证摘要原子绑定到
|
||||
唯一 `order_submission`;明确响应包含 `click_permitted=true`。
|
||||
2. **闸门二通过**:目标规格未漂移,第二次规格面板单价等于第一次。
|
||||
3. **闸门三通过**:确认页规格、数量正确,应付总额不超过授权最高总价。
|
||||
4. **提交控件唯一**:文本精确等于“提交订单”,可点击祖先唯一。
|
||||
2. **闸门二通过**:目标规格和数量未漂移,目标数量面板总额唯一可读且不超过授权最高总价。
|
||||
3. **闸门三通过**:同一最终面板的规格、数量正确;唯一结构化文本 `提交订单 ¥{金额}` 中金额严格
|
||||
等于闸门二顶部总额,且不超过授权最高总价。
|
||||
4. **提交控件唯一**:完整结构化文本唯一、启用,且其最近可点击祖先唯一。不得使用包含、前缀、
|
||||
OCR、裸坐标或返回通用可点击对象。
|
||||
|
||||
围栏请求超时、断网、冲突或响应不明时不得点击。围栏建立后:
|
||||
|
||||
@@ -193,8 +205,9 @@ T-103 最新真机证据表明:拼多多 `8.17.0`、goods_id `937122477375`、
|
||||
|
||||
### 发布前 dry-run 与生产围栏的区别
|
||||
|
||||
首次启用某一 App 版本的真实提交能力前,必须用独立真机任务完成只读 dry-run:进入确认页,验证
|
||||
规格、数量、金额与提交控件唯一,然后退出且不点击。它用于证明判据和不可达测试,不是每笔采购的
|
||||
首次启用某一 App 版本的真实提交能力前,必须用独立真机任务完成只读 dry-run:在合并式最终提交
|
||||
面板验证规格、数量、Gate2 顶部总额、Gate3 最终控件金额与最近可点击祖先唯一,然后退出且不点击。
|
||||
它用于证明判据和不可达测试,不是每笔采购的
|
||||
“第一趟”,也不产生可复用页面事实。生产任务仍在同一趟内重新通过三道闸门并申请服务端围栏。
|
||||
|
||||
## 五、数据模型
|
||||
@@ -246,9 +259,9 @@ CREATE TABLE purchase_attempts (
|
||||
claim_generation INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
gate1_unit_price TEXT,
|
||||
gate2_unit_price TEXT,
|
||||
gate2_panel_total_price TEXT,
|
||||
quantity_read INTEGER,
|
||||
confirm_amount TEXT,
|
||||
gate3_submit_amount TEXT,
|
||||
failure_code TEXT,
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT,
|
||||
@@ -296,9 +309,9 @@ CREATE TABLE order_submissions (
|
||||
attempt_id TEXT NOT NULL REFERENCES purchase_attempts(id),
|
||||
status TEXT NOT NULL, -- FENCED | SUBMITTED | RECONCILIATION_REQUIRED | MANUAL_RESOLVED
|
||||
gate1_unit_price TEXT NOT NULL,
|
||||
gate2_unit_price TEXT NOT NULL,
|
||||
gate2_panel_total_price TEXT NOT NULL,
|
||||
quantity_read INTEGER NOT NULL,
|
||||
confirm_amount TEXT NOT NULL,
|
||||
gate3_submit_amount TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
resolved_at TEXT,
|
||||
UNIQUE (authorization_id),
|
||||
@@ -343,6 +356,10 @@ MVP 不再用 `spec_trials` 作为审批记录,也不存在 `authorized_unit_p
|
||||
`purchase_attempts` / `order_submissions` 的执行与审计事实;管理员授权的资金边界始终是
|
||||
`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 位小写十六进制
|
||||
只显示一次;服务端把 token 解码回原始字节后计算 SHA-256,并与按设备 id 查出的 32 字节 BLOB
|
||||
恒定时间比较。未知 id 也执行固定宽度 dummy compare。认证逐请求查库,因此撤销事务提交后才开始的
|
||||
@@ -426,7 +443,7 @@ DRAFT / PENDING / NEEDS_MANUAL ─管理员取消(围栏前)→ CANCELED
|
||||
|
||||
| 数据 | 位置 | 边界 |
|
||||
| --- | --- | --- |
|
||||
| 商品 / 规格 / 确认页原始 screenshot | 采购工具本机 + 采购服务内部证据存储 | 可含页面已显示地址/手机号;设备鉴权上传、管理员登录查看,不遮罩 |
|
||||
| 商品 / 规格 / 最终提交面板原始 screenshot | 采购工具本机 + 采购服务内部证据存储 | 可含页面已显示地址/手机号;设备鉴权上传、管理员登录查看,不遮罩 |
|
||||
| 完整 XML | 仅采购工具本机隔离目录 | 可在内存解析页面判据;不上传、不写日志、Git、Vikunja |
|
||||
| 最小 XML fixture | 采购工具测试 / Git | 只保留判据所需结构,确认无地址、手机号、支付凭据 |
|
||||
| 外部支付页或支付凭据 | 不保存、不上传 | 检测到交接立即停止 |
|
||||
@@ -457,8 +474,8 @@ DRAFT / PENDING / NEEDS_MANUAL ─管理员取消(围栏前)→ CANCELED
|
||||
| --- | --- | --- |
|
||||
| 页面结构随版本变化 | 旧选择器误点新页面 | 每条判据先真机取证,记录 App 版本、截图、XML、goods_id |
|
||||
| 购买语义入口才打开面板 | 能力范围易扩散 | 只批准证据绑定的精确唯一入口;每种文案单独取证 |
|
||||
| 当前价与原价/按钮价混杂 | 读错价格 | 限定已取证结构和语义;价格只在面板/确认页读,歧义即停 |
|
||||
| 单趟页面状态变化 | 数量或促销导致价格变化 | 同一趟两次读规格面板价,再以确认页总额兜底 |
|
||||
| 当前价与原价/按钮价混杂 | 读错价格 | Gate1/Gate2 只读顶部角色;Gate3 只读最终控件结构化金额,角色不互相兜底 |
|
||||
| 单趟页面状态变化 | 数量或促销导致金额非线性变化 | 同一趟读 Gate1 单价与 Gate2 顶部总额,再要求最终控件 Gate3 金额与 Gate2 严格相等 |
|
||||
| WiFi ADB / 双通道 | 断连或操作错设备 | serial 必填;USB/WiFi 同设备或身份不明时 fail closed |
|
||||
| 不可逆动作超时 | 可能已创建订单 | 服务端围栏 + 点击一次 + 只调和,不重试 |
|
||||
| 双端契约漂移 | 静默不兼容 | [api.md](api.md) 唯一权威;契约改动跑完整双端门禁 |
|
||||
@@ -466,14 +483,14 @@ DRAFT / PENDING / NEEDS_MANUAL ─管理员取消(围栏前)→ CANCELED
|
||||
## 七、推荐开发顺序
|
||||
|
||||
1. **Phase 0 地基**:双端骨架、测试命令、初始化脚本和原型。
|
||||
2. **Phase 1 真机取证**:逐段验证商品打开、受控面板入口、精确规格与读价、数量、确认页和提交
|
||||
2. **Phase 1 真机取证**:逐段验证商品打开、受控面板入口、精确规格与读价、数量、最终提交面板和提交
|
||||
控件;真实点击前先完成独立 dry-run。每个 spike 的 capability 只覆盖当期动作。
|
||||
3. **Phase 2 采购服务核心**:DRAFT 建单/列表、批量开始采购与授权、状态/证据/围栏/调和接口。
|
||||
4. **Phase 3 双端打通**:设备身份、原子领取、单趟执行到围栏前、事件与截图。
|
||||
5. **Phase 4 闭环**:经明确真机授权验证一次提交、待付款收口、失败分类、打包。
|
||||
6. **V2**:图搜、Excel、ERP、自动核对、AI、多设备。
|
||||
|
||||
T-103 继续作为“规格选择与读价”的隔离前置,不含数量、确认页或提交。生产单趟并不意味着在一个
|
||||
T-103 继续作为“规格选择与读价”的隔离前置,不含数量、最终提交面板或提交。生产单趟并不意味着在一个
|
||||
任务里跳过逐段取证;它只意味着这些已验证能力集成后,每笔业务任务不再等待中途人工确认。
|
||||
|
||||
## 八、项目结构
|
||||
|
||||
+10
-6
@@ -19,7 +19,7 @@
|
||||
### 1.1 资金与不可逆动作
|
||||
|
||||
- **绝不**编写点击支付、免密支付、先用后付或任何扣款控件的代码。
|
||||
- **绝不**在订单确认页上点击除「提交订单」与返回外的任何控件。
|
||||
- **绝不**在合并式最终提交面板上点击除满足四条件后的最终提交控件与安全返回外的任何控件。
|
||||
- 「提交订单」只在**四个前置条件**同时满足时点击一次:授权未消费且服务端提交围栏已
|
||||
明确建立、闸门二通过、闸门三通过、控件唯一。
|
||||
- 围栏接口超时、冲突、网络失败或响应不明时不得点击;围栏建立后不得释放授权、重新领取
|
||||
@@ -37,16 +37,19 @@
|
||||
|
||||
- 规格按维度**等值**匹配,必须防前缀碰撞(`红`/`粉红`、`1`/`10`)。
|
||||
- 找不到精确值就停止转人工,**绝不选相近项、绝不猜测**。
|
||||
- 「提交订单」控件必须文本精确相等且可点击祖先唯一,否则停止。
|
||||
- 最终控件必须唯一匹配结构化精确文本 `提交订单 ¥{规范十进制金额}`,且最近可点击祖先唯一;完整
|
||||
文本中的金额必须成为 Gate3 并严格等于 Gate2,否则停止。不得用包含、前缀或相似匹配。
|
||||
- 数量设置后必须读回复核精确等于要求值。
|
||||
|
||||
### 1.3 价格读取边界
|
||||
|
||||
- 价格**只在规格面板(闸门一 / 二)和订单确认页(闸门三)读**。
|
||||
- 价格只从已取证合并式最终提交面板的两个独立角色读取:顶部金额用于闸门一 / 二,最终控件结构化
|
||||
精确文本中的金额用于闸门三。
|
||||
- **不从商品详情页正文、搜索结果卡片或任何其他位置读价格。**
|
||||
- 读不到就转人工,**不用别处的数字凑合**。
|
||||
- 同一趟设置并复核数量后,闸门二重读的规格必须不变、单价必须与闸门一完全一致;授权锁定的是
|
||||
最高总价而不是旧观察价。价格变化即停。
|
||||
- 同一趟设置并复核数量后,闸门二重读的规格必须不变,并唯一读取目标数量的规格面板总额,直接
|
||||
验证不超授权最高总价;后续闸门三最终控件金额必须与该面板总额严格相等并再次不超上限。2026-08-06
|
||||
真机已证明多件优惠非线性,不得用闸门一金额乘数量猜 Gate2 总额,也不得要求 Gate2 等于 Gate1。
|
||||
- 图片搜索(V2)的唯一产出是 `goods_id`,同样不读价。
|
||||
|
||||
### 1.4 安全与隐私
|
||||
@@ -63,7 +66,8 @@
|
||||
|
||||
### 1.5 页面判据
|
||||
|
||||
- **不得从前序项目、旧文档或推理直接写页面判据。** 必须有本项目的真机取证。
|
||||
- **不得从前序项目、旧文档或推理直接写页面判据。** 必须有本项目的真机取证。2026-08-06 已确认
|
||||
拼多多 `8.17.0` 不存在独立确认页导航,当前合并式面板的最终红色控件会直接创建待付款订单。
|
||||
- 每条判据必须记录取证时的**拼多多 App 版本**。
|
||||
- 购买语义按钮不能按“作用相同”共享判据。`快要抢光`、`免拼购买`、`单独购买`、`直接拼成` 等
|
||||
每个入口文案都必须分别取证;只允许精确唯一匹配,不允许包含、前缀、相似或坐标兜底。
|
||||
|
||||
+7
-7
@@ -59,10 +59,10 @@ Phase 是里程碑分组,不是整段串行栅栏。推荐波次:
|
||||
| T-101 | 验证 ADB / uiautomator2(USB + WiFi) | T-002 | 两通道截图和 dump;同手机双通道 fail closed;超时可配置 |
|
||||
| T-102 | 验证按链接打开商品详情页 | T-101 | canonical goods 链接进入对应商品;前台包与 goods_id 有证据 |
|
||||
| T-103 | 验证受控规格面板、精确选择与 SKU 单价 | T-102, T-110, T-111 | 精确唯一入口、维度内等值选择/读回、防前缀碰撞、当前价唯一读取;隔离 capability 不含数量/确认/提交 |
|
||||
| T-104 | 验证规格选择能力安全退出 | T-103 | 完成选择/读价/截图后可关闭面板或退出;不进入确认页、不提交 |
|
||||
| T-105 | 验证数量设置、读回与闸门二 | T-104 | 先只读取证并由人确认,再设置数量、精确读回并复核规格/单价;不一致即停 |
|
||||
| T-106 | dry-run:确认页与闸门三取证 | T-105 | 只读采集导航前、确认页、最终控件及一次人工 Back 状态;agent 不执行页面动作 |
|
||||
| T-107 | 固化确认页与提交控件安全判据 | T-106 | 只点已取证导航控件一次;最终控件只读观察;一次 Back;静态不可达提交 |
|
||||
| T-104 | 验证规格选择能力安全退出 | T-103 | 完成选择/读价/截图后可关闭面板或退出;不接触最终提交控件 |
|
||||
| T-105 | 验证数量设置、读回与闸门二 | T-104 | 先只读取证并由人确认,再设置数量、精确读回并复核规格/目标数量面板总额;规格或数量不一致、总额不可读/超上限即停 |
|
||||
| T-106 | dry-run:合并式最终提交面板与闸门三取证 | T-105 | 只读采集 Gate2/Gate3 两个独立金额位置及一次人工 Back 状态;红色最终控件不点击 |
|
||||
| T-107 | 固化最终提交面板与提交控件安全判据 | T-106 | 零页面点击读取 Gate3;结构化最终文本与最近可点击祖先唯一;一次 Back;静态不可达提交 |
|
||||
| T-110 | 批准证据绑定的受控规格入口 | T-103 no-go 决策 | 仅批准拼多多 8.17.0 已取证的精确唯一“快要抢光”,不泛化 |
|
||||
| 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-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-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-206 | 单趟状态视图与安全主动作 | T-205 | PENDING/CLAIMED/ORDERING/NEEDS_MANUAL/WAITING_PAYMENT/RECONCILIATION_REQUIRED 显示和允许动作一致;无中途确认 |
|
||||
| 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-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-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 · 真实提交与收尾
|
||||
|
||||
@@ -126,7 +126,7 @@ T-106 / T-107 是发布前只读验证,不是业务任务的第一趟。首次
|
||||
- **M0**:两端原型确认;单趟主动作、状态、异常和键盘可枚举。(T-005、T-006、T-111)
|
||||
- **M1**:两端骨架、基础模型与验证入口可运行。
|
||||
- **M2**:真机按链接打开商品、精确选择颜色/尺码并读 SKU 单价。(T-103)
|
||||
- **M3**:真机设置数量、闸门二、确认页闸门三和唯一提交控件 dry-run 通过,不点击。(T-107)
|
||||
- **M3**:真机设置数量、闸门二、同一最终面板闸门三和唯一提交控件 dry-run 通过,不点击。(T-107)
|
||||
- **M4**:管理员建单并通过批量开始采购创建一次性授权;设备可安全领取。(T-203、T-302)
|
||||
- **M5**:围栏前 dry-run 跑通,客户端围栏恢复和离线一次性提交安全闭包成立。(T-305、T-308、T-400)
|
||||
- **M6**:新真实单趟只创建可能的待付款订单,围栏/调和/人工收口、Windows 交付和只读验收闭环。(T-401~T-405)
|
||||
|
||||
@@ -52,13 +52,13 @@
|
||||
### US-003 让手机逐条执行已授权任务
|
||||
|
||||
作为采购执行员,我希望配置服务、设备 serial 和轮询间隔后,点击一次开始轮询,让采购工具逐条领取
|
||||
已授权任务并在同一趟中完成选规格、价格校验、数量、确认页和提交围栏。
|
||||
已授权任务并在同一趟中完成选规格、价格校验、数量、合并式最终提交面板和提交围栏。
|
||||
|
||||
验收场景:
|
||||
|
||||
- 启动前同时校验采购服务、ADB 设备和已取证拼多多版本;任一不满足则不启动。
|
||||
- 顶部始终显示会话状态;当前任务、图片、滚动日志和倒序采购记录实时更新。
|
||||
- 精确规格、两次单价、数量读回、确认页总额或提交控件任一不合格时停止该任务。
|
||||
- 精确规格、闸门一单价、数量读回、闸门二顶部总额、闸门三最终控件金额或提交控件任一不合格时停止该任务。
|
||||
- 围栏明确成功后只点一次“提交订单”;系统绝不点击付款。
|
||||
- 连续轮询失败达到阈值自动停止;停止轮询不取消正在执行或已围栏的任务。
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
|
||||
验收场景:
|
||||
|
||||
- 证据按 attempt 和时间排序,区分规格面板、确认页、提交结果。
|
||||
- 证据按 attempt 和时间排序,区分规格面板 Gate1/Gate2、最终提交面板 Gate3;不保存付款页结果图。
|
||||
- 原始截图仅登录管理员可看,响应不缓存;完整 XML 不出现在页面或接口中。
|
||||
- 地址/手机号不提取为字段或日志,外部支付页和支付凭据不存储。
|
||||
- 执行中没有“确认机器选对了吗”或“确认后继续第二趟”按钮。
|
||||
@@ -89,7 +89,7 @@
|
||||
|
||||
### US-006 付款前核对订单
|
||||
|
||||
作为采购管理员,我希望任务转为待付款后看到商品、规格、数量、授权上限、确认页金额与订单截图,
|
||||
作为采购管理员,我希望任务转为待付款后看到商品、规格、数量、授权上限、Gate3 最终控件金额与订单截图,
|
||||
在拼多多核对后由人决定是否付款。
|
||||
|
||||
验收场景:
|
||||
@@ -114,7 +114,7 @@
|
||||
|
||||
验收场景:
|
||||
|
||||
- 至少区分设备、App 版本、商品、面板、规格、价格、数量、确认页、围栏、安全校验和网络错误。
|
||||
- 至少区分设备、App 版本、商品、面板、规格、价格、数量、Gate3、围栏、安全校验和网络错误。
|
||||
- 围栏前错误提示停止、留证并等待人工处理;不会自动重新开始。
|
||||
- 围栏后错误明确写“可能已创建订单,只能调和,禁止再次点击”。
|
||||
- 桌面端详情切换不改变服务端状态;Esc 只回到当前任务视图,不取消业务流程。
|
||||
|
||||
@@ -57,8 +57,9 @@
|
||||
|
||||
### IX-006 待付款核对与标记完成
|
||||
|
||||
- 页面显示商品、目标与实际规格、数量、授权上限、两次单价、确认页总额和三闸门内部证据;MVP
|
||||
没有提交后结果截图类型,不把确认页图片冒充订单结果图。
|
||||
- 页面显示商品、目标与实际规格、数量、授权上限、闸门一单价、闸门二顶部总额、闸门三最终控件同额校验和
|
||||
三闸门内部证据;MVP
|
||||
没有提交后结果截图类型,不把 Gate3 最终面板图片冒充订单结果图。
|
||||
- 首屏警示:“订单已创建,系统尚未付款。请在拼多多人工核对并付款。”
|
||||
- “标记为已付款并完成”只写系统状态,不打开支付页面、不触发手机操作。
|
||||
- 提交前用简短确认对话框说明这是人工事实记录;成功转 `SUCCEEDED`,重复提交幂等。
|
||||
@@ -81,7 +82,7 @@
|
||||
|
||||
```text
|
||||
待领取 → 已领取 → 打开商品 → 打开规格面板 → 选择规格
|
||||
→ 闸门一 → 数量复核 → 闸门二 → 确认页 / 闸门三
|
||||
→ 闸门一 → 数量复核 → 闸门二 → 同一最终面板 / 闸门三
|
||||
→ 申请围栏 → 已发出唯一提交 → 待付款 / 待调和
|
||||
```
|
||||
|
||||
@@ -89,7 +90,7 @@
|
||||
| --- | --- |
|
||||
| 服务/设备/App 未就绪 | 开始轮询禁用;状态区给出具体修复入口 |
|
||||
| 正在领取或执行 | 显示阶段与任务;停止轮询只阻止下一次领取,不中断当前安全步骤 |
|
||||
| 规格、价格、数量或确认页失败 | 记录固定 reason code,停止点击并进入待人工 |
|
||||
| 规格、价格、数量或闸门三失败 | 记录固定 reason code,停止点击并进入待人工 |
|
||||
| 正在申请围栏 | 禁用停止当前任务、退出和任何提交动作;界面只显示进度 |
|
||||
| 围栏响应不明 | 不点击,进入待调和 |
|
||||
| 已围栏 / 点击结果不明 | 红色安全横幅“可能已创建订单,禁止再次提交”;只能调和 |
|
||||
@@ -103,7 +104,7 @@
|
||||
|
||||
围栏由后台流程自动申请,不向执行员提供“提交订单”按钮。
|
||||
|
||||
1. 当前任务在同一趟内完成闸门二和闸门三,并确认提交控件精确唯一。
|
||||
1. 当前任务在同一合并式最终提交面板完成闸门二和闸门三,并确认结构化文本及最近可点击祖先唯一。
|
||||
2. 客户端显示“正在申请提交围栏”,提交当前验证摘要。
|
||||
3. 只有服务端首次明确返回 `click_permitted=true`,客户端才先持久化唯一点击意图,再精确点击一次。
|
||||
4. MVP 首次真实点击后因没有已取证的提交后页面判据,无条件上报 `UNKNOWN` 并转待调和;只有管理员
|
||||
|
||||
+2
-2
@@ -46,12 +46,12 @@ cmbuyer 是一个自动化采购系统:**采购服务**(网页端,`admin/`
|
||||
新接手时如果只看四个地方,看这四个:
|
||||
|
||||
1. [`04-architecture.md`](04-architecture.md) **第三节单趟执行与三道价格闸门**——开始采购
|
||||
如何授权、两次面板价与确认页金额如何实时保护上限。
|
||||
如何授权、Gate1/Gate2 顶部金额与 Gate3 最终控件金额如何实时保护上限。
|
||||
2. [`04-architecture.md`](04-architecture.md) **第四节安全边界与提交订单四条件**——每条
|
||||
都要有测试。
|
||||
3. [`05-coding-rules.md`](05-coding-rules.md) **第 1 节红线**——违反即拒绝。
|
||||
4. [`00-ai-start-here.md`](00-ai-start-here.md) **四条特有纪律**——先取证、只收紧、
|
||||
只在两处读价、隔离验证能力不越界。
|
||||
只在已取证价格角色读价、隔离验证能力不越界。
|
||||
|
||||
## 维护原则
|
||||
|
||||
|
||||
+31
-10
@@ -294,7 +294,7 @@ claim/renew 的格式错误固定为 `400 {"error":"invalid_request"}`,超限
|
||||
}
|
||||
```
|
||||
|
||||
- 允许规格面板和确认页截图保留页面已显示的地址/手机号;不要求遮罩或裁剪。
|
||||
- 允许规格面板和合并式最终提交面板截图保留页面已显示的地址/手机号;不要求遮罩或裁剪。
|
||||
- 不接受 XML、目录、manifest、本机绝对路径、外部支付页截图或支付凭据。
|
||||
- T-204 只开放 `kind=SKU_PANEL_GATE_1`;后续 kind 必须由对应真机证据任务收紧扩展。
|
||||
- `privacy_tier` 只能是 `INTERNAL_RAW`;时间必须是以 `Z` 结尾的 UTC RFC 3339。
|
||||
@@ -326,17 +326,28 @@ claim/renew 的格式错误固定为 `400 {"error":"invalid_request"}`,超限
|
||||
"selected_color": "黑色CHA(纯棉)",
|
||||
"selected_size": "M(建议100-115)",
|
||||
"gate1_unit_price": "12.88",
|
||||
"gate2_unit_price": "12.88",
|
||||
"gate2_panel_total_price": "32.76",
|
||||
"quantity_read": 2,
|
||||
"confirm_page_amount": "25.76",
|
||||
"submit_control_match_count": 1
|
||||
"gate3_submit_amount": "32.76",
|
||||
"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 一致;授权有效未消费且字段等于任务快照;
|
||||
规格与授权相等;数量相等;两个单价相等;计算金额及确认页金额均不超过 `total_price_cap`;提交控件
|
||||
计数为一;此前不存在该授权或 attempt 的 submission。随后创建唯一 `order_submission`,授权转
|
||||
`FENCED`,任务保持不可重领。
|
||||
规格与授权相等;数量相等;闸门一单价预检通过;闸门二目标数量面板总额不超过 `total_price_cap`;
|
||||
Gate3 最终控件金额严格等于闸门二面板总额且再次不超过上限;完整结构化文本必须唯一、启用且最近
|
||||
可点击祖先唯一;此前不存在该授权或 attempt
|
||||
的 submission。随后创建唯一 `order_submission`,授权转 `FENCED`,任务保持不可重领。
|
||||
|
||||
`gate2_panel_total_price` 是目标数量下规格面板顶部证据角色的总额,不是单价。拼多多多件优惠可能
|
||||
非线性,禁止用 `gate1_unit_price × quantity_read` 推导它,也禁止继续用旧字段名
|
||||
`gate2_unit_price` 承载总额。最终红色控件金额只能作为 Gate3,绝不能用于 Gate1/Gate2 或为顶部金额兜底。
|
||||
|
||||
首次明确成功响应:
|
||||
|
||||
@@ -345,7 +356,7 @@ claim/renew 的格式错误固定为 `400 {"error":"invalid_request"}`,超限
|
||||
"submission_id": "f3c9f507-7473-4fa6-8d71-8786c34c6301",
|
||||
"status": "FENCED",
|
||||
"click_permitted": true,
|
||||
"submit_text": "提交订单"
|
||||
"submit_control_text": "提交订单 ¥32.76"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -443,12 +454,22 @@ events/fail/fence/result 或完整 `ResultSink`。T-304/T-306 必须通过 `Dura
|
||||
| `select_sku_options()` | 维度 → 精确值 | 选中态摘要 | 维度内唯一匹配并读回 |
|
||||
| `read_sku_unit_price()` | 已确认规格面板 | 十进制单价 | 排除原价、按钮价和歧义候选 |
|
||||
| `set_quantity_and_readback()` | 授权数量 | 实际数量 | 精确读回,否则停 |
|
||||
| `go_to_order_confirm()` | 已通过闸门二 | 确认页摘要 | 后续真机任务取证后才实现 |
|
||||
| `read_sku_panel_total_price()` | 已读回目标数量 | 十进制面板总额 | 只读证据绑定的顶部当前金额;排除底部提交按钮金额和歧义候选 |
|
||||
| `observe_final_submit_panel()` | 已通过闸门二且仍在同一面板 | Gate3 摘要 | 复核规格/数量;只读最终控件结构化金额;零页面点击 |
|
||||
| `submit_order_once()` | 不可伪造的首次 `SubmissionPermit` | 观察结果 | 许可、闸门、唯一控件全校验;点前持久化;绝不重试 |
|
||||
|
||||
T-103 只实现隔离的 `SkuSelectionFlow`:前四项加安全退出。它的模块和静态依赖不得引用数量、确认页、
|
||||
T-103 只实现隔离的 `SkuSelectionFlow`:前四项加安全退出。它的模块和静态依赖不得引用数量、最终提交面板、
|
||||
围栏、提交或支付能力。后续任务按取证顺序组合成生产 `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 同商品正判据时不重试。
|
||||
|
||||
## 四、实现前仍需定值
|
||||
|
||||
- 授权有效期、领取租约时长、心跳/轮询间隔和连续失败停止阈值;
|
||||
|
||||
+107
-24
@@ -11,8 +11,8 @@
|
||||
|
||||
## 当前快照
|
||||
|
||||
- 日期:2026-08-05
|
||||
- 阶段:**Phase 1 · T-103 规格选择/读价与 Phase 2 安全服务端任务并行**
|
||||
- 日期:2026-08-06
|
||||
- 阶段:**Phase 1 · T-107 已完成;Phase 2 安全服务端任务继续并行**
|
||||
- MVP 形态:手工填链接建单 → 批量“开始采购”并签发一次性授权 → 定时领取 → 同一趟精确
|
||||
选规格 / 三道价格闸门 / 提交围栏 / 点击一次 → 待付款。管理员点击“开始采购”即授权桌面端
|
||||
创建待付款订单;不再有试选后确认,系统绝不自动付款。
|
||||
@@ -22,18 +22,21 @@
|
||||
- 生产代码:`admin/` 已有登录/会话、手工 DRAFT 建单、批量开始采购与一次性授权、任务详情、内部
|
||||
原始截图、设备身份,以及已授权任务的原子领取/幂等重放/租约续期;
|
||||
`client/` 已有 Python 包、PySide6 最小入口、运行目录与日志脱敏策略,以及显式 serial 的 ADB
|
||||
连接边界、本地基线取证 CLI、受限商品链接打开取证 CLI、人工声明规格面板状态的只读取证 CLI,
|
||||
连接边界、本地基线取证 CLI、受限商品链接打开取证 CLI、人工声明规格面板与数量两态的只读取证 CLI,
|
||||
绑定 PKG110 / Android 16 / 拼多多 8.17.0 的证据工具,以及受控规格入口、精确规格选择、SKU 当前价
|
||||
读取、原始截图和一次安全退出能力;并已具备严格 localhost claim/renew/evidence HTTP、Windows DPAPI
|
||||
凭据保护、SQLite append-only 恢复状态图、Global named mutex 与 durable gateway。原生桌面主界面、
|
||||
显式本地配置、停止/重启会话边界和安全轮询协调器已实现;未注入单趟执行 consumer 时保持零领取。
|
||||
独立的一次性尺码 reveal 取证脚本已就绪,但尚未成为生产 Flow;真机能力与桌面界面仍待最终人工
|
||||
验收,数量/确认页/提交仍未开放。
|
||||
已取证的固定尺码 reveal、精确 M 选择、规格面板单价 `12.88` 与一次 Back 已进入生产 Flow 并完成
|
||||
真机人工验收;T-104 已把同商品详情页安全退出收紧为版本绑定、连续两帧稳定判据,数量/Gate2 已开放,
|
||||
合并式最终提交面板 Gate3 与最终提交仍未开放。
|
||||
- 测试:采购服务已覆盖登录、建单、授权、详情/证据、设备身份、迁移、原子 claim/renew 与竞态;
|
||||
采购工具 255 项离线单元测试(全部 mock,不连接真机)。
|
||||
采购工具 346 项离线单元测试(全部 mock,不连接真机)。
|
||||
- 数据:受保护迁移已落到 `00005_task_claims.sql`。旧两趟模型已移除;开始采购会锁定任务快照并签发
|
||||
一次性授权,领取会创建唯一 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 入口
|
||||
优先使用合规的既有 venv;仅在其缺失时才从 Python Launcher 已安装版本中选择最高的 Python 3.11+,
|
||||
并且不覆盖低版本环境;成功后打印真实启动命令。
|
||||
@@ -41,19 +44,20 @@
|
||||
editable install / 包导入 / unittest / compileall,以及仓库上下文校验。可单独运行两端命令诊断。
|
||||
- 当前设备门禁:人工已确认拼多多 8.17.0、goods_id `937122477375` 的衣服商品只能通过“快要抢光”
|
||||
打开规格面板;T-110 已获项目所有者批准,只把该证据/版本绑定的精确唯一入口作为受控导航。
|
||||
T-103 的隔离 capability 仍不得包含数量、确认页、提交订单、付款或通用点击;这些动作由后续任务
|
||||
T-103 的隔离 capability 不包含数量、最终提交面板、提交订单、付款或通用点击;这些动作由后续任务
|
||||
分别取证后才接入生产单趟流程。T-103 旧 v5 派生 XML 中的“打开即目标预选”已被 2026-08-05
|
||||
五组最新 live 证据纠正:真实初态为颜色与尺码均未选,目标颜色选中后尺码仍未选且不在视口;
|
||||
S 非目标值与恢复目标 M 的前后态已由人确认。底部“提交订单 ¥12.88”继续属于硬拒绝区。项目已停止
|
||||
S 非目标值与恢复目标 M 的前后态已由人确认。底部“提交订单 ¥12.88”在 T-103/Gate1/Gate2
|
||||
继续属于硬拒绝区;只有 T-106/T-107 最终面板 Gate3 可只读其结构化金额。项目已停止
|
||||
遮罩器开发,T-204 已允许认证设备上传内部原始截图供管理员查看。入口父链证据已由人确认并
|
||||
最新失败证据已纠正入口绑定:此前实现误点商品内容区同名促销小字;真实入口是底部购买区第一行
|
||||
`快要抢光 + 金额` 的精确文本叶节点中心。第二行“免拼购买”只能作为不可点击的证据绑定兄弟节点
|
||||
校验结构,绝不作为选择器或目标;按钮金额不作为价格来源。提交 `2f1380d` 已把入口改为只点击
|
||||
第一行中心 `(865,2218)`,旧内容区小字为零点击反例并保留一次点击无重试。T-103 还缺一组窄动作
|
||||
证据:颜色已选且尺码未选/隐藏时执行一次受控 reveal,动作后 M 仍未选但已可见。提交 `e8ca738`
|
||||
已增加与生产 Flow 隔离的固定一次 reveal 证据脚本:只在完整前置与安全通道两次重证后发送一次,
|
||||
RPC 结果不明只读调和且不重试,成功不 Back 并等待人工核对;在该证据获人确认前仍不把 reveal
|
||||
接入生产 Flow。之后才进行入口、颜色、reveal、目标 M、读取 `12.88` 和一次 Back 的最终人工真机验收。
|
||||
第一行中心 `(865,2218)`,旧内容区小字为零点击反例并保留一次点击无重试。T-103 已完成固定一次
|
||||
reveal 的原子证据、人审和生产化:同一 Flow 只允许 current-only 初态依次执行目标颜色、reveal、
|
||||
exact M,随后复用既有 dual 目标态读取 `12.88`;入口、颜色、reveal、M、Back 各一次的生产真机
|
||||
manifest 与 Back 前后截图已由项目所有者完成三项人工确认。T-104 又用独立 post-exit 真机证据,
|
||||
把“Back 后仍为同一商品”收紧为完整标题正锚、底部入口、连续两帧与版本绑定的自动判据。
|
||||
完整事件链必须等待 T-107 与 T-210:先让 Gate2/Gate3 证据进入真实 schema,再实现 T-205 事件,
|
||||
不用 mock 或无证据成功事件抢跑。
|
||||
|
||||
@@ -62,11 +66,11 @@
|
||||
| 路径 | 状态 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `docs/` | 已有 | 项目规范化文档,本次已完整生成 |
|
||||
| `docs/tasks/` | 已有(含 T-001~T-111、T-201~T-211、T-301~T-308、T-400~T-405) | T-303、T-211 已完成;T-304 代码完成后保持 DOING 等人工 UI 复核;T-103 等最终真机验收;双端围栏、真实提交、调和、打包与只读验收均已落成任务 |
|
||||
| `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 采购执行 / 配置;均已人工确认 |
|
||||
| `scripts/` | 已有 | 上下文门禁、Vikunja 单向导出与 MCP 启动包装 |
|
||||
| `admin/` | 已初始化 | Go 1.23+ / gin / SQLite,含建单/授权/详情/证据/设备身份与原子 claim/renew;不执行真机动作 |
|
||||
| `client/` | 已初始化 | Python 3.11+、PySide6/uiautomator2、固定双 Tab 主界面、安全轮询、严格 HTTP/DPAPI/SQLite 恢复底座及受控规格选择/读价/安全退出;数量、确认页和提交未开放 |
|
||||
| `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 缺工具链明确失败 |
|
||||
|
||||
## 任务状态
|
||||
@@ -84,12 +88,19 @@
|
||||
`DRAFT`,未实现授权、设备领取或采购执行。
|
||||
- 已完成 T-110、T-111、T-203、T-204、T-301 与 T-302。服务端已能授权、认证设备、原子领取、
|
||||
稳定重放和续租;claim token 不是提交许可,尚无事件、提交围栏或结果 API。
|
||||
- T-103 四态 classifier、精确颜色/S→M 选择、滚动态读价与独立一次性 reveal 取证脚本已实现并通过
|
||||
独立审计,仍为 `DOING` 等人工真机验收;T-104~T-107 按数量/确认页风险逐段开放。真机运行要求
|
||||
- T-103 已完成受控入口、精确颜色、固定 reveal、exact M、规格面板 `12.88` 读价、一次 Back 和最终
|
||||
人工真机验收;T-104 已完成同商品安全退出,T-105 已完成数量/Gate2,T-106 已完成合并式最终面板
|
||||
与一次 Back 返回两态只读取证,T-107 已据此固化并真机验收 Gate3 判据。真机运行要求
|
||||
同一物理手机只保留一个 ADB 通道;CLI 已增加不回显页面
|
||||
正文的固定白名单失败阶段码;`sku_entry` 动态页面误判已按最新真机证据修复,覆盖点击中心的浮窗
|
||||
会保持零点击,入口失败也已细分为前置、发现、点击和面板后置验证四个固定子阶段。服务端后续顺序已收紧为
|
||||
T-210 → T-205 → T-206 → T-207 → T-208。
|
||||
- T-105 已完成。阶段一数量 1/2 两态 screenshot/XML/App/设备摘要已完成四项人工确认。真机证明同一规格
|
||||
数量 1 面板金额为 `12.88`、数量 2 为 `32.76`,多件优惠非线性;Gate2 已改为唯一读取目标数量
|
||||
面板总额并直接比较最高总价,后续 Gate3 必须与其严格相等。阶段二的一次加号、精确读回、Gate2
|
||||
原始截图和 T-104 一次安全退出已实现并通过 330 项离线测试及完整 `init.ps1`。最终生产运行精确
|
||||
完成一次加号与一次 Back,项目所有者确认数量 2、目标规格、`12.88/32.76`、同商品安全退出,且未进入
|
||||
提交/付款页、未创建订单。最终控件金额仍禁止用于 Gate1/Gate2;T-106 依赖已解除。
|
||||
- T-303 已完成并通过三重审查;T-304 的轮询会话、配置页和固定双 Tab 主界面已通过两轮独立代码审计,
|
||||
当前 255 项客户端测试与根门禁通过,继续保持 `DOING` 等原生 Windows DPI、主题、高对比度、
|
||||
Narrator 和纯键盘人工验收。客户端后续按依赖推进 T-306/T-307/T-308,再与 T-305 汇合到
|
||||
@@ -187,13 +198,85 @@ T-103 已确认当前衣服商品只能从精确文案“快要抢光”进入
|
||||
classifier、颜色与 S→M 精确选择、滚动态价格判据和独立一次性 reveal 证据脚本已经落地;生产 Flow
|
||||
中的受控 reveal 仍必须等待动作前后 M 均未选的真机证据和人工确认,最终完整链路也必须由人验收。
|
||||
|
||||
T-105 阶段一只读取证命令如下。脚本不打开页面,也不点击数量、返回、确认、提交或付款控件;必须由人
|
||||
先把目标商品规格面板准备为“黑色CHA(纯棉) + M(建议100-115) + 数量 1”,完成初态取证后再由人只点
|
||||
一次数量 `+` 准备数量 2,随后运行目标态取证:
|
||||
|
||||
```powershell
|
||||
# 仓库根目录;当前只保留已人工确认的 WiFi ADB 通道
|
||||
.\client\.venv\Scripts\python.exe client\scripts\capture_quantity_gate2_spike.py --serial 192.168.0.173:5555 --goods-id 937122477375 --state initial --declared-quantity 1 --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-105\quantity-initial-937122477375" --timeout 10 --adb D:\Portable\adb\adb.exe
|
||||
.\client\.venv\Scripts\python.exe client\scripts\capture_quantity_gate2_spike.py --serial 192.168.0.173:5555 --goods-id 937122477375 --state target --declared-quantity 2 --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-105\quantity-target-937122477375" --timeout 10 --adb D:\Portable\adb\adb.exe
|
||||
```
|
||||
|
||||
两次成功目录只保存在本机。人工需核对截图/XML 确实对应数量 1/2、目标颜色和尺码、规格面板顶部金额,
|
||||
并确认过程中未进入确认/提交/支付页且未创建订单;确认前 T-105 保持 `DOING`。
|
||||
|
||||
2026-08-06 两态已经完成上述人工确认。初态目录为
|
||||
`%LOCALAPPDATA%\cmbuyer\artifacts\T-105\quantity-initial-937122477375-20260806-v3`,目标态目录为
|
||||
`%LOCALAPPDATA%\cmbuyer\artifacts\T-105\quantity-target-937122477375-20260806`。数量节点精确读回
|
||||
`1 → 2`,唯一加号的 `content-desc` 为“增加数量”,目标颜色和 M 均未漂移;顶部当前金额
|
||||
`12.88 → 32.76`。旧 `gate2_unit_price` 语义已废止,T-210/T-205 消费前必须把 API/存储迁移为
|
||||
`gate2_panel_total_price`,迁移未完成不得建立提交围栏。
|
||||
|
||||
T-105 阶段二生产验收已在人工确认的同一手机、拼多多 `8.17.0`、目标颜色/尺码和数量 1 面板完成。
|
||||
脚本自己只点一次证据绑定的数量加号到 2,读回 `32.76`、保存 Gate2 原图并执行一次安全 Back;不打开
|
||||
确认页,不点击底部“提交订单”,不创建订单。输出目录必须不存在:
|
||||
|
||||
```powershell
|
||||
.\client\.venv\Scripts\python.exe client\scripts\run_t105_quantity_gate2.py --serial 192.168.0.173:5555 --goods-id 937122477375 --color "黑色CHA(纯棉)" --size "M(建议100-115)" --target-quantity 2 --gate1-unit-price 12.88 --gate1-screenshot "$env:LOCALAPPDATA\cmbuyer\artifacts\T-105\quantity-initial-937122477375-20260806-v3\screenshot.png" --gate1-captured-at "2026-08-06T00:53:00.023935+00:00" --max-total-price 40.00 --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-105\quantity-gate2-live-937122477375-20260806" --timeout 10 --adb D:\Portable\adb\adb.exe
|
||||
```
|
||||
|
||||
成功证据目录为 `%LOCALAPPDATA%\cmbuyer\artifacts\T-105\quantity-gate2-live-937122477375-20260806`;
|
||||
项目所有者已确认数量精确为 2、颜色尺码未变、Gate1/Gate2 分别为 `12.88/32.76`、Gate2 截图正确、
|
||||
一次 Back 回到同一商品详情,且未进入确认/提交/支付页、未创建订单。该命令只作已验运行记录,不再重跑。
|
||||
|
||||
T-106 第一态只读取证已于 2026-08-06 完成。脚本重新核对设备、拼多多 `8.17.0`、前台包和
|
||||
1080×2376 屏幕,只调用截图与 `compressed=false` XML;不会打开页面、点击、返回、滑动或输入:
|
||||
|
||||
```powershell
|
||||
.\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、顶部 `32.76`,以及唯一
|
||||
“提交订单 ¥32.76”;点击该红色控件会立刻创建待付款订单并进入付款界面,并不存在原计划中可安全
|
||||
先进入的独立确认页。项目所有者确认本次未点击、未创建订单、未进入付款,并批准“同一最终面板、
|
||||
两个独立金额位置”方案:顶部金额继续承担 Gate2,最终控件结构化金额承担 Gate3,二者必须分别唯一、
|
||||
严格相等且不超上限;Gate3 不得回流 Gate1/Gate2。一次人工 Back 后的返回态只读命令也已完成:
|
||||
|
||||
```powershell
|
||||
.\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
|
||||
```
|
||||
|
||||
返回态 screenshot/XML/App/manifest 的 SHA-256 均与 manifest 一致;截图与 XML 显示同一商品详情
|
||||
安全页,恢复“快要抢光 ¥12.88 / 免拼购买”,不再出现“提交订单”。项目所有者确认只按一次系统
|
||||
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 已完成。
|
||||
|
||||
## 关键背景
|
||||
|
||||
本项目是 `cmroubao`(Go 后端 + Android AccessibilityService)与 `cmpdd`
|
||||
(Python + uiautomator2)两个前序项目的合并重启。
|
||||
|
||||
- 取 `cmroubao` 的后端任务生命周期、设备侧 API 形状、下单授权状态机、ERP 对接、管理 Web。
|
||||
- 取 `cmpdd` 的 uiautomator2 真机自动化、按维度精确选规格、订单确认页读取、付款闸门。
|
||||
- 取 `cmpdd` 的 uiautomator2 真机自动化与按维度精确选规格思路;最终面板与付款闸门在本项目重新取证。
|
||||
- 丢弃自研 Android APK 与 AccessibilityService 感知层。
|
||||
|
||||
**前序项目是设计依据,不是事实来源。** 其中的结论(尤其是拼多多页面判据)必须在本项目
|
||||
@@ -201,14 +284,14 @@ classifier、颜色与 S→M 精确选择、滚动态价格判据和独立一次
|
||||
|
||||
## 已知风险(开工前须知)
|
||||
|
||||
1. **M2 是生死线**:真机能按链接打开商品、精确勾选颜色分类和尺码、**读到该 SKU 单价**
|
||||
(T-103)。M2 前可以写不依赖页面字段的管理员会话、DRAFT 建单和服务端授权事务;不能写
|
||||
依赖未取证选择器的生产执行代码。真机结论改变字段时先回修契约与原型。
|
||||
1. **M2 已通过**:T-103 已在真机按链接打开商品、精确勾选颜色分类和尺码并读到该 SKU 单价;
|
||||
后续页面能力仍必须逐任务先取证。真机结论改变字段时先回修契约与原型。
|
||||
2. **拼多多页面结构随版本变化**,已观察到详情页无独立规格入口、价格节点被拆分等情况。
|
||||
3. **授权卡死**:前序项目出现过 `EXECUTING` 授权永不推进导致任务锁死。本项目在 T-207
|
||||
实现围栏前超时 / 放弃,在 T-208 实现围栏后调和;围栏后不得释放或重试。
|
||||
4. **规格面板读价尚未形成生产判据**:已人工观察当前价与原价,但 T-103 仍需把证据固化为
|
||||
唯一、版本绑定且排除按钮价的读取规则;失败就转人工,不删价格闸门。
|
||||
4. **独立确认页前提已被推翻并重定值**:T-106 第一态证明“提交订单 ¥32.76”会直接创建待付款订单。
|
||||
已批准 Gate2 顶部总额与 Gate3 最终控件金额的同面板独立交叉校验;一次人工 Back 返回证据已完成,
|
||||
控件仍禁止点击。历史 `confirm_amount/confirm_page_amount` 必须在 T-210/T-208 前迁移。
|
||||
5. **MVP 已收窄**:只做手工填链接、批量开始采购和单趟创建待付款订单。Excel、ERP、图搜、
|
||||
批量顺序编排 / 暂停接管、订单自动核对、AI 辅助全部推到 V2(见 `06-tasks.md` 的
|
||||
T-501~T-508)。
|
||||
|
||||
@@ -470,6 +470,7 @@
|
||||
<div><dt>颜色分类</dt><dd id="color">—</dd></div>
|
||||
<div><dt>尺码 / 数量</dt><dd id="size-qty">—</dd></div>
|
||||
<div><dt>闸门一单价</dt><dd id="gate1-price">—</dd></div>
|
||||
<div><dt>闸门二面板总额</dt><dd id="gate2-total">—</dd></div>
|
||||
<div><dt>金额上限</dt><dd id="price-cap">—</dd></div>
|
||||
<div class="span-2"><dt>提交围栏</dt><dd id="submission">未建立</dd></div>
|
||||
</dl>
|
||||
@@ -583,7 +584,7 @@
|
||||
const task = {
|
||||
id: 'CB-20260803-0042',
|
||||
product: '纯棉圆领短袖 T 恤(原型假数据)',
|
||||
color: '白色', sizeQty: 'XL / 2 件', gate1Price: 'CNY 32.50', cap: 'CNY 80.00'
|
||||
color: '白色', sizeQty: 'XL / 2 件', gate1Price: 'CNY 32.50', gate2Total: 'CNY 65.00', cap: 'CNY 80.00'
|
||||
};
|
||||
const records = [
|
||||
{
|
||||
@@ -604,15 +605,15 @@
|
||||
id:'REC-DEMO-0038', taskId:'CB-20260803-0038', title:'标签打印纸补货(原型假数据)', collectedAt:'2026-08-03T10:06:42+08:00',
|
||||
status:'WAITING_PAYMENT', spec:'白色 | 50×30 | 1 件', cap:'CNY 50.00', submission:'SUB-DEMO-0038 · SUBMITTED',
|
||||
note:'已明确创建待付款订单,自动化已停止,等待人核对后自行付款。', evidence:['订单确认页内部原始截图(内联假证据)'],
|
||||
timeline:['10:04:22 同一趟精确选择规格','10:05:10 两次价格与数量复核通过','10:05:36 获得唯一围栏许可','10:06:42 明确创建待付款订单'],
|
||||
timeline:['10:04:22 同一趟精确选择规格','10:05:10 Gate1 单价、Gate2 面板总额与 Gate3 确认金额通过','10:05:36 获得唯一围栏许可','10:06:42 明确创建待付款订单'],
|
||||
result:'已创建待付款订单:白色 | 50×30 | CNY 46.00 | 1 件', resultNote:'系统只创建待付款订单,付款始终由人完成。'
|
||||
},
|
||||
{
|
||||
id:'REC-DEMO-0032', taskId:'CB-20260803-0032', title:'仓库打包胶带(原型假数据)', collectedAt:'2026-08-03T09:18:05+08:00',
|
||||
status:'NEEDS_MANUAL', spec:'透明 | 45mm | 4 件', cap:'CNY 55.00', submission:'未建立',
|
||||
note:'设置数量后规格面板价格与闸门一不一致,已在围栏前转人工。', evidence:[],
|
||||
timeline:['09:16:40 打开商品','09:17:22 匹配透明 / 45mm','09:18:05 价格闸门二不通过,停止自动流程'],
|
||||
result:'未采购:闸门一 CNY 12.00 | 闸门二 CNY 13.50', resultNote:'没有建立围栏、创建订单或付款。'
|
||||
note:'目标数量的规格面板总额超过授权上限,已在围栏前转人工。', evidence:[],
|
||||
timeline:['09:16:40 打开商品','09:17:22 匹配透明 / 45mm','09:18:05 闸门二面板总额超限,停止自动流程'],
|
||||
result:'未采购:闸门一单价 CNY 12.00 | 闸门二面板总额 CNY 58.00 > 上限 CNY 55.00', resultNote:'没有建立围栏、创建订单或付款。'
|
||||
}
|
||||
].sort((a, b) => b.collectedAt.localeCompare(a.collectedAt));
|
||||
const recordStatus = {
|
||||
@@ -627,7 +628,7 @@
|
||||
let detailRequestId = 0;
|
||||
const baseSteps = {
|
||||
idle: [],
|
||||
single: ['核对一次性授权并打开商品', '精确选择颜色分类与尺码', '闸门一:读取 SKU 单价并校验上限', '设置数量、读回并执行闸门二', '确认页执行闸门三与控件唯一校验', '申请服务端提交围栏', '首次许可后持久化并内部点击一次', '调和同一提交结果']
|
||||
single: ['核对一次性授权并打开商品', '精确选择颜色分类与尺码', '闸门一:读取 SKU 单价并校验上限', '设置数量、读回并读取闸门二面板总额', '确认页执行闸门三同额与控件唯一校验', '申请服务端提交围栏', '首次许可后持久化并内部点击一次', '调和同一提交结果']
|
||||
};
|
||||
const scenarios = {
|
||||
not_ready: { tone:'critical', title:'web 服务不可达', text:'未开始轮询。检查本地网络和服务地址;已完成的本地证据会保留待补传。', web:'bad', device:'ok', version:'ok', leg:'未就绪', legTone:'critical', action:'开始轮询', enabled:false, reason:'web 服务未就绪,不能领取任务。', session:'未启动', step:'连接检查', kind:'idle', active:-1, countdown:'—', fail:'1 / 3' },
|
||||
@@ -637,8 +638,8 @@
|
||||
claimed: { tone:'info', title:'已领取管理员授权的采购任务', text:'任务字段与最高总价已锁定;当前只准备打开目标商品,尚未建立提交围栏。', web:'ok', device:'ok', version:'ok', leg:'单趟 · 已领取', legTone:'info', action:'真机步骤执行中', enabled:false, reason:'当前任务已开始,停止轮询只应阻止下一次领取,不能硬取消本任务。', session:'运行中', step:'核对授权与商品', kind:'single', active:0, countdown:'暂停', fail:'0 / 3', lease:'04:48' },
|
||||
selecting: { tone:'info', title:'正在精确选择颜色与尺码', text:'只在已取证维度容器内精确唯一匹配并读回;缺失、重复或版本变化立即停止。', web:'ok', device:'ok', version:'ok', leg:'单趟 · 选择规格', legTone:'info', action:'真机步骤执行中', enabled:false, reason:'当前受控步骤不能被硬取消。', session:'运行中', step:'精确选择规格', kind:'single', active:1, countdown:'暂停', fail:'0 / 3', lease:'04:18' },
|
||||
gate1: { tone:'info', title:'闸门一:读取 SKU 单价', text:'当前规格单价必须唯一可读,单价乘数量不得超过管理员授权的最高总价。', web:'ok', device:'ok', version:'ok', leg:'单趟 · 闸门一', legTone:'info', action:'安全校验进行中', enabled:false, reason:'价格不可读或超上限时立即停止。', session:'运行中', step:'闸门一:读取 SKU 单价', kind:'single', active:2, countdown:'暂停', fail:'0 / 3', lease:'03:58' },
|
||||
quantity_gate2: { tone:'info', title:'数量复核与闸门二', text:'数量必须读回为 2;目标规格不能漂移,第二次单价必须等于闸门一 CNY 32.50。', web:'ok', device:'ok', version:'ok', leg:'单趟 · 闸门二', legTone:'info', action:'安全校验进行中', enabled:false, reason:'数量、规格或价格变化都停止。', session:'运行中', step:'数量读回与闸门二', kind:'single', active:3, countdown:'暂停', fail:'0 / 3', lease:'03:31' },
|
||||
confirm_gate3: { tone:'caution', title:'确认页与闸门三', text:'正在核对规格、数量、应付总额与提交控件唯一;此时没有点击许可。', web:'ok', device:'ok', version:'ok', leg:'单趟 · 闸门三', legTone:'caution', action:'安全校验进行中', enabled:false, reason:'没有服务端围栏许可,绝不能点击提交订单。', session:'运行中', step:'闸门三:核对应付总额', kind:'single', active:4, countdown:'暂停', fail:'0 / 3', lease:'02:58' },
|
||||
quantity_gate2: { tone:'info', title:'数量复核与闸门二', text:'数量必须读回为 2;目标规格不能漂移,面板总额 CNY 65.00 必须唯一可读且不超过 CNY 80.00。', web:'ok', device:'ok', version:'ok', leg:'单趟 · 闸门二', legTone:'info', action:'安全校验进行中', enabled:false, reason:'数量、规格、总额歧义或超限都停止。', session:'运行中', step:'数量读回与闸门二', kind:'single', active:3, countdown:'暂停', fail:'0 / 3', lease:'03:31' },
|
||||
confirm_gate3: { tone:'caution', title:'确认页与闸门三', text:'正在核对规格、数量、应付总额严格等于闸门二面板总额,并确认提交控件唯一;此时没有点击许可。', web:'ok', device:'ok', version:'ok', leg:'单趟 · 闸门三', legTone:'caution', action:'安全校验进行中', enabled:false, reason:'没有服务端围栏许可,绝不能点击提交订单。', session:'运行中', step:'闸门三:核对应付总额', kind:'single', active:4, countdown:'暂停', fail:'0 / 3', lease:'02:58' },
|
||||
fencing: { tone:'caution', title:'正在申请服务端提交围栏', text:'等待首次明确的 click_permitted。超时、冲突、网络失败或响应不明都不会点击。', web:'ok', device:'ok', version:'ok', leg:'单趟 · 申请围栏', legTone:'caution', action:'等待围栏响应', enabled:false, reason:'申请中禁止取消、关闭或换 key 重复申请。', session:'运行中', step:'申请唯一提交围栏', kind:'single', active:5, countdown:'暂停', fail:'0 / 3', lease:'02:31' },
|
||||
fence_failed: { tone:'critical', title:'未获得首次明确点击许可', text:'围栏失败或响应不明:没有点击提交订单。只核查同一幂等键,不能直接重试。', web:'bad', device:'ok', version:'ok', leg:'单趟 · 未获许可', legTone:'critical', action:'打开围栏核查说明', enabled:true, safeAction:'review', reason:'只核查围栏状态,不触发提交。', session:'已停止', step:'核查幂等键', kind:'single', active:5, countdown:'—', fail:'1 / 3', submission:'围栏状态未知 · 幂等键 DEMO-FENCE-042' },
|
||||
fenced: { tone:'caution', title:'围栏已建立,只核对同一提交', text:'submission_id 已固定。内部流程至多点击一次;界面不提供提交、重领、放弃或重试。', web:'ok', device:'ok', version:'ok', leg:'单趟 · 已围栏', legTone:'caution', action:'打开同一提交核查说明', enabled:true, safeAction:'review', reason:'只能查看与调和同一提交记录。', session:'受控执行', step:'调和订单结果', kind:'single', active:7, countdown:'暂停', fail:'0 / 3', lease:'01:49', submission:'SUB-DEMO-0042 · FENCED' },
|
||||
@@ -646,7 +647,7 @@
|
||||
uncertain: { tone:'critical', title:'订单可能已创建,结果不明确', text:'保持金额预留和永久围栏,只核查 SUB-DEMO-0042;禁止重新领取、释放授权或再次点击。', web:'ok', device:'ok', version:'ok', leg:'RECONCILIATION_REQUIRED', legTone:'critical', action:'打开人工核查说明', enabled:true, safeAction:'review', reason:'核查不会发起新的下单。可另行导出假证据摘要。', session:'已停止', step:'人工核查同一提交', kind:'single', active:7, countdown:'—', fail:'0 / 3', submission:'SUB-DEMO-0042 · 结果不明确' },
|
||||
security: { tone:'critical', title:'检测到安全校验,已停止', text:'不绕过验证码、风控、人脸或短信。若已围栏,只调和同一提交;围栏前转人工。', web:'ok', device:'ok', version:'ok', leg:'待人工 · 安全校验', legTone:'critical', action:'打开人工核查说明', enabled:true, safeAction:'review', reason:'只显示核查说明,不提供绕过或继续。', session:'已停止', step:'等待人工处理', kind:'single', active:7, countdown:'—', fail:'1 / 3', submission:'SUB-DEMO-0042 · RECONCILIATION_REQUIRED' },
|
||||
payment_handoff: { tone:'critical', title:'进入外部支付交接,已停止', text:'订单可能已创建、支付未完成。不会读取或输入支付凭据,也没有“继续付款”按钮。', web:'ok', device:'ok', version:'ok', leg:'待人工 · 外部支付', legTone:'critical', action:'打开人工核查说明', enabled:true, safeAction:'paymentInfo', reason:'只显示人工核查与付款边界。', session:'已停止', step:'人工核查同一提交', kind:'single', active:7, countdown:'—', fail:'0 / 3', submission:'SUB-DEMO-0042 · HANDED_OFF' },
|
||||
manual: { tone:'caution', title:'任务已转人工:闸门二价格变化', text:'闸门一 CNY 32.50,数量设置后重读 CNY 35.00。围栏未建立,没有创建订单;需管理员核查后重置。', web:'ok', device:'ok', version:'ok', leg:'NEEDS_MANUAL', legTone:'caution', action:'导出假诊断摘要', enabled:true, safeAction:'export', reason:'导出只生成本地假文本,不含完整 XML、页面全文或敏感字段。', session:'继续轮询其他任务', step:'当前任务已移交', kind:'single', active:3, countdown:'00:11', fail:'0 / 3', submission:'未建立' },
|
||||
manual: { tone:'caution', title:'任务已转人工:闸门二总额超限', text:'目标数量面板总额 CNY 85.00 超过最高总价 CNY 80.00。围栏未建立,没有创建订单;需管理员核查后重置。', web:'ok', device:'ok', version:'ok', leg:'NEEDS_MANUAL', legTone:'caution', action:'导出假诊断摘要', enabled:true, safeAction:'export', reason:'导出只生成本地假文本,不含完整 XML、页面全文或敏感字段。', session:'继续轮询其他任务', step:'当前任务已移交', kind:'single', active:3, countdown:'00:11', fail:'0 / 3', submission:'未建立' },
|
||||
failed_stop: { tone:'critical', title:'连续失败达到阈值,轮询已停止', text:'最近三次均为服务连接失败。检查设置和网络;修复后重新开始普通轮询,不会重试任何提交点击。', web:'bad', device:'ok', version:'ok', leg:'自动停止', legTone:'critical', action:'前往配置', enabled:true, safeAction:'settings', reason:'先修复连接;不会自动恢复轮询。', session:'已停止', step:'等待修复', kind:'idle', active:-1, countdown:'—', fail:'3 / 3', count:4 }
|
||||
};
|
||||
const sessionControls = {
|
||||
@@ -834,7 +835,9 @@
|
||||
$('task-id').textContent = hasTask ? task.id : '—'; $('lease').textContent = s.lease || '—';
|
||||
$('product').textContent = hasTask ? task.product : '暂无商品;轮询会在安全就绪后领取一条任务';
|
||||
$('color').textContent = hasTask ? task.color : '—'; $('size-qty').textContent = hasTask ? task.sizeQty : '—';
|
||||
$('gate1-price').textContent = hasTask && ['gate1','quantity_gate2','confirm_gate3','fencing','fence_failed','fenced','waiting_payment','uncertain','security','payment_handoff','manual'].includes($('scenario').value) ? task.gate1Price : '—'; $('price-cap').textContent = hasTask ? task.cap : '—';
|
||||
$('gate1-price').textContent = hasTask && ['gate1','quantity_gate2','confirm_gate3','fencing','fence_failed','fenced','waiting_payment','uncertain','security','payment_handoff','manual'].includes($('scenario').value) ? task.gate1Price : '—';
|
||||
$('gate2-total').textContent = hasTask && ['quantity_gate2','confirm_gate3','fencing','fence_failed','fenced','waiting_payment','uncertain','security','payment_handoff'].includes($('scenario').value) ? task.gate2Total : '—';
|
||||
$('price-cap').textContent = hasTask ? task.cap : '—';
|
||||
$('submission').textContent = s.submission || '未建立';
|
||||
$('preview-empty').hidden = hasTask; $('product-preview-image').hidden = !hasTask;
|
||||
$('preview-source').textContent = hasTask ? '来源:内部原始商品截图(内联假证据)' : '来源:尚无可信图片';
|
||||
|
||||
@@ -26,22 +26,22 @@
|
||||
|
||||
<article class="state-panel" data-panel="draft">
|
||||
<header class="panel-head"><h2>待开始</h2><p>任务已保存,但尚未授权采购工具领取或创建订单。</p></header>
|
||||
<div class="panel-body"><div class="notice"><strong>创建不等于授权</strong>请返回任务列表勾选任务,并点击“开始采购(只创建待付款订单)”。</div><dl class="summary"><div><dt>商品</dt><dd>goods_id 937122477375</dd></div><div><dt>颜色 / 尺码</dt><dd>黑色CHA(纯棉) / M(建议100-115)</dd></div><div><dt>数量</dt><dd>2 件</dd></div><div><dt>最高总价</dt><dd>¥30.00</dd></div></dl><div class="actions"><a class="button primary" href="web-task-workbench.html">返回列表选择并开始采购</a></div></div>
|
||||
<div class="panel-body"><div class="notice"><strong>创建不等于授权</strong>请返回任务列表勾选任务,并点击“开始采购(只创建待付款订单)”。</div><dl class="summary"><div><dt>商品</dt><dd>goods_id 937122477375</dd></div><div><dt>颜色 / 尺码</dt><dd>黑色CHA(纯棉) / M(建议100-115)</dd></div><div><dt>数量</dt><dd>2 件</dd></div><div><dt>最高总价</dt><dd>¥40.00</dd></div></dl><div class="actions"><a class="button primary" href="web-task-workbench.html">返回列表选择并开始采购</a></div></div>
|
||||
</article>
|
||||
|
||||
<article class="state-panel" data-panel="pending" hidden>
|
||||
<header class="panel-head"><h2>已授权,等待采购工具领取</h2><p>管理员的开始采购动作已锁定任务字段和最高总价。</p></header>
|
||||
<div class="panel-body"><div class="notice ok"><strong>一次性授权已创建</strong>允许采购工具按本任务创建一笔待付款订单;不授权付款。</div><dl class="summary"><div><dt>授权编号</dt><dd class="mono">AUTH-018-V2</dd></div><div><dt>任务版本</dt><dd>2</dd></div><div><dt>最高总价</dt><dd>¥30.00</dd></div><div><dt>提交围栏</dt><dd>尚未建立</dd></div></dl><p>本状态没有“机器选对了吗”按钮。规格与价格将在设备执行时实时校验。</p></div>
|
||||
<div class="panel-body"><div class="notice ok"><strong>一次性授权已创建</strong>允许采购工具按本任务创建一笔待付款订单;不授权付款。</div><dl class="summary"><div><dt>授权编号</dt><dd class="mono">AUTH-018-V2</dd></div><div><dt>任务版本</dt><dd>2</dd></div><div><dt>最高总价</dt><dd>¥40.00</dd></div><div><dt>提交围栏</dt><dd>尚未建立</dd></div></dl><p>本状态没有“机器选对了吗”按钮。规格与价格将在设备执行时实时校验。</p></div>
|
||||
</article>
|
||||
|
||||
<article class="state-panel" data-panel="ordering" hidden>
|
||||
<header class="panel-head"><h2>单趟采购执行中</h2><p>采购工具正在同一设备会话中完成规格、价格、数量和确认页校验。</p></header>
|
||||
<div class="panel-body"><ol class="timeline"><li><strong>已打开目标商品</strong>App 8.17.0;goods_id 已核对</li><li><strong>已精确选择规格</strong>黑色CHA(纯棉) / M(建议100-115)</li><li><strong>闸门一通过</strong>SKU 单价 ¥12.88;预计总额 ¥25.76</li><li><strong>数量已读回</strong>2 件;正在执行闸门二</li><li><strong>尚未建立提交围栏</strong>不会点击提交订单</li></ol><div class="notice warn"><strong>无需中途人工确认</strong>任一读数变化或有歧义,系统会停止并转人工。</div></div>
|
||||
<div class="panel-body"><ol class="timeline"><li><strong>已打开目标商品</strong>App 8.17.0;goods_id 已核对</li><li><strong>已精确选择规格</strong>黑色CHA(纯棉) / M(建议100-115)</li><li><strong>闸门一通过</strong>SKU 单价 ¥12.88;数量前预检通过</li><li><strong>闸门二通过</strong>数量读回 2 件;面板总额 ¥32.76 ≤ ¥40.00</li><li><strong>尚未建立提交围栏</strong>不会点击提交订单</li></ol><div class="notice warn"><strong>无需中途人工确认</strong>任一读数变化或有歧义,系统会停止并转人工。</div></div>
|
||||
</article>
|
||||
|
||||
<article class="state-panel" data-panel="fenced" hidden>
|
||||
<header class="panel-head"><h2>提交围栏已建立</h2><p>只允许处理这一个 submission,不允许释放或再次提交。</p></header>
|
||||
<div class="panel-body"><div class="notice danger"><strong>禁止重试</strong>唯一提交记录 <span class="mono">SUB-018-5A7C</span> 已占用本授权;关闭页面也不会释放。</div><table class="gates"><thead><tr><th>门禁</th><th>读取值</th><th>结论</th></tr></thead><tbody><tr><td>闸门一</td><td>¥12.88 × 2 = ¥25.76</td><td class="good">通过</td></tr><tr><td>闸门二</td><td>规格不变;¥12.88</td><td class="good">通过</td></tr><tr><td>闸门三</td><td>确认页 ¥25.76 ≤ ¥30.00</td><td class="good">通过</td></tr><tr><td>提交控件</td><td>精确唯一 1 个</td><td class="good">通过</td></tr></tbody></table><div class="actions"><button class="button primary" type="button" data-state="reconcile">查看同一提交的调和页</button></div></div>
|
||||
<div class="panel-body"><div class="notice danger"><strong>禁止重试</strong>唯一提交记录 <span class="mono">SUB-018-5A7C</span> 已占用本授权;关闭页面也不会释放。</div><table class="gates"><thead><tr><th>门禁</th><th>读取值</th><th>结论</th></tr></thead><tbody><tr><td>闸门一</td><td>单价 ¥12.88;数量前预检通过</td><td class="good">通过</td></tr><tr><td>闸门二</td><td>规格 / 数量不变;面板总额 ¥32.76 ≤ ¥40.00</td><td class="good">通过</td></tr><tr><td>闸门三</td><td>确认页 ¥32.76 = 闸门二;≤ ¥40.00</td><td class="good">通过</td></tr><tr><td>提交控件</td><td>精确唯一 1 个</td><td class="good">通过</td></tr></tbody></table><div class="actions"><button class="button primary" type="button" data-state="reconcile">查看同一提交的调和页</button></div></div>
|
||||
</article>
|
||||
|
||||
<article class="state-panel" data-panel="reconcile" hidden>
|
||||
@@ -51,12 +51,12 @@
|
||||
|
||||
<article class="state-panel" data-panel="payment" hidden>
|
||||
<header class="panel-head"><h2>待付款</h2><p>订单已创建,系统尚未付款;请人工核对。</p></header>
|
||||
<div class="panel-body"><figure class="evidence"><svg viewBox="0 0 760 260" role="img" aria-labelledby="ev-title ev-desc"><title id="ev-title">内部订单截图假数据</title><desc id="ev-desc">只表示证据位置,不代表真实拼多多布局。</desc><rect width="760" height="260" fill="#e8eef7"/><rect x="30" y="25" width="700" height="210" rx="10" fill="#fff" stroke="#b8c4d5"/><text x="55" y="65" fill="#526079" font-size="18">INTERNAL_RAW 截图占位 · 假数据</text><text x="55" y="115" fill="#172033" font-size="19">黑色CHA(纯棉) · M(建议100-115) · 2 件</text><text x="55" y="165" fill="#172033" font-size="24">确认页金额 ¥25.76</text><text x="55" y="205" fill="#9a5b00" font-size="18">订单已创建,尚未付款</text></svg><figcaption>原始截图只供已登录管理员查看;完整 XML 和支付凭据不上传。</figcaption></figure><div class="checks"><label><input type="checkbox" data-pay-check>商品与任务一致</label><label><input type="checkbox" data-pay-check>规格和数量一致</label><label><input type="checkbox" data-pay-check>金额不超过授权上限 ¥30.00</label><label><input type="checkbox" data-pay-check>我已在拼多多人工完成付款</label></div><div class="actions"><button class="button primary" id="mark-paid" type="button" disabled>标记为已付款并完成</button></div></div>
|
||||
<div class="panel-body"><figure class="evidence"><svg viewBox="0 0 760 260" role="img" aria-labelledby="ev-title ev-desc"><title id="ev-title">内部订单截图假数据</title><desc id="ev-desc">只表示证据位置,不代表真实拼多多布局。</desc><rect width="760" height="260" fill="#e8eef7"/><rect x="30" y="25" width="700" height="210" rx="10" fill="#fff" stroke="#b8c4d5"/><text x="55" y="65" fill="#526079" font-size="18">INTERNAL_RAW 截图占位 · 假数据</text><text x="55" y="115" fill="#172033" font-size="19">黑色CHA(纯棉) · M(建议100-115) · 2 件</text><text x="55" y="165" fill="#172033" font-size="24">确认页金额 ¥32.76</text><text x="55" y="205" fill="#9a5b00" font-size="18">订单已创建,尚未付款</text></svg><figcaption>原始截图只供已登录管理员查看;完整 XML 和支付凭据不上传。</figcaption></figure><div class="checks"><label><input type="checkbox" data-pay-check>商品与任务一致</label><label><input type="checkbox" data-pay-check>规格和数量一致</label><label><input type="checkbox" data-pay-check>金额不超过授权上限 ¥40.00</label><label><input type="checkbox" data-pay-check>我已在拼多多人工完成付款</label></div><div class="actions"><button class="button primary" id="mark-paid" type="button" disabled>标记为已付款并完成</button></div></div>
|
||||
</article>
|
||||
|
||||
<article class="state-panel" data-panel="manual" hidden>
|
||||
<header class="panel-head"><h2>围栏前需要人工处理</h2><p>系统已经停止,未申请围栏、未点击提交订单。</p></header>
|
||||
<div class="panel-body"><div class="notice danger"><strong>闸门二未通过</strong>闸门一 ¥12.88,数量设置后重读 ¥13.20;不使用新价格继续。</div><dl class="summary"><div><dt>失败阶段</dt><dd>规格面板 · 闸门二</dd></div><div><dt>原因码</dt><dd class="mono">SKU_PRICE_CHANGED</dd></div><div><dt>安全处置</dt><dd>停止并保留证据</dd></div><div><dt>可用动作</dt><dd>人工核查后重置为待开始或取消</dd></div></dl><div class="actions"><button class="button danger" id="reset-button" type="button">关闭旧授权并重置为待开始</button></div></div>
|
||||
<div class="panel-body"><div class="notice danger"><strong>闸门二未通过</strong>目标数量面板总额 ¥42.00 超过最高总价 ¥40.00;围栏未建立。</div><dl class="summary"><div><dt>失败阶段</dt><dd>规格面板 · 闸门二</dd></div><div><dt>原因码</dt><dd class="mono">GATE2_TOTAL_OVER_CAP</dd></div><div><dt>安全处置</dt><dd>停止并保留证据</dd></div><div><dt>可用动作</dt><dd>人工核查后重置为待开始或取消</dd></div></dl><div class="actions"><button class="button danger" id="reset-button" type="button">关闭旧授权并重置为待开始</button></div></div>
|
||||
</article>
|
||||
|
||||
<article class="state-panel" data-panel="succeeded" hidden><header class="panel-head"><h2>任务已完成</h2><p>人工付款并核对后才进入终态。</p></header><div class="panel-body"><div class="notice ok"><strong>人工付款事实已记录</strong>系统没有执行付款,也没有读取支付凭据。</div><div class="actions"><a class="button primary" href="web-task-workbench.html">返回任务列表</a></div></div></article>
|
||||
@@ -66,7 +66,7 @@
|
||||
|
||||
<aside class="panel side" aria-label="原型状态与任务摘要">
|
||||
<section><h2>原型状态</h2><p>只切换假数据,不发送请求。</p><label class="field-label" for="state-select">业务状态</label><select id="state-select"><option value="draft">DRAFT</option><option value="pending">PENDING</option><option value="ordering">ORDERING</option><option value="fenced">FENCED</option><option value="reconcile">RECONCILIATION_REQUIRED</option><option value="payment">WAITING_PAYMENT</option><option value="manual">NEEDS_MANUAL</option><option value="succeeded">SUCCEEDED</option><option value="failed">FAILED</option><option value="canceled">CANCELED</option></select></section>
|
||||
<section><h2>任务要求</h2><p>黑色CHA(纯棉)<br>M(建议100-115)<br>2 件<br>最高总价 ¥30.00</p></section>
|
||||
<section><h2>任务要求</h2><p>黑色CHA(纯棉)<br>M(建议100-115)<br>2 件<br>最高总价 ¥40.00</p></section>
|
||||
<section><h2>固定边界</h2><p>单趟实时三闸门;服务端围栏;提交一次;系统不付款。</p></section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
+3
-3
@@ -75,8 +75,8 @@
|
||||
1. 任务要求:商品链接 / goods_id、颜色、尺码、数量、最高总价、版本。
|
||||
2. 开始采购授权:授权 id、授权人、锁定任务版本、创建/有效期、当前状态。
|
||||
3. 设备执行:attempt、设备、App 版本、步骤时间线和失败 code。
|
||||
4. 三道闸门:两次规格面板单价、规格/数量读回、确认页总额与判定。
|
||||
5. 内部截图:三道闸门已批准的规格面板/确认页证据;只经受保护端点读取。MVP 不存在提交后结果
|
||||
4. 三道闸门:闸门一规格面板单价、规格/数量读回、闸门二顶部总额、闸门三最终控件金额同额校验。
|
||||
5. 内部截图:三道闸门已批准的规格面板/合并式最终提交面板证据;只经受保护端点读取。MVP 不存在提交后结果
|
||||
截图类型,不展示外部支付页,也不把 Gate3 图片冒充结果图。
|
||||
6. 提交围栏:submission id、是否首次明确许可、唯一点击和调和记录。
|
||||
7. 待付款收口:明确写系统尚未付款;人工核对后记录完成。
|
||||
@@ -118,7 +118,7 @@ T-204 只显示数据库中当前实际存在的任务、授权、attempt、subm
|
||||
- 双击或 Enter 记录:左侧原位切换到记录详情,不弹窗;上方文字/图片,下方执行结果。
|
||||
- Esc 或“返回当前任务”:恢复当前任务视图;不暂停执行、不释放围栏。
|
||||
|
||||
单趟状态:待领取、已领取、打开商品、选择规格、闸门一、数量复核、闸门二、确认页/闸门三、
|
||||
单趟状态:待领取、已领取、打开商品、选择规格、闸门一、数量复核、闸门二、最终提交面板/闸门三、
|
||||
申请围栏、已发出唯一提交、待付款或待调和。桌面端没有让用户手工点击“提交订单”的按钮。
|
||||
|
||||
### 配置 tab
|
||||
|
||||
+104
-2
@@ -3,7 +3,7 @@ id: T-103
|
||||
title: 验证规格面板精确选择与 SKU 单价
|
||||
phase: 1
|
||||
deps: [T-102, T-110, T-111]
|
||||
status: DOING
|
||||
status: DONE
|
||||
created: 2026-08-04
|
||||
vikunja_task_id: 23
|
||||
context_ref: 1dc8308
|
||||
@@ -25,7 +25,7 @@ write_paths:
|
||||
- docs/current-state.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=23 synced=2026-08-05T04:46:50Z sha256=f225a9770923a004a3bae956e052e0cecdccee2e475b220c0487f534589bfb39 -->
|
||||
<!-- BEGIN VIKUNJA EXPORT id=23 synced=2026-08-05T09:03:05Z sha256=2a81aa73a233e09b2b9418aba9a6f298428bba12371110205a0f782fb90787ca -->
|
||||
## 问题 / 背景
|
||||
|
||||
T-102 已证明 canonical 链接可进入目标商品。T-103 在 PKG110 / Android 16 / 拼多多 8.17.0、goods_id `937122477375` 上确认:详情页底部购买区第一行“快要抢光 + 金额”是唯一获准的受控规格入口;商品内容区同名小字和第二行“免拼购买”都不得点击。
|
||||
@@ -278,6 +278,108 @@ T-103 sanitizer v2 坐标修正与主审:提交 44c027a 将 screenshot space
|
||||
### 2026-08-05T04:45:40Z · ila
|
||||
|
||||
2026-08-05 T-011 方案 B 由 T-103 所有者就地完成 pdd/device 测试提速:仅修改测试。`test_sku_selection` 保持 47 项,无删除/合并/skip,使用生产既有时钟注入缝和 runner 测试工厂,把 4.029s 降至 1.725s(≤2s);`test_sku_evidence_sanitizer` 保持 25 项,确认根因不是时钟而是重复编码 1080×2376 合成 PNG,改为按尺寸缓存同一确定性 PNG bytes,把 5.383s 降至 4.360s。两模块合计 72/72 PASS;与 T-304 所有者的 polling 改动合并后 client 全量仍为 255/255,12.892s(≤15s),compileall/diff-check/agent-context 均通过。未修改生产代码或任何规格/价格/提交安全断言。T-103 仍因真机 reveal 与最终完整链路人工验收保持 Doing。
|
||||
|
||||
### 2026-08-05T04:56:30Z · ila
|
||||
|
||||
2026-08-05 T-103 reveal 放行前独立审计返修:实现未发现生产缺陷,但原测试未永久覆盖验收条目中的四类后置失败。仅修改 client/tests/pdd/test_sku_reveal_spike.py,新增端到端反例:swipe 后页面不变/尺码仍隐藏、目标颜色 selected 漂移、提交硬拒绝区文本缺失/重复/上移、RPC 结果不明且页面不变。每例均断言 swipe 恰好一次且不重试、target/manifest 不发布、staging 无残留。受托 agent 聚焦 59/59 PASS;主 agent 独立逐行审查并复跑 client 全量 259/259(13.883s)、compileall、agent-context、diff-check 全部 PASS。未改生产代码,未引入数量、确认页、围栏、提交或支付能力。T-103 继续保持 Doing;可在该修复提交推送后放行项目所有者执行唯一一次真机 reveal。
|
||||
|
||||
### 2026-08-05T06:19:03Z · ila
|
||||
|
||||
2026-08-05 T-103 第二次真机失败诊断:项目所有者确认从拼多多首页运行后已进入目标商品并弹出规格面板,但面板保留此前人工选择的“白色 + 其他尺码”,脚本没有选择“黑色CHA(纯棉)”或目标尺码。按调用链,入口后置只接受 PANEL_OPEN_EMPTY;非目标规格使 `sku_entry_panel_verify` 失败,尚未进入 `select_sku_options`,因此 zero target-color click / zero reveal swipe / zero submit。为避免统一错误再次丢失控制流事实,仅修改 capture_sku_reveal_spike.py 复用既有 safe_failure_stage,输出白名单 stage 或 unknown;异常正文、页面文本、serial、XML、路径仍不回显,伪造 stage 无正式 marker 时降级 unknown。独立审计与主审均确认未改 Capturer/Flow/判据/动作;聚焦 60/60、client 全量 260/260(14.788s)、compileall、agent-context、diff-check PASS。T-103 继续 Doing,下一步先由人把该商品规格面板恢复为颜色/尺码均未选,再从首页执行全新 output-dir。
|
||||
|
||||
### 2026-08-05T06:50:08Z · ila
|
||||
|
||||
2026-08-05 T-103 同版本单价格节点变体真机证据与实施方案(先证据、后判据):第一次正式 reveal 在 `sku_entry_panel_verify` fail closed。项目所有者已确认 force-stop 后颜色/尺码恢复未选;root 只读检查证明 PDD 8.17.0/PKG110/Android16/1080×2376/前台正确、summary=`请选择: 颜色分类 尺码`、selected-count=0、目标颜色 bounds `[372,1188][684,1587]` selected=false。证据目录 `C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-103\sku-panel-empty-current-only-20260805-143741`,screenshot SHA-256 `7f3143d7fab42b41a088e27a4b2d9f8dbdf31b5ecc9ba02b148ca021304026f6`,XML SHA-256 `d3dbf217b4567069feccb30ab43124c5fa7da3734659ca5452b98c2e0a7ff158`。根因不是 selected,而是价格行由旧双节点变为 exact 单节点:current 仍为 `限1件 ¥12.88 ` `[396,580][675,647]`,其唯一 LinearLayout 为 `[396,575][693,647]` 且仅一 child,外层 FrameLayout `[396,575][1053,647]`;`券前¥29.88` count=0。项目所有者随后在同一 goods_id 937122477375 面板人工选择目标颜色并明确完成第二态确认;目录 `...\sku-panel-color-only-current-only-20260805-144550`,screenshot `931dc2d31f4e34872fdd97879abd076e414b6494569e8a587353266011610545`,XML `c686737942e5fdcebc76c2bc1d5cd9ae90a45f4a921297119ddaa4313758a034`;summary=`请选择: 尺码`,目标颜色 selected=true,未选尺码,current count=1/original count=0,固定 reveal 通道用现有代码只读验证 PASS,未发送 swipe。实施只允许新增两个 exact 单价格前置 spec(PANEL_OPEN_EMPTY / COLOR_SELECTED_SIZE_HIDDEN),旧双价格 spec 继续保留;不得把 original 泛化为 optional、不得接受任意 row/bounds/child count/price text,也不得修改 rolled candidate、数量、确认页、提交或支付。正式再跑前新增带正式 marker 的 reveal 白名单阶段码,至少能明确 `reveal_not_attempted` 与 `reveal_attempted/candidate/after/publish` 边界,异常正文和页面内容仍不外泄;补 cross-variant/duplicate/unknown/伪造 stage 失败测试。T-103 继续 Doing,先提交此证据计划,再实现并独立主审。
|
||||
|
||||
### 2026-08-05T07:19:02Z · ila
|
||||
|
||||
2026-08-05 第二态由用户人工确认:目标颜色“黑色 CHA(纯棉)”已选,尺码选项已显示且尚未选尺码。实现已按两组新 raw 证据收紧:新增 PANEL_OPEN_EMPTY / COLOR_SELECTED_SIZE_HIDDEN 的 exact current-only 价格布局;历史 dual 布局继续按已取证嵌套后代关系匹配;颜色点击后的后置条件绑定点击前完整 price_layout,禁止 dual 与 current-only 双向漂移。reveal 失败阶段新增不可伪造 marker,precondition 保证零 swipe,attempted 及以后仅在唯一 RPC 机会已封存后报告。两名独立 agent 审计均 PASS;根代理直接读取六组真实 raw 验证唯一命中:dual empty/color/S/M 与 current-only empty/color 均正确,M 读价 12.88。根代理完整门禁:client unittest 269/269 PASS,compileall PASS,validate_agent_context PASS,git diff --check PASS。未新增数量、确认页、围栏、提交订单或付款能力。T-103 仍保持 DOING,下一步仅允许强停重置后运行一次真机 reveal,并等待人工验收。
|
||||
|
||||
### 2026-08-05T07:23:56Z · ila
|
||||
|
||||
2026-08-05 15:23(Asia/Shanghai)唯一一次真机 reveal 运行结果:从拼多多 MainFrameActivity 首页、显式 Wi-Fi serial 192.168.0.173:5555、全新 output-dir `C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-103\sku-reveal-current-only-20260805-152300` 启动;CLI 返回 `stage=reveal_precondition`,exit 1,未发布目录。按正式 marker,reveal swipe 未发生;禁止自动重跑。随后准备以 `capture_device_baseline.py` 对保持不动的页面做只读取证,但 `adb devices` 同时出现 Wi-Fi `192.168.0.173:5555` 与 USB `3B65BD02H7F00000`,基线脚本按同一物理手机多通道门禁拒绝,未采集。等待项目所有者拔掉 USB,仅保留 Wi-Fi;手机保持失败后的当前页面。之后只做一次只读截图/XML,不重跑 reveal,再根据首个真实漂移决定最小修复。T-103 保持 DOING。
|
||||
|
||||
### 2026-08-05T07:29:22Z · ila
|
||||
|
||||
2026-08-05 15:26-15:27(Asia/Shanghai)T-103 唯一真实 reveal 动作与失败后只读取证:仅 Wi-Fi ADB,拼多多 MainFrameActivity 首页,显式 goods_id 937122477375,全新 output-dir `...\sku-reveal-current-only-20260805-152636`。App 版本在本次运行重新检查为 8.17.0。脚本在 47 秒后返回 `stage=reveal_candidate`,证明唯一 reveal RPC 已发出;未发布 staging,未重试。手机保持不动后用只读 baseline 采集到 `C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-103\reveal-candidate-failure-20260805-152636`:screenshot SHA-256 `5c86a922b1064c1a3bf175182340703ad0a78c6f96d9ccee25ffbee336afdf68`,XML SHA-256 `840ef3e59aa3f19c5c9521d92ff5af769c9e19d348a68b79cce74e83e5f38295`。只读代码诊断 `_candidate_projection` 的首个错误为“候选价格行不唯一”。新 raw 显示目标颜色 exact selected=true,摘要 `请选择: 尺码`,S/M 可见且 selected=false,提交文字仍在 `[285,2225][795,2284]` 的硬拒绝区;滚动态价格是独立 exact current-only 变体:Frame `[396,498][1053,570]` 唯一 child,LinearLayout `[396,498][693,570]` 唯一 child,current=`限1件 ¥12.88 ` `[396,503][675,570]`,`¥29.88` count=0。旧 rolled dual 证据保持不变,不得把 original 泛化为 optional。等待项目所有者对当前手机画面确认:目标黑色仍选、S/M 均可见且均未选、仍在规格面板而非确认页。确认前不改判据、不再操作手机;T-103 保持 DOING。
|
||||
|
||||
### 2026-08-05T07:31:25Z · ila
|
||||
|
||||
2026-08-05 项目所有者完成人工确认:真实 reveal 后目标颜色已选中,未选中任何尺码。结合失败后保持不动采集的截图/XML,S/M 已显示,页面仍是规格面板而非订单确认页;因此唯一固定 reveal 动作已证明不会误选尺码,也未触发提交区。实施方案收紧为:只在独立 spike 的 rolled candidate 中新增与 raw 完全一致的 current-only 价格布局(Frame `[396,498][1053,570]` → 唯一 row `[396,498][693,570]` → 唯一 current `限1件 ¥12.88 ` `[396,503][675,570]`,原价 count=0);旧 dual candidate `快卖完 ¥12.88` + `¥29.88` 原样保留。两种布局必须分别完整唯一匹配,禁止 optional original、跨布局拼装、未知文字/坐标/父链/child count;候选其余颜色、摘要、S/M 未选、提交硬拒绝区判据不变。补 dual/current-only 双向混合、duplicate、unknown、parent drift 失败测试;不得改生产 Flow 的 reveal,不得新增数量、确认页、围栏、提交或支付能力。先提交本计划投影,再由唯一 client 写入者实现、独立审计、根代理重跑真实 raw 与完整客户端门禁。T-103 保持 DOING;代码通过后是否需要再次运行 spike 以形成原子 before/after 发布证据,仍以任务验收全集为准。
|
||||
|
||||
### 2026-08-05T07:53:16Z · ila
|
||||
|
||||
2026-08-05 T-103 rolled current-only candidate 实现与双轮审计完成。唯一 client 写入者只修改 `sku_reveal_spike.py` 与对应测试:candidate 现在绑定两份真实 raw 共有的 exact header→Frame→Relative→ViewGroup→price Frame 链、各层 child count、summary direct parent;旧 dual row/current/original 与新 current-only row/current 是完整互斥 variant,原价绝非 optional,双向 complete/partial/hybrid/duplicate/unknown/parent drift 和已知 `¥29.88`/`券前¥29.88` 残片全部拒绝。首轮审计发现并退回两个 P1(current-only 任意 descendant、dual+current 共存仍通过);返修后根代理及独立审计均复现为 REJECT。真实 current-only after raw projection PASS(14 roles);历史 dual M raw 仅内存机械恢复未选尺码态后 PASS(14 roles)。截图后 current-only→dual 漂移永久测试证明 `stage=reveal_after`、唯一 swipe=1、target/staging 均不发布。根代理完整门禁:client unittest 274/274 PASS,compileall PASS,agent-context PASS,git diff-check PASS;生产新增行只有 clickability 只读检查,无新 tap/swipe/数量/确认页/授权/围栏/提交或支付能力。T-103 仍保持 DOING;下一步需按验收全集形成可发布的原子 before/after spike 证据并由人终验,代码提交后不得复用旧 output-dir。
|
||||
|
||||
### 2026-08-05T07:56:33Z · ila
|
||||
|
||||
2026-08-05 15:55(Asia/Shanghai)T-103 修复后原子 reveal 证据成功发布。前置:仅 Wi-Fi ADB、从拼多多 MainFrameActivity 首页、force-stop 清除持久选择、脚本本次重新核验 PDD 8.17.0 / PKG110 / Android 16 / 1080×2376。命令使用全新目录 `C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-103\sku-reveal-accepted-20260805-155502`,33.5 秒完成;manifest `rpc_outcome=completed`、`reveal_attempts=1`、`candidate_status=human_review_required`。哈希:before screenshot `01d3c5a3c39635389d5ff5ec86809fd10dae897d8bc39c6aa5840da8b25f6e60`,before XML `8e318edf578e404d1e2de317d3c206cb7c481378e0b6b60927047d033b867adc`,after screenshot `fa3024167aaf7d5719b0916de8f22c27b83a0b200bc38c750af410ed85cac7d1`,after XML `539e76856c175a973f81a0e879b8d5c4103767a7464858f7a0977d6ffa47ce0f`。根代理只读重证:before exact COLOR_SELECTED_SIZE_HIDDEN + 固定安全通道 PASS;after candidate projection 14 roles PASS,目标颜色 selected 子树唯一,S 与 M 均可见且 selected=false,未发布外部证据/未 Back。截图显示仍为规格面板,提交硬拒绝区在底部;等待项目所有者查看手机/after screenshot 后最终人工确认“目标颜色保持选中、S/M 均未选、未进入订单确认页”。确认前 T-103 保持 DOING,不推进生产 reveal 或后续规格点击。
|
||||
|
||||
### 2026-08-05T08:09:05Z · ila
|
||||
|
||||
2026-08-05 项目所有者对第二次原子 reveal 证据完成三项人工终验并明确回复“是的,3个确认”:1) 目标黑色保持选中;2) 未选中任何尺码;3) 仍在规格面板,未进入订单确认页。证据目录 `C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-103\sku-reveal-human-review-20260805-160655`;manifest 为 Wi-Fi / PDD 8.17.0 / PKG110 / Android 16 / goods_id 937122477375 / `reveal_attempts=1` / `rpc_outcome=completed`。哈希:before screenshot `7f1fb74ce91d67054deb462a02f4306128889ec7d04194ba2fb07bb90b664169`,before XML `314257333a50cd3be3a9e883d5a33e5b7da763224210f350b95c6593be7cd710`,after screenshot `e9b628821d1e39b8b501efd21a88e43a5f8e6eda9332c44159c38b0a8a5eb38a`,after XML `e17e0a789f53b33fa564b678511ba633bfb969861fc32417b81e5545e309e9a1`。根代理机器重证 before color-only+safe-path PASS,after projection 14 roles PASS,S/M selected=false。reveal 动作证据门禁现已满足。T-103 仍保持 DOING,因为验收全集还要求将已证固定 reveal 收入生产 Flow,并由人完成入口→目标颜色→固定 reveal→目标 M→读取 12.88→一次安全 Back 的最终真机闭环;不得把本次三项确认误当成整个 T-103 DONE。下一步先做只读设计审计与方案记录,再编码;不新增数量、确认页、提交或支付能力。
|
||||
|
||||
### 2026-08-05T08:20:30Z · ila
|
||||
|
||||
2026-08-05 16:14(Asia/Shanghai)T-103 目标 M 真实后置证据与生产 Flow 实施计划:
|
||||
|
||||
项目所有者在原子 reveal 人审通过后的当前规格面板中,手工点击了 exact M(M(建议100-115))。root 随即只读采集全新证据目录 C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-103\sku-target-m-current-only-20260805-161434;manifest 绑定 Wi-Fi / 拼多多 8.17.0 / PKG110 / Android 16 / goods_id 937122477375 / 1080×2376。screenshot SHA-256=74e006b25c8618f0b283ff2472ef331e9628306877088b760c103f15f629e1e7,XML SHA-256=6887322d2ba9309e82442aa57b9a4ac4540f2850f6931a2631edbf899138aad1。现有代码只读重证:唯一命中 TARGETS_SELECTED / _DualPriceLayout;仅 exact M selected=true,目标颜色保持 selected=true;_unit_price 精确返回十进制字符串 12.88。截图显示仍为规格面板。
|
||||
|
||||
该证据补齐的唯一获准真实转移是:CURRENT_ONLY COLOR_SELECTED_SIZE_HIDDEN → 固定无参 reveal 一次 → CURRENT_ONLY SIZE_VISIBLE_UNSELECTED(S/M 均 false)→ exact M 一次 → 既有 DUAL TARGETS_SELECTED → 既有价格节点读取 12.88。不得泛化为任意跨 variant;DUAL color-only 在 reveal 前必须零动作失败;不得新增 current-only TARGETS_SELECTED、不得把原价改为 optional、不得修改既有 target classifier 或读价语义。
|
||||
|
||||
生产化方案经两名只读 agent 独立审计后由 root 收敛:
|
||||
1. sku_selection.py 成为 profile/revealed-unselected 判据唯一权威,新增 current-only 可见未选尺码窄状态和显式转移白名单;从 spike 提取共用纯判据,生产模块不得 import spike。
|
||||
2. Protocol 只新增无参数 reveal_size_options_once;绝不公开 swipe/scroll/坐标。Flow 在 RPC 前封存 pending/attempt,固定 reveal 最多一次;fresh hierarchy 连续两帧稳定证明 target color=true、summary=请选择尺码、S/M exact 可见且均 false、硬拒绝区未漂移后,才允许 exact M 一次。M 后继续复用既有 _require_target_panel 与 _unit_price。
|
||||
3. sku_selection_runner.py 的 adapter 固定唯一 reveal 手势并在 RPC 前封存。RPC timeout/结果不明只读调和,resolved 或 unresolved 均不重发;无法证明后置时进入终止态,不得继续 M、读价、Back 或发布。正常完整链才允许 Back 一次。
|
||||
4. sku_reveal_spike.py 删除重复手势/候选判据并单向复用生产 matcher;改造后不得因 Flow 已生产化而重复 reveal。
|
||||
5. T-103 退出证据只记录 back_attempts/RPC outcome 和 post_exit_status=human_review_required;“面板消失”不得自动写 safe_exit=completed。同 goods 商品页自动 post-exit 判据仍留给 T-104。
|
||||
6. 补正反例:dual color-only 零 reveal、variant/hybrid/父链/child-count/summary/selected/硬拒绝区漂移、exact M 缺失/重复/禁用/近似/已有其他尺码、reveal/M delivered-timeout 与 undelivered-timeout、重入二次调用零 RPC、截图后漂移、Back 不明、原子发布竞态、静态 API/AST 不可达数量/确认页/围栏/提交/支付。
|
||||
|
||||
实现仅限 T-103 write_paths,任务继续 DOING。代码与完整 client 门禁通过后,必须再从首页、单 Wi-Fi、全新 output-dir 跑一次生产 runner,并由项目所有者人工核对:入口/颜色/reveal/M 各最多一次、仅 M 选中、规格面板价 12.88、Back 一次后仍为同 goods 商品详情页且未进入确认/提交/支付页。
|
||||
|
||||
### 2026-08-05T08:55:34Z · ila
|
||||
|
||||
2026-08-05 T-103 生产 Flow 离线实现与双重终审完成,提交 60cf05b 已推送 main。
|
||||
|
||||
唯一 client 写入者仅修改 5 个 T-103 文件:sku_selection.py、sku_selection_runner.py、sku_reveal_spike.py 及两份对应测试;未操作真机,未修改共享文档或用户文件。
|
||||
|
||||
实现结果:
|
||||
- 生产只放行同一 Flow 的 current-only empty → exact 目标颜色一次 → 固定无参 reveal 一次 → 连续两帧稳定的 current-only S/M 均未选 → exact M 一次 → 复用既有 dual TARGETS_SELECTED / 12.88。dual color-only、非本 Flow 已选颜色和 fresh revealed-unselected 均在后续 mutation 前零动作失败;已验 S=true→exact M 恢复路径保留。
|
||||
- sku_selection.py 成为 safe path/revealed-unselected 唯一判据;spike 删除重复手势和 300+ 行候选 matcher,单向复用生产判据与正式 adapter,且不调用完整选择流程,避免双 reveal/M。
|
||||
- reveal 手势只有一份不可变 tuple;Protocol 仅新增无参数命名动作,不暴露 swipe/scroll/坐标。entry/option/reveal/Back 均在 RPC 前封存,结果不明只读调和;Flow terminal 防重入,失败 finally 不再自动 Back。
|
||||
- reveal 后提交硬拒绝区要求全局所有 PDD TextView 中包含“提交订单”的集合恰好等于唯一 exact 底部证据节点;额外、上移、改文案、混合价格/父链/child-count/selected 漂移均失败关闭。
|
||||
- 每个 S/reveal/M mutation 前 fresh 重查版本、前台和完整 hierarchy。截图后重证目标规格/12.88;Back 前再用 fresh tree 通过既有 dual TARGETS_SELECTED 与 _unit_price,才封存并发送一次 Back。
|
||||
- 发布前强制 entry/color/reveal/M/Back 各 exactly 1 且 RPC outcome=completed。manifest 移除 safe_exit=completed,记录五个命名 actions 的 attempts/outcome,以及 post_exit_status=human_review_required;T-104 前不自动宣称同商品安全退出。
|
||||
- 调用链未引入数量、确认页、授权、围栏、提交订单或支付能力。
|
||||
|
||||
审查与验证:
|
||||
- 实现 agent 首轮后,独立审计先后发现并退回:S→M 回归、手势双常量、动作审计不足、提交硬拒绝全局唯一缺口;root 另退回 Back 前 target/price 不够精确、mutation 前台未 fresh 重证、manifest 缺五类动作明细。全部补测并修复。
|
||||
- 独立终审最终 PASS,无 P1/P2;额外注入上移/改文案“提交订单”节点均 REJECT。
|
||||
- root 使用 client/.venv 独立复跑:聚焦 84/84 PASS;client 全量 284/284 PASS,13.434 秒(≤15 秒);compileall(src/tests/scripts) PASS;wheel build 与 requirements metadata PASS;validate_agent_context PASS;git diff-check PASS。测试提速只缓存不可变的同一白色 PNG Base64,未删/合并/skip 测试。
|
||||
|
||||
T-103 仍保持 DOING。下一步必须从拼多多首页、仅 Wi-Fi ADB、全新 output-dir 运行生产 run_t103_sku_selection.py,并由项目所有者人工核对:目标颜色与 M、规格面板价 12.88、五类动作均一次、Back 后仍为 goods_id 937122477375 商品详情且未进入确认/提交/支付页。
|
||||
|
||||
### 2026-08-05T08:59:04Z · ila
|
||||
|
||||
2026-08-05 16:57(Asia/Shanghai)T-103 生产 runner 最终真机闭环已单次执行成功,等待项目所有者最终人工确认;任务继续 DOING。
|
||||
|
||||
运行前只读门禁:adb devices 仅有 Wi-Fi serial 192.168.0.173:5555;本次重新读取拼多多 versionName=8.17.0;force-stop 清除手工 M 状态后显式启动 MainFrameActivity,最终前台为 HomeActivity。随后从首页以 canonical goods_id 937122477375、任务颜色“黑色CHA(纯棉)”、尺码“M(建议100-115)”、全新目录执行 production run_t103_sku_selection.py,一次运行 53.9 秒、exit 0;未重跑。
|
||||
|
||||
生产证据目录:C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-103\sku-selection-production-20260805-final。runner 返回目标规格正确、规格面板单价 12.88。manifest 的五类 actions 全部为 attempts=1 / rpc_outcome=completed:sku_entry、target_color、size_reveal、target_size、back;channel=wifi、PDD 8.17.0、PKG110、Android16、unit_price=12.88、post_exit_status=human_review_required。Back 前 screenshot SHA-256=c0f6a4225c1a59195672ea6ff45a360f33886571c9566b6b6b66c4d7a59abb53,复算一致。root 只读查看该截图:规格面板仍在,已选摘要显示目标黑色与 M,M 有唯一红色选中框,顶部规格面板现价 12.88;底部“提交订单 ¥12.88”仅可见,未点击。
|
||||
|
||||
Back 后不再进行任何 mutation,只用 baseline 只读采集:C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-103\sku-selection-after-back-20260805-final。前台为 com.xunmeng.pinduoduo/.activity.NewPageActivity;screenshot SHA-256=477fdf4202a6cef9d615bd37defd473b0e18fc6312469b1061b45fb8c00034f4,XML SHA-256=1aefe2b73ec27aa32c00b601c74f208b69675c8807ac91ce28e6997a75624da2,均与 manifest 一致。root 只读查看截图:已返回商品详情页,商品主图/标题与 goods_id 937122477375 证据一致,底部仍是“快要抢光 ¥12.88 / 免拼购买”,没有进入订单确认、提交结果或支付页。
|
||||
|
||||
按 needs_device=true 规则,以上 agent/机器检查不能替代人验收。等待项目所有者在手机或两张本地截图上明确确认三项:1) Back 前目标颜色与 M 正确且规格面板价为 12.88;2) manifest 五类动作各一次;3) Back 后仍是该商品详情页,未进入确认/提交/支付页。确认前不得将 T-103 标 DONE。
|
||||
|
||||
### 2026-08-05T09:00:34Z · ila
|
||||
|
||||
2026-08-05 项目所有者完成 T-103 最终人工真机验收并明确回复“三项确认”:
|
||||
|
||||
1. Back 前目标颜色与 M 正确,规格面板当前价为 12.88;
|
||||
2. manifest 中 sku_entry、target_color、size_reveal、target_size、back 五类动作均为 attempts=1 / rpc_outcome=completed;
|
||||
3. Back 后仍为 goods_id 937122477375 商品详情页,未进入订单确认、提交结果或支付页。
|
||||
|
||||
至此 T-103 的验收要点全集全部满足:已取证页面判据、离线 284/284 与构建/元数据/上下文门禁、生产单次真机闭环和人类最终确认均具备。系统未设置数量、未进入确认页、未提交订单、未创建待付款订单、未触及任何付款能力。现将线上 done 与本地 git status 同步为 DONE;T-104 依赖解除。
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
+67
-3
@@ -3,10 +3,10 @@ id: T-104
|
||||
title: 验证规格选择能力安全退出
|
||||
phase: 1
|
||||
deps: [T-103]
|
||||
status: TODO
|
||||
status: DONE
|
||||
created: 2026-08-04
|
||||
vikunja_task_id: 32
|
||||
context_ref: 3a0a41d
|
||||
context_ref: 29fb528
|
||||
work_branch: task/t-104-safe-exit
|
||||
needs_device: true
|
||||
needs_human_review: true
|
||||
@@ -21,7 +21,7 @@ write_paths:
|
||||
- docs/current-state.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=32 synced=2026-08-04T15:33:56Z sha256=cd649f78ae8d79edcfe94fb3702f8dbf99b12d1d4cbf2c0069b14cffa8703464 -->
|
||||
<!-- BEGIN VIKUNJA EXPORT id=32 synced=2026-08-05T09:57:21Z sha256=8469785af6c7ffd0d42dd7f2bb56bad15996a1f696c854823074304739bf652e -->
|
||||
## 问题 / 背景
|
||||
|
||||
T-103 已实现受控规格选择、闸门一读价、规格面板原始截图和单次 Back,但当前 `exit_sku_panel_safely()` 只要 hierarchy 变化且规格面板判据不再成立就返回成功。现有纯 mock 测试还把 Back 后的空 hierarchy 当成成功并允许 runner 发布 `safe_exit=completed`。2026-08-04 的只读诊断同时证明:通知栏覆盖时 `app_current` 仍可能报告拼多多,而节点树全部属于 SystemUI。因此“面板消失”或“前台包仍是 PDD”都不是安全退出的充分条件;T-104 必须先取得独立 post-exit 真机证据,再把后置条件收紧为稳定回到同一目标商品详情页。
|
||||
@@ -62,6 +62,70 @@ T-103 已实现受控规格选择、闸门一读价、规格面板原始截图
|
||||
### 2026-08-04T13:46:33Z · ila
|
||||
|
||||
2026-08-04 T-306 预研反向约束 T-104:成功 SkuSelectionRunResult 必须显式携带规格面板原始 screenshot_path 与实际 UTC captured_at;只有规格选择、闸门一读价、截图和严格安全退出全成功才返回。T-306 不枚举目录、不读 manifest 猜文件/时间,post-exit 商品详情截图不得冒充 gate1 证据。
|
||||
|
||||
### 2026-08-05T09:04:13Z · ila
|
||||
|
||||
2026-08-05 T-103 已在 git/线上完成,T-104 依赖解除,现由 root 领取并转 DOING;领取基线 29fb528,沿用工作路径 task/t-104-safe-exit。
|
||||
|
||||
先证据后判据的实施顺序保持不变:
|
||||
1. 先只读审计 T-103 最新生产 Flow、最终 Back 前/后本地证据与 T-104 任务边界。T-103 的 sku-selection-production-20260805-final 与 sku-selection-after-back-20260805-final 已获人确认,可用于设计窄取证脚本和寻找候选正锚;但它们不是同一 T-104 原子 manifest,不能直接冒充阶段 A 验收证据。
|
||||
2. 唯一 client 写入者先实现 capture_sku_exit_spike.py:输入只允许显式 serial 与全新 output-dir;无商品 intent、页面 click、坐标、规格动作、数量、确认、提交或支付能力。前置复用 T-103 已证目标颜色/M/12.88 classifier;唯一 mutation 为 RPC 前封存且最多一次的 Android Back。
|
||||
3. Back 后只读连续采集 screenshot/XML/App/版本并原子发布本机证据;manifest 仅记录 back_attempts=1、RPC outcome、哈希和 human_review_required,人工确认前绝不写 safe_exit=completed。结果不明不重试、不发送任何后续 mutation。
|
||||
4. 离线脚本、原子性、超时/重入、静态 API/AST 测试经独立审计和 root 门禁通过后,才由项目所有者把手机手工停在已验目标规格面板并执行阶段 A;人确认同 goods 商品详情、面板关闭、未进入确认/提交/支付且未创建订单。
|
||||
5. 只有上述人验后,才从新 raw 提取 privacy-safe 的 product_exit_8_17_0.xml 最小 fixture,并设计阶段 B。若 raw 不含唯一同商品正锚,保持 DOING,不编造 selector、不放宽为任意 PDD 商品页。
|
||||
6. 阶段 B 收紧现有 Flow/runner:版本/PDD 包、同 goods 正锚、无危险页与连续两帧稳定全部成立才返回安全退出;空 hierarchy/SystemUI/锁屏/其他 PDD 页/面板未退/锚点漂移全部失败。成功结果同时补齐规格面板 screenshot_path 与截图附近 UTC captured_at,post-exit 图不得冒充 Gate1。
|
||||
|
||||
写路径严格限于 T-104 frontmatter;共享 current-state 由 root 合入。needs_device/needs_human_review 保持,agent 不得自行 DONE。
|
||||
|
||||
### 2026-08-05T09:27:14Z · ila
|
||||
|
||||
2026-08-05 阶段 A 离线实现与独立审查完成,代码 commit 43fd8ce 已推送 main。
|
||||
|
||||
仅新增窄取证器/CLI/测试:人工先停驻目标规格面板,内存中连续两次核验 PDD 8.17.0、PKG110/Android 16/1080x2376、目标颜色+M+12.88;唯一 mutation 为 RPC 前封存且最多一次的命名 Android Back。Back 响应不明不重试,只允许发布 ambiguous_reconciled + human_review_required。Back 后在任何截图/XML 前先核验仍为 PDD 8.17.0,采集后复核 package/activity/version 稳定;外部包、版本或页面漂移、PNG/XML/读取/发布失败均清 staging、零 target。
|
||||
|
||||
manifest 不含 safe_exit 或 Gate1 资格;post-exit 图/XML 仅本地人工复核。静态检查证明脚本不可达 intent、页面 click、规格动作、数量、确认、围栏、提交或支付能力。
|
||||
|
||||
root 独立门禁:focused 71/71、client full 293/293、compileall、validate_agent_context、git diff --check、完整 init.ps1 全部通过。独立 reviewer 终审 PASS,无 P1/P2。
|
||||
|
||||
任务保持 DOING,下一步等待项目所有者把手机手工停在 goods_id 937122477375 的目标规格面板(黑色CHA(纯棉)、M(建议100-115)、12.88),执行一次阶段 A 脚本并人工确认 Back 后为同一商品详情、面板关闭、未进入确认/提交/支付、未创建订单。
|
||||
|
||||
### 2026-08-05T09:38:22Z · ila
|
||||
|
||||
2026-08-05 阶段 A 首次真机尝试在唯一 Back 前置核验处 fail closed,未发布 target/staging。项目所有者随后人工确认规格面板仍打开,因此本次 Back 没有送达;后续仅做只读诊断。
|
||||
|
||||
只读诊断在 PDD 8.17.0、PKG110、Android 16、1080x2376、Wi-Fi 设备上证明:目标颜色与 M 均为 selected=true,尺码仍为既有精确 bounds `[439,1582][831,1667]`;同一目标颜色卡片因横向列表滚动,从既有 `[372,1000][684,1024]` 精确移动为 `[126,1000][438,1024]`,其余完整 TARGETS_SELECTED profile 与 12.88 双价格布局均未漂移。
|
||||
|
||||
新本地只读证据:`%LOCALAPPDATA%\cmbuyer\artifacts\T-104\sku-exit-precondition-color-shift-937122477375-20260805`。screenshot SHA-256 `aef1281c09e72990f06ef5a301d701efc37eaeb0f3ed5866f617e81313b22028`;hierarchy SHA-256 `3ffd7fa673e3b83e288c37eecaf65ab03480f9471814dd18019480fdeff0a41e`;captured_at `2026-08-05T09:35:40.312763+00:00`。项目所有者在运行前已按要求声明已选“黑色CHA(纯棉)”与“M(建议100-115)”、价格 12.88 并保持面板打开。
|
||||
|
||||
最小兼容边界:只新增上述第二个 exact color bounds 的完整 TARGETS_SELECTED spec;文字、selected、价格、摘要、尺码和其余结构继续全部精确匹配,任意相邻/未知 bounds 仍在 Back 前零动作失败。真机只读双 precheck 已通过;尚未再次发送 Back。
|
||||
|
||||
### 2026-08-05T09:47:20Z · ila
|
||||
|
||||
2026-08-05 T-104 阶段 A 真机取证完成并通过项目所有者人工复核。
|
||||
|
||||
最终证据目录:`%LOCALAPPDATA%\cmbuyer\artifacts\T-104\sku-exit-phase-a-937122477375-20260805-final-v3`;captured_at `2026-08-05T09:44:33.891598+00:00`;Wi-Fi;PKG110;Android 16;PDD 8.17.0;goods_id `937122477375`。manifest 明确 `back_attempts=1`、`rpc_outcome=completed`、`post_exit_status=human_review_required`,不含 safe_exit 或 Gate1 资格。
|
||||
|
||||
原子产物:post_exit_screenshot.png SHA-256 `2b9d931d8dfa7325629f53a996d65dc2e95bfa185ddeae174110fa1237221e2e`;post_exit_hierarchy.xml SHA-256 `d57e835e9d4b1b7ac1b96d6178ed5d9de09f53b2b64ec2ef76740980fc9243bc`;post_exit_app.json SHA-256 `b86205c3a7a865ce14eb57e4776184fcf7cc8aefe6d5f7442839be6218f6abf5`。
|
||||
|
||||
生产 capturer 外仅包了一层只记录方法名的诊断代理,调用序列证明:两次前置核验 → 唯一一次 pressKey → 前台/版本 → screenshot → XML → 前台/版本;没有 intent、页面 click、规格、数量、确认、提交或支付动作,也没有第二次 Back。
|
||||
|
||||
项目所有者完成三项人工确认:①手机与截图均为 goods_id 937122477375 的同一商品详情;②规格面板已关闭,未进入确认、提交或支付页面;③未创建订单。截图/XML 由同一 staging 原子发布且前后 package/activity/version 稳定。
|
||||
|
||||
阶段 A 达标。任务保持 DOING,下一步只从上述本项目真实 post-exit XML 提取无个人信息/支付凭据的最小 `product_exit_8_17_0.xml`,再实现版本绑定、同商品正锚、连续两帧稳定的阶段 B 判据。
|
||||
|
||||
### 2026-08-05T09:56:55Z · ila
|
||||
|
||||
2026-08-05 T-104 阶段 B 完成,代码 commit `1bb5492` 已推送 main。
|
||||
|
||||
从已人工确认 raw XML 提取最小 `client/tests/pdd/fixtures/product_exit_8_17_0.xml`:同商品正锚为唯一 exact `tv_title` 容器、完整标题 content-desc、两段直接只读文本及固定属性;同时要求已取证底部规格入口完整投影唯一。安全退出不解析或返回商品页价格,规格入口中的金额只保持既有身份常量,Gate1 仍只来自规格面板原始截图。
|
||||
|
||||
`exit_sku_panel_safely()` 现在每帧重查 PDD 8.17.0/前台包,要求同商品投影连续两次一致;单次命中后发生漂移会清空稳定计数。空 hierarchy、面板仍在、SystemUI、锁屏、非 PDD overlay、PDD 其他页、正锚缺失/重复/属性漂移、版本/前台漂移与合成危险动作语义全部失败关闭,所有路径 Back≤1。
|
||||
|
||||
runner 只有在精确规格、Gate1 读价、原始规格 PNG、严格同商品退出和完整动作审计全部成功后才发布 `safe_exit=completed`;`SkuSelectionRunResult` 明确返回原始规格 `screenshot_path` 与截图 RPC 成功附近的 UTC-aware `captured_at`,manifest 使用同一时间。post-exit 图不进入 runner 结果,也无 Gate1 资格。
|
||||
|
||||
验证:真实 post-exit raw 与最小 fixture 均被新投影命中;focused 76/76、client full 298/298、compileall、validate_agent_context、git diff --check、完整 init.ps1 全部通过。静态边界继续证明 T-104 不可达数量、确认、授权、提交围栏、submit_order_once 或支付能力。
|
||||
|
||||
结合阶段 A 的最终 manifest 与项目所有者三项人工确认,T-104 全部验收要点已满足,可转 DONE。
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
+67
-11
@@ -3,15 +3,25 @@ id: T-105
|
||||
title: 数量设置、读回与闸门二
|
||||
phase: 1
|
||||
deps: [T-104]
|
||||
status: TODO
|
||||
status: DONE
|
||||
created: 2026-08-04
|
||||
vikunja_task_id: 40
|
||||
context_ref: 8c50e15
|
||||
context_ref: 67b4278
|
||||
work_branch: task/t-105-quantity-gate2
|
||||
needs_device: true
|
||||
needs_human_review: true
|
||||
write_paths:
|
||||
- docs/tasks/T-105.md
|
||||
- docs/02-requirements.md
|
||||
- docs/04-architecture.md
|
||||
- docs/05-coding-rules.md
|
||||
- docs/api.md
|
||||
- docs/06-tasks.md
|
||||
- docs/07-user-stories.md
|
||||
- docs/08-interaction-checklist.md
|
||||
- docs/routes.md
|
||||
- docs/design/desk-execution.html
|
||||
- docs/design/web-task-detail.html
|
||||
- client/src/cmbuyer_client/pdd/quantity_gate2_spike.py
|
||||
- client/src/cmbuyer_client/pdd/quantity_gate2.py
|
||||
- client/src/cmbuyer_client/pdd/quantity_gate2_runner.py
|
||||
@@ -23,7 +33,7 @@ write_paths:
|
||||
- docs/current-state.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=40 synced=2026-08-04T13:59:13Z sha256=86e0079c5db57033818f75763d3e9012764e11f805a1ab12cafbe58a8ba9a526 -->
|
||||
<!-- BEGIN VIKUNJA EXPORT id=40 synced=2026-08-06T01:53:44Z sha256=fcc45a75560c145044787020f8e994751c203763df3a4a46d0cdb765958a71f2 -->
|
||||
## 问题 / 背景
|
||||
|
||||
T-104 将把规格选择、闸门一读价、原始截图和安全退出固化为受控能力。T-105 先对当前真机数量控件和读回位置取证,再实现规格面板内的数量设置、精确读回与闸门二;不进入确认页、不接触最终提交。
|
||||
@@ -42,8 +52,8 @@ T-104 将把规格选择、闸门一读价、原始截图和安全退出固化
|
||||
2. 阶段二从本项目证据提取最小 fixture,固化数量显示、增减控件、选中规格和价格节点的精确唯一结构;版本/包/面板/规格/控件任一失配都零点击。
|
||||
3. QuantityGate2Flow 接受 T-104 Gate1Observation、目标数量和最高总价。数量必须为服务端任务正整数,不接受浮点、字符串猜测或超上限值。
|
||||
4. 每次数量修改只点击证据证明的唯一控件一次,并等待明确后置条件;超时或结果不明不得重试该次点击。达到目标前每一步都重新定位和读回,不使用裸坐标或缓存节点。
|
||||
5. 闸门二重新读取颜色、尺码、数量和规格面板单价;颜色/尺码必须等于目标,数量精确等于请求,gate2 单价逐字规范化后等于 gate1 单价,并用十进制运算确认单价×数量不超最高总价。
|
||||
6. 成功结果是不可变 Gate2Observation,包含请求/实际规格、quantity_read、gate1/gate2_unit_price、max_total_price、规格面板原始 screenshot_path 与 UTC captured_at。
|
||||
5. 闸门二重新读取颜色、尺码、数量和规格面板目标数量总额;颜色/尺码必须等于目标,数量精确等于请求,`gate2_panel_total_price` 必须唯一可读且以十进制比较不超最高总价。2026-08-06 真机证明多件优惠非线性,数量 1 为 12.88、数量 2 为 32.76,因此不得把 gate1 金额乘数量、不得要求 gate2 等于 gate1,也不得用底部“提交订单”金额作为候选或兜底。后续闸门三必须与该 Gate2 面板总额严格相等并再次不超上限,否则转人工。
|
||||
6. 成功结果是不可变 Gate2Observation,包含请求/实际规格、quantity_read、gate1_unit_price、gate2_panel_total_price、max_total_price、规格面板原始 screenshot_path 与 UTC captured_at;旧 `gate2_unit_price` 语义已被真机证据否定,后续服务端契约与存储必须在 T-210/T-205 前改名迁移,禁止以旧字段名承载总额。
|
||||
7. 失败、价格不可读/多候选、规格漂移、数量不符或超价都安全停止并使用 T-104 一次性安全退出;Back 结果不明不重试。
|
||||
8. 能力与 T-103/T-104 分层:T-103 不能反向导入本任务;本任务不暴露确认页导航、通用 click、最终提交控件、围栏或付款能力。
|
||||
|
||||
@@ -51,9 +61,9 @@ T-104 将把规格选择、闸门一读价、原始截图和安全退出固化
|
||||
|
||||
- 先证据后实现,执行记录包含两态截图/XML/hash、版本、设备、goods_id 与人工确认。
|
||||
- 离线覆盖数量边界、每步单击一次、后置条件、超时不重试、节点缺失/重复、SystemUI/锁屏/版本漂移。
|
||||
- 覆盖闸门二规格不变、价格严格相等、Decimal 总价、数量读回及所有失败分支零确认页动作。
|
||||
- 覆盖闸门二规格不变、数量读回、Gate2 面板总额唯一可读、Decimal 上限比较、非线性优惠反例,以及节点缺失/重复/漂移和所有失败分支零确认页动作;静态禁止把 Gate1 乘数量、底部“提交订单”金额或旧 `gate2_unit_price` 当作 Gate2 事实。
|
||||
- 静态证明 T-103 不导入数量,本任务不可达确认页、围栏、submit_order_once 或付款。
|
||||
- 真机人工确认目标数量、规格、两次价格、截图、一次安全退出,且未进入确认/提交/支付页、未创建订单。
|
||||
- 真机人工确认目标数量、规格、Gate1 单价与 Gate2 面板总额、截图、一次安全退出,且未进入确认/提交/支付页、未创建订单。
|
||||
- client 全量 unittest、compileall、完整 init、上下文校验、Vikunja 导出与 diff-check 通过;agent 保持 DOING 等人工验收。
|
||||
|
||||
## 执行记录
|
||||
@@ -61,6 +71,47 @@ T-104 将把规格选择、闸门一读价、原始截图和安全退出固化
|
||||
### 2026-08-04T13:58:34Z · ila
|
||||
|
||||
2026-08-04 预研定值:T-105 依赖 T-104 的一次安全退出,严格分“数量两态只读取证”和“人工确认后实现”两个门禁;闸门二输出显式 screenshot_path/captured_at,且不可达确认页、提交或付款。
|
||||
|
||||
### 2026-08-05T09:58:46Z · ila
|
||||
|
||||
2026-08-05 T-104 已在 git/线上完成,T-105 依赖解除,现由 root 领取;领取基线 `67b4278`,沿用工作路径 `task/t-105-quantity-gate2`。
|
||||
|
||||
严格按两阶段门禁:
|
||||
1. 先实现只读 `quantity_gate2_spike.py` 与 CLI,只接受显式 serial、goods_id、human-declared state/quantity 和全新 output-dir;核验 PKG110/Android16/PDD8.17.0/PDD 前台/1080x2376 后,仅采 screenshot/XML/App/设备摘要并原子发布。不得 intent、页面 click、坐标、数量修改、Back、确认、提交或支付。
|
||||
2. 项目所有者分别把目标商品规格面板停在黑色CHA(纯棉)+M(建议100-115)+数量1,以及相同规格+数量2;各运行一次只读取证并人工确认控件、读回、价格、规格和页面对应性。
|
||||
3. 只有两态人验后才从本项目 raw 提取最小 fixture,实现逐步单击一次、每步 fresh 定位/readback、Gate2 Decimal 总价和 T-104 一次性安全退出。任一失败不进入确认页。
|
||||
|
||||
写路径严格限于 T-105 frontmatter;共享 current-state 由 root 合入。needs_device/needs_human_review 保持,未完成人工验收前不得 DONE。
|
||||
|
||||
### 2026-08-05T10:08:24Z · ila
|
||||
|
||||
2026-08-05 T-105 阶段一只读取证代码完成并推送,代码 commit `994b6c8`。新增 `quantity_gate2_spike.py` 与 CLI:只接受显式 serial、固定 goods_id 937122477375、人工声明 initial=1 / target=2 和全新目录;每次重新核验 PKG110 / Android 16 / PDD 8.17.0 / PDD 前台 / 1080×2376,只调用 `takeScreenshot` 与 `dumpWindowHierarchy`,保存 screenshot/XML/App 摘要/manifest,前后台或版本漂移不发布。接口与静态测试证明无 intent、click、swipe、Back、数量修改、确认、提交或支付能力。专项 13/13、客户端全量 311/311、compileall、agent-context、完整 `init.ps1` 均通过。下一步必须由人分别准备同一商品、黑色CHA(纯棉)+M(建议100-115) 的数量 1 和数量 2 两态并运行只读取证;两态人工确认前不实现数量点击、readback 或 Gate2 判据。任务保持 DOING。
|
||||
|
||||
### 2026-08-06T01:00:27Z · ila
|
||||
|
||||
2026-08-06 T-105 阶段一两态真机证据与四项人工确认完成。设备 PKG110 / Android16 / WiFi,PDD 8.17.0,goods_id 937122477375,目标规格均为黑色CHA(纯棉)+M(建议100-115)。
|
||||
|
||||
初态目录:`C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-105\quantity-initial-937122477375-20260806-v3`;captured_at `2026-08-06T00:53:00.023935+00:00`;screenshot SHA-256 `959c62f90d05a4d2a8e212b0286d7ad7e958e00e56915ddf733ce7fd9b11f9c0`;XML SHA-256 `f31d15ef0763d9685448aba0086b6f67b459e4991b5d01a5daae333dd3b9c16d`。人工确认数量1、目标规格、金额12.88。
|
||||
|
||||
目标态目录:`C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-105\quantity-target-937122477375-20260806`;captured_at `2026-08-06T00:55:30.898374+00:00`;screenshot SHA-256 `1954d9f8dbbc80b73ef5b93ae941c0501baa3f35c3f040e0cd4ef9723eb40057`;XML SHA-256 `e6ffcdc71119b8e0ac35e135afac54517f9c28c4d9da1070168901dce1988b81`。人工确认数量2、目标规格不变、金额32.76。
|
||||
|
||||
人工四项确认:两态数量分别1/2;颜色尺码均未变化;金额分别12.88/32.76;未进入确认/支付页、未点击“提交订单”、未创建订单。
|
||||
|
||||
XML 允许字段摘要:数量节点为唯一 clickable/enabled EditText,`1 [480,752][561,827]` → `2 [480,750][561,825]`;唯一加号为 clickable/enabled ImageView、content-desc 精确“增加数量”、初态 bounds `[567,752][645,827]`、目标态 `[567,750][645,825]`;顶部价格角色 `快卖完 ¥12.88` → `快卖完 ¥32.76`;底部 `提交订单 ¥...` 继续排除且禁止点击。真机事实证明多件优惠非线性,Gate2 必须读取目标数量面板总额并与最高总价比较,不能继续用旧 gate2_unit_price 等值假设。任务进入阶段二,仍保持 DOING。
|
||||
|
||||
### 2026-08-06T01:04:25Z · ila
|
||||
|
||||
2026-08-06 T-105 初态首次只读取证阻塞取证:人工已确认规格面板为目标颜色/尺码/数量1,并关闭 OPPO 游戏助手视觉层;`dumpsys window` 的 mCurrentFocus/mFocusedApp 与 `dumpsys activity activities` 的唯一 topResumedActivity 均为 `com.xunmeng.pinduoduo/.activity.NewPageActivity`,PDD versionName=8.17.0、PKG110、Android16、1080×2376 均正确,但 uiautomator2 `app_current()` 误报 `com.oplus.games`。根因是当前 adbutils 2.12.0 先执行 `dumpsys window windows`(本机 Android16 无对应焦点行),随后只识别旧式 `mResumedActivity:`,不识别实际 `topResumedActivity=`,最终错误退回 `dumpsys activity top` 的最后一项。
|
||||
|
||||
最小修复计划:不放宽“PDD 必须前台”,也不接受 `com.oplus.games`。T-105 阶段一改用仅限 PKG110/Android16 的窄只读 foreground reader,执行固定的 adb 参数数组:指定 serial、shell、dumpsys activity activities;只接受恰好一个 `topResumedActivity` 且 package 精确为 PDD、activity 非空;缺失/重复/其他包/命令失败全部不截图、不发布。通用 shell 不暴露给 capturer,既有只读设备协议反而移除错误的 `app_current`。离线补齐真实 Android16 行、旧格式拒绝、重复/缺失/其他包/命令失败、前后漂移测试;验证后再重跑数量1初态。任务仍 DOING。
|
||||
|
||||
### 2026-08-06T01:28:33Z · ila
|
||||
|
||||
2026-08-06 T-105 阶段二离线实现已完成并推送,代码 commit `21628a4`。从本项目两态证据提取最小 fixture,生产 Flow 只允许数量 1 零点击或数量 1→2 的唯一加号一次点击;每次 fresh 核验 PKG110 / Android 16 / PDD 8.17.0 / PDD 前台 / 1080×2376、目标颜色尺码、数量读回与顶部面板总额。数量 2 的 Gate2 事实为 `gate2_panel_total_price=32.76`,直接以 Decimal 比较 `max_total_price`;未使用 Gate1×数量推导 Gate2,底部“提交订单”金额不是候选,旧 gate2_unit_price 未进入生产代码。动作 RPC 前封存,超时/结果不明不重试;只有已知精确状态才执行 T-104 一次 Back,同商品详情需连续两帧稳定。模块不可达确认页、围栏、提交或付款。专项 32/32、客户端全量 330/330、compileall、agent-context、diff-check 与完整 init.ps1 均通过。下一步仅需项目所有者把目标面板手工恢复到数量1后运行生产验收;人工确认数量2、目标规格、12.88/32.76、Gate2 原图、一次安全退出,以及未进入确认/提交/支付页、未创建订单。任务保持 DOING。
|
||||
|
||||
### 2026-08-06T01:52:14Z · ila
|
||||
|
||||
2026-08-06 T-105 最终生产真机验收完成。证据目录:`C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-105\quantity-gate2-live-937122477375-20260806`;manifest:同目录 `manifest.json`;captured_at `2026-08-06T01:33:33.818765+00:00`;Gate2 screenshot SHA-256 `6b52c73f933934d270f0c2f5c9e37c67f7fba443ce03340807a764f80a2bf299`。manifest 固定事实:PKG110 / Android 16 / WiFi / PDD 8.17.0 / goods_id 937122477375;数量请求与读回均为2;黑色CHA(纯棉)+M(建议100-115);Gate1 单价12.88;Gate2 面板总额32.76;最高总价40.00;加号1次且 completed;Back 1次且 completed;safe_exit=completed。项目所有者随后明确完成四项人工确认:Gate2 截图显示数量2、目标规格与顶部32.76;Back 后回到同一商品详情且面板关闭;未进入确认/提交/支付页;未创建订单且无异常操作。T-105 全部验收要点满足,达到条件即停止,不追加真机测试。
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
@@ -68,15 +119,20 @@ T-104 将把规格选择、闸门一读价、原始截图和安全退出固化
|
||||
- 必须先由人在拼多多 8.17.0 的目标规格面板分别准备初始数量与目标数量状态,只读采集并人工确认
|
||||
screenshot/XML/版本/goods_id/规格/数量的对应性,之后才允许从本项目证据编写数量判据和点击代码。
|
||||
不得从前序项目、旧 XML、控件常识、OCR、裸坐标或推理直接实现。
|
||||
- 数量控件、读回位置、规格选中态和两次价格都必须证据绑定且精确唯一。每次修改数量最多点击已批准
|
||||
- 数量控件、读回位置、规格选中态、Gate1 单价和 Gate2 面板总额都必须证据绑定且精确唯一。每次修改数量最多点击已批准
|
||||
控件一次并等待明确后置条件;超时或结果不明不得重试同一次点击,不得缓存节点跨状态继续操作。
|
||||
- 闸门二必须重新核对颜色/尺码未漂移、数量精确等于任务、gate2 单价严格等于 gate1 单价,并以
|
||||
十进制运算验证单价乘数量不超最高总价。价格只从规格面板读取,不用详情页或其他数字兜底。
|
||||
- 闸门二必须重新核对颜色/尺码未漂移、数量精确等于任务,并从证据绑定的规格面板顶部当前金额
|
||||
角色唯一读取 `gate2_panel_total_price`,以十进制直接验证其不超最高总价。2026-08-06 真机已证明
|
||||
多件优惠非线性:数量 1 为 `12.88`、数量 2 为 `32.76`;不得用 gate1 金额乘数量猜总额、不得要求
|
||||
gate2 等于 gate1,也不得把底部“提交订单”金额作为候选或兜底。后续闸门三必须与 Gate2 面板总额
|
||||
严格相等且再次不超上限;任一不符即转人工。
|
||||
- 只有规格、数量、价格、总价上限和原始截图全部通过后才返回不可变 Gate2Observation;它必须显式
|
||||
携带 `screenshot_path` 与 UTC `captured_at`,不得让后续任务枚举目录或读取 manifest 猜证据。
|
||||
- 任一判据失败、规格漂移、数量不符、价格不可读/多候选/变化或超价都停止前进,只能调用 T-104
|
||||
已验证的一次性安全退出;Back 超时或结果不明不重试,不把“面板消失”当作退出成功。
|
||||
- T-103/T-104 不得反向导入本任务。本任务不进入确认页、不观察或获取最终提交控件,不实现通用 click、
|
||||
事件、证据上传、围栏或结果接口;不编写或引用点击“提交订单”的代码,不编写任何付款能力。
|
||||
- `needs_device: true`:agent 完成离线实现后仍保持 DOING。只有人确认真机数量、规格、两次价格、
|
||||
- 旧 `gate2_unit_price` 字段名已被本项目真机证据否定,不能用旧名字承载总额来掩盖语义变化;API 与
|
||||
服务端存储必须在 T-210/T-205 消费 Gate2 前迁移为 `gate2_panel_total_price`,迁移未完成时不得建立围栏。
|
||||
- `needs_device: true`:agent 完成离线实现后仍保持 DOING。只有人确认真机数量、规格、Gate1 单价与 Gate2 面板总额、
|
||||
截图和安全退出,且未进入确认/提交/支付页、未创建订单,才可完成任务。
|
||||
|
||||
+85
-27
@@ -1,52 +1,72 @@
|
||||
---
|
||||
id: T-106
|
||||
title: 确认页、闸门三与返回路径真机取证
|
||||
title: 合并式最终提交面板、闸门三与返回路径真机取证
|
||||
phase: 1
|
||||
deps: [T-105]
|
||||
status: TODO
|
||||
status: DONE
|
||||
created: 2026-08-04
|
||||
vikunja_task_id: 41
|
||||
context_ref: 0144054
|
||||
context_ref: db2b781
|
||||
work_branch: task/t-106-confirm-evidence
|
||||
needs_device: true
|
||||
needs_human_review: true
|
||||
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-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/tests/pdd/test_order_confirm_spike.py
|
||||
- client/scripts/capture_order_confirm_spike.py
|
||||
- docs/current-state.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=41 synced=2026-08-04T14:02:41Z sha256=1b7fda4d3b4951f20504d45b88676fda9271f1aaeaacb67b0674e4a6357ca3cc -->
|
||||
<!-- 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。
|
||||
- 用户故事:US-004、US-005。
|
||||
- 依赖: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 修改。
|
||||
2. 人先手工完成已取证规格/数量并进入确认页,停在规格、数量、应付总额及最终“提交订单”控件可见的安全状态;绝不能点击最终提交控件。
|
||||
3. 只读采集确认页主体与最终控件状态,记录准确规格、数量、应付总额、控件精确文案/匹配数/启用态;这些只是人工观察,不转成生产 selector。
|
||||
4. 随后由人只按一次返回并停手,再用同一只读取证 CLI 采集返回后的页面。Back 结果不明不再次按;记录页面身份与是否回到证据允许的安全页面。
|
||||
5. 三组证据都记录设备型号、Android、USB/WiFi、serial、PDD 版本、goods_id、截图/XML 路径和 SHA-256;完整 XML 仅留本机。
|
||||
6. 如果人误触最终提交、产生待付款订单、进入支付/安全验证/外部页面或页面结果不明,立即停止、不重试、不继续取证,并如实记录路径为不可用;绝不操作付款。
|
||||
7. 当前规格面板中出现“提交订单 ¥...”不能仅凭文案推断为确认页导航或最终控件;T-107 只能消费本任务由人确认的结构事实。
|
||||
1. 保留第一态原始截图/XML/App/manifest 与哈希:目标颜色/M、数量 2、面板顶部 Gate2 总额 32.76、唯一可见文本“提交订单 ¥32.76”;项目所有者确认该控件会直接创建待付款订单。
|
||||
2. 把页面模型改为“合并式最终提交面板”:Gate2 仍只读面板顶部目标数量总额;Gate3 是同一稳定面板中最终红色控件显示的金额。两者是独立 UI 位置,必须分别唯一读取、严格相等且都不超上限。
|
||||
3. Gate3 仅批准结构化精确文本“提交订单 ¥{规范十进制金额}”;不得包含/前缀猜测、OCR 或坐标兜底。它只能作为 Gate3 只读金额与最终控件身份,不得回流充当 Gate1/Gate2。
|
||||
4. 最终控件观察记录完整文本、匹配数、启用态、最近可点击祖先是否唯一;不返回 selector、坐标、节点或可点击对象。当前第一态观察到文本匹配数为 1,祖先结构留给 T-107 从最小 fixture 固化。
|
||||
5. 取消确认页导航动作。T-106/T-107 都不得点击最终红色控件;生产真实点击仍只允许在授权、服务端围栏、Gate2、Gate3、唯一控件四条件同时满足后执行一次。
|
||||
6. 人从合并式最终提交面板只按一次系统返回并停手,再运行纯只读 CLI 采集返回后状态;结果不明不得再次返回。
|
||||
7. 同步架构、API、编码规则、路线图和下游任务。旧 confirm_page_amount/confirm_amount 语义废止;T-210/T-208 消费前必须迁移为 gate3_submit_amount,迁移完成前不得建立提交围栏。
|
||||
8. 若误触最终提交、产生待付款订单、进入付款/安全验证/外部页面或结果不明,立即停止、不重试、不继续取证;绝不付款。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- 静态证明 spike/CLI 没有任何 UI 修改 API;mock 覆盖设备/版本/包/截图/XML/超时/原子发布/异常脱敏。
|
||||
- 人工确认三组截图/XML对应:确认页规格数量金额、最终控件可见态、一次返回后页面。
|
||||
- 执行记录列出准确控件文案与匹配数,但不把坐标/selector 作为结论;完整 XML、地址、手机号、支付信息不进 Git/Vikunja/日志。
|
||||
- 明确记录未点击最终提交、未创建订单、未进入支付;若相反则任务保持 DOING/受阻且不自动处置。
|
||||
- 第一态证据哈希完整,人工确认合并式面板、目标规格、数量 2、Gate2 顶部 32.76、最终文本“提交订单 ¥32.76”匹配数 1,以及点击语义为直接创建待付款订单。
|
||||
- 一次人工返回后的截图/XML/App/manifest 完整,人工确认回到同一商品安全页;返回结果不明不重试。
|
||||
- 静态证明 spike/CLI 只有 app/截图/XML 读取能力,没有任何页面修改 API;只允许合并式面板历史态与人工返回态。
|
||||
- 架构保留三道逻辑闸门:Gate3 使用同一最终面板的独立最终控件金额,不删除 Gate3、不把其金额用于 Gate1/Gate2、不放宽提交四条件。
|
||||
- client 全量 unittest、compileall、完整 init、上下文校验、Vikunja 导出与 diff-check 通过;agent 不得自行标 DONE。
|
||||
|
||||
## 执行记录
|
||||
@@ -58,25 +78,63 @@ T-105 验证规格面板数量与闸门二后,下一步会进入更高风险
|
||||
### 2026-08-04T14:02:39Z · ila
|
||||
|
||||
2026-08-04 主审补齐导航来源事实:T-106 必须采集四态——闸门二后的确认页导航前状态、确认页闸门三、最终提交控件可见态、人工一次返回后状态;人记录进入确认页时实际单击的精确控件/容器与匹配数,agent 仍只读取证。缺少来源态时 T-107 不得从目标页反推入口。
|
||||
|
||||
### 2026-08-06T02:12:17Z · ila
|
||||
|
||||
2026-08-06T02:12:16.724Z · Codex
|
||||
已领取 T-106。基线 init.ps1 通过(client 330 tests);按任务边界只实现四态纯只读取证,禁止 click/press/back/swipe/input/intent,代码完成后等待人工真机准备与验收。
|
||||
|
||||
### 2026-08-06T02:20:23Z · ila
|
||||
|
||||
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、最终控件可见、一次返回后四态;绝不点击最终提交。
|
||||
|
||||
### 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 -->
|
||||
|
||||
## 边界
|
||||
|
||||
- 2026-08-06 第一态真机证据与项目所有者确认推翻了“规格面板后还有独立确认页”的前提:当前规格
|
||||
面板已经显示地址、支付方式和唯一“提交订单 ¥32.76”,点击会立刻创建待付款订单并进入付款界面。
|
||||
项目所有者随后批准“同一最终面板、两个独立金额位置”方案:Gate2 仍是顶部总额,Gate3 只读最终
|
||||
控件金额,二者必须唯一、严格相等且不超上限。原四态方案已被真机事实终止;现在只允许既有
|
||||
合并式最终面板态和一次人工 Back 后的返回态,红色控件仍绝对禁止点击,不放宽提交四条件。
|
||||
|
||||
- 本任务只实现和运行只读取证能力:设备/App 摘要、`app_current`、screenshot、`compressed=false` XML、
|
||||
hash 与原子 manifest。代码中不得出现 click、press/back、swipe、input、intent 或其他 UI 修改 API,
|
||||
不得自动进入或退出确认页,也不得从前序项目、旧 XML 或推理写生产判据。
|
||||
- 确认页、最终提交控件可见态和一次返回后的页面都由人手工准备;人不得点击最终“提交订单”控件。
|
||||
一次返回结果不明时不得再次按 Back。agent 只采集已准备状态,不远程替人执行这些动作。
|
||||
- 人工验收必须明确规格、数量、应付总额、最终控件精确文案/匹配数/启用态和返回后的页面身份;
|
||||
本任务只记录真机事实,不把文案、坐标、节点或 selector 批准为生产能力。
|
||||
- 取证必须包含四个稳定状态:闸门二通过后的**确认页导航前**页面、确认页闸门三、最终提交控件
|
||||
可见态、一次返回后页面。人还需记录从第一态进入第二态时实际只点击的精确控件文本/容器及当时
|
||||
匹配数量;agent 不执行该点击。缺少来源态时,T-107 不得仅凭“已到确认页”反推导航控件。
|
||||
- 规格面板中出现的“提交订单 ¥...”不得凭文案推断为确认页导航或最终提交控件。任何确认页入口、
|
||||
金额节点、最终控件和返回后置条件都必须等 T-107 从本任务已确认的证据中最小化固化。
|
||||
- 合并式最终面板与一次返回后的页面都由人手工准备;人不得点击最终“提交订单”控件。一次返回
|
||||
结果不明时不得再次按 Back。agent 只采集已准备状态,不远程替人执行这些动作。
|
||||
- 人工验收必须明确合并式最终面板的规格、数量、Gate2/Gate3 金额、最终控件精确文案/匹配数/
|
||||
启用态,以及返回后的页面身份;本任务只记录真机事实,不把坐标、节点或 selector 批准为生产能力。
|
||||
- 取证只包含两个稳定状态:既有合并式最终面板历史态,以及人工只按一次系统 Back 后的商品详情
|
||||
安全页。不得再采集或声称存在独立确认页导航态、确认页态或额外最终控件态。
|
||||
- 合并式最终面板中的“提交订单 ¥...”已经由项目所有者确认是直接创建待付款订单的最终控件;
|
||||
T-107 只能从本任务已确认的证据中最小化固化 Gate3 只读判据和返回后置条件,不得实现点击。
|
||||
- 若人工误触最终提交、产生待付款订单、进入支付/安全验证/外部页面或结果不明,立即停止、不重试、
|
||||
不继续取证并如实记录;绝不点击支付、免密支付、先用后付或任何扣款控件。
|
||||
- 原始 screenshot/XML 只留 `%LOCALAPPDATA%\cmbuyer\artifacts\T-106\...`,不提交 Git、不上传采购服务,
|
||||
不把完整 XML、地址、手机号、支付信息、页面全文或本机路径写入普通日志/Vikunja/fixture。
|
||||
- `needs_device: true`:agent 不得自行标 DONE。只有人确认四组证据对应、一次返回安全、未点击最终提交、
|
||||
- `needs_device: true`:agent 不得自行标 DONE。只有人确认两组证据对应、一次返回安全、未点击最终提交、
|
||||
未创建订单且未进入支付相关页面,任务才可完成。
|
||||
|
||||
+58
-39
@@ -1,33 +1,32 @@
|
||||
---
|
||||
id: T-107
|
||||
title: 固化确认页、闸门三与提交控件只读判据
|
||||
title: 固化合并式最终提交面板闸门三与提交控件只读判据
|
||||
phase: 1
|
||||
deps: [T-106]
|
||||
status: TODO
|
||||
status: DONE
|
||||
created: 2026-08-04
|
||||
vikunja_task_id: 42
|
||||
context_ref: 83b244f
|
||||
context_ref: dbf9b8c
|
||||
work_branch: task/t-107-confirm-gate3
|
||||
needs_device: true
|
||||
needs_human_review: true
|
||||
write_paths:
|
||||
- docs/tasks/T-107.md
|
||||
- client/src/cmbuyer_client/pdd/order_confirm.py
|
||||
- client/src/cmbuyer_client/pdd/order_confirm_runner.py
|
||||
- client/tests/pdd/test_order_confirm.py
|
||||
- client/tests/pdd/fixtures/order_confirm_source_8_17_0.xml
|
||||
- client/tests/pdd/fixtures/order_confirm_gate3_8_17_0.xml
|
||||
- client/tests/pdd/fixtures/order_submit_control_8_17_0.xml
|
||||
- client/tests/pdd/fixtures/order_confirm_exit_8_17_0.xml
|
||||
- client/scripts/run_t107_order_confirm_dry_run.py
|
||||
- client/src/cmbuyer_client/pdd/final_submit_panel.py
|
||||
- client/src/cmbuyer_client/pdd/final_submit_panel_runner.py
|
||||
- client/tests/pdd/test_final_submit_panel.py
|
||||
- client/tests/pdd/fixtures/final_submit_panel_8_17_0.xml
|
||||
- client/tests/pdd/fixtures/final_submit_panel_shifted_8_17_0.xml
|
||||
- client/tests/pdd/fixtures/final_submit_panel_exit_8_17_0.xml
|
||||
- client/scripts/run_t107_final_submit_panel_dry_run.py
|
||||
- docs/api.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;页面版本、前台包、来源页面、导航控件或目标页面任一漂移都零点击。
|
||||
2. go_to_order_confirm 只允许点击 T-106 人工证明的导航前状态中精确唯一的已批准控件一次;点击后等待确认页精确后置条件。超时或结果不明不重试,不使用相似文案、OCR、裸坐标或其他购买按钮兜底。
|
||||
3. observe_gate3 只从确认页读取颜色、尺码、数量和应付总额;必须等于目标规格/数量且金额为规范十进制、不超最高总价。不得从详情页、规格面板或其他数字补值。
|
||||
4. 最终“提交订单”控件由纯 XML observer 只读:记录精确文本、匹配数、启用态和是否存在唯一可点击祖先,但不返回 selector、坐标、节点、祖先句柄或任何可点击对象;observer 不持有 device。
|
||||
5. Gate3Observation 包含规格、数量、确认页金额、最高总价、submit_control_text/match_count/enabled、确认页原始 screenshot_path 和 UTC captured_at。
|
||||
6. 观察完成后 exit_order_confirm_safely 只发送一次 Android Back,并稳定验证 T-106 post-back 正判据;超时/结果不明不重试,SystemUI/锁屏/其他 PDD 页/外部页都不是成功。
|
||||
7. runner 是永久围栏前 dry-run capability,不接受 allow_submit/dry_run=False 开关;composition/import 静态不可达 fence、SubmissionPermit、submit_order_once、payment。
|
||||
8. T-107 不上传证据、不定义 T-205 事件码、不申请围栏、不创建待付款订单。任何失败只停止并等待人工,不自动回 DRAFT/释放/转领。
|
||||
1. 从 T-106 合并式面板和人工一次返回证据提取不含个人信息的最小 fixture;页面版本、前台包、规格、数量、两个金额角色或结构任一漂移都零提交动作。
|
||||
2. 删除 go_to_order_confirm 导航能力。观察入口只接受已通过 Gate2 且仍停留在证据绑定合并式面板的实时状态;T-107 成功路径不点击任何页面控件。
|
||||
3. observe_gate3 分别复核目标颜色、尺码、数量、Gate2 顶部总额,并从唯一结构化文本“提交订单 ¥{金额}”读取 gate3_submit_amount;两金额必须规范、严格相等且不超最高总价。
|
||||
4. 最终控件纯 XML observer 记录完整文本、匹配数、启用态以及最近可点击祖先是否唯一;不返回 selector、坐标、节点、祖先句柄或可点击对象,observer 不持有 device。
|
||||
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_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。
|
||||
8. T-107 不上传证据、不申请围栏、不创建待付款订单。任何失败只停止并等待人工。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- fixture 正反例覆盖来源页、唯一导航控件、确认页、规格/数量/金额、最终控件 0/1/2 个、一次返回后置条件与版本漂移。
|
||||
- 动作轨迹证明成功路径仅一个已批准导航点击和一次 Back;导航结果不明与 Back 结果不明均不重试。
|
||||
- Gate3 金额用 Decimal,规格/数量/上限严格;最终控件观察 DTO 不含 selector/坐标/节点/可点击对象。
|
||||
- AST/import closure 证明没有 fence/result/SubmissionPermit/click_permitted/submit_order_once/payment,最终控件 observer 无 device/click。
|
||||
- 真机人工确认闸门三、submit match count=1、一次安全返回、未点击最终提交、未创建订单/进入支付。
|
||||
- fixture 正反例覆盖合并式面板、规格/数量、Gate2 顶部金额、Gate3 最终控件金额、0/1/2 个文本、最近可点击祖先、一次返回和版本漂移。
|
||||
- 成功轨迹证明页面控件点击数为 0,仅一次 Back;Back 结果不明不重试。
|
||||
- 金额使用 Decimal,Gate3 严格等于 Gate2且不超上限;按钮金额绝不用于 Gate1/Gate2。
|
||||
- Gate3 DTO 不含 selector/坐标/节点/可点击对象;AST/import closure 不含 fence/result/permit/submit/payment。
|
||||
- 真机人工确认两金额一致、submit match count=1、最近可点击祖先唯一、一次安全返回,未点击最终提交、未创建订单/进入付款。
|
||||
- client 全量 unittest、compileall、完整 init、上下文校验、Vikunja 导出与 diff-check 通过;agent 保持 DOING 等人工验收。
|
||||
|
||||
## 执行记录
|
||||
@@ -62,26 +61,46 @@ T-106 将形成确认页导航前、闸门三、最终提交控件可见态和
|
||||
### 2026-08-04T14:04:47Z · ila
|
||||
|
||||
2026-08-04 预研定值:T-107 只消费 T-106 四态证据;确认页导航最多一次,最终提交控件由无 device 的纯 observer 读取且 DTO 不暴露节点/坐标/selector,随后最多一次 Back 并验证返回后置条件。全路径静态不可达提交与付款。
|
||||
|
||||
### 2026-08-06T03:26:59Z · ila
|
||||
|
||||
2026-08-06 · Codex 已领取 T-107,基线 commit=dbf9b8c,独立 worktree/branch=task/t-107-confirm-gate3。完整 init.ps1 基线通过(admin test/vet/build、client 346 tests、compileall、上下文门禁)。本任务只从 T-106 两态真机证据固化 Gate3 纯 XML observer 与一次安全 Back;成功轨迹页面控件点击数必须为 0,绝不点击“提交订单”,不接触围栏、订单创建或付款能力。代码完成后保持 DOING,等待项目所有者真机人工验收。
|
||||
|
||||
### 2026-08-06T04:08:25Z · ila
|
||||
|
||||
2026-08-06 T-107 实现候选完成:基于 T-106 人工确认的拼多多 8.17.0 / goods_id 937122477375 两态原始 XML,实现纯 XML Gate3 observer、零页面控件动作 dry-run runner、成功后唯一一次 Android Back、连续两帧同商品返回正判据及本地原子证据。最终提交控件只读,未引入 fence/permit/submit/payment 能力。fixture/静态边界/金额/结构/一次返回等 36 项专项测试通过;client 全量 382 项 unittest、compileall、admin go test/vet/build、完整 init.ps1、agent-context 校验均通过;两份 T-106 原始 XML 离线复核通过。任务因 needs_device/needs_human_review 保持 DOING,等待项目所有者按任务命令确认 Gate2=Gate3=32.76、match_count=1、最近可点击祖先唯一、仅一次 Back,且未创建订单/进入付款。
|
||||
|
||||
### 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 -->
|
||||
|
||||
## 边界
|
||||
|
||||
- 任何确认页来源判据、导航控件、目标页结构、闸门三节点、最终提交控件和返回后置条件都只能来自
|
||||
T-106 已由人确认的四态真机证据,并与拼多多 8.17.0/goods_id 绑定。不得从前序项目、旧 XML、
|
||||
Activity 名、相似文案、OCR、裸坐标或推理补判据。
|
||||
- `go_to_order_confirm()` 只允许在精确来源页点击 T-106 已证明的唯一导航控件一次;超时或结果不明
|
||||
不重试,不尝试其他购买按钮,不把规格面板中任意“提交订单 ¥...”文本直接当成已批准入口。
|
||||
- 闸门三只从确认页读取颜色、尺码、数量和应付总额;必须严格等于目标规格/数量且金额为规范十进制、
|
||||
不超最高总价。不得用详情页、规格面板或其他位置数字凑齐,不得使用浮点数。
|
||||
- 最终“提交订单”控件只能由不持有 device/click 能力的纯 XML observer 只读。结果可含精确文本、
|
||||
匹配数、启用态和“唯一可点击祖先是否存在”的布尔值,但不得返回 selector、坐标、节点、祖先句柄
|
||||
或任何可点击对象;runner 不得对该控件或祖先调用 click。
|
||||
- 合并式最终提交面板、Gate2 顶部金额、Gate3 最终控件金额、控件结构和返回后置条件只能来自 T-106
|
||||
已由人确认的真机证据,以及 2026-08-06 T-107 人工五项确认的同版本精确布局证据
|
||||
`C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-107\gate3-layout-shift-937122477375-20260806\raw`;
|
||||
两者都必须与拼多多 8.17.0/goods_id 绑定并作为互斥完整配置精确命中。不得从前序项目、旧 XML、
|
||||
Activity 名、相似文案、OCR、坐标范围或推理补判据,也不得混搭两组坐标。
|
||||
- 不存在也不得重新引入 `go_to_order_confirm()` 或任何确认页导航点击。T-107 成功路径对页面控件的
|
||||
点击数必须为 0;只有观察完成后的一次 Android Back。
|
||||
- Gate2 仍只从面板顶部已批准角色读取目标数量总额;Gate3 只从最终控件的结构化精确文本
|
||||
`提交订单 ¥{规范十进制金额}` 读取。两个候选必须分别唯一、严格相等且不超最高总价;最终控件金额
|
||||
绝不能充当 Gate1/Gate2,详情页和其他数字仍全部禁止。
|
||||
- 最终控件只能由不持有 device/click 能力的纯 XML observer 只读。结果可含完整文本、匹配数、启用态
|
||||
和“最近可点击祖先是否唯一”的布尔值,但不得返回 selector、坐标、节点、祖先句柄或任何可点击对象;
|
||||
runner 不得对该控件或祖先调用 click。
|
||||
- 观察完成后只允许一次 Android Back,并必须命中 T-106 固化的稳定 post-back 正判据;超时或结果
|
||||
不明不重试。SystemUI/锁屏、其他 PDD 页面、外部支付/安全验证页或仅“确认页消失”都不是退出成功。
|
||||
不明不重试。SystemUI/锁屏、其他 PDD 页面、付款/安全验证页或仅“面板消失”都不是退出成功。
|
||||
- Runner 是永久围栏前 dry-run capability,不接受 `allow_submit`、`dry_run=False` 等开关;构造和
|
||||
import 闭包不得引用 submission fence、SubmissionPermit、`click_permitted`、`submit_order_once()`、
|
||||
结果调和、支付、免密支付、先用后付或任何扣款能力。
|
||||
- T-107 不上传证据、不定义 T-205 事件、不申请围栏、不创建待付款订单。任何失败只停止并等待人工,
|
||||
不自动释放 claim/授权、不回 DRAFT、不转领或重试页面动作。
|
||||
- `needs_device: true`:agent 不得自行标 DONE。只有人确认闸门三、最终控件 match count=1、一次安全
|
||||
返回,且未点击最终提交、未创建订单、未进入支付相关页面,任务才可完成。
|
||||
- `needs_device: true`:agent 不得自行标 DONE。只有人确认 Gate2/Gate3 两位置金额严格相等、最终控件
|
||||
match count=1、最近可点击祖先唯一、一次安全返回,且未点击最终提交、未创建订单、未进入付款页面,
|
||||
任务才可完成。
|
||||
|
||||
+9
-3
@@ -32,7 +32,7 @@ write_paths:
|
||||
- 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 只实现采购服务端围栏与调和;不连接真机、不点击提交订单、更不付款。
|
||||
@@ -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 -->
|
||||
|
||||
## 边界
|
||||
|
||||
- 2026-08-06 起 Gate3 是合并式最终提交面板唯一结构化最终控件金额;围栏请求/存储必须使用
|
||||
`gate3_submit_amount`、完整控件文本、匹配数和最近可点击祖先唯一布尔值。旧
|
||||
`confirm_page_amount` / `confirm_amount` 不得继续消费;T-210 迁移未完成时禁止建立围栏。
|
||||
|
||||
- `CREATE_ONCE` 建立围栏时必须复用 T-302 的 **current claim validator**,严格要求当前设备、session、
|
||||
task、authorization、attempt、generation、claim token、未过期 lease/authorization 与当前状态
|
||||
全部一致。不得为了让围栏或结果流程跑通而放宽、复活或绕过该 validator。
|
||||
@@ -104,4 +111,3 @@ T-205 将保存同一 attempt 的 append-only 三闸门事件和执行摘要,T
|
||||
`RECONCILIATION_REQUIRED`,绝不能回到围栏前状态。
|
||||
- 本任务只实现服务端围栏、一次性结果和同一 submission 调和;不修改 client/PDD 页面判据,不连接
|
||||
真机,不实现 `submit_order_once()`,不点击“提交订单”,不编写支付、免密支付、先用后付或扣款能力。
|
||||
|
||||
|
||||
+10
-1
@@ -22,7 +22,7 @@ write_paths:
|
||||
- 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 提供完整三闸门审计。
|
||||
@@ -61,10 +61,19 @@ T-204 当前数据库和 API 只允许 SKU_PANEL_GATE_1;T-107 将以真机证
|
||||
### 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-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 -->
|
||||
|
||||
## 边界
|
||||
|
||||
- 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`、
|
||||
`ORDER_CONFIRM_GATE_3`;不得接受自由 kind、别名、大小写变体或客户端自定义值。
|
||||
- 升级必须保留既有闸门一 asset、哈希、attempt/task/authorization/generation 归属、append-only
|
||||
|
||||
+9
-1
@@ -32,7 +32,7 @@ write_paths:
|
||||
- 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,验证领取后同一趟执行可以安全到达最终提交控件观察点,并在任何不可逆动作前停止。
|
||||
@@ -71,10 +71,18 @@ T-103~T-107 分段固化真机页面能力,T-303/T-304 提供本地恢复与
|
||||
### 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-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 -->
|
||||
|
||||
## 边界
|
||||
|
||||
- 2026-08-06 起删除确认页导航步骤:Gate2 后保持同一合并式最终提交面板,以零页面点击观察 Gate3
|
||||
最终控件金额和最近可点击祖先唯一性,然后只执行一次安全 Back。任何旧导航判据都不得实现或调用。
|
||||
|
||||
- 只实现 `PreFenceDryRunOrchestrator` 与围栏前 UI 组合;不得在本任务的构造、import 闭包或运行时
|
||||
引入 submission fence/result client、`SubmissionPermit`、`click_permitted`、submitter、
|
||||
`submit_order_once()`、`order_submission` 或任何付款能力。
|
||||
|
||||
+8
-2
@@ -16,7 +16,7 @@ write_paths:
|
||||
- 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/真机。
|
||||
@@ -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 -->
|
||||
|
||||
## 边界
|
||||
|
||||
- 2026-08-06 起离线事实使用 `gate3_submit_amount` 和结构化完整控件文本,不再使用确认页金额语义。
|
||||
Gate3 必须严格等于 Gate2,最近可点击祖先必须唯一;fixture 不表达独立确认页或导航动作。
|
||||
|
||||
- T-305 正常 dry-run 已执行一次安全 Back,并以 `DRY_RUN_COMPLETED_BEFORE_FENCE` 终止 attempt。
|
||||
本任务只能复用其已验收的代码/判据契约;不得接收或复用该次/任何 dry-run 的结果对象、attempt、
|
||||
screenshot、AssetRef、action journal、缓存、页面对象、本地快照或旧 proof 来申请围栏或提交。
|
||||
|
||||
+9
-2
@@ -22,7 +22,7 @@ write_paths:
|
||||
- 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 调和。
|
||||
@@ -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 -->
|
||||
|
||||
## 边界
|
||||
|
||||
- 2026-08-06 起生产单趟在数量/Gate2 后保持同一合并式最终提交面板,读取结构化最终控件金额作为
|
||||
Gate3;不存在确认页导航。最终点击会直接创建待付款订单并进入付款界面,仍须四条件全过、只点
|
||||
一次,点击后立即停止并调和,绝不执行付款。
|
||||
|
||||
- 本任务只把 T-107 已取证且绑定拼多多 App 版本的精确唯一最终控件、T-308 当前进程首次明确
|
||||
`SubmissionPermit` / 一次性 `ArmedSubmission` 和 T-400 离线编排接入生产。不得接收服务端
|
||||
selector/坐标/动作脚本,不得使用相似文案、OCR、通用 click、旧项目常量或未取证页面能力。
|
||||
|
||||
+8
-2
@@ -24,7 +24,7 @@ write_paths:
|
||||
- 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,不新增失败码、数据库事实或第二套调和事务。
|
||||
@@ -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 -->
|
||||
|
||||
## 边界
|
||||
|
||||
- 2026-08-06 起管理视图把 Gate3 展示为合并式最终提交面板的最终控件金额,不再称确认页金额;
|
||||
只消费 T-210 迁移后的 `gate3_submit_amount`,不读取旧 `confirm_amount` / `confirm_page_amount`。
|
||||
|
||||
- 本任务只展示 T-205/T-208 已有失败、事件、三闸门 evidence、submission 与 reconciliation 事实,
|
||||
并把管理员表单提交到 T-208 既有 reconcile handler/store;不得新增或改写 failure/reason code、
|
||||
migration、状态机、调和事务或直接数据库写入。
|
||||
|
||||
Reference in New Issue
Block a user