Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80ed9b71e8 | ||
|
|
3f2e0e5598 | ||
|
|
d27fda6be3 | ||
|
|
acf7e11114 | ||
|
|
946b064470 | ||
|
|
44c027a18f | ||
|
|
cf3692112c | ||
|
|
dbb69a7d5e | ||
|
|
3d063b9785 | ||
|
|
932ca8c7f3 | ||
|
|
9b2eb74478 | ||
|
|
17e295e99d |
@@ -0,0 +1,41 @@
|
|||||||
|
"""本机脱敏 T-103 raw 证据到同级 derived;不连接设备或解析页面语义。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
CLIENT_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(CLIENT_ROOT / "src"))
|
||||||
|
|
||||||
|
from cmbuyer_client.device.sku_evidence_sanitizer import (
|
||||||
|
SkuEvidenceSanitizationError,
|
||||||
|
sanitize_sku_panel_evidence,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description="将本机 raw 规格面板证据确定性脱敏到同级 derived。")
|
||||||
|
parser.add_argument("--raw-dir", required=True, type=Path, help="仅允许名为 raw 的本机原始证据目录。")
|
||||||
|
parser.add_argument("--output-dir", required=True, type=Path, help="仅允许 raw 同级且名为 derived 的新目录。")
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
arguments = parse_arguments(argv)
|
||||||
|
try:
|
||||||
|
result = sanitize_sku_panel_evidence(arguments.raw_dir, arguments.output_dir)
|
||||||
|
except SkuEvidenceSanitizationError as error:
|
||||||
|
# 错误不回显 raw 路径、manifest/XML、地址、手机号或 serial。
|
||||||
|
print(f"证据脱敏失败:{error}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(f"派生证据脱敏完成:{result.output_directory}")
|
||||||
|
print(f"manifest:{result.manifest_path}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,469 @@
|
|||||||
|
"""T-103 原始规格面板证据的本机确定性隐私脱敏。
|
||||||
|
|
||||||
|
此模块只处理人工采集的本地文件:不连接设备、不理解拼多多页面,也不识别规格或价格。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from hashlib import sha256
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
from xml.etree import ElementTree
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw, UnidentifiedImageError
|
||||||
|
|
||||||
|
from ..pdd.product_url import ProductUrl, ProductUrlError, parse_product_url
|
||||||
|
from ..pdd.sku_panel_state import HUMAN_DECLARED_STATES
|
||||||
|
|
||||||
|
|
||||||
|
SANITIZER_VERSION = "t103-privacy-v3"
|
||||||
|
EXPECTED_GOODS_ID = "937122477375"
|
||||||
|
EXPECTED_PDD_VERSION = "8.17.0"
|
||||||
|
EXPECTED_DEVICE_MODEL = "PKG110"
|
||||||
|
EXPECTED_ANDROID_VERSION = "16"
|
||||||
|
EXPECTED_SCREENSHOT_WIDTH = 1080
|
||||||
|
EXPECTED_SCREENSHOT_HEIGHT = 2376
|
||||||
|
EXPECTED_XML_WIDTH = 1080
|
||||||
|
EXPECTED_XML_HEIGHT = 2376
|
||||||
|
_ARTIFACT_FILES = ("screenshot.png", "hierarchy.xml")
|
||||||
|
_SHA256_RE = re.compile(r"[0-9a-f]{64}\Z")
|
||||||
|
_BOUNDS_RE = re.compile(r"\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]\Z")
|
||||||
|
_FULL_PHONE_RE = re.compile(r"(?:\+?86)?1[3-9]\d{9}")
|
||||||
|
_MASKED_PHONE_RE = re.compile(r"1[3-9]\d\*{4}\d{4}")
|
||||||
|
_MASK_TRANSLATION = str.maketrans({"*": "*", "•": "*", "·": "*", "×": "*", "x": "*", "X": "*"})
|
||||||
|
_SEPARATOR_RE = re.compile(r"[\s\-‐‑‒–—―()()]+")
|
||||||
|
|
||||||
|
|
||||||
|
class SkuEvidenceSanitizationError(RuntimeError):
|
||||||
|
"""原始证据不能被安全地发布为派生证据。"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _CleanupStats:
|
||||||
|
"""仅记录节点数量,供派生 manifest 审计;不记录任何页面文本。"""
|
||||||
|
|
||||||
|
removed_nodes: int = 0
|
||||||
|
cleared_crossing_nodes: int = 0
|
||||||
|
retained_below_nodes: int = 0
|
||||||
|
max_right: int = 0
|
||||||
|
max_bottom: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PrivacyMaskConfig:
|
||||||
|
"""仅描述已人工确认的隐私几何区域,绝不承担页面或规格判据。"""
|
||||||
|
|
||||||
|
version: str
|
||||||
|
screenshot_width: int
|
||||||
|
screenshot_height: int
|
||||||
|
xml_width: int
|
||||||
|
xml_height: int
|
||||||
|
privacy_top: int
|
||||||
|
|
||||||
|
|
||||||
|
PRIVACY_MASK_CONFIG = PrivacyMaskConfig(
|
||||||
|
version=SANITIZER_VERSION,
|
||||||
|
screenshot_width=EXPECTED_SCREENSHOT_WIDTH,
|
||||||
|
screenshot_height=EXPECTED_SCREENSHOT_HEIGHT,
|
||||||
|
xml_width=EXPECTED_XML_WIDTH,
|
||||||
|
xml_height=EXPECTED_XML_HEIGHT,
|
||||||
|
# 主审在原始截图确认 y < 540 为收货/手机号区域;整宽遮罩优先保护隐私而非保留版面。
|
||||||
|
privacy_top=540,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SkuEvidenceSanitizationResult:
|
||||||
|
"""已经原子发布的派生证据位置。"""
|
||||||
|
|
||||||
|
output_directory: Path
|
||||||
|
manifest_path: Path
|
||||||
|
screenshot_path: Path
|
||||||
|
hierarchy_path: Path
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_sku_panel_evidence(raw_directory: Path, output_directory: Path) -> SkuEvidenceSanitizationResult:
|
||||||
|
"""校验 raw 三文件,并发布同级 ``derived`` 的脱敏副本。
|
||||||
|
|
||||||
|
目标已存在时在读取任何输入前拒绝,避免混入旧派生物或覆盖人工保留文件。
|
||||||
|
"""
|
||||||
|
|
||||||
|
raw = Path(raw_directory)
|
||||||
|
target = Path(output_directory)
|
||||||
|
_validate_directories(raw, target)
|
||||||
|
if target.exists():
|
||||||
|
raise SkuEvidenceSanitizationError("派生证据目录已存在,拒绝覆盖。")
|
||||||
|
|
||||||
|
staging: Path | None = None
|
||||||
|
try:
|
||||||
|
source_manifest_path = _required_file(raw, "manifest.json")
|
||||||
|
source_screenshot_path = _required_file(raw, "screenshot.png")
|
||||||
|
source_hierarchy_path = _required_file(raw, "hierarchy.xml")
|
||||||
|
manifest = _read_source_manifest(source_manifest_path)
|
||||||
|
link, state, source_hashes = _validate_source_manifest(manifest)
|
||||||
|
_verify_source_hashes(source_screenshot_path, source_hierarchy_path, source_hashes)
|
||||||
|
|
||||||
|
staging = raw.parent / f".derived.staging-{uuid4().hex}"
|
||||||
|
staging.mkdir()
|
||||||
|
derived_screenshot_path = staging / "screenshot.png"
|
||||||
|
_sanitize_screenshot(source_screenshot_path, derived_screenshot_path)
|
||||||
|
derived_hierarchy_path = staging / "hierarchy.xml"
|
||||||
|
cleanup_stats = _sanitize_hierarchy(source_hierarchy_path, derived_hierarchy_path)
|
||||||
|
|
||||||
|
derived_manifest_path = staging / "manifest.json"
|
||||||
|
derived_manifest_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
_derived_manifest(
|
||||||
|
manifest,
|
||||||
|
link,
|
||||||
|
state,
|
||||||
|
source_manifest_path,
|
||||||
|
source_screenshot_path,
|
||||||
|
source_hierarchy_path,
|
||||||
|
derived_screenshot_path,
|
||||||
|
derived_hierarchy_path,
|
||||||
|
cleanup_stats,
|
||||||
|
),
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
sort_keys=True,
|
||||||
|
)
|
||||||
|
+ "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
_publish_staging(staging, target)
|
||||||
|
except SkuEvidenceSanitizationError:
|
||||||
|
_clean_staging(staging)
|
||||||
|
raise
|
||||||
|
except (OSError, ValueError, ElementTree.ParseError, UnidentifiedImageError) as error:
|
||||||
|
_clean_staging(staging)
|
||||||
|
# 原始异常可能含文件路径、JSON/XML 文本或其他敏感内容,不能向 CLI/日志传播。
|
||||||
|
raise SkuEvidenceSanitizationError("原始证据无法安全脱敏,未发布任何派生产物。") from error
|
||||||
|
except Exception as error:
|
||||||
|
_clean_staging(staging)
|
||||||
|
raise SkuEvidenceSanitizationError("原始证据脱敏未完成,未发布任何派生产物。") from error
|
||||||
|
|
||||||
|
return SkuEvidenceSanitizationResult(
|
||||||
|
output_directory=target,
|
||||||
|
manifest_path=target / "manifest.json",
|
||||||
|
screenshot_path=target / "screenshot.png",
|
||||||
|
hierarchy_path=target / "hierarchy.xml",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_directories(raw: Path, target: Path) -> None:
|
||||||
|
if raw.name != "raw" or not raw.is_dir():
|
||||||
|
raise SkuEvidenceSanitizationError("原始证据目录必须是存在的 raw 目录。")
|
||||||
|
if target.name != "derived" or target.parent != raw.parent:
|
||||||
|
raise SkuEvidenceSanitizationError("派生证据目录必须是 raw 同级的 derived 目录。")
|
||||||
|
|
||||||
|
|
||||||
|
def _required_file(raw: Path, filename: str) -> Path:
|
||||||
|
candidate = raw / filename
|
||||||
|
if not candidate.is_file():
|
||||||
|
raise SkuEvidenceSanitizationError("原始证据文件集合不完整。")
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def _read_source_manifest(path: Path) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
value = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error:
|
||||||
|
raise SkuEvidenceSanitizationError("原始证据 manifest 无效。") from error
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise SkuEvidenceSanitizationError("原始证据 manifest 结构无效。")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_source_manifest(manifest: dict[str, Any]) -> tuple[ProductUrl, str, dict[str, str]]:
|
||||||
|
product = manifest.get("product")
|
||||||
|
device = manifest.get("device")
|
||||||
|
state = manifest.get("human_declared_state")
|
||||||
|
if manifest.get("schema_version") != 1 or not isinstance(product, dict) or not isinstance(device, dict):
|
||||||
|
raise SkuEvidenceSanitizationError("原始证据 manifest 缺少必要元数据。")
|
||||||
|
canonical_url = product.get("canonical_url")
|
||||||
|
goods_id = product.get("goods_id")
|
||||||
|
try:
|
||||||
|
link = parse_product_url(canonical_url)
|
||||||
|
except ProductUrlError as error:
|
||||||
|
raise SkuEvidenceSanitizationError("原始证据商品元数据不匹配。") from error
|
||||||
|
if (
|
||||||
|
link.goods_id != EXPECTED_GOODS_ID
|
||||||
|
or goods_id != EXPECTED_GOODS_ID
|
||||||
|
or device.get("model") != EXPECTED_DEVICE_MODEL
|
||||||
|
or device.get("android_version") != EXPECTED_ANDROID_VERSION
|
||||||
|
or device.get("pdd_version") != EXPECTED_PDD_VERSION
|
||||||
|
or device.get("pdd_package") != "com.xunmeng.pinduoduo"
|
||||||
|
or not isinstance(state, str)
|
||||||
|
or state not in HUMAN_DECLARED_STATES
|
||||||
|
):
|
||||||
|
raise SkuEvidenceSanitizationError("原始证据元数据与脱敏配置不匹配。")
|
||||||
|
return link, state, _artifact_hashes(manifest)
|
||||||
|
|
||||||
|
|
||||||
|
def _artifact_hashes(manifest: dict[str, Any]) -> dict[str, str]:
|
||||||
|
artifacts = manifest.get("artifacts")
|
||||||
|
if not isinstance(artifacts, list):
|
||||||
|
raise SkuEvidenceSanitizationError("原始证据 manifest 缺少文件校验信息。")
|
||||||
|
hashes: dict[str, str] = {}
|
||||||
|
for artifact in artifacts:
|
||||||
|
if not isinstance(artifact, dict):
|
||||||
|
raise SkuEvidenceSanitizationError("原始证据 manifest 文件校验信息无效。")
|
||||||
|
path = artifact.get("path")
|
||||||
|
digest = artifact.get("sha256")
|
||||||
|
if path not in _ARTIFACT_FILES or path in hashes or not isinstance(digest, str) or not _SHA256_RE.fullmatch(digest):
|
||||||
|
raise SkuEvidenceSanitizationError("原始证据 manifest 文件校验信息无效。")
|
||||||
|
hashes[path] = digest
|
||||||
|
if set(hashes) != set(_ARTIFACT_FILES):
|
||||||
|
raise SkuEvidenceSanitizationError("原始证据 manifest 文件校验信息不完整。")
|
||||||
|
return hashes
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_source_hashes(screenshot_path: Path, hierarchy_path: Path, expected: dict[str, str]) -> None:
|
||||||
|
if (
|
||||||
|
_sha256_file(screenshot_path) != expected["screenshot.png"]
|
||||||
|
or _sha256_file(hierarchy_path) != expected["hierarchy.xml"]
|
||||||
|
):
|
||||||
|
raise SkuEvidenceSanitizationError("原始证据文件校验失败。")
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_screenshot(source: Path, target: Path) -> None:
|
||||||
|
try:
|
||||||
|
with Image.open(source) as image:
|
||||||
|
image.load()
|
||||||
|
if image.format != "PNG" or image.size != (
|
||||||
|
PRIVACY_MASK_CONFIG.screenshot_width,
|
||||||
|
PRIVACY_MASK_CONFIG.screenshot_height,
|
||||||
|
):
|
||||||
|
raise SkuEvidenceSanitizationError("原始截图分辨率或格式与脱敏配置不匹配。")
|
||||||
|
sanitized = image.convert("RGBA")
|
||||||
|
except SkuEvidenceSanitizationError:
|
||||||
|
raise
|
||||||
|
except (OSError, UnidentifiedImageError) as error:
|
||||||
|
raise SkuEvidenceSanitizationError("原始截图无效。") from error
|
||||||
|
|
||||||
|
# 用不透明黑色覆盖 y < 540,保证截图与 XML 使用相同的隐私几何边界。
|
||||||
|
ImageDraw.Draw(sanitized).rectangle(
|
||||||
|
(0, 0, PRIVACY_MASK_CONFIG.screenshot_width - 1, PRIVACY_MASK_CONFIG.privacy_top - 1),
|
||||||
|
fill=(0, 0, 0, 255),
|
||||||
|
)
|
||||||
|
sanitized.save(target, format="PNG", optimize=False, compress_level=9)
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_hierarchy(source: Path, target: Path) -> _CleanupStats:
|
||||||
|
try:
|
||||||
|
root = ElementTree.parse(source).getroot()
|
||||||
|
except (OSError, ElementTree.ParseError) as error:
|
||||||
|
raise SkuEvidenceSanitizationError("原始节点树无效。") from error
|
||||||
|
if root.tag != "hierarchy":
|
||||||
|
raise SkuEvidenceSanitizationError("原始节点树结构不匹配。")
|
||||||
|
if not list(root):
|
||||||
|
raise SkuEvidenceSanitizationError("原始节点树结构不匹配。")
|
||||||
|
stats = _CleanupStats()
|
||||||
|
_clear_node_text(root)
|
||||||
|
for child in list(root):
|
||||||
|
_sanitize_node(root, child, stats)
|
||||||
|
_require_expected_xml_coordinate_space(stats)
|
||||||
|
if stats.removed_nodes < 1 or stats.retained_below_nodes < 1:
|
||||||
|
raise SkuEvidenceSanitizationError("原始节点树未满足隐私几何结构。")
|
||||||
|
if _contains_phone(root):
|
||||||
|
raise SkuEvidenceSanitizationError("派生节点树仍包含手机号,拒绝发布。")
|
||||||
|
ElementTree.ElementTree(root).write(target, encoding="utf-8", xml_declaration=True)
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_node(parent: ElementTree.Element, node: ElementTree.Element, stats: _CleanupStats) -> None:
|
||||||
|
if node.tag != "node":
|
||||||
|
raise SkuEvidenceSanitizationError("原始节点树结构不匹配。")
|
||||||
|
bounds = _parse_bounds(node.get("bounds"))
|
||||||
|
_observe_bounds(stats, bounds)
|
||||||
|
position = _vertical_position(bounds)
|
||||||
|
if position == "private":
|
||||||
|
# 私有带内的父节点不可以悄然包含下方子节点,否则会把仍需审计的下方内容一起丢失。
|
||||||
|
for descendant in node.iter("node"):
|
||||||
|
descendant_bounds = _parse_bounds(descendant.get("bounds"))
|
||||||
|
_observe_bounds(stats, descendant_bounds)
|
||||||
|
if _vertical_position(descendant_bounds) != "private":
|
||||||
|
raise SkuEvidenceSanitizationError("原始节点树 bounds 结构不匹配。")
|
||||||
|
stats.removed_nodes += sum(1 for _ in node.iter("node"))
|
||||||
|
parent.remove(node)
|
||||||
|
return
|
||||||
|
if position == "crossing":
|
||||||
|
# 全屏/跨界容器可保留其下方子节点,但自身所有属性和文本都可能含地址或手机号。
|
||||||
|
_clear_node_text(node)
|
||||||
|
stats.cleared_crossing_nodes += 1
|
||||||
|
else:
|
||||||
|
stats.retained_below_nodes += 1
|
||||||
|
for child in list(node):
|
||||||
|
_sanitize_node(node, child, stats)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_bounds(value: object) -> tuple[int, int, int, int]:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise SkuEvidenceSanitizationError("原始节点树 bounds 缺失或无效。")
|
||||||
|
match = _BOUNDS_RE.fullmatch(value)
|
||||||
|
if match is None:
|
||||||
|
raise SkuEvidenceSanitizationError("原始节点树 bounds 缺失或无效。")
|
||||||
|
left, top, right, bottom = (int(group) for group in match.groups())
|
||||||
|
if not (0 <= left < right and 0 <= top < bottom):
|
||||||
|
raise SkuEvidenceSanitizationError("原始节点树 bounds 缺失或无效。")
|
||||||
|
return left, top, right, bottom
|
||||||
|
|
||||||
|
|
||||||
|
def _observe_bounds(stats: _CleanupStats, bounds: tuple[int, int, int, int]) -> None:
|
||||||
|
_, _, right, bottom = bounds
|
||||||
|
stats.max_right = max(stats.max_right, right)
|
||||||
|
stats.max_bottom = max(stats.max_bottom, bottom)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_expected_xml_coordinate_space(stats: _CleanupStats) -> None:
|
||||||
|
if (
|
||||||
|
stats.max_right != PRIVACY_MASK_CONFIG.xml_width
|
||||||
|
or stats.max_bottom != PRIVACY_MASK_CONFIG.xml_height
|
||||||
|
):
|
||||||
|
raise SkuEvidenceSanitizationError(
|
||||||
|
f"原始节点树坐标范围不匹配(observed {stats.max_right}x{stats.max_bottom})。"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _vertical_position(bounds: tuple[int, int, int, int]) -> str:
|
||||||
|
_, top, _, bottom = bounds
|
||||||
|
if bottom <= PRIVACY_MASK_CONFIG.privacy_top:
|
||||||
|
return "private"
|
||||||
|
if top >= PRIVACY_MASK_CONFIG.privacy_top:
|
||||||
|
return "below"
|
||||||
|
return "crossing"
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_node_text(node: ElementTree.Element) -> None:
|
||||||
|
node.attrib = {"bounds": node.attrib["bounds"]} if "bounds" in node.attrib else {}
|
||||||
|
node.text = None
|
||||||
|
node.tail = None
|
||||||
|
|
||||||
|
|
||||||
|
def _contains_phone(root: ElementTree.Element) -> bool:
|
||||||
|
"""逐项与跨节点复检电话,避免分隔符、遮罩字符或节点切分绕过。"""
|
||||||
|
|
||||||
|
all_values: list[str] = []
|
||||||
|
content_values: list[str] = []
|
||||||
|
for element in root.iter():
|
||||||
|
if element.text:
|
||||||
|
all_values.append(element.text)
|
||||||
|
content_values.append(element.text)
|
||||||
|
for attribute, value in element.attrib.items():
|
||||||
|
all_values.append(value)
|
||||||
|
if attribute != "bounds":
|
||||||
|
content_values.append(value)
|
||||||
|
if element.tail:
|
||||||
|
all_values.append(element.tail)
|
||||||
|
content_values.append(element.tail)
|
||||||
|
normalized_values = [_normalize_phone_value(value) for value in all_values]
|
||||||
|
normalized_all_document = "".join(normalized_values)
|
||||||
|
normalized_document = "".join(_normalize_phone_value(value) for value in content_values)
|
||||||
|
return (
|
||||||
|
any(_matches_phone(value) for value in normalized_values)
|
||||||
|
or _matches_phone(normalized_all_document)
|
||||||
|
or _matches_phone(normalized_document)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_phone_value(value: str) -> str:
|
||||||
|
return _SEPARATOR_RE.sub("", value.translate(_MASK_TRANSLATION))
|
||||||
|
|
||||||
|
|
||||||
|
def _matches_phone(value: str) -> bool:
|
||||||
|
return _FULL_PHONE_RE.search(value) is not None or _MASKED_PHONE_RE.search(value) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _derived_manifest(
|
||||||
|
source_manifest: dict[str, Any],
|
||||||
|
link: ProductUrl,
|
||||||
|
state: str,
|
||||||
|
source_manifest_path: Path,
|
||||||
|
source_screenshot_path: Path,
|
||||||
|
source_hierarchy_path: Path,
|
||||||
|
derived_screenshot_path: Path,
|
||||||
|
derived_hierarchy_path: Path,
|
||||||
|
cleanup_stats: _CleanupStats,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
device = source_manifest["device"]
|
||||||
|
return {
|
||||||
|
"schema_version": 1,
|
||||||
|
"privacy_tier": "SANITIZED",
|
||||||
|
"sanitizer_version": PRIVACY_MASK_CONFIG.version,
|
||||||
|
"screenshot_space": {
|
||||||
|
"width": PRIVACY_MASK_CONFIG.screenshot_width,
|
||||||
|
"height": PRIVACY_MASK_CONFIG.screenshot_height,
|
||||||
|
"privacy_mask_rectangle": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
PRIVACY_MASK_CONFIG.screenshot_width,
|
||||||
|
PRIVACY_MASK_CONFIG.privacy_top,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"xml_coordinate_space": {
|
||||||
|
"width": PRIVACY_MASK_CONFIG.xml_width,
|
||||||
|
"height": PRIVACY_MASK_CONFIG.xml_height,
|
||||||
|
"privacy_mask_rectangle": [0, 0, PRIVACY_MASK_CONFIG.xml_width, PRIVACY_MASK_CONFIG.privacy_top],
|
||||||
|
"observed_max": {"right": cleanup_stats.max_right, "bottom": cleanup_stats.max_bottom},
|
||||||
|
},
|
||||||
|
"privacy_cleanup": {
|
||||||
|
"removed_nodes": cleanup_stats.removed_nodes,
|
||||||
|
"cleared_crossing_nodes": cleanup_stats.cleared_crossing_nodes,
|
||||||
|
"retained_below_nodes": cleanup_stats.retained_below_nodes,
|
||||||
|
"max_right": cleanup_stats.max_right,
|
||||||
|
"max_bottom": cleanup_stats.max_bottom,
|
||||||
|
},
|
||||||
|
"product": {"goods_id": link.goods_id},
|
||||||
|
"human_declared_state": state,
|
||||||
|
"device": {
|
||||||
|
"model": device["model"],
|
||||||
|
"android_version": device.get("android_version"),
|
||||||
|
"pdd_package": device["pdd_package"],
|
||||||
|
"pdd_version": device["pdd_version"],
|
||||||
|
},
|
||||||
|
# source hashes stay only in the local derived manifest; no raw path, serial or body is retained.
|
||||||
|
"source": {
|
||||||
|
"manifest_sha256": _sha256_file(source_manifest_path),
|
||||||
|
"artifacts": [
|
||||||
|
{"path": "screenshot.png", "sha256": _sha256_file(source_screenshot_path)},
|
||||||
|
{"path": "hierarchy.xml", "sha256": _sha256_file(source_hierarchy_path)},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"derived": {
|
||||||
|
"artifacts": [
|
||||||
|
{"path": "screenshot.png", "sha256": _sha256_file(derived_screenshot_path)},
|
||||||
|
{"path": "hierarchy.xml", "sha256": _sha256_file(derived_hierarchy_path)},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256_file(path: Path) -> str:
|
||||||
|
digest = sha256()
|
||||||
|
with path.open("rb") as source:
|
||||||
|
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_staging(staging: Path | None) -> None:
|
||||||
|
if staging is not None and staging.exists():
|
||||||
|
shutil.rmtree(staging)
|
||||||
|
|
||||||
|
|
||||||
|
def _publish_staging(staging: Path, target: Path) -> None:
|
||||||
|
"""发布前二次检查,并使用目录 rename 而不是会覆盖目标的 replace。"""
|
||||||
|
|
||||||
|
if target.exists():
|
||||||
|
raise SkuEvidenceSanitizationError("派生证据目录已存在,拒绝覆盖。")
|
||||||
|
try:
|
||||||
|
staging.rename(target)
|
||||||
|
except OSError as error:
|
||||||
|
# 竞态中新目标出现或文件系统拒绝 rename 时一律不尝试覆盖或重试。
|
||||||
|
raise SkuEvidenceSanitizationError("派生证据目录发布失败,未覆盖已有目录。") from error
|
||||||
@@ -31,15 +31,7 @@ from ..device.baseline import (
|
|||||||
)
|
)
|
||||||
from .product_open import EXPECTED_PDD_VERSION
|
from .product_open import EXPECTED_PDD_VERSION
|
||||||
from .product_url import ProductUrl, parse_product_url
|
from .product_url import ProductUrl, parse_product_url
|
||||||
|
from .sku_panel_state import HUMAN_DECLARED_STATES
|
||||||
|
|
||||||
HUMAN_DECLARED_STATES = frozenset(
|
|
||||||
{
|
|
||||||
"initial",
|
|
||||||
"one-dimension-selected",
|
|
||||||
"all-dimensions-selected",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SkuPanelEvidenceError(RuntimeError):
|
class SkuPanelEvidenceError(RuntimeError):
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
"""T-103 的人工声明证据状态;不含任何页面识别或规格语义。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
# 这些值由现场人员填写到 manifest,不能被解释为自动检测出的页面或选择状态。
|
||||||
|
HUMAN_DECLARED_STATES = frozenset(
|
||||||
|
{
|
||||||
|
"panel-opened-target-preselected",
|
||||||
|
"alternate-all-dimensions-selected",
|
||||||
|
"target-selection-restored",
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
"""T-103 脱敏器测试:全部证据为合成数据,绝不读取真实 raw 目录。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from hashlib import sha256
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
from xml.etree import ElementTree
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
|
||||||
|
CLIENT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(CLIENT_ROOT / "src"))
|
||||||
|
|
||||||
|
from cmbuyer_client.device.sku_evidence_sanitizer import (
|
||||||
|
EXPECTED_GOODS_ID,
|
||||||
|
EXPECTED_SCREENSHOT_HEIGHT,
|
||||||
|
EXPECTED_SCREENSHOT_WIDTH,
|
||||||
|
EXPECTED_XML_HEIGHT,
|
||||||
|
EXPECTED_XML_WIDTH,
|
||||||
|
HUMAN_DECLARED_STATES,
|
||||||
|
SkuEvidenceSanitizationError,
|
||||||
|
sanitize_sku_panel_evidence,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
TEST_SERIAL = "synthetic-serial-never-publish"
|
||||||
|
TEST_ADDRESS = "SYNTHETIC_ADDRESS_NEVER_PUBLISH"
|
||||||
|
FULL_PHONE = "13800138000"
|
||||||
|
MASKED_PHONE = "138****0000"
|
||||||
|
SAFE_TEXT = "synthetic-safe-lower-content"
|
||||||
|
|
||||||
|
|
||||||
|
def _hash(path: Path) -> str:
|
||||||
|
digest = sha256()
|
||||||
|
with path.open("rb") as source:
|
||||||
|
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _default_xml() -> str:
|
||||||
|
return (
|
||||||
|
"<hierarchy rotation='0'>"
|
||||||
|
f"<node bounds='[0,0][1080,540]' text='{TEST_ADDRESS}' content-desc='{MASKED_PHONE} {FULL_PHONE}' />"
|
||||||
|
f"<node bounds='[0,540][1080,2376]' text='{SAFE_TEXT}' />"
|
||||||
|
"</hierarchy>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_raw(
|
||||||
|
root: Path,
|
||||||
|
*,
|
||||||
|
state: str = "panel-opened-target-preselected",
|
||||||
|
goods_id: str = EXPECTED_GOODS_ID,
|
||||||
|
model: str = "PKG110",
|
||||||
|
android_version: str = "16",
|
||||||
|
pdd_version: str = "8.17.0",
|
||||||
|
size: tuple[int, int] = (EXPECTED_SCREENSHOT_WIDTH, EXPECTED_SCREENSHOT_HEIGHT),
|
||||||
|
xml: str | None = None,
|
||||||
|
) -> Path:
|
||||||
|
raw = root / "raw"
|
||||||
|
raw.mkdir(parents=True)
|
||||||
|
screenshot = raw / "screenshot.png"
|
||||||
|
image = Image.new("RGB", size, color=(0, 180, 0))
|
||||||
|
if size == (EXPECTED_SCREENSHOT_WIDTH, EXPECTED_SCREENSHOT_HEIGHT):
|
||||||
|
for y in range(540):
|
||||||
|
for x in range(8):
|
||||||
|
image.putpixel((x, y), (255, 0, 0))
|
||||||
|
image.save(screenshot, format="PNG")
|
||||||
|
hierarchy = raw / "hierarchy.xml"
|
||||||
|
hierarchy.write_text(_default_xml() if xml is None else xml, encoding="utf-8")
|
||||||
|
manifest = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"product": {
|
||||||
|
"goods_id": goods_id,
|
||||||
|
"canonical_url": f"https://mobile.yangkeduo.com/goods.html?goods_id={goods_id}",
|
||||||
|
},
|
||||||
|
"human_declared_state": state,
|
||||||
|
"serial": TEST_SERIAL,
|
||||||
|
"channel": "wifi",
|
||||||
|
"device": {
|
||||||
|
"model": model,
|
||||||
|
"android_version": android_version,
|
||||||
|
"pdd_package": "com.xunmeng.pinduoduo",
|
||||||
|
"pdd_version": pdd_version,
|
||||||
|
},
|
||||||
|
"artifacts": [
|
||||||
|
{"path": "screenshot.png", "sha256": _hash(screenshot)},
|
||||||
|
{"path": "hierarchy.xml", "sha256": _hash(hierarchy)},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
(raw / "manifest.json").write_text(json.dumps(manifest, sort_keys=True), encoding="utf-8")
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
class SkuEvidenceSanitizerTests(unittest.TestCase):
|
||||||
|
def test_all_declared_states_mask_screenshot_and_xml_without_raw_metadata(self) -> None:
|
||||||
|
for state in sorted(HUMAN_DECLARED_STATES):
|
||||||
|
with self.subTest(state=state), TemporaryDirectory() as temporary:
|
||||||
|
raw = _write_raw(Path(temporary), state=state)
|
||||||
|
result = sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||||
|
with Image.open(result.screenshot_path) as image:
|
||||||
|
self.assertEqual(image.getpixel((0, 0)), (0, 0, 0, 255))
|
||||||
|
self.assertEqual(image.getpixel((100, 600)), (0, 180, 0, 255))
|
||||||
|
derived_xml = result.hierarchy_path.read_text(encoding="utf-8")
|
||||||
|
manifest = result.manifest_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertIn(SAFE_TEXT, derived_xml)
|
||||||
|
self.assertNotIn(TEST_ADDRESS, derived_xml)
|
||||||
|
self.assertNotIn(FULL_PHONE, derived_xml)
|
||||||
|
self.assertNotIn(MASKED_PHONE, derived_xml)
|
||||||
|
self.assertIn(f'"human_declared_state": "{state}"', manifest)
|
||||||
|
self.assertIn('"privacy_tier": "SANITIZED"', manifest)
|
||||||
|
self.assertIn('"sanitizer_version": "t103-privacy-v3"', manifest)
|
||||||
|
self.assertIn('"screenshot_space": {', manifest)
|
||||||
|
self.assertIn('"xml_coordinate_space": {', manifest)
|
||||||
|
self.assertIn('"height": 2376', manifest)
|
||||||
|
self.assertIn('"height": 2376', manifest)
|
||||||
|
self.assertIn('"privacy_mask_rectangle": [', manifest)
|
||||||
|
self.assertIn('"removed_nodes": 1', manifest)
|
||||||
|
self.assertIn('"cleared_crossing_nodes": 0', manifest)
|
||||||
|
self.assertIn('"retained_below_nodes": 1', manifest)
|
||||||
|
self.assertIn('"max_right": 1080', manifest)
|
||||||
|
self.assertIn('"max_bottom": 2376', manifest)
|
||||||
|
self.assertNotIn("canonical_url", manifest)
|
||||||
|
self.assertNotIn(TEST_SERIAL, manifest)
|
||||||
|
self.assertNotIn("serial", manifest)
|
||||||
|
self.assertNotIn("channel", manifest)
|
||||||
|
self.assertNotIn(TEST_ADDRESS, manifest)
|
||||||
|
self.assertNotIn(FULL_PHONE, manifest)
|
||||||
|
|
||||||
|
def test_crossing_container_keeps_lower_children_but_clears_its_text(self) -> None:
|
||||||
|
xml = (
|
||||||
|
"<hierarchy>"
|
||||||
|
f"<node bounds='[0,0][1080,2376]' text='{TEST_ADDRESS}' content-desc='{MASKED_PHONE}'>"
|
||||||
|
f"<node bounds='[0,0][1080,540]' text='{FULL_PHONE}' />"
|
||||||
|
f"<node bounds='[0,540][1080,2376]' text='{SAFE_TEXT}' />"
|
||||||
|
"</node></hierarchy>"
|
||||||
|
)
|
||||||
|
with TemporaryDirectory() as temporary:
|
||||||
|
raw = _write_raw(Path(temporary), xml=xml)
|
||||||
|
result = sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||||
|
root = ElementTree.parse(result.hierarchy_path).getroot()
|
||||||
|
crossing = root.find("node")
|
||||||
|
|
||||||
|
self.assertIsNotNone(crossing)
|
||||||
|
assert crossing is not None
|
||||||
|
self.assertEqual(crossing.attrib, {"bounds": "[0,0][1080,2376]"})
|
||||||
|
self.assertEqual(len(list(crossing)), 1)
|
||||||
|
self.assertEqual(list(crossing)[0].get("text"), SAFE_TEXT)
|
||||||
|
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
|
||||||
|
self.assertEqual(
|
||||||
|
manifest["privacy_cleanup"],
|
||||||
|
{
|
||||||
|
"removed_nodes": 1,
|
||||||
|
"cleared_crossing_nodes": 1,
|
||||||
|
"retained_below_nodes": 1,
|
||||||
|
"max_right": 1080,
|
||||||
|
"max_bottom": 2376,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_same_raw_and_config_produce_identical_derived_files(self) -> None:
|
||||||
|
with TemporaryDirectory() as temporary:
|
||||||
|
root = Path(temporary)
|
||||||
|
left_raw = _write_raw(root / "left")
|
||||||
|
right_raw = _write_raw(root / "right")
|
||||||
|
left = sanitize_sku_panel_evidence(left_raw, left_raw.parent / "derived")
|
||||||
|
right = sanitize_sku_panel_evidence(right_raw, right_raw.parent / "derived")
|
||||||
|
|
||||||
|
for left_path, right_path in (
|
||||||
|
(left.screenshot_path, right.screenshot_path),
|
||||||
|
(left.hierarchy_path, right.hierarchy_path),
|
||||||
|
(left.manifest_path, right.manifest_path),
|
||||||
|
):
|
||||||
|
self.assertEqual(left_path.read_bytes(), right_path.read_bytes())
|
||||||
|
|
||||||
|
def test_manifest_records_distinct_screenshot_and_xml_coordinate_spaces(self) -> None:
|
||||||
|
with TemporaryDirectory() as temporary:
|
||||||
|
raw = _write_raw(Path(temporary))
|
||||||
|
result = sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||||
|
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
manifest["screenshot_space"],
|
||||||
|
{"width": 1080, "height": 2376, "privacy_mask_rectangle": [0, 0, 1080, 540]},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
manifest["xml_coordinate_space"],
|
||||||
|
{
|
||||||
|
"width": 1080,
|
||||||
|
"height": 2376,
|
||||||
|
"privacy_mask_rectangle": [0, 0, 1080, 540],
|
||||||
|
"observed_max": {"right": 1080, "bottom": 2376},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_hash_and_metadata_mismatch_fail_closed_without_leak(self) -> None:
|
||||||
|
scenarios = (
|
||||||
|
("hash", {}, "screenshot"),
|
||||||
|
("model", {"model": "other"}, None),
|
||||||
|
("android", {"android_version": "15"}, None),
|
||||||
|
("version", {"pdd_version": "8.17.1"}, None),
|
||||||
|
("goods", {"goods_id": "123"}, None),
|
||||||
|
("state", {"state": "guessed"}, None),
|
||||||
|
("old-screenshot-space", {"size": (1080, 2400)}, None),
|
||||||
|
("other-screenshot-space", {"size": (100, 100)}, None),
|
||||||
|
)
|
||||||
|
for name, kwargs, corrupt_file in scenarios:
|
||||||
|
with self.subTest(name=name), TemporaryDirectory() as temporary:
|
||||||
|
raw = _write_raw(Path(temporary), **kwargs)
|
||||||
|
if corrupt_file is not None:
|
||||||
|
(raw / f"{corrupt_file}.png").write_bytes(b"changed")
|
||||||
|
with self.assertRaises(SkuEvidenceSanitizationError) as raised:
|
||||||
|
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||||
|
|
||||||
|
message = str(raised.exception)
|
||||||
|
self.assertNotIn(TEST_SERIAL, message)
|
||||||
|
self.assertNotIn(TEST_ADDRESS, message)
|
||||||
|
self.assertNotIn(FULL_PHONE, message)
|
||||||
|
self.assertFalse((raw.parent / "derived").exists())
|
||||||
|
self.assertEqual(list(raw.parent.glob(".derived.staging-*")), [])
|
||||||
|
|
||||||
|
def test_old_and_unknown_human_states_are_rejected(self) -> None:
|
||||||
|
for state in ("initial", "one-dimension-selected", "all-dimensions-selected", "guessed"):
|
||||||
|
with self.subTest(state=state), TemporaryDirectory() as temporary:
|
||||||
|
raw = _write_raw(Path(temporary), state=state)
|
||||||
|
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||||
|
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||||
|
self.assertFalse((raw.parent / "derived").exists())
|
||||||
|
|
||||||
|
def test_malformed_inputs_and_bounds_or_phone_residue_fail_closed(self) -> None:
|
||||||
|
malformed = (
|
||||||
|
("manifest", None),
|
||||||
|
("png", None),
|
||||||
|
("xml", None),
|
||||||
|
("bounds", "<hierarchy><node text='missing bounds' /></hierarchy>"),
|
||||||
|
(
|
||||||
|
"private-parent-with-lower-child",
|
||||||
|
"<hierarchy><node bounds='[0,0][1080,540]'><node bounds='[0,540][1080,2376]' text='x' /></node></hierarchy>",
|
||||||
|
),
|
||||||
|
("full-phone-below", f"<hierarchy><node bounds='[0,540][1080,2376]' text='{FULL_PHONE}' /></hierarchy>"),
|
||||||
|
("masked-phone-below", f"<hierarchy><node bounds='[0,540][1080,2376]' text='{MASKED_PHONE}' /></hierarchy>"),
|
||||||
|
)
|
||||||
|
for kind, xml in malformed:
|
||||||
|
with self.subTest(kind=kind), TemporaryDirectory() as temporary:
|
||||||
|
raw = _write_raw(Path(temporary), xml=xml)
|
||||||
|
if kind == "manifest":
|
||||||
|
(raw / "manifest.json").write_text("{invalid", encoding="utf-8")
|
||||||
|
elif kind == "png":
|
||||||
|
(raw / "screenshot.png").write_bytes(b"not a png")
|
||||||
|
manifest = json.loads((raw / "manifest.json").read_text(encoding="utf-8"))
|
||||||
|
manifest["artifacts"][0]["sha256"] = _hash(raw / "screenshot.png")
|
||||||
|
(raw / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
|
||||||
|
elif kind == "xml":
|
||||||
|
(raw / "hierarchy.xml").write_text("<hierarchy>", encoding="utf-8")
|
||||||
|
manifest = json.loads((raw / "manifest.json").read_text(encoding="utf-8"))
|
||||||
|
manifest["artifacts"][1]["sha256"] = _hash(raw / "hierarchy.xml")
|
||||||
|
(raw / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
|
||||||
|
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||||
|
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||||
|
|
||||||
|
self.assertFalse((raw.parent / "derived").exists())
|
||||||
|
self.assertEqual(list(raw.parent.glob(".derived.staging-*")), [])
|
||||||
|
|
||||||
|
def test_phone_recheck_rejects_separator_mask_and_cross_node_bypasses(self) -> None:
|
||||||
|
variants = (
|
||||||
|
"138 0013-8000",
|
||||||
|
"138****0000",
|
||||||
|
"138••••0000",
|
||||||
|
"138xxxx0000",
|
||||||
|
"138XXXX0000",
|
||||||
|
)
|
||||||
|
for value in variants:
|
||||||
|
xml = (
|
||||||
|
"<hierarchy>"
|
||||||
|
"<node bounds='[0,0][1080,540]' text='private' />"
|
||||||
|
f"<node bounds='[0,540][1080,2376]' text='{value}' />"
|
||||||
|
"</hierarchy>"
|
||||||
|
)
|
||||||
|
with self.subTest(value=value), TemporaryDirectory() as temporary:
|
||||||
|
raw = _write_raw(Path(temporary), xml=xml)
|
||||||
|
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||||
|
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||||
|
self.assertFalse((raw.parent / "derived").exists())
|
||||||
|
|
||||||
|
split_xml = (
|
||||||
|
"<hierarchy>"
|
||||||
|
"<node bounds='[0,0][1080,540]' text='private' />"
|
||||||
|
"<node bounds='[0,540][1080,1000]' text='138' content-desc='0013' />"
|
||||||
|
"<node bounds='[0,1000][1080,2376]' text='8000' />"
|
||||||
|
"</hierarchy>"
|
||||||
|
)
|
||||||
|
with TemporaryDirectory() as temporary:
|
||||||
|
raw = _write_raw(Path(temporary), xml=split_xml)
|
||||||
|
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||||
|
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||||
|
self.assertFalse((raw.parent / "derived").exists())
|
||||||
|
|
||||||
|
def test_privacy_geometry_requires_removed_and_retained_nodes(self) -> None:
|
||||||
|
scenarios = (
|
||||||
|
("no-private", f"<hierarchy><node bounds='[0,540][1080,2376]' text='{SAFE_TEXT}' /></hierarchy>"),
|
||||||
|
("no-below", "<hierarchy><node bounds='[0,0][1080,540]' text='private' /></hierarchy>"),
|
||||||
|
)
|
||||||
|
for name, xml in scenarios:
|
||||||
|
with self.subTest(name=name), TemporaryDirectory() as temporary:
|
||||||
|
raw = _write_raw(Path(temporary), xml=xml)
|
||||||
|
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||||
|
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||||
|
self.assertFalse((raw.parent / "derived").exists())
|
||||||
|
self.assertEqual(list(raw.parent.glob(".derived.staging-*")), [])
|
||||||
|
|
||||||
|
def test_xml_coordinate_space_must_have_exact_configured_maximums(self) -> None:
|
||||||
|
scenarios = (
|
||||||
|
(
|
||||||
|
"short-width",
|
||||||
|
"<hierarchy><node bounds='[0,0][1079,540]' text='private' />"
|
||||||
|
"<node bounds='[0,540][1079,2376]' text='safe' /></hierarchy>",
|
||||||
|
"1079x2376",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"short-height",
|
||||||
|
"<hierarchy><node bounds='[0,0][1080,540]' text='private' />"
|
||||||
|
"<node bounds='[0,540][1080,2375]' text='safe' /></hierarchy>",
|
||||||
|
"1080x2375",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"wide-width",
|
||||||
|
"<hierarchy><node bounds='[0,0][1081,540]' text='private' />"
|
||||||
|
"<node bounds='[0,540][1081,2376]' text='safe' /></hierarchy>",
|
||||||
|
"1081x2376",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"old-v2-xml-height",
|
||||||
|
"<hierarchy><node bounds='[0,0][1080,540]' text='private' />"
|
||||||
|
"<node bounds='[0,540][1080,2400]' text='safe' /></hierarchy>",
|
||||||
|
"1080x2400",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for name, xml, observed in scenarios:
|
||||||
|
with self.subTest(name=name), TemporaryDirectory() as temporary:
|
||||||
|
raw = _write_raw(Path(temporary), xml=xml)
|
||||||
|
with self.assertRaises(SkuEvidenceSanitizationError) as raised:
|
||||||
|
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||||
|
|
||||||
|
self.assertIn(observed, str(raised.exception))
|
||||||
|
self.assertNotIn(TEST_SERIAL, str(raised.exception))
|
||||||
|
self.assertFalse((raw.parent / "derived").exists())
|
||||||
|
self.assertEqual(list(raw.parent.glob(".derived.staging-*")), [])
|
||||||
|
|
||||||
|
def test_manifest_schema_package_and_artifact_structure_are_required(self) -> None:
|
||||||
|
def mutate(manifest: dict[str, object], kind: str) -> None:
|
||||||
|
if kind == "schema":
|
||||||
|
manifest["schema_version"] = 2
|
||||||
|
elif kind == "package":
|
||||||
|
manifest["device"]["pdd_package"] = "com.example.other" # type: ignore[index]
|
||||||
|
elif kind == "missing":
|
||||||
|
manifest.pop("artifacts")
|
||||||
|
elif kind == "duplicate":
|
||||||
|
manifest["artifacts"].append(manifest["artifacts"][0]) # type: ignore[index]
|
||||||
|
elif kind == "bad-hash":
|
||||||
|
manifest["artifacts"][0]["sha256"] = "g" * 64 # type: ignore[index]
|
||||||
|
|
||||||
|
for kind in ("schema", "package", "missing", "duplicate", "bad-hash"):
|
||||||
|
with self.subTest(kind=kind), TemporaryDirectory() as temporary:
|
||||||
|
raw = _write_raw(Path(temporary))
|
||||||
|
manifest_path = raw / "manifest.json"
|
||||||
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
|
mutate(manifest, kind)
|
||||||
|
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
||||||
|
with self.assertRaises(SkuEvidenceSanitizationError) as raised:
|
||||||
|
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||||
|
|
||||||
|
self.assertNotIn(TEST_SERIAL, str(raised.exception))
|
||||||
|
self.assertNotIn(TEST_ADDRESS, str(raised.exception))
|
||||||
|
self.assertFalse((raw.parent / "derived").exists())
|
||||||
|
|
||||||
|
def test_target_created_during_publish_is_preserved_and_staging_is_removed(self) -> None:
|
||||||
|
with TemporaryDirectory() as temporary:
|
||||||
|
raw = _write_raw(Path(temporary))
|
||||||
|
target = raw.parent / "derived"
|
||||||
|
|
||||||
|
def race_rename(destination: Path) -> None:
|
||||||
|
destination.mkdir()
|
||||||
|
(destination / "sentinel.txt").write_text("keep", encoding="utf-8")
|
||||||
|
raise FileExistsError("simulated publish race")
|
||||||
|
|
||||||
|
with patch("cmbuyer_client.device.sku_evidence_sanitizer.Path.rename", side_effect=race_rename):
|
||||||
|
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||||
|
sanitize_sku_panel_evidence(raw, target)
|
||||||
|
|
||||||
|
self.assertEqual((target / "sentinel.txt").read_text(encoding="utf-8"), "keep")
|
||||||
|
self.assertEqual(list(raw.parent.glob(".derived.staging-*")), [])
|
||||||
|
|
||||||
|
def test_existing_derived_is_preserved_without_reading_or_writing_raw(self) -> None:
|
||||||
|
with TemporaryDirectory() as temporary:
|
||||||
|
raw = _write_raw(Path(temporary))
|
||||||
|
target = raw.parent / "derived"
|
||||||
|
target.mkdir()
|
||||||
|
sentinel = target / "sentinel.txt"
|
||||||
|
sentinel.write_text("keep", encoding="utf-8")
|
||||||
|
|
||||||
|
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||||
|
sanitize_sku_panel_evidence(raw, target)
|
||||||
|
|
||||||
|
self.assertEqual(sentinel.read_text(encoding="utf-8"), "keep")
|
||||||
|
self.assertEqual(list(raw.parent.glob(".derived.staging-*")), [])
|
||||||
|
|
||||||
|
def test_directory_contract_rejects_non_sibling_paths(self) -> None:
|
||||||
|
with TemporaryDirectory() as temporary:
|
||||||
|
root = Path(temporary)
|
||||||
|
raw = _write_raw(root)
|
||||||
|
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||||
|
sanitize_sku_panel_evidence(raw, root / "not-derived")
|
||||||
|
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||||
|
sanitize_sku_panel_evidence(root / "not-raw", root / "derived")
|
||||||
@@ -124,14 +124,16 @@ class SkuPanelEvidenceTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_invalid_state_and_url_fail_before_device_access(self) -> None:
|
def test_invalid_state_and_url_fail_before_device_access(self) -> None:
|
||||||
adb = FakeAdbClient()
|
adb = FakeAdbClient()
|
||||||
|
rejected_states = ("initial", "one-dimension-selected", "all-dimensions-selected", "guessed")
|
||||||
with TemporaryDirectory() as temporary:
|
with TemporaryDirectory() as temporary:
|
||||||
with self.assertRaises(SkuPanelDeclaredStateError):
|
for state in rejected_states:
|
||||||
self._capturer(adb, FakeUiDevice()).capture(SERIAL, URL, "guessed", Path(temporary) / "state")
|
with self.subTest(state=state), self.assertRaises(SkuPanelDeclaredStateError):
|
||||||
|
self._capturer(adb, FakeUiDevice()).capture(SERIAL, URL, state, Path(temporary) / "state")
|
||||||
with self.assertRaises(ProductUrlError):
|
with self.assertRaises(ProductUrlError):
|
||||||
self._capturer(adb, FakeUiDevice()).capture(
|
self._capturer(adb, FakeUiDevice()).capture(
|
||||||
SERIAL,
|
SERIAL,
|
||||||
"https://mobile.yangkeduo.com/goods.html?goods_id=12x",
|
"https://mobile.yangkeduo.com/goods.html?goods_id=12x",
|
||||||
"initial",
|
"panel-opened-target-preselected",
|
||||||
Path(temporary) / "url",
|
Path(temporary) / "url",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -146,7 +148,7 @@ class SkuPanelEvidenceTests(unittest.TestCase):
|
|||||||
with self.subTest(error_type=error_type.__name__), TemporaryDirectory() as temporary:
|
with self.subTest(error_type=error_type.__name__), TemporaryDirectory() as temporary:
|
||||||
target = Path(temporary) / "evidence"
|
target = Path(temporary) / "evidence"
|
||||||
with self.assertRaises(error_type):
|
with self.assertRaises(error_type):
|
||||||
self._capturer(FakeAdbClient(), device).capture(SERIAL, URL, "initial", target)
|
self._capturer(FakeAdbClient(), device).capture(SERIAL, URL, "panel-opened-target-preselected", target)
|
||||||
|
|
||||||
self.assertEqual(device.calls, expected_calls)
|
self.assertEqual(device.calls, expected_calls)
|
||||||
self.assertFalse(target.exists())
|
self.assertFalse(target.exists())
|
||||||
@@ -160,7 +162,7 @@ class SkuPanelEvidenceTests(unittest.TestCase):
|
|||||||
with self.subTest(error_type=error_type.__name__), TemporaryDirectory() as temporary:
|
with self.subTest(error_type=error_type.__name__), TemporaryDirectory() as temporary:
|
||||||
target = Path(temporary) / "evidence"
|
target = Path(temporary) / "evidence"
|
||||||
with self.assertRaises(error_type):
|
with self.assertRaises(error_type):
|
||||||
self._capturer(FakeAdbClient(), device).capture(SERIAL, URL, "initial", target)
|
self._capturer(FakeAdbClient(), device).capture(SERIAL, URL, "panel-opened-target-preselected", target)
|
||||||
|
|
||||||
self.assertFalse(target.exists())
|
self.assertFalse(target.exists())
|
||||||
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
|
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
|
||||||
@@ -176,7 +178,7 @@ class SkuPanelEvidenceTests(unittest.TestCase):
|
|||||||
with TemporaryDirectory() as temporary:
|
with TemporaryDirectory() as temporary:
|
||||||
target = Path(temporary) / "evidence"
|
target = Path(temporary) / "evidence"
|
||||||
with self.assertRaises(SkuPanelEvidenceTimeoutError) as raised:
|
with self.assertRaises(SkuPanelEvidenceTimeoutError) as raised:
|
||||||
self._capturer(FakeAdbClient(), TimeoutScreenshotDevice()).capture(SERIAL, URL, "initial", target)
|
self._capturer(FakeAdbClient(), TimeoutScreenshotDevice()).capture(SERIAL, URL, "panel-opened-target-preselected", target)
|
||||||
|
|
||||||
self.assertNotIn(SERIAL, str(raised.exception))
|
self.assertNotIn(SERIAL, str(raised.exception))
|
||||||
self.assertNotIn("adb", str(raised.exception).lower())
|
self.assertNotIn("adb", str(raised.exception).lower())
|
||||||
@@ -193,7 +195,7 @@ class SkuPanelEvidenceTests(unittest.TestCase):
|
|||||||
sentinel.write_text("keep", encoding="utf-8")
|
sentinel.write_text("keep", encoding="utf-8")
|
||||||
|
|
||||||
with self.assertRaises(SkuPanelEvidenceError):
|
with self.assertRaises(SkuPanelEvidenceError):
|
||||||
self._capturer(adb, device).capture(SERIAL, URL, "initial", target)
|
self._capturer(adb, device).capture(SERIAL, URL, "panel-opened-target-preselected", target)
|
||||||
|
|
||||||
self.assertEqual(adb.calls, [])
|
self.assertEqual(adb.calls, [])
|
||||||
self.assertEqual(device.calls, [])
|
self.assertEqual(device.calls, [])
|
||||||
@@ -224,7 +226,7 @@ class SkuPanelSpikeCliTests(unittest.TestCase):
|
|||||||
"serial": SERIAL,
|
"serial": SERIAL,
|
||||||
"url": URL,
|
"url": URL,
|
||||||
"goods_id": None,
|
"goods_id": None,
|
||||||
"state": "initial",
|
"state": "panel-opened-target-preselected",
|
||||||
"output_dir": Path("evidence"),
|
"output_dir": Path("evidence"),
|
||||||
"timeout": 10.0,
|
"timeout": 10.0,
|
||||||
"adb": "adb",
|
"adb": "adb",
|
||||||
@@ -234,6 +236,9 @@ class SkuPanelSpikeCliTests(unittest.TestCase):
|
|||||||
("timeout", 0),
|
("timeout", 0),
|
||||||
("timeout", float("inf")),
|
("timeout", float("inf")),
|
||||||
("state", "not-declared"),
|
("state", "not-declared"),
|
||||||
|
("state", "initial"),
|
||||||
|
("state", "one-dimension-selected"),
|
||||||
|
("state", "all-dimensions-selected"),
|
||||||
("url", "https://mobile.yangkeduo.com/goods.html?goods_id=bad"),
|
("url", "https://mobile.yangkeduo.com/goods.html?goods_id=bad"),
|
||||||
)
|
)
|
||||||
for field, value in invalid_values:
|
for field, value in invalid_values:
|
||||||
@@ -248,7 +253,7 @@ class SkuPanelSpikeCliTests(unittest.TestCase):
|
|||||||
serial=SERIAL,
|
serial=SERIAL,
|
||||||
url=None,
|
url=None,
|
||||||
goods_id="00123",
|
goods_id="00123",
|
||||||
state="all-dimensions-selected",
|
state="target-selection-restored",
|
||||||
output_dir=Path("evidence"),
|
output_dir=Path("evidence"),
|
||||||
timeout=10.0,
|
timeout=10.0,
|
||||||
adb="adb",
|
adb="adb",
|
||||||
|
|||||||
@@ -85,22 +85,24 @@ cmbuyer 是一个自动化采购系统:**采购服务**(网页端,`admin/`
|
|||||||
|
|
||||||
## 当前阶段
|
## 当前阶段
|
||||||
|
|
||||||
**Phase 0 · 地基。** 采购服务与采购工具骨架均已初始化,尚无采购业务代码。
|
**Phase 1 · 真机可行性,并行启动采购服务基础能力。** 两端骨架与核心数据模型已完成,T-103
|
||||||
|
仍是当前真机关键路径;与真机可读字段无关的采购服务能力不再空等。
|
||||||
|
|
||||||
执行按任务依赖驱动,**不按 Phase 整段串行等待**。当前优先路径:
|
执行按任务依赖驱动,**不按 Phase 整段串行等待**。当前优先路径:
|
||||||
|
|
||||||
1. T-003 统一入口与 T-004 核心数据模型已完成;T-101 真机环境盘点已就绪,但尚未真机验收。
|
1. T-001~T-004 与 T-101~T-102 已完成,继续推进 **T-103 真机取证**。
|
||||||
2. T-002 已完成,立即推进 **T-101 → T-102 → T-103 真机取证**。
|
2. T-103 进行时并行推进 T-201 管理会话 → T-202 `DRAFT` 建单与基础列表;两者不得启动试选,
|
||||||
3. T-103 结论确认后,才开始依赖真机可读字段的 Phase 2 生产页面;采购服务核心与
|
也不得引入机器结果、规格面板单价或证据字段。
|
||||||
T-104 → T-107 后续真机安全判据按依赖并行推进。
|
3. T-103 结论确认后,再推进 T-203 批量开始试选、T-204 试选证据详情、T-205~T-207,
|
||||||
|
并按依赖推进 T-104 → T-107 后续真机安全判据。
|
||||||
4. Phase 3:双端打通与**第一趟试选**端到端。
|
4. Phase 3:双端打通与**第一趟试选**端到端。
|
||||||
5. Phase 4:**第二趟下单**与收尾。
|
5. Phase 4:**第二趟下单**与收尾。
|
||||||
6. V2 及以后:图搜、Excel、ERP、订单自动核对、AI 辅助。
|
6. V2 及以后:图搜、Excel、ERP、订单自动核对、AI 辅助。
|
||||||
|
|
||||||
> **M2 是本项目的生死线**:真机能按链接打开商品、精确勾选颜色分类和尺码、
|
> **M2 是本项目的生死线**:真机能按链接打开商品、精确勾选颜色分类和尺码、
|
||||||
> **读到该 SKU 的单价**(T-103)。前序项目正是卡在选规格和读价。
|
> **读到该 SKU 的单价**(T-103)。前序项目正是卡在选规格和读价。
|
||||||
> M2 不通过之前不要写 Phase 2 的生产页面;Phase 0 原型只确认流程与信息架构,实际可读
|
> M2 不通过之前不要写**依赖真机可读字段或会启动试选**的 Phase 2 功能;T-201 与仅创建
|
||||||
> 字段仍以真机证据为准。
|
> `DRAFT` 的 T-202 可以并行。原型只确认流程与信息架构,实际可读字段仍以真机证据为准。
|
||||||
|
|
||||||
## 领取任务规则
|
## 领取任务规则
|
||||||
|
|
||||||
|
|||||||
+34
-13
@@ -129,30 +129,51 @@ D:\Portable\adb\adb.exe devices -l
|
|||||||
|
|
||||||
`client/scripts/capture_sku_panel_spike.py` 只采集人已在手机上准备好的规格面板截图和完整 XML。
|
`client/scripts/capture_sku_panel_spike.py` 只采集人已在手机上准备好的规格面板截图和完整 XML。
|
||||||
它不打开链接或规格面板,不识别面板,不点击、滑动、输入或选择规格,也不读取价格;接口只暴露
|
它不打开链接或规格面板,不识别面板,不点击、滑动、输入或选择规格,也不读取价格;接口只暴露
|
||||||
`app_info`、`app_current` 和两个只读 JSON-RPC 方法。`initial`、`one-dimension-selected`、
|
`app_info`、`app_current` 和两个只读 JSON-RPC 方法。`panel-opened-target-preselected`、
|
||||||
`all-dimensions-selected` 三种值写入 manifest 的字段名是 `human_declared_state`,明确表示人工声明,
|
`alternate-all-dimensions-selected`、`target-selection-restored` 三种值写入 manifest 的字段名是
|
||||||
不得将其当作自动识别结果。运行拼多多版本必须精确为 `8.17.0`,前台 package 必须是拼多多,否则
|
`human_declared_state`,明确表示人工声明,不得将其当作自动识别结果。旧的“未选 / 单维度 / 全选”
|
||||||
fail closed;目标目录已存在、截图/XML 无效或超时均不得覆盖已有内容或发布半成品。
|
状态假设已被真机事实推翻,旧枚举会被采集器和脱敏器明确拒绝。运行拼多多版本必须精确为 `8.17.0`,
|
||||||
|
前台 package 必须是拼多多,否则 fail closed;目标目录已存在、截图/XML 无效或超时均不得覆盖已有内容
|
||||||
|
或发布半成品。
|
||||||
|
|
||||||
T-103 已证明当前衣服商品没有独立「规格/已选」入口。T-110 只批准拼多多 `8.17.0`、goods_id
|
T-103 已证明当前衣服商品没有独立「规格/已选」入口。T-110 只批准拼多多 `8.17.0`、goods_id
|
||||||
`937122477375` 上经真机确认的精确唯一 `快要抢光` 作为可逆的受控规格面板入口;“免拼购买 / 单独购买 /
|
`937122477375` 上经真机确认的精确唯一 `快要抢光` 作为可逆的受控规格面板入口;“免拼购买 / 单独购买 /
|
||||||
直接拼成”等其他文案不能凭人工经验复用,必须分别重新取证。只读取证 CLI 本身仍不执行点击;三种状态
|
直接拼成”等其他文案不能凭人工经验复用,必须分别重新取证。项目所有者已确认面板刚打开时目标颜色
|
||||||
|
“黑色CHA(纯棉)”和尺码“M(建议100-115)”均已自动选中;取证不强行取消选择,而是记录刚打开状态、
|
||||||
|
人工把两个维度都改成非目标值、再恢复目标值三个真实状态。只读取证 CLI 本身仍不执行点击;三种状态
|
||||||
分别使用一个全新原始证据目录:
|
分别使用一个全新原始证据目录:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
.\client\.venv\Scripts\python.exe client\scripts\capture_sku_panel_spike.py --serial <SERIAL> --url "https://mobile.yangkeduo.com/goods.html?goods_id=<GOODS_ID>" --state initial --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-initial-<GOODS_ID>\raw" --timeout 10 --adb D:\Portable\adb\adb.exe
|
.\client\.venv\Scripts\python.exe client\scripts\capture_sku_panel_spike.py --serial <SERIAL> --url "https://mobile.yangkeduo.com/goods.html?goods_id=<GOODS_ID>" --state panel-opened-target-preselected --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-opened-target-<GOODS_ID>-v2\raw" --timeout 10 --adb D:\Portable\adb\adb.exe
|
||||||
.\client\.venv\Scripts\python.exe client\scripts\capture_sku_panel_spike.py --serial <SERIAL> --url "https://mobile.yangkeduo.com/goods.html?goods_id=<GOODS_ID>" --state one-dimension-selected --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-one-dimension-<GOODS_ID>\raw" --timeout 10 --adb D:\Portable\adb\adb.exe
|
.\client\.venv\Scripts\python.exe client\scripts\capture_sku_panel_spike.py --serial <SERIAL> --url "https://mobile.yangkeduo.com/goods.html?goods_id=<GOODS_ID>" --state alternate-all-dimensions-selected --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-alternate-<GOODS_ID>-v2\raw" --timeout 10 --adb D:\Portable\adb\adb.exe
|
||||||
.\client\.venv\Scripts\python.exe client\scripts\capture_sku_panel_spike.py --serial <SERIAL> --url "https://mobile.yangkeduo.com/goods.html?goods_id=<GOODS_ID>" --state all-dimensions-selected --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-all-dimensions-<GOODS_ID>\raw" --timeout 10 --adb D:\Portable\adb\adb.exe
|
.\client\.venv\Scripts\python.exe client\scripts\capture_sku_panel_spike.py --serial <SERIAL> --url "https://mobile.yangkeduo.com/goods.html?goods_id=<GOODS_ID>" --state target-selection-restored --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-target-restored-<GOODS_ID>-v2\raw" --timeout 10 --adb D:\Portable\adb\adb.exe
|
||||||
```
|
```
|
||||||
|
|
||||||
PDD 规格面板不可避免显示收货区域和掩码手机号。原始截图/XML 只能留在
|
PDD 规格面板不可避免显示收货区域和掩码手机号。原始截图/XML 只能留在
|
||||||
`%LOCALAPPDATA%\cmbuyer\artifacts\...\raw` 隔离目录,不供 agent、fixture、业务或 HTTP 上传读取;
|
`%LOCALAPPDATA%\cmbuyer\artifacts\...\raw` 隔离目录,不供 agent、fixture、业务或 HTTP 上传读取;
|
||||||
T-103 必须实现本机确定性脱敏器,把隐私区域从 screenshot/XML 同时移除并在 sibling `derived` 目录
|
T-103 已在 `client/src/cmbuyer_client/device/sku_evidence_sanitizer.py` 实现本机确定性脱敏器,并由
|
||||||
原子发布。脱敏器必须绑定 PDD 版本、设备分辨率与页面结构,派生 XML 仍命中手机号模式、隐私区域无法
|
`client/scripts/sanitize_sku_panel_evidence.py` 提供离线 CLI。第一组新 raw 的 PNG 头部由人确认实际为
|
||||||
确认、版本/分辨率/结构失配或哈希不一致时拒绝发布。只有派生 manifest 和人工状态确认完成后,agent
|
1080×2376,推翻了 v1 将截图和 XML 坐标空间都写成 1080×2400 的假设;v1 正确拒绝且原始证据未重采。
|
||||||
才能从派生物提取最小 fixture、编写判据。
|
随后 v2 在同一份 raw 上安全报告 XML `observed 1080x2376` 并拒绝发布,证明其独立配置的
|
||||||
|
1080×2400 假设同样不成立。当前 `t103-privacy-v3` 配置精确绑定 PKG110 / Android 16 / 拼多多
|
||||||
|
8.17.0 / goods_id `937122477375`;screenshot space 与 XML coordinate space 仍分别建模、分别校验,
|
||||||
|
当前都精确为 1080×2376,且都把
|
||||||
|
`[0,0,1080,540)` 作为整宽隐私带。截图覆盖该区域;XML 递归移除区域内节点,跨界容器只清空自身
|
||||||
|
敏感属性并保留下方子节点。地址依靠已确认的整块几何隔离而不是易漏的关键词表;完整、掩码、带分隔符
|
||||||
|
或跨节点手机号残留会被自动复检拒绝。
|
||||||
|
|
||||||
2026-08-04 的首轮证据只用于确认上述入口事实;其中 `initial` 不是规格面板,另两张原始截图含隐私
|
```powershell
|
||||||
|
.\client\.venv\Scripts\python.exe client\scripts\sanitize_sku_panel_evidence.py --raw-dir "<证据目录>\raw" --output-dir "<证据目录>\derived"
|
||||||
|
```
|
||||||
|
|
||||||
|
脱敏器校验源 manifest 与文件哈希、设备/App/商品/人工状态、截图分辨率和 XML 隐私结构;XML 所有
|
||||||
|
节点观察到的最大 right/bottom 必须精确为 1080×2376,否则只报告非敏感 observed 尺寸并拒绝。它只允许发布到
|
||||||
|
同级且尚不存在的 `derived`;失败或发布竞态不覆盖已有目录、不留下 staging。派生 manifest 记录
|
||||||
|
`privacy_tier=SANITIZED`、sanitizer 版本、两个坐标空间、清理计数及本机 source/derived 哈希,不记录
|
||||||
|
原始路径、serial 或页面正文。只有自动复检通过并经人确认状态对应性的派生物,agent 才能读取并提取
|
||||||
|
最小 fixture、编写判据。
|
||||||
|
|
||||||
|
2026-08-04 的首轮旧证据只用于确认上述入口事实;其中旧 `initial` 不是规格面板,另两张原始截图含隐私
|
||||||
区域,因此 XML 未读取、fixture 与选择器未生成。T-110 完成受控入口与脱敏契约后,T-103 重新取证;
|
区域,因此 XML 未读取、fixture 与选择器未生成。T-110 完成受控入口与脱敏契约后,T-103 重新取证;
|
||||||
该边界调整不授权第一趟调整数量、进入确认页、点击“提交订单”或触碰任何支付控件。
|
该边界调整不授权第一趟调整数量、进入确认页、点击“提交订单”或触碰任何支付控件。
|
||||||
|
|
||||||
|
|||||||
@@ -96,8 +96,10 @@
|
|||||||
- V2 / V3 只记录,不实现。**图搜、Excel、ERP、订单自动核对、AI 辅助全部不在 MVP。**
|
- V2 / V3 只记录,不实现。**图搜、Excel、ERP、订单自动核对、AI 辅助全部不在 MVP。**
|
||||||
- 需求明确排除的非目标不得实现。
|
- 需求明确排除的非目标不得实现。
|
||||||
- 不为「将来可能用到」提前抽象。
|
- 不为「将来可能用到」提前抽象。
|
||||||
- **Phase 1 真机结论出来之前不写 Phase 2 的页面**——确认页显示什么,取决于真机上
|
- **Phase 1 真机结论出来之前,不写依赖真机可读字段或会启动试选的 Phase 2 功能。** T-201
|
||||||
能读到什么,尤其是规格面板上的单价。
|
管理会话与只创建 `DRAFT` 的 T-202 基础建单 / 列表可以并行;T-203 的批量开始试选、T-204
|
||||||
|
的试选证据详情以及 T-205 以后仍等待 T-103。T-201 / T-202 不得夹带机器实际规格、规格面板
|
||||||
|
单价、证据、授权、提交或付款字段。
|
||||||
|
|
||||||
## 5. 架构纪律
|
## 5. 架构纪律
|
||||||
|
|
||||||
|
|||||||
+9
-6
@@ -10,8 +10,9 @@
|
|||||||
把验收要点展开成可执行、可观察的步骤,再开始实现。
|
把验收要点展开成可执行、可观察的步骤,再开始实现。
|
||||||
2. **每个 agent 一次只做一个任务**:领取、状态流转、执行记录、完成定义遵循
|
2. **每个 agent 一次只做一个任务**:领取、状态流转、执行记录、完成定义遵循
|
||||||
[`tasks/README.md`](tasks/README.md) 和[编码规则](05-coding-rules.md)。
|
[`tasks/README.md`](tasks/README.md) 和[编码规则](05-coding-rules.md)。
|
||||||
3. **不跳步**:依赖未完成的任务不能开工。Phase 0 可先做使用假数据的低保真交互原型;
|
3. **不跳步**:依赖未完成的任务不能开工。T-103 完成前可并行 T-201 管理会话与只创建 `DRAFT`
|
||||||
**Phase 1 的真机结论出来之前不写 Phase 2 的生产页面**,T-103 若改变可读字段则先修订原型与 IX。
|
的 T-202 基础建单 / 列表;**依赖真机可读字段或会启动试选的 Phase 2 功能仍不得抢跑**,
|
||||||
|
T-103 若改变可读字段则先修订原型与 IX。
|
||||||
4. **本文只在规划变化时修改**:单个任务开工或完成**不**修改本文。
|
4. **本文只在规划变化时修改**:单个任务开工或完成**不**修改本文。
|
||||||
5. **动手前**先读 `00-ai-start-here.md`、`05-coding-rules.md` 和 `current-state.md`。
|
5. **动手前**先读 `00-ai-start-here.md`、`05-coding-rules.md` 和 `current-state.md`。
|
||||||
|
|
||||||
@@ -24,14 +25,15 @@
|
|||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| 1 | T-001 初始化 `admin/` | T-002 初始化 `client/` | - |
|
| 1 | T-001 初始化 `admin/` | T-002 初始化 `client/` | - |
|
||||||
| 2 | T-001 后立即做 T-004 | T-002 后立即做 T-101 → T-102 → **T-103** | T-001、T-002 都完成后做 T-003 |
|
| 2 | T-001 后立即做 T-004 | T-002 后立即做 T-101 → T-102 → **T-103** | T-001、T-002 都完成后做 T-003 |
|
||||||
| 3 | T-103 通过后推进 T-201 → T-207 | T-103 后推进 T-104 → T-107 | T-208 等待 T-207 与 T-107 均完成 |
|
| 3 | 与 T-103 并行做 T-201 → T-202;T-103 后做 T-203 → T-207 | T-103 后推进 T-104 → T-107 | T-208 等待 T-207 与 T-107 均完成 |
|
||||||
| 4 | T-301 → T-302 | T-302 后交接 T-303 → T-304 → T-306 | T-306 与 T-104 完成后做 T-305 |
|
| 4 | T-301 → T-302 | T-302 后交接 T-303 → T-304 → T-306 | T-306 与 T-104 完成后做 T-305 |
|
||||||
| 5 | - | T-305、T-208、T-306 后做 T-401 | T-401 后并行 T-402 / T-403,再做 T-404 → T-405 |
|
| 5 | - | T-305、T-208、T-306 后做 T-401 | T-401 后并行 T-402 / T-403,再做 T-404 → T-405 |
|
||||||
|
|
||||||
**T-103 是当前最高优先级和 MVP 生死线。** T-002 一完成就启动 T-101,不等待 T-003、
|
**T-103 是当前最高优先级和 MVP 生死线。** T-002 一完成就启动 T-101,不等待 T-003、
|
||||||
T-004 或整个 Phase 0 收尾。并行只优化等待关系,不改变下列门禁:
|
T-004 或整个 Phase 0 收尾。并行只优化等待关系,不改变下列门禁:
|
||||||
|
|
||||||
- T-103 的真机结论出来前,不写依赖真机可读字段的 Phase 2 生产页面。
|
- T-103 的真机结论出来前,T-201 / T-202 只能落管理会话、`DRAFT` 建单与基础列表;不启动试选,
|
||||||
|
不展示机器规格、规格面板单价或证据。T-203~T-207 继续等待 T-103。
|
||||||
- `needs_device: true` 的任务仍只能由人完成验收。
|
- `needs_device: true` 的任务仍只能由人完成验收。
|
||||||
- 不复用前序项目页面判据,不放宽三道价格闸门,不让第一趟引用任何下单函数。
|
- 不复用前序项目页面判据,不放宽三道价格闸门,不让第一趟引用任何下单函数。
|
||||||
- 第三个 agent 优先做写路径独立的集成任务或只读复核,不与两端任务争写共享文档。
|
- 第三个 agent 优先做写路径独立的集成任务或只读复核,不与两端任务争写共享文档。
|
||||||
@@ -126,8 +128,9 @@ T-004 或整个 Phase 0 收尾。并行只优化等待关系,不改变下列
|
|||||||
- **M5**:第一趟试选端到端跑通,任务能停在「等你确认」。(T-305)
|
- **M5**:第一趟试选端到端跑通,任务能停在「等你确认」。(T-305)
|
||||||
- **M6**:MVP 闭环——第二趟下单成功,任务停在「待付款」。(T-401)
|
- **M6**:MVP 闭环——第二趟下单成功,任务停在「待付款」。(T-401)
|
||||||
|
|
||||||
**M2 是本项目的生死线。** 前序项目正是卡在选规格和读价;M2 不通过之前不要写 Phase 2
|
**M2 是本项目的生死线。** 前序项目正是卡在选规格和读价;M2 不通过之前只允许 T-201 与
|
||||||
的生产页面。Phase 0 原型只用于确认信息架构,T-103 若改变可读字段必须先回修原型与 IX。
|
不启动试选的 T-202,不写依赖真机字段或会推进任务执行的 Phase 2 功能。原型只用于确认信息架构,
|
||||||
|
T-103 若改变可读字段必须先回修原型与 IX。
|
||||||
|
|
||||||
## 待办池(Backlog)
|
## 待办池(Backlog)
|
||||||
|
|
||||||
|
|||||||
+29
-13
@@ -19,9 +19,9 @@
|
|||||||
详见 [`03-tech-stack.md`](03-tech-stack.md)
|
详见 [`03-tech-stack.md`](03-tech-stack.md)
|
||||||
- 生产代码:`admin/` 已有最小 Go 服务、健康检查、核心领域模型、SQLite 迁移与任务状态机;
|
- 生产代码:`admin/` 已有最小 Go 服务、健康检查、核心领域模型、SQLite 迁移与任务状态机;
|
||||||
`client/` 已有 Python 包、PySide6 最小入口、运行目录与日志脱敏策略,以及显式 serial 的 ADB
|
`client/` 已有 Python 包、PySide6 最小入口、运行目录与日志脱敏策略,以及显式 serial 的 ADB
|
||||||
连接边界、本地基线取证 CLI、受限商品链接打开取证 CLI 和人工声明规格面板状态的只读取证 CLI;
|
连接边界、本地基线取证 CLI、受限商品链接打开取证 CLI、人工声明规格面板状态的只读取证 CLI,
|
||||||
尚无规格选择、价格读取或下单流程
|
以及绑定 PKG110 / Android 16 / 拼多多 8.17.0 的规格证据确定性脱敏 CLI;尚无规格选择、价格读取或下单流程
|
||||||
- 测试:采购服务已覆盖健康检查、核心模型、迁移与状态机等离线包级测试;采购工具 55 项离线单元测试
|
- 测试:采购服务已覆盖健康检查、核心模型、迁移与状态机等离线包级测试;采购工具 69 项离线单元测试
|
||||||
(全部 mock,不连接真机)
|
(全部 mock,不连接真机)
|
||||||
- 数据:SQLite 核心表与迁移已落成;无业务实例数据
|
- 数据:SQLite 核心表与迁移已落成;无业务实例数据
|
||||||
- 标准启动路径:Windows PowerShell 运行 `./init.ps1`,Unix shell 运行 `./init.sh`。Windows 入口
|
- 标准启动路径:Windows PowerShell 运行 `./init.ps1`,Unix shell 运行 `./init.sh`。Windows 入口
|
||||||
@@ -31,8 +31,11 @@
|
|||||||
editable install / 包导入 / unittest / compileall,以及仓库上下文校验。可单独运行两端命令诊断。
|
editable install / 包导入 / unittest / compileall,以及仓库上下文校验。可单独运行两端命令诊断。
|
||||||
- 当前设备门禁:人工已确认拼多多 8.17.0、goods_id `937122477375` 的衣服商品只能通过“快要抢光”
|
- 当前设备门禁:人工已确认拼多多 8.17.0、goods_id `937122477375` 的衣服商品只能通过“快要抢光”
|
||||||
打开规格面板;T-110 已获项目所有者批准,只把该证据/版本绑定的精确唯一入口作为第一趟可逆导航,
|
打开规格面板;T-110 已获项目所有者批准,只把该证据/版本绑定的精确唯一入口作为第一趟可逆导航,
|
||||||
数量、确认页、提交订单、付款与通用点击能力仍不可达。T-103 下一步先实现原始证据本机隔离和自动
|
数量、确认页、提交订单、付款与通用点击能力仍不可达。T-103 的原始证据本机隔离与确定性脱敏器
|
||||||
脱敏派生物,再重新采集三态证据;在此之前不写页面判据,Phase 2 仍不能抢跑。
|
已完成离线实现;人工确认面板刚打开时目标颜色和尺码已经自动选中,取证三态已据此修正。下一步
|
||||||
|
用 v3 对已采第一态 raw 重新脱敏;截图与 XML 已分别安全观测为 1080×2376,仍作为两个独立坐标
|
||||||
|
空间严格校验。第一态 derived 经人验收后,再采集“两个维度改为非目标 / 两个维度恢复目标”的 raw,
|
||||||
|
并由人只核对 derived。在派生证据验收前不写页面判据,Phase 2 仍不能抢跑。
|
||||||
|
|
||||||
## 当前目录要点
|
## 当前目录要点
|
||||||
|
|
||||||
@@ -43,7 +46,7 @@
|
|||||||
| `docs/design/` | 已有(6 个原型) | web 登录 / 建单 / 工作台 / 详情,desk 采购执行 / 配置;均已人工确认 |
|
| `docs/design/` | 已有(6 个原型) | web 登录 / 建单 / 工作台 / 详情,desk 采购执行 / 配置;均已人工确认 |
|
||||||
| `scripts/` | 已有 | 上下文门禁、Vikunja 单向导出与 MCP 启动包装 |
|
| `scripts/` | 已有 | 上下文门禁、Vikunja 单向导出与 MCP 启动包装 |
|
||||||
| `admin/` | 已初始化 | Go 1.23+ / gin / SQLite,含核心模型、迁移与状态机;无真机采购执行 |
|
| `admin/` | 已初始化 | Go 1.23+ / gin / SQLite,含核心模型、迁移与状态机;无真机采购执行 |
|
||||||
| `client/` | 已初始化 | Python 3.11+ 包、依赖源、PySide6 最小入口、显式 serial 的基线/商品打开/规格面板只读取证、离线测试与 wheel 元数据检查;无规格选择、价格读取或下单流程 |
|
| `client/` | 已初始化 | Python 3.11+ 包、依赖源、PySide6 最小入口、显式 serial 的基线/商品打开/规格面板只读取证、确定性证据脱敏、离线测试与 wheel 元数据检查;无规格选择、价格读取或下单流程 |
|
||||||
| `init.ps1` / `init.sh` | 已完成 | 统一安装与离线验证入口;PowerShell 优先复用合规 venv,缺失时自动选择最高的 Python 3.11+,Unix 缺工具链明确失败 |
|
| `init.ps1` / `init.sh` | 已完成 | 统一安装与离线验证入口;PowerShell 优先复用合规 venv,缺失时自动选择最高的 Python 3.11+,Unix 缺工具链明确失败 |
|
||||||
|
|
||||||
## 任务状态
|
## 任务状态
|
||||||
@@ -57,7 +60,7 @@
|
|||||||
T-004(核心数据模型)、T-101(真机环境盘点与 USB/WiFi 双通道人工验收)、T-102(canonical
|
T-004(核心数据模型)、T-101(真机环境盘点与 USB/WiFi 双通道人工验收)、T-102(canonical
|
||||||
链接打开与目标商品/隐私人工验收)。
|
链接打开与目标商品/隐私人工验收)。
|
||||||
- 已完成 T-110(第一趟受控规格入口与隐私脱敏边界)。T-103 是当前最高优先级和 MVP 生死线,
|
- 已完成 T-110(第一趟受控规格入口与隐私脱敏边界)。T-103 是当前最高优先级和 MVP 生死线,
|
||||||
已补充 T-110 依赖并恢复 `DOING`;自动脱敏派生证据和新真机验收完成前不开发依赖真机可读字段的
|
已补充 T-110 依赖并恢复 `DOING`;脱敏器已完成,三态派生证据和新真机验收完成前不开发依赖真机可读字段的
|
||||||
Phase 2 生产页面。
|
Phase 2 生产页面。
|
||||||
- 已确认原型继续只作信息架构依据;原型假数据不调用真实接口、不驱动真机。真机结论改变
|
- 已确认原型继续只作信息架构依据;原型假数据不调用真实接口、不驱动真机。真机结论改变
|
||||||
可读字段时必须先回修原型与交互清单。
|
可读字段时必须先回修原型与交互清单。
|
||||||
@@ -139,19 +142,32 @@ D:\Portable\adb\adb.exe devices -l
|
|||||||
|
|
||||||
T-103 已确认当前衣服商品只能从精确文案“快要抢光”进入规格面板;T-110 只批准该证据/版本绑定入口,
|
T-103 已确认当前衣服商品只能从精确文案“快要抢光”进入规格面板;T-110 只批准该证据/版本绑定入口,
|
||||||
不授权“免拼购买 / 单独购买 / 直接拼成”等其他文案。现有只读取证脚本本身仍不打开面板、不点击或选择
|
不授权“免拼购买 / 单独购买 / 直接拼成”等其他文案。现有只读取证脚本本身仍不打开面板、不点击或选择
|
||||||
规格,也不读取价格;`--state` 只是人工声明,不能作为自动判据。T-103 将先把以下原始目录接入本机
|
规格,也不读取价格;`--state` 只是人工声明,不能作为自动判据。面板刚打开时目标颜色
|
||||||
确定性脱敏器,再使用派生证据:
|
“黑色CHA(纯棉)”与尺码“M(建议100-115)”已经自动选中,不强行取消。人依次准备刚打开目标预选、
|
||||||
|
颜色和尺码均为非目标值、再恢复目标值三个状态,并分别采集到全新 `raw` 目录:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# 仓库根目录;三次采集使用三个全新的 output-dir
|
# 仓库根目录;三次采集使用三个全新的 output-dir
|
||||||
.\client\.venv\Scripts\python.exe client\scripts\capture_sku_panel_spike.py --serial <SERIAL> --url "https://mobile.yangkeduo.com/goods.html?goods_id=<GOODS_ID>" --state initial --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-initial-<GOODS_ID>\raw" --timeout 10 --adb D:\Portable\adb\adb.exe
|
.\client\.venv\Scripts\python.exe client\scripts\capture_sku_panel_spike.py --serial <SERIAL> --url "https://mobile.yangkeduo.com/goods.html?goods_id=<GOODS_ID>" --state panel-opened-target-preselected --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-opened-target-<GOODS_ID>-v2\raw" --timeout 10 --adb D:\Portable\adb\adb.exe
|
||||||
.\client\.venv\Scripts\python.exe client\scripts\capture_sku_panel_spike.py --serial <SERIAL> --url "https://mobile.yangkeduo.com/goods.html?goods_id=<GOODS_ID>" --state one-dimension-selected --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-one-dimension-<GOODS_ID>\raw" --timeout 10 --adb D:\Portable\adb\adb.exe
|
.\client\.venv\Scripts\python.exe client\scripts\capture_sku_panel_spike.py --serial <SERIAL> --url "https://mobile.yangkeduo.com/goods.html?goods_id=<GOODS_ID>" --state alternate-all-dimensions-selected --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-alternate-<GOODS_ID>-v2\raw" --timeout 10 --adb D:\Portable\adb\adb.exe
|
||||||
.\client\.venv\Scripts\python.exe client\scripts\capture_sku_panel_spike.py --serial <SERIAL> --url "https://mobile.yangkeduo.com/goods.html?goods_id=<GOODS_ID>" --state all-dimensions-selected --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-all-dimensions-<GOODS_ID>\raw" --timeout 10 --adb D:\Portable\adb\adb.exe
|
.\client\.venv\Scripts\python.exe client\scripts\capture_sku_panel_spike.py --serial <SERIAL> --url "https://mobile.yangkeduo.com/goods.html?goods_id=<GOODS_ID>" --state target-selection-restored --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-target-restored-<GOODS_ID>-v2\raw" --timeout 10 --adb D:\Portable\adb\adb.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
每组 raw 采集成功后分别运行离线脱敏器;`derived` 必须尚不存在:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\client\.venv\Scripts\python.exe client\scripts\sanitize_sku_panel_evidence.py --raw-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-opened-target-<GOODS_ID>-v2\raw" --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-opened-target-<GOODS_ID>-v2\derived"
|
||||||
|
.\client\.venv\Scripts\python.exe client\scripts\sanitize_sku_panel_evidence.py --raw-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-alternate-<GOODS_ID>-v2\raw" --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-alternate-<GOODS_ID>-v2\derived"
|
||||||
|
.\client\.venv\Scripts\python.exe client\scripts\sanitize_sku_panel_evidence.py --raw-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-target-restored-<GOODS_ID>-v2\raw" --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-target-restored-<GOODS_ID>-v2\derived"
|
||||||
```
|
```
|
||||||
|
|
||||||
原始 screenshot/XML 可能包含 PDD 固定展示的地址和掩码手机号,只能留在本机 `raw` 目录,不能由
|
原始 screenshot/XML 可能包含 PDD 固定展示的地址和掩码手机号,只能留在本机 `raw` 目录,不能由
|
||||||
agent、fixture、业务或上传端消费。脱敏器在 sibling `derived` 目录原子发布 screenshot/XML 与 manifest,
|
agent、fixture、业务或上传端消费。`t103-privacy-v3` 把截图与 XML 保持为两个独立坐标空间,
|
||||||
|
并根据同一第一态 raw 的两次 fail-closed 安全观测将二者分别精确固定为 1080×2376;它校验源哈希、
|
||||||
|
设备/App/商品/人工状态与隐私结构,在 sibling
|
||||||
|
`derived` 目录原子发布 screenshot/XML 与 manifest;任何不匹配、手机号残留或已有目标均拒绝发布。
|
||||||
自动复检通过且人确认三态对应性后,agent 才能读取派生物并提取最小 fixture、编写判据。
|
自动复检通过且人确认三态对应性后,agent 才能读取派生物并提取最小 fixture、编写判据。
|
||||||
|
人工确认中还必须记录中间态实际选择的非目标颜色和尺码,不能只写“已切换”。
|
||||||
|
|
||||||
## 关键背景
|
## 关键背景
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
id: T-010
|
||||||
|
title: 收窄 Phase 2 门禁并启动安全并行
|
||||||
|
phase: 0
|
||||||
|
deps: [T-009]
|
||||||
|
status: DONE
|
||||||
|
created: 2026-08-04
|
||||||
|
vikunja_task_id: 25
|
||||||
|
context_ref: d27fda6
|
||||||
|
work_branch: task/t-010-parallel-gate
|
||||||
|
needs_device: false
|
||||||
|
needs_human_review: false
|
||||||
|
write_paths:
|
||||||
|
- docs/tasks/T-010.md
|
||||||
|
- docs/00-ai-start-here.md
|
||||||
|
- docs/05-coding-rules.md
|
||||||
|
- docs/06-tasks.md
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- BEGIN VIKUNJA EXPORT id=25 synced=2026-08-04T06:39:13Z sha256=b3485476184414158cac243aa3f2d08567372179c2e91c2cf4bb5552e7191510 -->
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
客户要求加快 MVP。现有全局门禁把全部 Phase 2 页面都冻结到 T-103 完成,范围过宽;管理员登录与只创建 DRAFT 的基础建单不读取真机页面字段,也不会启动试选或触发下单。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
1. 把门禁收窄为:T-103 前允许 T-201 管理会话与 T-202 DRAFT 建单/基础列表;T-203 的批量开始试选、T-204 试选证据详情和 T-205 以后继续冻结。
|
||||||
|
2. 同步 AI 入口、编码规则和路线图;不修改 T-103 安全边界,不放宽不付款、第一趟禁下单、真机判据先取证或隐私证据分层。
|
||||||
|
3. 并行启动 admin T-201;共享文档由本任务所有者统一修改。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- 门禁文字不存在“冻结全部 Phase 2”的歧义。
|
||||||
|
- T-201/T-202 不得出现 PDD 页面判据、试选结果、证据、授权、提交或支付能力。
|
||||||
|
- 上下文校验、Vikunja 导出检查与 diff-check 通过。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
### 2026-08-04T06:38:51Z · ila
|
||||||
|
|
||||||
|
2026-08-04 完成并行门禁收窄:提交 3f2e0e5 更新 AI 入口、编码规则和路线图。T-103 期间只放行 T-201 管理会话与不启动试选的 T-202 DRAFT 建单/基础列表;T-203~T-207 继续等待 T-103。不付款、第一趟禁下单、真机判据与证据边界均未放宽。主 agent 复跑 agent-context validator、冲突文案检索和 git diff --check,全部通过。
|
||||||
|
<!-- END VIKUNJA EXPORT -->
|
||||||
|
|
||||||
|
## 边界
|
||||||
|
|
||||||
|
- 本任务只调整依赖门禁和并行顺序,不实现任何采购服务、真机自动化或页面判据代码。
|
||||||
|
- T-103 完成前只放行与真机可读字段无关的 T-201 管理会话,以及只创建 `DRAFT`、不启动试选的
|
||||||
|
T-202 基础建单与列表壳。
|
||||||
|
- T-203 的批量 `DRAFT → PENDING`、T-204 的试选证据详情以及 T-205 以后仍由 T-103 阻塞;不得用
|
||||||
|
假字段、原型假数据或前序项目结论提前固化生产契约。
|
||||||
|
- 不付款、第一趟不可达下单、价格读取位置、真机判据先取证、原始证据本机隔离与派生物消费边界
|
||||||
|
均保持不变。
|
||||||
+42
-5
@@ -17,6 +17,7 @@ write_paths:
|
|||||||
- client/tests/pdd/**
|
- client/tests/pdd/**
|
||||||
- client/tests/device/**
|
- client/tests/device/**
|
||||||
- client/scripts/capture_sku_panel_spike.py
|
- client/scripts/capture_sku_panel_spike.py
|
||||||
|
- client/scripts/sanitize_sku_panel_evidence.py
|
||||||
- docs/02-requirements.md
|
- docs/02-requirements.md
|
||||||
- docs/03-tech-stack.md
|
- docs/03-tech-stack.md
|
||||||
- docs/04-architecture.md
|
- docs/04-architecture.md
|
||||||
@@ -24,10 +25,10 @@ write_paths:
|
|||||||
- docs/current-state.md
|
- docs/current-state.md
|
||||||
---
|
---
|
||||||
|
|
||||||
<!-- BEGIN VIKUNJA EXPORT id=23 synced=2026-08-04T02:56:52Z sha256=2f5ce314d8ae55d466abe28d5d3a4c36e235e008af3a4d251b4ea362a4de1314 -->
|
<!-- BEGIN VIKUNJA EXPORT id=23 synced=2026-08-04T06:06:05Z sha256=9de2a35beba4cca7a29cf4dc6b5ff643d829f6467ff576da976589a8d1f3dcad -->
|
||||||
## 问题 / 背景
|
## 问题 / 背景
|
||||||
|
|
||||||
T-102 已证明 canonical 链接可进入目标商品。T-103 随后在 PKG110 / Android 16 / 拼多多 8.17.0、衣服商品 goods_id `937122477375` 上确认:规格面板只能从详情页右下角精确文案“快要抢光”进入,面板固定显示收货区域和掩码手机号。T-110 经项目所有者批准,将该已取证点击定义为可逆、能力受限的规格面板导航;这不是通用购买入口豁免,不授权其他文案、数量、确认页、提交订单或支付。
|
T-102 已证明 canonical 链接可进入目标商品。T-103 随后在 PKG110 / Android 16 / 拼多多 8.17.0、衣服商品 goods_id `937122477375` 上确认:规格面板只能从详情页右下角精确文案“快要抢光”进入,面板固定显示收货区域和掩码手机号。T-110 经项目所有者批准,将该已取证点击定义为可逆、能力受限的规格面板导航;这不是通用购买入口豁免,不授权其他文案、数量、确认页、提交订单或支付。 项目所有者随后确认,面板刚打开时已自动选中目标颜色“黑色CHA(纯棉)”和尺码“M(建议100-115)”;因此本任务不再假设存在“未选择”或“只选择一个维度”的初始状态。
|
||||||
|
|
||||||
## 关联需求与交互
|
## 关联需求与交互
|
||||||
|
|
||||||
@@ -39,16 +40,16 @@ T-102 已证明 canonical 链接可进入目标商品。T-103 随后在 PKG110 /
|
|||||||
|
|
||||||
1. 先实现本机确定性脱敏器,不先写页面判据。原始 screenshot/XML 只保存到 `%LOCALAPPDATA%\cmbuyer\artifacts\T-103\...\raw`,只允许脱敏器消费;agent、fixture、业务、日志、HTTP sink、Vikunja 与 Git 均不得读取/上传原始内容。
|
1. 先实现本机确定性脱敏器,不先写页面判据。原始 screenshot/XML 只保存到 `%LOCALAPPDATA%\cmbuyer\artifacts\T-103\...\raw`,只允许脱敏器消费;agent、fixture、业务、日志、HTTP sink、Vikunja 与 Git 均不得读取/上传原始内容。
|
||||||
2. 脱敏器绑定 PKG110 分辨率、拼多多 8.17.0、goods_id、人工声明状态和本项目证据配置,同时从 screenshot/XML 移除收货区域、地址与手机号内容,在 sibling `derived` 目录原子发布派生物。派生 XML 仍命中手机号模式、隐私区域无法确认、结构/分辨率/版本失配、哈希不一致或目标目录存在时 fail closed,不发布半成品。服务端只接收派生截图哈希和 sanitizer 版本,不接收原始文件/哈希/路径/XML。
|
2. 脱敏器绑定 PKG110 分辨率、拼多多 8.17.0、goods_id、人工声明状态和本项目证据配置,同时从 screenshot/XML 移除收货区域、地址与手机号内容,在 sibling `derived` 目录原子发布派生物。派生 XML 仍命中手机号模式、隐私区域无法确认、结构/分辨率/版本失配、哈希不一致或目标目录存在时 fail closed,不发布半成品。服务端只接收派生截图哈希和 sanitizer 版本,不接收原始文件/哈希/路径/XML。
|
||||||
3. 由人运行脱敏器并本地确认派生 screenshot/XML 的 initial、one-dimension-selected、all-dimensions-selected 三态对应性;主 agent 只在派生 manifest 与人工确认齐全后读取派生证据,提取最小 fixture。首轮 `initial` 详情页截图与含隐私面板截图不得复用为 fixture。
|
3. 由人运行脱敏器并本地确认派生 screenshot/XML 的三种真实状态:panel-opened-target-preselected(刚打开且目标规格已自动选中)、alternate-all-dimensions-selected(人工把颜色和尺码都改成非目标值)、target-selection-restored(再把两个维度恢复为目标值)。主 agent 只在派生 manifest、每态实际选中值与人工确认齐全后读取派生证据,提取最小 fixture。首轮旧证据不得复用为 fixture。
|
||||||
4. 实现 `TrialSkuFlow` 窄 capability。`open_trial_sku_panel()` 只允许拼多多 8.17.0、goods_id `937122477375` 上证据绑定、精确唯一的 `快要抢光`;缺失、重复、版本失配或打开后面板判据不唯一时零后续点击。`免拼购买 / 单独购买 / 直接拼成` 等其他文案必须分别取证,不能包含、前缀、同义、OCR 或坐标兜底。
|
4. 实现 `TrialSkuFlow` 窄 capability。`open_trial_sku_panel()` 只允许拼多多 8.17.0、goods_id `937122477375` 上证据绑定、精确唯一的 `快要抢光`;缺失、重复、版本失配或打开后面板判据不唯一时零后续点击。`免拼购买 / 单独购买 / 直接拼成` 等其他文案必须分别取证,不能包含、前缀、同义、OCR 或坐标兜底。
|
||||||
5. 第一趟 capability 只含打开商品、受控打开面板、在维度容器内精确选择、读面板单价、脱敏取证和返回。接口不得暴露通用 `click`、`set_quantity()`、`go_to_order_confirm()`、`submit_order()` 或支付能力;规格面板中的“提交订单”、微信支付、先用后付和 0 元下单只作为硬拒绝判据,静态调用链测试证明不可达。
|
5. 第一趟 capability 只含打开商品、受控打开面板、在维度容器内精确选择、读面板单价、脱敏取证和返回。接口不得暴露通用 `click`、`set_quantity()`、`go_to_order_confirm()`、`submit_order()` 或支付能力;规格面板中的“提交订单”、微信支付、先用后付和 0 元下单只作为硬拒绝判据,静态调用链测试证明不可达。
|
||||||
6. 在脱敏派生 XML 上按维度容器隔离候选,文本等值匹配并读回选中态;缺失、重复、禁用、维度不明或选中态不唯一均停止。纯函数测试覆盖 `红`/`粉红`、`1`/`10` 等前缀碰撞。
|
6. 在脱敏派生 XML 上按维度容器隔离候选,文本等值匹配并读回选中态;缺失、重复、禁用、维度不明或选中态不唯一均停止。纯函数测试覆盖 `红`/`粉红`、`1`/`10` 等前缀碰撞。
|
||||||
7. 单价只从规格面板证据确定的唯一节点读取,以十进制字符串返回,禁止浮点。记录真实文本是否含优惠前缀、货币符号是否拆节点、是否并列原价/区间价;语义不唯一、节点缺失、候选冲突或只能从详情页读取时返回 unreadable。
|
7. 单价只从规格面板证据确定的唯一节点读取,以十进制字符串返回,禁止浮点。记录真实文本是否含优惠前缀、货币符号是否拆节点、是否并列原价/区间价;语义不唯一、节点缺失、候选冲突或只能从详情页读取时返回 unreadable。
|
||||||
8. 人最终在真机执行受控入口、精确选择和读价脚本,确认实际选择 `颜色分类=黑色 CHA(纯棉)`、`尺码=M(建议100-115)`(以派生 XML 精确文本为准)、面板单价语义、派生证据隐私与退出行为;agent 保持 DOING 直到人工验收。
|
8. 人最终在真机先把两个维度改成一组明确记录的非目标值,再执行受控入口、精确选择和读价脚本恢复目标规格,确认实际选择颜色分类=黑色 CHA(纯棉)、尺码=M(建议100-115)(以派生 XML 精确文本为准)、面板单价语义、派生证据隐私与退出行为;agent 保持 DOING 直到人工验收。
|
||||||
|
|
||||||
## 验收要点
|
## 验收要点
|
||||||
|
|
||||||
- 三组原始证据仅在本机 raw 目录;脱敏器离线测试覆盖地址/手机号移除、截图与 XML 同步处理、版本/分辨率/结构失配、手机号残留、原子性、异常脱敏和已有目录不覆盖。
|
- 三组原始证据分别对应 panel-opened-target-preselected、alternate-all-dimensions-selected、target-selection-restored,且仅在本机 raw 目录;脱敏器离线测试覆盖地址/手机号移除、截图与 XML 同步处理、状态/版本/分辨率/结构失配、手机号残留、原子性、异常脱敏和已有目录不覆盖。
|
||||||
- 派生 manifest 记录 source/derived 本机哈希、sanitizer 版本、设备/App/goods_id/人工状态;原始页面正文、serial、地址和手机号不进入 manifest、日志、错误或上传。
|
- 派生 manifest 记录 source/derived 本机哈希、sanitizer 版本、设备/App/goods_id/人工状态;原始页面正文、serial、地址和手机号不进入 manifest、日志、错误或上传。
|
||||||
- 只从人工验收通过的派生证据编写页面判据与最小 fixture;判据绑定拼多多 8.17.0 和证据哈希。
|
- 只从人工验收通过的派生证据编写页面判据与最小 fixture;判据绑定拼多多 8.17.0 和证据哈希。
|
||||||
- `快要抢光` 精确唯一时才打开面板;其他文案、入口缺失/重复、面板不唯一、规格缺失/碰撞/禁用、价格不唯一均 fail closed。
|
- `快要抢光` 精确唯一时才打开面板;其他文案、入口缺失/重复、面板不唯一、规格缺失/碰撞/禁用、价格不唯一均 fail closed。
|
||||||
@@ -94,6 +95,42 @@ T-102 已证明 canonical 链接可进入目标商品。T-103 随后在 PKG110 /
|
|||||||
### 2026-08-04T02:56:36Z · ila
|
### 2026-08-04T02:56:36Z · ila
|
||||||
|
|
||||||
2026-08-04:T-110 已获项目所有者授权并同步架构契约。T-103 方案已重写为“先自动脱敏派生证据,再实现受控 `快要抢光` 入口与精确规格/价格判据”;T-110 完成并转 Done 后,本任务恢复 Doing。
|
2026-08-04:T-110 已获项目所有者授权并同步架构契约。T-103 方案已重写为“先自动脱敏派生证据,再实现受控 `快要抢光` 入口与精确规格/价格判据”;T-110 完成并转 Done 后,本任务恢复 Doing。
|
||||||
|
|
||||||
|
### 2026-08-04T03:20:51Z · ila
|
||||||
|
|
||||||
|
2026-08-04 T-103 自动脱敏第一阶段实现与主审:
|
||||||
|
|
||||||
|
- 提交 `9b2eb74` 新增离线确定性脱敏 CLI、实现模块和纯合成测试;仅允许 `raw` 到同级全新 `derived`,不连接设备。
|
||||||
|
- 绑定 PKG110 / Android 16 / 拼多多 8.17.0 / goods_id `937122477375` / 1080×2400 / 人工声明三态;校验源 manifest 与文件哈希。
|
||||||
|
- 截图整宽遮罩 `[0,0,1080,540)`;XML 递归删除隐私带节点、清空跨界容器并保留下方规格节点,完整/掩码/分隔/跨节点手机号残留均 fail closed。
|
||||||
|
- 主审首轮发现手机号跨节点绕过、隐私结构未确认和发布竞态风险后退回;第二轮修复并补测,目标目录已存在或竞态出现均不覆盖且不留 staging。
|
||||||
|
- 主 agent 独立验证:66 项 client 单测、compileall、CLI help、wheel metadata、完整 `init.ps1`、上下文校验与 diff-check 全部通过。
|
||||||
|
- 实现与审查过程未读取、列举或打开任何真实 raw artifacts,也未实现 PDD 页面判据、点击、规格选择、价格、数量、确认页、提交或付款能力。
|
||||||
|
- `needs_device=true`,T-103 保持 DOING;等待人重新采集三态 raw、运行脱敏器并只核对 derived 后,才能继续页面判据与真机选择/读价。
|
||||||
|
|
||||||
|
### 2026-08-04T03:34:47Z · ila
|
||||||
|
|
||||||
|
项目所有者确认:规格面板刚打开时已自动选中目标颜色“黑色CHA(纯棉)”与尺码“M(建议100-115)”。旧的未选择/单维度/全选三态假设失效;T-103 改为记录刚打开目标预选、两个维度改为非目标值、两个维度恢复目标值三态。修改 CLI 状态枚举与测试前不再采集旧状态。
|
||||||
|
|
||||||
|
### 2026-08-04T03:40:39Z · ila
|
||||||
|
|
||||||
|
T-103 新三态离线实现与主审:提交 dbb69a7 将人工声明状态集中到 sku_panel_state.py,采集器和脱敏器仅接受 panel-opened-target-preselected、alternate-all-dimensions-selected、target-selection-restored;旧 initial / one-dimension-selected / all-dimensions-selected 与未知值均 fail closed。主 agent 独立复跑 67 项 client 单测、compileall、两个 CLI help、静态禁用能力检索与完整 init.ps1,全部通过。过程未连接设备、未读取真实 raw、未新增页面判据或点击/数量/确认/提交/付款能力。T-103 保持 DOING,等待新三态 derived 人工验收。
|
||||||
|
|
||||||
|
### 2026-08-04T03:54:21Z · ila
|
||||||
|
|
||||||
|
第一组新三态 raw 已由人成功采集;脱敏器 v1 按设计拒绝。人只读取 PNG 头部元数据并确认实际 format=PNG、size=1080x2376,证明 v1 将截图尺寸与 Android XML 坐标空间统一设为 1080x2400 的假设错误。原始证据不重采、不删除、不读取正文;下一步把 screenshot 与 XML coordinate space 分离配置,升级 sanitizer 版本并继续 fail closed。
|
||||||
|
|
||||||
|
### 2026-08-04T03:58:54Z · ila
|
||||||
|
|
||||||
|
T-103 sanitizer v2 坐标修正与主审:提交 44c027a 将 screenshot space 固定为人确认的 1080x2376,并把 XML coordinate space 独立严格配置为待验证的 1080x2400;sanitizer_version 升为 t103-privacy-v2。XML 会统计所有 bounds 的 max right/bottom,任何小于或大于配置的偏差均 fail closed,错误只含非敏感 observed 尺寸。派生 manifest 记录两个坐标空间和各自遮罩。主 agent 独立复跑 69 项 client 单测、compileall、CLI help 与 diff-check,全部通过;未读取真实 raw 正文、未连接设备。等待人用同一第一态 raw 重跑 v2 脱敏。
|
||||||
|
|
||||||
|
### 2026-08-04T06:01:02Z · ila
|
||||||
|
|
||||||
|
人使用 sanitizer v2 对同一第一态 raw 重跑,安全诊断返回 XML observed 1080x2376;未发布 derived。由此确认本次 screenshot space 与 XML coordinate space 均为 1080x2376,v2 对 XML=1080x2400 的待验证假设被真机证据否定。原始证据继续保留且不重采;下一步升级 v3,仍分离建模两个空间但分别精确绑定当前相同尺寸。
|
||||||
|
|
||||||
|
### 2026-08-04T06:05:53Z · ila
|
||||||
|
|
||||||
|
2026-08-04 T-103 sanitizer v3 坐标修正与主审:人用同一第一态 raw 运行 v2,脱敏器安全拒绝并仅报告 observed 1080x2376,证明 XML 实际坐标与截图相同;原始证据仍有效、未重采、未读取正文。提交 acf7e11 将 sanitizer_version 升为 t103-privacy-v3;截图与 XML 仍分别建模并分别严格校验,但当前均精确绑定 1080x2376,隐私带保持 [0,0,1080,540)。合成测试明确拒绝旧截图 1080x2400、旧 v2 XML 1080x2400,以及较小/较大坐标;手机号残留、哈希/状态/版本、确定性、原子发布和不覆盖门禁保持不变。主 agent 独立复跑 69 项 client 单测、compileall、CLI help、静态禁用能力检索与 diff-check,全部通过;未连接设备、未读取真实 raw。T-103 保持 DOING,等待人用同一第一态 raw 重跑 v3 并只核对 derived。
|
||||||
<!-- END VIKUNJA EXPORT -->
|
<!-- END VIKUNJA EXPORT -->
|
||||||
|
|
||||||
## 边界
|
## 边界
|
||||||
|
|||||||
Reference in New Issue
Block a user