fix: 安全处理采购数量输入法 (#117)
This commit is contained in:
@@ -61,6 +61,13 @@ _PAYMENT_MARKERS = ("输入支付密码", "立即支付", "支付成功", "支
|
||||
_FINAL_SUBMIT_MARKERS = ("提交订单", "现在买,仅", "确认购买")
|
||||
_OUT_OF_STOCK_MARKERS = ("已售罄", "暂时缺货", "库存不足", "该商品已售罄")
|
||||
_SUPPORTED_OPTION_KEYS = frozenset({"color", "size"})
|
||||
_MAX_QUANTITY_BUTTON_CLICKS = 5
|
||||
_KEYBOARD_STATUS_MARKERS = (
|
||||
"minputshown",
|
||||
"misinputviewshown",
|
||||
"minputviewshown",
|
||||
"inputshown",
|
||||
)
|
||||
_DIAGNOSTIC_MARKERS = (
|
||||
"增加数量",
|
||||
"减少数量",
|
||||
@@ -196,6 +203,52 @@ def _quantity(root: ET.Element) -> int:
|
||||
return values[0] if len(values) == 1 else 0
|
||||
|
||||
|
||||
def _quantity_button_targets(root: ET.Element, description: str) -> list[Bounds]:
|
||||
"""返回唯一、可点击的数量加减按钮坐标候选。"""
|
||||
|
||||
targets = set()
|
||||
for node in root.iter("node"):
|
||||
labels = {
|
||||
node.get("text", "").strip(),
|
||||
node.get("content-desc", "").strip(),
|
||||
}
|
||||
bounds = _parse_bounds(node.get("bounds", ""))
|
||||
if description not in labels or bounds is None:
|
||||
continue
|
||||
if node.get("clickable") != "true":
|
||||
continue
|
||||
if node.get("enabled") == "false" or node.get("visible-to-user") == "false":
|
||||
continue
|
||||
targets.add(bounds)
|
||||
return sorted(targets)
|
||||
|
||||
|
||||
def _shell_output(response: Any) -> str:
|
||||
"""兼容 uiautomator2 ShellResponse 和测试中的普通字符串。"""
|
||||
|
||||
output = getattr(response, "output", response)
|
||||
if isinstance(output, bytes):
|
||||
return output.decode("utf-8", errors="replace")
|
||||
return str(output or "")
|
||||
|
||||
|
||||
def _keyboard_is_shown(device: Any) -> Optional[bool]:
|
||||
"""读取安卓输入法状态;无法可靠判断时返回 None。"""
|
||||
|
||||
try:
|
||||
response = device.shell("dumpsys input_method")
|
||||
except (AttributeError, OSError, RuntimeError):
|
||||
return None
|
||||
normalized = re.sub(r"\s+", "", _shell_output(response)).lower()
|
||||
found_false = False
|
||||
for marker in _KEYBOARD_STATUS_MARKERS:
|
||||
if f"{marker}=true" in normalized:
|
||||
return True
|
||||
if f"{marker}=false" in normalized:
|
||||
found_false = True
|
||||
return False if found_false else None
|
||||
|
||||
|
||||
def _price_cent(root: ET.Element) -> int:
|
||||
"""优先读取页面上半部的当前单价,不把划线价当成当前价。"""
|
||||
|
||||
@@ -439,26 +492,150 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter):
|
||||
)
|
||||
self._check_cancelled("purchase_set_quantity")
|
||||
try:
|
||||
editor = device(className="android.widget.EditText")
|
||||
if int(getattr(editor, "count", 0)) != 1:
|
||||
root = _parse_xml(self._dump_hierarchy())
|
||||
current_quantity = _quantity(root)
|
||||
if current_quantity <= 0:
|
||||
raise PddPurchaseError(
|
||||
"PURCHASE_QUANTITY_CONTROL_MISSING",
|
||||
"页面没有唯一可靠的采购数量输入框",
|
||||
step="purchase_set_quantity",
|
||||
)
|
||||
editor.set_text(str(quantity))
|
||||
self._sleep(0.2)
|
||||
if _quantity(_parse_xml(self._dump_hierarchy())) != quantity:
|
||||
raise PddPurchaseError(
|
||||
"PURCHASE_QUANTITY_MISMATCH",
|
||||
"PDD 页面没有保存目标采购数量",
|
||||
step="purchase_set_quantity",
|
||||
)
|
||||
if current_quantity == quantity:
|
||||
return
|
||||
|
||||
difference = quantity - current_quantity
|
||||
if abs(difference) <= _MAX_QUANTITY_BUTTON_CLICKS:
|
||||
description = "增加数量" if difference > 0 else "减少数量"
|
||||
targets = _quantity_button_targets(root, description)
|
||||
if len(targets) == 1:
|
||||
self._set_quantity_with_buttons(
|
||||
current_quantity, quantity, description
|
||||
)
|
||||
return
|
||||
|
||||
self._set_quantity_with_editor(quantity)
|
||||
except PddPurchaseError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
self._raise_device_or_page_error(exc, "purchase_set_quantity")
|
||||
|
||||
def _set_quantity_with_buttons(
|
||||
self, current_quantity: int, target_quantity: int, description: str
|
||||
) -> None:
|
||||
"""逐次点击加减按钮,每次都用最新控件树确认数量。"""
|
||||
|
||||
device = self._require_device()
|
||||
step = 1 if target_quantity > current_quantity else -1
|
||||
expected = current_quantity
|
||||
while expected != target_quantity:
|
||||
self._check_cancelled("purchase_set_quantity")
|
||||
root = _parse_xml(self._dump_hierarchy())
|
||||
targets = _quantity_button_targets(root, description)
|
||||
if len(targets) != 1:
|
||||
raise PddPurchaseError(
|
||||
"PURCHASE_QUANTITY_CONTROL_MISSING",
|
||||
f"页面没有唯一可靠的“{description}”按钮",
|
||||
step="purchase_set_quantity",
|
||||
)
|
||||
left, top, right, bottom = targets[0]
|
||||
device.click((left + right) // 2, (top + bottom) // 2)
|
||||
expected += step
|
||||
self._sleep(0.2)
|
||||
actual = _quantity(_parse_xml(self._dump_hierarchy()))
|
||||
if actual != expected:
|
||||
raise PddPurchaseError(
|
||||
"PURCHASE_QUANTITY_MISMATCH",
|
||||
f"调整采购数量后期望为 {expected},页面实际为 {actual or '未知'}",
|
||||
step="purchase_set_quantity",
|
||||
)
|
||||
|
||||
def _set_quantity_with_editor(self, quantity: int) -> None:
|
||||
"""输入框兜底;只在确认输入法显示后按一次返回键。"""
|
||||
|
||||
device = self._require_device()
|
||||
editor = device(className="android.widget.EditText")
|
||||
if int(getattr(editor, "count", 0)) != 1:
|
||||
raise PddPurchaseError(
|
||||
"PURCHASE_QUANTITY_CONTROL_MISSING",
|
||||
"页面没有唯一可靠的采购数量输入框",
|
||||
step="purchase_set_quantity",
|
||||
)
|
||||
editor.set_text(str(quantity))
|
||||
self._sleep(0.2)
|
||||
if _quantity(_parse_xml(self._dump_hierarchy())) != quantity:
|
||||
raise PddPurchaseError(
|
||||
"PURCHASE_QUANTITY_MISMATCH",
|
||||
"PDD 页面没有保存目标采购数量",
|
||||
step="purchase_set_quantity",
|
||||
)
|
||||
|
||||
keyboard_shown = _keyboard_is_shown(device)
|
||||
if keyboard_shown is None:
|
||||
raise PddPurchaseError(
|
||||
"PURCHASE_KEYBOARD_DISMISS_FAILED",
|
||||
"无法确认安卓输入法状态,已停止采购以避免误按返回键",
|
||||
step="purchase_set_quantity",
|
||||
retryable=True,
|
||||
)
|
||||
if keyboard_shown:
|
||||
device.press("back")
|
||||
for _attempt in range(25):
|
||||
self._check_cancelled("purchase_set_quantity")
|
||||
self._sleep(0.2)
|
||||
keyboard_shown = _keyboard_is_shown(device)
|
||||
if keyboard_shown is False:
|
||||
break
|
||||
if keyboard_shown is not False:
|
||||
raise PddPurchaseError(
|
||||
"PURCHASE_KEYBOARD_DISMISS_FAILED",
|
||||
"安卓输入法没有在规定时间内关闭,已停止采购",
|
||||
step="purchase_set_quantity",
|
||||
retryable=True,
|
||||
)
|
||||
|
||||
self._validate_panel_after_keyboard(quantity)
|
||||
|
||||
def _validate_panel_after_keyboard(self, quantity: int) -> None:
|
||||
"""输入框操作后重新确认规格面板和最终提交前状态。"""
|
||||
|
||||
root = _parse_xml(self._dump_hierarchy())
|
||||
if _page_kind(root, "") != "order_confirmation":
|
||||
raise PddPurchaseError(
|
||||
"PURCHASE_PANEL_LOST_AFTER_KEYBOARD",
|
||||
"关闭输入法后规格面板已经消失,已停止采购",
|
||||
step="purchase_set_quantity",
|
||||
retryable=True,
|
||||
)
|
||||
if _quantity(root) != quantity:
|
||||
raise PddPurchaseError(
|
||||
"PURCHASE_QUANTITY_MISMATCH",
|
||||
"关闭输入法后采购数量发生变化",
|
||||
step="purchase_set_quantity",
|
||||
)
|
||||
missing_options = [
|
||||
value
|
||||
for value in self._requested_options.values()
|
||||
if not _selected(root, value)
|
||||
]
|
||||
if missing_options:
|
||||
raise PddPurchaseError(
|
||||
"PURCHASE_OPTIONS_MISMATCH",
|
||||
"关闭输入法后已选颜色或尺码发生变化",
|
||||
step="purchase_set_quantity",
|
||||
)
|
||||
if _price_cent(root) <= 0:
|
||||
raise PddPurchaseError(
|
||||
"PURCHASE_PRICE_MISSING",
|
||||
"关闭输入法后没有识别到有效价格",
|
||||
step="purchase_set_quantity",
|
||||
)
|
||||
if len(_final_submit_targets(root)) != 1:
|
||||
raise PddPurchaseError(
|
||||
"PURCHASE_SUBMIT_TARGET_AMBIGUOUS",
|
||||
"关闭输入法后没有唯一可靠的下单按钮",
|
||||
step="purchase_set_quantity",
|
||||
)
|
||||
|
||||
def enter_confirmation(self) -> None:
|
||||
"""只确认已到最终提交前页面,不点击底部提交按钮。"""
|
||||
|
||||
|
||||
@@ -65,7 +65,10 @@ class FakeEditor:
|
||||
self.count = 1
|
||||
|
||||
def set_text(self, value: str) -> None:
|
||||
self.device.editor_values.append(value)
|
||||
self.device.quantity = int(value)
|
||||
if self.device.show_keyboard_on_edit:
|
||||
self.device.keyboard_shown = True
|
||||
|
||||
|
||||
class FakeDevice:
|
||||
@@ -78,6 +81,13 @@ class FakeDevice:
|
||||
self.app_wait_calls = 0
|
||||
self.app_current_calls = 0
|
||||
self.has_opened = False
|
||||
self.editor_values = []
|
||||
self.show_keyboard_on_edit = True
|
||||
self.keyboard_shown = False
|
||||
self.keyboard_status_known = True
|
||||
self.keyboard_can_dismiss = True
|
||||
self.close_panel_on_back = False
|
||||
self.presses = []
|
||||
|
||||
def app_current(self):
|
||||
self.app_current_calls += 1
|
||||
@@ -103,8 +113,28 @@ class FakeDevice:
|
||||
|
||||
def click(self, x, y):
|
||||
self.clicks.append((x, y))
|
||||
if self.mode == "panel" and 570 <= x <= 650 and 700 <= y <= 780:
|
||||
self.quantity += 1
|
||||
return
|
||||
if self.mode == "panel" and 390 <= x <= 470 and 700 <= y <= 780:
|
||||
self.quantity -= 1
|
||||
return
|
||||
self.mode = "panel"
|
||||
|
||||
def shell(self, command):
|
||||
self.asserted_shell_command = command
|
||||
if not self.keyboard_status_known:
|
||||
return "input method state unavailable"
|
||||
value = "true" if self.keyboard_shown else "false"
|
||||
return f"mInputShown={value} mIsInputViewShown={value}"
|
||||
|
||||
def press(self, key):
|
||||
self.presses.append(key)
|
||||
if key == "back" and self.keyboard_can_dismiss:
|
||||
self.keyboard_shown = False
|
||||
if self.close_panel_on_back:
|
||||
self.mode = "home"
|
||||
|
||||
def __call__(self, **selector):
|
||||
if selector == {"className": "android.widget.EditText"}:
|
||||
return FakeEditor(self)
|
||||
@@ -216,10 +246,107 @@ class U2PddPurchaseAdapterTest(unittest.TestCase):
|
||||
self.assertEqual(confirmation.price_cent, 503)
|
||||
self.assertEqual(confirmation.page_kind, "order_confirmation")
|
||||
self.assertEqual(calls, [("color", "黑色"), ("size", "3XL【140-165斤】")])
|
||||
# 只点击一次商品页采购入口;最终“提交订单”从不点击。
|
||||
self.assertEqual(len(device.clicks), 1)
|
||||
# 点击一次商品页采购入口和一次加号;最终“提交订单”从不点击。
|
||||
self.assertEqual(len(device.clicks), 2)
|
||||
self.assertEqual(device.editor_values, [])
|
||||
self.assertEqual(device.app_wait_calls, 0)
|
||||
|
||||
def test_same_quantity_does_not_focus_editor(self):
|
||||
device = FakeDevice()
|
||||
adapter = self._adapter(device, [])
|
||||
adapter.open_goods(GOODS_URL)
|
||||
adapter.select_options({"color": "黑色", "size": "3XL【140-165斤】"})
|
||||
|
||||
adapter.set_quantity(1)
|
||||
|
||||
self.assertEqual(device.editor_values, [])
|
||||
self.assertEqual(device.presses, [])
|
||||
self.assertEqual(len(device.clicks), 1)
|
||||
adapter.close()
|
||||
|
||||
def test_small_quantity_difference_uses_buttons(self):
|
||||
device = FakeDevice()
|
||||
adapter = self._adapter(device, [])
|
||||
adapter.open_goods(GOODS_URL)
|
||||
adapter.select_options({"color": "黑色", "size": "3XL【140-165斤】"})
|
||||
|
||||
adapter.set_quantity(4)
|
||||
|
||||
self.assertEqual(device.quantity, 4)
|
||||
self.assertEqual(device.editor_values, [])
|
||||
self.assertEqual(device.presses, [])
|
||||
# 采购入口一次,加号三次。
|
||||
self.assertEqual(len(device.clicks), 4)
|
||||
adapter.close()
|
||||
|
||||
def test_large_quantity_difference_closes_visible_keyboard_once(self):
|
||||
device = FakeDevice()
|
||||
adapter = self._adapter(device, [])
|
||||
adapter.open_goods(GOODS_URL)
|
||||
adapter.select_options({"color": "黑色", "size": "3XL【140-165斤】"})
|
||||
|
||||
adapter.set_quantity(7)
|
||||
|
||||
self.assertEqual(device.editor_values, ["7"])
|
||||
self.assertEqual(device.presses, ["back"])
|
||||
self.assertFalse(device.keyboard_shown)
|
||||
adapter.close()
|
||||
|
||||
def test_editor_does_not_press_back_when_keyboard_is_already_hidden(self):
|
||||
device = FakeDevice()
|
||||
device.show_keyboard_on_edit = False
|
||||
adapter = self._adapter(device, [])
|
||||
adapter.open_goods(GOODS_URL)
|
||||
adapter.select_options({"color": "黑色", "size": "3XL【140-165斤】"})
|
||||
|
||||
adapter.set_quantity(7)
|
||||
|
||||
self.assertEqual(device.editor_values, ["7"])
|
||||
self.assertEqual(device.presses, [])
|
||||
adapter.close()
|
||||
|
||||
def test_editor_stops_when_keyboard_cannot_be_closed(self):
|
||||
device = FakeDevice()
|
||||
device.keyboard_can_dismiss = False
|
||||
adapter = self._adapter(device, [])
|
||||
adapter.open_goods(GOODS_URL)
|
||||
adapter.select_options({"color": "黑色", "size": "3XL【140-165斤】"})
|
||||
|
||||
with self.assertRaises(PddPurchaseError) as raised:
|
||||
adapter.set_quantity(7)
|
||||
|
||||
self.assertEqual(raised.exception.code, "PURCHASE_KEYBOARD_DISMISS_FAILED")
|
||||
self.assertEqual(device.presses, ["back"])
|
||||
adapter.close()
|
||||
|
||||
def test_editor_does_not_press_back_when_keyboard_state_is_unknown(self):
|
||||
device = FakeDevice()
|
||||
device.keyboard_status_known = False
|
||||
adapter = self._adapter(device, [])
|
||||
adapter.open_goods(GOODS_URL)
|
||||
adapter.select_options({"color": "黑色", "size": "3XL【140-165斤】"})
|
||||
|
||||
with self.assertRaises(PddPurchaseError) as raised:
|
||||
adapter.set_quantity(7)
|
||||
|
||||
self.assertEqual(raised.exception.code, "PURCHASE_KEYBOARD_DISMISS_FAILED")
|
||||
self.assertEqual(device.presses, [])
|
||||
adapter.close()
|
||||
|
||||
def test_editor_stops_when_back_closes_specification_panel(self):
|
||||
device = FakeDevice()
|
||||
device.close_panel_on_back = True
|
||||
adapter = self._adapter(device, [])
|
||||
adapter.open_goods(GOODS_URL)
|
||||
adapter.select_options({"color": "黑色", "size": "3XL【140-165斤】"})
|
||||
|
||||
with self.assertRaises(PddPurchaseError) as raised:
|
||||
adapter.set_quantity(7)
|
||||
|
||||
self.assertEqual(raised.exception.code, "PURCHASE_PANEL_LOST_AFTER_KEYBOARD")
|
||||
self.assertEqual(device.presses, ["back"])
|
||||
adapter.close()
|
||||
|
||||
def test_unsupported_dynamic_option_stops_before_click(self):
|
||||
device = FakeDevice()
|
||||
adapter = self._adapter(device, [])
|
||||
@@ -396,8 +523,8 @@ class U2PddPurchaseAdapterTest(unittest.TestCase):
|
||||
|
||||
self.assertEqual(state.submit_candidate_count, 1)
|
||||
self.assertEqual(raised.exception.code, "PURCHASE_SUBMIT_ALREADY_ATTEMPTED")
|
||||
# 第一次点击采购入口,第二次且仅一次点击最终提交。
|
||||
self.assertEqual(len(device.clicks), 2)
|
||||
# 依次点击采购入口、数量加号和最终提交。
|
||||
self.assertEqual(len(device.clicks), 3)
|
||||
adapter.close()
|
||||
|
||||
def test_live_adapter_rejects_multiple_submit_targets_without_click(self):
|
||||
|
||||
@@ -106,6 +106,9 @@ Client 不保存或读取 `purchase.live_*` 手工授权设置。`purchase_mode`
|
||||
- 点击前必须确认包名、页面类型、目标语义和坐标边界。
|
||||
- 高风险点击应在最新控件树上重新定位,不使用长时间缓存的坐标。
|
||||
- 规格选择后读取最新控件树并验证选中状态。
|
||||
- 设置采购数量时先读取当前值;数量相同不聚焦输入框,小差值优先使用加减按钮。
|
||||
只有输入框兜底路径确认输入法已经显示时才允许按一次返回键;输入法关闭后必须
|
||||
重新核对规格、数量、价格和唯一提交目标,规格面板丢失时立即停止。
|
||||
- 最终下单前重新读取实际价格、数量和规格。
|
||||
- 页面出现验证码、登录、支付、权限请求或未知模态层时停止并转人工处理。
|
||||
- uiautomator2 连接由单一工作线程独占,任务间清理临时状态。
|
||||
|
||||
Reference in New Issue
Block a user