Compare commits

...
2 Commits
Author SHA1 Message Date
QiuSW 757eca39fd docs(tasks): record T-103 entry substage 2026-08-05 10:34:12 +08:00
QiuSW b754cc9ca9 fix(client): identify sku entry failure substage 2026-08-05 10:32:35 +08:00
5 changed files with 155 additions and 23 deletions
@@ -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): class SkuPanelDevice(Protocol):
def app_info(self, package_name: str) -> dict[str, Any]: ... def app_info(self, package_name: str) -> dict[str, Any]: ...
def app_current(self) -> dict[str, Any]: ... def app_current(self) -> dict[str, Any]: ...
@@ -89,6 +126,7 @@ class SkuSelectionFlow:
self._pending: tuple[str, Callable[[list[_Node]], Any]] | None = None 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: def open_sku_panel(self, product_url: str, pre_intent_hierarchy: str | None = None) -> None:
try:
if parse_product_url(product_url).goods_id != EXPECTED_GOODS_ID: if parse_product_url(product_url).goods_id != EXPECTED_GOODS_ID:
raise SkuSelectionError("商品不是已取证目标,已停止操作。") raise SkuSelectionError("商品不是已取证目标,已停止操作。")
if pre_intent_hierarchy is not None: if pre_intent_hierarchy is not None:
@@ -97,11 +135,29 @@ class SkuSelectionFlow:
# 伪装成“不安全所以不存在”,否则 intent 后可能误把旧页当成新目标页。 # 伪装成“不安全所以不存在”,否则 intent 后可能误把旧页当成新目标页。
if _physical_entries(previous_nodes): if _physical_entries(previous_nodes):
raise SkuSelectionError("intent 前页面已出现规格入口,已拒绝旧商品误点。") 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) entry, before = self._wait_for_entry(pre_intent_hierarchy)
_action_bounds(entry.bounds) _action_bounds(entry.bounds)
except BaseException as error:
_annotate_sku_entry_failure(error, "sku_entry_discovery")
raise
try:
self._pending = (before, _panel) self._pending = (before, _panel)
self._device.tap_sku_entry(entry.bounds) 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) 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: def select_sku_options(self, selection: SkuSelection) -> None:
if selection not in {SkuSelection(*item) for item in TASK_TO_UI_SELECTION.values()}: if selection not in {SkuSelection(*item) for item in TASK_TO_UI_SELECTION.values()}:
@@ -30,7 +30,10 @@ from .sku_selection import (
SkuPanelDevice, SkuPanelDevice,
SkuSelectionError, SkuSelectionError,
SkuSelectionFlow, SkuSelectionFlow,
_SKU_ENTRY_FAILURE_STAGES,
_action_bounds, _action_bounds,
_annotate_sku_entry_failure,
_safe_sku_entry_failure_stage,
resolve_task_selection, resolve_task_selection,
) )
@@ -48,6 +51,7 @@ _FAILURE_STAGES = frozenset(
"device_session", "device_session",
"product_open", "product_open",
"sku_entry", "sku_entry",
*_SKU_ENTRY_FAILURE_STAGES,
"sku_selection", "sku_selection",
"price_verification", "price_verification",
"screenshot_capture", "screenshot_capture",
@@ -85,7 +89,11 @@ def safe_failure_stage(error: BaseException) -> str:
stage = getattr(error, "_cmbuyer_failure_stage", None) stage = getattr(error, "_cmbuyer_failure_stage", None)
# exact str 避免恶意 str 子类在 hash/eq 中执行任意异常;诊断路径 # exact str 避免恶意 str 子类在 hash/eq 中执行任意异常;诊断路径
# 自己也必须失败闭合,不能让异常正文越过 CLI 的统一脱敏出口。 # 自己也必须失败闭合,不能让异常正文越过 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: except BaseException:
return "unknown" return "unknown"
@@ -101,6 +109,21 @@ def _annotate_failure(error: BaseException, stage: str) -> None:
pass 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) @dataclass(frozen=True)
class SkuSelectionRunResult: class SkuSelectionRunResult:
"""已发布的截图和无页面正文 manifest 摘要。""" """已发布的截图和无页面正文 manifest 摘要。"""
@@ -299,22 +322,22 @@ class SkuSelectionRunner:
staging = None staging = None
except (DeviceConnectionError, ProductUrlError, SkuSelectionRunError, SkuSelectionError) as error: except (DeviceConnectionError, ProductUrlError, SkuSelectionRunError, SkuSelectionError) as error:
_clean_staging(staging) _clean_staging(staging)
_annotate_failure(error, stage) _annotate_failure(error, _failure_stage_for(error, stage))
raise raise
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error: except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
_clean_staging(staging) _clean_staging(staging)
mapped = SkuSelectionRunTimeoutError("规格面板运行超时,未发布任何证据产物。") mapped = SkuSelectionRunTimeoutError("规格面板运行超时,未发布任何证据产物。")
_annotate_failure(mapped, stage) _annotate_mapped_failure(mapped, error, stage)
raise mapped from error raise mapped from error
except OSError as error: except OSError as error:
_clean_staging(staging) _clean_staging(staging)
mapped = SkuSelectionRunError("规格面板证据目录无法创建或发布,未发布任何证据产物。") mapped = SkuSelectionRunError("规格面板证据目录无法创建或发布,未发布任何证据产物。")
_annotate_failure(mapped, stage) _annotate_mapped_failure(mapped, error, stage)
raise mapped from error raise mapped from error
except Exception as error: except Exception as error:
_clean_staging(staging) _clean_staging(staging)
mapped = SkuSelectionRunError("规格面板运行未完成,未发布任何证据产物。") mapped = SkuSelectionRunError("规格面板运行未完成,未发布任何证据产物。")
_annotate_failure(mapped, stage) _annotate_mapped_failure(mapped, error, stage)
raise mapped from error raise mapped from error
finally: finally:
# 失败路径只能复用 Flow 的版本、前台和面板证明;证明不了便停止,绝不盲目返回。 # 失败路径只能复用 Flow 的版本、前台和面板证明;证明不了便停止,绝不盲目返回。
+45 -3
View File
@@ -605,14 +605,17 @@ class SkuSelectionRunnerTests(unittest.TestCase):
return "" return ""
return super().jsonrpc_call(method, params, timeout) return super().jsonrpc_call(method, params, timeout)
adb = _FakeAdb()
device = NoPanelAfterEntry("<hierarchy />")
adb.on_intent = lambda: setattr(device, "hierarchy", _PRODUCT_PAGE)
with TemporaryDirectory() as temporary, self.assertRaises(SkuSelectionError) as raised: with TemporaryDirectory() as temporary, self.assertRaises(SkuSelectionError) as raised:
self._runner(_FakeAdb(), NoPanelAfterEntry()).run( SkuSelectionRunner(adb, lambda serial: device, 0.03).run(
"device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result" "device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result"
) )
self.assertEqual(safe_failure_stage(raised.exception), "sku_entry") self.assertEqual(safe_failure_stage(raised.exception), "sku_entry_panel_verify")
forged = SkuSelectionError("<hierarchy>private</hierarchy>") forged = SkuSelectionError("<hierarchy>private</hierarchy>")
setattr(forged, "_cmbuyer_failure_stage", "<private-stage>") setattr(forged, "_cmbuyer_failure_stage", "sku_entry_click")
self.assertEqual(safe_failure_stage(forged), "unknown") self.assertEqual(safe_failure_stage(forged), "unknown")
class HostileSetterError(DeviceConnectionError): class HostileSetterError(DeviceConnectionError):
@@ -629,6 +632,45 @@ class SkuSelectionRunnerTests(unittest.TestCase):
) )
self.assertEqual(safe_failure_stage(hostile.exception), "unknown") self.assertEqual(safe_failure_stage(hostile.exception), "unknown")
def test_failure_stage_identifies_sku_entry_pre_intent(self) -> None:
device = _RawDevice(_PRODUCT_PAGE)
with TemporaryDirectory() as temporary, self.assertRaises(SkuSelectionError) as raised:
SkuSelectionRunner(_FakeAdb(), lambda serial: device, 0.03).run(
"device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result"
)
self.assertEqual(safe_failure_stage(raised.exception), "sku_entry_pre_intent")
self.assertEqual(_actions(device, "click"), [])
def test_failure_stage_identifies_sku_entry_discovery(self) -> None:
device = _RawDevice("<hierarchy />")
with TemporaryDirectory() as temporary, self.assertRaises(SkuSelectionError) as raised:
SkuSelectionRunner(_FakeAdb(), lambda serial: device, 0.01).run(
"device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result"
)
self.assertEqual(safe_failure_stage(raised.exception), "sku_entry_discovery")
self.assertEqual(_actions(device, "click"), [])
def test_failure_stage_identifies_sku_entry_click(self) -> None:
class ClickFailureDevice(_RawDevice):
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
if method == "click":
self.calls.append(("jsonrpc", method, params, timeout))
raise TimeoutError("private device detail")
return super().jsonrpc_call(method, params, timeout)
adb = _FakeAdb()
device = ClickFailureDevice("<hierarchy />")
adb.on_intent = lambda: setattr(device, "hierarchy", _PRODUCT_PAGE)
with TemporaryDirectory() as temporary, self.assertRaises(SkuSelectionRunError) as raised:
SkuSelectionRunner(adb, lambda serial: device, 0.03).run(
"device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result"
)
self.assertEqual(safe_failure_stage(raised.exception), "sku_entry_click")
self.assertEqual(len(_actions(device, "click")), 1)
def test_target_created_during_publish_is_preserved_without_staging_residue(self) -> None: def test_target_created_during_publish_is_preserved_without_staging_residue(self) -> None:
with TemporaryDirectory() as temporary: with TemporaryDirectory() as temporary:
target = Path(temporary) / "result" target = Path(temporary) / "result"
+1 -1
View File
@@ -82,7 +82,7 @@
- T-103 离线 `SkuSelectionFlow` 已实现并通过独立审计,仍为 `DOING` 等人工真机验收;T-104~T-107 - T-103 离线 `SkuSelectionFlow` 已实现并通过独立审计,仍为 `DOING` 等人工真机验收;T-104~T-107
按数量/确认页风险逐段开放。真机运行要求同一物理手机只保留一个 ADB 通道;CLI 已增加不回显页面 按数量/确认页风险逐段开放。真机运行要求同一物理手机只保留一个 ADB 通道;CLI 已增加不回显页面
正文的固定白名单失败阶段码;`sku_entry` 动态页面误判已按最新真机证据修复,覆盖点击中心的浮窗 正文的固定白名单失败阶段码;`sku_entry` 动态页面误判已按最新真机证据修复,覆盖点击中心的浮窗
会保持零点击。服务端后续顺序已收紧为 会保持零点击,入口失败也已细分为前置、发现、点击和面板后置验证四个固定子阶段。服务端后续顺序已收紧为
T-210 → T-205 → T-206 → T-207 → T-208。 T-210 → T-205 → T-206 → T-207 → T-208。
- T-303 已完成并通过三重审查;T-304 的轮询会话、配置页和固定双 Tab 主界面已通过两轮独立代码审计, - T-303 已完成并通过三重审查;T-304 的轮询会话、配置页和固定双 Tab 主界面已通过两轮独立代码审计,
当前 240 项客户端测试与根门禁通过,继续保持 `DOING` 等原生 Windows DPI、主题、高对比度、 当前 240 项客户端测试与根门禁通过,继续保持 `DOING` 等原生 Windows DPI、主题、高对比度、
+12 -1
View File
@@ -22,7 +22,7 @@ write_paths:
- docs/current-state.md - docs/current-state.md
--- ---
<!-- BEGIN VIKUNJA EXPORT id=23 synced=2026-08-05T02:15:51Z sha256=4d24a78f3e69b08f44255093d6c95897aba647953fb78e0f4e143a9c91cc74e9 --> <!-- BEGIN VIKUNJA EXPORT id=23 synced=2026-08-05T02:33:54Z sha256=a7dbe75340cdc1f30d51ac1c4ad3f83888e38c7683bc171139a774622e365e98 -->
## 问题 / 背景 ## 问题 / 背景
T-102 已证明 canonical 链接可进入目标商品。T-103 在 PKG110 / Android 16 / 拼多多 8.17.0、goods_id `937122477375` 上确认:规格面板由详情页精确唯一的“快要抢光”打开;T-110 已把该证据/版本绑定入口批准为受控导航。面板刚打开时目标颜色“黑色CHA(纯棉)”和尺码“M(建议100-115)”自动选中。 T-102 已证明 canonical 链接可进入目标商品。T-103 在 PKG110 / Android 16 / 拼多多 8.17.0、goods_id `937122477375` 上确认:规格面板由详情页精确唯一的“快要抢光”打开;T-110 已把该证据/版本绑定入口批准为受控导航。面板刚打开时目标颜色“黑色CHA(纯棉)”和尺码“M(建议100-115)”自动选中。
@@ -206,6 +206,17 @@ T-103 sanitizer v2 坐标修正与主审:提交 44c027a 将 screenshot space
### 2026-08-05T02:15:29Z · ila ### 2026-08-05T02:15:29Z · ila
更正 comment 105:最新 sku-entry-stability 证据的 screenshot.png 由 root 只读视觉核对为目标商品页;项目所有者尚未对本次证据做最终人工页面对应性验收。该更正不改变 T-103 Doing/needs_device/needs_human_review。 更正 comment 105:最新 sku-entry-stability 证据的 screenshot.png 由 root 只读视觉核对为目标商品页;项目所有者尚未对本次证据做最终人工页面对应性验收。该更正不改变 T-103 Doing/needs_device/needs_human_review。
### 2026-08-05T02:33:17Z · ila
2026-08-05 T-103 第二次 sku_entry 复跑与安全子阶段诊断:
- 项目所有者关闭“肉包采购辅助”浮窗、force-stop `com.oplus.games`、仅保留 Wi-Fi ADB,并确认拼多多 `MainFrameActivity` 首页后再次复跑;仍返回 `stage=sku_entry`,安全停止且未发布目录。
- root 只读核对失败后的商品页:拼多多 `NewPageActivity`,`physical=1`、`safe projection=true`、`eligible=1`、`panel=false`;点击中心仅存在唯一 PDD action,连续 3 帧入口动作子树 projection hash 一致。
- 项目所有者返回首页后,root 只读核对 `physical=0`。因此排除当前首页旧链误判、商品入口结构漂移和浮窗覆盖,剩余待区分范围为 click RPC/点击生效与 panel postcondition 两段。
- 提交 `b754cc9` 新增固定、安全且带 Flow provenance 的四个子阶段:`sku_entry_pre_intent`、`sku_entry_discovery`、`sku_entry_click`、`sku_entry_panel_verify`。本次不修改页面判据;入口仍单击且不重试,finally 安全调和、失败不发布和 T-103 能力边界保持不变。
- 验证通过:聚焦 5/5、T-103 43/43、compileall、agent-context、diff-check。依项目所有者“最小验收即停止”的要求,本次未重复完整全量门禁。
- T-103 继续 Doing,保留 `needs_device` / `needs_human_review`;等待更新代码后从拼多多首页再次复跑并反馈新的安全子阶段。
<!-- END VIKUNJA EXPORT --> <!-- END VIKUNJA EXPORT -->
## 边界 ## 边界