fix(client): expose safe T-103 failure stages

This commit is contained in:
QiuSW
2026-08-05 09:07:30 +08:00
parent 025edaf273
commit 870e5f861d
3 changed files with 159 additions and 37 deletions
@@ -23,7 +23,7 @@ from uiautomator2.exceptions import HTTPTimeoutError
from ..device.adb import AdbClient, DeviceConnectionError, DeviceInspection
from ..device.baseline import PDD_PACKAGE, SCREENSHOT_PARAMS, _save_base64_screenshot, _sha256_file
from .product_open import EXPECTED_PDD_VERSION
from .product_url import ProductUrl, parse_product_url
from .product_url import ProductUrl, ProductUrlError, parse_product_url
from .sku_selection import (
EXPECTED_GOODS_ID,
EXPECTED_UNIT_PRICE,
@@ -39,6 +39,24 @@ EXPECTED_DEVICE_MODEL = "PKG110"
EXPECTED_ANDROID_VERSION = "16"
EXPECTED_SCREEN_SIZE = (1080, 2376)
# CLI 只允许输出这些固定阶段码。阶段码描述运行器自己的控制流,不包含页面
# 文本、节点属性、serial、路径或第三方异常;未知/伪造值统一降级为 unknown。
_FAILURE_STAGES = frozenset(
(
"precheck",
"device_inspection",
"device_session",
"product_open",
"sku_entry",
"sku_selection",
"price_verification",
"screenshot_capture",
"screenshot_reverify",
"safe_exit",
"publish",
)
)
class SkuSelectionRunError(RuntimeError):
"""T-103 运行未完整完成;错误文本不携带设备或页面原文。"""
@@ -60,6 +78,29 @@ class SkuSelectionDeviceAdapterError(SkuSelectionRunError):
"""第三方设备接口失败的脱敏映射。"""
def safe_failure_stage(error: BaseException) -> str:
"""返回允许公开的固定阶段码,绝不回显异常正文。"""
try:
stage = getattr(error, "_cmbuyer_failure_stage", None)
# exact str 避免恶意 str 子类在 hash/eq 中执行任意异常;诊断路径
# 自己也必须失败闭合,不能让异常正文越过 CLI 的统一脱敏出口。
return stage if type(stage) is str and stage in _FAILURE_STAGES else "unknown"
except BaseException:
return "unknown"
def _annotate_failure(error: BaseException, stage: str) -> None:
"""只给本次异常附加白名单控制流事实;原异常文本仍不对外输出。"""
safe_stage = stage if stage in _FAILURE_STAGES else "unknown"
try:
setattr(error, "_cmbuyer_failure_stage", safe_stage)
except BaseException:
# 极端第三方异常不允许写属性时仍保持原失败闭合语义。
pass
@dataclass(frozen=True)
class SkuSelectionRunResult:
"""已发布的截图和无页面正文 manifest 摘要。"""
@@ -183,38 +224,52 @@ class SkuSelectionRunner:
task_size: str,
output_directory: Path,
) -> SkuSelectionRunResult:
link = parse_product_url(product_url)
if link.goods_id != EXPECTED_GOODS_ID:
raise SkuSelectionRunError("商品不是 T-103 已取证目标,已停止操作。")
selection = resolve_task_selection(task_color, task_size)
target = Path(output_directory)
_validate_new_target(target)
stage = "precheck"
staging: Path | None = None
adapter: UiautomatorSkuPanelAdapter | None = None
flow: SkuSelectionFlow | None = None
staging = _prepare_staging(target)
deadline = self._monotonic_clock() + self._timeout_seconds
try:
link = parse_product_url(product_url)
if link.goods_id != EXPECTED_GOODS_ID:
raise SkuSelectionRunError("商品不是 T-103 已取证目标,已停止操作。")
selection = resolve_task_selection(task_color, task_size)
target = Path(output_directory)
_validate_new_target(target)
staging = _prepare_staging(target)
deadline = self._monotonic_clock() + self._timeout_seconds
stage = "device_inspection"
inspection = self._adb_client.inspect(serial)
_require_expected_device(inspection)
stage = "device_session"
adapter = UiautomatorSkuPanelAdapter(self._connector(serial), self._timeout_seconds)
_require_expected_version(adapter.app_info(PDD_PACKAGE))
if adapter.display_size() != EXPECTED_SCREEN_SIZE:
raise SkuSelectionRunError("设备不是已取证的竖屏坐标空间,已停止操作。")
pre_intent_hierarchy = adapter.dump_window_hierarchy()
# 固定 ACTION_VIEW、固定 PDD package 和 canonical goods_id;不接受任意 URL 或 shell。
stage = "product_open"
self._adb_client.start_pdd_view_intent(serial, link.goods_id)
remaining = deadline - self._monotonic_clock()
if remaining <= 0:
raise SkuSelectionRunTimeoutError("等待规格入口超时,未执行点击。")
stage = "sku_entry"
flow = SkuSelectionFlow(adapter, entry_wait_timeout_seconds=remaining)
flow.open_sku_panel(link.canonical_url, pre_intent_hierarchy)
stage = "sku_selection"
flow.select_sku_options(selection)
stage = "price_verification"
unit_price = flow.verify_target_selection_and_read_price(selection)
if unit_price != EXPECTED_UNIT_PRICE:
raise SkuSelectionUnexpectedPriceError("规格面板现价不是本任务已确认值,已停止操作。")
stage = "screenshot_capture"
screenshot_path = staging / "screenshot.png"
try:
_save_base64_screenshot(adapter.capture_screenshot(), screenshot_path)
@@ -226,11 +281,15 @@ class SkuSelectionRunner:
manifest_path = staging / "manifest.json"
# 截图可能落在动态页面切换边界;发布前必须用一棵更新节点树同时重证两维和现价。
stage = "screenshot_reverify"
final_price = flow.verify_target_selection_and_read_price(selection)
if final_price != EXPECTED_UNIT_PRICE:
raise SkuSelectionUnexpectedPriceError("截图后规格面板现价不是本任务已确认值,已停止操作。")
# 正常路径仍经 Flow 做最后一次前台和面板判定;返回操作只发生一次。
stage = "safe_exit"
flow.exit_sku_panel_safely()
stage = "publish"
manifest_path.write_text(
json.dumps(_manifest(inspection, serial, link, screenshot_path, task_color, task_size), ensure_ascii=False, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
@@ -238,18 +297,25 @@ class SkuSelectionRunner:
# Windows 的 rename 不替换既有目标;并发创建 target 时保留其内容并把本次运行判失败。
os.rename(staging, target)
staging = None
except (DeviceConnectionError, SkuSelectionRunError, SkuSelectionError):
except (DeviceConnectionError, ProductUrlError, SkuSelectionRunError, SkuSelectionError) as error:
_clean_staging(staging)
_annotate_failure(error, stage)
raise
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
_clean_staging(staging)
raise SkuSelectionRunTimeoutError("规格面板运行超时,未发布任何证据产物。") from error
mapped = SkuSelectionRunTimeoutError("规格面板运行超时,未发布任何证据产物。")
_annotate_failure(mapped, stage)
raise mapped from error
except OSError as error:
_clean_staging(staging)
raise SkuSelectionRunError("规格面板证据目录无法创建或发布,未发布任何证据产物。") from error
mapped = SkuSelectionRunError("规格面板证据目录无法创建或发布,未发布任何证据产物。")
_annotate_failure(mapped, stage)
raise mapped from error
except Exception as error:
_clean_staging(staging)
raise SkuSelectionRunError("规格面板运行未完成,未发布任何证据产物。") from error
mapped = SkuSelectionRunError("规格面板运行未完成,未发布任何证据产物。")
_annotate_failure(mapped, stage)
raise mapped from error
finally:
# 失败路径只能复用 Flow 的版本、前台和面板证明;证明不了便停止,绝不盲目返回。
if flow is not None and adapter is not None and adapter.entry_was_tapped and not adapter.left_panel: