Compare commits

...
2 Commits
Author SHA1 Message Date
QiuSW e45d26fe93 docs(tasks): record T-103 entry evidence 2026-08-05 10:16:05 +08:00
QiuSW a09c58beef fix(client): stabilize verified sku entry 2026-08-05 10:10:56 +08:00
4 changed files with 374 additions and 40 deletions
+127 -28
View File
@@ -20,6 +20,10 @@ _ENTRY = "快要抢光"
_ENTRY_TEXT_BOUNDS = "[900,1312][1056,1355]" _ENTRY_TEXT_BOUNDS = "[900,1312][1056,1355]"
_ENTRY_INNER_BOUNDS = "[712,1312][1056,1355]" _ENTRY_INNER_BOUNDS = "[712,1312][1056,1355]"
_ENTRY_ACTION_BOUNDS = "[0,1256][1080,1355]" _ENTRY_ACTION_BOUNDS = "[0,1256][1080,1355]"
_FORBIDDEN_ENTRY_ACTION_DESC = (
"购买", "下单", "付款", "订单", "免拼购买", "单独购买", "直接拼成", "提交订单", "支付",
"先用后付", "0元下单", "0 元下单",
)
_SIZE = "尺码" _SIZE = "尺码"
_W, _H = 1080, 2376 _W, _H = 1080, 2376
_PRICE_PARENT = "[396,498][895,570]" _PRICE_PARENT = "[396,498][895,570]"
@@ -89,7 +93,9 @@ class SkuSelectionFlow:
raise SkuSelectionError("商品不是已取证目标,已停止操作。") raise SkuSelectionError("商品不是已取证目标,已停止操作。")
if pre_intent_hierarchy is not None: if pre_intent_hierarchy is not None:
previous_nodes = _parse_nodes(pre_intent_hierarchy) previous_nodes = _parse_nodes(pre_intent_hierarchy)
if _eligible_entries(previous_nodes): # intent 前只判断旧页是否已经存在完整入口链;浮层或额外动作节点不能把旧商品
# 伪装成“不安全所以不存在”,否则 intent 后可能误把旧页当成新目标页。
if _physical_entries(previous_nodes):
raise SkuSelectionError("intent 前页面已出现规格入口,已拒绝旧商品误点。") raise SkuSelectionError("intent 前页面已出现规格入口,已拒绝旧商品误点。")
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)
@@ -168,13 +174,19 @@ class SkuSelectionFlow:
current = self._device.app_current() current = self._device.app_current()
if isinstance(current, dict) and current.get("package") == PDD_PACKAGE: if isinstance(current, dict) and current.get("package") == PDD_PACKAGE:
raw = self._read_hierarchy() raw = self._read_hierarchy()
entries = _eligible_entries(_parse_nodes(raw)) nodes = _parse_nodes(raw)
entries = _eligible_entries(nodes)
if len(entries) > 1: if len(entries) > 1:
raise SkuSelectionError("商品页规格入口不唯一,已停止操作。") raise SkuSelectionError("商品页规格入口不唯一,已停止操作。")
if len(entries) == 1 and raw != previous: if len(entries) == 1 and raw != previous:
if stable == raw: # 商品详情正文包含倒计时等动态节点,全文 XML 稳定不是已取证入口的安全属性。
# 连续两帧只比较完整五层入口判据投影;任一层失配仍会在 eligible 阶段清空稳定态。
projection = _entry_projection(entries[0], nodes)
if projection is None:
raise SkuSelectionError("商品页规格入口结构失效,已停止操作。")
if stable == projection:
return entries[0], raw return entries[0], raw
stable = raw stable = projection
else: else:
stable = None stable = None
else: else:
@@ -358,41 +370,128 @@ def _eligible_entries(nodes: list[_Node]) -> list[_Node]:
# 入口文本本身不可点击:必须逐层证明它仍位于已取证的唯一可点击祖先中,但动作坐标继续 # 入口文本本身不可点击:必须逐层证明它仍位于已取证的唯一可点击祖先中,但动作坐标继续
# 使用文本子节点的窄 bounds,避免把同一祖先内未知区域变成坐标兜底。“免拼购买”等底部 # 使用文本子节点的窄 bounds,避免把同一祖先内未知区域变成坐标兜底。“免拼购买”等底部
# 容器既不属于这条祖先链,也绝不能作为替代入口。 # 容器既不属于这条祖先链,也绝不能作为替代入口。
if any(node.bounds == _PRICE_PARENT for node in nodes): return [] if any(node.bounds == _PRICE_PARENT for node in nodes):
return []
entries = _physical_entries(nodes)
if len(entries) != 1:
return entries
entry = entries[0]
chain = _physical_entry_chain(entry)
if chain is None or _entry_projection(entry, nodes) is None:
return []
ancestor = chain[-1]
left, top, right, bottom = _action_bounds(entry.bounds)
center = (left + (right - left) // 2, top + (bottom - top) // 2)
occupants = _live_clickables_covering(nodes, center)
# RPC 点的是文本中心而不是祖先对象。只有完整入口链自己的动作祖先占用该坐标时才可点击;
# SystemUI 浮层、额外按钮或任意部分覆盖矩形都可能截获触摸,必须零点击失败关闭。
return entries if len(occupants) == 1 and occupants[0].element is ancestor.element else []
def _physical_entries(nodes: list[_Node]) -> list[_Node]:
entry_labels = [ entry_labels = [
node for node in nodes node for node in nodes
if node.text == _ENTRY if node.text == _ENTRY
and node.element.get("package") == PDD_PACKAGE and node.element.get("package") == PDD_PACKAGE
and node.element.get("class") == "android.widget.TextView" and node.element.get("class") == "android.widget.TextView"
] ]
if len(entry_labels) != 1: # 这里故意只证明物理五层链。pre-intent 必须识别旧商品,不能让禁词、浮层或其他
return entry_labels # post-intent 安全条件把已经存在的旧入口伪装成“不存在”。
action_ancestors = [ return [node for node in entry_labels if _physical_entry_chain(node) is not None]
node for node in nodes
if _exact_entry_node(node, "android.view.ViewGroup", _ENTRY_ACTION_BOUNDS, "true")
] def _physical_entry_chain(node: _Node) -> tuple[_Node, ...] | None:
if len(action_ancestors) != 1: if node.text != _ENTRY or not _exact_entry_node(
return [] node, "android.widget.TextView", _ENTRY_TEXT_BOUNDS, "false"
action_ancestor = action_ancestors[0] ) or len(node.element) != 0:
entries: list[_Node] = [] return None
for node in entry_labels:
if not _exact_entry_node(node, "android.widget.TextView", _ENTRY_TEXT_BOUNDS, "false"):
return []
inner = node.parent inner = node.parent
switcher = inner.parent if inner is not None else None switcher = inner.parent if inner is not None else None
frame = switcher.parent if switcher is not None else None frame = switcher.parent if switcher is not None else None
ancestor = frame.parent if frame is not None else None ancestor = frame.parent if frame is not None else None
if ( chain = (
inner is not None (node, "android.widget.TextView", _ENTRY_TEXT_BOUNDS, "false"),
and _exact_entry_node(inner, "android.view.ViewGroup", _ENTRY_INNER_BOUNDS, "false") (inner, "android.view.ViewGroup", _ENTRY_INNER_BOUNDS, "false"),
and switcher is not None (switcher, "android.widget.ViewSwitcher", _ENTRY_INNER_BOUNDS, "false"),
and _exact_entry_node(switcher, "android.widget.ViewSwitcher", _ENTRY_INNER_BOUNDS, "false") (frame, "android.widget.FrameLayout", _ENTRY_INNER_BOUNDS, "false"),
and frame is not None (ancestor, "android.view.ViewGroup", _ENTRY_ACTION_BOUNDS, "true"),
and _exact_entry_node(frame, "android.widget.FrameLayout", _ENTRY_INNER_BOUNDS, "false") )
and ancestor is action_ancestor if any(
candidate is None or not _exact_entry_node(candidate, class_name, bounds, clickable)
for candidate, class_name, bounds, clickable in chain
): ):
entries.append(node) return None
return entries return tuple(candidate for candidate, _, _, _ in chain if candidate is not None)
def _entry_projection(node: _Node, nodes: list[_Node]) -> tuple[object, ...] | None:
chain = _physical_entry_chain(node)
if chain is None:
return None
_, inner, switcher, frame, ancestor = chain
if node.desc:
return None
if any(candidate.text or candidate.desc for candidate in (inner, switcher, frame)):
return None
if (
ancestor.text
or (ancestor.desc and not ancestor.desc.endswith(_ENTRY))
or any(forbidden in ancestor.desc for forbidden in _FORBIDDEN_ENTRY_ACTION_DESC)
):
return None
subtree = [
candidate for candidate in nodes
if candidate.element is ancestor.element or _descendant(candidate, ancestor)
]
if any(
forbidden in value
for candidate in subtree
for value in (candidate.text, candidate.desc)
for forbidden in _FORBIDDEN_ENTRY_ACTION_DESC
):
return None
entry_text_nodes = [candidate for candidate in subtree if candidate.text == _ENTRY]
if len(entry_text_nodes) != 1 or entry_text_nodes[0].element is not node.element:
return None
# 动作祖先整棵子树按真实层级和顺序投影。允许非危险动态正文存在,但任一结构或语义
# 在两帧间变化都不会点击;正文只作为不透明稳定键,不从中解析详情价。
return _subtree_projection(ancestor.element)
def _subtree_projection(element: ElementTree.Element) -> tuple[object, ...]:
return (
element.tag,
element.get("package", ""),
element.get("class", ""),
element.get("bounds", ""),
element.get("clickable", ""),
element.get("enabled", ""),
element.get("visible-to-user", ""),
element.get("text", ""),
element.get("content-desc", ""),
tuple(_subtree_projection(child) for child in element),
)
def _is_live_clickable(node: _Node) -> bool:
return (
node.element.get("clickable") == "true"
and node.element.get("enabled") == "true"
and node.element.get("visible-to-user") == "true"
)
def _live_clickables_covering(nodes: list[_Node], point: tuple[int, int]) -> list[_Node]:
occupants: list[_Node] = []
x, y = point
for node in nodes:
if not _is_live_clickable(node):
continue
# 活跃可点击节点的 bounds 无法验证时,无法证明它不会截获入口坐标,因此整体失败关闭。
left, top, right, bottom = _action_bounds(node.bounds)
if left <= x < right and top <= y < bottom:
occupants.append(node)
return occupants
def _exact_entry_node(node: _Node, class_name: str, bounds: str, clickable: str) -> bool: def _exact_entry_node(node: _Node, class_name: str, bounds: str, clickable: str) -> bool:
+218 -1
View File
@@ -143,6 +143,86 @@ def _duplicate_entry() -> str:
return ElementTree.tostring(root, encoding="unicode") return ElementTree.tostring(root, encoding="unicode")
def _extra_entry_action_ancestor() -> str:
root = ElementTree.fromstring(_PRODUCT_PAGE)
action = _entry_chain(root)[4]
root.append(ElementTree.Element("node", dict(action.attrib)))
return ElementTree.tostring(root, encoding="unicode")
def _entry_with_panel_price_marker() -> str:
root = ElementTree.fromstring(_PRODUCT_PAGE)
root.append(ElementTree.Element("node", {"bounds": "[396,498][895,570]"}))
return ElementTree.tostring(root, encoding="unicode")
def _dynamic_product_page(value: str, action_desc: str = "") -> str:
root = ElementTree.fromstring(_PRODUCT_PAGE)
root.set("dynamic-page-value", value)
_entry_chain(root)[4].set("content-desc", action_desc)
return ElementTree.tostring(root, encoding="unicode")
def _with_overlapping_clickable(package: str, class_name: str, bounds: str) -> str:
root = ElementTree.fromstring(_PRODUCT_PAGE)
root.append(
ElementTree.Element(
"node",
{
"package": package,
"class": class_name,
"bounds": bounds,
"clickable": "true",
"enabled": "true",
"visible-to-user": "true",
},
)
)
return ElementTree.tostring(root, encoding="unicode")
def _with_action_subtree_child(
text: str,
*,
bounds: str = "[10,1260][100,1300]",
) -> str:
root = ElementTree.fromstring(_PRODUCT_PAGE)
action = _entry_chain(root)[4]
ElementTree.SubElement(
action,
"node",
{
"text": text,
"package": "com.xunmeng.pinduoduo",
"class": "android.widget.TextView",
"bounds": bounds,
"clickable": "false",
"enabled": "true",
"visible-to-user": "true",
},
)
return ElementTree.tostring(root, encoding="unicode")
def _home_with_unverified_entry_labels() -> str:
root = ElementTree.Element("hierarchy")
for index in range(2):
ElementTree.SubElement(
root,
"node",
{
"text": "快要抢光",
"package": "com.xunmeng.pinduoduo",
"class": "android.widget.TextView",
"clickable": "false",
"enabled": "true",
"visible-to-user": "true",
"bounds": f"[{index},{index}][{index + 1},{index + 1}]",
},
)
return ElementTree.tostring(root, encoding="unicode")
def _actions(device: _RawDevice, method: str) -> list[tuple[object, ...]]: def _actions(device: _RawDevice, method: str) -> list[tuple[object, ...]]:
return [call for call in device.calls if call[0] == "jsonrpc" and call[1] == method] return [call for call in device.calls if call[0] == "jsonrpc" and call[1] == method]
@@ -202,6 +282,73 @@ class SkuSelectionFlowTests(unittest.TestCase):
self.assertEqual(_tap_centers(device), [(978, 1333)]) self.assertEqual(_tap_centers(device), [(978, 1333)])
def test_entry_stability_uses_verified_chain_projection_not_whole_xml(self) -> None:
class DynamicProductDevice(_RawDevice):
def __init__(self) -> None:
super().__init__()
self.frame = 0
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
if method == "dumpWindowHierarchy" and "快要抢光" in self.hierarchy:
self.frame += 1
self.hierarchy = _dynamic_product_page(str(self.frame), "活动剩余快要抢光")
return super().jsonrpc_call(method, params, timeout)
now = [0.0]
device = DynamicProductDevice()
flow = SkuSelectionFlow(
UiautomatorSkuPanelAdapter(device, 10),
0.03,
0.01,
lambda: now[0],
lambda seconds: now.__setitem__(0, now[0] + seconds),
)
flow.open_sku_panel(_TARGET_URL, "<hierarchy />")
self.assertEqual(device.frame, 2)
self.assertEqual(_tap_centers(device), [(978, 1333)])
def test_entry_action_description_must_be_stable_across_frames(self) -> None:
class ChangingActionDescriptionDevice(_RawDevice):
def __init__(self) -> None:
super().__init__()
self.frame = 0
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
if method == "dumpWindowHierarchy":
self.frame += 1
self.hierarchy = _dynamic_product_page(
str(self.frame),
f"活动{self.frame}快要抢光",
)
return super().jsonrpc_call(method, params, timeout)
now = [0.0]
device = ChangingActionDescriptionDevice()
flow = SkuSelectionFlow(
UiautomatorSkuPanelAdapter(device, 10),
0.02,
0.01,
lambda: now[0],
lambda seconds: now.__setitem__(0, now[0] + seconds),
)
with self.assertRaises(SkuSelectionError):
flow.open_sku_panel(_TARGET_URL, "<hierarchy />")
self.assertEqual(_actions(device, "click"), [])
def test_unverified_home_entry_labels_do_not_trigger_old_product_rejection(self) -> None:
device = _RawDevice()
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).open_sku_panel(
_TARGET_URL,
_home_with_unverified_entry_labels(),
)
self.assertEqual(_tap_centers(device), [(978, 1333)])
def test_entry_child_and_every_ancestor_attribute_drift_never_clicks(self) -> None: def test_entry_child_and_every_ancestor_attribute_drift_never_clicks(self) -> None:
expected_clickable = ("false", "false", "false", "false", "true") expected_clickable = ("false", "false", "false", "false", "true")
for depth in range(5): for depth in range(5):
@@ -217,8 +364,74 @@ class SkuSelectionFlowTests(unittest.TestCase):
with self.subTest(depth=depth, attribute=attribute): with self.subTest(depth=depth, attribute=attribute):
self._assert_entry_rejected_without_click(_mutate_entry(depth, attribute, value)) self._assert_entry_rejected_without_click(_mutate_entry(depth, attribute, value))
def test_entry_chain_text_and_description_drift_never_clicks(self) -> None:
cases = [
_mutate_entry(depth, "text", "祖先文字漂移") for depth in range(1, 5)
] + [
_mutate_entry(depth, "content-desc", "祖先描述漂移") for depth in range(0, 4)
] + [
_mutate_entry(4, "content-desc", "祖先描述漂移")
] + [
_mutate_entry(4, "content-desc", forbidden + "快要抢光")
for forbidden in (
"免拼购买", "单独购买", "直接拼成", "提交订单", "支付", "先用后付", "0元下单",
"立即购买", "确认下单", "立即付款", "订单详情",
)
]
for hierarchy in cases:
with self.subTest():
self._assert_entry_rejected_without_click(hierarchy)
def test_any_live_clickable_covering_entry_center_blocks_click(self) -> None:
cases = (
_with_overlapping_clickable(
"com.xunmeng.pinduoduo",
"android.widget.Button",
"[950,1300][1000,1340]",
),
_with_overlapping_clickable(
"com.android.systemui",
"android.view.ViewGroup",
"[936,1208][1080,1352]",
),
_with_overlapping_clickable(
"com.android.systemui",
"android.view.ViewGroup",
"not-a-bound",
),
)
for hierarchy in cases:
with self.subTest():
self._assert_entry_rejected_without_click(hierarchy)
def test_pre_intent_old_entry_is_rejected_even_with_extra_action_node(self) -> None:
for old_page in (
_extra_entry_action_ancestor(),
_entry_with_panel_price_marker(),
_mutate_entry(4, "content-desc", "立即购买快要抢光"),
):
with self.subTest():
device = _RawDevice()
flow = SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10))
with self.assertRaises(SkuSelectionError):
flow.open_sku_panel(_TARGET_URL, old_page)
self.assertEqual(_actions(device, "click"), [])
def test_action_subtree_dangerous_or_ambiguous_children_never_click(self) -> None:
cases = (
_with_action_subtree_child("提交订单"),
_with_action_subtree_child("免拼购买"),
_with_action_subtree_child("快要抢光"),
)
for hierarchy in cases:
with self.subTest():
self._assert_entry_rejected_without_click(hierarchy)
def test_duplicate_entry_and_forbidden_sibling_entry_never_click(self) -> None: def test_duplicate_entry_and_forbidden_sibling_entry_never_click(self) -> None:
self._assert_entry_rejected_without_click(_duplicate_entry()) self._assert_entry_rejected_without_click(_duplicate_entry())
self._assert_entry_rejected_without_click(_extra_entry_action_ancestor())
self._assert_entry_rejected_without_click(_without_entry()) self._assert_entry_rejected_without_click(_without_entry())
self._assert_entry_rejected_without_click(_mutate_entry(0, "clickable", "true")) self._assert_entry_rejected_without_click(_mutate_entry(0, "clickable", "true"))
@@ -555,7 +768,11 @@ class SkuSelectionRunnerTests(unittest.TestCase):
now = [0.0] now = [0.0]
class SequenceDevice(_RawDevice): class SequenceDevice(_RawDevice):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__(); self.frames = [_PRODUCT_PAGE, "<hierarchy />", _PRODUCT_PAGE] super().__init__(); self.frames = [
_dynamic_product_page("first"),
"<hierarchy />",
_dynamic_product_page("second"),
]
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str: def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
if method == "dumpWindowHierarchy" and self.frames: if method == "dumpWindowHierarchy" and self.frames:
self.hierarchy = self.frames.pop(0) self.hierarchy = self.frames.pop(0)
+9 -6
View File
@@ -11,7 +11,7 @@
## 当前快照 ## 当前快照
- 日期:2026-08-04 - 日期:2026-08-05
- 阶段:**Phase 1 · T-103 规格选择/读价与 Phase 2 安全服务端任务并行** - 阶段:**Phase 1 · T-103 规格选择/读价与 Phase 2 安全服务端任务并行**
- MVP 形态:手工填链接建单 → 批量“开始采购”并签发一次性授权 → 定时领取 → 同一趟精确 - MVP 形态:手工填链接建单 → 批量“开始采购”并签发一次性授权 → 定时领取 → 同一趟精确
选规格 / 三道价格闸门 / 提交围栏 / 点击一次 → 待付款。管理员点击“开始采购”即授权桌面端 选规格 / 三道价格闸门 / 提交围栏 / 点击一次 → 待付款。管理员点击“开始采购”即授权桌面端
@@ -29,7 +29,7 @@
显式本地配置、停止/重启会话边界和安全轮询协调器已实现;未注入单趟执行 consumer 时保持零领取。 显式本地配置、停止/重启会话边界和安全轮询协调器已实现;未注入单趟执行 consumer 时保持零领取。
真机能力与桌面界面仍待最终人工验收,数量/确认页/提交仍未开放。 真机能力与桌面界面仍待最终人工验收,数量/确认页/提交仍未开放。
- 测试:采购服务已覆盖登录、建单、授权、详情/证据、设备身份、迁移、原子 claim/renew 与竞态; - 测试:采购服务已覆盖登录、建单、授权、详情/证据、设备身份、迁移、原子 claim/renew 与竞态;
采购工具 233 项离线单元测试(全部 mock,不连接真机)。 采购工具 240 项离线单元测试(全部 mock,不连接真机)。
- 数据:受保护迁移已落到 `00005_task_claims.sql`。旧两趟模型已移除;开始采购会锁定任务快照并签发 - 数据:受保护迁移已落到 `00005_task_claims.sql`。旧两趟模型已移除;开始采购会锁定任务快照并签发
一次性授权,领取会创建唯一 attempt/claim、HMAC claim token 与有界租约。T-211 已统一 title、SKU、 一次性授权,领取会创建唯一 attempt/claim、HMAC claim token 与有界租约。T-211 已统一 title、SKU、
goods_id 与金额的双端 wire 上限,并证明最坏合法 claim 响应小于 32 KiB。仓库不含业务实例数据。 goods_id 与金额的双端 wire 上限,并证明最坏合法 claim 响应小于 32 KiB。仓库不含业务实例数据。
@@ -45,8 +45,10 @@
手机号,并保留目标预选规格、顶部当前价“快卖完 ¥12.88”和原价“¥29.88”;底部“提交订单 ¥12.88” 手机号,并保留目标预选规格、顶部当前价“快卖完 ¥12.88”和原价“¥29.88”;底部“提交订单 ¥12.88”
继续属于硬拒绝区。派生截图虽然把顶部价格遮住一半,但项目已停止遮罩器开发,视觉完整性不再阻塞 继续属于硬拒绝区。派生截图虽然把顶部价格遮住一半,但项目已停止遮罩器开发,视觉完整性不再阻塞
规格选择与读价;T-204 已允许认证设备上传内部原始截图供管理员查看。入口父链证据已由人确认并 规格选择与读价;T-204 已允许认证设备上传内部原始截图供管理员查看。入口父链证据已由人确认并
收紧为精确唯一“快要抢光”子节点及证据绑定可点击祖先,“免拼购买”仍为硬拒绝。离线实现和 112 项 收紧为精确唯一“快要抢光”子节点及证据绑定可点击祖先,“免拼购买”仍为硬拒绝。最新真机证据
测试已通过;T-103 只差人把手机置于 PDD 安全起点后完成精确规格恢复、读取 `12.88` 和一次安全退出。 证明商品页全文 XML 会随动态正文持续变化,但入口动作子树保持稳定;实现已改为连续验证该精确子树,
并拒绝任何覆盖入口点击中心的额外可点击控件。当前还发现 SystemUI“肉包采购辅助”浮窗覆盖入口中心,
复跑前必须由人关闭。T-103 只差关闭浮窗后完成精确规格恢复、读取 `12.88` 和一次安全退出。
完整事件链必须等待 T-107 与 T-210:先让 Gate2/Gate3 证据进入真实 schema,再实现 T-205 事件, 完整事件链必须等待 T-107 与 T-210:先让 Gate2/Gate3 证据进入真实 schema,再实现 T-205 事件,
不用 mock 或无证据成功事件抢跑。 不用 mock 或无证据成功事件抢跑。
@@ -79,10 +81,11 @@
稳定重放和续租;claim token 不是提交许可,尚无事件、提交围栏或结果 API。 稳定重放和续租;claim token 不是提交许可,尚无事件、提交围栏或结果 API。
- T-103 离线 `SkuSelectionFlow` 已实现并通过独立审计,仍为 `DOING` 等人工真机验收;T-104~T-107 - T-103 离线 `SkuSelectionFlow` 已实现并通过独立审计,仍为 `DOING` 等人工真机验收;T-104~T-107
按数量/确认页风险逐段开放。真机运行要求同一物理手机只保留一个 ADB 通道;CLI 已增加不回显页面 按数量/确认页风险逐段开放。真机运行要求同一物理手机只保留一个 ADB 通道;CLI 已增加不回显页面
正文的固定白名单失败阶段码,用于继续定位最新的安全失败。服务端后续顺序已收紧为 正文的固定白名单失败阶段码;`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 主界面已通过两轮独立代码审计,
合并后 233 项客户端测试与根门禁通过,继续保持 `DOING` 等原生 Windows DPI、主题、高对比度、 当前 240 项客户端测试与根门禁通过,继续保持 `DOING` 等原生 Windows DPI、主题、高对比度、
Narrator 和纯键盘人工验收。客户端后续按依赖推进 T-306/T-307/T-308,再与 T-305 汇合到 Narrator 和纯键盘人工验收。客户端后续按依赖推进 T-306/T-307/T-308,再与 T-305 汇合到
T-400 离线一次性提交安全编排。围栏后管理链为 T-403 → T-402;T-401 必须在新的真实单趟重新 T-400 离线一次性提交安全编排。围栏后管理链为 T-403 → T-402;T-401 必须在新的真实单趟重新
通过三闸门,首次点击后固定进入 `UNKNOWN`/人工调和,T-405 打包后由 T-404 只读验收。 通过三闸门,首次点击后固定进入 `UNKNOWN`/人工调和,T-405 打包后由 T-404 只读验收。
+16 -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-05T01:23:53Z sha256=bdf9d253b198f67579cf9cd6e7c98a6dd4dd6657828dda14bebc8ea8eff2f605 --> <!-- BEGIN VIKUNJA EXPORT id=23 synced=2026-08-05T02:15:51Z sha256=4d24a78f3e69b08f44255093d6c95897aba647953fb78e0f4e143a9c91cc74e9 -->
## 问题 / 背景 ## 问题 / 背景
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)”自动选中。
@@ -191,6 +191,21 @@ T-103 sanitizer v2 坐标修正与主审:提交 44c027a 将 screenshot space
- 主线提交 `870e5f8` 为真机 CLI 增加只来自固定白名单的 failure stage;异常正文、serial、路径与 XML 均不回显,hostile getter、`str` 子类和 setter 全部失败闭合为 `unknown`,原异常类型、finally 安全调和、单击不重试与失败不发布语义保持不变。 - 主线提交 `870e5f8` 为真机 CLI 增加只来自固定白名单的 failure stage;异常正文、serial、路径与 XML 均不回显,hostile getter、`str` 子类和 setter 全部失败闭合为 `unknown`,原异常类型、finally 安全调和、单击不重试与失败不发布语义保持不变。
- 独立终审 PASS;T-103 33 tests PASS,client 全量 233 tests PASS,仓库根 `init.ps1` PASS。 - 独立终审 PASS;T-103 33 tests PASS,client 全量 233 tests PASS,仓库根 `init.ps1` PASS。
- 尚待项目所有者使用新 CLI 再次真机复跑;T-103 保持 done=false / Doing,并继续保留 needs_device 与 needs_human_review 门禁。 - 尚待项目所有者使用新 CLI 再次真机复跑;T-103 保持 done=false / Doing,并继续保留 needs_device 与 needs_human_review 门禁。
### 2026-08-05T02:14:16Z · ila
2026-08-05 T-103 真机复跑与入口稳定性证据:
- 项目所有者复跑返回 `stage=sku_entry`,流程安全停止且未发布本地证据目录。
- root 只读保存本机证据 `C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-103\sku-entry-stability-937122477375-20260805-093541`,manifest 位于同目录。证据绑定 PKG110 / Android 16 / 拼多多 8.17.0 / goods_id `937122477375` / 1080×2376;5 份完整 XML 的 hash 与节点数发生变化,但完整、精确的入口动作子树投影连续一致,截图由项目所有者确认是目标商品页。
- 同时确认 SystemUI“肉包采购辅助”浮窗覆盖入口中心,会截获入口点击;因此真机复跑前必须先关闭该浮窗。
- 提交 `a09c58b`:pre-intent 只按完整物理旧链防止误点;动态整页稳定性改为精确入口动作子树连续稳定;任何覆盖入口中心的 live clickable 节点一律拒绝;危险语义和重复入口继续拒绝。入口仍只点击一次且不重试,不包含数量、确认页、提交或支付能力。
- 验证通过:聚焦测试 9/9、T-103 40/40、仓库根 `init.ps1` PASS(client 240 项)。
- `needs_device=true` / `needs_human_review=true`,T-103 继续 Doing;等待项目所有者关闭浮窗、返回拼多多首页并再次真机复跑。
### 2026-08-05T02:15:29Z · ila
更正 comment 105:最新 sku-entry-stability 证据的 screenshot.png 由 root 只读视觉核对为目标商品页;项目所有者尚未对本次证据做最终人工页面对应性验收。该更正不改变 T-103 Doing/needs_device/needs_human_review。
<!-- END VIKUNJA EXPORT --> <!-- END VIKUNJA EXPORT -->
## 边界 ## 边界