fix(client): identify sku entry failure substage

This commit is contained in:
QiuSW
2026-08-05 10:32:35 +08:00
parent e45d26fe93
commit b754cc9ca9
3 changed files with 142 additions and 21 deletions
+69 -13
View File
@@ -44,6 +44,43 @@ class SkuSelectionError(RuntimeError):
"""已取证判据不成立时的脱敏停止。"""
_SKU_ENTRY_FAILURE_STAGES = frozenset(
(
"sku_entry_pre_intent",
"sku_entry_discovery",
"sku_entry_click",
"sku_entry_panel_verify",
)
)
_SKU_ENTRY_FAILURE_MARKER = object()
def _annotate_sku_entry_failure(error: BaseException, stage: str) -> None:
"""把 Flow 实际经过的固定入口子阶段附到原异常,不改变异常类型。"""
if type(stage) is not str or stage not in _SKU_ENTRY_FAILURE_STAGES:
return
try:
# marker 最后写入:若第三方异常拒绝任一属性写入,就不能形成可信诊断。
setattr(error, "_cmbuyer_failure_stage", stage)
setattr(error, "_cmbuyer_sku_entry_failure_marker", _SKU_ENTRY_FAILURE_MARKER)
except BaseException:
pass
def _safe_sku_entry_failure_stage(error: BaseException) -> str | None:
"""只读取由本模块写入的入口子阶段;任意异常自报的值不可信。"""
try:
marker = getattr(error, "_cmbuyer_sku_entry_failure_marker", None)
stage = getattr(error, "_cmbuyer_failure_stage", None)
if marker is not _SKU_ENTRY_FAILURE_MARKER or type(stage) is not str:
return None
return stage if stage in _SKU_ENTRY_FAILURE_STAGES else None
except BaseException:
return None
class SkuPanelDevice(Protocol):
def app_info(self, package_name: str) -> dict[str, Any]: ...
def app_current(self) -> dict[str, Any]: ...
@@ -89,19 +126,38 @@ class SkuSelectionFlow:
self._pending: tuple[str, Callable[[list[_Node]], Any]] | None = None
def open_sku_panel(self, product_url: str, pre_intent_hierarchy: str | None = None) -> None:
if parse_product_url(product_url).goods_id != EXPECTED_GOODS_ID:
raise SkuSelectionError("商品不是已取证目标,已停止操作。")
if pre_intent_hierarchy is not None:
previous_nodes = _parse_nodes(pre_intent_hierarchy)
# intent 前只判断旧页是否已经存在完整入口链;浮层或额外动作节点不能把旧商品
# 伪装成“不安全所以不存在”,否则 intent 后可能误把旧页当成新目标页。
if _physical_entries(previous_nodes):
raise SkuSelectionError("intent 前页面已出现规格入口,已拒绝旧商品误点。")
entry, before = self._wait_for_entry(pre_intent_hierarchy)
_action_bounds(entry.bounds)
self._pending = (before, _panel)
self._device.tap_sku_entry(entry.bounds)
self._wait_after_action(before, _panel)
try:
if parse_product_url(product_url).goods_id != EXPECTED_GOODS_ID:
raise SkuSelectionError("商品不是已取证目标,已停止操作。")
if pre_intent_hierarchy is not None:
previous_nodes = _parse_nodes(pre_intent_hierarchy)
# intent 前只判断旧页是否已经存在完整入口链;浮层或额外动作节点不能把旧商品
# 伪装成“不安全所以不存在”,否则 intent 后可能误把旧页当成新目标页。
if _physical_entries(previous_nodes):
raise SkuSelectionError("intent 前页面已出现规格入口,已拒绝旧商品误点。")
except BaseException as error:
_annotate_sku_entry_failure(error, "sku_entry_pre_intent")
raise
try:
entry, before = self._wait_for_entry(pre_intent_hierarchy)
_action_bounds(entry.bounds)
except BaseException as error:
_annotate_sku_entry_failure(error, "sku_entry_discovery")
raise
try:
self._pending = (before, _panel)
self._device.tap_sku_entry(entry.bounds)
except BaseException as error:
_annotate_sku_entry_failure(error, "sku_entry_click")
raise
try:
self._wait_after_action(before, _panel)
except BaseException as error:
_annotate_sku_entry_failure(error, "sku_entry_panel_verify")
raise
def select_sku_options(self, selection: SkuSelection) -> None:
if selection not in {SkuSelection(*item) for item in TASK_TO_UI_SELECTION.values()}:
@@ -30,7 +30,10 @@ from .sku_selection import (
SkuPanelDevice,
SkuSelectionError,
SkuSelectionFlow,
_SKU_ENTRY_FAILURE_STAGES,
_action_bounds,
_annotate_sku_entry_failure,
_safe_sku_entry_failure_stage,
resolve_task_selection,
)
@@ -48,6 +51,7 @@ _FAILURE_STAGES = frozenset(
"device_session",
"product_open",
"sku_entry",
*_SKU_ENTRY_FAILURE_STAGES,
"sku_selection",
"price_verification",
"screenshot_capture",
@@ -85,7 +89,11 @@ def safe_failure_stage(error: BaseException) -> str:
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"
if type(stage) is not str or stage not in _FAILURE_STAGES:
return "unknown"
if stage in _SKU_ENTRY_FAILURE_STAGES:
return stage if _safe_sku_entry_failure_stage(error) == stage else "unknown"
return stage
except BaseException:
return "unknown"
@@ -101,6 +109,21 @@ def _annotate_failure(error: BaseException, stage: str) -> None:
pass
def _failure_stage_for(error: BaseException, runner_stage: str) -> str:
if runner_stage == "sku_entry":
flow_stage = _safe_sku_entry_failure_stage(error)
if flow_stage is not None:
return flow_stage
return runner_stage
def _annotate_mapped_failure(mapped: BaseException, source: BaseException, runner_stage: str) -> None:
stage = _failure_stage_for(source, runner_stage)
if stage in _SKU_ENTRY_FAILURE_STAGES:
_annotate_sku_entry_failure(mapped, stage)
_annotate_failure(mapped, stage)
@dataclass(frozen=True)
class SkuSelectionRunResult:
"""已发布的截图和无页面正文 manifest 摘要。"""
@@ -299,22 +322,22 @@ class SkuSelectionRunner:
staging = None
except (DeviceConnectionError, ProductUrlError, SkuSelectionRunError, SkuSelectionError) as error:
_clean_staging(staging)
_annotate_failure(error, stage)
_annotate_failure(error, _failure_stage_for(error, stage))
raise
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
_clean_staging(staging)
mapped = SkuSelectionRunTimeoutError("规格面板运行超时,未发布任何证据产物。")
_annotate_failure(mapped, stage)
_annotate_mapped_failure(mapped, error, stage)
raise mapped from error
except OSError as error:
_clean_staging(staging)
mapped = SkuSelectionRunError("规格面板证据目录无法创建或发布,未发布任何证据产物。")
_annotate_failure(mapped, stage)
_annotate_mapped_failure(mapped, error, stage)
raise mapped from error
except Exception as error:
_clean_staging(staging)
mapped = SkuSelectionRunError("规格面板运行未完成,未发布任何证据产物。")
_annotate_failure(mapped, stage)
_annotate_mapped_failure(mapped, error, stage)
raise mapped from error
finally:
# 失败路径只能复用 Flow 的版本、前台和面板证明;证明不了便停止,绝不盲目返回。