From e7fb06d7aa2eb70765eb3a90eadef21f59a5d200 Mon Sep 17 00:00:00 2001 From: chengma Date: Mon, 10 Aug 2026 09:35:46 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BB=BB=E5=8A=A1=E6=89=A7=E8=A1=8C?= =?UTF-8?q?=E5=89=8D=E6=A3=80=E6=9F=A5=20Android=20=E8=AE=BE=E5=A4=87?= =?UTF-8?q?=E8=BF=9E=E6=8E=A5=20(#84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/android_device_service.py | 26 +++++ client/src/pdd_ui.py | 1 + client/src/pdd_ui_event.py | 127 ++++++++++++++++++--- client/src/task_dispatcher.py | 14 ++- client/src/ui_main.py | 3 + client/test/test_android_device_service.py | 33 ++++++ client/test/test_pdd_ui_event.py | 101 ++++++++++++++++ client/test/test_purchase_recovery.py | 2 + client/test/test_task_dispatcher.py | 56 ++++++++- docs/client/02-architecture.md | 16 +-- docs/client/05-ui-specification.md | 6 + 11 files changed, 360 insertions(+), 25 deletions(-) diff --git a/client/src/android_device_service.py b/client/src/android_device_service.py index 4b18755..83f7cf6 100644 --- a/client/src/android_device_service.py +++ b/client/src/android_device_service.py @@ -120,6 +120,32 @@ class AndroidDeviceService: ) 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( self, serial: str, diff --git a/client/src/pdd_ui.py b/client/src/pdd_ui.py index 97c8d38..ee32869 100644 --- a/client/src/pdd_ui.py +++ b/client/src/pdd_ui.py @@ -278,6 +278,7 @@ class PDDTaskPage(QWidget): refreshRequested = pyqtSignal() rerunRequested = pyqtSignal(str) rerunCancelRequested = pyqtSignal() + openSettingsRequested = pyqtSignal() detailRequested = pyqtSignal(str) def __init__(self, parent=None): diff --git a/client/src/pdd_ui_event.py b/client/src/pdd_ui_event.py index c8e53ce..605555e 100644 --- a/client/src/pdd_ui_event.py +++ b/client/src/pdd_ui_event.py @@ -20,7 +20,7 @@ 只有必须让用户当场做决定时才用模态对话框。 """ -from typing import Dict, Optional +from typing import Callable, Dict, Optional from PyQt5.QtCore import ( QCoreApplication, @@ -32,6 +32,10 @@ from PyQt5.QtCore import ( ) from qfluentwidgets import InfoBar, InfoBarPosition, MessageBox, PushButton +from .android_device_service import ( + AndroidDeviceSearchError, + AndroidDeviceService, +) from .admin_gateway import ( AdminGatewayError, ClientInfo, @@ -101,6 +105,7 @@ class ClaimTaskWorker(QObject): localSaveFailed = pyqtSignal(str, str) retryableFailed = pyqtSignal(str) failed = pyqtSignal(str) + deviceUnavailable = pyqtSignal(str) outcome = pyqtSignal(str, str, str) completed = pyqtSignal() @@ -114,6 +119,7 @@ class ClaimTaskWorker(QObject): purchase_adapter_factory: Optional[PurchaseAdapterFactory] = None, purchase_reconcile_factory: Optional[PurchaseReconcileFactory] = None, selected_task_id: str = "", + device_connection_checker: Optional[Callable[[str], None]] = None, ) -> None: super().__init__() self._gateway = gateway @@ -125,6 +131,10 @@ class ClaimTaskWorker(QObject): self._purchase_adapter_factory = purchase_adapter_factory self._purchase_reconcile_factory = purchase_reconcile_factory self._selected_task_id = selected_task_id + self._device_connection_checker = ( + device_connection_checker + or AndroidDeviceService().require_connected + ) def cancel(self) -> None: """阻止尚未开始的领取;已领取的任务仍必须保存到本地。""" @@ -147,6 +157,10 @@ class ClaimTaskWorker(QObject): client_settings.client_name, ) if self._selected_task_id: + self._device_connection_checker(android_serial or "") + self._task_repository.prepare_collect_rerun( + self._selected_task_id + ) service = CollectTaskService( self._gateway, self._task_repository, @@ -166,10 +180,14 @@ class ClaimTaskWorker(QObject): purchase_adapter_factory=self._purchase_adapter_factory, purchase_reconcile_factory=self._purchase_reconcile_factory, cancelled=lambda: self._cancelled, + device_connection_checker=self._device_connection_checker, ) result = dispatcher.execute_one() if not self._cancelled or result.kind == "cancelled": 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: if not self._cancelled: request_hint = ( @@ -200,6 +218,7 @@ class PDDTaskPageEvent(QObject): collect_service_factory: Optional[CollectServiceFactory] = None, purchase_adapter_factory: Optional[PurchaseAdapterFactory] = None, purchase_reconcile_factory: Optional[PurchaseReconcileFactory] = None, + device_connection_checker: Optional[Callable[[str], None]] = None, next_task_delay_ms: int = 500, no_task_delay_ms: int = 5_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._rerun_cancel_requested = False self._rerun_feedback: Optional[InfoBar] = None + self._device_feedback: Optional[InfoBar] = None self._auto_fetch_running = False self._stop_requested = False self._cycle_next_delay_ms: Optional[int] = None @@ -223,6 +243,10 @@ class PDDTaskPageEvent(QObject): self._collect_service_factory = collect_service_factory self._purchase_adapter_factory = purchase_adapter_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: raise ValueError("自动获取等待时间配置无效") if not retry_delays_ms or any(value <= 0 for value in retry_delays_ms): @@ -325,8 +349,8 @@ class PDDTaskPageEvent(QObject): ) return if not android_serial: - self._show_rerun_warning( - "重新执行不可用", "请先在设置页选择并保存 Android 设备。" + self._show_device_unavailable( + "请先在设置页选择并保存 Android 设备" ) return try: @@ -353,17 +377,6 @@ class PDDTaskPageEvent(QObject): if not dialog.exec(): 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) def _start_rerun_worker(self, task_id: str) -> None: @@ -371,10 +384,13 @@ class PDDTaskPageEvent(QObject): assert self._claim_gateway is not None self._close_rerun_feedback() + self._close_device_feedback() self._rerun_cancel_requested = False self._claim_busy = 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() thread = QThread(self) @@ -385,11 +401,13 @@ class PDDTaskPageEvent(QObject): self._selected_android_device_service, self._collect_service_factory, selected_task_id=task_id, + device_connection_checker=self._device_connection_checker, ) worker.moveToThread(thread) thread.started.connect(worker.run) worker.retryableFailed.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.completed.connect(thread.quit) worker.completed.connect(worker.deleteLater) @@ -443,6 +461,16 @@ class PDDTaskPageEvent(QObject): self._page.set_engine_status(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() def _on_rerun_thread_finished(self) -> None: cancel_requested = self._rerun_cancel_requested @@ -528,6 +556,7 @@ class PDDTaskPageEvent(QObject): self._show_claim_error("领取任务失败", message) return + self._close_device_feedback() self._auto_fetch_running = True self._stop_requested = False self._retry_count = 0 @@ -560,6 +589,7 @@ class PDDTaskPageEvent(QObject): collect_service_factory=self._collect_service_factory, purchase_adapter_factory=self._purchase_adapter_factory, purchase_reconcile_factory=self._purchase_reconcile_factory, + device_connection_checker=self._device_connection_checker, ) worker.moveToThread(thread) thread.started.connect(worker.run) @@ -569,6 +599,7 @@ class PDDTaskPageEvent(QObject): worker.localSaveFailed.connect(self._on_claimed_task_save_failed) worker.retryableFailed.connect(self._on_claim_retryable_failed) worker.failed.connect(self._on_claim_failed) + worker.deviceUnavailable.connect(self._on_claim_device_unavailable) worker.outcome.connect(self._on_collect_outcome) worker.completed.connect(thread.quit) worker.completed.connect(worker.deleteLater) @@ -682,6 +713,16 @@ class PDDTaskPageEvent(QObject): self._show_claim_error("领取任务失败", 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) def _on_collect_outcome(self, kind: str, message: str, task_id: str) -> None: if self._closing: @@ -753,6 +794,53 @@ class PDDTaskPageEvent(QObject): 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() def _on_claim_thread_finished(self) -> None: self._claim_worker = None @@ -849,6 +937,7 @@ class PDDTaskPageEvent(QObject): self._stop_requested = True self._next_cycle_timer.stop() self._close_rerun_feedback() + self._close_device_feedback() for window in list(self._detail_windows.values()): window.close() @@ -868,6 +957,14 @@ class PDDTaskPageEvent(QObject): self._on_claimed_task_save_failed, ), (worker.failed, self._on_claim_failed), + ( + worker.deviceUnavailable, + self._on_claim_device_unavailable, + ), + ( + worker.deviceUnavailable, + self._on_rerun_device_unavailable, + ), ( worker.retryableFailed, self._on_claim_retryable_failed, diff --git a/client/src/task_dispatcher.py b/client/src/task_dispatcher.py index f2bacbf..5c37294 100644 --- a/client/src/task_dispatcher.py +++ b/client/src/task_dispatcher.py @@ -14,6 +14,10 @@ from .admin_gateway import ( ClientInfo, ) from .collect_task_service import CollectServiceFactory, CollectTaskService +from .android_device_service import ( + AndroidDeviceSearchError, + AndroidDeviceService, +) from .purchase_task_service import ( PurchaseAdapterFactory, PurchaseTaskService, @@ -126,6 +130,7 @@ class TaskDispatcher: purchase_adapter_factory: Optional[PurchaseAdapterFactory] = None, purchase_reconcile_factory: Optional[PurchaseReconcileFactory] = None, cancelled: Callable[[], bool] = lambda: False, + device_connection_checker: Optional[Callable[[str], None]] = None, ) -> None: self._gateway = gateway self._repository = repository @@ -135,6 +140,10 @@ class TaskDispatcher: self._purchase_factory = purchase_adapter_factory self._reconcile_factory = purchase_reconcile_factory self._cancelled = cancelled + self._device_connection_checker = ( + device_connection_checker + or AndroidDeviceService().require_connected + ) @property def purchase_ready(self) -> bool: @@ -169,7 +178,10 @@ class TaskDispatcher: if pending is not None: return self._submit_pending(pending) 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() if reconcile_task is not None: diff --git a/client/src/ui_main.py b/client/src/ui_main.py index 819e708..76c7117 100644 --- a/client/src/ui_main.py +++ b/client/src/ui_main.py @@ -58,6 +58,9 @@ class MainWindow(FluentWindow): 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.settingsPage, diff --git a/client/test/test_android_device_service.py b/client/test/test_android_device_service.py index fdf2193..5711cd9 100644 --- a/client/test/test_android_device_service.py +++ b/client/test/test_android_device_service.py @@ -72,6 +72,39 @@ USB-002 unauthorized usb:1-2 transport_id:3 self.assertEqual(len(commands), 2) 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): commands = [] diff --git a/client/test/test_pdd_ui_event.py b/client/test/test_pdd_ui_event.py index 573d925..4ca689f 100644 --- a/client/test/test_pdd_ui_event.py +++ b/client/test/test_pdd_ui_event.py @@ -12,6 +12,7 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") from PyQt5.QtWidgets import QApplication +from src.android_device_service import AndroidDeviceSearchError from src.pdd_ui import PDDTaskPage from src.admin_gateway import AdminGatewayError, AdminTask, SubmissionReceipt from src.pdd_ui_event import ( @@ -238,8 +239,14 @@ class PDDTaskPageEventTest(unittest.TestCase): self.temp_directory = tempfile.TemporaryDirectory() self.db_path = Path(self.temp_directory.name) / "client.db" 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): + self.device_checker_patch.stop() self.temp_directory.cleanup() def _add_task( @@ -481,6 +488,97 @@ class PDDTaskPageEventTest(unittest.TestCase): events.shutdown() 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): page = PDDTaskPage() events = PDDTaskPageEvent(page, BrokenRepository()) @@ -587,6 +685,9 @@ class PDDTaskPageEventTest(unittest.TestCase): create_u2_purchase_adapter, ) 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.deleteLater() diff --git a/client/test/test_purchase_recovery.py b/client/test/test_purchase_recovery.py index 14cec46..245d66d 100644 --- a/client/test/test_purchase_recovery.py +++ b/client/test/test_purchase_recovery.py @@ -235,6 +235,7 @@ class PurchaseRecoveryTest(unittest.TestCase): purchase_reconcile_factory=( lambda _address, _cancelled: ReadOnlyReconcileAdapter(calls) ), + device_connection_checker=lambda _serial: None, ) first = dispatcher.execute_one() @@ -267,6 +268,7 @@ class PurchaseRecoveryTest(unittest.TestCase): lambda _address, _cancelled: purchase_calls.append("purchase") ), purchase_reconcile_factory=unavailable_reconcile, + device_connection_checker=lambda _serial: None, ).execute_one() self.assertEqual(outcome.kind, "manual_review") diff --git a/client/test/test_task_dispatcher.py b/client/test/test_task_dispatcher.py index cb20f43..89353af 100644 --- a/client/test/test_task_dispatcher.py +++ b/client/test/test_task_dispatcher.py @@ -4,7 +4,8 @@ import tempfile import unittest 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.pdd_purchase_adapter import PddPurchaseAdapter, PurchasePageState from src.task_dispatcher import TaskDispatcher, admin_task_to_new_claimed_task @@ -102,7 +103,7 @@ class TaskDispatcherTest(unittest.TestCase): def tearDown(self): self.temporary.cleanup() - def _dispatcher(self, *, purchase_ready): + def _dispatcher(self, *, purchase_ready, device_checker=lambda _serial: None): purchase_factory = None if purchase_ready: purchase_factory = ( @@ -117,6 +118,7 @@ class TaskDispatcherTest(unittest.TestCase): lambda *_args: RecordingCollector(self.calls) ), purchase_adapter_factory=purchase_factory, + device_connection_checker=device_checker, ) def test_capability_only_includes_purchase_when_adapter_is_ready(self): @@ -173,6 +175,56 @@ class TaskDispatcherTest(unittest.TestCase): assert detail is not None 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): invalid_payloads = ( {"goods_url": "https://example.test"}, diff --git a/docs/client/02-architecture.md b/docs/client/02-architecture.md index 028b62a..7c226c8 100644 --- a/docs/client/02-architecture.md +++ b/docs/client/02-architecture.md @@ -322,13 +322,15 @@ Client 与 Admin 的任务交互只有三种调用:领一个任务、提交结 ### 任务领取与执行 -1. 调用 Admin `claim` 领一个任务;返回 204 表示暂时没活,按轮询周期退避后再试。 -2. Client 持久化任务,状态置 `claimed`。 -3. 根据 `task_type` 分派给采集或采购执行器。 -4. 工作线程执行,持续更新 `current_step`(只写本地,不上报 Admin)。 -5. 完成后在同一事务里写入结果与 Outbox,状态置 `result_pending`。 -6. Outbox 提交成功、Admin 返回 `accepted: true` 后标记 `succeeded`。 -7. 回到第 1 步领下一个任务。**同一时间只做一个任务。** +1. 优先补交一条本地 Outbox。提交结果不依赖 Android 设备。 +2. 没有待提交结果时,在工作线程通过 ADB 检查已保存设备号是否仍为 `device` 状态。检查失败立即停止,不执行本地任务,也不领取新任务。 +3. 执行最早的本地待处理任务;没有本地任务时才调用 Admin `claim`。返回 204 表示暂时没活,按轮询周期退避后再试。 +4. Client 持久化新领取的任务,状态置 `claimed`。 +5. 根据 `task_type` 分派给采集或采购执行器。 +6. 工作线程执行,持续更新 `current_step`(只写本地,不上报 Admin)。 +7. 完成后在同一事务里写入结果与 Outbox,状态置 `result_pending`。 +8. Outbox 提交成功、Admin 返回 `accepted: true` 后标记 `succeeded`。 +9. 回到第 1 步处理下一轮。**同一时间只做一个任务。** `TaskDispatcher` 是能力声明的唯一入口。没有可用采购演练 Adapter、没有已保存 Android 设备或本地持久化未准备好时,只声明 `collect`;条件满足时才声明 diff --git a/docs/client/05-ui-specification.md b/docs/client/05-ui-specification.md index 2b4d5bc..b4fe05f 100644 --- a/docs/client/05-ui-specification.md +++ b/docs/client/05-ui-specification.md @@ -67,6 +67,11 @@ 或配置错误会停止自动获取。采购演练执行器和 Android 设备都准备好时,可以领取 采购任务;界面必须明确显示“演练”,任何路径都不得显示成真实下单。 +- 补交本地 Outbox 不依赖手机,可以在 Android 设备断开时继续执行。 +- 执行本地任务或向 Admin 领取新任务前,工作线程必须用 `adb devices -l` 检查已保存的精确设备号。USB 和 Wi-Fi 设备都只接受 `device` 状态。 +- 设备未连接、`offline`、`unauthorized` 或 ADB 检查失败时,立即停止本轮和自动获取;不得领取新任务,也不得启动本地任务。 +- 设备错误使用一条持久 `InfoBar` 提示,提供“打开设置”和“关闭提示”。重复错误替换旧提示,不得堆叠。 + ### 4.2 搜索与筛选 建议控件: @@ -83,6 +88,7 @@ - “刷新”只重新读取本地任务列表,不请求 Admin,也不操作手机。 - “重新执行”位于刷新右侧;没有选中任务时禁用。 - 点击后先校验任务,再显示明确的“重新采集”确认弹窗。弹窗显示任务编号、商品标题,并说明新结果会覆盖 Client 和 Admin 的当前采集数据。“暂不重新采集”是默认聚焦的安全操作,点击该按钮、按 `Escape` 或关闭弹窗都不得启动任务。 +- 用户确认后,工作线程必须先检查已保存 Android 设备的实际连接状态,再重置任务和创建执行记录。检查失败时保留原任务状态、采集结果和执行历史。 - 允许重新采集已经结束或处于“等待重试”的采集任务。采购、执行中、结果待提交、仍有未发送 Outbox 或自动获取忙碌时必须阻止,并用中文说明原因。 - 确认后只执行选中的稳定任务编号,不领取新任务,不先处理其他任务或 Outbox。 - 重新采集在工作线程运行。执行期间禁用“获取任务”,“重新执行”变为可点击的“停止重新采集”。