Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1dc83086a0 | ||
|
|
ad55e77bda | ||
|
|
652eca7953 | ||
|
|
cb646b4974 | ||
|
|
9452debf66 | ||
|
|
4adeb1b37f | ||
|
|
2ea28c2626 | ||
|
|
7040bb61d8 | ||
|
|
e7a4be1b9b | ||
|
|
4281b06711 | ||
|
|
393f26de53 | ||
|
|
71c66a074a | ||
|
|
7905fa0b70 | ||
|
|
c4cf19fd55 | ||
|
|
de8187eb5b | ||
|
|
f3294633c2 | ||
|
|
0fbf66836b | ||
|
|
4f4a95a55e | ||
|
|
637c341b95 |
@@ -1,7 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# 桌面界面(Qt 官方 Python 绑定)。
|
||||
PySide6
|
||||
# 后续真机取证会使用;本阶段不导入或连接设备。
|
||||
# T-101 基线取证使用;只连接显式 serial,不打开或操作拼多多页面。
|
||||
uiautomator2
|
||||
# T-101 直接使用当前 ADB server 的已列出设备对象交给 uiautomator2,禁止 WiFi 自动重连。
|
||||
adbutils>=2.11,<3
|
||||
# 后续截图完整性检查会使用。
|
||||
Pillow
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""采集指定 Android 设备的本地基线证据;不打开或操作拼多多页面。"""
|
||||
|
||||
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 (
|
||||
BaselineCaptureError,
|
||||
DeviceBaselineCapturer,
|
||||
NoReconnectUiautomatorConnector,
|
||||
)
|
||||
|
||||
|
||||
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="采集显式指定 Android 设备的本地基线证据。")
|
||||
parser.add_argument("--serial", required=True, help="ADB device serial;禁止自动选择。")
|
||||
parser.add_argument("--output-dir", required=True, type=Path, help="新建的本地证据目录;不得覆盖已有目录。")
|
||||
parser.add_argument("--timeout", type=float, default=10.0, help="ADB、uiautomator2 RPC 与 ADB socket 超时(秒)。")
|
||||
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。")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
arguments = parse_arguments(argv)
|
||||
try:
|
||||
validate_arguments(arguments)
|
||||
except ValueError as error:
|
||||
print(f"失败:{error}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
try:
|
||||
import adbutils
|
||||
import uiautomator2 as u2
|
||||
except ImportError:
|
||||
print("失败:缺少 uiautomator2;请在采购工具虚拟环境中运行。", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
client = AdbClient(SubprocessAdbRunner(arguments.adb), timeout_seconds=arguments.timeout)
|
||||
connector = NoReconnectUiautomatorConnector(
|
||||
adbutils.AdbClient(socket_timeout=arguments.timeout).device_list,
|
||||
u2.connect,
|
||||
)
|
||||
capturer = DeviceBaselineCapturer(client, connector, timeout_seconds=arguments.timeout)
|
||||
try:
|
||||
result = capturer.capture(arguments.serial, arguments.output_dir)
|
||||
except (DeviceConnectionError, BaselineCaptureError) as error:
|
||||
# 错误类型只表达状态,不打印 ADB 输出、serial、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,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,15 @@
|
||||
"""设备连接与基线取证边界。
|
||||
|
||||
本包只提供显式设备选择、非敏感身份核验和本地基线采集;不包含任何采购页面或订单操作。
|
||||
"""
|
||||
|
||||
from .adb import AdbClient, AdbDevice, CommandResult
|
||||
from .baseline import BaselineCaptureResult, DeviceBaselineCapturer
|
||||
|
||||
__all__ = [
|
||||
"AdbClient",
|
||||
"AdbDevice",
|
||||
"BaselineCaptureResult",
|
||||
"CommandResult",
|
||||
"DeviceBaselineCapturer",
|
||||
]
|
||||
@@ -0,0 +1,282 @@
|
||||
"""ADB 设备清单与物理设备冲突的 fail-closed 边界。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
from typing import Protocol, Sequence
|
||||
|
||||
|
||||
class DeviceConnectionError(RuntimeError):
|
||||
"""显式设备连接边界的基础错误,不携带命令输出或设备敏感内容。"""
|
||||
|
||||
|
||||
class SerialRequiredError(DeviceConnectionError):
|
||||
"""调用方没有明确指定设备 serial。"""
|
||||
|
||||
|
||||
class DeviceNotFoundError(DeviceConnectionError):
|
||||
"""指定 serial 不在 ADB 当前清单中。"""
|
||||
|
||||
|
||||
class DeviceOfflineError(DeviceConnectionError):
|
||||
"""指定设备处于 offline 状态。"""
|
||||
|
||||
|
||||
class DeviceUnauthorizedError(DeviceConnectionError):
|
||||
"""指定设备尚未授权此电脑。"""
|
||||
|
||||
|
||||
class DeviceStateError(DeviceConnectionError):
|
||||
"""指定设备处于其他不可用状态。"""
|
||||
|
||||
|
||||
class DeviceCommandTimeoutError(DeviceConnectionError):
|
||||
"""ADB 命令超过调用方指定的超时。"""
|
||||
|
||||
|
||||
class DeviceCommandError(DeviceConnectionError):
|
||||
"""ADB 命令失败;错误文本刻意不回显设备输出。"""
|
||||
|
||||
|
||||
class DeviceIdentityUnconfirmedError(DeviceConnectionError):
|
||||
"""多条在线通道无法完成同机身份判断,必须由人处理。"""
|
||||
|
||||
|
||||
class DuplicatePhysicalDeviceError(DeviceConnectionError):
|
||||
"""同一物理手机通过多个 ADB 通道同时在线。"""
|
||||
|
||||
|
||||
class IntentLaunchUnconfirmedError(DeviceConnectionError):
|
||||
"""`am start -W` 没有给出可确认的启动成功结果。"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CommandResult:
|
||||
"""可注入命令执行器的最小、可离线构造结果。"""
|
||||
|
||||
stdout: str
|
||||
stderr: str = ""
|
||||
returncode: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IntentLaunchSummary:
|
||||
"""不含 Activity、页面内容或 ADB 输出的受限启动摘要。"""
|
||||
|
||||
status: str
|
||||
returncode: int
|
||||
|
||||
|
||||
class CommandRunner(Protocol):
|
||||
"""运行 ADB 子命令的可替换边界。"""
|
||||
|
||||
def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
|
||||
"""运行参数,不得通过 shell 拼接。"""
|
||||
|
||||
|
||||
class SubprocessAdbRunner:
|
||||
"""使用 subprocess 的生产执行器,所有调用必须带超时。"""
|
||||
|
||||
def __init__(self, executable: str | Path = "adb") -> None:
|
||||
self._executable = str(executable)
|
||||
|
||||
def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
[self._executable, *arguments],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=timeout_seconds,
|
||||
)
|
||||
except subprocess.TimeoutExpired as error:
|
||||
raise DeviceCommandTimeoutError("ADB 命令超时,请检查设备连接后由人工重试。") from error
|
||||
except OSError as error:
|
||||
raise DeviceCommandError("无法启动 ADB,请检查 adb 路径与本机工具链。") from error
|
||||
|
||||
return CommandResult(
|
||||
stdout=completed.stdout,
|
||||
stderr=completed.stderr,
|
||||
returncode=completed.returncode,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AdbDevice:
|
||||
"""`adb devices -l` 的单行非敏感传输元数据。"""
|
||||
|
||||
serial: str
|
||||
state: str
|
||||
product: str | None = None
|
||||
model: str | None = None
|
||||
device: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeviceInspection:
|
||||
"""选定通道的只读身份结果,原始硬件标识只在内存中参与比较。"""
|
||||
|
||||
device: AdbDevice
|
||||
model: str
|
||||
android_version: str
|
||||
|
||||
|
||||
def parse_adb_devices(output: str) -> list[AdbDevice]:
|
||||
"""解析 `adb devices -l`,忽略标题、空行和 adb 附加提示。"""
|
||||
|
||||
devices: list[AdbDevice] = []
|
||||
for raw_line in output.splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("List of devices attached") or line.startswith("*"):
|
||||
continue
|
||||
fields = line.split()
|
||||
if len(fields) < 2:
|
||||
continue
|
||||
details = {
|
||||
key: value
|
||||
for field in fields[2:]
|
||||
if ":" in field
|
||||
for key, value in [field.split(":", 1)]
|
||||
}
|
||||
devices.append(
|
||||
AdbDevice(
|
||||
serial=fields[0],
|
||||
state=fields[1],
|
||||
product=details.get("product"),
|
||||
model=details.get("model"),
|
||||
device=details.get("device"),
|
||||
)
|
||||
)
|
||||
return devices
|
||||
|
||||
|
||||
class AdbClient:
|
||||
"""显式 serial 的 ADB 只读查询。
|
||||
|
||||
多个在线通道必须完成硬件身份比对。比对失败时不能用相同 model/product 猜测同一台手机,
|
||||
因为那会把不确定性隐藏成错误的安全结论。
|
||||
"""
|
||||
|
||||
def __init__(self, runner: CommandRunner, timeout_seconds: float = 10.0) -> None:
|
||||
if timeout_seconds <= 0:
|
||||
raise ValueError("timeout_seconds 必须大于 0")
|
||||
self._runner = runner
|
||||
self._timeout_seconds = timeout_seconds
|
||||
|
||||
def inspect(self, serial: str) -> DeviceInspection:
|
||||
"""确认指定通道在线且不与另一在线通道指向同一物理设备。"""
|
||||
|
||||
selected_serial = _require_serial(serial)
|
||||
devices = self.devices()
|
||||
selected = next((device for device in devices if device.serial == selected_serial), None)
|
||||
if selected is None:
|
||||
raise DeviceNotFoundError("指定设备不在 ADB 清单中,请显式检查 serial。")
|
||||
_raise_for_state(selected.state)
|
||||
|
||||
online_devices = [device for device in devices if device.state == "device"]
|
||||
if len(online_devices) > 1:
|
||||
identities: dict[str, frozenset[str]] = {}
|
||||
for candidate in online_devices:
|
||||
try:
|
||||
identities[candidate.serial] = self._physical_identity(candidate)
|
||||
except DeviceConnectionError as error:
|
||||
raise DeviceIdentityUnconfirmedError(
|
||||
"存在多个在线 ADB 通道且身份无法确认,已拒绝选择设备。"
|
||||
) from error
|
||||
|
||||
selected_identity = identities[selected.serial]
|
||||
if any(
|
||||
candidate_serial != selected.serial and selected_identity.intersection(candidate_identity)
|
||||
for candidate_serial, candidate_identity in identities.items()
|
||||
):
|
||||
raise DuplicatePhysicalDeviceError(
|
||||
"同一物理手机的多个 ADB 通道同时在线,已拒绝继续;请仅保留一个通道。"
|
||||
)
|
||||
|
||||
model = self._getprop(selected.serial, "ro.product.model") or selected.model or "unknown"
|
||||
android_version = self._getprop(selected.serial, "ro.build.version.release") or "unknown"
|
||||
return DeviceInspection(device=selected, model=model, android_version=android_version)
|
||||
|
||||
def devices(self) -> list[AdbDevice]:
|
||||
"""读取并解析 ADB 设备清单。"""
|
||||
|
||||
result = self._run_checked(("devices", "-l"))
|
||||
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]:
|
||||
serialno = self._getprop(device.serial, "ro.serialno")
|
||||
boot_serialno = self._getprop(device.serial, "ro.boot.serialno")
|
||||
identifiers = frozenset(value for value in (serialno, boot_serialno) if value)
|
||||
if identifiers:
|
||||
return identifiers
|
||||
# model/product/device 只能作为展示元数据,不能证明两台同型号设备是同一物理机。
|
||||
raise DeviceIdentityUnconfirmedError("无法读取设备硬件身份摘要。")
|
||||
|
||||
def _getprop(self, serial: str, property_name: str) -> str:
|
||||
result = self._run_checked(("-s", serial, "shell", "getprop", property_name))
|
||||
return result.stdout.strip()
|
||||
|
||||
def _run_checked(self, arguments: Sequence[str]) -> CommandResult:
|
||||
try:
|
||||
result = self._runner.run(arguments, self._timeout_seconds)
|
||||
except subprocess.TimeoutExpired as error:
|
||||
raise DeviceCommandTimeoutError("ADB 命令超时,请检查设备连接后由人工重试。") from error
|
||||
if result.returncode != 0:
|
||||
raise DeviceCommandError("ADB 命令失败,请检查设备连接或授权状态。")
|
||||
return result
|
||||
|
||||
|
||||
def _require_serial(serial: str) -> str:
|
||||
if not isinstance(serial, str) or not serial.strip():
|
||||
raise SerialRequiredError("必须显式提供设备 serial,禁止自动选择设备。")
|
||||
return serial.strip()
|
||||
|
||||
|
||||
def _raise_for_state(state: str) -> None:
|
||||
if state == "device":
|
||||
return
|
||||
if state == "offline":
|
||||
raise DeviceOfflineError("指定设备处于 offline 状态。")
|
||||
if state == "unauthorized":
|
||||
raise DeviceUnauthorizedError("指定设备尚未授权此电脑。")
|
||||
raise DeviceStateError("指定设备不处于可用状态。")
|
||||
@@ -0,0 +1,238 @@
|
||||
"""只读设备基线取证,严格限制在元数据、截图和完整节点树。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from hashlib import sha256
|
||||
import base64
|
||||
import binascii
|
||||
from io import BytesIO
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
from typing import Any, Protocol
|
||||
from uuid import uuid4
|
||||
from xml.etree import ElementTree
|
||||
|
||||
from adbutils.errors import AdbTimeout
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from uiautomator2.exceptions import HTTPTimeoutError
|
||||
|
||||
from .adb import AdbClient, DeviceInspection
|
||||
|
||||
|
||||
PDD_PACKAGE = "com.xunmeng.pinduoduo"
|
||||
SCREENSHOT_PARAMS = [1, 80]
|
||||
HIERARCHY_PARAMS = [False, 50]
|
||||
_BASE64_ASCII_WHITESPACE = " \t\r\n"
|
||||
|
||||
|
||||
class BaselineCaptureError(RuntimeError):
|
||||
"""基线取证无法完整落盘时的失败,不会伪造成功产物。"""
|
||||
|
||||
|
||||
class BaselineCaptureTimeoutError(BaselineCaptureError):
|
||||
"""设备基线取证超时;底层异常文本不向 CLI 或日志泄露。"""
|
||||
|
||||
|
||||
class UiAutomatorDevice(Protocol):
|
||||
"""本任务唯一需要的 uiautomator2 只读能力。"""
|
||||
|
||||
def app_info(self, package_name: str) -> dict[str, Any]:
|
||||
"""读取已安装应用元数据。"""
|
||||
|
||||
def jsonrpc_call(self, method: str, params: Any = None, timeout: float = 10) -> Any:
|
||||
"""调用公开 uiautomator2 JSON-RPC 接口。"""
|
||||
|
||||
|
||||
class NoReconnectUiautomatorConnector:
|
||||
"""只把当前 ADB server 已列出的设备对象交给 uiautomator2。
|
||||
|
||||
uiautomator2 直接接收 IP serial 时会在内部尝试 adb disconnect/connect。这里先从已列出设备中
|
||||
取对象再调用 ``u2.connect(device_object)``,避免连接阶段隐式重连已经掉线的 WiFi 通道。
|
||||
"""
|
||||
|
||||
def __init__(self, list_devices: Callable[[], list[Any]], connect: Callable[[Any], UiAutomatorDevice]) -> None:
|
||||
self._list_devices = list_devices
|
||||
self._connect = connect
|
||||
|
||||
def __call__(self, serial: str) -> UiAutomatorDevice:
|
||||
device = next((item for item in self._list_devices() if item.serial == serial), None)
|
||||
if device is None:
|
||||
raise BaselineCaptureError("设备在连接前已从 ADB 清单消失,已拒绝自动重连。")
|
||||
return self._connect(device)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BaselineCaptureResult:
|
||||
"""已原子发布的基线取证摘要,不包含页面正文或原始 serial。"""
|
||||
|
||||
output_directory: Path
|
||||
manifest_path: Path
|
||||
screenshot_path: Path
|
||||
hierarchy_path: Path
|
||||
|
||||
|
||||
class DeviceBaselineCapturer:
|
||||
"""以先校验通道、后连接、最后原子发布的顺序采集基线。
|
||||
|
||||
截图和 XML 可能包含页面敏感内容,因此仅落在调用方明确指定的本地目录;manifest 只写
|
||||
哈希、设备非敏感元数据和脱敏后的 serial 摘要,绝不嵌入 XML 或页面文本。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
adb_client: AdbClient,
|
||||
connector: Callable[[str], UiAutomatorDevice],
|
||||
timeout_seconds: float,
|
||||
) -> None:
|
||||
if timeout_seconds <= 0:
|
||||
raise ValueError("timeout_seconds 必须大于 0")
|
||||
self._adb_client = adb_client
|
||||
self._connector = connector
|
||||
self._timeout_seconds = timeout_seconds
|
||||
|
||||
def capture(self, serial: str, output_directory: Path) -> BaselineCaptureResult:
|
||||
"""采集完整基线,任何一步失败均不发布 output_directory。"""
|
||||
|
||||
inspection = self._adb_client.inspect(serial)
|
||||
target = Path(output_directory)
|
||||
if target.exists():
|
||||
raise BaselineCaptureError("输出目录已存在;为防止混入旧证据,拒绝覆盖。")
|
||||
if not target.name:
|
||||
raise BaselineCaptureError("输出目录必须是明确的新目录。")
|
||||
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
staging = target.parent / f".{target.name}.staging-{uuid4().hex}"
|
||||
staging.mkdir()
|
||||
try:
|
||||
device = self._connector(serial)
|
||||
app_info = device.app_info(PDD_PACKAGE)
|
||||
version = _extract_version(app_info)
|
||||
|
||||
screenshot_path = staging / "screenshot.png"
|
||||
screenshot_base64 = device.jsonrpc_call(
|
||||
"takeScreenshot",
|
||||
SCREENSHOT_PARAMS,
|
||||
timeout=self._timeout_seconds,
|
||||
)
|
||||
_save_base64_screenshot(screenshot_base64, screenshot_path)
|
||||
|
||||
hierarchy = device.jsonrpc_call(
|
||||
"dumpWindowHierarchy",
|
||||
HIERARCHY_PARAMS,
|
||||
timeout=self._timeout_seconds,
|
||||
)
|
||||
_validate_hierarchy(hierarchy)
|
||||
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, version, screenshot_path, hierarchy_path),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.replace(staging, target)
|
||||
except BaselineCaptureError:
|
||||
# 仅删除本次创建、名称带随机标识的暂存目录,绝不触碰调用方原有输出目录。
|
||||
if staging.exists():
|
||||
shutil.rmtree(staging)
|
||||
raise
|
||||
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
|
||||
if staging.exists():
|
||||
shutil.rmtree(staging)
|
||||
raise BaselineCaptureTimeoutError("设备基线取证超时,未发布任何证据产物。") from error
|
||||
except Exception as error:
|
||||
if staging.exists():
|
||||
shutil.rmtree(staging)
|
||||
# uiautomator2/adbutils 可能把 serial、路径或远端响应放入异常文本,不能直接传播到 CLI。
|
||||
raise BaselineCaptureError("设备基线取证未完成,未发布任何证据产物。") from error
|
||||
|
||||
return BaselineCaptureResult(
|
||||
output_directory=target,
|
||||
manifest_path=target / "manifest.json",
|
||||
screenshot_path=target / "screenshot.png",
|
||||
hierarchy_path=target / "hierarchy.xml",
|
||||
)
|
||||
|
||||
|
||||
def _extract_version(app_info: dict[str, Any]) -> str:
|
||||
version = app_info.get("versionName") or app_info.get("version_name")
|
||||
if not isinstance(version, str) or not version.strip():
|
||||
raise BaselineCaptureError("无法读取拼多多版本,拒绝发布不完整取证。")
|
||||
return version.strip()
|
||||
|
||||
|
||||
def _save_base64_screenshot(value: Any, target: Path) -> None:
|
||||
"""规范化常见 ASCII Base64 空白后严格解码;没有 adb screenshot fallback。"""
|
||||
|
||||
if not isinstance(value, str) or not value:
|
||||
raise BaselineCaptureError("截图 RPC 未返回 base64 数据,拒绝发布不完整取证。")
|
||||
try:
|
||||
normalized = value.translate({ord(character): None for character in _BASE64_ASCII_WHITESPACE})
|
||||
raw_image = base64.b64decode(normalized.encode("ascii"), validate=True)
|
||||
except (UnicodeEncodeError, ValueError, binascii.Error) as error:
|
||||
raise BaselineCaptureError("截图 RPC Base64 语法无效,拒绝发布不完整取证。") from error
|
||||
try:
|
||||
with Image.open(BytesIO(raw_image)) as image:
|
||||
image.load()
|
||||
image.save(target, format="PNG")
|
||||
except (UnidentifiedImageError, OSError) as error:
|
||||
raise BaselineCaptureError("截图 RPC 图像数据无效,拒绝发布不完整取证。") from error
|
||||
|
||||
|
||||
def _validate_hierarchy(value: Any) -> None:
|
||||
"""确认 RPC 返回的是完整节点树,不把原始 XML 放进错误或日志。"""
|
||||
|
||||
if not isinstance(value, str) or not value:
|
||||
raise BaselineCaptureError("节点树导出为空,拒绝发布不完整取证。")
|
||||
try:
|
||||
root = ElementTree.fromstring(value)
|
||||
except ElementTree.ParseError as error:
|
||||
raise BaselineCaptureError("节点树不是有效 XML,拒绝发布不完整取证。") from error
|
||||
if root.tag != "hierarchy":
|
||||
raise BaselineCaptureError("节点树根节点无效,拒绝发布不完整取证。")
|
||||
|
||||
|
||||
def _manifest(
|
||||
inspection: DeviceInspection,
|
||||
serial: str,
|
||||
pdd_version: str,
|
||||
screenshot_path: Path,
|
||||
hierarchy_path: Path,
|
||||
) -> dict[str, Any]:
|
||||
"""只序列化审计摘要;页面内容留在 XML 文件,不进入日志或 manifest。"""
|
||||
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"captured_at": datetime.now(UTC).isoformat(),
|
||||
"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)},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
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()
|
||||
@@ -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 @@
|
||||
"""设备连接与基线取证的离线测试。"""
|
||||
@@ -0,0 +1,273 @@
|
||||
"""ADB 设备边界测试:所有命令执行器均为 mock,不连接真机。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
|
||||
CLIENT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(CLIENT_ROOT / "src"))
|
||||
|
||||
from cmbuyer_client.device.adb import (
|
||||
AdbClient,
|
||||
CommandResult,
|
||||
DeviceIdentityUnconfirmedError,
|
||||
DeviceCommandError,
|
||||
DeviceCommandTimeoutError,
|
||||
DeviceNotFoundError,
|
||||
DeviceOfflineError,
|
||||
DeviceStateError,
|
||||
DeviceUnauthorizedError,
|
||||
DuplicatePhysicalDeviceError,
|
||||
IntentLaunchUnconfirmedError,
|
||||
SerialRequiredError,
|
||||
)
|
||||
|
||||
|
||||
USB_SERIAL = "3B65BD02H7F00000"
|
||||
WIFI_SERIAL = "192.168.0.173:5555"
|
||||
|
||||
|
||||
class FakeRunner:
|
||||
def __init__(self, devices_output: str, properties: dict[tuple[str, str], CommandResult | str]) -> None:
|
||||
self.devices_output = devices_output
|
||||
self.properties = properties
|
||||
self.calls: list[tuple[str, ...]] = []
|
||||
|
||||
def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
|
||||
self.calls.append(tuple(arguments))
|
||||
if tuple(arguments) == ("devices", "-l"):
|
||||
return CommandResult(stdout=self.devices_output)
|
||||
key = (arguments[1], arguments[-1])
|
||||
value = self.properties.get(key, "")
|
||||
return value if isinstance(value, CommandResult) else CommandResult(stdout=value)
|
||||
|
||||
|
||||
def _properties(serials: tuple[str, ...]) -> dict[tuple[str, str], str]:
|
||||
values: dict[tuple[str, str], str] = {}
|
||||
for serial in serials:
|
||||
values[(serial, "ro.serialno")] = "physical-phone-1"
|
||||
values[(serial, "ro.boot.serialno")] = "physical-phone-1"
|
||||
values[(serial, "ro.product.model")] = "PKG110"
|
||||
values[(serial, "ro.product.name")] = "PKG110"
|
||||
values[(serial, "ro.product.device")] = "OP5D2BL1"
|
||||
values[(serial, "ro.build.version.release")] = "16"
|
||||
return values
|
||||
|
||||
|
||||
class AdbClientTests(unittest.TestCase):
|
||||
def test_requires_explicit_serial(self) -> None:
|
||||
runner = FakeRunner("List of devices attached\n", {})
|
||||
|
||||
with self.assertRaises(SerialRequiredError):
|
||||
AdbClient(runner).inspect(" ")
|
||||
|
||||
self.assertEqual(runner.calls, [])
|
||||
|
||||
def test_missing_offline_and_unauthorized_are_distinct(self) -> None:
|
||||
missing = AdbClient(FakeRunner("List of devices attached\n", {}))
|
||||
with self.assertRaises(DeviceNotFoundError):
|
||||
missing.inspect(USB_SERIAL)
|
||||
|
||||
offline = AdbClient(FakeRunner(f"List of devices attached\n{USB_SERIAL}\toffline\n", {}))
|
||||
with self.assertRaises(DeviceOfflineError):
|
||||
offline.inspect(USB_SERIAL)
|
||||
|
||||
unauthorized = AdbClient(FakeRunner(f"List of devices attached\n{USB_SERIAL}\tunauthorized\n", {}))
|
||||
with self.assertRaises(DeviceUnauthorizedError):
|
||||
unauthorized.inspect(USB_SERIAL)
|
||||
|
||||
def test_two_channels_with_same_physical_identity_fail_closed(self) -> None:
|
||||
output = (
|
||||
"List of devices attached\n"
|
||||
f"{WIFI_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
|
||||
f"{USB_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
|
||||
)
|
||||
runner = FakeRunner(output, _properties((WIFI_SERIAL, USB_SERIAL)))
|
||||
|
||||
with self.assertRaises(DuplicatePhysicalDeviceError):
|
||||
AdbClient(runner).inspect(USB_SERIAL)
|
||||
|
||||
self.assertIn(("-s", WIFI_SERIAL, "shell", "getprop", "ro.serialno"), runner.calls)
|
||||
self.assertIn(("-s", USB_SERIAL, "shell", "getprop", "ro.serialno"), runner.calls)
|
||||
|
||||
def test_multiple_online_devices_with_failed_identity_fail_closed(self) -> None:
|
||||
output = (
|
||||
"List of devices attached\n"
|
||||
f"{WIFI_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
|
||||
f"{USB_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
|
||||
)
|
||||
properties = _properties((WIFI_SERIAL, USB_SERIAL))
|
||||
properties[(WIFI_SERIAL, "ro.serialno")] = CommandResult(stdout="", returncode=1)
|
||||
runner = FakeRunner(output, properties)
|
||||
|
||||
with self.assertRaises(DeviceIdentityUnconfirmedError):
|
||||
AdbClient(runner).inspect(USB_SERIAL)
|
||||
|
||||
def test_online_explicit_serial_reads_non_sensitive_metadata(self) -> None:
|
||||
output = f"List of devices attached\n{USB_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
|
||||
inspection = AdbClient(FakeRunner(output, _properties((USB_SERIAL,)))).inspect(USB_SERIAL)
|
||||
|
||||
self.assertEqual(inspection.device.serial, USB_SERIAL)
|
||||
self.assertEqual(inspection.model, "PKG110")
|
||||
self.assertEqual(inspection.android_version, "16")
|
||||
|
||||
def test_single_online_device_does_not_require_hardware_identity(self) -> None:
|
||||
output = f"List of devices attached\n{USB_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
|
||||
properties = _properties((USB_SERIAL,))
|
||||
properties[(USB_SERIAL, "ro.serialno")] = ""
|
||||
properties[(USB_SERIAL, "ro.boot.serialno")] = ""
|
||||
|
||||
inspection = AdbClient(FakeRunner(output, properties)).inspect(USB_SERIAL)
|
||||
|
||||
self.assertEqual(inspection.model, "PKG110")
|
||||
|
||||
def test_multiple_online_devices_without_hardware_identity_are_unconfirmed(self) -> None:
|
||||
output = (
|
||||
"List of devices attached\n"
|
||||
f"{WIFI_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
|
||||
f"{USB_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
|
||||
)
|
||||
properties = _properties((WIFI_SERIAL, USB_SERIAL))
|
||||
properties[(WIFI_SERIAL, "ro.serialno")] = ""
|
||||
properties[(WIFI_SERIAL, "ro.boot.serialno")] = ""
|
||||
|
||||
with self.assertRaises(DeviceIdentityUnconfirmedError):
|
||||
AdbClient(FakeRunner(output, properties)).inspect(USB_SERIAL)
|
||||
|
||||
def test_multiple_online_devices_with_different_identity_keep_explicit_selection(self) -> None:
|
||||
output = (
|
||||
"List of devices attached\n"
|
||||
f"{WIFI_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
|
||||
f"{USB_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
|
||||
)
|
||||
properties = _properties((WIFI_SERIAL, USB_SERIAL))
|
||||
properties[(WIFI_SERIAL, "ro.serialno")] = "physical-phone-2"
|
||||
properties[(WIFI_SERIAL, "ro.boot.serialno")] = "physical-phone-2"
|
||||
|
||||
inspection = AdbClient(FakeRunner(output, properties)).inspect(USB_SERIAL)
|
||||
|
||||
self.assertEqual(inspection.device.serial, USB_SERIAL)
|
||||
|
||||
def test_shared_boot_serial_is_duplicate_even_when_ro_serial_differs(self) -> None:
|
||||
output = (
|
||||
"List of devices attached\n"
|
||||
f"{WIFI_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
|
||||
f"{USB_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
|
||||
)
|
||||
properties = _properties((WIFI_SERIAL, USB_SERIAL))
|
||||
properties[(WIFI_SERIAL, "ro.serialno")] = "wifi-transport-serial"
|
||||
properties[(USB_SERIAL, "ro.serialno")] = "usb-transport-serial"
|
||||
properties[(WIFI_SERIAL, "ro.boot.serialno")] = "shared-hardware-serial"
|
||||
properties[(USB_SERIAL, "ro.boot.serialno")] = "shared-hardware-serial"
|
||||
|
||||
with self.assertRaises(DuplicatePhysicalDeviceError):
|
||||
AdbClient(FakeRunner(output, properties)).inspect(USB_SERIAL)
|
||||
|
||||
def test_unknown_adb_state_is_rejected(self) -> None:
|
||||
client = AdbClient(FakeRunner(f"List of devices attached\n{USB_SERIAL}\trecovery\n", {}))
|
||||
|
||||
with self.assertRaises(DeviceStateError):
|
||||
client.inspect(USB_SERIAL)
|
||||
|
||||
def test_runner_timeout_is_a_distinct_connection_error(self) -> None:
|
||||
class TimeoutRunner:
|
||||
def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
|
||||
raise subprocess.TimeoutExpired(arguments, timeout_seconds)
|
||||
|
||||
with self.assertRaises(DeviceCommandTimeoutError):
|
||||
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,281 @@
|
||||
"""基线取证测试:mock ADB/uiautomator2,不连接手机。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import base64
|
||||
from io import BytesIO
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from PIL import Image
|
||||
from uiautomator2.exceptions import HTTPTimeoutError
|
||||
|
||||
|
||||
CLIENT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(CLIENT_ROOT / "src"))
|
||||
sys.path.insert(0, str(CLIENT_ROOT / "scripts"))
|
||||
|
||||
from cmbuyer_client.device.adb import AdbDevice, DeviceInspection
|
||||
from cmbuyer_client.device.baseline import (
|
||||
BaselineCaptureError,
|
||||
BaselineCaptureTimeoutError,
|
||||
DeviceBaselineCapturer,
|
||||
NoReconnectUiautomatorConnector,
|
||||
PDD_PACKAGE,
|
||||
)
|
||||
from capture_device_baseline import parse_arguments, validate_arguments
|
||||
|
||||
|
||||
SERIAL = "USB-serial-for-test"
|
||||
|
||||
|
||||
class StaticAdbClient:
|
||||
def __init__(self) -> None:
|
||||
self.serials: list[str] = []
|
||||
|
||||
def inspect(self, serial: str) -> DeviceInspection:
|
||||
self.serials.append(serial)
|
||||
return DeviceInspection(
|
||||
device=AdbDevice(serial=serial, state="device", model="Test Model"),
|
||||
model="Test Model",
|
||||
android_version="16",
|
||||
)
|
||||
|
||||
|
||||
class FakeUiDevice:
|
||||
def __init__(self, fail_dump: bool = False) -> None:
|
||||
self.fail_dump = fail_dump
|
||||
self.rpc_calls: list[tuple[str, object, float]] = []
|
||||
self.app_info_calls: list[str] = []
|
||||
|
||||
def app_info(self, package_name: str) -> dict[str, str]:
|
||||
self.app_info_calls.append(package_name)
|
||||
return {"versionName": "8.17.0"}
|
||||
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||
self.rpc_calls.append((method, params, timeout))
|
||||
if method == "takeScreenshot":
|
||||
image_data = BytesIO()
|
||||
Image.new("RGB", (1, 1), color="white").save(image_data, format="PNG")
|
||||
return base64.b64encode(image_data.getvalue()).decode("ascii")
|
||||
if method != "dumpWindowHierarchy":
|
||||
raise AssertionError(f"unexpected method: {method}")
|
||||
if self.fail_dump:
|
||||
raise RuntimeError("mock dump failed")
|
||||
return "<hierarchy><node text='page body must stay out of manifest'/></hierarchy>"
|
||||
|
||||
|
||||
class BaselineCaptureTests(unittest.TestCase):
|
||||
def test_capture_writes_hashes_without_xml_or_raw_serial_in_manifest(self) -> None:
|
||||
adb = StaticAdbClient()
|
||||
device = FakeUiDevice()
|
||||
capturer = DeviceBaselineCapturer(adb, lambda serial: device, timeout_seconds=7.5)
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
output = Path(directory) / "baseline"
|
||||
result = capturer.capture(SERIAL, output)
|
||||
manifest = result.manifest_path.read_text(encoding="utf-8")
|
||||
|
||||
self.assertEqual(adb.serials, [SERIAL])
|
||||
self.assertEqual(device.app_info_calls, [PDD_PACKAGE])
|
||||
self.assertEqual(
|
||||
device.rpc_calls,
|
||||
[
|
||||
("takeScreenshot", [1, 80], 7.5),
|
||||
("dumpWindowHierarchy", [False, 50], 7.5),
|
||||
],
|
||||
)
|
||||
self.assertTrue(result.screenshot_path.is_file())
|
||||
self.assertTrue(result.hierarchy_path.is_file())
|
||||
self.assertIn('"sha256"', manifest)
|
||||
self.assertNotIn("page body must stay out of manifest", manifest)
|
||||
self.assertNotIn(SERIAL, manifest)
|
||||
self.assertIn('"channel": "usb"', manifest)
|
||||
|
||||
def test_capture_failure_cleans_staging_and_does_not_publish_partial_output(self) -> None:
|
||||
device = FakeUiDevice(fail_dump=True)
|
||||
capturer = DeviceBaselineCapturer(StaticAdbClient(), lambda serial: device, timeout_seconds=5)
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
parent = Path(directory)
|
||||
output = parent / "baseline"
|
||||
with self.assertRaises(BaselineCaptureError) as raised:
|
||||
capturer.capture(SERIAL, output)
|
||||
|
||||
self.assertFalse(output.exists())
|
||||
self.assertEqual(list(parent.iterdir()), [])
|
||||
self.assertNotIn("mock dump failed", str(raised.exception))
|
||||
|
||||
def test_existing_output_is_never_overwritten(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
output = Path(directory) / "baseline"
|
||||
output.mkdir()
|
||||
sentinel = output / "keep.txt"
|
||||
sentinel.write_text("preserve", encoding="utf-8")
|
||||
capturer = DeviceBaselineCapturer(StaticAdbClient(), lambda serial: FakeUiDevice(), timeout_seconds=5)
|
||||
|
||||
with self.assertRaises(BaselineCaptureError):
|
||||
capturer.capture(SERIAL, output)
|
||||
self.assertEqual(sentinel.read_text(encoding="utf-8"), "preserve")
|
||||
|
||||
def test_no_reconnect_connector_passes_only_current_adb_device_object(self) -> None:
|
||||
class ListedDevice:
|
||||
serial = SERIAL
|
||||
|
||||
listed = ListedDevice()
|
||||
connected: list[object] = []
|
||||
|
||||
connector = NoReconnectUiautomatorConnector(lambda: [listed], lambda device: connected.append(device) or FakeUiDevice())
|
||||
connector(SERIAL)
|
||||
|
||||
self.assertEqual(connected, [listed])
|
||||
|
||||
def test_no_reconnect_connector_refuses_disappeared_serial(self) -> None:
|
||||
connector = NoReconnectUiautomatorConnector(lambda: [], lambda device: FakeUiDevice())
|
||||
|
||||
with self.assertRaises(BaselineCaptureError) as raised:
|
||||
connector(SERIAL)
|
||||
self.assertIn("拒绝自动重连", str(raised.exception))
|
||||
|
||||
def test_connector_exception_is_redacted_and_publishes_no_partial_output(self) -> None:
|
||||
def failing_connector(serial: str) -> FakeUiDevice:
|
||||
raise RuntimeError(f"third party leaked {serial}")
|
||||
|
||||
capturer = DeviceBaselineCapturer(StaticAdbClient(), failing_connector, timeout_seconds=5)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
output = Path(directory) / "baseline"
|
||||
with self.assertRaises(BaselineCaptureError) as raised:
|
||||
capturer.capture(SERIAL, output)
|
||||
|
||||
self.assertNotIn(SERIAL, str(raised.exception))
|
||||
self.assertFalse(output.exists())
|
||||
self.assertEqual(list(Path(directory).iterdir()), [])
|
||||
|
||||
def test_invalid_screenshot_base64_syntax_fails_closed_without_partial_output(self) -> None:
|
||||
class InvalidScreenshotDevice(FakeUiDevice):
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||
if method == "takeScreenshot":
|
||||
valid = super().jsonrpc_call(method, params, timeout)
|
||||
return valid[:12] + "!" + valid[12:]
|
||||
return super().jsonrpc_call(method, params, timeout)
|
||||
|
||||
capturer = DeviceBaselineCapturer(StaticAdbClient(), lambda serial: InvalidScreenshotDevice(), timeout_seconds=5)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
output = Path(directory) / "baseline"
|
||||
with self.assertRaises(BaselineCaptureError) as raised:
|
||||
capturer.capture(SERIAL, output)
|
||||
|
||||
self.assertIn("Base64 语法无效", str(raised.exception))
|
||||
self.assertNotIn("!", str(raised.exception))
|
||||
self.assertFalse(output.exists())
|
||||
self.assertEqual(list(Path(directory).iterdir()), [])
|
||||
|
||||
def test_invalid_padding_and_unapproved_ascii_whitespace_fail_closed(self) -> None:
|
||||
invalid_insertions = {
|
||||
"padding": lambda value: value[:-1],
|
||||
"vertical-tab": lambda value: value[:12] + "\v" + value[12:],
|
||||
"form-feed": lambda value: value[:12] + "\f" + value[12:],
|
||||
}
|
||||
|
||||
for name, make_invalid in invalid_insertions.items():
|
||||
with self.subTest(name=name), tempfile.TemporaryDirectory() as directory:
|
||||
class InvalidScreenshotDevice(FakeUiDevice):
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||
value = super().jsonrpc_call(method, params, timeout)
|
||||
if method == "takeScreenshot":
|
||||
return make_invalid(value)
|
||||
return value
|
||||
|
||||
output = Path(directory) / "baseline"
|
||||
capturer = DeviceBaselineCapturer(
|
||||
StaticAdbClient(),
|
||||
lambda serial: InvalidScreenshotDevice(),
|
||||
timeout_seconds=5,
|
||||
)
|
||||
with self.assertRaises(BaselineCaptureError) as raised:
|
||||
capturer.capture(SERIAL, output)
|
||||
|
||||
self.assertIn("Base64 语法无效", str(raised.exception))
|
||||
self.assertNotIn(SERIAL, str(raised.exception))
|
||||
self.assertFalse(output.exists())
|
||||
self.assertEqual(list(Path(directory).iterdir()), [])
|
||||
|
||||
def test_base64_decoded_nonimage_fails_closed_without_partial_output(self) -> None:
|
||||
class NonImageScreenshotDevice(FakeUiDevice):
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||
if method == "takeScreenshot":
|
||||
return base64.b64encode(b"not an image").decode("ascii")
|
||||
return super().jsonrpc_call(method, params, timeout)
|
||||
|
||||
capturer = DeviceBaselineCapturer(StaticAdbClient(), lambda serial: NonImageScreenshotDevice(), timeout_seconds=5)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
output = Path(directory) / "baseline"
|
||||
with self.assertRaises(BaselineCaptureError) as raised:
|
||||
capturer.capture(SERIAL, output)
|
||||
|
||||
self.assertIn("图像数据无效", str(raised.exception))
|
||||
self.assertNotIn("not an image", str(raised.exception))
|
||||
self.assertFalse(output.exists())
|
||||
self.assertEqual(list(Path(directory).iterdir()), [])
|
||||
|
||||
def test_ascii_base64_whitespace_is_normalized_before_strict_decode(self) -> None:
|
||||
class WhitespaceScreenshotDevice(FakeUiDevice):
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||
value = super().jsonrpc_call(method, params, timeout)
|
||||
if method == "takeScreenshot":
|
||||
return value[:10] + " \t\r\n" + value[10:30] + "\n" + value[30:]
|
||||
return value
|
||||
|
||||
capturer = DeviceBaselineCapturer(StaticAdbClient(), lambda serial: WhitespaceScreenshotDevice(), timeout_seconds=5)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
output = Path(directory) / "baseline"
|
||||
result = capturer.capture(SERIAL, output)
|
||||
|
||||
self.assertTrue(result.screenshot_path.is_file())
|
||||
with Image.open(result.screenshot_path) as image:
|
||||
self.assertEqual(image.size, (1, 1))
|
||||
|
||||
def test_invalid_or_non_hierarchy_xml_fails_closed_without_partial_output(self) -> None:
|
||||
class InvalidHierarchyDevice(FakeUiDevice):
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||
if method == "dumpWindowHierarchy":
|
||||
return "<not-hierarchy/>"
|
||||
return super().jsonrpc_call(method, params, timeout)
|
||||
|
||||
capturer = DeviceBaselineCapturer(StaticAdbClient(), lambda serial: InvalidHierarchyDevice(), timeout_seconds=5)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
output = Path(directory) / "baseline"
|
||||
with self.assertRaises(BaselineCaptureError) as raised:
|
||||
capturer.capture(SERIAL, output)
|
||||
|
||||
self.assertNotIn("not-hierarchy", str(raised.exception))
|
||||
self.assertFalse(output.exists())
|
||||
self.assertEqual(list(Path(directory).iterdir()), [])
|
||||
|
||||
def test_rpc_timeout_is_distinct_redacted_and_does_not_publish_partial_output(self) -> None:
|
||||
class TimeoutRpcDevice(FakeUiDevice):
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||
raise HTTPTimeoutError(f"raw serial={SERIAL} xml=<hierarchy/>")
|
||||
|
||||
capturer = DeviceBaselineCapturer(StaticAdbClient(), lambda serial: TimeoutRpcDevice(), timeout_seconds=5)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
output = Path(directory) / "baseline"
|
||||
with self.assertRaises(BaselineCaptureTimeoutError) as raised:
|
||||
capturer.capture(SERIAL, output)
|
||||
|
||||
self.assertIn("超时", str(raised.exception))
|
||||
self.assertNotIn(SERIAL, str(raised.exception))
|
||||
self.assertNotIn("hierarchy", str(raised.exception))
|
||||
self.assertFalse(output.exists())
|
||||
self.assertEqual(list(Path(directory).iterdir()), [])
|
||||
|
||||
def test_cli_validation_rejects_empty_serial_and_nonpositive_timeout(self) -> None:
|
||||
empty_serial = parse_arguments(["--serial", "", "--output-dir", "baseline"])
|
||||
with self.assertRaisesRegex(ValueError, "非空 --serial"):
|
||||
validate_arguments(empty_serial)
|
||||
|
||||
nonpositive_timeout = parse_arguments(["--serial", SERIAL, "--output-dir", "baseline", "--timeout", "0"])
|
||||
with self.assertRaisesRegex(ValueError, "必须大于 0"):
|
||||
validate_arguments(nonpositive_timeout)
|
||||
@@ -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)
|
||||
+16
-12
@@ -89,8 +89,8 @@ cmbuyer 是一个自动化采购系统:**采购服务**(网页端,`admin/`
|
||||
|
||||
执行按任务依赖驱动,**不按 Phase 整段串行等待**。当前优先路径:
|
||||
|
||||
1. T-004 核心数据模型正在进行;T-003 统一入口与 T-101 真机环境盘点可开始。
|
||||
2. T-002 已完成,立即推进 **T-101 → T-102 → T-103 真机取证**,并可并行补 T-003 统一入口。
|
||||
1. T-003 统一入口与 T-004 核心数据模型已完成;T-101 真机环境盘点已就绪,但尚未真机验收。
|
||||
2. T-002 已完成,立即推进 **T-101 → T-102 → T-103 真机取证**。
|
||||
3. T-103 结论确认后,才开始依赖真机可读字段的 Phase 2 生产页面;采购服务核心与
|
||||
T-104 → T-107 后续真机安全判据按依赖并行推进。
|
||||
4. Phase 3:双端打通与**第一趟试选**端到端。
|
||||
@@ -172,25 +172,29 @@ cmbuyer 是一个自动化采购系统:**采购服务**(网页端,`admin/`
|
||||
|
||||
## 验证命令
|
||||
|
||||
> 采购服务命令已由 T-001 实际验证;采购工具的离线测试、编译和 wheel 元数据命令已由
|
||||
> Python 3.12 实际验证。当前默认 `python` 是 Python 3.10.11,统一入口尚未正确选择
|
||||
> Python 3.11+,此缺口由 T-003 修复。
|
||||
> Windows 标准入口 `./init.ps1` 已由 T-003 实际验证。它优先使用合规的既有 venv(本机实际为
|
||||
> Python 3.12),仅在 venv 缺失时才从 Python Launcher 自动选择最高的 Python 3.11+,避免回退到
|
||||
> 默认 Python 3.10;通过后会输出两端真实启动命令。
|
||||
|
||||
```powershell
|
||||
# Windows 统一安装、离线验证与启动命令提示
|
||||
.\init.ps1
|
||||
|
||||
# 以下为诊断或单独验证时使用的等价命令
|
||||
# 采购服务 admin/(改了 Go 代码后)
|
||||
cd admin
|
||||
go test ./...
|
||||
go vet ./...
|
||||
go build ./...
|
||||
|
||||
# 采购工具 client/(使用 Python 3.11+;本机已验证 3.12)
|
||||
# 采购工具 client/(由 init.ps1 创建的 Python 3.11+ 虚拟环境)
|
||||
cd client
|
||||
py -3.12 -m unittest discover -s tests -t .
|
||||
py -3.12 -m compileall -q src tests scripts
|
||||
.\.venv\Scripts\python.exe -m unittest discover -s tests -t .
|
||||
.\.venv\Scripts\python.exe -m compileall -q src tests scripts
|
||||
$wheelDir = Join-Path $env:TEMP ('cmbuyer-client-wheel-' + [guid]::NewGuid())
|
||||
New-Item -ItemType Directory -Path $wheelDir | Out-Null
|
||||
py -3.12 -m pip wheel --no-deps . --wheel-dir $wheelDir
|
||||
py -3.12 scripts/verify_wheel_metadata.py (Get-ChildItem $wheelDir -Filter '*.whl').FullName
|
||||
.\.venv\Scripts\python.exe -m pip wheel --no-deps . --wheel-dir $wheelDir
|
||||
.\.venv\Scripts\python.exe scripts/verify_wheel_metadata.py (Get-ChildItem $wheelDir -Filter '*.whl').FullName
|
||||
|
||||
# 跨端契约改动:两端全跑
|
||||
```
|
||||
@@ -198,5 +202,5 @@ py -3.12 scripts/verify_wheel_metadata.py (Get-ChildItem $wheelDir -Filter '*.wh
|
||||
验证层级何时触发见 [`03-tech-stack.md`](03-tech-stack.md) 第六节验证矩阵。
|
||||
`requirements.txt` 是采购工具唯一的运行时依赖来源,`pyproject.toml` 动态读取它写入 wheel
|
||||
元数据。规范安装是 Python 3.11+ 虚拟环境中的 `python -m pip install -e .`,已由 Python 3.12
|
||||
验证通过;桌面 GUI 和真机流程不属于本次验收。`init.ps1` 仍未正确选择 Python 3.11+,统一入口
|
||||
由 T-003 修复。若命令当前不可运行,必须在回复里如实说明原因。
|
||||
验证通过;桌面 GUI 和真机流程不属于本次验收。`init.ps1` 使用合规既有 venv,或在缺失时自动选择
|
||||
Python 3.11+ 创建它;本机现有 venv 实际验证为 3.12。若命令当前不可运行,必须在回复里如实说明原因。
|
||||
|
||||
+73
-11
@@ -59,25 +59,87 @@
|
||||
|
||||
## 五、构建与运行命令
|
||||
|
||||
> 采购服务命令已由 T-001 实际验证。采购工具以下离线测试、编译和 wheel 元数据命令已由
|
||||
> Python 3.12 实际验证;桌面 GUI 和真机流程不属于 T-002 验收范围。
|
||||
> `init.ps1` 已由 T-003 在 Windows PowerShell 实际验证:它优先复用合规的既有采购工具虚拟环境
|
||||
> (本机实际为 Python 3.12);仅在虚拟环境不存在时,才从 Python Launcher 的已安装版本中确定性选择
|
||||
> 最高的 Python 3.11+ 创建它,以 editable 方式安装采购工具,并跑两端
|
||||
> 离线门禁。桌面 GUI 和真机流程不属于该入口的验收范围。
|
||||
|
||||
| 用途 | 采购服务(`admin/`) | 采购工具(`client/`) |
|
||||
| --- | --- | --- |
|
||||
| 安装依赖 | `go mod download` | `py -3.12 -m venv .venv` 后运行 `.\.venv\Scripts\python.exe -m pip install -e .` |
|
||||
| 安装依赖 | `go mod download` | Windows 运行 `./init.ps1`;它复用合规 venv,或选择 Python Launcher 中最高的 Python 3.11+ 创建 venv 后执行 `pip install -e .` |
|
||||
| 本地开发 | `go run ./cmd/server` | `.\.venv\Scripts\python.exe -m cmbuyer_client` |
|
||||
| 构建 | `go build ./...` | `py -3.12 -m pip wheel --no-deps . --wheel-dir <输出目录>`,再运行 `py -3.12 scripts/verify_wheel_metadata.py <wheel 路径>` |
|
||||
| 测试 | `go test ./...` | `py -3.12 -m unittest discover -s tests -t .` |
|
||||
| 静态检查 | `go vet ./...` | `py -3.12 -m compileall -q src tests scripts` |
|
||||
| 构建 | `go build ./...` | `.\.venv\Scripts\python.exe -m pip wheel --no-deps . --wheel-dir <输出目录>`,再运行 `.\.venv\Scripts\python.exe scripts/verify_wheel_metadata.py <wheel 路径>` |
|
||||
| 测试 | `go test ./...` | `.\.venv\Scripts\python.exe -m unittest discover -s tests -t .` |
|
||||
| 静态检查 | `go vet ./...` | `.\.venv\Scripts\python.exe -m compileall -q src tests scripts` |
|
||||
|
||||
### T-101 设备基线取证(2026-08-04 已完成人工双通道验收)
|
||||
|
||||
`client/scripts/capture_device_baseline.py` 只允许对**手工明确填写**的 ADB serial 做连接前核验、
|
||||
设备型号 / Android / 拼多多版本读取、截图和 `dump_hierarchy(compressed=False)`。它不打开商品、
|
||||
不读取页面判据,也不执行采购、下单或付款动作。多个在线通道必须完成 `getprop` 物理身份比对:
|
||||
同一手机 USB + WiFi 同时在线,或任一在线通道的身份读取失败,都会 fail closed,不能随机继续。
|
||||
|
||||
人工验收前,先由人把手机切换到不含收货地址、手机号、支付信息或其他无关隐私的安全页面,再在
|
||||
`adb devices -l` 中**手工复制**一个在线 serial;USB 和 WiFi 分别验收,且每次只保留一个通道在线。
|
||||
WiFi 通道必须由人先行建立;脚本禁止 `adb connect`、`adb disconnect` 或自动重连。以下命令中的尖括号
|
||||
必须替换为该次人工确认的实际 serial,不能省略或改成自动选择:
|
||||
|
||||
```powershell
|
||||
# 仓库根目录;先手工确认设备状态,命令本身只读 ADB 清单
|
||||
D:\Portable\adb\adb.exe devices -l
|
||||
|
||||
# USB:粘贴该次 devices -l 显示的 USB serial
|
||||
.\client\.venv\Scripts\python.exe client\scripts\capture_device_baseline.py --serial <USB_SERIAL> --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-101\usb-baseline" --timeout 10 --adb D:\Portable\adb\adb.exe
|
||||
|
||||
# WiFi:由人先建立 WiFi ADB 通道、断开 USB 后,粘贴该次 devices -l 显示的 WiFi serial
|
||||
.\client\.venv\Scripts\python.exe client\scripts\capture_device_baseline.py --serial <WIFI_SERIAL> --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-101\wifi-baseline" --timeout 10 --adb D:\Portable\adb\adb.exe
|
||||
```
|
||||
|
||||
成功时输出目录仅包含截图、完整 XML 与不含页面正文的 `manifest.json`(设备元数据、通道、时间、
|
||||
文件 SHA-256 和 serial 哈希)。`--timeout` 约束 ADB 命令、ADB socket 及 `takeScreenshot` /
|
||||
`dumpWindowHierarchy(compressed=False, max_depth=50)` 的公开 JSON-RPC 调用;uiautomator2 初始化仍有
|
||||
上游固定启动上限。截图 Base64 仅兼容 RPC 返回值中的空格、TAB、CR、LF,其余字符
|
||||
仍严格拒绝。XML 仅留在本机明确指定的证据目录;人工必须先在本地检查截图/XML,再只记录路径
|
||||
和哈希,不得把原始证据提交 Git。USB 与 WiFi 已分别由人完成取证和隐私检查,设备型号、Android、
|
||||
拼多多版本、产物路径及 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 已记录到任务执行记录。
|
||||
|
||||
Windows 的标准入口是仓库根 `./init.ps1`。它要求 Go、两端目录及其哨兵文件存在;已有合规
|
||||
`client/.venv` 时,所有采购工具检查与 validator 都使用该解释器。只有 venv 不存在时,才从 `py -0p`
|
||||
枚举的版本中确定性选择最高的 Python 3.11+ 创建它;没有合规版本时明确失败,绝不回退默认 `python`。
|
||||
它执行 admin 的 `go mod download` / test / vet / build、client 的 editable install / 包导入 / unittest /
|
||||
compileall。既有 `client/.venv` 若不是 Python 3.11+ 会明确失败,不会自动覆盖用户环境。`init.sh` 保持等价门禁语义;WSL 或 Unix 环境缺少所需 Go、Python 3.11+
|
||||
或项目文件时必须非零退出,语法通过或 Unix 成功不构成 Windows / 真机验收。
|
||||
|
||||
采购工具的运行时依赖只维护在 `client/requirements.txt`。`client/pyproject.toml` 通过 setuptools
|
||||
动态读取该文件生成 wheel 的 `Requires-Dist`,避免两份依赖列表漂移;
|
||||
`scripts/verify_wheel_metadata.py` 会验证生成 wheel 已声明全部这些依赖。
|
||||
|
||||
当前 Windows 默认 `python` 指向 Python 3.10.11,不满足采购工具的 Python 3.11+ 下限,
|
||||
不得把未加版本选择器的 `python` 当作采购工具的已验证命令。T-003 负责统一入口与解释器选择;
|
||||
在此之前使用 `py -3.12` 或 Python 3.11+ 虚拟环境。Python 3.12 虚拟环境中的完整
|
||||
`pip install -e .` 已验证通过;桌面 GUI 与真机流程不属于 T-002 验收范围。
|
||||
当前 Windows 默认 `python` 仍可能指向 Python 3.10,不满足采购工具的 Python 3.11+ 下限;
|
||||
不得把未加版本选择器的 `python` 当作采购工具命令。统一入口优先使用既有合规 venv,仅在需要创建时
|
||||
自动选择 Launcher 中最高的合规版本;本机 Python 3.12 与 3.14 均已验证,主工作区当前选择 Python
|
||||
3.14。桌面 GUI 与真机流程不属于 T-003 验收范围。
|
||||
|
||||
Windows PowerShell 差异:
|
||||
|
||||
@@ -90,7 +152,7 @@ $env:GOTOOLCHAIN = "local"
|
||||
|
||||
| 层级 | 触发条件 | 命令 / 操作 | 通过证据 |
|
||||
| --- | --- | --- | --- |
|
||||
| 任务相关验证 | 每个任务必跑 | 改 `admin/` 跑 `go test ./...` + `go vet ./...`;改 `client/` 跑 `py -3.12 -m unittest discover -s tests -t .` + `py -3.12 -m compileall -q src tests scripts` | 退出码 0、测试数 |
|
||||
| 任务相关验证 | 每个任务必跑 | 改 `admin/` 跑 `go test ./...` + `go vet ./...`;改 `client/` 跑 `.venv\Scripts\python.exe -m unittest discover -s tests -t .` + `.venv\Scripts\python.exe -m compileall -q src tests scripts` | 退出码 0、测试数 |
|
||||
| 完整门禁 | 发布前;修改 HTTP 契约、数据库 schema、依赖或构建配置时;跨端改动时 | 两端全部测试 + 静态检查 + 两端构建 | 退出码 0、测试数、产物路径 |
|
||||
| 人工 / 设备验收 | 任何涉及真机页面判据、下单动作或付款路径的任务 | 连接真机执行,记录设备型号、Android 版本、拼多多版本、goods_id、截图与页面 XML 路径 | 人工结论 + 证据文件路径 |
|
||||
|
||||
|
||||
@@ -386,7 +386,7 @@ PENDING_RETRIAL ─────────────┴─claim→ CLAIMED
|
||||
| 同一商品两趟结果不一致 | 第二趟价格变了、规格选项变了或商品下架 | 闸门二拦截;一律转人工,不自动放弃也不自动继续 |
|
||||
| 图搜结果含跨类目商品(V2) | 搜服装出现纸巾 | B 路径只产 goods_id 且限 5 个;后续用 VLM 看截图筛同款 |
|
||||
| WiFi ADB 稳定性 | 息屏、换网、DHCP 续租会断连 | 超时可配置;断连视为技术失败并保留现场,不重试点击 |
|
||||
| 同一手机 USB + WiFi 同时在线 | `adb devices` 列出两条,自动选设备会失败 | 设备档案必须显式指定 serial,不允许留空自动选 |
|
||||
| 同一手机 USB + WiFi 同时在线 | `adb devices` 列出两条,自动选设备会失败 | serial 必填;多在线通道必须读到 `ro.serialno` 或 `ro.boot.serialno` 才能比对。身份一致或任一身份读取失败时都 fail closed,不能以相同 model/product 猜测后继续 |
|
||||
| 不可逆动作的重试 | 点击「现在买」后超时,无法判断订单是否已创建 | 一律转人工并预留金额额度,**禁止自动重试点击** |
|
||||
| 双端契约漂移 | 两端独立演进会静默不兼容 | 契约改动必跑完整门禁;[api.md](api.md) 是唯一权威 |
|
||||
|
||||
|
||||
+74
-43
@@ -11,35 +11,37 @@
|
||||
|
||||
## 当前快照
|
||||
|
||||
- 日期:2026-08-03
|
||||
- 阶段:**Phase 0 · 地基(采购服务与采购工具骨架已初始化,尚无采购业务代码)**
|
||||
- 日期:2026-08-04
|
||||
- 阶段:**Phase 1 · 真机可行性(T-101、T-102 已完成人工真机验收,下一步 T-103)**
|
||||
- MVP 形态:手工填链接建单 → 批量开始试选 → 定时轮询 → **第一趟试选** → 人工确认 → **第二趟下单** → 待付款
|
||||
- 技术栈:已定。采购服务(`admin/`)使用 Go 1.23+ / gin / SQLite;采购工具(`client/`)
|
||||
使用 Python 3.11+ / uiautomator2 / PySide6。
|
||||
详见 [`03-tech-stack.md`](03-tech-stack.md)
|
||||
- 生产代码:`admin/` 已有最小 Go 服务、`GET /healthz` 与 SQLite 驱动封装;`client/` 已有
|
||||
Python 包、PySide6 最小入口、运行目录与日志脱敏策略;两端均尚无采购业务代码
|
||||
- 测试:采购服务 2 项离线单元测试;采购工具 6 项离线单元测试(不连接真机)
|
||||
- 数据:**无**
|
||||
- 标准启动路径:`./init.ps1`(Windows)/ `./init.sh`。两端骨架现已存在,但 `init.ps1` 仍使用
|
||||
默认 Python 3.10,不能正确验证要求 Python 3.11+ 的采购工具;T-003 负责统一入口与解释器选择。
|
||||
- 标准验证路径:`admin/` 下运行 `go test ./...`、`go vet ./...`、`go build ./...`;`client/` 下使用
|
||||
`py -3.12 -m unittest discover -s tests -t .`、`py -3.12 -m compileall -q src tests scripts`;仓库根运行
|
||||
`python scripts/validate_agent_context.py`
|
||||
- 当前 blocker:无外部 blocker。T-002 已完成;T-004 核心数据模型正在进行;T-003 统一入口与
|
||||
T-101 真机环境盘点已就绪。当前不宣称统一入口或桌面 GUI 已验收。
|
||||
- 生产代码:`admin/` 已有最小 Go 服务、健康检查、核心领域模型、SQLite 迁移与任务状态机;
|
||||
`client/` 已有 Python 包、PySide6 最小入口、运行目录与日志脱敏策略,以及显式 serial 的 ADB
|
||||
连接边界、本地基线取证 CLI 和受限商品链接打开取证 CLI;尚无规格选择、价格读取或下单流程
|
||||
- 测试:采购服务已覆盖健康检查、核心模型、迁移与状态机等离线包级测试;采购工具 46 项离线单元测试
|
||||
(全部 mock,不连接真机)
|
||||
- 数据:SQLite 核心表与迁移已落成;无业务实例数据
|
||||
- 标准启动路径:Windows PowerShell 运行 `./init.ps1`,Unix shell 运行 `./init.sh`。Windows 入口
|
||||
优先使用合规的既有 venv;仅在其缺失时才从 Python Launcher 已安装版本中选择最高的 Python 3.11+,
|
||||
并且不覆盖低版本环境;成功后打印真实启动命令。
|
||||
- 标准验证路径:`./init.ps1` 已实际跑通 admin 的 mod download / test / vet / build、client 的
|
||||
editable install / 包导入 / unittest / compileall,以及仓库上下文校验。可单独运行两端命令诊断。
|
||||
- 当前 blocker:无外部 blocker。T-102 已证明 canonical 链接可在拼多多 8.17.0 打开到目标商品并完成
|
||||
人工隐私验收;下一步落成 T-103 的规格面板证据与试选验证。T-103 通过前 Phase 2 仍不能抢跑。
|
||||
|
||||
## 当前目录要点
|
||||
|
||||
| 路径 | 状态 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `docs/` | 已有 | 项目规范化文档,本次已完整生成 |
|
||||
| `docs/tasks/` | 已有(T-001、T-002、T-004~T-009) | T-001、T-002 已完成;T-004 正在进行;其余既有任务已完成 |
|
||||
| `docs/tasks/` | 已有(T-001~T-004、T-005~T-009、T-101~T-102) | T-001~T-004、T-101~T-102 已完成;下一任务为 T-103 |
|
||||
| `docs/design/` | 已有(6 个原型) | web 登录 / 建单 / 工作台 / 详情,desk 采购执行 / 配置;均已人工确认 |
|
||||
| `scripts/` | 已有 | 上下文门禁、Vikunja 单向导出与 MCP 启动包装 |
|
||||
| `admin/` | 已初始化 | Go 1.23+ / gin / SQLite 骨架与健康检查;无采购业务路由 |
|
||||
| `client/` | 已初始化 | Python 3.11+ 包、依赖源、PySide6 最小入口、离线测试与 wheel 元数据检查;无真机或采购流程 |
|
||||
| `init.ps1` / `init.sh` | 已有(骨架) | 统一入口。两端目录建好后由 T-003 补全并验证 |
|
||||
| `admin/` | 已初始化 | Go 1.23+ / gin / SQLite,含核心模型、迁移与状态机;无真机采购执行 |
|
||||
| `client/` | 已初始化 | Python 3.11+ 包、依赖源、PySide6 最小入口、显式 serial 的基线/商品打开取证、离线测试与 wheel 元数据检查;无规格选择、价格读取或下单流程 |
|
||||
| `init.ps1` / `init.sh` | 已完成 | 统一安装与离线验证入口;PowerShell 优先复用合规 venv,缺失时自动选择最高的 Python 3.11+,Unix 缺工具链明确失败 |
|
||||
|
||||
## 任务状态
|
||||
|
||||
@@ -48,59 +50,88 @@
|
||||
- 已完成:T-005(采购服务交互原型)、T-006(采购工具交互原型)、T-007(产品名称与
|
||||
源码目录契约)、T-008(Vikunja 任务权威与单向导出)、T-009(MVP 关键路径与并行波次),
|
||||
以及 T-001(采购服务 Go 骨架)。
|
||||
- 已完成:T-002(采购工具 Python 骨架)。正在进行:T-004(核心数据模型)。
|
||||
T-003(统一入口)与 T-101(真机环境盘点)已就绪,立即推进 T-101 → T-102 → T-103。
|
||||
- T-103 是当前最高优先级和 MVP 生死线。通过前不开发依赖真机可读字段的 Phase 2 生产页面。
|
||||
- 已完成:T-002(采购工具 Python 骨架)、T-003(双端统一初始化与验证入口)、
|
||||
T-004(核心数据模型)、T-101(真机环境盘点与 USB/WiFi 双通道人工验收)、T-102(canonical
|
||||
链接打开与目标商品/隐私人工验收)。下一步推进 T-103。
|
||||
- T-103 是当前最高优先级和 MVP 生死线。通过前不开发依赖真机可读字段的
|
||||
Phase 2 生产页面。
|
||||
- 已确认原型继续只作信息架构依据;原型假数据不调用真实接口、不驱动真机。真机结论改变
|
||||
可读字段时必须先回修原型与交互清单。
|
||||
|
||||
## 当前可运行内容
|
||||
|
||||
采购服务当前可运行:
|
||||
Windows 统一安装、离线验证与启动命令提示已可运行:
|
||||
|
||||
```bash
|
||||
cd admin
|
||||
go mod download
|
||||
go run ./cmd/server
|
||||
```powershell
|
||||
.\init.ps1
|
||||
```
|
||||
|
||||
采购服务验证与仓库级上下文门禁:
|
||||
它会在成功后输出以下真实启动命令,而不自动启动或连接设备:
|
||||
|
||||
```bash
|
||||
cd admin
|
||||
go test ./...
|
||||
go vet ./...
|
||||
go build ./...
|
||||
|
||||
cd ..
|
||||
python scripts/validate_agent_context.py
|
||||
```text
|
||||
采购服务:cd admin; go run ./cmd/server
|
||||
采购工具:cd client; .\.venv\Scripts\python.exe -m cmbuyer_client
|
||||
```
|
||||
|
||||
采购工具当前可运行的离线验证与 wheel 元数据检查:
|
||||
|
||||
```powershell
|
||||
cd client
|
||||
py -3.12 -m unittest discover -s tests -t .
|
||||
py -3.12 -m compileall -q src tests scripts
|
||||
.\.venv\Scripts\python.exe -m unittest discover -s tests -t .
|
||||
.\.venv\Scripts\python.exe -m compileall -q src tests scripts
|
||||
$wheelDir = Join-Path $env:TEMP ('cmbuyer-client-wheel-' + [guid]::NewGuid())
|
||||
New-Item -ItemType Directory -Path $wheelDir | Out-Null
|
||||
py -3.12 -m pip wheel --no-deps . --wheel-dir $wheelDir
|
||||
py -3.12 scripts/verify_wheel_metadata.py (Get-ChildItem $wheelDir -Filter '*.whl').FullName
|
||||
.\.venv\Scripts\python.exe -m pip wheel --no-deps . --wheel-dir $wheelDir
|
||||
.\.venv\Scripts\python.exe scripts/verify_wheel_metadata.py (Get-ChildItem $wheelDir -Filter '*.whl').FullName
|
||||
```
|
||||
|
||||
采购工具规范安装与本地开发入口:
|
||||
|
||||
```powershell
|
||||
# 在仓库根执行统一初始化;成功后再进入 client/
|
||||
.\init.ps1
|
||||
cd client
|
||||
py -3.12 -m venv .venv
|
||||
.\.venv\Scripts\python.exe -m pip install -e .
|
||||
.\.venv\Scripts\python.exe -m cmbuyer_client
|
||||
```
|
||||
|
||||
`client/requirements.txt` 是唯一依赖来源,`client/pyproject.toml` 动态读取它生成 wheel 的
|
||||
`Requires-Dist`。Python 3.12 已验证 6 项离线测试、编译与 wheel 元数据;完整运行时依赖安装
|
||||
(`pip install -e .`)已通过。`init.ps1` 当前仍选择 Python 3.10,因此不能声称统一入口已正确;
|
||||
桌面 GUI 与真机流程未作为 T-002 验收执行。
|
||||
`Requires-Dist`。Python 3.12 与 3.14 均已验证离线测试、编译与 wheel 元数据;完整运行时依赖安装
|
||||
(`pip install -e .`)已通过。`init.ps1` 优先使用合规既有 venv,缺失时自动选择最高的 Python 3.11+;
|
||||
本机 Python 3.12 与 3.14 均已验证,主工作区当前选择 Python 3.14。桌面 GUI 与真机流程未作为 T-003
|
||||
验收执行。
|
||||
|
||||
T-101 的人工真机验收命令(先把手机切到不含收货地址、手机号、支付信息或其他无关隐私的安全页面;
|
||||
必须从 `adb devices -l` 手工复制在线 serial,不能留空或自动选择):
|
||||
|
||||
```powershell
|
||||
# 仓库根目录;USB 和 WiFi 分开执行,每次只保留一个通道在线
|
||||
D:\Portable\adb\adb.exe devices -l
|
||||
.\client\.venv\Scripts\python.exe client\scripts\capture_device_baseline.py --serial <USB_SERIAL> --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-101\usb-baseline" --timeout 10 --adb D:\Portable\adb\adb.exe
|
||||
# WiFi 必须由人先建立通道、断开 USB 后再手工粘贴在线 WiFi serial;脚本不自动 connect/reconnect
|
||||
.\client\.venv\Scripts\python.exe client\scripts\capture_device_baseline.py --serial <WIFI_SERIAL> --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-101\wifi-baseline" --timeout 10 --adb D:\Portable\adb\adb.exe
|
||||
```
|
||||
|
||||
脚本只读取非敏感设备元数据、拼多多版本、截图和完整 XML;不会打开商品或写页面判据。它对同机双
|
||||
通道、身份读取失败、offline、unauthorized、超时或 serial 不存在均 fail closed。成功目录的
|
||||
`manifest.json` 只记录元数据、产物路径和 SHA-256,不记录 XML 页面正文或原始 serial。T-101 已由人
|
||||
完成两次验收并确认原始证据不含敏感信息;`--timeout` 约束 ADB 命令、ADB socket 以及截图/节点树的
|
||||
公开 JSON-RPC 调用,uiautomator2 初始化仍有上游固定启动上限。截图/XML 只保留在本地,执行记录只写
|
||||
路径和 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 隐私,并完成验收。
|
||||
|
||||
## 关键背景
|
||||
|
||||
|
||||
+7
-3
@@ -3,7 +3,7 @@ id: T-003
|
||||
title: 建立双端统一初始化与验证入口
|
||||
phase: 0
|
||||
deps: [T-001, T-002]
|
||||
status: DOING
|
||||
status: DONE
|
||||
created: 2026-08-03
|
||||
vikunja_task_id: 20
|
||||
context_ref: bcec012
|
||||
@@ -19,7 +19,7 @@ write_paths:
|
||||
- docs/current-state.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=20 synced=2026-08-03T10:35:06Z sha256=24a880be86ebfda9b3176ed3fdbfae289c750427baa67eaa5207e4eb69a1841e -->
|
||||
<!-- BEGIN VIKUNJA EXPORT id=20 synced=2026-08-03T10:55:26Z sha256=ded66098a74140a3738f1afd64299eb429325a72fdd018aeb1dabbedc89cca56 -->
|
||||
## 问题 / 背景
|
||||
|
||||
T-001 与 T-002 已建立两端可运行骨架,但仓库根 init.ps1 / init.sh 仍沿用占位命令:Windows 默认 Python 3.10 不满足 client 的 Python 3.11+ 下限,桌面端仍按 requirements.txt 安装而不是规范的 editable package,启动命令也指向不存在的 src/main.py。统一入口若不能主动验证工具链和两端基线,后续任务会在错误环境上叠加实现。
|
||||
@@ -48,7 +48,11 @@ T-001 与 T-002 已建立两端可运行骨架,但仓库根 init.ps1 / init.sh
|
||||
|
||||
## 执行记录
|
||||
|
||||
(暂无)
|
||||
### 2026-08-03T10:54:57Z · ila
|
||||
|
||||
主 agent 终审通过:init.ps1/init.sh 已成为双端统一安装与离线门禁入口;不启动产品、不连接设备、不触发采购、下单或付款动作。
|
||||
Windows 入口优先复用并验证现有 Python 3.11+ venv,仅缺失时从 Launcher 合规版本确定性创建;安装、包导入、unittest、compileall、validator 全部使用同一 venv Python。当前实测 venv 为 Python 3.12。
|
||||
独立验证:在已合入 T-004 的最新组合基线上连续两次运行 init.ps1 成功;admin test/vet/build、client 6 tests/compileall、validator 通过。Python 3.10 venv fixture 与仅 3.10/3.9 Launcher fixture 均 fail closed;bash -n 通过,WSL 缺 Go 时明确非零退出。
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
---
|
||||
id: T-101
|
||||
title: 验证 ADB 与 uiautomator2 双通道连接
|
||||
phase: 1
|
||||
deps: [T-002]
|
||||
status: DONE
|
||||
created: 2026-08-03
|
||||
vikunja_task_id: 21
|
||||
context_ref: 87591e8
|
||||
work_branch: task/t-101-device-connectivity
|
||||
needs_device: true
|
||||
needs_human_review: true
|
||||
write_paths:
|
||||
- docs/tasks/T-101.md
|
||||
- client/.gitignore
|
||||
- client/requirements.txt
|
||||
- client/src/cmbuyer_client/device/**
|
||||
- client/tests/device/**
|
||||
- client/scripts/capture_device_baseline.py
|
||||
- docs/evidence/T-101/**
|
||||
- docs/04-architecture.md
|
||||
- docs/03-tech-stack.md
|
||||
- docs/current-state.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=21 synced=2026-08-04T01:02:36Z sha256=b6458effd879f2e258c861b1b7b99d1162fdbea586e3fa467d3a65178c900453 -->
|
||||
## 问题 / 背景
|
||||
|
||||
T-002 已建立采购工具骨架,但本项目还没有对实际 Android 手机、ADB 通道或 uiautomator2 做过取证。后续所有拼多多页面判据都依赖稳定、显式且可审计的设备连接;若复用前序项目结论或自动猜设备,会把错误设备和旧页面事实带入生产流程。
|
||||
|
||||
## 关联需求与交互
|
||||
|
||||
- 功能:Phase 1 真机环境盘点,是 T-102/T-103 的前置。
|
||||
- 用户故事 / 交互:采购工具配置页的显式设备 serial、连接检查与错误反馈。
|
||||
- 架构 / API:只验证 ADB/uiautomator2 基础能力,不编写拼多多页面判据,不调用采购服务业务接口。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 实现显式 serial 的设备连接边界与可替换命令执行器;禁止留空自动选择,离线测试覆盖无设备、多设备、unauthorized/offline 和同一手机 USB+WiFi 双通道冲突。
|
||||
2. 提供只读取证命令:对指定通道连接 uiautomator2,获取设备型号/Android 版本/拼多多 App 版本,执行截图与 dump_hierarchy(compressed=False),所有超时可配置。
|
||||
3. 产物按稳定目录保存并记录 SHA-256;日志只记录设备标识与元数据,不记录 token、地址、手机号或页面全文。
|
||||
4. 先完成 mock/fixture 离线测试;随后由人分别用 USB 与 WiFi 真机执行并把设备、Android、拼多多版本、截图/XML 路径和结论写入任务执行记录。
|
||||
5. 同一手机两个通道同时在线必须明确拒绝,不能随机选一个继续。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- USB 和 WiFi 均能显式指定并完成截图、compressed=False XML dump。
|
||||
- 设备未授权、离线、超时、serial 不存在和双通道冲突有可区分错误。
|
||||
- 取证记录含设备型号、Android 版本、拼多多 App 版本、通道、时间、截图/XML 路径及 SHA-256。
|
||||
- 离线单测与 compileall 通过,不要求真机。
|
||||
- needs_device=true:agent 完成代码后保持 DOING,只有人完成两通道真机验收后才能 DONE。
|
||||
|
||||
## 执行记录
|
||||
|
||||
### 2026-08-03T10:56:19Z · ila
|
||||
|
||||
T-101 已领取,Git 状态将提交为 DOING。只读环境盘点:adb 位于 D:\Portable\adb\adb.exe;当前同时列出 WiFi serial 192.168.0.173:5555 与 USB serial 3B65BD02H7F00000,二者 product/model/device 均为 PKG110/PKG110/OP5D2BL1。该现场必须由实现识别为同机双通道并 fail closed。尚未截图、dump 或操作手机,本记录不是人工真机验收;needs_device 规则继续生效。
|
||||
|
||||
### 2026-08-03T11:27:08Z · ila
|
||||
|
||||
2026-08-03 离线实现与主审完成:新增显式 serial 的 ADB 边界、USB/WiFi 同机多通道 fail-closed、禁止 uiautomator2 隐式重连的连接器,以及只读基线 CLI(型号、Android、拼多多版本、截图、compressed=false XML、manifest SHA-256)。主 agent 三轮审查后补齐:截图/XML JSON-RPC 可配置超时、adbutils/uiautomator2 类型化超时、完整硬件身份集合比对、非法截图/XML fail-closed、异常脱敏、暂存清理,以及人工安全页面/原始证据不入 Git 的操作要求。独立验证:init.ps1 通过;admin go test/vet/build 通过;client 27 项 mock 单测、compileall、wheel METADATA、validator、bash -n 与 diff check 通过。未连接或操作真机,T-101 继续保持 DOING;等待人分别完成 USB 与 WiFi 取证并记录 manifest 中的设备型号、Android、拼多多版本、路径与 SHA-256。已知边界:ADB/ADB socket/截图与节点树 RPC 超时可配置,uiautomator2 初始化仍受上游固定启动上限约束。
|
||||
|
||||
### 2026-08-04T00:36:03Z · ila
|
||||
|
||||
2026-08-04 人工进展:用户确认 WiFi ADB 已连接,serial 为 192.168.0.173:5555。主 agent 随后仅执行 `adb devices -l` 复核,WiFi 与 USB 3B65BD02H7F00000 当前均为 device,product/model/device 均显示 PKG110/PKG110/OP5D2BL1;本机尚无 `%LOCALAPPDATA%\cmbuyer\artifacts\T-101` 取证产物。该结果只证明 WiFi 通道在线,不是 T-101 完整人工验收。按 fail-closed 边界,人工进行 WiFi 取证前须先断开 USB,只保留 WiFi;USB 取证时须断开 WiFi,只保留 USB。仍等待两次截图、compressed=false XML 与 manifest 路径/SHA-256,任务保持 DOING。
|
||||
|
||||
### 2026-08-04T00:43:38Z · ila
|
||||
|
||||
2026-08-04 人工 WiFi 取证失败:用户在仅 WiFi serial 192.168.0.173:5555 在线时运行基线 CLI,工具返回“截图 RPC 返回的数据不是有效图像,拒绝发布不完整取证”。主 agent 只读复核:ADB 仅列出该 WiFi 通道为 device,T-101 产物目录为空,说明暂存清理与 fail-closed 生效。核对本机 uiautomator2 3.7.0 源码后发现其高层 screenshot 使用允许 Base64 空白的 `base64.b64decode`,而当前实现直接 `validate=True`,可能错误拒绝真实 RPC 返回中的 CR/LF/空格。已退回 client agent 修复为“只规范化 ASCII Base64 空白,再严格 validate=True;其他非法字符与非图像继续拒绝”,并补回归测试。该失败不是验收通过,T-101 继续 DOING;修复、主审、提交后由人重新执行 WiFi 取证。
|
||||
|
||||
### 2026-08-04T00:49:31Z · ila
|
||||
|
||||
2026-08-04 WiFi 截图兼容性修复已完成主审:实现只删除 RPC Base64 返回值中的空格、TAB、CR、LF,再使用 `validate=True` 严格解码;非法字符、错误 padding、vertical-tab、form-feed、data URI 与解码后非图像仍 fail closed,不增加 adb screenshot fallback。错误安全地区分为“Base64 语法无效”和“图像数据无效”,均不输出原始内容,失败继续清理暂存目录。新增真实 PNG Base64 空白兼容、非法字符、错误 padding、未白名单空白及非图像测试;client 共 30 项离线测试。主 agent 独立复核并运行 client 测试/compileall、admin test/vet/build、完整 init.ps1、validator、bash 语法与 diff check,均通过;Python 3.12 和主工作区 3.14 均已验证。未由 agent 连接或截图真机,T-101 保持 DOING,等待人工重新执行 WiFi 取证确认现场问题是否解决。
|
||||
|
||||
### 2026-08-04T00:55:14Z · ila
|
||||
|
||||
2026-08-04 WiFi 人工取证技术验收通过:用户报告基线命令成功,manifest 位于 `%LOCALAPPDATA%\cmbuyer\artifacts\T-101\wifi-baseline\manifest.json`。主 agent 仅读取 manifest 元数据并计算文件哈希,未打开或展示截图/XML 内容。manifest schema=1、channel=wifi、设备型号 PKG110、Android 16、拼多多包 com.xunmeng.pinduoduo、版本 8.17.0;serial 仅保存 64 位 SHA-256。`screenshot.png` 存在,SHA-256=`8f86c94700d93380805b275469123a31749d592d99c0c4d2bcfd540784d1620b`;`hierarchy.xml` 存在,SHA-256=`708182200e25085c07cbb546c9b0adbc7310022bb75a7285c5581bd262661573`;两者均与 manifest 一致。仍等待人工确认原始截图/XML 不含敏感信息,并完成仅 USB 通道的同等取证;T-101 保持 DOING。
|
||||
|
||||
### 2026-08-04T01:02:03Z · ila
|
||||
|
||||
2026-08-04 USB 人工取证与隐私验收通过:用户报告仅 USB 通道基线命令成功,manifest 位于 `%LOCALAPPDATA%\cmbuyer\artifacts\T-101\usb-baseline\manifest.json`。主 agent 仅读取 manifest 元数据并计算哈希,未打开或展示截图/XML:schema=1、channel=usb、设备 PKG110、Android 16、拼多多 8.17.0;`screenshot.png` SHA-256=`da66e27ef6df1d4b99a64590e9aa383069c8c3995eb9b3695e5daec3a0e7bcf6`,`hierarchy.xml` SHA-256=`e0817f26c07e6861f2ab80052995aff2622ce7cdb61f18689d55c5591bb2045a`,均与 manifest 一致。用户随后明确确认 USB 与 WiFi 两组截图/XML 均不含地址、手机号、支付信息或其他无关隐私。至此 USB/WiFi 显式 serial、截图、compressed=false XML、设备/Android/拼多多版本、路径和 SHA-256 的人工验收全部满足;T-101 可关闭,后续进入 T-102。
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
- 本任务只建立并验证设备连接、元数据读取、截图和 `dump_hierarchy(compressed=False)`;不打开
|
||||
拼多多商品、不编写任何页面判据,不选择规格,不进入购买或订单页面。
|
||||
- 设备 serial 必须显式提供;无设备、多设备、未授权、离线或同一手机 USB + WiFi 双通道并存时
|
||||
必须明确失败,不得随机选择或静默降级。
|
||||
- 不使用 `/mnt/d/chengma/cmroubao`、`/mnt/d/chengma/cmpdd` 的设备常量、页面 XML 或结论作为
|
||||
本项目事实;所有真机结论都必须在本项目重新取证。
|
||||
- 产物与执行记录不得包含 token、地址、手机号、支付信息或无关页面全文;页面 XML 原始文件只
|
||||
保存于明确的 T-101 取证目录,日志只记录路径、哈希与非敏感元数据。
|
||||
- 不修改 `admin/`、业务 API、数据模型或产品 UI,不实现任务领取、采购执行、下单、提交订单、
|
||||
付款或支付逻辑。
|
||||
- `needs_device: true`:agent 完成代码和离线门禁后仍必须保持 `DOING`;只有人完成 USB、WiFi
|
||||
两通道真机验收并记录设备型号、Android 版本、拼多多 App 版本、截图/XML 路径后才能标 `DONE`。
|
||||
@@ -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`。
|
||||
@@ -1,84 +1,149 @@
|
||||
#!/usr/bin/env pwsh
|
||||
|
||||
# cmbuyer 标准启动与验证入口(Windows PowerShell 版),与 init.sh 等价,二选一:
|
||||
# - Windows 原生 PowerShell:./init.ps1
|
||||
# - WSL / Git Bash:./init.sh
|
||||
#
|
||||
# 本项目是双产品结构:admin/(采购服务,Go)+ client/(采购工具,Python)。脚本按
|
||||
# admin/go.mod 与 client/requirements.txt 两个初始化哨兵分别处理;未初始化的一端会跳过并
|
||||
# 提示对应任务,不会把空目录误判为可运行项目,也不会静默通过。
|
||||
#
|
||||
# T-001 / T-002 / T-003 落地后,把下面的命令补全,并同步:
|
||||
# docs/03-tech-stack.md、docs/00-ai-start-here.md、docs/current-state.md
|
||||
# cmbuyer Windows 统一安装、验证与启动入口。
|
||||
# 这个入口只处理开发环境和离线门禁,不连接设备,也不触发采购、下单或付款动作。
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-Location -Path $PSScriptRoot
|
||||
|
||||
# Go 工具链固定用本机版本,避免自动下载
|
||||
# Go 工具链固定用本机版本,避免自动下载造成不可复现的环境漂移。
|
||||
$env:GOTOOLCHAIN = "local"
|
||||
|
||||
$AdminDir = Join-Path $PSScriptRoot "admin"
|
||||
$AdminDir = Join-Path $PSScriptRoot "admin"
|
||||
$ClientDir = Join-Path $PSScriptRoot "client"
|
||||
$Pending = @()
|
||||
$ClientVenv = Join-Path $ClientDir ".venv"
|
||||
$ClientPython = Join-Path $ClientVenv "Scripts\python.exe"
|
||||
|
||||
function Fail([string]$Message) {
|
||||
throw $Message
|
||||
}
|
||||
|
||||
function Require-Directory([string]$Path, [string]$Name) {
|
||||
if (-not (Test-Path -LiteralPath $Path -PathType Container)) {
|
||||
Fail "$Name 目录不存在:$Path。请先完成对应初始化任务。"
|
||||
}
|
||||
}
|
||||
|
||||
function Require-File([string]$Path, [string]$Name) {
|
||||
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
|
||||
Fail "$Name 不存在:$Path。请先完成对应初始化任务。"
|
||||
}
|
||||
}
|
||||
|
||||
function Require-Command([string]$Name, [string]$Hint) {
|
||||
if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
|
||||
Fail "缺少 $Name。$Hint"
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-Step([string]$Description, [scriptblock]$Action) {
|
||||
Write-Host "==> $Description"
|
||||
& $Action
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Fail "$Description 失败,退出码:$LASTEXITCODE"
|
||||
}
|
||||
}
|
||||
|
||||
function Confirm-Python311OrNewer([string]$PythonExecutable, [string]$Context) {
|
||||
& $PythonExecutable -c "import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)"
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Fail "$Context 必须使用 Python 3.11+。不会自动覆盖既有虚拟环境;请手工删除 client/.venv 后重试。"
|
||||
}
|
||||
}
|
||||
|
||||
function Select-PythonLauncherSelector() {
|
||||
$launcherLines = @(& py -0p)
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Fail "无法从 Python Launcher 枚举已安装版本。请安装 Python 3.11+ 并确认 py -0p 可运行。"
|
||||
}
|
||||
|
||||
$minimumVersion = [version]::new(3, 11)
|
||||
$candidates = @()
|
||||
foreach ($line in $launcherLines) {
|
||||
$selectorMatch = [regex]::Match([string]$line, '^\s*-V:(?<selector>\S+)(?:\s+\*)?\s+(?<path>.+?)\s*$')
|
||||
if (-not $selectorMatch.Success) {
|
||||
continue
|
||||
}
|
||||
|
||||
$selector = $selectorMatch.Groups['selector'].Value
|
||||
$pythonPath = $selectorMatch.Groups['path'].Value
|
||||
$versionMatch = [regex]::Match($selector, '(?<major>\d+)\.(?<minor>\d+)(?:\.(?<patch>\d+))?')
|
||||
if (-not $versionMatch.Success) {
|
||||
continue
|
||||
}
|
||||
|
||||
$patch = if ($versionMatch.Groups['patch'].Success) { [int]$versionMatch.Groups['patch'].Value } else { 0 }
|
||||
$version = [version]::new(
|
||||
[int]$versionMatch.Groups['major'].Value,
|
||||
[int]$versionMatch.Groups['minor'].Value,
|
||||
$patch
|
||||
)
|
||||
if ($version -ge $minimumVersion) {
|
||||
$candidates += [PSCustomObject]@{ Selector = $selector; Version = $version; Path = $pythonPath }
|
||||
}
|
||||
}
|
||||
|
||||
if ($candidates.Count -eq 0) {
|
||||
Fail "Python Launcher 未发现 Python 3.11+。当前默认 python 不会作为回退;请安装合规版本后重试。"
|
||||
}
|
||||
|
||||
$selected = $candidates | Sort-Object @{ Expression = 'Version'; Descending = $true }, @{ Expression = 'Selector'; Descending = $false } | Select-Object -First 1
|
||||
Write-Host "==> 选择 Python Launcher 解释器:-V:$($selected.Selector)($($selected.Version))"
|
||||
return $selected
|
||||
}
|
||||
|
||||
Write-Host "==> 当前目录: $($PWD.Path)"
|
||||
|
||||
# ------------------------------------------------------- 采购服务(admin/,Go)
|
||||
if (Test-Path (Join-Path $AdminDir "go.mod") -PathType Leaf) {
|
||||
Write-Host "==> 采购服务:admin/ 同步依赖"
|
||||
Push-Location $AdminDir
|
||||
try {
|
||||
go mod download
|
||||
Write-Host "==> 采购服务:admin/ 静态检查"
|
||||
go vet ./...
|
||||
Write-Host "==> 采购服务:admin/ 测试"
|
||||
go test ./...
|
||||
} finally {
|
||||
Pop-Location
|
||||
Require-Directory $AdminDir "采购服务 admin"
|
||||
Require-File (Join-Path $AdminDir "go.mod") "采购服务哨兵 admin/go.mod"
|
||||
Require-Directory $ClientDir "采购工具 client"
|
||||
Require-File (Join-Path $ClientDir "requirements.txt") "采购工具哨兵 client/requirements.txt"
|
||||
Require-File (Join-Path $ClientDir "pyproject.toml") "采购工具打包配置 client/pyproject.toml"
|
||||
Require-Command "go" "请安装 Go 1.23+ 并重新打开 PowerShell。"
|
||||
if (Test-Path -LiteralPath $ClientVenv) {
|
||||
if (-not (Test-Path -LiteralPath $ClientVenv -PathType Container)) {
|
||||
Fail "client/.venv 不是目录。不会覆盖该路径,请人工处理后重试。"
|
||||
}
|
||||
Require-File $ClientPython "既有 client/.venv Python"
|
||||
Confirm-Python311OrNewer $ClientPython "既有 client/.venv"
|
||||
} else {
|
||||
Write-Host "==> 采购服务:admin/go.mod 不存在,跳过。先做 T-001(初始化 admin/ Go 骨架)。"
|
||||
$Pending += "T-001 初始化采购服务 admin/ Go 骨架"
|
||||
# 默认 python 在本机可能仍指向 3.10;仅在需要新建 venv 时,从 Launcher 中确定性选择最高的合规版本。
|
||||
Require-Command "py" "请安装 Python Launcher 和 Python 3.11+。"
|
||||
$PythonRuntime = Select-PythonLauncherSelector
|
||||
Require-File $PythonRuntime.Path "Python Launcher 选择的解释器"
|
||||
Invoke-Step "验证所选 Python Launcher 解释器" { & $PythonRuntime.Path -c "import sys; print(sys.version); raise SystemExit(0 if sys.version_info >= (3, 11) else 1)" }
|
||||
Invoke-Step "创建所选 Python 3.11+ 采购工具虚拟环境" { & $PythonRuntime.Path -m venv $ClientVenv }
|
||||
Require-File $ClientPython "新建 client/.venv Python"
|
||||
Confirm-Python311OrNewer $ClientPython "新建 client/.venv"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------- 采购工具(client/,Python)
|
||||
if (Test-Path (Join-Path $ClientDir "requirements.txt") -PathType Leaf) {
|
||||
Write-Host "==> 采购工具:client/ 同步依赖"
|
||||
Push-Location $ClientDir
|
||||
try {
|
||||
if (-not (Test-Path ".venv")) {
|
||||
python -m venv .venv
|
||||
}
|
||||
& ".venv\Scripts\python.exe" -m pip install -q -r requirements.txt
|
||||
Write-Host "==> 采购工具:client/ 语法检查"
|
||||
& ".venv\Scripts\python.exe" -m compileall -q src tests
|
||||
Write-Host "==> 采购工具:client/ 测试"
|
||||
& ".venv\Scripts\python.exe" -m unittest discover -s tests -t .
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
} else {
|
||||
Write-Host "==> 采购工具:client/requirements.txt 不存在,跳过。先做 T-002(初始化 client/ Python 骨架)。"
|
||||
$Pending += "T-002 初始化采购工具 client/ Python 骨架"
|
||||
# 后续所有采购工具检查与仓库 validator 均使用同一个合规 venv 解释器,避免混用宿主 Python。
|
||||
Invoke-Step "验证采购工具虚拟环境 Python" { & $ClientPython -c "import sys; print(sys.version); raise SystemExit(0 if sys.version_info >= (3, 11) else 1)" }
|
||||
|
||||
Push-Location $AdminDir
|
||||
try {
|
||||
Invoke-Step "采购服务:admin/ 同步依赖" { go mod download }
|
||||
Invoke-Step "采购服务:admin/ 测试" { go test ./... }
|
||||
Invoke-Step "采购服务:admin/ 静态检查" { go vet ./... }
|
||||
Invoke-Step "采购服务:admin/ 构建" { go build ./... }
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ 文档自检
|
||||
Write-Host "==> 校验 agent 上下文清单"
|
||||
python scripts/validate_agent_context.py
|
||||
Push-Location $ClientDir
|
||||
try {
|
||||
Invoke-Step "采购工具:以 editable 方式安装" { & $ClientPython -m pip install -e . }
|
||||
Invoke-Step "采购工具:验证已安装包" { & $ClientPython -c "import cmbuyer_client; print(cmbuyer_client.__version__)" }
|
||||
Invoke-Step "采购工具:测试" { & $ClientPython -m unittest discover -s tests -t . }
|
||||
Invoke-Step "采购工具:语法检查" { & $ClientPython -m compileall -q src tests scripts }
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
Invoke-Step "校验 agent 上下文清单" { & $ClientPython scripts/validate_agent_context.py }
|
||||
|
||||
# -------------------------------------------------------------------- 汇总
|
||||
Write-Host ""
|
||||
if ($Pending.Count -gt 0) {
|
||||
Write-Host "==> 尚未初始化的部分:"
|
||||
foreach ($item in $Pending) { Write-Host " - $item" }
|
||||
Write-Host ""
|
||||
Write-Host "当前仓库只有文档。按 docs/06-tasks.md 领取上述任务后再运行本脚本。"
|
||||
exit 3
|
||||
}
|
||||
|
||||
Write-Host "==> 启动命令"
|
||||
Write-Host " 采购服务:cd admin ; go run ./cmd/server"
|
||||
Write-Host " 采购工具:cd client ; .venv\Scripts\python.exe src/main.py"
|
||||
Write-Host "==> 基线验证通过。启动命令:"
|
||||
Write-Host " 采购服务:cd admin; go run ./cmd/server"
|
||||
Write-Host " 采购工具:cd client; .\.venv\Scripts\python.exe -m cmbuyer_client"
|
||||
Write-Host ""
|
||||
Write-Host "基础验证失败时先修基线,不要在坏的起点上继续叠新功能。"
|
||||
Write-Host "真机连接与设备验收只能由人工完成,agent 不得据此把任务标为 DONE。"
|
||||
Write-Host "真机连接与设备验收只能由人工完成;本入口不执行任何采购、下单或付款动作。"
|
||||
|
||||
@@ -1,69 +1,88 @@
|
||||
#!/usr/bin/env bash
|
||||
# cmbuyer 标准启动与验证入口(Unix shell 版),与 init.ps1 等价,二选一。
|
||||
#
|
||||
# 注意:本项目的构建与验证工具链只在 Windows 侧(Go SDK、Python 环境、Android SDK)。
|
||||
# 在 WSL 中运行本脚本通常会因缺少工具链而失败——这是预期行为,不是 bug。
|
||||
# 正式验证请在 Windows PowerShell 运行 ./init.ps1。
|
||||
# 双产品目录为 admin/(采购服务,Go)与 client/(采购工具,Python);分别以 go.mod 与
|
||||
# requirements.txt 作为初始化哨兵,不能只按空目录存在判断。
|
||||
#
|
||||
# T-001 / T-002 / T-003 落地后补全命令,并同步:
|
||||
# docs/03-tech-stack.md、docs/00-ai-start-here.md、docs/current-state.md
|
||||
# cmbuyer Unix shell 统一安装、验证与启动入口。
|
||||
# 本脚本与 init.ps1 保持同一门禁语义;在 WSL 缺少 Windows 侧工具链时必须明确失败,
|
||||
# 不得将跳过或 bash 语法通过误报为 Windows 产品验收。
|
||||
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
export GOTOOLCHAIN=local
|
||||
|
||||
pending=()
|
||||
fail() {
|
||||
echo "错误:$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
require_directory() {
|
||||
local path="$1"
|
||||
local name="$2"
|
||||
[[ -d "$path" ]] || fail "$name 目录不存在:$path。请先完成对应初始化任务。"
|
||||
}
|
||||
|
||||
require_file() {
|
||||
local path="$1"
|
||||
local name="$2"
|
||||
[[ -f "$path" ]] || fail "$name 不存在:$path。请先完成对应初始化任务。"
|
||||
}
|
||||
|
||||
require_command() {
|
||||
local name="$1"
|
||||
local hint="$2"
|
||||
command -v "$name" >/dev/null 2>&1 || fail "缺少 $name。$hint"
|
||||
}
|
||||
|
||||
run_step() {
|
||||
local description="$1"
|
||||
shift
|
||||
echo "==> $description"
|
||||
"$@" || fail "$description 失败。"
|
||||
}
|
||||
|
||||
confirm_python311_or_newer() {
|
||||
local executable="$1"
|
||||
local context="$2"
|
||||
"$executable" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)' \
|
||||
|| fail "$context 必须使用 Python 3.11+。不会自动覆盖既有虚拟环境;请手工删除 client/.venv 后重试。"
|
||||
}
|
||||
|
||||
echo "==> 当前目录: $(pwd)"
|
||||
require_directory admin "采购服务 admin"
|
||||
require_file admin/go.mod "采购服务哨兵 admin/go.mod"
|
||||
require_directory client "采购工具 client"
|
||||
require_file client/requirements.txt "采购工具哨兵 client/requirements.txt"
|
||||
require_file client/pyproject.toml "采购工具打包配置 client/pyproject.toml"
|
||||
require_command go "请安装 Go 1.23+ 后重试。"
|
||||
|
||||
# ------------------------------------------------------- 采购服务(admin/,Go)
|
||||
if [ -f admin/go.mod ]; then
|
||||
echo "==> 采购服务:admin/ 同步依赖"
|
||||
( cd admin && go mod download )
|
||||
echo "==> 采购服务:admin/ 静态检查"
|
||||
( cd admin && go vet ./... )
|
||||
echo "==> 采购服务:admin/ 测试"
|
||||
( cd admin && go test ./... )
|
||||
client_python="client/.venv/bin/python"
|
||||
if [[ -e client/.venv ]]; then
|
||||
[[ -d client/.venv ]] || fail "client/.venv 不是目录。不会覆盖该路径,请人工处理后重试。"
|
||||
require_file "$client_python" "既有 client/.venv Python"
|
||||
confirm_python311_or_newer "$client_python" "既有 client/.venv"
|
||||
else
|
||||
echo "==> 采购服务:admin/go.mod 不存在,跳过。先做 T-001(初始化 admin/ Go 骨架)。"
|
||||
pending+=("T-001 初始化采购服务 admin/ Go 骨架")
|
||||
require_command python3 "请安装 Python 3.11+ 后重试。"
|
||||
confirm_python311_or_newer python3 "python3"
|
||||
run_step "创建 Python 3.11+ 采购工具虚拟环境" python3 -m venv client/.venv
|
||||
require_file "$client_python" "新建 client/.venv Python"
|
||||
confirm_python311_or_newer "$client_python" "新建 client/.venv"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------- 采购工具(client/,Python)
|
||||
if [ -f client/requirements.txt ]; then
|
||||
echo "==> 采购工具:client/ 同步依赖"
|
||||
( cd client \
|
||||
&& { [ -d .venv ] || python3 -m venv .venv; } \
|
||||
&& .venv/bin/python -m pip install -q -r requirements.txt )
|
||||
echo "==> 采购工具:client/ 语法检查"
|
||||
( cd client && .venv/bin/python -m compileall -q src tests )
|
||||
echo "==> 采购工具:client/ 测试"
|
||||
( cd client && .venv/bin/python -m unittest discover -s tests -t . )
|
||||
else
|
||||
echo "==> 采购工具:client/requirements.txt 不存在,跳过。先做 T-002(初始化 client/ Python 骨架)。"
|
||||
pending+=("T-002 初始化采购工具 client/ Python 骨架")
|
||||
fi
|
||||
run_step "验证采购工具虚拟环境 Python" "$client_python" -c 'import sys; print(sys.version); raise SystemExit(0 if sys.version_info >= (3, 11) else 1)'
|
||||
|
||||
# ------------------------------------------------------------------ 文档自检
|
||||
echo "==> 校验 agent 上下文清单"
|
||||
python3 scripts/validate_agent_context.py
|
||||
( cd admin && run_step "采购服务:admin/ 同步依赖" go mod download )
|
||||
( cd admin && run_step "采购服务:admin/ 测试" go test ./... )
|
||||
( cd admin && run_step "采购服务:admin/ 静态检查" go vet ./... )
|
||||
( cd admin && run_step "采购服务:admin/ 构建" go build ./... )
|
||||
|
||||
( cd client && run_step "采购工具:以 editable 方式安装" .venv/bin/python -m pip install -e . )
|
||||
( cd client && run_step "采购工具:验证已安装包" .venv/bin/python -c 'import cmbuyer_client; print(cmbuyer_client.__version__)' )
|
||||
( cd client && run_step "采购工具:测试" .venv/bin/python -m unittest discover -s tests -t . )
|
||||
( cd client && run_step "采购工具:语法检查" .venv/bin/python -m compileall -q src tests scripts )
|
||||
|
||||
run_step "校验 agent 上下文清单" "$client_python" scripts/validate_agent_context.py
|
||||
|
||||
# -------------------------------------------------------------------- 汇总
|
||||
echo
|
||||
if [ ${#pending[@]} -gt 0 ]; then
|
||||
echo "==> 尚未初始化的部分:"
|
||||
for item in "${pending[@]}"; do echo " - $item"; done
|
||||
echo
|
||||
echo "当前仓库只有文档。按 docs/06-tasks.md 领取上述任务后再运行本脚本。"
|
||||
exit 3
|
||||
fi
|
||||
|
||||
echo "==> 启动命令"
|
||||
echo " 采购服务:cd admin && go run ./cmd/server"
|
||||
echo " 采购工具:cd client && .venv/bin/python src/main.py"
|
||||
echo "==> 基线验证通过。启动命令:"
|
||||
echo " 采购服务:cd admin && go run ./cmd/server"
|
||||
echo " 采购工具:cd client && .venv/bin/python -m cmbuyer_client"
|
||||
echo
|
||||
echo "基础验证失败时先修基线,不要在坏的起点上继续叠新功能。"
|
||||
echo "真机连接与设备验收只能由人工完成,agent 不得据此把任务标为 DONE。"
|
||||
echo "WSL / Unix shell 仅在本机工具链齐备时能通过;这不构成 Windows 或真机验收。"
|
||||
|
||||
Reference in New Issue
Block a user