From 44c027a18f0ed8eedc45c0d283eed7f85f68c6d7 Mon Sep 17 00:00:00 2001
From: QiuSW <105186638@qq.com>
Date: Tue, 4 Aug 2026 11:57:45 +0800
Subject: [PATCH] fix(client): separate screenshot and XML coordinates
---
.../device/sku_evidence_sanitizer.py | 79 ++++++++++++----
.../device/test_sku_evidence_sanitizer.py | 89 +++++++++++++++++--
2 files changed, 143 insertions(+), 25 deletions(-)
diff --git a/client/src/cmbuyer_client/device/sku_evidence_sanitizer.py b/client/src/cmbuyer_client/device/sku_evidence_sanitizer.py
index ca9df74..b71e73c 100644
--- a/client/src/cmbuyer_client/device/sku_evidence_sanitizer.py
+++ b/client/src/cmbuyer_client/device/sku_evidence_sanitizer.py
@@ -21,13 +21,15 @@ from ..pdd.product_url import ProductUrl, ProductUrlError, parse_product_url
from ..pdd.sku_panel_state import HUMAN_DECLARED_STATES
-SANITIZER_VERSION = "t103-privacy-v1"
+SANITIZER_VERSION = "t103-privacy-v2"
EXPECTED_GOODS_ID = "937122477375"
EXPECTED_PDD_VERSION = "8.17.0"
EXPECTED_DEVICE_MODEL = "PKG110"
EXPECTED_ANDROID_VERSION = "16"
-EXPECTED_WIDTH = 1080
-EXPECTED_HEIGHT = 2400
+EXPECTED_SCREENSHOT_WIDTH = 1080
+EXPECTED_SCREENSHOT_HEIGHT = 2376
+EXPECTED_XML_WIDTH = 1080
+EXPECTED_XML_HEIGHT = 2400
_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")
@@ -48,6 +50,8 @@ class _CleanupStats:
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)
@@ -55,15 +59,19 @@ class PrivacyMaskConfig:
"""仅描述已人工确认的隐私几何区域,绝不承担页面或规格判据。"""
version: str
- width: int
- height: int
+ screenshot_width: int
+ screenshot_height: int
+ xml_width: int
+ xml_height: int
privacy_top: int
PRIVACY_MASK_CONFIG = PrivacyMaskConfig(
version=SANITIZER_VERSION,
- width=EXPECTED_WIDTH,
- height=EXPECTED_HEIGHT,
+ 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,
)
@@ -228,7 +236,10 @@ 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.width, PRIVACY_MASK_CONFIG.height):
+ 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:
@@ -238,7 +249,7 @@ def _sanitize_screenshot(source: Path, target: Path) -> None:
# 用不透明黑色覆盖 y < 540,保证截图与 XML 使用相同的隐私几何边界。
ImageDraw.Draw(sanitized).rectangle(
- (0, 0, PRIVACY_MASK_CONFIG.width - 1, PRIVACY_MASK_CONFIG.privacy_top - 1),
+ (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)
@@ -257,6 +268,7 @@ def _sanitize_hierarchy(source: Path, target: Path) -> _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):
@@ -269,11 +281,15 @@ def _sanitize_node(parent: ElementTree.Element, node: ElementTree.Element, stats
if node.tag != "node":
raise SkuEvidenceSanitizationError("原始节点树结构不匹配。")
bounds = _parse_bounds(node.get("bounds"))
+ _observe_bounds(stats, bounds)
position = _vertical_position(bounds)
if position == "private":
# 私有带内的父节点不可以悄然包含下方子节点,否则会把仍需审计的下方内容一起丢失。
- if any(_vertical_position(_parse_bounds(descendant.get("bounds"))) != "private" for descendant in node.iter("node")):
- raise SkuEvidenceSanitizationError("原始节点树 bounds 结构不匹配。")
+ 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
@@ -294,11 +310,27 @@ def _parse_bounds(value: object) -> tuple[int, int, int, int]:
if match is None:
raise SkuEvidenceSanitizationError("原始节点树 bounds 缺失或无效。")
left, top, right, bottom = (int(group) for group in match.groups())
- if not (0 <= left < right <= PRIVACY_MASK_CONFIG.width and 0 <= top < bottom <= PRIVACY_MASK_CONFIG.height):
- raise SkuEvidenceSanitizationError("原始节点树 bounds 超出脱敏配置。")
+ 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:
@@ -364,15 +396,28 @@ def _derived_manifest(
"schema_version": 1,
"privacy_tier": "SANITIZED",
"sanitizer_version": PRIVACY_MASK_CONFIG.version,
- "privacy_mask": {
- "width": PRIVACY_MASK_CONFIG.width,
- "height": PRIVACY_MASK_CONFIG.height,
- "rectangle": [0, 0, PRIVACY_MASK_CONFIG.width, PRIVACY_MASK_CONFIG.privacy_top],
+ "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,
diff --git a/client/tests/device/test_sku_evidence_sanitizer.py b/client/tests/device/test_sku_evidence_sanitizer.py
index c85095e..90fa7f4 100644
--- a/client/tests/device/test_sku_evidence_sanitizer.py
+++ b/client/tests/device/test_sku_evidence_sanitizer.py
@@ -19,8 +19,10 @@ sys.path.insert(0, str(CLIENT_ROOT / "src"))
from cmbuyer_client.device.sku_evidence_sanitizer import (
EXPECTED_GOODS_ID,
- EXPECTED_HEIGHT,
- EXPECTED_WIDTH,
+ EXPECTED_SCREENSHOT_HEIGHT,
+ EXPECTED_SCREENSHOT_WIDTH,
+ EXPECTED_XML_HEIGHT,
+ EXPECTED_XML_WIDTH,
HUMAN_DECLARED_STATES,
SkuEvidenceSanitizationError,
sanitize_sku_panel_evidence,
@@ -59,14 +61,14 @@ def _write_raw(
model: str = "PKG110",
android_version: str = "16",
pdd_version: str = "8.17.0",
- size: tuple[int, int] = (EXPECTED_WIDTH, EXPECTED_HEIGHT),
+ 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_WIDTH, EXPECTED_HEIGHT):
+ 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))
@@ -115,11 +117,17 @@ class SkuEvidenceSanitizerTests(unittest.TestCase):
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-v1"', manifest)
- self.assertIn('"rectangle": [', manifest)
+ self.assertIn('"sanitizer_version": "t103-privacy-v2"', manifest)
+ self.assertIn('"screenshot_space": {', manifest)
+ self.assertIn('"xml_coordinate_space": {', manifest)
+ self.assertIn('"height": 2376', manifest)
+ self.assertIn('"height": 2400', 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": 2400', manifest)
self.assertNotIn("canonical_url", manifest)
self.assertNotIn(TEST_SERIAL, manifest)
self.assertNotIn("serial", manifest)
@@ -149,7 +157,13 @@ class SkuEvidenceSanitizerTests(unittest.TestCase):
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},
+ {
+ "removed_nodes": 1,
+ "cleared_crossing_nodes": 1,
+ "retained_below_nodes": 1,
+ "max_right": 1080,
+ "max_bottom": 2400,
+ },
)
def test_same_raw_and_config_produce_identical_derived_files(self) -> None:
@@ -167,6 +181,26 @@ class SkuEvidenceSanitizerTests(unittest.TestCase):
):
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": 2400,
+ "privacy_mask_rectangle": [0, 0, 1080, 540],
+ "observed_max": {"right": 1080, "bottom": 2400},
+ },
+ )
+
def test_hash_and_metadata_mismatch_fail_closed_without_leak(self) -> None:
scenarios = (
("hash", {}, "screenshot"),
@@ -175,7 +209,8 @@ class SkuEvidenceSanitizerTests(unittest.TestCase):
("version", {"pdd_version": "8.17.1"}, None),
("goods", {"goods_id": "123"}, None),
("state", {"state": "guessed"}, None),
- ("resolution", {"size": (100, 100)}, 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:
@@ -281,6 +316,44 @@ class SkuEvidenceSanitizerTests(unittest.TestCase):
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",
+ ""
+ "",
+ "1079x2400",
+ ),
+ (
+ "short-height",
+ ""
+ "",
+ "1080x2376",
+ ),
+ (
+ "wide-width",
+ ""
+ "",
+ "1081x2400",
+ ),
+ (
+ "tall-height",
+ ""
+ "",
+ "1080x2401",
+ ),
+ )
+ 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":