Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80ed9b71e8 | ||
|
|
3f2e0e5598 | ||
|
|
d27fda6be3 | ||
|
|
acf7e11114 | ||
|
|
946b064470 | ||
|
|
44c027a18f | ||
|
|
cf3692112c | ||
|
|
dbb69a7d5e | ||
|
|
3d063b9785 | ||
|
|
932ca8c7f3 | ||
|
|
9b2eb74478 | ||
|
|
17e295e99d | ||
|
|
d80e4393b4 | ||
|
|
82f57c7233 | ||
|
|
d81fc722dc | ||
|
|
abeefbbea8 | ||
|
|
b66417d80b | ||
|
|
5b56161fd7 | ||
|
|
a92b8f6be0 | ||
|
|
ec8257ba8b | ||
|
|
27999c8c85 | ||
|
|
1dc83086a0 | ||
|
|
ad55e77bda | ||
|
|
652eca7953 | ||
|
|
cb646b4974 | ||
|
|
9452debf66 | ||
|
|
4adeb1b37f | ||
|
|
2ea28c2626 | ||
|
|
7040bb61d8 | ||
|
|
e7a4be1b9b | ||
|
|
4281b06711 |
@@ -0,0 +1,75 @@
|
|||||||
|
"""打开已验证的拼多多商品直链并采集只读本地证据。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
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.product_open import ProductOpenCapturer, ProductOpenError
|
||||||
|
from cmbuyer_client.pdd.product_url import ProductUrlError, parse_product_url
|
||||||
|
|
||||||
|
|
||||||
|
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description="打开 canonical 拼多多商品链接并采集只读证据。")
|
||||||
|
parser.add_argument("--serial", required=True, help="ADB device serial;禁止自动选择。")
|
||||||
|
parser.add_argument("--url", required=True, help="唯一允许的 goods.html?goods_id= 直链。")
|
||||||
|
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 not arguments.serial.strip():
|
||||||
|
raise ValueError("必须显式提供非空 --serial。")
|
||||||
|
if arguments.timeout <= 0:
|
||||||
|
raise ValueError("--timeout 必须大于 0。")
|
||||||
|
parse_product_url(arguments.url)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
arguments = parse_arguments(argv)
|
||||||
|
try:
|
||||||
|
validate_arguments(arguments)
|
||||||
|
link = parse_product_url(arguments.url)
|
||||||
|
except (ValueError, ProductUrlError) 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
|
||||||
|
|
||||||
|
client = AdbClient(SubprocessAdbRunner(arguments.adb), timeout_seconds=arguments.timeout)
|
||||||
|
connector = NoReconnectUiautomatorConnector(
|
||||||
|
adbutils.AdbClient(socket_timeout=arguments.timeout).device_list,
|
||||||
|
u2.connect,
|
||||||
|
)
|
||||||
|
capturer = ProductOpenCapturer(client, connector, timeout_seconds=arguments.timeout)
|
||||||
|
try:
|
||||||
|
result = capturer.open_and_capture(arguments.serial, link.canonical_url, arguments.output_dir)
|
||||||
|
except (DeviceConnectionError, ProductOpenError) as error:
|
||||||
|
# 不打印 ADB 输出、serial、Activity、XML 或页面正文。
|
||||||
|
print(f"商品打开取证失败:{error}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
except OSError:
|
||||||
|
print("商品打开取证失败:无法创建或发布本地证据目录。", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(f"商品打开取证完成:{result.output_directory}")
|
||||||
|
print(f"manifest:{result.manifest_path}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""采集人工已停在规格面板的三种状态证据;不执行任何页面操作。"""
|
||||||
|
|
||||||
|
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.product_url import ProductUrl, ProductUrlError, parse_product_url
|
||||||
|
from cmbuyer_client.pdd.sku_panel_spike import (
|
||||||
|
HUMAN_DECLARED_STATES,
|
||||||
|
SkuPanelEvidenceCapturer,
|
||||||
|
SkuPanelEvidenceError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description="采集人工已打开的拼多多规格面板状态证据。")
|
||||||
|
parser.add_argument("--serial", required=True, help="ADB device serial;禁止自动选择。")
|
||||||
|
product_source = parser.add_mutually_exclusive_group(required=True)
|
||||||
|
product_source.add_argument("--url", help="唯一 canonical goods.html?goods_id= 直链。")
|
||||||
|
product_source.add_argument("--goods-id", help="纯数字商品标识;仅用于记录证据归属。")
|
||||||
|
parser.add_argument("--state", required=True, choices=sorted(HUMAN_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 resolve_product_url(arguments: argparse.Namespace) -> ProductUrl:
|
||||||
|
if isinstance(arguments.url, str):
|
||||||
|
return parse_product_url(arguments.url)
|
||||||
|
if isinstance(arguments.goods_id, str):
|
||||||
|
# 仅使用严格 parser 重新验证并构建,不把输入交给 ADB 或页面。
|
||||||
|
return parse_product_url(f"https://mobile.yangkeduo.com/goods.html?goods_id={arguments.goods_id}")
|
||||||
|
raise ValueError("必须提供 --url 或 --goods-id。")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_arguments(arguments: argparse.Namespace) -> ProductUrl:
|
||||||
|
if not isinstance(arguments.serial, str) or not arguments.serial.strip():
|
||||||
|
raise ValueError("必须显式提供非空 --serial。")
|
||||||
|
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 的有限数值。")
|
||||||
|
if arguments.state not in HUMAN_DECLARED_STATES:
|
||||||
|
raise ValueError("--state 必须是允许的人工声明状态。")
|
||||||
|
return resolve_product_url(arguments)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
arguments = parse_arguments(argv)
|
||||||
|
try:
|
||||||
|
link = validate_arguments(arguments)
|
||||||
|
except (ValueError, ProductUrlError) 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
|
||||||
|
|
||||||
|
client = AdbClient(SubprocessAdbRunner(arguments.adb), timeout_seconds=arguments.timeout)
|
||||||
|
connector = NoReconnectUiautomatorConnector(
|
||||||
|
adbutils.AdbClient(socket_timeout=arguments.timeout).device_list,
|
||||||
|
u2.connect,
|
||||||
|
)
|
||||||
|
capturer = SkuPanelEvidenceCapturer(client, connector, timeout_seconds=arguments.timeout)
|
||||||
|
try:
|
||||||
|
result = capturer.capture(
|
||||||
|
arguments.serial,
|
||||||
|
link.canonical_url,
|
||||||
|
arguments.state,
|
||||||
|
arguments.output_dir,
|
||||||
|
)
|
||||||
|
except (DeviceConnectionError, SkuPanelEvidenceError) as error:
|
||||||
|
# 不打印 ADB 输出、serial、Activity、XML 或页面正文。
|
||||||
|
print(f"规格面板证据采集失败:{error}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
except OSError:
|
||||||
|
print("规格面板证据采集失败:无法创建或发布本地证据目录。", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(f"规格面板证据采集完成:{result.output_directory}")
|
||||||
|
print(f"manifest:{result.manifest_path}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""本机脱敏 T-103 raw 证据到同级 derived;不连接设备或解析页面语义。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
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.sku_evidence_sanitizer import (
|
||||||
|
SkuEvidenceSanitizationError,
|
||||||
|
sanitize_sku_panel_evidence,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description="将本机 raw 规格面板证据确定性脱敏到同级 derived。")
|
||||||
|
parser.add_argument("--raw-dir", required=True, type=Path, help="仅允许名为 raw 的本机原始证据目录。")
|
||||||
|
parser.add_argument("--output-dir", required=True, type=Path, help="仅允许 raw 同级且名为 derived 的新目录。")
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
arguments = parse_arguments(argv)
|
||||||
|
try:
|
||||||
|
result = sanitize_sku_panel_evidence(arguments.raw_dir, arguments.output_dir)
|
||||||
|
except SkuEvidenceSanitizationError as error:
|
||||||
|
# 错误不回显 raw 路径、manifest/XML、地址、手机号或 serial。
|
||||||
|
print(f"证据脱敏失败:{error}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(f"派生证据脱敏完成:{result.output_directory}")
|
||||||
|
print(f"manifest:{result.manifest_path}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -48,6 +48,10 @@ class DuplicatePhysicalDeviceError(DeviceConnectionError):
|
|||||||
"""同一物理手机通过多个 ADB 通道同时在线。"""
|
"""同一物理手机通过多个 ADB 通道同时在线。"""
|
||||||
|
|
||||||
|
|
||||||
|
class IntentLaunchUnconfirmedError(DeviceConnectionError):
|
||||||
|
"""`am start -W` 没有给出可确认的启动成功结果。"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class CommandResult:
|
class CommandResult:
|
||||||
"""可注入命令执行器的最小、可离线构造结果。"""
|
"""可注入命令执行器的最小、可离线构造结果。"""
|
||||||
@@ -57,6 +61,14 @@ class CommandResult:
|
|||||||
returncode: int = 0
|
returncode: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class IntentLaunchSummary:
|
||||||
|
"""不含 Activity、页面内容或 ADB 输出的受限启动摘要。"""
|
||||||
|
|
||||||
|
status: str
|
||||||
|
returncode: int
|
||||||
|
|
||||||
|
|
||||||
class CommandRunner(Protocol):
|
class CommandRunner(Protocol):
|
||||||
"""运行 ADB 子命令的可替换边界。"""
|
"""运行 ADB 子命令的可替换边界。"""
|
||||||
|
|
||||||
@@ -195,6 +207,42 @@ class AdbClient:
|
|||||||
result = self._run_checked(("devices", "-l"))
|
result = self._run_checked(("devices", "-l"))
|
||||||
return parse_adb_devices(result.stdout)
|
return parse_adb_devices(result.stdout)
|
||||||
|
|
||||||
|
def start_pdd_view_intent(self, serial: str, goods_id: str) -> IntentLaunchSummary:
|
||||||
|
"""以参数数组启动唯一允许的拼多多 ACTION_VIEW Intent。
|
||||||
|
|
||||||
|
这里刻意不提供任意 shell 或任意 package 的执行接口。调用方必须先完成
|
||||||
|
``inspect`` 和应用版本核验;本方法在本层从纯数字 ``goods_id`` 重建 URL,调用方不能
|
||||||
|
把另一个 URL 直接交给 ADB。本方法既不点击控件,也不解析 Activity 或页面文本。
|
||||||
|
"""
|
||||||
|
|
||||||
|
selected_serial = _require_serial(serial)
|
||||||
|
if (
|
||||||
|
not isinstance(goods_id, str)
|
||||||
|
or not goods_id
|
||||||
|
or any(character < "0" or character > "9" for character in goods_id)
|
||||||
|
):
|
||||||
|
raise ValueError("goods_id 必须是纯数字")
|
||||||
|
canonical_url = f"https://mobile.yangkeduo.com/goods.html?goods_id={goods_id}"
|
||||||
|
result = self._run_checked(
|
||||||
|
(
|
||||||
|
"-s",
|
||||||
|
selected_serial,
|
||||||
|
"shell",
|
||||||
|
"am",
|
||||||
|
"start",
|
||||||
|
"-W",
|
||||||
|
"-a",
|
||||||
|
"android.intent.action.VIEW",
|
||||||
|
"-d",
|
||||||
|
canonical_url,
|
||||||
|
"-p",
|
||||||
|
"com.xunmeng.pinduoduo",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not any(line.strip() == "Status: ok" for line in result.stdout.splitlines()):
|
||||||
|
raise IntentLaunchUnconfirmedError("商品链接启动结果无法确认,已停止后续取证。")
|
||||||
|
return IntentLaunchSummary(status="ok", returncode=result.returncode)
|
||||||
|
|
||||||
def _physical_identity(self, device: AdbDevice) -> frozenset[str]:
|
def _physical_identity(self, device: AdbDevice) -> frozenset[str]:
|
||||||
serialno = self._getprop(device.serial, "ro.serialno")
|
serialno = self._getprop(device.serial, "ro.serialno")
|
||||||
boot_serialno = self._getprop(device.serial, "ro.boot.serialno")
|
boot_serialno = self._getprop(device.serial, "ro.boot.serialno")
|
||||||
|
|||||||
@@ -0,0 +1,469 @@
|
|||||||
|
"""T-103 原始规格面板证据的本机确定性隐私脱敏。
|
||||||
|
|
||||||
|
此模块只处理人工采集的本地文件:不连接设备、不理解拼多多页面,也不识别规格或价格。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from hashlib import sha256
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
from xml.etree import ElementTree
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw, UnidentifiedImageError
|
||||||
|
|
||||||
|
from ..pdd.product_url import ProductUrl, ProductUrlError, parse_product_url
|
||||||
|
from ..pdd.sku_panel_state import HUMAN_DECLARED_STATES
|
||||||
|
|
||||||
|
|
||||||
|
SANITIZER_VERSION = "t103-privacy-v3"
|
||||||
|
EXPECTED_GOODS_ID = "937122477375"
|
||||||
|
EXPECTED_PDD_VERSION = "8.17.0"
|
||||||
|
EXPECTED_DEVICE_MODEL = "PKG110"
|
||||||
|
EXPECTED_ANDROID_VERSION = "16"
|
||||||
|
EXPECTED_SCREENSHOT_WIDTH = 1080
|
||||||
|
EXPECTED_SCREENSHOT_HEIGHT = 2376
|
||||||
|
EXPECTED_XML_WIDTH = 1080
|
||||||
|
EXPECTED_XML_HEIGHT = 2376
|
||||||
|
_ARTIFACT_FILES = ("screenshot.png", "hierarchy.xml")
|
||||||
|
_SHA256_RE = re.compile(r"[0-9a-f]{64}\Z")
|
||||||
|
_BOUNDS_RE = re.compile(r"\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]\Z")
|
||||||
|
_FULL_PHONE_RE = re.compile(r"(?:\+?86)?1[3-9]\d{9}")
|
||||||
|
_MASKED_PHONE_RE = re.compile(r"1[3-9]\d\*{4}\d{4}")
|
||||||
|
_MASK_TRANSLATION = str.maketrans({"*": "*", "•": "*", "·": "*", "×": "*", "x": "*", "X": "*"})
|
||||||
|
_SEPARATOR_RE = re.compile(r"[\s\-‐‑‒–—―()()]+")
|
||||||
|
|
||||||
|
|
||||||
|
class SkuEvidenceSanitizationError(RuntimeError):
|
||||||
|
"""原始证据不能被安全地发布为派生证据。"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _CleanupStats:
|
||||||
|
"""仅记录节点数量,供派生 manifest 审计;不记录任何页面文本。"""
|
||||||
|
|
||||||
|
removed_nodes: int = 0
|
||||||
|
cleared_crossing_nodes: int = 0
|
||||||
|
retained_below_nodes: int = 0
|
||||||
|
max_right: int = 0
|
||||||
|
max_bottom: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PrivacyMaskConfig:
|
||||||
|
"""仅描述已人工确认的隐私几何区域,绝不承担页面或规格判据。"""
|
||||||
|
|
||||||
|
version: str
|
||||||
|
screenshot_width: int
|
||||||
|
screenshot_height: int
|
||||||
|
xml_width: int
|
||||||
|
xml_height: int
|
||||||
|
privacy_top: int
|
||||||
|
|
||||||
|
|
||||||
|
PRIVACY_MASK_CONFIG = PrivacyMaskConfig(
|
||||||
|
version=SANITIZER_VERSION,
|
||||||
|
screenshot_width=EXPECTED_SCREENSHOT_WIDTH,
|
||||||
|
screenshot_height=EXPECTED_SCREENSHOT_HEIGHT,
|
||||||
|
xml_width=EXPECTED_XML_WIDTH,
|
||||||
|
xml_height=EXPECTED_XML_HEIGHT,
|
||||||
|
# 主审在原始截图确认 y < 540 为收货/手机号区域;整宽遮罩优先保护隐私而非保留版面。
|
||||||
|
privacy_top=540,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SkuEvidenceSanitizationResult:
|
||||||
|
"""已经原子发布的派生证据位置。"""
|
||||||
|
|
||||||
|
output_directory: Path
|
||||||
|
manifest_path: Path
|
||||||
|
screenshot_path: Path
|
||||||
|
hierarchy_path: Path
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_sku_panel_evidence(raw_directory: Path, output_directory: Path) -> SkuEvidenceSanitizationResult:
|
||||||
|
"""校验 raw 三文件,并发布同级 ``derived`` 的脱敏副本。
|
||||||
|
|
||||||
|
目标已存在时在读取任何输入前拒绝,避免混入旧派生物或覆盖人工保留文件。
|
||||||
|
"""
|
||||||
|
|
||||||
|
raw = Path(raw_directory)
|
||||||
|
target = Path(output_directory)
|
||||||
|
_validate_directories(raw, target)
|
||||||
|
if target.exists():
|
||||||
|
raise SkuEvidenceSanitizationError("派生证据目录已存在,拒绝覆盖。")
|
||||||
|
|
||||||
|
staging: Path | None = None
|
||||||
|
try:
|
||||||
|
source_manifest_path = _required_file(raw, "manifest.json")
|
||||||
|
source_screenshot_path = _required_file(raw, "screenshot.png")
|
||||||
|
source_hierarchy_path = _required_file(raw, "hierarchy.xml")
|
||||||
|
manifest = _read_source_manifest(source_manifest_path)
|
||||||
|
link, state, source_hashes = _validate_source_manifest(manifest)
|
||||||
|
_verify_source_hashes(source_screenshot_path, source_hierarchy_path, source_hashes)
|
||||||
|
|
||||||
|
staging = raw.parent / f".derived.staging-{uuid4().hex}"
|
||||||
|
staging.mkdir()
|
||||||
|
derived_screenshot_path = staging / "screenshot.png"
|
||||||
|
_sanitize_screenshot(source_screenshot_path, derived_screenshot_path)
|
||||||
|
derived_hierarchy_path = staging / "hierarchy.xml"
|
||||||
|
cleanup_stats = _sanitize_hierarchy(source_hierarchy_path, derived_hierarchy_path)
|
||||||
|
|
||||||
|
derived_manifest_path = staging / "manifest.json"
|
||||||
|
derived_manifest_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
_derived_manifest(
|
||||||
|
manifest,
|
||||||
|
link,
|
||||||
|
state,
|
||||||
|
source_manifest_path,
|
||||||
|
source_screenshot_path,
|
||||||
|
source_hierarchy_path,
|
||||||
|
derived_screenshot_path,
|
||||||
|
derived_hierarchy_path,
|
||||||
|
cleanup_stats,
|
||||||
|
),
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
sort_keys=True,
|
||||||
|
)
|
||||||
|
+ "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
_publish_staging(staging, target)
|
||||||
|
except SkuEvidenceSanitizationError:
|
||||||
|
_clean_staging(staging)
|
||||||
|
raise
|
||||||
|
except (OSError, ValueError, ElementTree.ParseError, UnidentifiedImageError) as error:
|
||||||
|
_clean_staging(staging)
|
||||||
|
# 原始异常可能含文件路径、JSON/XML 文本或其他敏感内容,不能向 CLI/日志传播。
|
||||||
|
raise SkuEvidenceSanitizationError("原始证据无法安全脱敏,未发布任何派生产物。") from error
|
||||||
|
except Exception as error:
|
||||||
|
_clean_staging(staging)
|
||||||
|
raise SkuEvidenceSanitizationError("原始证据脱敏未完成,未发布任何派生产物。") from error
|
||||||
|
|
||||||
|
return SkuEvidenceSanitizationResult(
|
||||||
|
output_directory=target,
|
||||||
|
manifest_path=target / "manifest.json",
|
||||||
|
screenshot_path=target / "screenshot.png",
|
||||||
|
hierarchy_path=target / "hierarchy.xml",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_directories(raw: Path, target: Path) -> None:
|
||||||
|
if raw.name != "raw" or not raw.is_dir():
|
||||||
|
raise SkuEvidenceSanitizationError("原始证据目录必须是存在的 raw 目录。")
|
||||||
|
if target.name != "derived" or target.parent != raw.parent:
|
||||||
|
raise SkuEvidenceSanitizationError("派生证据目录必须是 raw 同级的 derived 目录。")
|
||||||
|
|
||||||
|
|
||||||
|
def _required_file(raw: Path, filename: str) -> Path:
|
||||||
|
candidate = raw / filename
|
||||||
|
if not candidate.is_file():
|
||||||
|
raise SkuEvidenceSanitizationError("原始证据文件集合不完整。")
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def _read_source_manifest(path: Path) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
value = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error:
|
||||||
|
raise SkuEvidenceSanitizationError("原始证据 manifest 无效。") from error
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise SkuEvidenceSanitizationError("原始证据 manifest 结构无效。")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_source_manifest(manifest: dict[str, Any]) -> tuple[ProductUrl, str, dict[str, str]]:
|
||||||
|
product = manifest.get("product")
|
||||||
|
device = manifest.get("device")
|
||||||
|
state = manifest.get("human_declared_state")
|
||||||
|
if manifest.get("schema_version") != 1 or not isinstance(product, dict) or not isinstance(device, dict):
|
||||||
|
raise SkuEvidenceSanitizationError("原始证据 manifest 缺少必要元数据。")
|
||||||
|
canonical_url = product.get("canonical_url")
|
||||||
|
goods_id = product.get("goods_id")
|
||||||
|
try:
|
||||||
|
link = parse_product_url(canonical_url)
|
||||||
|
except ProductUrlError as error:
|
||||||
|
raise SkuEvidenceSanitizationError("原始证据商品元数据不匹配。") from error
|
||||||
|
if (
|
||||||
|
link.goods_id != EXPECTED_GOODS_ID
|
||||||
|
or goods_id != EXPECTED_GOODS_ID
|
||||||
|
or device.get("model") != EXPECTED_DEVICE_MODEL
|
||||||
|
or device.get("android_version") != EXPECTED_ANDROID_VERSION
|
||||||
|
or device.get("pdd_version") != EXPECTED_PDD_VERSION
|
||||||
|
or device.get("pdd_package") != "com.xunmeng.pinduoduo"
|
||||||
|
or not isinstance(state, str)
|
||||||
|
or state not in HUMAN_DECLARED_STATES
|
||||||
|
):
|
||||||
|
raise SkuEvidenceSanitizationError("原始证据元数据与脱敏配置不匹配。")
|
||||||
|
return link, state, _artifact_hashes(manifest)
|
||||||
|
|
||||||
|
|
||||||
|
def _artifact_hashes(manifest: dict[str, Any]) -> dict[str, str]:
|
||||||
|
artifacts = manifest.get("artifacts")
|
||||||
|
if not isinstance(artifacts, list):
|
||||||
|
raise SkuEvidenceSanitizationError("原始证据 manifest 缺少文件校验信息。")
|
||||||
|
hashes: dict[str, str] = {}
|
||||||
|
for artifact in artifacts:
|
||||||
|
if not isinstance(artifact, dict):
|
||||||
|
raise SkuEvidenceSanitizationError("原始证据 manifest 文件校验信息无效。")
|
||||||
|
path = artifact.get("path")
|
||||||
|
digest = artifact.get("sha256")
|
||||||
|
if path not in _ARTIFACT_FILES or path in hashes or not isinstance(digest, str) or not _SHA256_RE.fullmatch(digest):
|
||||||
|
raise SkuEvidenceSanitizationError("原始证据 manifest 文件校验信息无效。")
|
||||||
|
hashes[path] = digest
|
||||||
|
if set(hashes) != set(_ARTIFACT_FILES):
|
||||||
|
raise SkuEvidenceSanitizationError("原始证据 manifest 文件校验信息不完整。")
|
||||||
|
return hashes
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_source_hashes(screenshot_path: Path, hierarchy_path: Path, expected: dict[str, str]) -> None:
|
||||||
|
if (
|
||||||
|
_sha256_file(screenshot_path) != expected["screenshot.png"]
|
||||||
|
or _sha256_file(hierarchy_path) != expected["hierarchy.xml"]
|
||||||
|
):
|
||||||
|
raise SkuEvidenceSanitizationError("原始证据文件校验失败。")
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_screenshot(source: Path, target: Path) -> None:
|
||||||
|
try:
|
||||||
|
with Image.open(source) as image:
|
||||||
|
image.load()
|
||||||
|
if image.format != "PNG" or image.size != (
|
||||||
|
PRIVACY_MASK_CONFIG.screenshot_width,
|
||||||
|
PRIVACY_MASK_CONFIG.screenshot_height,
|
||||||
|
):
|
||||||
|
raise SkuEvidenceSanitizationError("原始截图分辨率或格式与脱敏配置不匹配。")
|
||||||
|
sanitized = image.convert("RGBA")
|
||||||
|
except SkuEvidenceSanitizationError:
|
||||||
|
raise
|
||||||
|
except (OSError, UnidentifiedImageError) as error:
|
||||||
|
raise SkuEvidenceSanitizationError("原始截图无效。") from error
|
||||||
|
|
||||||
|
# 用不透明黑色覆盖 y < 540,保证截图与 XML 使用相同的隐私几何边界。
|
||||||
|
ImageDraw.Draw(sanitized).rectangle(
|
||||||
|
(0, 0, PRIVACY_MASK_CONFIG.screenshot_width - 1, PRIVACY_MASK_CONFIG.privacy_top - 1),
|
||||||
|
fill=(0, 0, 0, 255),
|
||||||
|
)
|
||||||
|
sanitized.save(target, format="PNG", optimize=False, compress_level=9)
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_hierarchy(source: Path, target: Path) -> _CleanupStats:
|
||||||
|
try:
|
||||||
|
root = ElementTree.parse(source).getroot()
|
||||||
|
except (OSError, ElementTree.ParseError) as error:
|
||||||
|
raise SkuEvidenceSanitizationError("原始节点树无效。") from error
|
||||||
|
if root.tag != "hierarchy":
|
||||||
|
raise SkuEvidenceSanitizationError("原始节点树结构不匹配。")
|
||||||
|
if not list(root):
|
||||||
|
raise SkuEvidenceSanitizationError("原始节点树结构不匹配。")
|
||||||
|
stats = _CleanupStats()
|
||||||
|
_clear_node_text(root)
|
||||||
|
for child in list(root):
|
||||||
|
_sanitize_node(root, child, stats)
|
||||||
|
_require_expected_xml_coordinate_space(stats)
|
||||||
|
if stats.removed_nodes < 1 or stats.retained_below_nodes < 1:
|
||||||
|
raise SkuEvidenceSanitizationError("原始节点树未满足隐私几何结构。")
|
||||||
|
if _contains_phone(root):
|
||||||
|
raise SkuEvidenceSanitizationError("派生节点树仍包含手机号,拒绝发布。")
|
||||||
|
ElementTree.ElementTree(root).write(target, encoding="utf-8", xml_declaration=True)
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_node(parent: ElementTree.Element, node: ElementTree.Element, stats: _CleanupStats) -> None:
|
||||||
|
if node.tag != "node":
|
||||||
|
raise SkuEvidenceSanitizationError("原始节点树结构不匹配。")
|
||||||
|
bounds = _parse_bounds(node.get("bounds"))
|
||||||
|
_observe_bounds(stats, bounds)
|
||||||
|
position = _vertical_position(bounds)
|
||||||
|
if position == "private":
|
||||||
|
# 私有带内的父节点不可以悄然包含下方子节点,否则会把仍需审计的下方内容一起丢失。
|
||||||
|
for descendant in node.iter("node"):
|
||||||
|
descendant_bounds = _parse_bounds(descendant.get("bounds"))
|
||||||
|
_observe_bounds(stats, descendant_bounds)
|
||||||
|
if _vertical_position(descendant_bounds) != "private":
|
||||||
|
raise SkuEvidenceSanitizationError("原始节点树 bounds 结构不匹配。")
|
||||||
|
stats.removed_nodes += sum(1 for _ in node.iter("node"))
|
||||||
|
parent.remove(node)
|
||||||
|
return
|
||||||
|
if position == "crossing":
|
||||||
|
# 全屏/跨界容器可保留其下方子节点,但自身所有属性和文本都可能含地址或手机号。
|
||||||
|
_clear_node_text(node)
|
||||||
|
stats.cleared_crossing_nodes += 1
|
||||||
|
else:
|
||||||
|
stats.retained_below_nodes += 1
|
||||||
|
for child in list(node):
|
||||||
|
_sanitize_node(node, child, stats)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_bounds(value: object) -> tuple[int, int, int, int]:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise SkuEvidenceSanitizationError("原始节点树 bounds 缺失或无效。")
|
||||||
|
match = _BOUNDS_RE.fullmatch(value)
|
||||||
|
if match is None:
|
||||||
|
raise SkuEvidenceSanitizationError("原始节点树 bounds 缺失或无效。")
|
||||||
|
left, top, right, bottom = (int(group) for group in match.groups())
|
||||||
|
if not (0 <= left < right and 0 <= top < bottom):
|
||||||
|
raise SkuEvidenceSanitizationError("原始节点树 bounds 缺失或无效。")
|
||||||
|
return left, top, right, bottom
|
||||||
|
|
||||||
|
|
||||||
|
def _observe_bounds(stats: _CleanupStats, bounds: tuple[int, int, int, int]) -> None:
|
||||||
|
_, _, right, bottom = bounds
|
||||||
|
stats.max_right = max(stats.max_right, right)
|
||||||
|
stats.max_bottom = max(stats.max_bottom, bottom)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_expected_xml_coordinate_space(stats: _CleanupStats) -> None:
|
||||||
|
if (
|
||||||
|
stats.max_right != PRIVACY_MASK_CONFIG.xml_width
|
||||||
|
or stats.max_bottom != PRIVACY_MASK_CONFIG.xml_height
|
||||||
|
):
|
||||||
|
raise SkuEvidenceSanitizationError(
|
||||||
|
f"原始节点树坐标范围不匹配(observed {stats.max_right}x{stats.max_bottom})。"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _vertical_position(bounds: tuple[int, int, int, int]) -> str:
|
||||||
|
_, top, _, bottom = bounds
|
||||||
|
if bottom <= PRIVACY_MASK_CONFIG.privacy_top:
|
||||||
|
return "private"
|
||||||
|
if top >= PRIVACY_MASK_CONFIG.privacy_top:
|
||||||
|
return "below"
|
||||||
|
return "crossing"
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_node_text(node: ElementTree.Element) -> None:
|
||||||
|
node.attrib = {"bounds": node.attrib["bounds"]} if "bounds" in node.attrib else {}
|
||||||
|
node.text = None
|
||||||
|
node.tail = None
|
||||||
|
|
||||||
|
|
||||||
|
def _contains_phone(root: ElementTree.Element) -> bool:
|
||||||
|
"""逐项与跨节点复检电话,避免分隔符、遮罩字符或节点切分绕过。"""
|
||||||
|
|
||||||
|
all_values: list[str] = []
|
||||||
|
content_values: list[str] = []
|
||||||
|
for element in root.iter():
|
||||||
|
if element.text:
|
||||||
|
all_values.append(element.text)
|
||||||
|
content_values.append(element.text)
|
||||||
|
for attribute, value in element.attrib.items():
|
||||||
|
all_values.append(value)
|
||||||
|
if attribute != "bounds":
|
||||||
|
content_values.append(value)
|
||||||
|
if element.tail:
|
||||||
|
all_values.append(element.tail)
|
||||||
|
content_values.append(element.tail)
|
||||||
|
normalized_values = [_normalize_phone_value(value) for value in all_values]
|
||||||
|
normalized_all_document = "".join(normalized_values)
|
||||||
|
normalized_document = "".join(_normalize_phone_value(value) for value in content_values)
|
||||||
|
return (
|
||||||
|
any(_matches_phone(value) for value in normalized_values)
|
||||||
|
or _matches_phone(normalized_all_document)
|
||||||
|
or _matches_phone(normalized_document)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_phone_value(value: str) -> str:
|
||||||
|
return _SEPARATOR_RE.sub("", value.translate(_MASK_TRANSLATION))
|
||||||
|
|
||||||
|
|
||||||
|
def _matches_phone(value: str) -> bool:
|
||||||
|
return _FULL_PHONE_RE.search(value) is not None or _MASKED_PHONE_RE.search(value) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _derived_manifest(
|
||||||
|
source_manifest: dict[str, Any],
|
||||||
|
link: ProductUrl,
|
||||||
|
state: str,
|
||||||
|
source_manifest_path: Path,
|
||||||
|
source_screenshot_path: Path,
|
||||||
|
source_hierarchy_path: Path,
|
||||||
|
derived_screenshot_path: Path,
|
||||||
|
derived_hierarchy_path: Path,
|
||||||
|
cleanup_stats: _CleanupStats,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
device = source_manifest["device"]
|
||||||
|
return {
|
||||||
|
"schema_version": 1,
|
||||||
|
"privacy_tier": "SANITIZED",
|
||||||
|
"sanitizer_version": PRIVACY_MASK_CONFIG.version,
|
||||||
|
"screenshot_space": {
|
||||||
|
"width": PRIVACY_MASK_CONFIG.screenshot_width,
|
||||||
|
"height": PRIVACY_MASK_CONFIG.screenshot_height,
|
||||||
|
"privacy_mask_rectangle": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
PRIVACY_MASK_CONFIG.screenshot_width,
|
||||||
|
PRIVACY_MASK_CONFIG.privacy_top,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"xml_coordinate_space": {
|
||||||
|
"width": PRIVACY_MASK_CONFIG.xml_width,
|
||||||
|
"height": PRIVACY_MASK_CONFIG.xml_height,
|
||||||
|
"privacy_mask_rectangle": [0, 0, PRIVACY_MASK_CONFIG.xml_width, PRIVACY_MASK_CONFIG.privacy_top],
|
||||||
|
"observed_max": {"right": cleanup_stats.max_right, "bottom": cleanup_stats.max_bottom},
|
||||||
|
},
|
||||||
|
"privacy_cleanup": {
|
||||||
|
"removed_nodes": cleanup_stats.removed_nodes,
|
||||||
|
"cleared_crossing_nodes": cleanup_stats.cleared_crossing_nodes,
|
||||||
|
"retained_below_nodes": cleanup_stats.retained_below_nodes,
|
||||||
|
"max_right": cleanup_stats.max_right,
|
||||||
|
"max_bottom": cleanup_stats.max_bottom,
|
||||||
|
},
|
||||||
|
"product": {"goods_id": link.goods_id},
|
||||||
|
"human_declared_state": state,
|
||||||
|
"device": {
|
||||||
|
"model": device["model"],
|
||||||
|
"android_version": device.get("android_version"),
|
||||||
|
"pdd_package": device["pdd_package"],
|
||||||
|
"pdd_version": device["pdd_version"],
|
||||||
|
},
|
||||||
|
# source hashes stay only in the local derived manifest; no raw path, serial or body is retained.
|
||||||
|
"source": {
|
||||||
|
"manifest_sha256": _sha256_file(source_manifest_path),
|
||||||
|
"artifacts": [
|
||||||
|
{"path": "screenshot.png", "sha256": _sha256_file(source_screenshot_path)},
|
||||||
|
{"path": "hierarchy.xml", "sha256": _sha256_file(source_hierarchy_path)},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"derived": {
|
||||||
|
"artifacts": [
|
||||||
|
{"path": "screenshot.png", "sha256": _sha256_file(derived_screenshot_path)},
|
||||||
|
{"path": "hierarchy.xml", "sha256": _sha256_file(derived_hierarchy_path)},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256_file(path: Path) -> str:
|
||||||
|
digest = sha256()
|
||||||
|
with path.open("rb") as source:
|
||||||
|
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_staging(staging: Path | None) -> None:
|
||||||
|
if staging is not None and staging.exists():
|
||||||
|
shutil.rmtree(staging)
|
||||||
|
|
||||||
|
|
||||||
|
def _publish_staging(staging: Path, target: Path) -> None:
|
||||||
|
"""发布前二次检查,并使用目录 rename 而不是会覆盖目标的 replace。"""
|
||||||
|
|
||||||
|
if target.exists():
|
||||||
|
raise SkuEvidenceSanitizationError("派生证据目录已存在,拒绝覆盖。")
|
||||||
|
try:
|
||||||
|
staging.rename(target)
|
||||||
|
except OSError as error:
|
||||||
|
# 竞态中新目标出现或文件系统拒绝 rename 时一律不尝试覆盖或重试。
|
||||||
|
raise SkuEvidenceSanitizationError("派生证据目录发布失败,未覆盖已有目录。") from error
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
"""拼多多链接的受限打开与只读取证。
|
||||||
|
|
||||||
|
此包不提供页面选择器、输入、滑动、下单或支付能力。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .product_open import ProductOpenCapturer, ProductOpenResult
|
||||||
|
from .product_url import ProductUrl, ProductUrlError, parse_product_url
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ProductOpenCapturer",
|
||||||
|
"ProductOpenResult",
|
||||||
|
"ProductUrl",
|
||||||
|
"ProductUrlError",
|
||||||
|
"parse_product_url",
|
||||||
|
]
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
"""安全打开 canonical 商品链接后的只读取证。"""
|
||||||
|
|
||||||
|
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 shutil
|
||||||
|
from time import monotonic, sleep
|
||||||
|
from typing import Any, Protocol
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from adbutils.errors import AdbTimeout
|
||||||
|
from uiautomator2.exceptions import HTTPTimeoutError
|
||||||
|
|
||||||
|
from ..device.adb import AdbClient, DeviceConnectionError, DeviceInspection, IntentLaunchSummary
|
||||||
|
from ..device.baseline import (
|
||||||
|
HIERARCHY_PARAMS,
|
||||||
|
PDD_PACKAGE,
|
||||||
|
SCREENSHOT_PARAMS,
|
||||||
|
_save_base64_screenshot,
|
||||||
|
_sha256_file,
|
||||||
|
_validate_hierarchy,
|
||||||
|
)
|
||||||
|
from .product_url import ProductUrl, parse_product_url
|
||||||
|
|
||||||
|
|
||||||
|
EXPECTED_PDD_VERSION = "8.17.0"
|
||||||
|
|
||||||
|
|
||||||
|
class ProductOpenError(RuntimeError):
|
||||||
|
"""商品打开或证据发布未完整完成。"""
|
||||||
|
|
||||||
|
|
||||||
|
class ProductVersionMismatchError(ProductOpenError):
|
||||||
|
"""运行时拼多多版本不是经取证允许的版本。"""
|
||||||
|
|
||||||
|
|
||||||
|
class ProductPackageMismatchError(ProductOpenError):
|
||||||
|
"""Intent 后在有限时间内未观察到拼多多前台包。"""
|
||||||
|
|
||||||
|
|
||||||
|
class ProductOpenTimeoutError(ProductOpenError):
|
||||||
|
"""商品打开后的只读取证超时。"""
|
||||||
|
|
||||||
|
|
||||||
|
class ProductScreenshotCaptureError(ProductOpenError):
|
||||||
|
"""Intent 后截图不能作为完整 PNG 证据保存。"""
|
||||||
|
|
||||||
|
|
||||||
|
class ProductHierarchyCaptureError(ProductOpenError):
|
||||||
|
"""Intent 后完整节点树不能作为有效 XML 证据保存。"""
|
||||||
|
|
||||||
|
|
||||||
|
class ProductOpenUiDevice(Protocol):
|
||||||
|
"""本任务所需的只读 uiautomator2 接口;故意没有任何 UI 操作方法。"""
|
||||||
|
|
||||||
|
def app_info(self, package_name: str) -> dict[str, Any]:
|
||||||
|
"""读取应用元数据。"""
|
||||||
|
|
||||||
|
def app_current(self) -> dict[str, Any]:
|
||||||
|
"""读取当前前台应用元数据。"""
|
||||||
|
|
||||||
|
def jsonrpc_call(self, method: str, params: Any = None, timeout: float = 10) -> Any:
|
||||||
|
"""调用只读取证所需的公开 JSON-RPC 方法。"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ProductOpenResult:
|
||||||
|
"""已原子发布的商品打开证据位置。"""
|
||||||
|
|
||||||
|
output_directory: Path
|
||||||
|
manifest_path: Path
|
||||||
|
screenshot_path: Path
|
||||||
|
hierarchy_path: Path
|
||||||
|
|
||||||
|
|
||||||
|
class ProductOpenCapturer:
|
||||||
|
"""以 fail-closed 顺序打开已重建链接,并在打开后只读留证。
|
||||||
|
|
||||||
|
本类不判断商品页、Activity、文案或控件;打开后只确认当前 package,随后采集截图与
|
||||||
|
完整节点树。任何失败都不会发布半成品证据目录。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
adb_client: AdbClient,
|
||||||
|
connector: Callable[[str], ProductOpenUiDevice],
|
||||||
|
timeout_seconds: float,
|
||||||
|
foreground_poll_interval_seconds: float = 0.2,
|
||||||
|
monotonic_clock: Callable[[], float] = monotonic,
|
||||||
|
sleep_function: Callable[[float], None] = sleep,
|
||||||
|
) -> None:
|
||||||
|
if not _is_positive_finite(timeout_seconds):
|
||||||
|
raise ValueError("timeout_seconds 必须是大于 0 的有限数值")
|
||||||
|
if not _is_positive_finite(foreground_poll_interval_seconds):
|
||||||
|
raise ValueError("foreground_poll_interval_seconds 必须是大于 0 的有限数值")
|
||||||
|
self._adb_client = adb_client
|
||||||
|
self._connector = connector
|
||||||
|
self._timeout_seconds = timeout_seconds
|
||||||
|
self._foreground_poll_interval_seconds = foreground_poll_interval_seconds
|
||||||
|
self._monotonic_clock = monotonic_clock
|
||||||
|
self._sleep_function = sleep_function
|
||||||
|
|
||||||
|
def open_and_capture(self, serial: str, product_url: str, output_directory: Path) -> ProductOpenResult:
|
||||||
|
"""完成唯一允许的 Intent 打开及其后的只读取证。"""
|
||||||
|
|
||||||
|
# 公共入口只接收原始字符串并每次重新解析,不能由调用方构造不一致的值对象伪造 manifest。
|
||||||
|
link = parse_product_url(product_url)
|
||||||
|
target = Path(output_directory)
|
||||||
|
_validate_new_target(target)
|
||||||
|
|
||||||
|
staging: Path | None = None
|
||||||
|
try:
|
||||||
|
# inspect 必须先于连接和 Intent,复用 T-101 的显式 serial、重复物理设备拒绝逻辑。
|
||||||
|
inspection = self._adb_client.inspect(serial)
|
||||||
|
device = self._connector(serial)
|
||||||
|
pdd_version = _require_expected_version(device.app_info(PDD_PACKAGE))
|
||||||
|
|
||||||
|
# 版本精确匹配是 Intent 的前置条件,失败时绝不调用 start_pdd_view_intent。
|
||||||
|
intent = self._adb_client.start_pdd_view_intent(serial, link.goods_id)
|
||||||
|
self._wait_for_pdd_foreground(device)
|
||||||
|
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
staging = target.parent / f".{target.name}.staging-{uuid4().hex}"
|
||||||
|
staging.mkdir()
|
||||||
|
screenshot_path = staging / "screenshot.png"
|
||||||
|
try:
|
||||||
|
_save_base64_screenshot(
|
||||||
|
device.jsonrpc_call("takeScreenshot", SCREENSHOT_PARAMS, timeout=self._timeout_seconds),
|
||||||
|
screenshot_path,
|
||||||
|
)
|
||||||
|
except (AdbTimeout, HTTPTimeoutError, TimeoutError):
|
||||||
|
raise
|
||||||
|
except Exception as error:
|
||||||
|
raise ProductScreenshotCaptureError("商品打开后截图取证失败,未发布任何证据产物。") from error
|
||||||
|
|
||||||
|
try:
|
||||||
|
hierarchy = device.jsonrpc_call(
|
||||||
|
"dumpWindowHierarchy",
|
||||||
|
HIERARCHY_PARAMS,
|
||||||
|
timeout=self._timeout_seconds,
|
||||||
|
)
|
||||||
|
_validate_hierarchy(hierarchy)
|
||||||
|
except (AdbTimeout, HTTPTimeoutError, TimeoutError):
|
||||||
|
raise
|
||||||
|
except Exception as error:
|
||||||
|
raise ProductHierarchyCaptureError("商品打开后节点树取证失败,未发布任何证据产物。") from error
|
||||||
|
hierarchy_path = staging / "hierarchy.xml"
|
||||||
|
hierarchy_path.write_text(hierarchy, encoding="utf-8")
|
||||||
|
|
||||||
|
manifest_path = staging / "manifest.json"
|
||||||
|
manifest_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
_manifest(inspection, serial, link, pdd_version, intent, screenshot_path, hierarchy_path),
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
sort_keys=True,
|
||||||
|
)
|
||||||
|
+ "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
os.replace(staging, target)
|
||||||
|
except (ProductOpenError, DeviceConnectionError):
|
||||||
|
_clean_staging(staging)
|
||||||
|
raise
|
||||||
|
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
|
||||||
|
_clean_staging(staging)
|
||||||
|
raise ProductOpenTimeoutError("商品打开后的只读取证超时,未发布任何证据产物。") from error
|
||||||
|
except Exception as error:
|
||||||
|
_clean_staging(staging)
|
||||||
|
# 底层异常可能含 serial、路径或远端页面内容,不能直接向 CLI 或日志传播。
|
||||||
|
raise ProductOpenError("商品打开或只读取证未完成,未发布任何证据产物。") from error
|
||||||
|
|
||||||
|
return ProductOpenResult(
|
||||||
|
output_directory=target,
|
||||||
|
manifest_path=target / "manifest.json",
|
||||||
|
screenshot_path=target / "screenshot.png",
|
||||||
|
hierarchy_path=target / "hierarchy.xml",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _wait_for_pdd_foreground(self, device: ProductOpenUiDevice) -> None:
|
||||||
|
"""只轮询当前 package,直到 deadline;Activity 和节点树均不参与本判据。"""
|
||||||
|
|
||||||
|
deadline = self._monotonic_clock() + self._timeout_seconds
|
||||||
|
while True:
|
||||||
|
if _is_pdd_foreground(device.app_current()):
|
||||||
|
return
|
||||||
|
remaining = deadline - self._monotonic_clock()
|
||||||
|
if remaining <= 0:
|
||||||
|
raise ProductPackageMismatchError(
|
||||||
|
"商品链接打开后未在限定时间内进入拼多多,已停止后续取证。"
|
||||||
|
)
|
||||||
|
# 每个失败观察后都等待正的、受 deadline 约束的时长,避免 busy-loop。
|
||||||
|
self._sleep_function(min(self._foreground_poll_interval_seconds, remaining))
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_new_target(target: Path) -> None:
|
||||||
|
if target.exists():
|
||||||
|
raise ProductOpenError("输出目录已存在;为防止混入旧证据,拒绝覆盖。")
|
||||||
|
if not target.name:
|
||||||
|
raise ProductOpenError("输出目录必须是明确的新目录。")
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_staging(staging: Path | None) -> None:
|
||||||
|
if staging is not None and staging.exists():
|
||||||
|
# staging 仅在本次调用中创建,删除前不解析或扩展任何调用方提供的路径。
|
||||||
|
shutil.rmtree(staging)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_expected_version(app_info: dict[str, Any]) -> str:
|
||||||
|
if not isinstance(app_info, dict):
|
||||||
|
raise ProductVersionMismatchError("拼多多版本与已取证版本不一致,已停止打开商品链接。")
|
||||||
|
version = app_info.get("versionName") or app_info.get("version_name")
|
||||||
|
if not isinstance(version, str) or version != EXPECTED_PDD_VERSION:
|
||||||
|
raise ProductVersionMismatchError("拼多多版本与已取证版本不一致,已停止打开商品链接。")
|
||||||
|
return version
|
||||||
|
|
||||||
|
|
||||||
|
def _is_positive_finite(value: object) -> bool:
|
||||||
|
return isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0 and isfinite(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_pdd_foreground(current: object) -> bool:
|
||||||
|
return isinstance(current, dict) and current.get("package") == PDD_PACKAGE
|
||||||
|
|
||||||
|
|
||||||
|
def _manifest(
|
||||||
|
inspection: DeviceInspection,
|
||||||
|
serial: str,
|
||||||
|
link: ProductUrl,
|
||||||
|
pdd_version: str,
|
||||||
|
intent: IntentLaunchSummary,
|
||||||
|
screenshot_path: Path,
|
||||||
|
hierarchy_path: Path,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""只写审计摘要;原始 serial、Activity、ADB 输出和页面正文均不进入 manifest。"""
|
||||||
|
|
||||||
|
return {
|
||||||
|
"schema_version": 1,
|
||||||
|
"captured_at": datetime.now(UTC).isoformat(),
|
||||||
|
"product": {"goods_id": link.goods_id, "canonical_url": link.canonical_url},
|
||||||
|
"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": pdd_version,
|
||||||
|
},
|
||||||
|
"intent": {"status": intent.status, "returncode": intent.returncode},
|
||||||
|
"current_package": PDD_PACKAGE,
|
||||||
|
"artifacts": [
|
||||||
|
{"path": screenshot_path.name, "sha256": _sha256_file(screenshot_path)},
|
||||||
|
{"path": hierarchy_path.name, "sha256": _sha256_file(hierarchy_path)},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""唯一允许交给 Android Intent 的商品链接。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from urllib.parse import parse_qsl, urlsplit
|
||||||
|
|
||||||
|
|
||||||
|
_SCHEME = "https"
|
||||||
|
_HOST = "mobile.yangkeduo.com"
|
||||||
|
_PATH = "/goods.html"
|
||||||
|
|
||||||
|
|
||||||
|
class ProductUrlError(ValueError):
|
||||||
|
"""输入不是可安全重建的 canonical 商品链接。"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ProductUrl:
|
||||||
|
"""经验证的商品标识及由它重建的 canonical URL。"""
|
||||||
|
|
||||||
|
goods_id: str
|
||||||
|
canonical_url: str
|
||||||
|
|
||||||
|
|
||||||
|
def parse_product_url(value: str) -> ProductUrl:
|
||||||
|
"""只接受一个 ASCII 数字 ``goods_id`` 的拼多多商品直链。
|
||||||
|
|
||||||
|
解析结果绝不原样透传:Intent 使用的 URL 必须从 ``goods_id`` 重新构建,以排除
|
||||||
|
短链、额外参数、userinfo、fragment 和 URL 解析器的边缘表示。
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise ProductUrlError("商品链接必须是字符串。")
|
||||||
|
try:
|
||||||
|
parsed = urlsplit(value)
|
||||||
|
port = parsed.port
|
||||||
|
query_pairs = parse_qsl(parsed.query, keep_blank_values=True, strict_parsing=True)
|
||||||
|
except ValueError as error:
|
||||||
|
raise ProductUrlError("商品链接格式无效。") from error
|
||||||
|
|
||||||
|
if (
|
||||||
|
parsed.scheme != _SCHEME
|
||||||
|
or parsed.hostname != _HOST
|
||||||
|
or parsed.username is not None
|
||||||
|
or parsed.password is not None
|
||||||
|
or port is not None
|
||||||
|
or parsed.path != _PATH
|
||||||
|
or parsed.fragment
|
||||||
|
):
|
||||||
|
raise ProductUrlError("商品链接不是允许的拼多多商品直链。")
|
||||||
|
if len(query_pairs) != 1 or query_pairs[0][0] != "goods_id":
|
||||||
|
raise ProductUrlError("商品链接必须且只能包含一个 goods_id 参数。")
|
||||||
|
|
||||||
|
goods_id = query_pairs[0][1]
|
||||||
|
if not goods_id or any(character < "0" or character > "9" for character in goods_id):
|
||||||
|
raise ProductUrlError("goods_id 必须是纯数字。")
|
||||||
|
canonical_url = f"{_SCHEME}://{_HOST}{_PATH}?goods_id={goods_id}"
|
||||||
|
if value != canonical_url:
|
||||||
|
raise ProductUrlError("商品链接必须使用唯一 canonical 表示。")
|
||||||
|
return ProductUrl(goods_id=goods_id, canonical_url=canonical_url)
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
"""人工停留在规格面板后的只读取证。
|
||||||
|
|
||||||
|
本模块不识别规格面板,不打开商品链接,也不读取价格;三种面板状态完全由现场人员声明。
|
||||||
|
"""
|
||||||
|
|
||||||
|
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 shutil
|
||||||
|
from typing import Any, Protocol
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from adbutils.errors import AdbTimeout
|
||||||
|
from uiautomator2.exceptions import HTTPTimeoutError
|
||||||
|
|
||||||
|
from ..device.adb import AdbClient, 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 ProductUrl, parse_product_url
|
||||||
|
from .sku_panel_state import HUMAN_DECLARED_STATES
|
||||||
|
|
||||||
|
|
||||||
|
class SkuPanelEvidenceError(RuntimeError):
|
||||||
|
"""人工规格面板证据无法完整发布。"""
|
||||||
|
|
||||||
|
|
||||||
|
class SkuPanelDeclaredStateError(SkuPanelEvidenceError):
|
||||||
|
"""调用方没有提供允许的人工声明状态。"""
|
||||||
|
|
||||||
|
|
||||||
|
class SkuPanelVersionMismatchError(SkuPanelEvidenceError):
|
||||||
|
"""运行时拼多多版本不是已取证版本。"""
|
||||||
|
|
||||||
|
|
||||||
|
class SkuPanelPackageMismatchError(SkuPanelEvidenceError):
|
||||||
|
"""人工声明前台不是拼多多时仍试图留证。"""
|
||||||
|
|
||||||
|
|
||||||
|
class SkuPanelEvidenceTimeoutError(SkuPanelEvidenceError):
|
||||||
|
"""只读截图或节点树取证超时。"""
|
||||||
|
|
||||||
|
|
||||||
|
class SkuPanelScreenshotError(SkuPanelEvidenceError):
|
||||||
|
"""截图不能保存为严格有效的 PNG。"""
|
||||||
|
|
||||||
|
|
||||||
|
class SkuPanelHierarchyError(SkuPanelEvidenceError):
|
||||||
|
"""节点树不能保存为严格有效的 XML。"""
|
||||||
|
|
||||||
|
|
||||||
|
class SkuPanelUiDevice(Protocol):
|
||||||
|
"""人工面板证据所需的只读接口,故意没有任何页面操作方法。"""
|
||||||
|
|
||||||
|
def app_info(self, package_name: str) -> dict[str, Any]:
|
||||||
|
"""读取应用元数据。"""
|
||||||
|
|
||||||
|
def app_current(self) -> dict[str, Any]:
|
||||||
|
"""读取当前前台应用元数据。"""
|
||||||
|
|
||||||
|
def jsonrpc_call(self, method: str, params: Any = None, timeout: float = 10) -> Any:
|
||||||
|
"""调用公开 JSON-RPC 的只读取证方法。"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SkuPanelEvidenceResult:
|
||||||
|
"""已原子发布的本地证据目录。"""
|
||||||
|
|
||||||
|
output_directory: Path
|
||||||
|
manifest_path: Path
|
||||||
|
screenshot_path: Path
|
||||||
|
hierarchy_path: Path
|
||||||
|
|
||||||
|
|
||||||
|
class SkuPanelEvidenceCapturer:
|
||||||
|
"""把人工已停留的面板状态留证,不对页面作任何自动结论。
|
||||||
|
|
||||||
|
状态字段命名为 ``human_declared_state``,防止消费者把本模块误解为自动面板/规格/价格识别。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
adb_client: AdbClient,
|
||||||
|
connector: Callable[[str], SkuPanelUiDevice],
|
||||||
|
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
|
||||||
|
|
||||||
|
def capture(
|
||||||
|
self,
|
||||||
|
serial: str,
|
||||||
|
product_url: str,
|
||||||
|
human_declared_state: str,
|
||||||
|
output_directory: Path,
|
||||||
|
) -> SkuPanelEvidenceResult:
|
||||||
|
"""采集人工已准备的状态;不会打开链接、面板或执行任何 UI 操作。"""
|
||||||
|
|
||||||
|
link = parse_product_url(product_url)
|
||||||
|
state = _validate_human_declared_state(human_declared_state)
|
||||||
|
target = Path(output_directory)
|
||||||
|
_validate_new_target(target)
|
||||||
|
|
||||||
|
staging: Path | None = None
|
||||||
|
try:
|
||||||
|
# 沿用 T-101 的显式 serial、在线状态与重复物理设备 fail-closed 核验。
|
||||||
|
inspection = self._adb_client.inspect(serial)
|
||||||
|
device = self._connector(serial)
|
||||||
|
pdd_version = _require_expected_version(device.app_info(PDD_PACKAGE))
|
||||||
|
_require_pdd_foreground(device.app_current())
|
||||||
|
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
staging = target.parent / f".{target.name}.staging-{uuid4().hex}"
|
||||||
|
staging.mkdir()
|
||||||
|
screenshot_path = staging / "screenshot.png"
|
||||||
|
try:
|
||||||
|
_save_base64_screenshot(
|
||||||
|
device.jsonrpc_call("takeScreenshot", SCREENSHOT_PARAMS, timeout=self._timeout_seconds),
|
||||||
|
screenshot_path,
|
||||||
|
)
|
||||||
|
except (AdbTimeout, HTTPTimeoutError, TimeoutError):
|
||||||
|
raise
|
||||||
|
except Exception as error:
|
||||||
|
raise SkuPanelScreenshotError("规格面板截图取证失败,未发布任何证据产物。") from error
|
||||||
|
|
||||||
|
try:
|
||||||
|
hierarchy = device.jsonrpc_call(
|
||||||
|
"dumpWindowHierarchy",
|
||||||
|
HIERARCHY_PARAMS,
|
||||||
|
timeout=self._timeout_seconds,
|
||||||
|
)
|
||||||
|
_validate_hierarchy(hierarchy)
|
||||||
|
except (AdbTimeout, HTTPTimeoutError, TimeoutError):
|
||||||
|
raise
|
||||||
|
except Exception as error:
|
||||||
|
raise SkuPanelHierarchyError("规格面板节点树取证失败,未发布任何证据产物。") from error
|
||||||
|
hierarchy_path = staging / "hierarchy.xml"
|
||||||
|
hierarchy_path.write_text(hierarchy, encoding="utf-8")
|
||||||
|
|
||||||
|
manifest_path = staging / "manifest.json"
|
||||||
|
manifest_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
_manifest(inspection, serial, link, state, pdd_version, screenshot_path, hierarchy_path),
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
sort_keys=True,
|
||||||
|
)
|
||||||
|
+ "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
os.replace(staging, target)
|
||||||
|
except (SkuPanelEvidenceError, DeviceConnectionError):
|
||||||
|
_clean_staging(staging)
|
||||||
|
raise
|
||||||
|
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
|
||||||
|
_clean_staging(staging)
|
||||||
|
raise SkuPanelEvidenceTimeoutError("规格面板只读取证超时,未发布任何证据产物。") from error
|
||||||
|
except Exception as error:
|
||||||
|
_clean_staging(staging)
|
||||||
|
# 第三方异常可能含 serial、Activity 或页面正文,不能直接向 CLI/日志传播。
|
||||||
|
raise SkuPanelEvidenceError("规格面板只读取证未完成,未发布任何证据产物。") from error
|
||||||
|
|
||||||
|
return SkuPanelEvidenceResult(
|
||||||
|
output_directory=target,
|
||||||
|
manifest_path=target / "manifest.json",
|
||||||
|
screenshot_path=target / "screenshot.png",
|
||||||
|
hierarchy_path=target / "hierarchy.xml",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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_human_declared_state(value: object) -> str:
|
||||||
|
if not isinstance(value, str) or value not in HUMAN_DECLARED_STATES:
|
||||||
|
raise SkuPanelDeclaredStateError("必须提供允许的人工声明规格面板状态。")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_new_target(target: Path) -> None:
|
||||||
|
if target.exists():
|
||||||
|
raise SkuPanelEvidenceError("输出目录已存在;为防止混入旧证据,拒绝覆盖。")
|
||||||
|
if not target.name:
|
||||||
|
raise SkuPanelEvidenceError("输出目录必须是明确的新目录。")
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_staging(staging: Path | None) -> None:
|
||||||
|
if staging is not None and staging.exists():
|
||||||
|
# staging 仅在本次调用中创建,绝不删除调用方已存在的目录。
|
||||||
|
shutil.rmtree(staging)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_expected_version(app_info: object) -> str:
|
||||||
|
if not isinstance(app_info, dict):
|
||||||
|
raise SkuPanelVersionMismatchError("拼多多版本与已取证版本不一致,已停止取证。")
|
||||||
|
version = app_info.get("versionName") or app_info.get("version_name")
|
||||||
|
if not isinstance(version, str) or version != EXPECTED_PDD_VERSION:
|
||||||
|
raise SkuPanelVersionMismatchError("拼多多版本与已取证版本不一致,已停止取证。")
|
||||||
|
return version
|
||||||
|
|
||||||
|
|
||||||
|
def _require_pdd_foreground(current: object) -> None:
|
||||||
|
if not isinstance(current, dict) or current.get("package") != PDD_PACKAGE:
|
||||||
|
raise SkuPanelPackageMismatchError("当前前台应用不是拼多多,已停止取证。")
|
||||||
|
|
||||||
|
|
||||||
|
def _manifest(
|
||||||
|
inspection: DeviceInspection,
|
||||||
|
serial: str,
|
||||||
|
link: ProductUrl,
|
||||||
|
human_declared_state: str,
|
||||||
|
pdd_version: str,
|
||||||
|
screenshot_path: Path,
|
||||||
|
hierarchy_path: Path,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""仅记录人工声明与非敏感审计摘要,不写入 Activity 或页面内容。"""
|
||||||
|
|
||||||
|
return {
|
||||||
|
"schema_version": 1,
|
||||||
|
"captured_at": datetime.now(UTC).isoformat(),
|
||||||
|
"product": {"goods_id": link.goods_id, "canonical_url": link.canonical_url},
|
||||||
|
"human_declared_state": human_declared_state,
|
||||||
|
"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": pdd_version,
|
||||||
|
},
|
||||||
|
"artifacts": [
|
||||||
|
{"path": screenshot_path.name, "sha256": _sha256_file(screenshot_path)},
|
||||||
|
{"path": hierarchy_path.name, "sha256": _sha256_file(hierarchy_path)},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
"""T-103 的人工声明证据状态;不含任何页面识别或规格语义。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
# 这些值由现场人员填写到 manifest,不能被解释为自动检测出的页面或选择状态。
|
||||||
|
HUMAN_DECLARED_STATES = frozenset(
|
||||||
|
{
|
||||||
|
"panel-opened-target-preselected",
|
||||||
|
"alternate-all-dimensions-selected",
|
||||||
|
"target-selection-restored",
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -16,11 +16,14 @@ from cmbuyer_client.device.adb import (
|
|||||||
AdbClient,
|
AdbClient,
|
||||||
CommandResult,
|
CommandResult,
|
||||||
DeviceIdentityUnconfirmedError,
|
DeviceIdentityUnconfirmedError,
|
||||||
|
DeviceCommandError,
|
||||||
|
DeviceCommandTimeoutError,
|
||||||
DeviceNotFoundError,
|
DeviceNotFoundError,
|
||||||
DeviceOfflineError,
|
DeviceOfflineError,
|
||||||
DeviceStateError,
|
DeviceStateError,
|
||||||
DeviceUnauthorizedError,
|
DeviceUnauthorizedError,
|
||||||
DuplicatePhysicalDeviceError,
|
DuplicatePhysicalDeviceError,
|
||||||
|
IntentLaunchUnconfirmedError,
|
||||||
SerialRequiredError,
|
SerialRequiredError,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -176,7 +179,95 @@ class AdbClientTests(unittest.TestCase):
|
|||||||
def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
|
def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
|
||||||
raise subprocess.TimeoutExpired(arguments, timeout_seconds)
|
raise subprocess.TimeoutExpired(arguments, timeout_seconds)
|
||||||
|
|
||||||
from cmbuyer_client.device.adb import DeviceCommandTimeoutError
|
|
||||||
|
|
||||||
with self.assertRaises(DeviceCommandTimeoutError):
|
with self.assertRaises(DeviceCommandTimeoutError):
|
||||||
AdbClient(TimeoutRunner()).inspect(USB_SERIAL)
|
AdbClient(TimeoutRunner()).inspect(USB_SERIAL)
|
||||||
|
|
||||||
|
def test_product_intent_is_fixed_to_action_view_and_pdd_package(self) -> None:
|
||||||
|
class IntentRunner:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.calls: list[tuple[str, ...]] = []
|
||||||
|
|
||||||
|
def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
|
||||||
|
self.calls.append(tuple(arguments))
|
||||||
|
return CommandResult(stdout="Status: ok\n")
|
||||||
|
|
||||||
|
runner = IntentRunner()
|
||||||
|
summary = AdbClient(runner).start_pdd_view_intent(
|
||||||
|
USB_SERIAL,
|
||||||
|
"123",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(summary.status, "ok")
|
||||||
|
self.assertEqual(
|
||||||
|
runner.calls,
|
||||||
|
[
|
||||||
|
(
|
||||||
|
"-s",
|
||||||
|
USB_SERIAL,
|
||||||
|
"shell",
|
||||||
|
"am",
|
||||||
|
"start",
|
||||||
|
"-W",
|
||||||
|
"-a",
|
||||||
|
"android.intent.action.VIEW",
|
||||||
|
"-d",
|
||||||
|
"https://mobile.yangkeduo.com/goods.html?goods_id=123",
|
||||||
|
"-p",
|
||||||
|
"com.xunmeng.pinduoduo",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_product_intent_without_explicit_success_is_rejected(self) -> None:
|
||||||
|
class UnknownIntentRunner:
|
||||||
|
def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
|
||||||
|
return CommandResult(stdout="Starting: Intent { ... }\n")
|
||||||
|
|
||||||
|
with self.assertRaises(IntentLaunchUnconfirmedError):
|
||||||
|
AdbClient(UnknownIntentRunner()).start_pdd_view_intent(
|
||||||
|
USB_SERIAL,
|
||||||
|
"123",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_product_intent_rejects_invalid_goods_id_before_runner(self) -> None:
|
||||||
|
class RecordingRunner:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.calls: list[tuple[str, ...]] = []
|
||||||
|
|
||||||
|
def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
|
||||||
|
self.calls.append(tuple(arguments))
|
||||||
|
return CommandResult(stdout="Status: ok\n")
|
||||||
|
|
||||||
|
invalid_values: tuple[object, ...] = (
|
||||||
|
"",
|
||||||
|
"12a",
|
||||||
|
"123",
|
||||||
|
" 123",
|
||||||
|
"123 ",
|
||||||
|
"https://mobile.yangkeduo.com/goods.html?goods_id=123",
|
||||||
|
"am start -W -d anything",
|
||||||
|
123,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
for value in invalid_values:
|
||||||
|
with self.subTest(value=repr(value)):
|
||||||
|
runner = RecordingRunner()
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
AdbClient(runner).start_pdd_view_intent(USB_SERIAL, value) # type: ignore[arg-type]
|
||||||
|
self.assertEqual(runner.calls, [])
|
||||||
|
|
||||||
|
def test_product_intent_nonzero_and_timeout_remain_distinct(self) -> None:
|
||||||
|
class FailedIntentRunner:
|
||||||
|
def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
|
||||||
|
return CommandResult(stdout="sensitive command output", returncode=1)
|
||||||
|
|
||||||
|
class TimeoutIntentRunner:
|
||||||
|
def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
|
||||||
|
raise subprocess.TimeoutExpired(arguments, timeout_seconds)
|
||||||
|
|
||||||
|
with self.assertRaises(DeviceCommandError) as command_error:
|
||||||
|
AdbClient(FailedIntentRunner()).start_pdd_view_intent(USB_SERIAL, "123")
|
||||||
|
self.assertNotIn("sensitive command output", str(command_error.exception))
|
||||||
|
|
||||||
|
with self.assertRaises(DeviceCommandTimeoutError):
|
||||||
|
AdbClient(TimeoutIntentRunner()).start_pdd_view_intent(USB_SERIAL, "123")
|
||||||
|
|||||||
@@ -0,0 +1,422 @@
|
|||||||
|
"""T-103 脱敏器测试:全部证据为合成数据,绝不读取真实 raw 目录。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from hashlib import sha256
|
||||||
|
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.sku_evidence_sanitizer import (
|
||||||
|
EXPECTED_GOODS_ID,
|
||||||
|
EXPECTED_SCREENSHOT_HEIGHT,
|
||||||
|
EXPECTED_SCREENSHOT_WIDTH,
|
||||||
|
EXPECTED_XML_HEIGHT,
|
||||||
|
EXPECTED_XML_WIDTH,
|
||||||
|
HUMAN_DECLARED_STATES,
|
||||||
|
SkuEvidenceSanitizationError,
|
||||||
|
sanitize_sku_panel_evidence,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
TEST_SERIAL = "synthetic-serial-never-publish"
|
||||||
|
TEST_ADDRESS = "SYNTHETIC_ADDRESS_NEVER_PUBLISH"
|
||||||
|
FULL_PHONE = "13800138000"
|
||||||
|
MASKED_PHONE = "138****0000"
|
||||||
|
SAFE_TEXT = "synthetic-safe-lower-content"
|
||||||
|
|
||||||
|
|
||||||
|
def _hash(path: Path) -> str:
|
||||||
|
digest = sha256()
|
||||||
|
with path.open("rb") as source:
|
||||||
|
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _default_xml() -> str:
|
||||||
|
return (
|
||||||
|
"<hierarchy rotation='0'>"
|
||||||
|
f"<node bounds='[0,0][1080,540]' text='{TEST_ADDRESS}' content-desc='{MASKED_PHONE} {FULL_PHONE}' />"
|
||||||
|
f"<node bounds='[0,540][1080,2376]' text='{SAFE_TEXT}' />"
|
||||||
|
"</hierarchy>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_raw(
|
||||||
|
root: Path,
|
||||||
|
*,
|
||||||
|
state: str = "panel-opened-target-preselected",
|
||||||
|
goods_id: str = EXPECTED_GOODS_ID,
|
||||||
|
model: str = "PKG110",
|
||||||
|
android_version: str = "16",
|
||||||
|
pdd_version: str = "8.17.0",
|
||||||
|
size: tuple[int, int] = (EXPECTED_SCREENSHOT_WIDTH, EXPECTED_SCREENSHOT_HEIGHT),
|
||||||
|
xml: str | None = None,
|
||||||
|
) -> Path:
|
||||||
|
raw = root / "raw"
|
||||||
|
raw.mkdir(parents=True)
|
||||||
|
screenshot = raw / "screenshot.png"
|
||||||
|
image = Image.new("RGB", size, color=(0, 180, 0))
|
||||||
|
if size == (EXPECTED_SCREENSHOT_WIDTH, EXPECTED_SCREENSHOT_HEIGHT):
|
||||||
|
for y in range(540):
|
||||||
|
for x in range(8):
|
||||||
|
image.putpixel((x, y), (255, 0, 0))
|
||||||
|
image.save(screenshot, format="PNG")
|
||||||
|
hierarchy = raw / "hierarchy.xml"
|
||||||
|
hierarchy.write_text(_default_xml() if xml is None else xml, encoding="utf-8")
|
||||||
|
manifest = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"product": {
|
||||||
|
"goods_id": goods_id,
|
||||||
|
"canonical_url": f"https://mobile.yangkeduo.com/goods.html?goods_id={goods_id}",
|
||||||
|
},
|
||||||
|
"human_declared_state": state,
|
||||||
|
"serial": TEST_SERIAL,
|
||||||
|
"channel": "wifi",
|
||||||
|
"device": {
|
||||||
|
"model": model,
|
||||||
|
"android_version": android_version,
|
||||||
|
"pdd_package": "com.xunmeng.pinduoduo",
|
||||||
|
"pdd_version": pdd_version,
|
||||||
|
},
|
||||||
|
"artifacts": [
|
||||||
|
{"path": "screenshot.png", "sha256": _hash(screenshot)},
|
||||||
|
{"path": "hierarchy.xml", "sha256": _hash(hierarchy)},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
(raw / "manifest.json").write_text(json.dumps(manifest, sort_keys=True), encoding="utf-8")
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
class SkuEvidenceSanitizerTests(unittest.TestCase):
|
||||||
|
def test_all_declared_states_mask_screenshot_and_xml_without_raw_metadata(self) -> None:
|
||||||
|
for state in sorted(HUMAN_DECLARED_STATES):
|
||||||
|
with self.subTest(state=state), TemporaryDirectory() as temporary:
|
||||||
|
raw = _write_raw(Path(temporary), state=state)
|
||||||
|
result = sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||||
|
with Image.open(result.screenshot_path) as image:
|
||||||
|
self.assertEqual(image.getpixel((0, 0)), (0, 0, 0, 255))
|
||||||
|
self.assertEqual(image.getpixel((100, 600)), (0, 180, 0, 255))
|
||||||
|
derived_xml = result.hierarchy_path.read_text(encoding="utf-8")
|
||||||
|
manifest = result.manifest_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertIn(SAFE_TEXT, derived_xml)
|
||||||
|
self.assertNotIn(TEST_ADDRESS, derived_xml)
|
||||||
|
self.assertNotIn(FULL_PHONE, derived_xml)
|
||||||
|
self.assertNotIn(MASKED_PHONE, derived_xml)
|
||||||
|
self.assertIn(f'"human_declared_state": "{state}"', manifest)
|
||||||
|
self.assertIn('"privacy_tier": "SANITIZED"', manifest)
|
||||||
|
self.assertIn('"sanitizer_version": "t103-privacy-v3"', manifest)
|
||||||
|
self.assertIn('"screenshot_space": {', manifest)
|
||||||
|
self.assertIn('"xml_coordinate_space": {', manifest)
|
||||||
|
self.assertIn('"height": 2376', manifest)
|
||||||
|
self.assertIn('"height": 2376', manifest)
|
||||||
|
self.assertIn('"privacy_mask_rectangle": [', manifest)
|
||||||
|
self.assertIn('"removed_nodes": 1', manifest)
|
||||||
|
self.assertIn('"cleared_crossing_nodes": 0', manifest)
|
||||||
|
self.assertIn('"retained_below_nodes": 1', manifest)
|
||||||
|
self.assertIn('"max_right": 1080', manifest)
|
||||||
|
self.assertIn('"max_bottom": 2376', manifest)
|
||||||
|
self.assertNotIn("canonical_url", manifest)
|
||||||
|
self.assertNotIn(TEST_SERIAL, manifest)
|
||||||
|
self.assertNotIn("serial", manifest)
|
||||||
|
self.assertNotIn("channel", manifest)
|
||||||
|
self.assertNotIn(TEST_ADDRESS, manifest)
|
||||||
|
self.assertNotIn(FULL_PHONE, manifest)
|
||||||
|
|
||||||
|
def test_crossing_container_keeps_lower_children_but_clears_its_text(self) -> None:
|
||||||
|
xml = (
|
||||||
|
"<hierarchy>"
|
||||||
|
f"<node bounds='[0,0][1080,2376]' text='{TEST_ADDRESS}' content-desc='{MASKED_PHONE}'>"
|
||||||
|
f"<node bounds='[0,0][1080,540]' text='{FULL_PHONE}' />"
|
||||||
|
f"<node bounds='[0,540][1080,2376]' text='{SAFE_TEXT}' />"
|
||||||
|
"</node></hierarchy>"
|
||||||
|
)
|
||||||
|
with TemporaryDirectory() as temporary:
|
||||||
|
raw = _write_raw(Path(temporary), xml=xml)
|
||||||
|
result = sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||||
|
root = ElementTree.parse(result.hierarchy_path).getroot()
|
||||||
|
crossing = root.find("node")
|
||||||
|
|
||||||
|
self.assertIsNotNone(crossing)
|
||||||
|
assert crossing is not None
|
||||||
|
self.assertEqual(crossing.attrib, {"bounds": "[0,0][1080,2376]"})
|
||||||
|
self.assertEqual(len(list(crossing)), 1)
|
||||||
|
self.assertEqual(list(crossing)[0].get("text"), SAFE_TEXT)
|
||||||
|
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
|
||||||
|
self.assertEqual(
|
||||||
|
manifest["privacy_cleanup"],
|
||||||
|
{
|
||||||
|
"removed_nodes": 1,
|
||||||
|
"cleared_crossing_nodes": 1,
|
||||||
|
"retained_below_nodes": 1,
|
||||||
|
"max_right": 1080,
|
||||||
|
"max_bottom": 2376,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_same_raw_and_config_produce_identical_derived_files(self) -> None:
|
||||||
|
with TemporaryDirectory() as temporary:
|
||||||
|
root = Path(temporary)
|
||||||
|
left_raw = _write_raw(root / "left")
|
||||||
|
right_raw = _write_raw(root / "right")
|
||||||
|
left = sanitize_sku_panel_evidence(left_raw, left_raw.parent / "derived")
|
||||||
|
right = sanitize_sku_panel_evidence(right_raw, right_raw.parent / "derived")
|
||||||
|
|
||||||
|
for left_path, right_path in (
|
||||||
|
(left.screenshot_path, right.screenshot_path),
|
||||||
|
(left.hierarchy_path, right.hierarchy_path),
|
||||||
|
(left.manifest_path, right.manifest_path),
|
||||||
|
):
|
||||||
|
self.assertEqual(left_path.read_bytes(), right_path.read_bytes())
|
||||||
|
|
||||||
|
def test_manifest_records_distinct_screenshot_and_xml_coordinate_spaces(self) -> None:
|
||||||
|
with TemporaryDirectory() as temporary:
|
||||||
|
raw = _write_raw(Path(temporary))
|
||||||
|
result = sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||||
|
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
manifest["screenshot_space"],
|
||||||
|
{"width": 1080, "height": 2376, "privacy_mask_rectangle": [0, 0, 1080, 540]},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
manifest["xml_coordinate_space"],
|
||||||
|
{
|
||||||
|
"width": 1080,
|
||||||
|
"height": 2376,
|
||||||
|
"privacy_mask_rectangle": [0, 0, 1080, 540],
|
||||||
|
"observed_max": {"right": 1080, "bottom": 2376},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_hash_and_metadata_mismatch_fail_closed_without_leak(self) -> None:
|
||||||
|
scenarios = (
|
||||||
|
("hash", {}, "screenshot"),
|
||||||
|
("model", {"model": "other"}, None),
|
||||||
|
("android", {"android_version": "15"}, None),
|
||||||
|
("version", {"pdd_version": "8.17.1"}, None),
|
||||||
|
("goods", {"goods_id": "123"}, None),
|
||||||
|
("state", {"state": "guessed"}, None),
|
||||||
|
("old-screenshot-space", {"size": (1080, 2400)}, None),
|
||||||
|
("other-screenshot-space", {"size": (100, 100)}, None),
|
||||||
|
)
|
||||||
|
for name, kwargs, corrupt_file in scenarios:
|
||||||
|
with self.subTest(name=name), TemporaryDirectory() as temporary:
|
||||||
|
raw = _write_raw(Path(temporary), **kwargs)
|
||||||
|
if corrupt_file is not None:
|
||||||
|
(raw / f"{corrupt_file}.png").write_bytes(b"changed")
|
||||||
|
with self.assertRaises(SkuEvidenceSanitizationError) as raised:
|
||||||
|
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||||
|
|
||||||
|
message = str(raised.exception)
|
||||||
|
self.assertNotIn(TEST_SERIAL, message)
|
||||||
|
self.assertNotIn(TEST_ADDRESS, message)
|
||||||
|
self.assertNotIn(FULL_PHONE, message)
|
||||||
|
self.assertFalse((raw.parent / "derived").exists())
|
||||||
|
self.assertEqual(list(raw.parent.glob(".derived.staging-*")), [])
|
||||||
|
|
||||||
|
def test_old_and_unknown_human_states_are_rejected(self) -> None:
|
||||||
|
for state in ("initial", "one-dimension-selected", "all-dimensions-selected", "guessed"):
|
||||||
|
with self.subTest(state=state), TemporaryDirectory() as temporary:
|
||||||
|
raw = _write_raw(Path(temporary), state=state)
|
||||||
|
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||||
|
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||||
|
self.assertFalse((raw.parent / "derived").exists())
|
||||||
|
|
||||||
|
def test_malformed_inputs_and_bounds_or_phone_residue_fail_closed(self) -> None:
|
||||||
|
malformed = (
|
||||||
|
("manifest", None),
|
||||||
|
("png", None),
|
||||||
|
("xml", None),
|
||||||
|
("bounds", "<hierarchy><node text='missing bounds' /></hierarchy>"),
|
||||||
|
(
|
||||||
|
"private-parent-with-lower-child",
|
||||||
|
"<hierarchy><node bounds='[0,0][1080,540]'><node bounds='[0,540][1080,2376]' text='x' /></node></hierarchy>",
|
||||||
|
),
|
||||||
|
("full-phone-below", f"<hierarchy><node bounds='[0,540][1080,2376]' text='{FULL_PHONE}' /></hierarchy>"),
|
||||||
|
("masked-phone-below", f"<hierarchy><node bounds='[0,540][1080,2376]' text='{MASKED_PHONE}' /></hierarchy>"),
|
||||||
|
)
|
||||||
|
for kind, xml in malformed:
|
||||||
|
with self.subTest(kind=kind), TemporaryDirectory() as temporary:
|
||||||
|
raw = _write_raw(Path(temporary), xml=xml)
|
||||||
|
if kind == "manifest":
|
||||||
|
(raw / "manifest.json").write_text("{invalid", encoding="utf-8")
|
||||||
|
elif kind == "png":
|
||||||
|
(raw / "screenshot.png").write_bytes(b"not a png")
|
||||||
|
manifest = json.loads((raw / "manifest.json").read_text(encoding="utf-8"))
|
||||||
|
manifest["artifacts"][0]["sha256"] = _hash(raw / "screenshot.png")
|
||||||
|
(raw / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
|
||||||
|
elif kind == "xml":
|
||||||
|
(raw / "hierarchy.xml").write_text("<hierarchy>", encoding="utf-8")
|
||||||
|
manifest = json.loads((raw / "manifest.json").read_text(encoding="utf-8"))
|
||||||
|
manifest["artifacts"][1]["sha256"] = _hash(raw / "hierarchy.xml")
|
||||||
|
(raw / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
|
||||||
|
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||||
|
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||||
|
|
||||||
|
self.assertFalse((raw.parent / "derived").exists())
|
||||||
|
self.assertEqual(list(raw.parent.glob(".derived.staging-*")), [])
|
||||||
|
|
||||||
|
def test_phone_recheck_rejects_separator_mask_and_cross_node_bypasses(self) -> None:
|
||||||
|
variants = (
|
||||||
|
"138 0013-8000",
|
||||||
|
"138****0000",
|
||||||
|
"138••••0000",
|
||||||
|
"138xxxx0000",
|
||||||
|
"138XXXX0000",
|
||||||
|
)
|
||||||
|
for value in variants:
|
||||||
|
xml = (
|
||||||
|
"<hierarchy>"
|
||||||
|
"<node bounds='[0,0][1080,540]' text='private' />"
|
||||||
|
f"<node bounds='[0,540][1080,2376]' text='{value}' />"
|
||||||
|
"</hierarchy>"
|
||||||
|
)
|
||||||
|
with self.subTest(value=value), TemporaryDirectory() as temporary:
|
||||||
|
raw = _write_raw(Path(temporary), xml=xml)
|
||||||
|
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||||
|
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||||
|
self.assertFalse((raw.parent / "derived").exists())
|
||||||
|
|
||||||
|
split_xml = (
|
||||||
|
"<hierarchy>"
|
||||||
|
"<node bounds='[0,0][1080,540]' text='private' />"
|
||||||
|
"<node bounds='[0,540][1080,1000]' text='138' content-desc='0013' />"
|
||||||
|
"<node bounds='[0,1000][1080,2376]' text='8000' />"
|
||||||
|
"</hierarchy>"
|
||||||
|
)
|
||||||
|
with TemporaryDirectory() as temporary:
|
||||||
|
raw = _write_raw(Path(temporary), xml=split_xml)
|
||||||
|
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||||
|
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||||
|
self.assertFalse((raw.parent / "derived").exists())
|
||||||
|
|
||||||
|
def test_privacy_geometry_requires_removed_and_retained_nodes(self) -> None:
|
||||||
|
scenarios = (
|
||||||
|
("no-private", f"<hierarchy><node bounds='[0,540][1080,2376]' text='{SAFE_TEXT}' /></hierarchy>"),
|
||||||
|
("no-below", "<hierarchy><node bounds='[0,0][1080,540]' text='private' /></hierarchy>"),
|
||||||
|
)
|
||||||
|
for name, xml in scenarios:
|
||||||
|
with self.subTest(name=name), TemporaryDirectory() as temporary:
|
||||||
|
raw = _write_raw(Path(temporary), xml=xml)
|
||||||
|
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||||
|
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||||
|
self.assertFalse((raw.parent / "derived").exists())
|
||||||
|
self.assertEqual(list(raw.parent.glob(".derived.staging-*")), [])
|
||||||
|
|
||||||
|
def test_xml_coordinate_space_must_have_exact_configured_maximums(self) -> None:
|
||||||
|
scenarios = (
|
||||||
|
(
|
||||||
|
"short-width",
|
||||||
|
"<hierarchy><node bounds='[0,0][1079,540]' text='private' />"
|
||||||
|
"<node bounds='[0,540][1079,2376]' text='safe' /></hierarchy>",
|
||||||
|
"1079x2376",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"short-height",
|
||||||
|
"<hierarchy><node bounds='[0,0][1080,540]' text='private' />"
|
||||||
|
"<node bounds='[0,540][1080,2375]' text='safe' /></hierarchy>",
|
||||||
|
"1080x2375",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"wide-width",
|
||||||
|
"<hierarchy><node bounds='[0,0][1081,540]' text='private' />"
|
||||||
|
"<node bounds='[0,540][1081,2376]' text='safe' /></hierarchy>",
|
||||||
|
"1081x2376",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"old-v2-xml-height",
|
||||||
|
"<hierarchy><node bounds='[0,0][1080,540]' text='private' />"
|
||||||
|
"<node bounds='[0,540][1080,2400]' text='safe' /></hierarchy>",
|
||||||
|
"1080x2400",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for name, xml, observed in scenarios:
|
||||||
|
with self.subTest(name=name), TemporaryDirectory() as temporary:
|
||||||
|
raw = _write_raw(Path(temporary), xml=xml)
|
||||||
|
with self.assertRaises(SkuEvidenceSanitizationError) as raised:
|
||||||
|
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||||
|
|
||||||
|
self.assertIn(observed, str(raised.exception))
|
||||||
|
self.assertNotIn(TEST_SERIAL, str(raised.exception))
|
||||||
|
self.assertFalse((raw.parent / "derived").exists())
|
||||||
|
self.assertEqual(list(raw.parent.glob(".derived.staging-*")), [])
|
||||||
|
|
||||||
|
def test_manifest_schema_package_and_artifact_structure_are_required(self) -> None:
|
||||||
|
def mutate(manifest: dict[str, object], kind: str) -> None:
|
||||||
|
if kind == "schema":
|
||||||
|
manifest["schema_version"] = 2
|
||||||
|
elif kind == "package":
|
||||||
|
manifest["device"]["pdd_package"] = "com.example.other" # type: ignore[index]
|
||||||
|
elif kind == "missing":
|
||||||
|
manifest.pop("artifacts")
|
||||||
|
elif kind == "duplicate":
|
||||||
|
manifest["artifacts"].append(manifest["artifacts"][0]) # type: ignore[index]
|
||||||
|
elif kind == "bad-hash":
|
||||||
|
manifest["artifacts"][0]["sha256"] = "g" * 64 # type: ignore[index]
|
||||||
|
|
||||||
|
for kind in ("schema", "package", "missing", "duplicate", "bad-hash"):
|
||||||
|
with self.subTest(kind=kind), TemporaryDirectory() as temporary:
|
||||||
|
raw = _write_raw(Path(temporary))
|
||||||
|
manifest_path = raw / "manifest.json"
|
||||||
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
|
mutate(manifest, kind)
|
||||||
|
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
||||||
|
with self.assertRaises(SkuEvidenceSanitizationError) as raised:
|
||||||
|
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||||
|
|
||||||
|
self.assertNotIn(TEST_SERIAL, str(raised.exception))
|
||||||
|
self.assertNotIn(TEST_ADDRESS, str(raised.exception))
|
||||||
|
self.assertFalse((raw.parent / "derived").exists())
|
||||||
|
|
||||||
|
def test_target_created_during_publish_is_preserved_and_staging_is_removed(self) -> None:
|
||||||
|
with TemporaryDirectory() as temporary:
|
||||||
|
raw = _write_raw(Path(temporary))
|
||||||
|
target = raw.parent / "derived"
|
||||||
|
|
||||||
|
def race_rename(destination: Path) -> None:
|
||||||
|
destination.mkdir()
|
||||||
|
(destination / "sentinel.txt").write_text("keep", encoding="utf-8")
|
||||||
|
raise FileExistsError("simulated publish race")
|
||||||
|
|
||||||
|
with patch("cmbuyer_client.device.sku_evidence_sanitizer.Path.rename", side_effect=race_rename):
|
||||||
|
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||||
|
sanitize_sku_panel_evidence(raw, target)
|
||||||
|
|
||||||
|
self.assertEqual((target / "sentinel.txt").read_text(encoding="utf-8"), "keep")
|
||||||
|
self.assertEqual(list(raw.parent.glob(".derived.staging-*")), [])
|
||||||
|
|
||||||
|
def test_existing_derived_is_preserved_without_reading_or_writing_raw(self) -> None:
|
||||||
|
with TemporaryDirectory() as temporary:
|
||||||
|
raw = _write_raw(Path(temporary))
|
||||||
|
target = raw.parent / "derived"
|
||||||
|
target.mkdir()
|
||||||
|
sentinel = target / "sentinel.txt"
|
||||||
|
sentinel.write_text("keep", encoding="utf-8")
|
||||||
|
|
||||||
|
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||||
|
sanitize_sku_panel_evidence(raw, target)
|
||||||
|
|
||||||
|
self.assertEqual(sentinel.read_text(encoding="utf-8"), "keep")
|
||||||
|
self.assertEqual(list(raw.parent.glob(".derived.staging-*")), [])
|
||||||
|
|
||||||
|
def test_directory_contract_rejects_non_sibling_paths(self) -> None:
|
||||||
|
with TemporaryDirectory() as temporary:
|
||||||
|
root = Path(temporary)
|
||||||
|
raw = _write_raw(root)
|
||||||
|
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||||
|
sanitize_sku_panel_evidence(raw, root / "not-derived")
|
||||||
|
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||||
|
sanitize_sku_panel_evidence(root / "not-raw", root / "derived")
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""拼多多受限打开模块的离线测试。"""
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
"""商品打开围栏的离线测试;所有设备和命令均为 fake。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
from io import BytesIO
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
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, IntentLaunchSummary
|
||||||
|
from cmbuyer_client.pdd.product_open import (
|
||||||
|
ProductOpenCapturer,
|
||||||
|
ProductOpenTimeoutError,
|
||||||
|
ProductOpenUiDevice,
|
||||||
|
ProductHierarchyCaptureError,
|
||||||
|
ProductPackageMismatchError,
|
||||||
|
ProductScreenshotCaptureError,
|
||||||
|
ProductVersionMismatchError,
|
||||||
|
)
|
||||||
|
from cmbuyer_client.pdd.product_url import ProductUrl, ProductUrlError
|
||||||
|
|
||||||
|
|
||||||
|
SERIAL = "192.168.0.173:5555"
|
||||||
|
URL = "https://mobile.yangkeduo.com/goods.html?goods_id=123"
|
||||||
|
HIERARCHY = "<?xml version='1.0' encoding='UTF-8'?><hierarchy rotation='0'><node /></hierarchy>"
|
||||||
|
|
||||||
|
|
||||||
|
def _png_base64() -> str:
|
||||||
|
image_data = BytesIO()
|
||||||
|
Image.new("RGB", (1, 1), color="white").save(image_data, format="PNG")
|
||||||
|
return base64.b64encode(image_data.getvalue()).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
class FakeAdbClient:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.calls: list[tuple[str, str | None]] = []
|
||||||
|
self.inspection = DeviceInspection(
|
||||||
|
device=AdbDevice(serial=SERIAL, state="device", model="PKG110"),
|
||||||
|
model="PKG110",
|
||||||
|
android_version="16",
|
||||||
|
)
|
||||||
|
|
||||||
|
def inspect(self, serial: str) -> DeviceInspection:
|
||||||
|
self.calls.append(("inspect", serial))
|
||||||
|
return self.inspection
|
||||||
|
|
||||||
|
def start_pdd_view_intent(self, serial: str, goods_id: str) -> IntentLaunchSummary:
|
||||||
|
self.calls.append(("intent", goods_id))
|
||||||
|
return IntentLaunchSummary(status="ok", returncode=0)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeUiDevice:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
version: str = "8.17.0",
|
||||||
|
current_package: str = "com.xunmeng.pinduoduo",
|
||||||
|
current_packages: list[str] | None = None,
|
||||||
|
hierarchy: str = HIERARCHY,
|
||||||
|
timeout_on_screenshot: bool = False,
|
||||||
|
) -> None:
|
||||||
|
self.version = version
|
||||||
|
self.current_package = current_package
|
||||||
|
self.current_packages = list(current_packages) if current_packages is not None else None
|
||||||
|
self.hierarchy = hierarchy
|
||||||
|
self.timeout_on_screenshot = timeout_on_screenshot
|
||||||
|
self.calls: list[str] = []
|
||||||
|
|
||||||
|
def app_info(self, package_name: str) -> dict[str, str]:
|
||||||
|
self.calls.append("app_info")
|
||||||
|
return {"versionName": self.version}
|
||||||
|
|
||||||
|
def app_current(self) -> dict[str, str]:
|
||||||
|
self.calls.append("app_current")
|
||||||
|
if self.current_packages:
|
||||||
|
package = self.current_packages.pop(0)
|
||||||
|
self.current_package = package
|
||||||
|
return {"package": self.current_package, "activity": "sensitive.activity.name"}
|
||||||
|
|
||||||
|
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||||
|
self.calls.append(method)
|
||||||
|
if method == "takeScreenshot":
|
||||||
|
if self.timeout_on_screenshot:
|
||||||
|
raise TimeoutError("raw remote detail")
|
||||||
|
return _png_base64()
|
||||||
|
if method == "dumpWindowHierarchy":
|
||||||
|
return self.hierarchy
|
||||||
|
raise AssertionError(f"unexpected RPC {method}")
|
||||||
|
|
||||||
|
|
||||||
|
class ProductOpenTests(unittest.TestCase):
|
||||||
|
def _capturer(self, adb: FakeAdbClient, device: FakeUiDevice, **kwargs: object) -> ProductOpenCapturer:
|
||||||
|
return ProductOpenCapturer(adb, lambda serial: device, timeout_seconds=2, **kwargs)
|
||||||
|
|
||||||
|
def test_success_uses_canonical_url_and_redacted_atomic_manifest(self) -> None:
|
||||||
|
adb = FakeAdbClient()
|
||||||
|
device = FakeUiDevice()
|
||||||
|
with TemporaryDirectory() as temporary:
|
||||||
|
target = Path(temporary) / "evidence"
|
||||||
|
result = self._capturer(adb, device).open_and_capture(SERIAL, URL, target)
|
||||||
|
manifest = result.manifest_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertTrue(result.screenshot_path.exists())
|
||||||
|
self.assertTrue(result.hierarchy_path.exists())
|
||||||
|
self.assertEqual(adb.calls, [("inspect", SERIAL), ("intent", "123")])
|
||||||
|
self.assertEqual(device.calls, ["app_info", "app_current", "takeScreenshot", "dumpWindowHierarchy"])
|
||||||
|
self.assertIn('"goods_id": "123"', manifest)
|
||||||
|
self.assertIn('"canonical_url": "https://mobile.yangkeduo.com/goods.html?goods_id=123"', manifest)
|
||||||
|
self.assertNotIn(SERIAL, manifest)
|
||||||
|
self.assertNotIn("sensitive.activity.name", manifest)
|
||||||
|
self.assertNotIn(HIERARCHY, manifest)
|
||||||
|
|
||||||
|
def test_version_mismatch_halts_before_intent(self) -> None:
|
||||||
|
adb = FakeAdbClient()
|
||||||
|
for version in ("8.17.1", " 8.17.0 "):
|
||||||
|
with self.subTest(version=version), TemporaryDirectory() as temporary:
|
||||||
|
target = Path(temporary) / "evidence"
|
||||||
|
with self.assertRaises(ProductVersionMismatchError):
|
||||||
|
self._capturer(adb, FakeUiDevice(version=version)).open_and_capture(SERIAL, URL, target)
|
||||||
|
|
||||||
|
self.assertEqual(adb.calls[-1:], [("inspect", SERIAL)])
|
||||||
|
self.assertFalse(target.exists())
|
||||||
|
|
||||||
|
def test_public_entry_rejects_caller_constructed_url_value_object(self) -> None:
|
||||||
|
adb = FakeAdbClient()
|
||||||
|
with TemporaryDirectory() as temporary:
|
||||||
|
with self.assertRaises(ProductUrlError):
|
||||||
|
self._capturer(adb, FakeUiDevice()).open_and_capture(
|
||||||
|
SERIAL,
|
||||||
|
ProductUrl(goods_id="123", canonical_url="https://example.invalid/"), # type: ignore[arg-type]
|
||||||
|
Path(temporary) / "evidence",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(adb.calls, [])
|
||||||
|
|
||||||
|
def test_foreground_package_mismatch_halts_before_capture(self) -> None:
|
||||||
|
adb = FakeAdbClient()
|
||||||
|
device = FakeUiDevice(current_package="com.example.other")
|
||||||
|
clock = FakeClock()
|
||||||
|
with TemporaryDirectory() as temporary:
|
||||||
|
target = Path(temporary) / "evidence"
|
||||||
|
with self.assertRaises(ProductPackageMismatchError):
|
||||||
|
self._capturer(
|
||||||
|
adb,
|
||||||
|
device,
|
||||||
|
foreground_poll_interval_seconds=0.5,
|
||||||
|
monotonic_clock=clock.monotonic,
|
||||||
|
sleep_function=clock.sleep,
|
||||||
|
).open_and_capture(SERIAL, URL, target)
|
||||||
|
|
||||||
|
self.assertEqual(adb.calls, [("inspect", SERIAL), ("intent", "123")])
|
||||||
|
self.assertEqual(device.calls, ["app_info", "app_current", "app_current", "app_current", "app_current", "app_current"])
|
||||||
|
self.assertEqual(clock.sleeps, [0.5, 0.5, 0.5, 0.5])
|
||||||
|
self.assertFalse(target.exists())
|
||||||
|
|
||||||
|
def test_foreground_package_poll_waits_for_pdd_before_reading_evidence(self) -> None:
|
||||||
|
adb = FakeAdbClient()
|
||||||
|
device = FakeUiDevice(current_packages=["com.example.other", "com.xunmeng.pinduoduo"])
|
||||||
|
clock = FakeClock()
|
||||||
|
with TemporaryDirectory() as temporary:
|
||||||
|
target = Path(temporary) / "evidence"
|
||||||
|
result = self._capturer(
|
||||||
|
adb,
|
||||||
|
device,
|
||||||
|
foreground_poll_interval_seconds=0.25,
|
||||||
|
monotonic_clock=clock.monotonic,
|
||||||
|
sleep_function=clock.sleep,
|
||||||
|
).open_and_capture(SERIAL, URL, target)
|
||||||
|
|
||||||
|
self.assertTrue(result.manifest_path.exists())
|
||||||
|
self.assertEqual(clock.sleeps, [0.25])
|
||||||
|
self.assertEqual(
|
||||||
|
device.calls,
|
||||||
|
["app_info", "app_current", "app_current", "takeScreenshot", "dumpWindowHierarchy"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_foreground_package_poll_stops_at_deadline_without_evidence(self) -> None:
|
||||||
|
adb = FakeAdbClient()
|
||||||
|
device = FakeUiDevice(current_packages=["com.example.other", "", "com.example.other"])
|
||||||
|
clock = FakeClock()
|
||||||
|
with TemporaryDirectory() as temporary:
|
||||||
|
target = Path(temporary) / "evidence"
|
||||||
|
with self.assertRaises(ProductPackageMismatchError):
|
||||||
|
self._capturer(
|
||||||
|
adb,
|
||||||
|
device,
|
||||||
|
foreground_poll_interval_seconds=0.8,
|
||||||
|
monotonic_clock=clock.monotonic,
|
||||||
|
sleep_function=clock.sleep,
|
||||||
|
).open_and_capture(SERIAL, URL, target)
|
||||||
|
|
||||||
|
self.assertEqual(len(clock.sleeps), 3)
|
||||||
|
for actual, expected in zip(clock.sleeps, (0.8, 0.8, 0.4), strict=True):
|
||||||
|
self.assertAlmostEqual(actual, expected)
|
||||||
|
self.assertEqual(device.calls, ["app_info", "app_current", "app_current", "app_current", "app_current"])
|
||||||
|
self.assertFalse(target.exists())
|
||||||
|
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
|
||||||
|
|
||||||
|
def test_foreground_poll_interval_must_be_positive_and_finite(self) -> None:
|
||||||
|
for interval in (0, -0.1, float("inf"), float("nan"), True):
|
||||||
|
with self.subTest(interval=interval):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
ProductOpenCapturer(
|
||||||
|
FakeAdbClient(),
|
||||||
|
lambda serial: FakeUiDevice(),
|
||||||
|
timeout_seconds=2,
|
||||||
|
foreground_poll_interval_seconds=interval, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_timeout_and_invalid_hierarchy_leave_no_partial_evidence(self) -> None:
|
||||||
|
scenarios = (
|
||||||
|
(FakeUiDevice(timeout_on_screenshot=True), ProductOpenTimeoutError),
|
||||||
|
(FakeUiDevice(hierarchy="<not-hierarchy />"), ProductHierarchyCaptureError),
|
||||||
|
)
|
||||||
|
for device, error_type in scenarios:
|
||||||
|
with self.subTest(error_type=error_type.__name__), TemporaryDirectory() as temporary:
|
||||||
|
target = Path(temporary) / "evidence"
|
||||||
|
with self.assertRaises(error_type):
|
||||||
|
self._capturer(FakeAdbClient(), device).open_and_capture(SERIAL, URL, target)
|
||||||
|
|
||||||
|
self.assertFalse(target.exists())
|
||||||
|
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
|
||||||
|
|
||||||
|
def test_invalid_screenshot_is_a_distinct_redacted_failure(self) -> None:
|
||||||
|
class InvalidScreenshotDevice(FakeUiDevice):
|
||||||
|
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||||
|
if method == "takeScreenshot":
|
||||||
|
self.calls.append(method)
|
||||||
|
return "not valid base64!"
|
||||||
|
return super().jsonrpc_call(method, params, timeout)
|
||||||
|
|
||||||
|
with TemporaryDirectory() as temporary:
|
||||||
|
target = Path(temporary) / "evidence"
|
||||||
|
with self.assertRaises(ProductScreenshotCaptureError) as raised:
|
||||||
|
self._capturer(FakeAdbClient(), InvalidScreenshotDevice()).open_and_capture(SERIAL, URL, target)
|
||||||
|
|
||||||
|
self.assertNotIn("base64", str(raised.exception).lower())
|
||||||
|
self.assertFalse(target.exists())
|
||||||
|
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
|
||||||
|
|
||||||
|
def test_read_only_protocol_has_no_ui_operation_methods(self) -> None:
|
||||||
|
forbidden = {"click", "swipe", "send_keys", "set_text", "press", "long_click"}
|
||||||
|
|
||||||
|
self.assertTrue(forbidden.isdisjoint(ProductOpenUiDevice.__dict__))
|
||||||
|
self.assertEqual(base64.b64decode(_png_base64())[:8], b"\x89PNG\r\n\x1a\n")
|
||||||
|
|
||||||
|
|
||||||
|
class FakeClock:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.value = 0.0
|
||||||
|
self.sleeps: list[float] = []
|
||||||
|
|
||||||
|
def monotonic(self) -> float:
|
||||||
|
return self.value
|
||||||
|
|
||||||
|
def sleep(self, seconds: float) -> None:
|
||||||
|
self.sleeps.append(seconds)
|
||||||
|
self.value += seconds
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""canonical 商品 URL 的离线解析测试。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
CLIENT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(CLIENT_ROOT / "src"))
|
||||||
|
|
||||||
|
from cmbuyer_client.pdd.product_url import ProductUrlError, parse_product_url
|
||||||
|
|
||||||
|
|
||||||
|
class ProductUrlTests(unittest.TestCase):
|
||||||
|
def test_rebuilds_url_from_goods_id(self) -> None:
|
||||||
|
link = parse_product_url("https://mobile.yangkeduo.com/goods.html?goods_id=00123")
|
||||||
|
|
||||||
|
self.assertEqual(link.goods_id, "00123")
|
||||||
|
self.assertEqual(
|
||||||
|
link.canonical_url,
|
||||||
|
"https://mobile.yangkeduo.com/goods.html?goods_id=00123",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_rejects_noncanonical_and_ambiguous_urls(self) -> None:
|
||||||
|
rejected = (
|
||||||
|
"http://mobile.yangkeduo.com/goods.html?goods_id=123",
|
||||||
|
"https://other.example/goods.html?goods_id=123",
|
||||||
|
"https://mobile.yangkeduo.com/other.html?goods_id=123",
|
||||||
|
"https://user@mobile.yangkeduo.com/goods.html?goods_id=123",
|
||||||
|
"https://mobile.yangkeduo.com:8443/goods.html?goods_id=123",
|
||||||
|
"https://mobile.yangkeduo.com:443/goods.html?goods_id=123",
|
||||||
|
"https://mobile.yangkeduo.com/goods.html?goods_id=123#fragment",
|
||||||
|
"https://mobile.yangkeduo.com/goods.html",
|
||||||
|
"https://mobile.yangkeduo.com/goods.html?goods_id=123&goods_id=456",
|
||||||
|
"https://mobile.yangkeduo.com/goods.html?goods_id=123&source=share",
|
||||||
|
"https://mobile.yangkeduo.com/goods.html?goods_id=12a",
|
||||||
|
"https://mobile.yangkeduo.com/goods.html?goods_id=%EF%BC%91%EF%BC%92%EF%BC%93",
|
||||||
|
"https://mobile.yangkeduo.com/goods.html?goods_id=",
|
||||||
|
" https://mobile.yangkeduo.com/goods.html?goods_id=123",
|
||||||
|
"https://MOBILE.YANGKEDUO.COM/goods.html?goods_id=123",
|
||||||
|
"https://mobile.yangkeduo.com/goods.html?goods_id=%31%32%33",
|
||||||
|
)
|
||||||
|
|
||||||
|
for value in rejected:
|
||||||
|
with self.subTest(value=value):
|
||||||
|
with self.assertRaises(ProductUrlError):
|
||||||
|
parse_product_url(value)
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
"""人工声明规格面板状态的离线只读取证测试。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
from importlib.util import module_from_spec, spec_from_file_location
|
||||||
|
from io import BytesIO
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
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.product_url import ProductUrlError
|
||||||
|
from cmbuyer_client.pdd.sku_panel_spike import (
|
||||||
|
HUMAN_DECLARED_STATES,
|
||||||
|
SkuPanelDeclaredStateError,
|
||||||
|
SkuPanelEvidenceCapturer,
|
||||||
|
SkuPanelEvidenceError,
|
||||||
|
SkuPanelEvidenceTimeoutError,
|
||||||
|
SkuPanelHierarchyError,
|
||||||
|
SkuPanelPackageMismatchError,
|
||||||
|
SkuPanelScreenshotError,
|
||||||
|
SkuPanelUiDevice,
|
||||||
|
SkuPanelVersionMismatchError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
SERIAL = "192.168.0.173:5555"
|
||||||
|
URL = "https://mobile.yangkeduo.com/goods.html?goods_id=123"
|
||||||
|
HIERARCHY = "<?xml version='1.0' encoding='UTF-8'?><hierarchy><node text='sensitive page text' /></hierarchy>"
|
||||||
|
|
||||||
|
|
||||||
|
def _png_base64() -> str:
|
||||||
|
image_data = BytesIO()
|
||||||
|
Image.new("RGB", (1, 1), color="white").save(image_data, format="PNG")
|
||||||
|
return base64.b64encode(image_data.getvalue()).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
class FakeAdbClient:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.calls: list[str] = []
|
||||||
|
self.inspection = DeviceInspection(
|
||||||
|
device=AdbDevice(serial=SERIAL, state="device", model="PKG110"),
|
||||||
|
model="PKG110",
|
||||||
|
android_version="16",
|
||||||
|
)
|
||||||
|
|
||||||
|
def inspect(self, serial: str) -> DeviceInspection:
|
||||||
|
self.calls.append(serial)
|
||||||
|
return self.inspection
|
||||||
|
|
||||||
|
|
||||||
|
class FakeUiDevice:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
version: str = "8.17.0",
|
||||||
|
package: str = "com.xunmeng.pinduoduo",
|
||||||
|
screenshot: str | None = None,
|
||||||
|
hierarchy: str = HIERARCHY,
|
||||||
|
) -> None:
|
||||||
|
self.version = version
|
||||||
|
self.package = package
|
||||||
|
self.screenshot = screenshot if screenshot is not None else _png_base64()
|
||||||
|
self.hierarchy = hierarchy
|
||||||
|
self.calls: list[str] = []
|
||||||
|
|
||||||
|
def app_info(self, package_name: str) -> dict[str, str]:
|
||||||
|
self.calls.append("app_info")
|
||||||
|
return {"versionName": self.version}
|
||||||
|
|
||||||
|
def app_current(self) -> dict[str, str]:
|
||||||
|
self.calls.append("app_current")
|
||||||
|
return {"package": self.package, "activity": "sensitive.activity.name"}
|
||||||
|
|
||||||
|
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||||
|
self.calls.append(method)
|
||||||
|
if method == "takeScreenshot":
|
||||||
|
return self.screenshot
|
||||||
|
if method == "dumpWindowHierarchy":
|
||||||
|
return self.hierarchy
|
||||||
|
raise AssertionError(f"unexpected RPC {method}")
|
||||||
|
|
||||||
|
|
||||||
|
def _load_spike_script() -> object:
|
||||||
|
script_path = CLIENT_ROOT / "scripts" / "capture_sku_panel_spike.py"
|
||||||
|
spec = spec_from_file_location("capture_sku_panel_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
|
||||||
|
|
||||||
|
|
||||||
|
class SkuPanelEvidenceTests(unittest.TestCase):
|
||||||
|
def _capturer(self, adb: FakeAdbClient, device: FakeUiDevice) -> SkuPanelEvidenceCapturer:
|
||||||
|
return SkuPanelEvidenceCapturer(adb, lambda serial: device, timeout_seconds=2)
|
||||||
|
|
||||||
|
def test_all_human_declared_states_publish_redacted_manifest(self) -> None:
|
||||||
|
for state in sorted(HUMAN_DECLARED_STATES):
|
||||||
|
with self.subTest(state=state), TemporaryDirectory() as temporary:
|
||||||
|
adb = FakeAdbClient()
|
||||||
|
device = FakeUiDevice()
|
||||||
|
target = Path(temporary) / "evidence"
|
||||||
|
result = self._capturer(adb, device).capture(SERIAL, URL, state, target)
|
||||||
|
manifest = result.manifest_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertEqual(adb.calls, [SERIAL])
|
||||||
|
self.assertEqual(device.calls, ["app_info", "app_current", "takeScreenshot", "dumpWindowHierarchy"])
|
||||||
|
self.assertIn(f'"human_declared_state": "{state}"', manifest)
|
||||||
|
self.assertIn('"goods_id": "123"', manifest)
|
||||||
|
self.assertIn('"canonical_url": "https://mobile.yangkeduo.com/goods.html?goods_id=123"', manifest)
|
||||||
|
self.assertNotIn("detected_state", manifest)
|
||||||
|
self.assertNotIn(SERIAL, manifest)
|
||||||
|
self.assertNotIn("sensitive.activity.name", manifest)
|
||||||
|
self.assertNotIn(HIERARCHY, manifest)
|
||||||
|
|
||||||
|
def test_invalid_state_and_url_fail_before_device_access(self) -> None:
|
||||||
|
adb = FakeAdbClient()
|
||||||
|
rejected_states = ("initial", "one-dimension-selected", "all-dimensions-selected", "guessed")
|
||||||
|
with TemporaryDirectory() as temporary:
|
||||||
|
for state in rejected_states:
|
||||||
|
with self.subTest(state=state), self.assertRaises(SkuPanelDeclaredStateError):
|
||||||
|
self._capturer(adb, FakeUiDevice()).capture(SERIAL, URL, state, Path(temporary) / "state")
|
||||||
|
with self.assertRaises(ProductUrlError):
|
||||||
|
self._capturer(adb, FakeUiDevice()).capture(
|
||||||
|
SERIAL,
|
||||||
|
"https://mobile.yangkeduo.com/goods.html?goods_id=12x",
|
||||||
|
"panel-opened-target-preselected",
|
||||||
|
Path(temporary) / "url",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(adb.calls, [])
|
||||||
|
|
||||||
|
def test_version_or_foreground_package_mismatch_stops_before_artifacts(self) -> None:
|
||||||
|
scenarios = (
|
||||||
|
(FakeUiDevice(version="8.17.1"), SkuPanelVersionMismatchError, ["app_info"]),
|
||||||
|
(FakeUiDevice(package="com.example.other"), SkuPanelPackageMismatchError, ["app_info", "app_current"]),
|
||||||
|
)
|
||||||
|
for device, error_type, expected_calls in scenarios:
|
||||||
|
with self.subTest(error_type=error_type.__name__), TemporaryDirectory() as temporary:
|
||||||
|
target = Path(temporary) / "evidence"
|
||||||
|
with self.assertRaises(error_type):
|
||||||
|
self._capturer(FakeAdbClient(), device).capture(SERIAL, URL, "panel-opened-target-preselected", target)
|
||||||
|
|
||||||
|
self.assertEqual(device.calls, expected_calls)
|
||||||
|
self.assertFalse(target.exists())
|
||||||
|
|
||||||
|
def test_screenshot_and_xml_failure_leave_no_partial_evidence(self) -> None:
|
||||||
|
scenarios = (
|
||||||
|
(FakeUiDevice(screenshot="not valid base64!"), SkuPanelScreenshotError),
|
||||||
|
(FakeUiDevice(hierarchy="<not-hierarchy />"), SkuPanelHierarchyError),
|
||||||
|
)
|
||||||
|
for device, error_type in scenarios:
|
||||||
|
with self.subTest(error_type=error_type.__name__), TemporaryDirectory() as temporary:
|
||||||
|
target = Path(temporary) / "evidence"
|
||||||
|
with self.assertRaises(error_type):
|
||||||
|
self._capturer(FakeAdbClient(), device).capture(SERIAL, URL, "panel-opened-target-preselected", target)
|
||||||
|
|
||||||
|
self.assertFalse(target.exists())
|
||||||
|
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
|
||||||
|
|
||||||
|
def test_screenshot_timeout_is_redacted_and_leaves_no_partial_evidence(self) -> None:
|
||||||
|
class TimeoutScreenshotDevice(FakeUiDevice):
|
||||||
|
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||||
|
if method == "takeScreenshot":
|
||||||
|
self.calls.append(method)
|
||||||
|
raise TimeoutError("adb 192.168.0.173:5555 raw detail")
|
||||||
|
return super().jsonrpc_call(method, params, timeout)
|
||||||
|
|
||||||
|
with TemporaryDirectory() as temporary:
|
||||||
|
target = Path(temporary) / "evidence"
|
||||||
|
with self.assertRaises(SkuPanelEvidenceTimeoutError) as raised:
|
||||||
|
self._capturer(FakeAdbClient(), TimeoutScreenshotDevice()).capture(SERIAL, URL, "panel-opened-target-preselected", target)
|
||||||
|
|
||||||
|
self.assertNotIn(SERIAL, str(raised.exception))
|
||||||
|
self.assertNotIn("adb", str(raised.exception).lower())
|
||||||
|
self.assertFalse(target.exists())
|
||||||
|
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
|
||||||
|
|
||||||
|
def test_existing_output_directory_is_not_overwritten_or_connected(self) -> None:
|
||||||
|
adb = FakeAdbClient()
|
||||||
|
device = FakeUiDevice()
|
||||||
|
with TemporaryDirectory() as temporary:
|
||||||
|
target = Path(temporary) / "evidence"
|
||||||
|
target.mkdir()
|
||||||
|
sentinel = target / "sentinel.txt"
|
||||||
|
sentinel.write_text("keep", encoding="utf-8")
|
||||||
|
|
||||||
|
with self.assertRaises(SkuPanelEvidenceError):
|
||||||
|
self._capturer(adb, device).capture(SERIAL, URL, "panel-opened-target-preselected", target)
|
||||||
|
|
||||||
|
self.assertEqual(adb.calls, [])
|
||||||
|
self.assertEqual(device.calls, [])
|
||||||
|
self.assertEqual(sentinel.read_text(encoding="utf-8"), "keep")
|
||||||
|
|
||||||
|
def test_protocol_has_no_ui_or_purchase_operation_methods(self) -> None:
|
||||||
|
forbidden = {
|
||||||
|
"click",
|
||||||
|
"swipe",
|
||||||
|
"send_keys",
|
||||||
|
"set_text",
|
||||||
|
"press",
|
||||||
|
"open_product",
|
||||||
|
"open_sku_panel",
|
||||||
|
"set_quantity",
|
||||||
|
"go_to_order_confirm",
|
||||||
|
"submit_order",
|
||||||
|
"pay",
|
||||||
|
}
|
||||||
|
self.assertTrue(forbidden.isdisjoint(SkuPanelUiDevice.__dict__))
|
||||||
|
self.assertEqual({name for name in SkuPanelEvidenceCapturer.__dict__ if not name.startswith("_")}, {"capture"})
|
||||||
|
|
||||||
|
|
||||||
|
class SkuPanelSpikeCliTests(unittest.TestCase):
|
||||||
|
def test_validate_arguments_rejects_invalid_serial_timeout_state_and_url(self) -> None:
|
||||||
|
script = _load_spike_script()
|
||||||
|
valid = {
|
||||||
|
"serial": SERIAL,
|
||||||
|
"url": URL,
|
||||||
|
"goods_id": None,
|
||||||
|
"state": "panel-opened-target-preselected",
|
||||||
|
"output_dir": Path("evidence"),
|
||||||
|
"timeout": 10.0,
|
||||||
|
"adb": "adb",
|
||||||
|
}
|
||||||
|
invalid_values = (
|
||||||
|
("serial", ""),
|
||||||
|
("timeout", 0),
|
||||||
|
("timeout", float("inf")),
|
||||||
|
("state", "not-declared"),
|
||||||
|
("state", "initial"),
|
||||||
|
("state", "one-dimension-selected"),
|
||||||
|
("state", "all-dimensions-selected"),
|
||||||
|
("url", "https://mobile.yangkeduo.com/goods.html?goods_id=bad"),
|
||||||
|
)
|
||||||
|
for field, value in invalid_values:
|
||||||
|
with self.subTest(field=field, value=value):
|
||||||
|
arguments = argparse.Namespace(**(valid | {field: value}))
|
||||||
|
with self.assertRaises((ValueError, ProductUrlError)):
|
||||||
|
script.validate_arguments(arguments) # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
def test_goods_id_is_rebuilt_as_canonical_url(self) -> None:
|
||||||
|
script = _load_spike_script()
|
||||||
|
arguments = argparse.Namespace(
|
||||||
|
serial=SERIAL,
|
||||||
|
url=None,
|
||||||
|
goods_id="00123",
|
||||||
|
state="target-selection-restored",
|
||||||
|
output_dir=Path("evidence"),
|
||||||
|
timeout=10.0,
|
||||||
|
adb="adb",
|
||||||
|
)
|
||||||
|
|
||||||
|
link = script.validate_arguments(arguments) # type: ignore[attr-defined]
|
||||||
|
self.assertEqual(link.canonical_url, "https://mobile.yangkeduo.com/goods.html?goods_id=00123")
|
||||||
@@ -85,22 +85,24 @@ cmbuyer 是一个自动化采购系统:**采购服务**(网页端,`admin/`
|
|||||||
|
|
||||||
## 当前阶段
|
## 当前阶段
|
||||||
|
|
||||||
**Phase 0 · 地基。** 采购服务与采购工具骨架均已初始化,尚无采购业务代码。
|
**Phase 1 · 真机可行性,并行启动采购服务基础能力。** 两端骨架与核心数据模型已完成,T-103
|
||||||
|
仍是当前真机关键路径;与真机可读字段无关的采购服务能力不再空等。
|
||||||
|
|
||||||
执行按任务依赖驱动,**不按 Phase 整段串行等待**。当前优先路径:
|
执行按任务依赖驱动,**不按 Phase 整段串行等待**。当前优先路径:
|
||||||
|
|
||||||
1. T-003 统一入口与 T-004 核心数据模型已完成;T-101 真机环境盘点已就绪,但尚未真机验收。
|
1. T-001~T-004 与 T-101~T-102 已完成,继续推进 **T-103 真机取证**。
|
||||||
2. T-002 已完成,立即推进 **T-101 → T-102 → T-103 真机取证**。
|
2. T-103 进行时并行推进 T-201 管理会话 → T-202 `DRAFT` 建单与基础列表;两者不得启动试选,
|
||||||
3. T-103 结论确认后,才开始依赖真机可读字段的 Phase 2 生产页面;采购服务核心与
|
也不得引入机器结果、规格面板单价或证据字段。
|
||||||
T-104 → T-107 后续真机安全判据按依赖并行推进。
|
3. T-103 结论确认后,再推进 T-203 批量开始试选、T-204 试选证据详情、T-205~T-207,
|
||||||
|
并按依赖推进 T-104 → T-107 后续真机安全判据。
|
||||||
4. Phase 3:双端打通与**第一趟试选**端到端。
|
4. Phase 3:双端打通与**第一趟试选**端到端。
|
||||||
5. Phase 4:**第二趟下单**与收尾。
|
5. Phase 4:**第二趟下单**与收尾。
|
||||||
6. V2 及以后:图搜、Excel、ERP、订单自动核对、AI 辅助。
|
6. V2 及以后:图搜、Excel、ERP、订单自动核对、AI 辅助。
|
||||||
|
|
||||||
> **M2 是本项目的生死线**:真机能按链接打开商品、精确勾选颜色分类和尺码、
|
> **M2 是本项目的生死线**:真机能按链接打开商品、精确勾选颜色分类和尺码、
|
||||||
> **读到该 SKU 的单价**(T-103)。前序项目正是卡在选规格和读价。
|
> **读到该 SKU 的单价**(T-103)。前序项目正是卡在选规格和读价。
|
||||||
> M2 不通过之前不要写 Phase 2 的生产页面;Phase 0 原型只确认流程与信息架构,实际可读
|
> M2 不通过之前不要写**依赖真机可读字段或会启动试选**的 Phase 2 功能;T-201 与仅创建
|
||||||
> 字段仍以真机证据为准。
|
> `DRAFT` 的 T-202 可以并行。原型只确认流程与信息架构,实际可读字段仍以真机证据为准。
|
||||||
|
|
||||||
## 领取任务规则
|
## 领取任务规则
|
||||||
|
|
||||||
|
|||||||
+19
-7
@@ -99,14 +99,20 @@ US-006(付款前核对)在 MVP 降级为:系统展示订单截图与授权
|
|||||||
- **F-005**:执行员启动会话后桌面端定时轮询,同时领取待试选与已授权两类任务。
|
- **F-005**:执行员启动会话后桌面端定时轮询,同时领取待试选与已授权两类任务。
|
||||||
两个实例并发领取同一条时只有一个成功,另一个得到明确的「无可领任务」而不是报错。
|
两个实例并发领取同一条时只有一个成功,另一个得到明确的「无可领任务」而不是报错。
|
||||||
**关闭会话即停止轮询;连续失败达到阈值自动停止并提示原因。**
|
**关闭会话即停止轮询;连续失败达到阈值自动停止并提示原因。**
|
||||||
- **F-006**:手机打开对应商品详情页,打开规格面板,按维度精确匹配颜色分类和尺码。
|
- **F-006**:手机打开对应商品详情页,只点击与当前拼多多版本及本项目真机证据绑定的精确唯一
|
||||||
|
**受控规格面板入口**,打开规格面板后按维度精确匹配颜色分类和尺码。当前只确认拼多多
|
||||||
|
`8.17.0`、goods_id `937122477375` 上的“快要抢光”;其他入口文案必须分别取证,不能按购买
|
||||||
|
语义泛化。
|
||||||
**任一维度找不到精确值即停止并转人工,不选相近选项。** 勾选后读取该 SKU 单价
|
**任一维度找不到精确值即停止并转人工,不选相近选项。** 勾选后读取该 SKU 单价
|
||||||
(闸门一),读不到即转人工,**不用商品详情页正文或搜索页的数字凑合**。
|
(闸门一),读不到即转人工,**不用商品详情页正文或搜索页的数字凑合**。
|
||||||
- **F-006 释放要求**:试选完成后**必须退出商品页释放手机**,不得停在规格面板等待人工。
|
- **F-006 释放要求**:试选完成后**必须退出商品页释放手机**,不得停在规格面板等待人工。
|
||||||
- **F-006 硬边界**:试选阶段**绝不点击「现在买」或任何进入下单流程的入口**。
|
- **F-006 硬边界**:第一趟只把上述精确入口点击视为可逆的“打开规格面板”能力;进入面板后
|
||||||
必须有测试证明该路径不调用任何下单语义动作。
|
不得调整数量、进入订单确认页、点击“提交订单”或任何支付/资金控件,也不得暴露通用任意点击能力。
|
||||||
|
必须有静态调用链和测试证明上述下单语义动作不可达。入口缺失、重复、版本失配或打开后不是已取证
|
||||||
|
面板时立即停止,不尝试“免拼购买 / 单独购买 / 直接拼成”等相似文案。
|
||||||
- **F-007**:回传商品标题、实际勾选到的颜色分类与尺码、单价、合计(单价 × 数量)和
|
- **F-007**:回传商品标题、实际勾选到的颜色分类与尺码、单价、合计(单价 × 数量)和
|
||||||
规格面板截图;任务转「等你确认」。
|
**自动脱敏后的**规格面板截图;任务转「等你确认」。原始 screenshot/XML 只能留在采购工具本机
|
||||||
|
隔离目录,不能上传、写日志或提交 Git。
|
||||||
|
|
||||||
### 决策与资金
|
### 决策与资金
|
||||||
|
|
||||||
@@ -138,7 +144,8 @@ US-006(付款前核对)在 MVP 降级为:系统展示订单截图与授权
|
|||||||
|
|
||||||
- **F-011**:失败必须可区分至少这些原因:设备未连接、商品页打不开、规格面板打不开、
|
- **F-011**:失败必须可区分至少这些原因:设备未连接、商品页打不开、规格面板打不开、
|
||||||
规格不匹配、单价读不到、单价与授权价不符、数量设置失败、金额超上限、提交控件不唯一、
|
规格不匹配、单价读不到、单价与授权价不符、数量设置失败、金额超上限、提交控件不唯一、
|
||||||
页面识别失败、安全校验、外部支付交接、超时。每种都保留截图和页面快照。
|
页面识别失败、安全校验、外部支付交接、超时。原始截图/页面快照只留采购工具本机隔离目录;
|
||||||
|
脱敏成功时保留派生物,任何上传、远程审阅或服务端展示都只能使用自动复检通过的派生证据。
|
||||||
- **版本失配**:运行时读取到的拼多多 App 版本与当前已取证版本不一致时,桌面端必须停止
|
- **版本失配**:运行时读取到的拼多多 App 版本与当前已取证版本不一致时,桌面端必须停止
|
||||||
领取真机任务并提示重新取证;不得继续使用旧页面判据。
|
领取真机任务并提示重新取证;不得继续使用旧页面判据。
|
||||||
- **付款收口(MVP 简化版)**:订单创建后任务转「待付款」,页面展示订单截图、商品、
|
- **付款收口(MVP 简化版)**:订单创建后任务转「待付款」,页面展示订单截图、商品、
|
||||||
@@ -177,8 +184,13 @@ US-006(付款前核对)在 MVP 降级为:系统展示订单截图与授权
|
|||||||
可配置的动作节奏,并在检测到安全校验时立即停止。
|
可配置的动作节奏,并在检测到安全校验时立即停止。
|
||||||
- **自动化边界风险**:会自动点击并创建订单,属不可逆操作。必须支持 dry-run(跑到
|
- **自动化边界风险**:会自动点击并创建订单,属不可逆操作。必须支持 dry-run(跑到
|
||||||
订单确认页停止)、服务端提交围栏和点击后调和;任一环节状态不明都不得继续点击。
|
订单确认页停止)、服务端提交围栏和点击后调和;任一环节状态不明都不得继续点击。
|
||||||
- **隐私风险**:订单确认页含收货地址和掩码手机号。**只读取非敏感摘要,不提取地址
|
- **隐私风险**:规格面板和订单确认页都会显示收货地址和掩码手机号。允许原始 screenshot/XML
|
||||||
原文、手机号或支付凭据**;上传服务端的证据需先脱敏。
|
仅在采购工具本机隔离目录短链路落盘供确定性脱敏器消费;业务逻辑、日志、agent、fixture 与采购
|
||||||
|
服务**不得提取或接收地址原文、手机号或支付凭据**。只有脱敏成功且自动复检通过的派生证据才能
|
||||||
|
上传或进入开发材料,无法确认脱敏完整即 fail closed。
|
||||||
|
- **受控规格入口风险**:T-103 已证明当前衣服商品只有购买语义按钮能打开规格面板。项目所有者批准
|
||||||
|
仅把经真机取证的精确唯一入口作为第一趟可逆导航;该批准不覆盖其他文案,不授权调整数量、提交订单、
|
||||||
|
进入确认页或付款。T-103 必须用 capability 隔离和不可达测试证明边界没有扩散。
|
||||||
- **规格面板上的单价位置未取证(阻塞 F-006 闸门一)**:选中 SKU 后价格显示在哪个节点、
|
- **规格面板上的单价位置未取证(阻塞 F-006 闸门一)**:选中 SKU 后价格显示在哪个节点、
|
||||||
是否带「券后」前缀、是否与原价并列,尚无本项目的真机证据。**T-103 必须一并取证。**
|
是否带「券后」前缀、是否与原价并列,尚无本项目的真机证据。**T-103 必须一并取证。**
|
||||||
若规格面板上无法可靠读到单价,闸门一要改为「只截图不判价」,确认页设计随之调整。
|
若规格面板上无法可靠读到单价,闸门一要改为「只截图不判价」,确认页设计随之调整。
|
||||||
|
|||||||
+75
-1
@@ -43,7 +43,7 @@
|
|||||||
| 调用位置 | 采购工具 | 已定 | PC 有算力;改 prompt 不需要重新打包 |
|
| 调用位置 | 采购工具 | 已定 | PC 有算力;改 prompt 不需要重新打包 |
|
||||||
| provider | 待定 | **待定** | 需先确认预算与合规;不得由 agent 自行选定 |
|
| provider | 待定 | **待定** | 需先确认预算与合规;不得由 agent 自行选定 |
|
||||||
| 凭据存储 | 采购工具本机配置文件,不入库、不上传 | 已定 | 采购服务不保存、不代理、不下发任何模型凭据 |
|
| 凭据存储 | 采购工具本机配置文件,不入库、不上传 | 已定 | 采购服务不保存、不代理、不下发任何模型凭据 |
|
||||||
| 输入 | 完整节点树 XML + 页面截图 | 已定 | `dump_hierarchy(compressed=False)` 不丢节点 |
|
| 输入 | 自动复检通过的脱敏派生 XML + 页面截图 | 已定 | 原始证据只允许本机确定性脱敏器消费,AI/agent 不读取原始地址或手机号 |
|
||||||
|
|
||||||
## 四、决策记录与演进
|
## 四、决策记录与演进
|
||||||
|
|
||||||
@@ -103,6 +103,80 @@ D:\Portable\adb\adb.exe devices -l
|
|||||||
和哈希,不得把原始证据提交 Git。USB 与 WiFi 已分别由人完成取证和隐私检查,设备型号、Android、
|
和哈希,不得把原始证据提交 Git。USB 与 WiFi 已分别由人完成取证和隐私检查,设备型号、Android、
|
||||||
拼多多版本、产物路径及 SHA-256 已记录到 T-101;上述命令保留用于可审计的复现与排障。
|
拼多多版本、产物路径及 SHA-256 已记录到 T-101;上述命令保留用于可审计的复现与排障。
|
||||||
|
|
||||||
|
### T-102 按链接打开商品取证(2026-08-04 已完成人工真机验收)
|
||||||
|
|
||||||
|
`client/scripts/capture_product_open.py` 只接受
|
||||||
|
`https://mobile.yangkeduo.com/goods.html?goods_id=<纯数字>` 的唯一 canonical 表示。解析后由
|
||||||
|
`goods_id` 再次重建 URL,并以参数数组执行显式限定 `com.xunmeng.pinduoduo` 的 Android `VIEW`
|
||||||
|
intent;没有任意 URL、任意 shell 或其他 App 控件操作入口。运行拼多多版本必须精确等于 T-101 已
|
||||||
|
取证的 `8.17.0`,版本失配会在 intent 前停止。
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 仓库根目录;USB 或 WiFi 每次只保留一个通道在线,再手工复制该次 serial
|
||||||
|
D:\Portable\adb\adb.exe devices -l
|
||||||
|
.\client\.venv\Scripts\python.exe client\scripts\capture_product_open.py --serial <SERIAL> --url "https://mobile.yangkeduo.com/goods.html?goods_id=<GOODS_ID>" --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-102\product-open-<GOODS_ID>" --timeout 10 --adb D:\Portable\adb\adb.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本在 intent 成功后,以 `--timeout` 为明确上限只读轮询前台 package;只有观察到拼多多才采集截图与
|
||||||
|
完整 XML,超时仍 fail closed。这一等待只解决 App 异步切换,不根据 Activity、节点文本或旧项目常量
|
||||||
|
声称已到详情页。成功目录以原子方式发布,manifest 仅记录 `goods_id`、canonical URL、
|
||||||
|
设备/App 非敏感元数据、受限命令摘要、文件路径和 SHA-256,不含原始 serial、Activity 或页面正文。
|
||||||
|
必须由人本地确认截图对应目标商品并检查截图/XML 无地址、手机号、支付信息或其他无关隐私;原始
|
||||||
|
证据不得提交 Git。T-102 已由人确认 goods_id `958756616606` 对应目标商品并完成截图/XML 隐私检查,
|
||||||
|
设备、版本、证据路径与 SHA-256 已记录到任务执行记录。
|
||||||
|
|
||||||
|
### T-103 规格面板三状态只读取证(T-110 边界调整后待重新验证)
|
||||||
|
|
||||||
|
`client/scripts/capture_sku_panel_spike.py` 只采集人已在手机上准备好的规格面板截图和完整 XML。
|
||||||
|
它不打开链接或规格面板,不识别面板,不点击、滑动、输入或选择规格,也不读取价格;接口只暴露
|
||||||
|
`app_info`、`app_current` 和两个只读 JSON-RPC 方法。`panel-opened-target-preselected`、
|
||||||
|
`alternate-all-dimensions-selected`、`target-selection-restored` 三种值写入 manifest 的字段名是
|
||||||
|
`human_declared_state`,明确表示人工声明,不得将其当作自动识别结果。旧的“未选 / 单维度 / 全选”
|
||||||
|
状态假设已被真机事实推翻,旧枚举会被采集器和脱敏器明确拒绝。运行拼多多版本必须精确为 `8.17.0`,
|
||||||
|
前台 package 必须是拼多多,否则 fail closed;目标目录已存在、截图/XML 无效或超时均不得覆盖已有内容
|
||||||
|
或发布半成品。
|
||||||
|
|
||||||
|
T-103 已证明当前衣服商品没有独立「规格/已选」入口。T-110 只批准拼多多 `8.17.0`、goods_id
|
||||||
|
`937122477375` 上经真机确认的精确唯一 `快要抢光` 作为可逆的受控规格面板入口;“免拼购买 / 单独购买 /
|
||||||
|
直接拼成”等其他文案不能凭人工经验复用,必须分别重新取证。项目所有者已确认面板刚打开时目标颜色
|
||||||
|
“黑色CHA(纯棉)”和尺码“M(建议100-115)”均已自动选中;取证不强行取消选择,而是记录刚打开状态、
|
||||||
|
人工把两个维度都改成非目标值、再恢复目标值三个真实状态。只读取证 CLI 本身仍不执行点击;三种状态
|
||||||
|
分别使用一个全新原始证据目录:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\client\.venv\Scripts\python.exe client\scripts\capture_sku_panel_spike.py --serial <SERIAL> --url "https://mobile.yangkeduo.com/goods.html?goods_id=<GOODS_ID>" --state panel-opened-target-preselected --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-opened-target-<GOODS_ID>-v2\raw" --timeout 10 --adb D:\Portable\adb\adb.exe
|
||||||
|
.\client\.venv\Scripts\python.exe client\scripts\capture_sku_panel_spike.py --serial <SERIAL> --url "https://mobile.yangkeduo.com/goods.html?goods_id=<GOODS_ID>" --state alternate-all-dimensions-selected --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-alternate-<GOODS_ID>-v2\raw" --timeout 10 --adb D:\Portable\adb\adb.exe
|
||||||
|
.\client\.venv\Scripts\python.exe client\scripts\capture_sku_panel_spike.py --serial <SERIAL> --url "https://mobile.yangkeduo.com/goods.html?goods_id=<GOODS_ID>" --state target-selection-restored --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-target-restored-<GOODS_ID>-v2\raw" --timeout 10 --adb D:\Portable\adb\adb.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
PDD 规格面板不可避免显示收货区域和掩码手机号。原始截图/XML 只能留在
|
||||||
|
`%LOCALAPPDATA%\cmbuyer\artifacts\...\raw` 隔离目录,不供 agent、fixture、业务或 HTTP 上传读取;
|
||||||
|
T-103 已在 `client/src/cmbuyer_client/device/sku_evidence_sanitizer.py` 实现本机确定性脱敏器,并由
|
||||||
|
`client/scripts/sanitize_sku_panel_evidence.py` 提供离线 CLI。第一组新 raw 的 PNG 头部由人确认实际为
|
||||||
|
1080×2376,推翻了 v1 将截图和 XML 坐标空间都写成 1080×2400 的假设;v1 正确拒绝且原始证据未重采。
|
||||||
|
随后 v2 在同一份 raw 上安全报告 XML `observed 1080x2376` 并拒绝发布,证明其独立配置的
|
||||||
|
1080×2400 假设同样不成立。当前 `t103-privacy-v3` 配置精确绑定 PKG110 / Android 16 / 拼多多
|
||||||
|
8.17.0 / goods_id `937122477375`;screenshot space 与 XML coordinate space 仍分别建模、分别校验,
|
||||||
|
当前都精确为 1080×2376,且都把
|
||||||
|
`[0,0,1080,540)` 作为整宽隐私带。截图覆盖该区域;XML 递归移除区域内节点,跨界容器只清空自身
|
||||||
|
敏感属性并保留下方子节点。地址依靠已确认的整块几何隔离而不是易漏的关键词表;完整、掩码、带分隔符
|
||||||
|
或跨节点手机号残留会被自动复检拒绝。
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\client\.venv\Scripts\python.exe client\scripts\sanitize_sku_panel_evidence.py --raw-dir "<证据目录>\raw" --output-dir "<证据目录>\derived"
|
||||||
|
```
|
||||||
|
|
||||||
|
脱敏器校验源 manifest 与文件哈希、设备/App/商品/人工状态、截图分辨率和 XML 隐私结构;XML 所有
|
||||||
|
节点观察到的最大 right/bottom 必须精确为 1080×2376,否则只报告非敏感 observed 尺寸并拒绝。它只允许发布到
|
||||||
|
同级且尚不存在的 `derived`;失败或发布竞态不覆盖已有目录、不留下 staging。派生 manifest 记录
|
||||||
|
`privacy_tier=SANITIZED`、sanitizer 版本、两个坐标空间、清理计数及本机 source/derived 哈希,不记录
|
||||||
|
原始路径、serial 或页面正文。只有自动复检通过并经人确认状态对应性的派生物,agent 才能读取并提取
|
||||||
|
最小 fixture、编写判据。
|
||||||
|
|
||||||
|
2026-08-04 的首轮旧证据只用于确认上述入口事实;其中旧 `initial` 不是规格面板,另两张原始截图含隐私
|
||||||
|
区域,因此 XML 未读取、fixture 与选择器未生成。T-110 完成受控入口与脱敏契约后,T-103 重新取证;
|
||||||
|
该边界调整不授权第一趟调整数量、进入确认页、点击“提交订单”或触碰任何支付控件。
|
||||||
|
|
||||||
Windows 的标准入口是仓库根 `./init.ps1`。它要求 Go、两端目录及其哨兵文件存在;已有合规
|
Windows 的标准入口是仓库根 `./init.ps1`。它要求 Go、两端目录及其哨兵文件存在;已有合规
|
||||||
`client/.venv` 时,所有采购工具检查与 validator 都使用该解释器。只有 venv 不存在时,才从 `py -0p`
|
`client/.venv` 时,所有采购工具检查与 validator 都使用该解释器。只有 venv 不存在时,才从 `py -0p`
|
||||||
枚举的版本中确定性选择最高的 Python 3.11+ 创建它;没有合规版本时明确失败,绝不回退默认 `python`。
|
枚举的版本中确定性选择最高的 Python 3.11+ 创建它;没有合规版本时明确失败,绝不回退默认 `python`。
|
||||||
|
|||||||
+39
-10
@@ -97,7 +97,7 @@
|
|||||||
┌──────────────── 第一趟:试选 ────────────────┐
|
┌──────────────── 第一趟:试选 ────────────────┐
|
||||||
│ 采购工具轮询领取 PENDING 任务 │
|
│ 采购工具轮询领取 PENDING 任务 │
|
||||||
│ 1. open_product(url) │
|
│ 1. open_product(url) │
|
||||||
│ 2. 打开规格面板 │
|
│ 2. 经版本绑定、精确唯一的受控入口打开规格面板 │
|
||||||
│ 3. 按维度精确勾选颜色分类、尺码 │
|
│ 3. 按维度精确勾选颜色分类、尺码 │
|
||||||
│ 4. 【闸门一】读该 SKU 单价,算合计 │
|
│ 4. 【闸门一】读该 SKU 单价,算合计 │
|
||||||
│ 5. 截图 │
|
│ 5. 截图 │
|
||||||
@@ -139,6 +139,25 @@
|
|||||||
代价是同一商品走两遍,但第二趟很快,而且换来两个好处:手机可以在人思考时继续跑别的
|
代价是同一商品走两遍,但第二趟很快,而且换来两个好处:手机可以在人思考时继续跑别的
|
||||||
任务的试选;价格变动能被第二趟抓住。
|
任务的试选;价格变动能被第二趟抓住。
|
||||||
|
|
||||||
|
### 第一趟受控规格面板入口
|
||||||
|
|
||||||
|
T-103 的真机证据推翻了“商品详情页存在独立规格入口”的假设:拼多多 8.17.0、goods_id
|
||||||
|
`937122477375` 只能从“快要抢光”打开规格面板。项目所有者于 2026-08-04 批准把这一点击定义为
|
||||||
|
**可逆且能力受限的规格面板导航**,不把它当作下单授权,也不把购买语义文案整体加入白名单。
|
||||||
|
|
||||||
|
第一趟执行器只能持有以下 capability:
|
||||||
|
|
||||||
|
1. 打开 canonical 商品链接。
|
||||||
|
2. 点击与证据哈希、拼多多版本和页面状态绑定的精确唯一 `快要抢光` 入口。
|
||||||
|
3. 在已确认的维度容器中精确选择规格并读回选中态。
|
||||||
|
4. 从规格面板读取单价、生成脱敏派生证据、关闭面板并退出商品页。
|
||||||
|
|
||||||
|
第一趟 capability **不得包含**通用 `click`、数量增减、进入订单确认页、提交订单或付款能力。
|
||||||
|
“免拼购买 / 单独购买 / 直接拼成”等其他文案即使人工认为行为相同,也必须各自重新取证后才能评审;
|
||||||
|
入口缺失、重复、版本失配、打开后面板判据不唯一,或出现“提交订单”以外的未知终态按钮时立即停止。
|
||||||
|
第一趟的静态依赖检查必须证明 `set_quantity()`、`go_to_order_confirm()`、`submit_order()` 和任何
|
||||||
|
支付函数不可达。
|
||||||
|
|
||||||
### 三道价格闸门
|
### 三道价格闸门
|
||||||
|
|
||||||
| 闸门 | 位置 | 作用 | 不通过时 |
|
| 闸门 | 位置 | 作用 | 不通过时 |
|
||||||
@@ -175,7 +194,7 @@ V2 实现时仍遵守:**图搜的唯一产出是 goods_id**,不在搜索结
|
|||||||
| 提交订单控件唯一 | 文本精确等于「提交订单」且可点击祖先唯一,否则停 | 点到未知控件 |
|
| 提交订单控件唯一 | 文本精确等于「提交订单」且可点击祖先唯一,否则停 | 点到未知控件 |
|
||||||
| 数量必须复核 | 设置后读回确认精确等于要求值,否则停 | 买错数量 |
|
| 数量必须复核 | 设置后读回确认精确等于要求值,否则停 | 买错数量 |
|
||||||
| 价格三道闸门 | 见第三节。任一道读不到或不通过即停,**不用其他位置的数字凑合** | 超预算采购 |
|
| 价格三道闸门 | 见第三节。任一道读不到或不通过即停,**不用其他位置的数字凑合** | 超预算采购 |
|
||||||
| 第一趟不下单 | 试选阶段只勾选规格和读价,**绝不点击「现在买」或任何进入下单流程的入口** | 无授权下单 |
|
| 第一趟不下单 | 只允许点击证据/版本绑定的精确唯一受控入口打开规格面板,当前仅为 `快要抢光`;随后只选规格、读价、脱敏取证和返回。数量、确认页、`提交订单`、付款与通用点击能力均不可达 | 无授权下单 |
|
||||||
| 外部支付页 | 检测到微信等外部支付交接立即停止、转人工、保留证据 | 凭据泄露 |
|
| 外部支付页 | 检测到微信等外部支付交接立即停止、转人工、保留证据 | 凭据泄露 |
|
||||||
| 安全校验 | 检测到验证码、风控、人脸、短信校验立即停止,不尝试绕过 | 封号 / 违规 |
|
| 安全校验 | 检测到验证码、风控、人脸、短信校验立即停止,不尝试绕过 | 封号 / 违规 |
|
||||||
| 敏感信息 | 只读非敏感摘要,不提取收货地址原文、手机号、支付凭据 | 隐私泄露 |
|
| 敏感信息 | 只读非敏感摘要,不提取收货地址原文、手机号、支付凭据 | 隐私泄露 |
|
||||||
@@ -368,20 +387,25 @@ PENDING_RETRIAL ─────────────┴─claim→ CLAIMED
|
|||||||
|
|
||||||
| 数据 | 位置 | 理由 |
|
| 数据 | 位置 | 理由 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 候选商品页 / 规格页截图 | 上传采购服务 | 管理员做授权决策必须看 |
|
| 原始商品页 / 规格页 screenshot/XML | **仅采购工具本机隔离目录** | PDD 页面不可避免包含收货区域和掩码手机号;只允许确定性脱敏器读取,不供业务、agent、fixture 或上传消费 |
|
||||||
| 订单确认页截图 | 上传采购服务 | 授权后核对与审计必须留 |
|
| 脱敏派生商品页 / 规格页截图 | 上传采购服务 | 管理员做授权决策必须看;服务端只接收派生哈希和 sanitizer 版本,原始/派生哈希映射仅留本机 manifest |
|
||||||
| 订单核对截图 | 上传采购服务 | 资金核对证据 |
|
| 最小脱敏 XML fixture | 采购工具测试 / 可提交 Git | 只保留页面判据所需结构;自动复检无地址、手机号、支付凭据后才可发布 |
|
||||||
| 完整节点树 XML | **仅采购工具本地** | 体积大、含页面全文、只用于排障 |
|
| 脱敏派生订单确认页截图 | 上传采购服务 | 授权后核对与审计必须留;原始物仍只在本机隔离目录 |
|
||||||
|
| 脱敏派生订单核对截图 | 上传采购服务 | 资金核对证据;原始物仍只在本机隔离目录 |
|
||||||
| AI 调用记录(P1) | **仅采购工具本地** | 含 prompt / 响应全文,脱敏成本高 |
|
| AI 调用记录(P1) | **仅采购工具本地** | 含 prompt / 响应全文,脱敏成本高 |
|
||||||
| 失败现场快照 | 仅采购工具本地,可按需手工导出 | 同上 |
|
| 失败现场快照 | 原始物仅采购工具本机;只能手工导出脱敏派生物 | 同上 |
|
||||||
|
|
||||||
上传前必须脱敏:**不上传含收货地址、手机号、支付凭据的截图区域或文本。**
|
原始目录不得被 HTTP sink、Vikunja 导出、日志或 fixture 构建器读取。脱敏器必须先验证设备分辨率、
|
||||||
|
页面/App 版本和预期隐私区域,再同时生成派生 screenshot/XML;派生 XML 仍命中手机号模式、截图隐私
|
||||||
|
区域无法确定、sanitizer 异常或任一哈希不一致时,不发布派生目录。上传端只接受 manifest 明确标记
|
||||||
|
`privacy_tier=SANITIZED` 的派生截图,**不上传含收货地址、手机号、支付凭据的区域或文本。**
|
||||||
|
|
||||||
## 六、关键技术难点
|
## 六、关键技术难点
|
||||||
|
|
||||||
| 难点 | 说明 | 应对 |
|
| 难点 | 说明 | 应对 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 拼多多页面结构随版本变化 | 前序项目已观察到详情页无独立规格入口、价格节点拆分等变化 | **每条判据先做真机 spike 取证再写代码**;判据与 App 版本一并记录 |
|
| 拼多多页面结构随版本变化 | 前序项目已观察到详情页无独立规格入口、价格节点拆分等变化 | **每条判据先做真机 spike 取证再写代码**;判据与 App 版本一并记录 |
|
||||||
|
| 规格面板安全入口 | T-103 已在拼多多 8.17.0 真机确认衣服商品只能通过购买语义入口打开规格面板 | T-110 已批准仅使用当前证据证明的精确唯一 `快要抢光` 作为受控导航;其他文案不泛化,第一趟下单能力保持不可达 |
|
||||||
| 规格面板上的价格位置 | 选中 SKU 后价格显示在哪、是否含券后前缀,未取证 | **T-103 必须一并取证**,闸门一依赖它;读不到就转人工,不用详情页数字凑合 |
|
| 规格面板上的价格位置 | 选中 SKU 后价格显示在哪、是否含券后前缀,未取证 | **T-103 必须一并取证**,闸门一依赖它;读不到就转人工,不用详情页数字凑合 |
|
||||||
| 同一商品两趟结果不一致 | 第二趟价格变了、规格选项变了或商品下架 | 闸门二拦截;一律转人工,不自动放弃也不自动继续 |
|
| 同一商品两趟结果不一致 | 第二趟价格变了、规格选项变了或商品下架 | 闸门二拦截;一律转人工,不自动放弃也不自动继续 |
|
||||||
| 图搜结果含跨类目商品(V2) | 搜服装出现纸巾 | B 路径只产 goods_id 且限 5 个;后续用 VLM 看截图筛同款 |
|
| 图搜结果含跨类目商品(V2) | 搜服装出现纸巾 | B 路径只产 goods_id 且限 5 个;后续用 VLM 看截图筛同款 |
|
||||||
@@ -395,8 +419,9 @@ PENDING_RETRIAL ─────────────┴─claim→ CLAIMED
|
|||||||
## 七、推荐开发顺序
|
## 七、推荐开发顺序
|
||||||
|
|
||||||
1. **Phase 0 地基**:两端骨架、测试命令、`init` 脚本可运行。
|
1. **Phase 0 地基**:两端骨架、测试命令、`init` 脚本可运行。
|
||||||
2. **Phase 1 真机取证**:WiFi ADB 连通;打开商品 → 打开规格面板 → 按维度精确勾选颜色
|
2. **Phase 1 真机取证**:WiFi ADB 连通;先验证第一趟的打开商品 → 受控打开规格面板 → 按维度
|
||||||
分类和尺码 → **读到该 SKU 单价** → 设数量 → 进订单确认页 → 读「实付款」。
|
精确勾选颜色分类和尺码 → **读到该 SKU 单价** → 脱敏取证 → 退出;再以独立的第二趟 spike
|
||||||
|
验证设数量 → 进订单确认页 → 读「实付款」。第一趟 capability 不含后三项。
|
||||||
**结论写入文档,判据带拼多多 App 版本。**
|
**结论写入文档,判据带拼多多 App 版本。**
|
||||||
3. **Phase 2 采购服务核心**:数据模型与状态机、手工建单、任务查询、试选结果接收、
|
3. **Phase 2 采购服务核心**:数据模型与状态机、手工建单、任务查询、试选结果接收、
|
||||||
确认页与授权签发、**授权超时与放弃**。
|
确认页与授权签发、**授权超时与放弃**。
|
||||||
@@ -408,6 +433,10 @@ PENDING_RETRIAL ─────────────┴─claim→ CLAIMED
|
|||||||
**不要在 Phase 1 结论出来之前写 Phase 2 的页面**——确认页要显示什么,取决于真机上
|
**不要在 Phase 1 结论出来之前写 Phase 2 的页面**——确认页要显示什么,取决于真机上
|
||||||
究竟能读到什么。尤其是闸门一的单价,如果规格面板上读不可靠,整个确认页的设计要改。
|
究竟能读到什么。尤其是闸门一的单价,如果规格面板上读不可靠,整个确认页的设计要改。
|
||||||
|
|
||||||
|
> 2026-08-04:Phase 1 证明已验证衣服商品没有独立规格入口。项目所有者随后批准 T-110 的受控入口
|
||||||
|
> 方案:当前只允许证据绑定的精确唯一 `快要抢光` 打开规格面板,并以 capability 隔离保证数量、确认页、
|
||||||
|
> 提交订单和付款在第一趟不可达。T-103 仍须先实现自动脱敏与新证据门禁,完成前 Phase 2 保持冻结。
|
||||||
|
|
||||||
## 八、项目结构
|
## 八、项目结构
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
+15
-2
@@ -28,6 +28,11 @@
|
|||||||
已消费。
|
已消费。
|
||||||
- **第一趟试选的代码路径不得引用 `go_to_order_confirm()` 与 `submit_order()`**,
|
- **第一趟试选的代码路径不得引用 `go_to_order_confirm()` 与 `submit_order()`**,
|
||||||
必须有测试证明不可达。
|
必须有测试证明不可达。
|
||||||
|
- 第一趟只可通过独立的 `open_trial_sku_panel()` capability 点击本项目真机证据与 App 版本绑定的
|
||||||
|
精确唯一入口;当前仅允许拼多多 `8.17.0` 上已取证的 `快要抢光`。不得向第一趟暴露通用 `click`、
|
||||||
|
`set_quantity()`、订单确认、提交或付款能力,不得把其他购买文案作为包含/同义匹配兜底。
|
||||||
|
- 规格面板内即使可见“提交订单”、微信支付、先用后付或 0 元下单,第一趟也只能把它们作为硬拒绝
|
||||||
|
判据,绝不能返回可点击对象或尝试继续。
|
||||||
|
|
||||||
### 1.2 匹配纪律
|
### 1.2 匹配纪律
|
||||||
|
|
||||||
@@ -51,11 +56,17 @@
|
|||||||
- 检测到外部支付交接立即停止,**不读取、不保存、不输入任何凭据**。
|
- 检测到外部支付交接立即停止,**不读取、不保存、不输入任何凭据**。
|
||||||
- 只读非敏感摘要,**不提取收货地址原文、手机号、支付凭据**。
|
- 只读非敏感摘要,**不提取收货地址原文、手机号、支付凭据**。
|
||||||
- 上传服务端的证据必须先脱敏。
|
- 上传服务端的证据必须先脱敏。
|
||||||
|
- PDD 页面不可避免显示地址/掩码手机号时,原始 screenshot/XML 只允许写入采购工具本机隔离目录,
|
||||||
|
只由确定性脱敏器消费;业务代码、agent、fixture、日志和 HTTP sink 只能读取自动复检通过且 manifest
|
||||||
|
标记 `privacy_tier=SANITIZED` 的派生物。脱敏失败、分辨率/版本不符或派生 XML 仍命中手机号模式时
|
||||||
|
必须拒绝发布,不得用人工口头确认绕过。
|
||||||
|
|
||||||
### 1.5 页面判据
|
### 1.5 页面判据
|
||||||
|
|
||||||
- **不得从前序项目、旧文档或推理直接写页面判据。** 必须有本项目的真机取证。
|
- **不得从前序项目、旧文档或推理直接写页面判据。** 必须有本项目的真机取证。
|
||||||
- 每条判据必须记录取证时的**拼多多 App 版本**。
|
- 每条判据必须记录取证时的**拼多多 App 版本**。
|
||||||
|
- 购买语义按钮不能按“作用相同”共享判据。`快要抢光`、`免拼购买`、`单独购买`、`直接拼成` 等
|
||||||
|
每个入口文案都必须分别取证;只允许精确唯一匹配,不允许包含、前缀、相似或坐标兜底。
|
||||||
- 判据失效时先重新取证,不要靠加兜底分支硬扛。
|
- 判据失效时先重新取证,不要靠加兜底分支硬扛。
|
||||||
|
|
||||||
> 这些红线不是建议。[`04-architecture.md`](04-architecture.md) 第四节列出的每一条都必须有
|
> 这些红线不是建议。[`04-architecture.md`](04-architecture.md) 第四节列出的每一条都必须有
|
||||||
@@ -85,8 +96,10 @@
|
|||||||
- V2 / V3 只记录,不实现。**图搜、Excel、ERP、订单自动核对、AI 辅助全部不在 MVP。**
|
- V2 / V3 只记录,不实现。**图搜、Excel、ERP、订单自动核对、AI 辅助全部不在 MVP。**
|
||||||
- 需求明确排除的非目标不得实现。
|
- 需求明确排除的非目标不得实现。
|
||||||
- 不为「将来可能用到」提前抽象。
|
- 不为「将来可能用到」提前抽象。
|
||||||
- **Phase 1 真机结论出来之前不写 Phase 2 的页面**——确认页显示什么,取决于真机上
|
- **Phase 1 真机结论出来之前,不写依赖真机可读字段或会启动试选的 Phase 2 功能。** T-201
|
||||||
能读到什么,尤其是规格面板上的单价。
|
管理会话与只创建 `DRAFT` 的 T-202 基础建单 / 列表可以并行;T-203 的批量开始试选、T-204
|
||||||
|
的试选证据详情以及 T-205 以后仍等待 T-103。T-201 / T-202 不得夹带机器实际规格、规格面板
|
||||||
|
单价、证据、授权、提交或付款字段。
|
||||||
|
|
||||||
## 5. 架构纪律
|
## 5. 架构纪律
|
||||||
|
|
||||||
|
|||||||
+9
-6
@@ -10,8 +10,9 @@
|
|||||||
把验收要点展开成可执行、可观察的步骤,再开始实现。
|
把验收要点展开成可执行、可观察的步骤,再开始实现。
|
||||||
2. **每个 agent 一次只做一个任务**:领取、状态流转、执行记录、完成定义遵循
|
2. **每个 agent 一次只做一个任务**:领取、状态流转、执行记录、完成定义遵循
|
||||||
[`tasks/README.md`](tasks/README.md) 和[编码规则](05-coding-rules.md)。
|
[`tasks/README.md`](tasks/README.md) 和[编码规则](05-coding-rules.md)。
|
||||||
3. **不跳步**:依赖未完成的任务不能开工。Phase 0 可先做使用假数据的低保真交互原型;
|
3. **不跳步**:依赖未完成的任务不能开工。T-103 完成前可并行 T-201 管理会话与只创建 `DRAFT`
|
||||||
**Phase 1 的真机结论出来之前不写 Phase 2 的生产页面**,T-103 若改变可读字段则先修订原型与 IX。
|
的 T-202 基础建单 / 列表;**依赖真机可读字段或会启动试选的 Phase 2 功能仍不得抢跑**,
|
||||||
|
T-103 若改变可读字段则先修订原型与 IX。
|
||||||
4. **本文只在规划变化时修改**:单个任务开工或完成**不**修改本文。
|
4. **本文只在规划变化时修改**:单个任务开工或完成**不**修改本文。
|
||||||
5. **动手前**先读 `00-ai-start-here.md`、`05-coding-rules.md` 和 `current-state.md`。
|
5. **动手前**先读 `00-ai-start-here.md`、`05-coding-rules.md` 和 `current-state.md`。
|
||||||
|
|
||||||
@@ -24,14 +25,15 @@
|
|||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| 1 | T-001 初始化 `admin/` | T-002 初始化 `client/` | - |
|
| 1 | T-001 初始化 `admin/` | T-002 初始化 `client/` | - |
|
||||||
| 2 | T-001 后立即做 T-004 | T-002 后立即做 T-101 → T-102 → **T-103** | T-001、T-002 都完成后做 T-003 |
|
| 2 | T-001 后立即做 T-004 | T-002 后立即做 T-101 → T-102 → **T-103** | T-001、T-002 都完成后做 T-003 |
|
||||||
| 3 | T-103 通过后推进 T-201 → T-207 | T-103 后推进 T-104 → T-107 | T-208 等待 T-207 与 T-107 均完成 |
|
| 3 | 与 T-103 并行做 T-201 → T-202;T-103 后做 T-203 → T-207 | T-103 后推进 T-104 → T-107 | T-208 等待 T-207 与 T-107 均完成 |
|
||||||
| 4 | T-301 → T-302 | T-302 后交接 T-303 → T-304 → T-306 | T-306 与 T-104 完成后做 T-305 |
|
| 4 | T-301 → T-302 | T-302 后交接 T-303 → T-304 → T-306 | T-306 与 T-104 完成后做 T-305 |
|
||||||
| 5 | - | T-305、T-208、T-306 后做 T-401 | T-401 后并行 T-402 / T-403,再做 T-404 → T-405 |
|
| 5 | - | T-305、T-208、T-306 后做 T-401 | T-401 后并行 T-402 / T-403,再做 T-404 → T-405 |
|
||||||
|
|
||||||
**T-103 是当前最高优先级和 MVP 生死线。** T-002 一完成就启动 T-101,不等待 T-003、
|
**T-103 是当前最高优先级和 MVP 生死线。** T-002 一完成就启动 T-101,不等待 T-003、
|
||||||
T-004 或整个 Phase 0 收尾。并行只优化等待关系,不改变下列门禁:
|
T-004 或整个 Phase 0 收尾。并行只优化等待关系,不改变下列门禁:
|
||||||
|
|
||||||
- T-103 的真机结论出来前,不写依赖真机可读字段的 Phase 2 生产页面。
|
- T-103 的真机结论出来前,T-201 / T-202 只能落管理会话、`DRAFT` 建单与基础列表;不启动试选,
|
||||||
|
不展示机器规格、规格面板单价或证据。T-203~T-207 继续等待 T-103。
|
||||||
- `needs_device: true` 的任务仍只能由人完成验收。
|
- `needs_device: true` 的任务仍只能由人完成验收。
|
||||||
- 不复用前序项目页面判据,不放宽三道价格闸门,不让第一趟引用任何下单函数。
|
- 不复用前序项目页面判据,不放宽三道价格闸门,不让第一趟引用任何下单函数。
|
||||||
- 第三个 agent 优先做写路径独立的集成任务或只读复核,不与两端任务争写共享文档。
|
- 第三个 agent 优先做写路径独立的集成任务或只读复核,不与两端任务争写共享文档。
|
||||||
@@ -126,8 +128,9 @@ T-004 或整个 Phase 0 收尾。并行只优化等待关系,不改变下列
|
|||||||
- **M5**:第一趟试选端到端跑通,任务能停在「等你确认」。(T-305)
|
- **M5**:第一趟试选端到端跑通,任务能停在「等你确认」。(T-305)
|
||||||
- **M6**:MVP 闭环——第二趟下单成功,任务停在「待付款」。(T-401)
|
- **M6**:MVP 闭环——第二趟下单成功,任务停在「待付款」。(T-401)
|
||||||
|
|
||||||
**M2 是本项目的生死线。** 前序项目正是卡在选规格和读价;M2 不通过之前不要写 Phase 2
|
**M2 是本项目的生死线。** 前序项目正是卡在选规格和读价;M2 不通过之前只允许 T-201 与
|
||||||
的生产页面。Phase 0 原型只用于确认信息架构,T-103 若改变可读字段必须先回修原型与 IX。
|
不启动试选的 T-202,不写依赖真机字段或会推进任务执行的 Phase 2 功能。原型只用于确认信息架构,
|
||||||
|
T-103 若改变可读字段必须先回修原型与 IX。
|
||||||
|
|
||||||
## 待办池(Backlog)
|
## 待办池(Backlog)
|
||||||
|
|
||||||
|
|||||||
+40
-15
@@ -168,6 +168,27 @@
|
|||||||
- 无可领任务返回 `200` 且 `task` 为 `null`,**不是 404**。
|
- 无可领任务返回 `200` 且 `task` 为 `null`,**不是 404**。
|
||||||
- 后续所有该任务的调用必须携带 `X-Claim-Token` 与匹配的 `claim_generation`。
|
- 后续所有该任务的调用必须携带 `X-Claim-Token` 与匹配的 `claim_generation`。
|
||||||
|
|
||||||
|
### `POST /api/v1/tasks/{id}/evidence`(只接收脱敏派生物)
|
||||||
|
|
||||||
|
规格面板和订单确认页可能固定展示地址与掩码手机号。采购工具必须先在本机隔离目录保存原始证据,
|
||||||
|
再由确定性脱敏器生成派生 screenshot/XML;本接口只接受派生截图及如下审计元数据:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kind": "SKU_PANEL_SCREENSHOT",
|
||||||
|
"privacy_tier": "SANITIZED",
|
||||||
|
"artifact_sha256": "<派生截图 SHA-256>",
|
||||||
|
"sanitizer_version": "sku-panel-pdd-8.17.0-v1"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `privacy_tier` 必须精确为 `SANITIZED`;缺失、其他值或 sanitizer 元数据不完整均拒绝。
|
||||||
|
- 上传内容的 SHA-256 必须等于 `artifact_sha256`。服务端不接收原始文件、原始哈希、原始路径、
|
||||||
|
原始 XML、地址、手机号或支付凭据;原始/派生哈希映射只存在于采购工具本机 manifest。
|
||||||
|
- 原始目录不得被 `HttpResultSink` 或证据上传器枚举;调用方必须显式传入已原子发布的派生目录。
|
||||||
|
- 完整 XML 永不上传。经自动复检的最小脱敏 XML 只用于采购工具离线 fixture;仍命中手机号模式或
|
||||||
|
脱敏结果不确定时,客户端调用 `/needs-manual`,不得上传截图或继续试选。
|
||||||
|
|
||||||
### `POST /api/v1/tasks/{id}/spec-trial`(第一趟回传)
|
### `POST /api/v1/tasks/{id}/spec-trial`(第一趟回传)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -187,7 +208,7 @@
|
|||||||
- `unit_price` 来自闸门一(规格面板)。**读不到时不要发这个接口**,改发
|
- `unit_price` 来自闸门一(规格面板)。**读不到时不要发这个接口**,改发
|
||||||
`/needs-manual` 并带原因码 `UNIT_PRICE_UNREADABLE`。
|
`/needs-manual` 并带原因码 `UNIT_PRICE_UNREADABLE`。
|
||||||
- `total_price` = `unit_price` × 任务数量,服务端会重算校验。
|
- `total_price` = `unit_price` × 任务数量,服务端会重算校验。
|
||||||
- 证据须先经 `/evidence` 上传。
|
- 证据须先经 `/evidence` 上传,且对应资产必须为 `privacy_tier=SANITIZED`。
|
||||||
- 服务端接收后创建 `spec_trials` 记录,任务转 `WAITING_CONFIRMATION`。
|
- 服务端接收后创建 `spec_trials` 记录,任务转 `WAITING_CONFIRMATION`。
|
||||||
|
|
||||||
### dry-run 与真实提交协议
|
### dry-run 与真实提交协议
|
||||||
@@ -282,25 +303,29 @@ class ResultSink(ABC):
|
|||||||
|
|
||||||
### 真机流程模块
|
### 真机流程模块
|
||||||
|
|
||||||
`client/src/android/pdd_flow.py` 的公开入口,每个都不得越界:
|
`client/src/android/pdd_flow.py` 的公开入口按 capability 分离,每个都不得越界:
|
||||||
|
|
||||||
| 函数 | 输入 | 输出 | 副作用边界 |
|
| 函数 | 可用趟次 | 输入 | 输出 | 副作用边界 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| `open_product(url)` | 商品 URL | 页面快照路径 | 只打开页面,不点击购买 |
|
| `open_product(url)` | TRIAL / ORDER | 商品 URL | 页面快照路径 | 只打开页面,不点击控件 |
|
||||||
| `open_sku_panel()` | - | 面板快照 | 只点规格入口,不提交 |
|
| `open_trial_sku_panel(evidence_key)` | **仅 TRIAL** | 版本与证据绑定键 | 面板快照 | 只点击精确唯一、已取证的受控入口;当前仅 `快要抢光`,无通用 click |
|
||||||
| `select_sku_options(items)` | `{维度: 值}` | 选中证据 | 按维度精确匹配,找不到抛错 |
|
| `select_sku_options(items)` | TRIAL / ORDER | `{维度: 值}` | 选中证据 | 按维度精确匹配,找不到抛错 |
|
||||||
| `set_quantity(n)` | 数量 | 读回值 | 必须复核等于 n |
|
| `sanitize_evidence(raw_manifest)` | TRIAL / ORDER | 本机隔离目录 manifest | 派生 manifest | 原子发布脱敏派生物;失败不发布,原始内容不进入日志/上传 |
|
||||||
| `read_sku_unit_price(xml)` | 规格面板 XML | 单价或 `None` | **闸门一**;读不到返回 `None`,不猜 |
|
| `read_sku_unit_price(xml)` | TRIAL / ORDER | 脱敏规格面板 XML | 单价或 `None` | **闸门一 / 二**;读不到返回 `None`,不猜 |
|
||||||
| `leave_product()` | - | - | 第一趟结束时退出并释放手机 |
|
| `leave_product()` | TRIAL / ORDER | - | - | 第一趟结束时退出并释放手机 |
|
||||||
| `go_to_order_confirm()` | - | 确认页摘要 | **可能创建订单**,需显式授权 |
|
| `set_quantity(n)` | **仅 ORDER** | 数量 | 读回值 | 必须复核等于 n;TRIAL capability 不暴露 |
|
||||||
| `read_order_confirm_info(xml)` | 页面 XML | 非敏感摘要 | **闸门三**;不提取地址原文、手机号 |
|
| `go_to_order_confirm()` | **仅 ORDER** | - | 确认页摘要 | 需显式授权;TRIAL capability 不暴露 |
|
||||||
| `submit_order(auth, submission)` | 授权 + 已建立的提交围栏 | 提交结果 | **唯一创建真实订单入口**,四条件与围栏全通过后只点一次 |
|
| `read_order_confirm_info(xml)` | **仅 ORDER** | 脱敏页面 XML | 非敏感摘要 | **闸门三**;不提取地址原文、手机号 |
|
||||||
|
| `submit_order(auth, submission)` | **仅 ORDER** | 授权 + 已建立的提交围栏 | 提交结果 | **唯一创建真实订单入口**,四条件与围栏全通过后只点一次 |
|
||||||
|
|
||||||
两个不可逆入口:
|
能力隔离规则:
|
||||||
|
|
||||||
- `go_to_order_confirm()` 必须校验授权存在;`submit_order()` 还必须校验授权已由服务端围栏
|
- `go_to_order_confirm()` 必须校验授权存在;`submit_order()` 还必须校验授权已由服务端围栏
|
||||||
且 `submission` 与当前任务、命令、授权完全一致。
|
且 `submission` 与当前任务、命令、授权完全一致。
|
||||||
- **第一趟的代码路径不得引用这两个函数。** 必须有测试证明试选流程不可达它们。
|
- 第一趟只能拿到 `TrialSkuFlow` 窄接口,接口中不得出现通用 `click`、`set_quantity()`、
|
||||||
|
`go_to_order_confirm()`、`submit_order()` 或支付能力;静态依赖测试必须证明试选流程不可达它们。
|
||||||
|
- `open_trial_sku_panel()` 的点击是唯一批准的购买语义控件例外,只用于打开已取证规格面板;入口
|
||||||
|
缺失/重复、App 版本不符、面板判据不唯一或出现未知终态控件时停止。其他入口文案不得推断复用。
|
||||||
- `search_by_image()` 属 B 路径,V2 再实现。
|
- `search_by_image()` 属 B 路径,V2 再实现。
|
||||||
|
|
||||||
## 四、待实现时确认
|
## 四、待实现时确认
|
||||||
|
|||||||
+63
-11
@@ -11,16 +11,17 @@
|
|||||||
|
|
||||||
## 当前快照
|
## 当前快照
|
||||||
|
|
||||||
- 日期:2026-08-03
|
- 日期:2026-08-04
|
||||||
- 阶段:**Phase 0 · 地基(两端骨架、核心数据模型与统一入口已完成,尚无真机采购业务代码)**
|
- 阶段:**Phase 1 · 真机可行性(T-110 已批准受控规格入口,T-103 重新执行)**
|
||||||
- MVP 形态:手工填链接建单 → 批量开始试选 → 定时轮询 → **第一趟试选** → 人工确认 → **第二趟下单** → 待付款
|
- MVP 形态:手工填链接建单 → 批量开始试选 → 定时轮询 → **第一趟试选** → 人工确认 → **第二趟下单** → 待付款
|
||||||
- 技术栈:已定。采购服务(`admin/`)使用 Go 1.23+ / gin / SQLite;采购工具(`client/`)
|
- 技术栈:已定。采购服务(`admin/`)使用 Go 1.23+ / gin / SQLite;采购工具(`client/`)
|
||||||
使用 Python 3.11+ / uiautomator2 / PySide6。
|
使用 Python 3.11+ / uiautomator2 / PySide6。
|
||||||
详见 [`03-tech-stack.md`](03-tech-stack.md)
|
详见 [`03-tech-stack.md`](03-tech-stack.md)
|
||||||
- 生产代码:`admin/` 已有最小 Go 服务、健康检查、核心领域模型、SQLite 迁移与任务状态机;
|
- 生产代码:`admin/` 已有最小 Go 服务、健康检查、核心领域模型、SQLite 迁移与任务状态机;
|
||||||
`client/` 已有 Python 包、PySide6 最小入口、运行目录与日志脱敏策略,以及显式 serial 的 ADB
|
`client/` 已有 Python 包、PySide6 最小入口、运行目录与日志脱敏策略,以及显式 serial 的 ADB
|
||||||
连接边界与本地基线取证 CLI;尚无真机采购流程
|
连接边界、本地基线取证 CLI、受限商品链接打开取证 CLI、人工声明规格面板状态的只读取证 CLI,
|
||||||
- 测试:采购服务已覆盖健康检查、核心模型、迁移与状态机等离线包级测试;采购工具 30 项离线单元测试
|
以及绑定 PKG110 / Android 16 / 拼多多 8.17.0 的规格证据确定性脱敏 CLI;尚无规格选择、价格读取或下单流程
|
||||||
|
- 测试:采购服务已覆盖健康检查、核心模型、迁移与状态机等离线包级测试;采购工具 69 项离线单元测试
|
||||||
(全部 mock,不连接真机)
|
(全部 mock,不连接真机)
|
||||||
- 数据:SQLite 核心表与迁移已落成;无业务实例数据
|
- 数据:SQLite 核心表与迁移已落成;无业务实例数据
|
||||||
- 标准启动路径:Windows PowerShell 运行 `./init.ps1`,Unix shell 运行 `./init.sh`。Windows 入口
|
- 标准启动路径:Windows PowerShell 运行 `./init.ps1`,Unix shell 运行 `./init.sh`。Windows 入口
|
||||||
@@ -28,19 +29,24 @@
|
|||||||
并且不覆盖低版本环境;成功后打印真实启动命令。
|
并且不覆盖低版本环境;成功后打印真实启动命令。
|
||||||
- 标准验证路径:`./init.ps1` 已实际跑通 admin 的 mod download / test / vet / build、client 的
|
- 标准验证路径:`./init.ps1` 已实际跑通 admin 的 mod download / test / vet / build、client 的
|
||||||
editable install / 包导入 / unittest / compileall,以及仓库上下文校验。可单独运行两端命令诊断。
|
editable install / 包导入 / unittest / compileall,以及仓库上下文校验。可单独运行两端命令诊断。
|
||||||
- 当前 blocker:无外部 blocker。T-101 已完成人工 USB/WiFi 双通道验收;下一步落成并领取 T-102,
|
- 当前设备门禁:人工已确认拼多多 8.17.0、goods_id `937122477375` 的衣服商品只能通过“快要抢光”
|
||||||
验证按链接打开商品详情页。桌面 GUI 与后续真机采购流程尚未验收。
|
打开规格面板;T-110 已获项目所有者批准,只把该证据/版本绑定的精确唯一入口作为第一趟可逆导航,
|
||||||
|
数量、确认页、提交订单、付款与通用点击能力仍不可达。T-103 的原始证据本机隔离与确定性脱敏器
|
||||||
|
已完成离线实现;人工确认面板刚打开时目标颜色和尺码已经自动选中,取证三态已据此修正。下一步
|
||||||
|
用 v3 对已采第一态 raw 重新脱敏;截图与 XML 已分别安全观测为 1080×2376,仍作为两个独立坐标
|
||||||
|
空间严格校验。第一态 derived 经人验收后,再采集“两个维度改为非目标 / 两个维度恢复目标”的 raw,
|
||||||
|
并由人只核对 derived。在派生证据验收前不写页面判据,Phase 2 仍不能抢跑。
|
||||||
|
|
||||||
## 当前目录要点
|
## 当前目录要点
|
||||||
|
|
||||||
| 路径 | 状态 | 说明 |
|
| 路径 | 状态 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `docs/` | 已有 | 项目规范化文档,本次已完整生成 |
|
| `docs/` | 已有 | 项目规范化文档,本次已完整生成 |
|
||||||
| `docs/tasks/` | 已有(T-001~T-004、T-005~T-009、T-101) | T-001~T-004、T-101 已完成;下一任务为 T-102 |
|
| `docs/tasks/` | 已有(T-001~T-004、T-005~T-009、T-101~T-110) | T-001~T-004、T-101~T-102、T-110 已完成;T-103 重新执行 |
|
||||||
| `docs/design/` | 已有(6 个原型) | web 登录 / 建单 / 工作台 / 详情,desk 采购执行 / 配置;均已人工确认 |
|
| `docs/design/` | 已有(6 个原型) | web 登录 / 建单 / 工作台 / 详情,desk 采购执行 / 配置;均已人工确认 |
|
||||||
| `scripts/` | 已有 | 上下文门禁、Vikunja 单向导出与 MCP 启动包装 |
|
| `scripts/` | 已有 | 上下文门禁、Vikunja 单向导出与 MCP 启动包装 |
|
||||||
| `admin/` | 已初始化 | Go 1.23+ / gin / SQLite,含核心模型、迁移与状态机;无真机采购执行 |
|
| `admin/` | 已初始化 | Go 1.23+ / gin / SQLite,含核心模型、迁移与状态机;无真机采购执行 |
|
||||||
| `client/` | 已初始化 | Python 3.11+ 包、依赖源、PySide6 最小入口、显式 serial 的设备基线取证、离线测试与 wheel 元数据检查;无采购流程 |
|
| `client/` | 已初始化 | Python 3.11+ 包、依赖源、PySide6 最小入口、显式 serial 的基线/商品打开/规格面板只读取证、确定性证据脱敏、离线测试与 wheel 元数据检查;无规格选择、价格读取或下单流程 |
|
||||||
| `init.ps1` / `init.sh` | 已完成 | 统一安装与离线验证入口;PowerShell 优先复用合规 venv,缺失时自动选择最高的 Python 3.11+,Unix 缺工具链明确失败 |
|
| `init.ps1` / `init.sh` | 已完成 | 统一安装与离线验证入口;PowerShell 优先复用合规 venv,缺失时自动选择最高的 Python 3.11+,Unix 缺工具链明确失败 |
|
||||||
|
|
||||||
## 任务状态
|
## 任务状态
|
||||||
@@ -51,9 +57,11 @@
|
|||||||
源码目录契约)、T-008(Vikunja 任务权威与单向导出)、T-009(MVP 关键路径与并行波次),
|
源码目录契约)、T-008(Vikunja 任务权威与单向导出)、T-009(MVP 关键路径与并行波次),
|
||||||
以及 T-001(采购服务 Go 骨架)。
|
以及 T-001(采购服务 Go 骨架)。
|
||||||
- 已完成:T-002(采购工具 Python 骨架)、T-003(双端统一初始化与验证入口)、
|
- 已完成:T-002(采购工具 Python 骨架)、T-003(双端统一初始化与验证入口)、
|
||||||
T-004(核心数据模型)、T-101(真机环境盘点与 USB/WiFi 双通道人工验收)。下一步推进
|
T-004(核心数据模型)、T-101(真机环境盘点与 USB/WiFi 双通道人工验收)、T-102(canonical
|
||||||
T-102 → T-103。
|
链接打开与目标商品/隐私人工验收)。
|
||||||
- T-103 是当前最高优先级和 MVP 生死线。通过前不开发依赖真机可读字段的 Phase 2 生产页面。
|
- 已完成 T-110(第一趟受控规格入口与隐私脱敏边界)。T-103 是当前最高优先级和 MVP 生死线,
|
||||||
|
已补充 T-110 依赖并恢复 `DOING`;脱敏器已完成,三态派生证据和新真机验收完成前不开发依赖真机可读字段的
|
||||||
|
Phase 2 生产页面。
|
||||||
- 已确认原型继续只作信息架构依据;原型假数据不调用真实接口、不驱动真机。真机结论改变
|
- 已确认原型继续只作信息架构依据;原型假数据不调用真实接口、不驱动真机。真机结论改变
|
||||||
可读字段时必须先回修原型与交互清单。
|
可读字段时必须先回修原型与交互清单。
|
||||||
|
|
||||||
@@ -117,6 +125,50 @@ D:\Portable\adb\adb.exe devices -l
|
|||||||
公开 JSON-RPC 调用,uiautomator2 初始化仍有上游固定启动上限。截图/XML 只保留在本地,执行记录只写
|
公开 JSON-RPC 调用,uiautomator2 初始化仍有上游固定启动上限。截图/XML 只保留在本地,执行记录只写
|
||||||
路径和 SHA-256,原始证据不得提交 Git。
|
路径和 SHA-256,原始证据不得提交 Git。
|
||||||
|
|
||||||
|
T-102 的人工真机验收命令(只接受唯一 canonical 链接;USB 或 WiFi 每次只保留一个通道在线):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 仓库根目录;从输出中手工复制本次在线 serial
|
||||||
|
D:\Portable\adb\adb.exe devices -l
|
||||||
|
.\client\.venv\Scripts\python.exe client\scripts\capture_product_open.py --serial <SERIAL> --url "https://mobile.yangkeduo.com/goods.html?goods_id=<GOODS_ID>" --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-102\product-open-<GOODS_ID>" --timeout 10 --adb D:\Portable\adb\adb.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本只允许 Android `VIEW` intent,并把 package 固定为 `com.xunmeng.pinduoduo`;intent 后会在
|
||||||
|
`--timeout` 的有限窗口内只读轮询前台 package,解决 App 异步切换造成的一次性误判,超时仍会停止。
|
||||||
|
它不点击、滑动、输入或判断商品页节点,也不打开规格、读取价格、进入下单或支付。运行拼多多版本必须精确为
|
||||||
|
`8.17.0`,否则在 intent 前停止。成功后由人本地查看截图/XML,确认页面确为该 `goods_id` 对应商品并
|
||||||
|
检查无地址、手机号、支付信息或其他无关隐私;只回报 manifest 路径及截图/XML SHA-256,原始证据
|
||||||
|
不得提交 Git。T-102 已由人确认 goods_id `958756616606` 的目标商品及截图/XML 隐私,并完成验收。
|
||||||
|
|
||||||
|
T-103 已确认当前衣服商品只能从精确文案“快要抢光”进入规格面板;T-110 只批准该证据/版本绑定入口,
|
||||||
|
不授权“免拼购买 / 单独购买 / 直接拼成”等其他文案。现有只读取证脚本本身仍不打开面板、不点击或选择
|
||||||
|
规格,也不读取价格;`--state` 只是人工声明,不能作为自动判据。面板刚打开时目标颜色
|
||||||
|
“黑色CHA(纯棉)”与尺码“M(建议100-115)”已经自动选中,不强行取消。人依次准备刚打开目标预选、
|
||||||
|
颜色和尺码均为非目标值、再恢复目标值三个状态,并分别采集到全新 `raw` 目录:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 仓库根目录;三次采集使用三个全新的 output-dir
|
||||||
|
.\client\.venv\Scripts\python.exe client\scripts\capture_sku_panel_spike.py --serial <SERIAL> --url "https://mobile.yangkeduo.com/goods.html?goods_id=<GOODS_ID>" --state panel-opened-target-preselected --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-opened-target-<GOODS_ID>-v2\raw" --timeout 10 --adb D:\Portable\adb\adb.exe
|
||||||
|
.\client\.venv\Scripts\python.exe client\scripts\capture_sku_panel_spike.py --serial <SERIAL> --url "https://mobile.yangkeduo.com/goods.html?goods_id=<GOODS_ID>" --state alternate-all-dimensions-selected --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-alternate-<GOODS_ID>-v2\raw" --timeout 10 --adb D:\Portable\adb\adb.exe
|
||||||
|
.\client\.venv\Scripts\python.exe client\scripts\capture_sku_panel_spike.py --serial <SERIAL> --url "https://mobile.yangkeduo.com/goods.html?goods_id=<GOODS_ID>" --state target-selection-restored --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-target-restored-<GOODS_ID>-v2\raw" --timeout 10 --adb D:\Portable\adb\adb.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
每组 raw 采集成功后分别运行离线脱敏器;`derived` 必须尚不存在:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\client\.venv\Scripts\python.exe client\scripts\sanitize_sku_panel_evidence.py --raw-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-opened-target-<GOODS_ID>-v2\raw" --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-opened-target-<GOODS_ID>-v2\derived"
|
||||||
|
.\client\.venv\Scripts\python.exe client\scripts\sanitize_sku_panel_evidence.py --raw-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-alternate-<GOODS_ID>-v2\raw" --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-alternate-<GOODS_ID>-v2\derived"
|
||||||
|
.\client\.venv\Scripts\python.exe client\scripts\sanitize_sku_panel_evidence.py --raw-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-target-restored-<GOODS_ID>-v2\raw" --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-target-restored-<GOODS_ID>-v2\derived"
|
||||||
|
```
|
||||||
|
|
||||||
|
原始 screenshot/XML 可能包含 PDD 固定展示的地址和掩码手机号,只能留在本机 `raw` 目录,不能由
|
||||||
|
agent、fixture、业务或上传端消费。`t103-privacy-v3` 把截图与 XML 保持为两个独立坐标空间,
|
||||||
|
并根据同一第一态 raw 的两次 fail-closed 安全观测将二者分别精确固定为 1080×2376;它校验源哈希、
|
||||||
|
设备/App/商品/人工状态与隐私结构,在 sibling
|
||||||
|
`derived` 目录原子发布 screenshot/XML 与 manifest;任何不匹配、手机号残留或已有目标均拒绝发布。
|
||||||
|
自动复检通过且人确认三态对应性后,agent 才能读取派生物并提取最小 fixture、编写判据。
|
||||||
|
人工确认中还必须记录中间态实际选择的非目标颜色和尺码,不能只写“已切换”。
|
||||||
|
|
||||||
## 关键背景
|
## 关键背景
|
||||||
|
|
||||||
本项目是 `cmroubao`(Go 后端 + Android AccessibilityService)与 `cmpdd`
|
本项目是 `cmroubao`(Go 后端 + Android AccessibilityService)与 `cmpdd`
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
id: T-010
|
||||||
|
title: 收窄 Phase 2 门禁并启动安全并行
|
||||||
|
phase: 0
|
||||||
|
deps: [T-009]
|
||||||
|
status: DONE
|
||||||
|
created: 2026-08-04
|
||||||
|
vikunja_task_id: 25
|
||||||
|
context_ref: d27fda6
|
||||||
|
work_branch: task/t-010-parallel-gate
|
||||||
|
needs_device: false
|
||||||
|
needs_human_review: false
|
||||||
|
write_paths:
|
||||||
|
- docs/tasks/T-010.md
|
||||||
|
- docs/00-ai-start-here.md
|
||||||
|
- docs/05-coding-rules.md
|
||||||
|
- docs/06-tasks.md
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- BEGIN VIKUNJA EXPORT id=25 synced=2026-08-04T06:39:13Z sha256=b3485476184414158cac243aa3f2d08567372179c2e91c2cf4bb5552e7191510 -->
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
客户要求加快 MVP。现有全局门禁把全部 Phase 2 页面都冻结到 T-103 完成,范围过宽;管理员登录与只创建 DRAFT 的基础建单不读取真机页面字段,也不会启动试选或触发下单。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
1. 把门禁收窄为:T-103 前允许 T-201 管理会话与 T-202 DRAFT 建单/基础列表;T-203 的批量开始试选、T-204 试选证据详情和 T-205 以后继续冻结。
|
||||||
|
2. 同步 AI 入口、编码规则和路线图;不修改 T-103 安全边界,不放宽不付款、第一趟禁下单、真机判据先取证或隐私证据分层。
|
||||||
|
3. 并行启动 admin T-201;共享文档由本任务所有者统一修改。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- 门禁文字不存在“冻结全部 Phase 2”的歧义。
|
||||||
|
- T-201/T-202 不得出现 PDD 页面判据、试选结果、证据、授权、提交或支付能力。
|
||||||
|
- 上下文校验、Vikunja 导出检查与 diff-check 通过。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
### 2026-08-04T06:38:51Z · ila
|
||||||
|
|
||||||
|
2026-08-04 完成并行门禁收窄:提交 3f2e0e5 更新 AI 入口、编码规则和路线图。T-103 期间只放行 T-201 管理会话与不启动试选的 T-202 DRAFT 建单/基础列表;T-203~T-207 继续等待 T-103。不付款、第一趟禁下单、真机判据与证据边界均未放宽。主 agent 复跑 agent-context validator、冲突文案检索和 git diff --check,全部通过。
|
||||||
|
<!-- END VIKUNJA EXPORT -->
|
||||||
|
|
||||||
|
## 边界
|
||||||
|
|
||||||
|
- 本任务只调整依赖门禁和并行顺序,不实现任何采购服务、真机自动化或页面判据代码。
|
||||||
|
- T-103 完成前只放行与真机可读字段无关的 T-201 管理会话,以及只创建 `DRAFT`、不启动试选的
|
||||||
|
T-202 基础建单与列表壳。
|
||||||
|
- T-203 的批量 `DRAFT → PENDING`、T-204 的试选证据详情以及 T-205 以后仍由 T-103 阻塞;不得用
|
||||||
|
假字段、原型假数据或前序项目结论提前固化生产契约。
|
||||||
|
- 不付款、第一趟不可达下单、价格读取位置、真机判据先取证、原始证据本机隔离与派生物消费边界
|
||||||
|
均保持不变。
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
---
|
||||||
|
id: T-102
|
||||||
|
title: 验证按链接打开商品详情页
|
||||||
|
phase: 1
|
||||||
|
deps: [T-101]
|
||||||
|
status: DONE
|
||||||
|
created: 2026-08-04
|
||||||
|
vikunja_task_id: 22
|
||||||
|
context_ref: 393f26d
|
||||||
|
work_branch: task/t-102-open-product
|
||||||
|
needs_device: true
|
||||||
|
needs_human_review: true
|
||||||
|
write_paths:
|
||||||
|
- docs/tasks/T-102.md
|
||||||
|
- client/src/cmbuyer_client/pdd/**
|
||||||
|
- client/src/cmbuyer_client/device/**
|
||||||
|
- client/tests/pdd/**
|
||||||
|
- client/tests/device/**
|
||||||
|
- client/scripts/capture_product_open.py
|
||||||
|
- docs/03-tech-stack.md
|
||||||
|
- docs/04-architecture.md
|
||||||
|
- docs/current-state.md
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- BEGIN VIKUNJA EXPORT id=22 synced=2026-08-04T01:50:40Z sha256=ee2405865a36aa01a3503e6d24d62a3dddd0f19b451845e1e2c3937734c31442 -->
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
T-101 已证明同一台 PKG110(Android 16、拼多多 8.17.0)可通过 USB/WiFi 显式 serial 完成只读截图与完整 XML 取证。MVP 下一风险是任务自带的合法拼多多链接能否由 Android intent 打开到对应商品详情页。此前项目和旧文档不能证明当前 App 的行为;若先写详情页判据,会把未经本项目真机验证的假设带入 T-103。
|
||||||
|
|
||||||
|
## 关联需求与交互
|
||||||
|
|
||||||
|
- 功能:F-006 的第一步 `open_product(url)`;仅覆盖链接打开,不覆盖规格面板、规格选择或价格。
|
||||||
|
- 用户故事 / 交互:采购工具执行链接任务前的只读真机 spike;无生产 GUI。
|
||||||
|
- 架构 / API:`docs/04-architecture.md` 第三节 A 路径;`docs/api.md` 的 canonical `product_url` / `goods_id` 示例;T-103 前置。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
1. 只接受 HTTPS canonical 链接 `https://mobile.yangkeduo.com/goods.html?goods_id=<纯数字>`;拒绝 userinfo、非默认端口、fragment、重复/缺失/非数字 goods_id、其他 host/path 和额外业务参数。解析后由代码按 goods_id 重建 canonical URL,绝不把任意输入拼入 shell。
|
||||||
|
2. 复用 T-101 的显式 serial、同机多通道 fail-closed、no-reconnect、类型化超时和原子证据发布边界;运行拼多多版本必须精确等于本项目已取证的 8.17.0,否则在打开链接前停止。
|
||||||
|
3. 通过参数数组执行只含 Android `VIEW` intent 语义的 `adb shell am start -W`,显式限定包 `com.xunmeng.pinduoduo`;不使用 shell 字符串、不点击/滑动/输入任何控件。Android 命令失败、超时、未解析 intent、未停留在拼多多包、截图/XML 失败分别给出脱敏错误。
|
||||||
|
4. 启动后只读取 current app/package、截图和 `dumpWindowHierarchy(compressed=False)`,manifest 记录 goods_id、canonical URL、设备/App 元数据、命令结果摘要、路径与 SHA-256,不记录页面全文或原始 serial。原始截图/XML 仅存 `%LOCALAPPDATA%`,不提交 Git。
|
||||||
|
5. 离线 mock 测试覆盖 URL 正反例、参数数组与超时、版本失配在 intent 前停止、包不匹配、无 UI 点击 API、证据原子性和异常脱敏。代码完成后由人使用一个明确 goods_id 在仅单通道在线的真机执行,人工确认截图确为对应商品详情页并检查 XML/截图无敏感信息;再把设备、Android、拼多多版本、goods_id、路径、SHA-256 与结论写入执行记录。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- 合法 canonical 链接可在拼多多 8.17.0 上打开;人工确认到达与 goods_id 对应的商品详情页。
|
||||||
|
- 非法链接、设备状态、App 版本失配、intent 失败/超时、落在非拼多多包、证据失败均有可区分且不泄露页面内容的错误。
|
||||||
|
- 取证记录包含 PKG110、Android 16、连接方式、拼多多 8.17.0、goods_id、canonical URL、截图/XML 本地路径与 SHA-256。
|
||||||
|
- client 全部单测、compileall、wheel metadata、上下文校验及完整 `init.ps1` 通过。
|
||||||
|
- `needs_device=true`:agent 只能完成离线实现并保持 DOING;只有人完成真机链接打开、页面对应性与隐私检查后才能 DONE。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
### 2026-08-04T01:09:47Z · ila
|
||||||
|
|
||||||
|
2026-08-04:已认领 T-102,工作分支 `task/t-102-open-product`,从任务定义提交 `4281b06` 开始实现。离线阶段只实现严格 canonical URL 校验、Android VIEW intent、版本/设备 fail-closed 与原子证据采集;不写商品详情页节点判据,不操作任何 App 控件。`needs_device=true`,离线实现完成后仍保持 DOING,等待人工提供明确商品链接并完成真机页面对应性和隐私验收。
|
||||||
|
|
||||||
|
### 2026-08-04T01:27:08Z · ila
|
||||||
|
|
||||||
|
2026-08-04:T-102 离线实现已由子 agent 完成并经主 agent 独立审查、两轮退回修正后通过,代码提交 `7040bb6`。实现包含:唯一 canonical 商品 URL 校验与重建、ADB 内层纯数字 goods_id 二次围栏、固定 package 的 Android VIEW intent、拼多多 8.17.0 intent 前版本围栏、前台 package 核验,以及截图/XML 原子取证与脱敏 manifest;无点击、滑动、输入、规格、价格、下单或支付能力,也未写商品详情页节点/Activity 判据。主审验证:43 项 client 离线测试、compileall、完整 init.ps1(含 admin test/vet/build)、wheel metadata、agent-context 与 diff-check 均通过;当前 wheel SHA-256 为 c675cd527849149ec25f213a0d67da31d0bb497cd8c5ed255d71154c103659ed(审计临时产物已清理)。未连接或操作真机,T-102 继续保持 DOING,等待人工使用明确 canonical 商品链接执行取证,并确认页面对应性与隐私。
|
||||||
|
|
||||||
|
### 2026-08-04T01:32:31Z · ila
|
||||||
|
|
||||||
|
2026-08-04 人工真机首轮:使用 WiFi serial `192.168.0.173:5555` 与 canonical 商品链接 `https://mobile.yangkeduo.com/goods.html?goods_id=958756616606` 执行 T-102 脚本。脚本通过 URL/intent 前置围栏,但在 intent 后返回“商品链接打开后前台应用不是拼多多”,按 fail-closed 规则停止,未发布完整取证。人在 Chrome 中可打开该 URL,只能证明网页链接有效,不能证明拼多多 App 已接管。T-102 保持 DOING;下一步由人只读取当前前台 package,区分瞬时切换/检查过早与实际落到 Chrome、系统解析器或其他包,在得到事实前不移除 package 围栏、不增加页面判据或 UI 兜底。
|
||||||
|
|
||||||
|
### 2026-08-04T01:34:39Z · ila
|
||||||
|
|
||||||
|
2026-08-04 人工追加诊断:T-102 首轮返回前台包不符后,人在未切换 App 的情况下立即执行只读 `dumpsys window`,得到 `mCurrentFocus` 与 `mFocusedApp` 均为 `com.xunmeng.pinduoduo/com.xunmeng.pinduoduo.activity.NewPageActivity`。该事实证明失败返回后前台已稳定到拼多多,支持“一次性 app_current 检查过早/存在异步切换窗口”的诊断;Activity 名只记录为诊断证据,不作为商品详情页判据。修复保持 package 围栏:改为有限时长、只读 package 的轮询,超时仍 fail closed,期间不点击、滑动、输入或读取页面节点。
|
||||||
|
|
||||||
|
### 2026-08-04T01:40:42Z · ila
|
||||||
|
|
||||||
|
2026-08-04:已按人工 foreground 证据完成最小修复,代码提交 `cb646b4`。保留 PDD package 围栏,将 intent 后的一次性判断改为以 CLI `--timeout` 为 deadline、默认 0.2 秒间隔的只读 package 轮询;只有观察到 `com.xunmeng.pinduoduo` 才采集截图/XML,超时仍 fail closed。轮询不读取 Activity/节点,不点击、滑动或输入。主 agent 已独立验证 46 项 client 测试、compileall、完整 init.ps1、wheel metadata、agent-context、diff-check;wheel SHA-256 `80fbc73785bb12b40856c6b1ef503bdb5cbc350fadd2b23cc6cc58358499bfe1`,临时产物已清理。未由 agent 连接真机,T-102 保持 DOING,等待人以 goods_id `958756616606` 重跑原命令并检查页面对应性与隐私。
|
||||||
|
|
||||||
|
### 2026-08-04T01:46:35Z · ila
|
||||||
|
|
||||||
|
2026-08-04 人工真机重跑已成功:人先退出到拼多多首页,再用 WiFi serial `192.168.0.173:5555`、goods_id `958756616606` 执行同一命令,证据原子发布到 `C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-102\product-open-958756616606`。manifest 记录 PKG110 / Android 16 / PDD 8.17.0 / current package `com.xunmeng.pinduoduo` / intent status ok;截图 SHA-256 `be9ea1f53b13fec82fd716875567870132ef0a1480cfbec5ad603436d774d81c`,XML SHA-256 `1449e78a154fddf7108e63a14a2e85bf74f162f4689c1fe95f2cb67fdb7b4fc5`。主 agent 只读取 manifest 并重新计算哈希,二者均匹配;未打开原始截图/XML。仍等待人明确确认截图对应目标商品且截图/XML 无地址、手机号、支付信息或其他无关隐私,确认前 T-102 保持 DOING。
|
||||||
|
|
||||||
|
### 2026-08-04T01:49:55Z · ila
|
||||||
|
|
||||||
|
2026-08-04 人工最终验收:人已查看本地 screenshot.png,确认对应 goods_id 958756616606;并确认截图与 hierarchy.xml 不含地址、手机号、支付信息或其他无关隐私。T-102 的 canonical 链接打开、PDD 8.17.0 前台 package、证据原子发布与人工页面对应性验收全部通过,允许关闭。
|
||||||
|
<!-- END VIKUNJA EXPORT -->
|
||||||
|
|
||||||
|
## 边界
|
||||||
|
|
||||||
|
- 本任务只验证由显式 canonical 链接启动拼多多并采集本地证据;不编写或声称商品详情页节点判据,
|
||||||
|
页面是否为对应商品必须由人查看本项目新产物确认。
|
||||||
|
- 只允许 Android `VIEW` intent;不点击、滑动、长按、输入任何 App 控件,不打开规格面板,不选择
|
||||||
|
颜色或尺码,不读取价格,不进入购买或订单页面。
|
||||||
|
- 不引用或实现 `go_to_order_confirm()`、`submit_order()`、提交订单、付款、支付、授权或采购服务接口;
|
||||||
|
不把前序项目的 URL、activity、节点文本或页面结论当作事实。
|
||||||
|
- 输入 URL 必须先严格解析并按纯数字 `goods_id` 重建,再以参数数组传给 ADB;禁止 shell 拼接、
|
||||||
|
任意 scheme/host/path、短链跳转、额外参数或自动猜测链接。
|
||||||
|
- 运行拼多多版本与本项目已取证版本不一致时必须在 intent 前停止;证据失败、当前包不符或结果无法
|
||||||
|
由人确认时均不得声称成功,不增加自动点击或其他兜底路径。
|
||||||
|
- 截图和完整 XML 只保存在明确的本地 T-102 目录,人工检查后只把路径、SHA-256 与非敏感元数据
|
||||||
|
写入执行记录;不得提交原始证据,不记录地址、手机号、支付信息或无关页面正文。
|
||||||
|
- 不修改 `admin/`、业务 API、数据模型、生产 GUI 或 T-103 规格面板逻辑。
|
||||||
|
- `needs_device: true`:agent 完成离线实现后保持 `DOING`;只有人使用明确 goods_id 完成真机打开、
|
||||||
|
确认对应商品详情页并完成隐私检查后才能标 `DONE`。
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
---
|
||||||
|
id: T-103
|
||||||
|
title: 验证规格面板精确选择与 SKU 单价
|
||||||
|
phase: 1
|
||||||
|
deps: [T-102, T-110]
|
||||||
|
status: DOING
|
||||||
|
created: 2026-08-04
|
||||||
|
vikunja_task_id: 23
|
||||||
|
context_ref: 1dc8308
|
||||||
|
work_branch: task/t-103-sku-panel
|
||||||
|
needs_device: true
|
||||||
|
needs_human_review: true
|
||||||
|
write_paths:
|
||||||
|
- docs/tasks/T-103.md
|
||||||
|
- client/src/cmbuyer_client/pdd/**
|
||||||
|
- client/src/cmbuyer_client/device/**
|
||||||
|
- client/tests/pdd/**
|
||||||
|
- client/tests/device/**
|
||||||
|
- client/scripts/capture_sku_panel_spike.py
|
||||||
|
- client/scripts/sanitize_sku_panel_evidence.py
|
||||||
|
- docs/02-requirements.md
|
||||||
|
- docs/03-tech-stack.md
|
||||||
|
- docs/04-architecture.md
|
||||||
|
- docs/api.md
|
||||||
|
- docs/current-state.md
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- BEGIN VIKUNJA EXPORT id=23 synced=2026-08-04T06:06:05Z sha256=9de2a35beba4cca7a29cf4dc6b5ff643d829f6467ff576da976589a8d1f3dcad -->
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
T-102 已证明 canonical 链接可进入目标商品。T-103 随后在 PKG110 / Android 16 / 拼多多 8.17.0、衣服商品 goods_id `937122477375` 上确认:规格面板只能从详情页右下角精确文案“快要抢光”进入,面板固定显示收货区域和掩码手机号。T-110 经项目所有者批准,将该已取证点击定义为可逆、能力受限的规格面板导航;这不是通用购买入口豁免,不授权其他文案、数量、确认页、提交订单或支付。 项目所有者随后确认,面板刚打开时已自动选中目标颜色“黑色CHA(纯棉)”和尺码“M(建议100-115)”;因此本任务不再假设存在“未选择”或“只选择一个维度”的初始状态。
|
||||||
|
|
||||||
|
## 关联需求与交互
|
||||||
|
|
||||||
|
- 功能:F-006 第一趟试选;覆盖自动脱敏、`open_trial_sku_panel()`、`select_sku_options()`、`read_sku_unit_price()` 的真机 spike。
|
||||||
|
- 用户故事:US-003、US-004、US-008;本任务无生产 GUI。
|
||||||
|
- 架构 / API:`docs/04-architecture.md` 第三、四、5.4、六节;`docs/api.md` 证据上传和 `TrialSkuFlow`;T-104、T-105 与 Phase 2 的前置。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
1. 先实现本机确定性脱敏器,不先写页面判据。原始 screenshot/XML 只保存到 `%LOCALAPPDATA%\cmbuyer\artifacts\T-103\...\raw`,只允许脱敏器消费;agent、fixture、业务、日志、HTTP sink、Vikunja 与 Git 均不得读取/上传原始内容。
|
||||||
|
2. 脱敏器绑定 PKG110 分辨率、拼多多 8.17.0、goods_id、人工声明状态和本项目证据配置,同时从 screenshot/XML 移除收货区域、地址与手机号内容,在 sibling `derived` 目录原子发布派生物。派生 XML 仍命中手机号模式、隐私区域无法确认、结构/分辨率/版本失配、哈希不一致或目标目录存在时 fail closed,不发布半成品。服务端只接收派生截图哈希和 sanitizer 版本,不接收原始文件/哈希/路径/XML。
|
||||||
|
3. 由人运行脱敏器并本地确认派生 screenshot/XML 的三种真实状态:panel-opened-target-preselected(刚打开且目标规格已自动选中)、alternate-all-dimensions-selected(人工把颜色和尺码都改成非目标值)、target-selection-restored(再把两个维度恢复为目标值)。主 agent 只在派生 manifest、每态实际选中值与人工确认齐全后读取派生证据,提取最小 fixture。首轮旧证据不得复用为 fixture。
|
||||||
|
4. 实现 `TrialSkuFlow` 窄 capability。`open_trial_sku_panel()` 只允许拼多多 8.17.0、goods_id `937122477375` 上证据绑定、精确唯一的 `快要抢光`;缺失、重复、版本失配或打开后面板判据不唯一时零后续点击。`免拼购买 / 单独购买 / 直接拼成` 等其他文案必须分别取证,不能包含、前缀、同义、OCR 或坐标兜底。
|
||||||
|
5. 第一趟 capability 只含打开商品、受控打开面板、在维度容器内精确选择、读面板单价、脱敏取证和返回。接口不得暴露通用 `click`、`set_quantity()`、`go_to_order_confirm()`、`submit_order()` 或支付能力;规格面板中的“提交订单”、微信支付、先用后付和 0 元下单只作为硬拒绝判据,静态调用链测试证明不可达。
|
||||||
|
6. 在脱敏派生 XML 上按维度容器隔离候选,文本等值匹配并读回选中态;缺失、重复、禁用、维度不明或选中态不唯一均停止。纯函数测试覆盖 `红`/`粉红`、`1`/`10` 等前缀碰撞。
|
||||||
|
7. 单价只从规格面板证据确定的唯一节点读取,以十进制字符串返回,禁止浮点。记录真实文本是否含优惠前缀、货币符号是否拆节点、是否并列原价/区间价;语义不唯一、节点缺失、候选冲突或只能从详情页读取时返回 unreadable。
|
||||||
|
8. 人最终在真机先把两个维度改成一组明确记录的非目标值,再执行受控入口、精确选择和读价脚本恢复目标规格,确认实际选择颜色分类=黑色 CHA(纯棉)、尺码=M(建议100-115)(以派生 XML 精确文本为准)、面板单价语义、派生证据隐私与退出行为;agent 保持 DOING 直到人工验收。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- 三组原始证据分别对应 panel-opened-target-preselected、alternate-all-dimensions-selected、target-selection-restored,且仅在本机 raw 目录;脱敏器离线测试覆盖地址/手机号移除、截图与 XML 同步处理、状态/版本/分辨率/结构失配、手机号残留、原子性、异常脱敏和已有目录不覆盖。
|
||||||
|
- 派生 manifest 记录 source/derived 本机哈希、sanitizer 版本、设备/App/goods_id/人工状态;原始页面正文、serial、地址和手机号不进入 manifest、日志、错误或上传。
|
||||||
|
- 只从人工验收通过的派生证据编写页面判据与最小 fixture;判据绑定拼多多 8.17.0 和证据哈希。
|
||||||
|
- `快要抢光` 精确唯一时才打开面板;其他文案、入口缺失/重复、面板不唯一、规格缺失/碰撞/禁用、价格不唯一均 fail closed。
|
||||||
|
- 静态 API/导入测试证明 T-103 第一趟无通用点击、数量、确认页、提交订单或支付能力,“提交订单”及资金控件不可达。
|
||||||
|
- 从规格面板可靠读取选中 SKU 单价为十进制字符串;无法唯一解释优惠价/原价/区间价时返回 unreadable。
|
||||||
|
- client 全部单测、compileall、wheel metadata、上下文校验、完整 `init.ps1` 与 diff-check 通过。
|
||||||
|
- `needs_device=true`:只有人完成受控入口、精确选择、单价读取、派生证据对应性/隐私和安全退出验收后才能 DONE。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
### 2026-08-04T01:58:01Z · ila
|
||||||
|
|
||||||
|
2026-08-04:已认领 T-103,工作分支 `task/t-103-sku-panel`,任务定义基线 `27999c8`。先执行人工证据门禁,不立即编写页面判据:T-102 现有详情截图只证明目标商品,且当前视口可见“单独购买 / 免拼购买”“先用后付 / 0元下单”等禁触入口,没有证明独立安全规格入口。下一步由人手工寻找非购买的“规格 / 已选”入口;若不存在就记录 no-go,不点击购买按钮兜底。规格面板三态证据与隐私确认完成前,受托 agent 只允许做证据采集脚手架、纯函数边界和只读审计,T-103 保持 DOING。
|
||||||
|
|
||||||
|
### 2026-08-04T02:09:48Z · ila
|
||||||
|
|
||||||
|
2026-08-04 离线实现与主审记录:
|
||||||
|
|
||||||
|
- 提交 `a92b8f6` 新增 T-103 人工声明三状态的只读取证 CLI、捕获模块与离线测试;脚本不打开链接/面板,不点击、滑动、选择规格或读取价格。
|
||||||
|
- 失败闭合覆盖:严格 URL/状态/版本/前台包校验、截图/XML 有效性、超时脱敏、原子发布、已有输出目录不覆盖;协议无购买、下单、支付或数量操作。
|
||||||
|
- 主 agent 独立复跑:client 55 项单测、compileall、wheel metadata、agent-context validator、diff-check 与完整 `init.ps1` 全部通过。
|
||||||
|
- `needs_device=true`,任务保持 DOING;等待人通过独立安全规格入口分别采集 initial、one-dimension-selected、all-dimensions-selected 三组证据并完成对应性与隐私检查。若只有购买/下单/资金入口可打开面板,则记录 no-go,不放宽边界。
|
||||||
|
|
||||||
|
### 2026-08-04T02:36:33Z · ila
|
||||||
|
|
||||||
|
2026-08-04 真机证据主审退回:
|
||||||
|
|
||||||
|
- goods_id `937122477375` 的三组 manifest、设备/App 元数据与文件 SHA-256 均一致。
|
||||||
|
- 人工给出隐私确认后,主 agent 只查看三张 screenshot,未读取 XML。
|
||||||
|
- `initial` 截图实际仍为商品详情页,不是规格面板初始态;`one-dimension-selected` 与 `all-dimensions-selected` 截图顶部含收货区域和掩码手机号,不满足任务的无地址/手机号证据边界。
|
||||||
|
- 本组证据判定不合格:不提取 fixture、不编写页面判据、不提交原始证据;T-103 保持 DOING。
|
||||||
|
- 下一步使用全新输出目录重采:人先在规格面板取消全部规格选择,并滚动到收货区域和手机号完全离开可见视口,同时保留规格面板价格与规格区域;三态逐次人工核对后再交主审。
|
||||||
|
|
||||||
|
### 2026-08-04T02:41:12Z · ila
|
||||||
|
|
||||||
|
2026-08-04 T-103 真机 no-go 结论:
|
||||||
|
|
||||||
|
- 人工确认 goods_id `937122477375` 的规格面板是通过商品详情页右下角“快要抢光”进入;通常“免拼购买 / 快要抢光 / 单独购买 / 直接拼成”等购买语义按钮才会打开规格面板。
|
||||||
|
- 这直接触发任务方案与边界中的 no-go 条件:第一趟不得点击任何购买、下单、提交或资金入口,也不得把购买按钮包装成规格入口。
|
||||||
|
- 因此不读取本轮 XML、不提取 fixture、不实现选择器;已有截图仅用于判定入口不可行,原始证据不提交 Git。
|
||||||
|
- 当前 MVP 的自动第一趟试选在现有安全边界下不可实现,T-103 转 BLOCKED。解除阻塞需要项目所有者选择新的架构方向,并先更新架构/任务边界;不得由 agent 擅自放宽。
|
||||||
|
|
||||||
|
### 2026-08-04T02:56:36Z · ila
|
||||||
|
|
||||||
|
2026-08-04:T-110 已获项目所有者授权并同步架构契约。T-103 方案已重写为“先自动脱敏派生证据,再实现受控 `快要抢光` 入口与精确规格/价格判据”;T-110 完成并转 Done 后,本任务恢复 Doing。
|
||||||
|
|
||||||
|
### 2026-08-04T03:20:51Z · ila
|
||||||
|
|
||||||
|
2026-08-04 T-103 自动脱敏第一阶段实现与主审:
|
||||||
|
|
||||||
|
- 提交 `9b2eb74` 新增离线确定性脱敏 CLI、实现模块和纯合成测试;仅允许 `raw` 到同级全新 `derived`,不连接设备。
|
||||||
|
- 绑定 PKG110 / Android 16 / 拼多多 8.17.0 / goods_id `937122477375` / 1080×2400 / 人工声明三态;校验源 manifest 与文件哈希。
|
||||||
|
- 截图整宽遮罩 `[0,0,1080,540)`;XML 递归删除隐私带节点、清空跨界容器并保留下方规格节点,完整/掩码/分隔/跨节点手机号残留均 fail closed。
|
||||||
|
- 主审首轮发现手机号跨节点绕过、隐私结构未确认和发布竞态风险后退回;第二轮修复并补测,目标目录已存在或竞态出现均不覆盖且不留 staging。
|
||||||
|
- 主 agent 独立验证:66 项 client 单测、compileall、CLI help、wheel metadata、完整 `init.ps1`、上下文校验与 diff-check 全部通过。
|
||||||
|
- 实现与审查过程未读取、列举或打开任何真实 raw artifacts,也未实现 PDD 页面判据、点击、规格选择、价格、数量、确认页、提交或付款能力。
|
||||||
|
- `needs_device=true`,T-103 保持 DOING;等待人重新采集三态 raw、运行脱敏器并只核对 derived 后,才能继续页面判据与真机选择/读价。
|
||||||
|
|
||||||
|
### 2026-08-04T03:34:47Z · ila
|
||||||
|
|
||||||
|
项目所有者确认:规格面板刚打开时已自动选中目标颜色“黑色CHA(纯棉)”与尺码“M(建议100-115)”。旧的未选择/单维度/全选三态假设失效;T-103 改为记录刚打开目标预选、两个维度改为非目标值、两个维度恢复目标值三态。修改 CLI 状态枚举与测试前不再采集旧状态。
|
||||||
|
|
||||||
|
### 2026-08-04T03:40:39Z · ila
|
||||||
|
|
||||||
|
T-103 新三态离线实现与主审:提交 dbb69a7 将人工声明状态集中到 sku_panel_state.py,采集器和脱敏器仅接受 panel-opened-target-preselected、alternate-all-dimensions-selected、target-selection-restored;旧 initial / one-dimension-selected / all-dimensions-selected 与未知值均 fail closed。主 agent 独立复跑 67 项 client 单测、compileall、两个 CLI help、静态禁用能力检索与完整 init.ps1,全部通过。过程未连接设备、未读取真实 raw、未新增页面判据或点击/数量/确认/提交/付款能力。T-103 保持 DOING,等待新三态 derived 人工验收。
|
||||||
|
|
||||||
|
### 2026-08-04T03:54:21Z · ila
|
||||||
|
|
||||||
|
第一组新三态 raw 已由人成功采集;脱敏器 v1 按设计拒绝。人只读取 PNG 头部元数据并确认实际 format=PNG、size=1080x2376,证明 v1 将截图尺寸与 Android XML 坐标空间统一设为 1080x2400 的假设错误。原始证据不重采、不删除、不读取正文;下一步把 screenshot 与 XML coordinate space 分离配置,升级 sanitizer 版本并继续 fail closed。
|
||||||
|
|
||||||
|
### 2026-08-04T03:58:54Z · ila
|
||||||
|
|
||||||
|
T-103 sanitizer v2 坐标修正与主审:提交 44c027a 将 screenshot space 固定为人确认的 1080x2376,并把 XML coordinate space 独立严格配置为待验证的 1080x2400;sanitizer_version 升为 t103-privacy-v2。XML 会统计所有 bounds 的 max right/bottom,任何小于或大于配置的偏差均 fail closed,错误只含非敏感 observed 尺寸。派生 manifest 记录两个坐标空间和各自遮罩。主 agent 独立复跑 69 项 client 单测、compileall、CLI help 与 diff-check,全部通过;未读取真实 raw 正文、未连接设备。等待人用同一第一态 raw 重跑 v2 脱敏。
|
||||||
|
|
||||||
|
### 2026-08-04T06:01:02Z · ila
|
||||||
|
|
||||||
|
人使用 sanitizer v2 对同一第一态 raw 重跑,安全诊断返回 XML observed 1080x2376;未发布 derived。由此确认本次 screenshot space 与 XML coordinate space 均为 1080x2376,v2 对 XML=1080x2400 的待验证假设被真机证据否定。原始证据继续保留且不重采;下一步升级 v3,仍分离建模两个空间但分别精确绑定当前相同尺寸。
|
||||||
|
|
||||||
|
### 2026-08-04T06:05:53Z · ila
|
||||||
|
|
||||||
|
2026-08-04 T-103 sanitizer v3 坐标修正与主审:人用同一第一态 raw 运行 v2,脱敏器安全拒绝并仅报告 observed 1080x2376,证明 XML 实际坐标与截图相同;原始证据仍有效、未重采、未读取正文。提交 acf7e11 将 sanitizer_version 升为 t103-privacy-v3;截图与 XML 仍分别建模并分别严格校验,但当前均精确绑定 1080x2376,隐私带保持 [0,0,1080,540)。合成测试明确拒绝旧截图 1080x2400、旧 v2 XML 1080x2400,以及较小/较大坐标;手机号残留、哈希/状态/版本、确定性、原子发布和不覆盖门禁保持不变。主 agent 独立复跑 69 项 client 单测、compileall、CLI help、静态禁用能力检索与 diff-check,全部通过;未连接设备、未读取真实 raw。T-103 保持 DOING,等待人用同一第一态 raw 重跑 v3 并只核对 derived。
|
||||||
|
<!-- END VIKUNJA EXPORT -->
|
||||||
|
|
||||||
|
## 边界
|
||||||
|
|
||||||
|
- 任何规格面板入口、维度容器、选项、选中态和价格判据都必须来自本项目新采集、自动复检通过的
|
||||||
|
拼多多 8.17.0 **脱敏派生** screenshot/XML;T-102 详情页证据只能证明已到目标商品,不能证明
|
||||||
|
规格面板结构。派生证据路径、SHA-256、goods_id、设备、Android、App 与 sanitizer 版本写入
|
||||||
|
执行记录之前,不得提交页面判据代码。
|
||||||
|
- 第一趟只允许 T-110 批准的 `open_trial_sku_panel()` 窄能力点击证据/版本绑定、精确唯一的
|
||||||
|
`快要抢光`;当前事实仅覆盖 goods_id `937122477375`、拼多多 `8.17.0`。不得把“免拼购买 /
|
||||||
|
单独购买 / 直接拼成”等其他文案加入包含、前缀、同义或坐标兜底,分别取证前一律拒绝。
|
||||||
|
- 规格选项只能在已确认的面板及对应维度容器内按文本精确唯一匹配;缺失、重复、禁用、维度不明、
|
||||||
|
选中态无法唯一读回或页面版本不符均 fail closed。不得前缀、包含、模糊、相似或跨维度匹配,
|
||||||
|
不得用 OCR / 坐标兜底猜选项。
|
||||||
|
- 单价只允许从规格面板证据确认的节点读取,并以十进制字符串表达;不得使用浮点,不得从详情页、
|
||||||
|
搜索卡片或其他页面的数字补值。券后价、原价、区间价、货币符号拆分或多个候选的语义无法唯一
|
||||||
|
证明时必须返回 unreadable 并转人工。
|
||||||
|
- T-103 代码路径不得引用或实现 `set_quantity()`、`go_to_order_confirm()`、`submit_order()`、
|
||||||
|
通用 `click`、创建订单、授权、提交围栏或任何支付能力;不得进入订单确认页,不得创建待付款订单。
|
||||||
|
规格面板中的“提交订单”、微信支付、先用后付、0 元下单只可作为硬拒绝判据,不能返回可点击对象。
|
||||||
|
- 不把 `/mnt/d/chengma/cmroubao`、`/mnt/d/chengma/cmpdd` 或任何旧版本的节点、Activity、选择器、
|
||||||
|
坐标、文本形态和页面结论当作事实;前序项目只可用于理解为什么必须 fail closed。
|
||||||
|
- 原始 screenshot/XML 只保存在 `%LOCALAPPDATA%\cmbuyer\artifacts\T-103\...\raw`,只允许本机
|
||||||
|
确定性脱敏器消费,不得由 agent、业务、fixture、HTTP sink、Vikunja 或 Git 读取/上传。脱敏器必须
|
||||||
|
校验设备分辨率、页面/App 版本和预期隐私区域,同时移除 screenshot/XML 中的地址与手机号内容;
|
||||||
|
派生 XML 仍命中手机号模式、结构/版本/分辨率失配或哈希不一致时不得发布 `derived`。
|
||||||
|
- 只有自动复检通过的最小脱敏 fixture、派生路径/哈希、sanitizer 版本与非敏感结论可入库;服务端
|
||||||
|
不接收原始文件、原始路径或原始哈希。
|
||||||
|
- `needs_device: true`:agent 只能完成离线实现并保持 `DOING`;只有人完成安全入口、精确规格选择、
|
||||||
|
SKU 单价语义、页面对应性与派生证据验收后才能标 `DONE`。
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
---
|
||||||
|
id: T-110
|
||||||
|
title: 调整第一趟受控规格入口与隐私脱敏边界
|
||||||
|
phase: 1
|
||||||
|
deps: [T-102]
|
||||||
|
status: DONE
|
||||||
|
created: 2026-08-04
|
||||||
|
vikunja_task_id: 24
|
||||||
|
context_ref: abeefbb
|
||||||
|
work_branch: task/t-110-trial-sku-boundary
|
||||||
|
needs_device: false
|
||||||
|
needs_human_review: true
|
||||||
|
write_paths:
|
||||||
|
- docs/tasks/T-110.md
|
||||||
|
- docs/tasks/T-103.md
|
||||||
|
- docs/02-requirements.md
|
||||||
|
- docs/03-tech-stack.md
|
||||||
|
- docs/04-architecture.md
|
||||||
|
- docs/05-coding-rules.md
|
||||||
|
- docs/api.md
|
||||||
|
- docs/current-state.md
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- BEGIN VIKUNJA EXPORT id=24 synced=2026-08-04T03:00:16Z sha256=dd7209dda3769397f96882556864c1836955507fd5ee293103295d4fd9f9a840 -->
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
T-103 在 PKG110 / Android 16 / 拼多多 8.17.0 真机确认:衣服商品 goods_id `937122477375` 的规格面板只能从详情页右下角“快要抢光”等购买语义按钮进入,不存在原架构假设的独立“规格 / 已选”入口。与此同时,规格面板固定展示收货区域与掩码手机号。项目所有者已批准调整第一趟入口边界,但“不付款、第一趟不可达提交订单、价格只从规格面板读取、敏感信息不进入业务数据/日志/上传/Git”等边界不变。
|
||||||
|
|
||||||
|
## 关联需求与交互
|
||||||
|
|
||||||
|
- 功能:F-006 第一趟试选;为 T-103 解阻,不实现页面选择器或真机点击代码。
|
||||||
|
- 架构:`docs/04-architecture.md` 第一趟流程、安全边界、证据分层;`docs/05-coding-rules.md` 不可逆动作和敏感信息纪律。
|
||||||
|
- API:设备试选证据只允许上传脱敏派生物;原始截图/XML 仅留采购工具本机隔离目录。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
1. 把“独立规格入口”改为“受控规格面板入口”:第一趟只可点击本项目真机证据证明、与拼多多版本绑定的唯一精确入口;当前仅确认 `快要抢光`。其他文案即使语义相近也不得推断复用,必须重新取证。
|
||||||
|
2. 将点击入口与下单能力隔离。第一趟 capability 只允许打开受控面板、在维度内精确选择、读规格面板单价、生成脱敏证据和返回商品页;接口不得出现设置数量、进入确认页、提交订单、支付或通用任意点击能力。静态调用链和测试必须证明这些能力不可达。
|
||||||
|
3. 规格面板内的“提交订单”以及微信支付、先用后付、0 元下单等控件全部列入硬拒绝区。入口缺失/重复、版本失配、打开后不是已取证面板、选择或价格不唯一时立即返回,不尝试相近按钮。
|
||||||
|
4. 接受 PDD 页面会显示收货区域与掩码手机号这一事实,但不允许业务系统提取或传播。原始 screenshot/XML 仅写入 `%LOCALAPPDATA%` 隔离目录,不打印页面正文、不上传、不提交 Git;本地确定性脱敏器生成派生 screenshot/XML 和哈希,开发、fixture、服务端证据只消费派生物。
|
||||||
|
5. 脱敏必须 fail closed:无法确认地址/手机号区域已移除、派生 XML 仍命中手机号模式、分辨率/结构不符合已取证版本,均拒绝发布派生证据。不得依赖人工口头“无需处理”绕过自动检查。
|
||||||
|
6. 同步需求、架构、技术栈、编码规则、API、current-state 与 T-103。T-110 完成后把 T-103 依赖补为 T-110 并重新转 DOING;真正的脱敏器、入口判据和真机实现仍由 T-103 完成。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- 文档明确区分“可逆的受控规格面板入口”与“不可逆的提交订单动作”,不把购买文案泛化为通用可点击入口。
|
||||||
|
- 第一趟的 capability 列表与硬拒绝列表完整;提交订单、数量、确认页、付款和任意点击能力不可达。
|
||||||
|
- 原始证据与脱敏派生证据的目录、生命周期、消费者和 fail-closed 条件清楚;服务端和 Git 永不接收原始隐私证据。
|
||||||
|
- `docs/tasks/T-103.md` 在 T-110 完成后恢复 DOING,并要求先产出自动脱敏证据再写页面判据。
|
||||||
|
- `python scripts/validate_agent_context.py`、Vikunja 导出检查与 `git diff --check` 通过。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
### 2026-08-04T02:48:25Z · ila
|
||||||
|
|
||||||
|
2026-08-04:项目所有者明确批准受控规格面板入口方案;PDD 页面不可避免显示地址/手机号的事实被接受,但系统仍按本机原始证据隔离、自动脱敏派生物消费的方式收紧传播边界。任务已认领,分支 `task/t-110-trial-sku-boundary`,基线 `abeefbb`。
|
||||||
|
|
||||||
|
### 2026-08-04T02:58:44Z · ila
|
||||||
|
|
||||||
|
2026-08-04 完成记录:
|
||||||
|
|
||||||
|
- 已同步 `docs/02-requirements.md`、`03-tech-stack.md`、`04-architecture.md`、`05-coding-rules.md`、`api.md`、`current-state.md` 与 T-103。
|
||||||
|
- 第一趟只批准证据/版本绑定、精确唯一的 `快要抢光`;其他购买文案不泛化。`TrialSkuFlow` 不暴露通用点击、数量、确认页、提交订单或支付能力。
|
||||||
|
- 原始 screenshot/XML 只留本机隔离目录;服务端只接收自动复检通过的派生截图哈希与 sanitizer 版本,不接收原始文件、哈希、路径或 XML。
|
||||||
|
- Phase 1 已拆分第一趟规格 spike 与第二趟数量/确认页 spike;T-103 已增加 T-110 依赖并按新边界恢复 DOING。
|
||||||
|
- `validate_agent_context.py` 与 `git diff --check` 通过;本任务没有实现或运行任何真机点击代码。
|
||||||
|
|
||||||
|
### 2026-08-04T03:00:01Z · ila
|
||||||
|
|
||||||
|
最终提交 `82f57c7`。主 agent 独立复跑完整 `init.ps1`:admin go test/vet/build、client editable install/55 项 unittest/compileall、agent-context validator 全部通过;`git diff --check` 通过。T-110 已完成且未实现或执行任何真机点击。
|
||||||
|
<!-- END VIKUNJA EXPORT -->
|
||||||
|
|
||||||
|
## 边界
|
||||||
|
|
||||||
|
- 本任务只调整架构、需求、API、编码规则与后续任务契约,不实现或运行任何真机点击代码。
|
||||||
|
- 仍然绝不点击支付、免密支付、先用后付、0 元下单或扣款控件;不创建订单,不进入订单确认页。
|
||||||
|
- 第一趟只允许未来实现点击**本项目真机证据证明、与拼多多版本绑定、精确唯一**的规格面板入口;
|
||||||
|
当前只确认 goods_id `937122477375`、拼多多 `8.17.0` 上的“快要抢光”。不得把人工经验中的
|
||||||
|
“免拼购买 / 单独购买 / 直接拼成”等文案直接加入白名单,分别取证前一律拒绝。
|
||||||
|
- 第一趟 capability 不得包含通用 `click`、数量调整、订单确认、提交订单或付款能力;规格面板中的
|
||||||
|
“提交订单”及任何支付提示始终属于硬拒绝区,静态调用链必须保持不可达。
|
||||||
|
- 接受原始规格面板证据在手机本地可能包含收货区域和掩码手机号,但不得从中提取业务字段、打印、
|
||||||
|
上传或提交 Git。只有确定性脱敏并通过自动检查的派生 screenshot/XML 才能供 agent、fixture、
|
||||||
|
采购服务或人工远程评审使用;脱敏不确定即 fail closed。
|
||||||
|
- 价格仍只允许从规格面板和订单确认页读取,使用十进制字符串;不得用详情页价格补齐。
|
||||||
|
- 本任务不得把 T-103 标为 `DONE`。文档边界完成并获得本次用户明确授权后,只能把 T-103 重新置为
|
||||||
|
`DOING`,由 T-103 继续实现脱敏器、判据、离线测试和人工真机验收。
|
||||||
Reference in New Issue
Block a user