fix: 任务执行前检查 Android 设备连接 (#84)

This commit is contained in:
chengma
2026-08-10 09:35:46 +08:00
parent dee9f34035
commit e7fb06d7aa
11 changed files with 360 additions and 25 deletions
+26
View File
@@ -120,6 +120,32 @@ class AndroidDeviceService:
) )
return enriched return enriched
def require_connected(self, serial: str) -> AndroidDevice:
"""确认已保存设备仍由 ADB 识别且状态可用。"""
self._validate_saved_serial(serial)
device = next(
(item for item in self._list_devices() if item.serial == serial),
None,
)
if device is None:
connection = "Wi-Fi" if ":" in serial else "USB"
raise AndroidDeviceSearchError(
f"{connection} Android 设备 {serial} 未连接,"
"请连接设备后重试"
)
if device.status == "unauthorized":
raise AndroidDeviceSearchError(
f"Android 设备 {serial} 未授权,"
"请在手机上允许 USB 调试后重试"
)
if device.status != "device":
raise AndroidDeviceSearchError(
f"Android 设备 {serial} 当前为 {device.status} 状态,"
"请重新连接后重试"
)
return device
def is_package_installed( def is_package_installed(
self, self,
serial: str, serial: str,
+1
View File
@@ -278,6 +278,7 @@ class PDDTaskPage(QWidget):
refreshRequested = pyqtSignal() refreshRequested = pyqtSignal()
rerunRequested = pyqtSignal(str) rerunRequested = pyqtSignal(str)
rerunCancelRequested = pyqtSignal() rerunCancelRequested = pyqtSignal()
openSettingsRequested = pyqtSignal()
detailRequested = pyqtSignal(str) detailRequested = pyqtSignal(str)
def __init__(self, parent=None): def __init__(self, parent=None):
+112 -15
View File
@@ -20,7 +20,7 @@
只有必须让用户当场做决定时才用模态对话框。 只有必须让用户当场做决定时才用模态对话框。
""" """
from typing import Dict, Optional from typing import Callable, Dict, Optional
from PyQt5.QtCore import ( from PyQt5.QtCore import (
QCoreApplication, QCoreApplication,
@@ -32,6 +32,10 @@ from PyQt5.QtCore import (
) )
from qfluentwidgets import InfoBar, InfoBarPosition, MessageBox, PushButton from qfluentwidgets import InfoBar, InfoBarPosition, MessageBox, PushButton
from .android_device_service import (
AndroidDeviceSearchError,
AndroidDeviceService,
)
from .admin_gateway import ( from .admin_gateway import (
AdminGatewayError, AdminGatewayError,
ClientInfo, ClientInfo,
@@ -101,6 +105,7 @@ class ClaimTaskWorker(QObject):
localSaveFailed = pyqtSignal(str, str) localSaveFailed = pyqtSignal(str, str)
retryableFailed = pyqtSignal(str) retryableFailed = pyqtSignal(str)
failed = pyqtSignal(str) failed = pyqtSignal(str)
deviceUnavailable = pyqtSignal(str)
outcome = pyqtSignal(str, str, str) outcome = pyqtSignal(str, str, str)
completed = pyqtSignal() completed = pyqtSignal()
@@ -114,6 +119,7 @@ class ClaimTaskWorker(QObject):
purchase_adapter_factory: Optional[PurchaseAdapterFactory] = None, purchase_adapter_factory: Optional[PurchaseAdapterFactory] = None,
purchase_reconcile_factory: Optional[PurchaseReconcileFactory] = None, purchase_reconcile_factory: Optional[PurchaseReconcileFactory] = None,
selected_task_id: str = "", selected_task_id: str = "",
device_connection_checker: Optional[Callable[[str], None]] = None,
) -> None: ) -> None:
super().__init__() super().__init__()
self._gateway = gateway self._gateway = gateway
@@ -125,6 +131,10 @@ class ClaimTaskWorker(QObject):
self._purchase_adapter_factory = purchase_adapter_factory self._purchase_adapter_factory = purchase_adapter_factory
self._purchase_reconcile_factory = purchase_reconcile_factory self._purchase_reconcile_factory = purchase_reconcile_factory
self._selected_task_id = selected_task_id self._selected_task_id = selected_task_id
self._device_connection_checker = (
device_connection_checker
or AndroidDeviceService().require_connected
)
def cancel(self) -> None: def cancel(self) -> None:
"""阻止尚未开始的领取;已领取的任务仍必须保存到本地。""" """阻止尚未开始的领取;已领取的任务仍必须保存到本地。"""
@@ -147,6 +157,10 @@ class ClaimTaskWorker(QObject):
client_settings.client_name, client_settings.client_name,
) )
if self._selected_task_id: if self._selected_task_id:
self._device_connection_checker(android_serial or "")
self._task_repository.prepare_collect_rerun(
self._selected_task_id
)
service = CollectTaskService( service = CollectTaskService(
self._gateway, self._gateway,
self._task_repository, self._task_repository,
@@ -166,10 +180,14 @@ class ClaimTaskWorker(QObject):
purchase_adapter_factory=self._purchase_adapter_factory, purchase_adapter_factory=self._purchase_adapter_factory,
purchase_reconcile_factory=self._purchase_reconcile_factory, purchase_reconcile_factory=self._purchase_reconcile_factory,
cancelled=lambda: self._cancelled, cancelled=lambda: self._cancelled,
device_connection_checker=self._device_connection_checker,
) )
result = dispatcher.execute_one() result = dispatcher.execute_one()
if not self._cancelled or result.kind == "cancelled": if not self._cancelled or result.kind == "cancelled":
self.outcome.emit(result.kind, result.message, result.task_id) self.outcome.emit(result.kind, result.message, result.task_id)
except AndroidDeviceSearchError as exc:
if not self._cancelled:
self.deviceUnavailable.emit(str(exc))
except AdminGatewayError as exc: except AdminGatewayError as exc:
if not self._cancelled: if not self._cancelled:
request_hint = ( request_hint = (
@@ -200,6 +218,7 @@ class PDDTaskPageEvent(QObject):
collect_service_factory: Optional[CollectServiceFactory] = None, collect_service_factory: Optional[CollectServiceFactory] = None,
purchase_adapter_factory: Optional[PurchaseAdapterFactory] = None, purchase_adapter_factory: Optional[PurchaseAdapterFactory] = None,
purchase_reconcile_factory: Optional[PurchaseReconcileFactory] = None, purchase_reconcile_factory: Optional[PurchaseReconcileFactory] = None,
device_connection_checker: Optional[Callable[[str], None]] = None,
next_task_delay_ms: int = 500, next_task_delay_ms: int = 500,
no_task_delay_ms: int = 5_000, no_task_delay_ms: int = 5_000,
retry_delays_ms: tuple[int, ...] = (5_000, 10_000, 20_000, 30_000), retry_delays_ms: tuple[int, ...] = (5_000, 10_000, 20_000, 30_000),
@@ -214,6 +233,7 @@ class PDDTaskPageEvent(QObject):
self._claim_worker: Optional[ClaimTaskWorker] = None self._claim_worker: Optional[ClaimTaskWorker] = None
self._rerun_cancel_requested = False self._rerun_cancel_requested = False
self._rerun_feedback: Optional[InfoBar] = None self._rerun_feedback: Optional[InfoBar] = None
self._device_feedback: Optional[InfoBar] = None
self._auto_fetch_running = False self._auto_fetch_running = False
self._stop_requested = False self._stop_requested = False
self._cycle_next_delay_ms: Optional[int] = None self._cycle_next_delay_ms: Optional[int] = None
@@ -223,6 +243,10 @@ class PDDTaskPageEvent(QObject):
self._collect_service_factory = collect_service_factory self._collect_service_factory = collect_service_factory
self._purchase_adapter_factory = purchase_adapter_factory self._purchase_adapter_factory = purchase_adapter_factory
self._purchase_reconcile_factory = purchase_reconcile_factory self._purchase_reconcile_factory = purchase_reconcile_factory
self._device_connection_checker = (
device_connection_checker
or AndroidDeviceService().require_connected
)
if next_task_delay_ms < 0 or no_task_delay_ms <= 0: if next_task_delay_ms < 0 or no_task_delay_ms <= 0:
raise ValueError("自动获取等待时间配置无效") raise ValueError("自动获取等待时间配置无效")
if not retry_delays_ms or any(value <= 0 for value in retry_delays_ms): if not retry_delays_ms or any(value <= 0 for value in retry_delays_ms):
@@ -325,8 +349,8 @@ class PDDTaskPageEvent(QObject):
) )
return return
if not android_serial: if not android_serial:
self._show_rerun_warning( self._show_device_unavailable(
"重新执行不可用", "请先在设置页选择并保存 Android 设备。" "请先在设置页选择并保存 Android 设备"
) )
return return
try: try:
@@ -353,17 +377,6 @@ class PDDTaskPageEvent(QObject):
if not dialog.exec(): if not dialog.exec():
return return
try:
self._repository.prepare_collect_rerun(task_id)
except (CollectRerunError, ValueError) as exc:
self._show_rerun_warning("不能重新执行", str(exc))
self._reload()
return
except Exception:
self._show_rerun_warning(
"重新执行失败", "无法更新本地任务状态,请检查数据库后重试。"
)
return
self._start_rerun_worker(task_id) self._start_rerun_worker(task_id)
def _start_rerun_worker(self, task_id: str) -> None: def _start_rerun_worker(self, task_id: str) -> None:
@@ -371,10 +384,13 @@ class PDDTaskPageEvent(QObject):
assert self._claim_gateway is not None assert self._claim_gateway is not None
self._close_rerun_feedback() self._close_rerun_feedback()
self._close_device_feedback()
self._rerun_cancel_requested = False self._rerun_cancel_requested = False
self._claim_busy = True self._claim_busy = True
self._page.set_rerun_running(True) self._page.set_rerun_running(True)
self._page.set_engine_status(f"正在重新采集任务 {task_id}…") self._page.set_engine_status(
f"正在检查 Android 设备并准备重新采集任务 {task_id}…"
)
self._reload() self._reload()
thread = QThread(self) thread = QThread(self)
@@ -385,11 +401,13 @@ class PDDTaskPageEvent(QObject):
self._selected_android_device_service, self._selected_android_device_service,
self._collect_service_factory, self._collect_service_factory,
selected_task_id=task_id, selected_task_id=task_id,
device_connection_checker=self._device_connection_checker,
) )
worker.moveToThread(thread) worker.moveToThread(thread)
thread.started.connect(worker.run) thread.started.connect(worker.run)
worker.retryableFailed.connect(self._on_rerun_failed) worker.retryableFailed.connect(self._on_rerun_failed)
worker.failed.connect(self._on_rerun_failed) worker.failed.connect(self._on_rerun_failed)
worker.deviceUnavailable.connect(self._on_rerun_device_unavailable)
worker.outcome.connect(self._on_rerun_outcome) worker.outcome.connect(self._on_rerun_outcome)
worker.completed.connect(thread.quit) worker.completed.connect(thread.quit)
worker.completed.connect(worker.deleteLater) worker.completed.connect(worker.deleteLater)
@@ -443,6 +461,16 @@ class PDDTaskPageEvent(QObject):
self._page.set_engine_status(message) self._page.set_engine_status(message)
self._show_rerun_error("重新采集失败", message) self._show_rerun_error("重新采集失败", message)
@pyqtSlot(str)
def _on_rerun_device_unavailable(self, message: str) -> None:
if self._closing or self._rerun_cancel_requested:
return
content = message or "Android 设备未连接,任务未开始"
self._page.set_engine_status(
f"Android 设备不可用,重新采集未开始:{content}"
)
self._show_device_unavailable(content)
@pyqtSlot() @pyqtSlot()
def _on_rerun_thread_finished(self) -> None: def _on_rerun_thread_finished(self) -> None:
cancel_requested = self._rerun_cancel_requested cancel_requested = self._rerun_cancel_requested
@@ -528,6 +556,7 @@ class PDDTaskPageEvent(QObject):
self._show_claim_error("领取任务失败", message) self._show_claim_error("领取任务失败", message)
return return
self._close_device_feedback()
self._auto_fetch_running = True self._auto_fetch_running = True
self._stop_requested = False self._stop_requested = False
self._retry_count = 0 self._retry_count = 0
@@ -560,6 +589,7 @@ class PDDTaskPageEvent(QObject):
collect_service_factory=self._collect_service_factory, collect_service_factory=self._collect_service_factory,
purchase_adapter_factory=self._purchase_adapter_factory, purchase_adapter_factory=self._purchase_adapter_factory,
purchase_reconcile_factory=self._purchase_reconcile_factory, purchase_reconcile_factory=self._purchase_reconcile_factory,
device_connection_checker=self._device_connection_checker,
) )
worker.moveToThread(thread) worker.moveToThread(thread)
thread.started.connect(worker.run) thread.started.connect(worker.run)
@@ -569,6 +599,7 @@ class PDDTaskPageEvent(QObject):
worker.localSaveFailed.connect(self._on_claimed_task_save_failed) worker.localSaveFailed.connect(self._on_claimed_task_save_failed)
worker.retryableFailed.connect(self._on_claim_retryable_failed) worker.retryableFailed.connect(self._on_claim_retryable_failed)
worker.failed.connect(self._on_claim_failed) worker.failed.connect(self._on_claim_failed)
worker.deviceUnavailable.connect(self._on_claim_device_unavailable)
worker.outcome.connect(self._on_collect_outcome) worker.outcome.connect(self._on_collect_outcome)
worker.completed.connect(thread.quit) worker.completed.connect(thread.quit)
worker.completed.connect(worker.deleteLater) worker.completed.connect(worker.deleteLater)
@@ -682,6 +713,16 @@ class PDDTaskPageEvent(QObject):
self._show_claim_error("领取任务失败", content) self._show_claim_error("领取任务失败", content)
self._stop_after_current(content) self._stop_after_current(content)
@pyqtSlot(str)
def _on_claim_device_unavailable(self, message: str) -> None:
if self._closing:
return
content = message or "Android 设备未连接,任务未开始"
status = f"自动获取:已停止 · Android 设备不可用:{content}"
self._page.set_engine_status(status)
self._show_device_unavailable(content)
self._stop_after_current(status)
@pyqtSlot(str, str, str) @pyqtSlot(str, str, str)
def _on_collect_outcome(self, kind: str, message: str, task_id: str) -> None: def _on_collect_outcome(self, kind: str, message: str, task_id: str) -> None:
if self._closing: if self._closing:
@@ -753,6 +794,53 @@ class PDDTaskPageEvent(QObject):
parent=self._page, parent=self._page,
) )
def _show_device_unavailable(self, content: str) -> None:
"""显示一条可进入设置且不会堆叠的设备错误。"""
self._close_device_feedback()
bar = InfoBar.error(
title="Android 设备不可用",
content=content,
isClosable=True,
duration=-1,
position=InfoBarPosition.TOP_RIGHT,
parent=self._page,
)
self._device_feedback = bar
settings_button = PushButton("打开设置", bar)
settings_button.setAccessibleName("打开 Android 设备设置")
settings_button.clicked.connect(
lambda _checked=False, current=bar: self._open_device_settings(current)
)
close_button = PushButton("关闭提示", bar)
close_button.setAccessibleName("关闭 Android 设备提示")
close_button.clicked.connect(
lambda _checked=False, current=bar: self._close_device_feedback(current)
)
bar.addWidget(settings_button)
bar.addWidget(close_button)
bar.destroyed.connect(
lambda _object=None, current=bar: self._forget_device_feedback(current)
)
def _open_device_settings(self, bar: InfoBar) -> None:
self._page.openSettingsRequested.emit()
self._close_device_feedback(bar)
def _close_device_feedback(self, expected: Optional[InfoBar] = None) -> None:
bar = self._device_feedback
if bar is None or (expected is not None and bar is not expected):
return
self._device_feedback = None
try:
bar.close()
except RuntimeError:
pass
def _forget_device_feedback(self, bar: InfoBar) -> None:
if self._device_feedback is bar:
self._device_feedback = None
@pyqtSlot() @pyqtSlot()
def _on_claim_thread_finished(self) -> None: def _on_claim_thread_finished(self) -> None:
self._claim_worker = None self._claim_worker = None
@@ -849,6 +937,7 @@ class PDDTaskPageEvent(QObject):
self._stop_requested = True self._stop_requested = True
self._next_cycle_timer.stop() self._next_cycle_timer.stop()
self._close_rerun_feedback() self._close_rerun_feedback()
self._close_device_feedback()
for window in list(self._detail_windows.values()): for window in list(self._detail_windows.values()):
window.close() window.close()
@@ -868,6 +957,14 @@ class PDDTaskPageEvent(QObject):
self._on_claimed_task_save_failed, self._on_claimed_task_save_failed,
), ),
(worker.failed, self._on_claim_failed), (worker.failed, self._on_claim_failed),
(
worker.deviceUnavailable,
self._on_claim_device_unavailable,
),
(
worker.deviceUnavailable,
self._on_rerun_device_unavailable,
),
( (
worker.retryableFailed, worker.retryableFailed,
self._on_claim_retryable_failed, self._on_claim_retryable_failed,
+13 -1
View File
@@ -14,6 +14,10 @@ from .admin_gateway import (
ClientInfo, ClientInfo,
) )
from .collect_task_service import CollectServiceFactory, CollectTaskService from .collect_task_service import CollectServiceFactory, CollectTaskService
from .android_device_service import (
AndroidDeviceSearchError,
AndroidDeviceService,
)
from .purchase_task_service import ( from .purchase_task_service import (
PurchaseAdapterFactory, PurchaseAdapterFactory,
PurchaseTaskService, PurchaseTaskService,
@@ -126,6 +130,7 @@ class TaskDispatcher:
purchase_adapter_factory: Optional[PurchaseAdapterFactory] = None, purchase_adapter_factory: Optional[PurchaseAdapterFactory] = None,
purchase_reconcile_factory: Optional[PurchaseReconcileFactory] = None, purchase_reconcile_factory: Optional[PurchaseReconcileFactory] = None,
cancelled: Callable[[], bool] = lambda: False, cancelled: Callable[[], bool] = lambda: False,
device_connection_checker: Optional[Callable[[str], None]] = None,
) -> None: ) -> None:
self._gateway = gateway self._gateway = gateway
self._repository = repository self._repository = repository
@@ -135,6 +140,10 @@ class TaskDispatcher:
self._purchase_factory = purchase_adapter_factory self._purchase_factory = purchase_adapter_factory
self._reconcile_factory = purchase_reconcile_factory self._reconcile_factory = purchase_reconcile_factory
self._cancelled = cancelled self._cancelled = cancelled
self._device_connection_checker = (
device_connection_checker
or AndroidDeviceService().require_connected
)
@property @property
def purchase_ready(self) -> bool: def purchase_ready(self) -> bool:
@@ -169,7 +178,10 @@ class TaskDispatcher:
if pending is not None: if pending is not None:
return self._submit_pending(pending) return self._submit_pending(pending)
if not self._device_address: if not self._device_address:
raise ValueError("请先在设置页选择并保存 Android 设备") raise AndroidDeviceSearchError(
"请先在设置页选择并保存 Android 设备"
)
self._device_connection_checker(self._device_address)
reconcile_task = self._repository.next_purchase_reconcile_task() reconcile_task = self._repository.next_purchase_reconcile_task()
if reconcile_task is not None: if reconcile_task is not None:
+3
View File
@@ -58,6 +58,9 @@ class MainWindow(FluentWindow):
purchase_adapter_factory=create_u2_purchase_adapter, purchase_adapter_factory=create_u2_purchase_adapter,
) )
self.pddTaskPage.openSettingsRequested.connect(
lambda: self.switchTo(self.settingsPage)
)
self.addSubInterface(self.pddTaskPage, FIF.HOME, "pdd") self.addSubInterface(self.pddTaskPage, FIF.HOME, "pdd")
self.addSubInterface( self.addSubInterface(
self.settingsPage, self.settingsPage,
@@ -72,6 +72,39 @@ USB-002 unauthorized usb:1-2 transport_id:3
self.assertEqual(len(commands), 2) self.assertEqual(len(commands), 2)
self.assertIn("ro.build.version.release", commands[1]) self.assertIn("ro.build.version.release", commands[1])
def test_require_connected_accepts_exact_usb_and_wifi_serial(self):
output = (
"List of devices attached\n"
"USB-001 device model:Phone\n"
"192.168.0.173:5555 device model:Phone\n"
)
service = AndroidDeviceService(
lambda command, _timeout: completed(command, output)
)
usb = service.require_connected("USB-001")
wifi = service.require_connected("192.168.0.173:5555")
self.assertEqual(usb.serial, "USB-001")
self.assertEqual(wifi.serial, "192.168.0.173:5555")
def test_require_connected_rejects_missing_offline_and_unauthorized(self):
cases = (
("USB-MISSING", "", "USB.*未连接"),
("192.168.0.173:5555", "offline", "offline"),
("USB-002", "unauthorized", "未授权"),
)
for serial, status, expected in cases:
with self.subTest(serial=serial, status=status):
line = f"{serial} {status}\n" if status else ""
service = AndroidDeviceService(
lambda command, _timeout, value=line: completed(
command, "List of devices attached\n" + value
)
)
with self.assertRaisesRegex(AndroidDeviceSearchError, expected):
service.require_connected(serial)
def test_restore_saved_wifi_connects_before_search(self): def test_restore_saved_wifi_connects_before_search(self):
commands = [] commands = []
+101
View File
@@ -12,6 +12,7 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt5.QtWidgets import QApplication from PyQt5.QtWidgets import QApplication
from src.android_device_service import AndroidDeviceSearchError
from src.pdd_ui import PDDTaskPage from src.pdd_ui import PDDTaskPage
from src.admin_gateway import AdminGatewayError, AdminTask, SubmissionReceipt from src.admin_gateway import AdminGatewayError, AdminTask, SubmissionReceipt
from src.pdd_ui_event import ( from src.pdd_ui_event import (
@@ -238,8 +239,14 @@ class PDDTaskPageEventTest(unittest.TestCase):
self.temp_directory = tempfile.TemporaryDirectory() self.temp_directory = tempfile.TemporaryDirectory()
self.db_path = Path(self.temp_directory.name) / "client.db" self.db_path = Path(self.temp_directory.name) / "client.db"
self.repository = TaskRepository(self.db_path) self.repository = TaskRepository(self.db_path)
self.device_checker_patch = patch(
"src.pdd_ui_event.AndroidDeviceService.require_connected",
autospec=True,
)
self.device_checker_patch.start()
def tearDown(self): def tearDown(self):
self.device_checker_patch.stop()
self.temp_directory.cleanup() self.temp_directory.cleanup()
def _add_task( def _add_task(
@@ -481,6 +488,97 @@ class PDDTaskPageEventTest(unittest.TestCase):
events.shutdown() events.shutdown()
page.deleteLater() page.deleteLater()
def test_disconnected_device_stops_auto_fetch_before_claim(self):
page = PDDTaskPage()
gateway = RecordingClaimGateway(collect_admin_task())
checker_threads = []
def disconnected(_serial):
checker_threads.append(threading.get_ident())
raise AndroidDeviceSearchError("USB Android 设备 USB-001 未连接")
events = PDDTaskPageEvent(
page,
self.repository,
claim_gateway=gateway,
settings_repository=self._saved_settings(),
collect_service_factory=fake_collect_factory,
device_connection_checker=disconnected,
)
page.autoFetchRequested.emit()
self.assertTrue(wait_until(self.app, lambda: not events._claim_busy))
self.assertEqual(gateway.calls, [])
self.assertEqual(self.repository.count_tasks(), 0)
self.assertEqual(len(checker_threads), 1)
self.assertNotEqual(checker_threads[0], threading.get_ident())
self.assertIn("Android 设备不可用", page.statusLabel.text())
self.assertIsNotNone(events._device_feedback)
events.shutdown()
page.deleteLater()
def test_disconnected_rerun_keeps_existing_task_unchanged(self):
self._add_task(1)
first = self.repository.start_collect_run("PDD-001", "USB-001")
event = self.repository.save_collect_result(
"PDD-001", first.attempt_id, FakeCollectResult().to_pdd_data()
)
self.repository.mark_outbox_sent(event.id)
before = self.repository.get_task("PDD-001")
before_run = self.repository.latest_task_run("PDD-001")
page = PDDTaskPage()
def disconnected(_serial):
raise AndroidDeviceSearchError(
"USB Android 设备 USB-001 未连接"
)
events = PDDTaskPageEvent(
page,
self.repository,
claim_gateway=RecordingClaimGateway(),
settings_repository=self._saved_settings(),
collect_service_factory=fake_collect_factory,
device_connection_checker=disconnected,
)
with patch("src.pdd_ui_event.MessageBox") as message_box:
message_box.return_value.exec.return_value = True
page.rerunRequested.emit("PDD-001")
self.assertTrue(wait_until(self.app, lambda: not events._claim_busy))
after = self.repository.get_task("PDD-001")
after_run = self.repository.latest_task_run("PDD-001")
self.assertEqual(after.status, TaskStatus.SUCCEEDED)
self.assertEqual(after.pdd_data, before.pdd_data)
self.assertEqual(after_run.attempt_id, before_run.attempt_id)
self.assertIn("重新采集未开始", page.statusLabel.text())
events.shutdown()
page.deleteLater()
def test_device_feedback_opens_settings_closes_and_does_not_stack(self):
page = PDDTaskPage()
events = PDDTaskPageEvent(page, self.repository)
opened = []
page.openSettingsRequested.connect(lambda: opened.append(True))
events._show_device_unavailable("第一条")
first = events._device_feedback
events._show_device_unavailable("第二条")
second = events._device_feedback
self.assertIsNot(first, second)
buttons = {
button.text(): button
for button in second.findChildren(type(page.rerunButton))
}
self.assertIn("打开设置", buttons)
self.assertIn("关闭提示", buttons)
buttons["打开设置"].click()
self.app.processEvents()
self.assertEqual(opened, [True])
self.assertIsNone(events._device_feedback)
events.shutdown()
page.deleteLater()
def test_query_failure_shows_readable_error(self): def test_query_failure_shows_readable_error(self):
page = PDDTaskPage() page = PDDTaskPage()
events = PDDTaskPageEvent(page, BrokenRepository()) events = PDDTaskPageEvent(page, BrokenRepository())
@@ -587,6 +685,9 @@ class PDDTaskPageEventTest(unittest.TestCase):
create_u2_purchase_adapter, create_u2_purchase_adapter,
) )
self.assertEqual(window.pddTaskPage.taskModel.data_row_count(), 0) self.assertEqual(window.pddTaskPage.taskModel.data_row_count(), 0)
window.pddTaskPage.openSettingsRequested.emit()
self.app.processEvents()
self.assertIs(window.stackedWidget.currentWidget(), window.settingsPage)
window.close() window.close()
window.deleteLater() window.deleteLater()
+2
View File
@@ -235,6 +235,7 @@ class PurchaseRecoveryTest(unittest.TestCase):
purchase_reconcile_factory=( purchase_reconcile_factory=(
lambda _address, _cancelled: ReadOnlyReconcileAdapter(calls) lambda _address, _cancelled: ReadOnlyReconcileAdapter(calls)
), ),
device_connection_checker=lambda _serial: None,
) )
first = dispatcher.execute_one() first = dispatcher.execute_one()
@@ -267,6 +268,7 @@ class PurchaseRecoveryTest(unittest.TestCase):
lambda _address, _cancelled: purchase_calls.append("purchase") lambda _address, _cancelled: purchase_calls.append("purchase")
), ),
purchase_reconcile_factory=unavailable_reconcile, purchase_reconcile_factory=unavailable_reconcile,
device_connection_checker=lambda _serial: None,
).execute_one() ).execute_one()
self.assertEqual(outcome.kind, "manual_review") self.assertEqual(outcome.kind, "manual_review")
+54 -2
View File
@@ -4,7 +4,8 @@ import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from src.admin_gateway import AdminTask, ClientInfo from src.android_device_service import AndroidDeviceSearchError
from src.admin_gateway import AdminTask, ClientInfo, SubmissionReceipt
from src.mock_admin_gateway import MockAdminGateway from src.mock_admin_gateway import MockAdminGateway
from src.pdd_purchase_adapter import PddPurchaseAdapter, PurchasePageState from src.pdd_purchase_adapter import PddPurchaseAdapter, PurchasePageState
from src.task_dispatcher import TaskDispatcher, admin_task_to_new_claimed_task from src.task_dispatcher import TaskDispatcher, admin_task_to_new_claimed_task
@@ -102,7 +103,7 @@ class TaskDispatcherTest(unittest.TestCase):
def tearDown(self): def tearDown(self):
self.temporary.cleanup() self.temporary.cleanup()
def _dispatcher(self, *, purchase_ready): def _dispatcher(self, *, purchase_ready, device_checker=lambda _serial: None):
purchase_factory = None purchase_factory = None
if purchase_ready: if purchase_ready:
purchase_factory = ( purchase_factory = (
@@ -117,6 +118,7 @@ class TaskDispatcherTest(unittest.TestCase):
lambda *_args: RecordingCollector(self.calls) lambda *_args: RecordingCollector(self.calls)
), ),
purchase_adapter_factory=purchase_factory, purchase_adapter_factory=purchase_factory,
device_connection_checker=device_checker,
) )
def test_capability_only_includes_purchase_when_adapter_is_ready(self): def test_capability_only_includes_purchase_when_adapter_is_ready(self):
@@ -173,6 +175,56 @@ class TaskDispatcherTest(unittest.TestCase):
assert detail is not None assert detail is not None
self.assertEqual(detail.status, TaskStatus.CLAIMED) self.assertEqual(detail.status, TaskStatus.CLAIMED)
def test_disconnected_device_does_not_claim_new_task(self):
self.gateway.enqueue_task(purchase_task(), self.client.client_id)
def disconnected(_serial):
raise AndroidDeviceSearchError("USB Android 设备 USB-001 未连接")
with self.assertRaisesRegex(AndroidDeviceSearchError, "未连接"):
self._dispatcher(
purchase_ready=True, device_checker=disconnected
).execute_one()
self.assertEqual(self.repository.count_tasks(), 0)
def test_pending_outbox_submits_without_connected_device(self):
task = admin_task_to_new_claimed_task(
AdminTask(
"COL-OUTBOX",
TaskType.COLLECT,
1,
0,
{"goods_url": "https://example.test", "goods_id": "G"},
)
)
self.repository.add_claimed_task(task)
started = self.repository.start_collect_run("COL-OUTBOX", "USB-001")
self.repository.save_collect_result(
"COL-OUTBOX", started.attempt_id, FakeCollectResult().to_pdd_data()
)
checks = []
class AcceptGateway:
def submit_result(self, *_args):
return SubmissionReceipt(
True, "RESULT-001", "2026-08-10T08:00:00Z"
)
outcome = TaskDispatcher(
AcceptGateway(),
self.repository,
self.client,
"USB-001",
purchase_adapter_factory=(
lambda _address, _cancelled: ReadyPurchaseAdapter(self.calls)
),
device_connection_checker=lambda serial: checks.append(serial),
).execute_one()
self.assertEqual(outcome.kind, "succeeded")
self.assertEqual(checks, [])
def test_purchase_mapper_rejects_missing_or_invalid_safety_fields(self): def test_purchase_mapper_rejects_missing_or_invalid_safety_fields(self):
invalid_payloads = ( invalid_payloads = (
{"goods_url": "https://example.test"}, {"goods_url": "https://example.test"},
+9 -7
View File
@@ -322,13 +322,15 @@ Client 与 Admin 的任务交互只有三种调用:领一个任务、提交结
### 任务领取与执行 ### 任务领取与执行
1. 调用 Admin `claim` 领一个任务;返回 204 表示暂时没活,按轮询周期退避后再试。 1. 优先补交一条本地 Outbox。提交结果不依赖 Android 设备。
2. Client 持久化任务,状态置 `claimed`。 2. 没有待提交结果时,在工作线程通过 ADB 检查已保存设备号是否仍为 `device` 状态。检查失败立即停止,不执行本地任务,也不领取新任务。
3. 根据 `task_type` 分派给采集或采购执行器。 3. 执行最早的本地待处理任务;没有本地任务时才调用 Admin `claim`。返回 204 表示暂时没活,按轮询周期退避后再试。
4. 工作线程执行,持续更新 `current_step`(只写本地,不上报 Admin)。 4. Client 持久化新领取的任务,状态置 `claimed`。
5. 完成后在同一事务里写入结果与 Outbox,状态置 `result_pending`。 5. 根据 `task_type` 分派给采集或采购执行器。
6. Outbox 提交成功、Admin 返回 `accepted: true` 后标记 `succeeded`。 6. 工作线程执行,持续更新 `current_step`(只写本地,不上报 Admin)。
7. 回到第 1 步领下一个任务。**同一时间只做一个任务。** 7. 完成后在同一事务里写入结果与 Outbox,状态置 `result_pending`。
8. Outbox 提交成功、Admin 返回 `accepted: true` 后标记 `succeeded`。
9. 回到第 1 步处理下一轮。**同一时间只做一个任务。**
`TaskDispatcher` 是能力声明的唯一入口。没有可用采购演练 Adapter、没有已保存 `TaskDispatcher` 是能力声明的唯一入口。没有可用采购演练 Adapter、没有已保存
Android 设备或本地持久化未准备好时,只声明 `collect`;条件满足时才声明 Android 设备或本地持久化未准备好时,只声明 `collect`;条件满足时才声明
+6
View File
@@ -67,6 +67,11 @@
或配置错误会停止自动获取。采购演练执行器和 Android 设备都准备好时,可以领取 或配置错误会停止自动获取。采购演练执行器和 Android 设备都准备好时,可以领取
采购任务;界面必须明确显示“演练”,任何路径都不得显示成真实下单。 采购任务;界面必须明确显示“演练”,任何路径都不得显示成真实下单。
- 补交本地 Outbox 不依赖手机,可以在 Android 设备断开时继续执行。
- 执行本地任务或向 Admin 领取新任务前,工作线程必须用 `adb devices -l` 检查已保存的精确设备号。USB 和 Wi-Fi 设备都只接受 `device` 状态。
- 设备未连接、`offline`、`unauthorized` 或 ADB 检查失败时,立即停止本轮和自动获取;不得领取新任务,也不得启动本地任务。
- 设备错误使用一条持久 `InfoBar` 提示,提供“打开设置”和“关闭提示”。重复错误替换旧提示,不得堆叠。
### 4.2 搜索与筛选 ### 4.2 搜索与筛选
建议控件: 建议控件:
@@ -83,6 +88,7 @@
- “刷新”只重新读取本地任务列表,不请求 Admin,也不操作手机。 - “刷新”只重新读取本地任务列表,不请求 Admin,也不操作手机。
- “重新执行”位于刷新右侧;没有选中任务时禁用。 - “重新执行”位于刷新右侧;没有选中任务时禁用。
- 点击后先校验任务,再显示明确的“重新采集”确认弹窗。弹窗显示任务编号、商品标题,并说明新结果会覆盖 Client 和 Admin 的当前采集数据。“暂不重新采集”是默认聚焦的安全操作,点击该按钮、按 `Escape` 或关闭弹窗都不得启动任务。 - 点击后先校验任务,再显示明确的“重新采集”确认弹窗。弹窗显示任务编号、商品标题,并说明新结果会覆盖 Client 和 Admin 的当前采集数据。“暂不重新采集”是默认聚焦的安全操作,点击该按钮、按 `Escape` 或关闭弹窗都不得启动任务。
- 用户确认后,工作线程必须先检查已保存 Android 设备的实际连接状态,再重置任务和创建执行记录。检查失败时保留原任务状态、采集结果和执行历史。
- 允许重新采集已经结束或处于“等待重试”的采集任务。采购、执行中、结果待提交、仍有未发送 Outbox 或自动获取忙碌时必须阻止,并用中文说明原因。 - 允许重新采集已经结束或处于“等待重试”的采集任务。采购、执行中、结果待提交、仍有未发送 Outbox 或自动获取忙碌时必须阻止,并用中文说明原因。
- 确认后只执行选中的稳定任务编号,不领取新任务,不先处理其他任务或 Outbox。 - 确认后只执行选中的稳定任务编号,不领取新任务,不先处理其他任务或 Outbox。
- 重新采集在工作线程运行。执行期间禁用“获取任务”,“重新执行”变为可点击的“停止重新采集”。 - 重新采集在工作线程运行。执行期间禁用“获取任务”,“重新执行”变为可点击的“停止重新采集”。