From 077cba6831bb088742ab0780ecdd471c97e30b48 Mon Sep 17 00:00:00 2001 From: chengma Date: Mon, 10 Aug 2026 11:18:06 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E9=87=8D=E6=96=B0?= =?UTF-8?q?=E4=B8=8A=E6=8A=A5=E4=BB=BB=E5=8A=A1=E7=8A=B6=E6=80=81=E4=B8=8D?= =?UTF-8?q?=E4=B8=80=E8=87=B4=20(#91)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/AGENTS.md | 2 +- client/src/pdd_ui_event.py | 33 +++--- client/src/task_repository.py | 160 ++++++++++++++++++++++++++-- client/test/test_pdd_ui_event.py | 77 ++++++++++++- client/test/test_task_repository.py | 93 +++++++++++++++- docs/client/03-data-model.md | 9 ++ docs/client/05-ui-specification.md | 5 +- 7 files changed, 352 insertions(+), 27 deletions(-) diff --git a/client/AGENTS.md b/client/AGENTS.md index 4b016c9..92ac881 100644 --- a/client/AGENTS.md +++ b/client/AGENTS.md @@ -72,7 +72,7 @@ - 优先使用 `FluentWindow`、Fluent 导航、主题和图标;不得使用表情符号充当结构图标。 - 使用布局、尺寸策略和伸缩项,不用固定坐标排列常规界面。 - 任务表格使用模型/视图和稳定任务编号,不把完整 `pdd_data` 放入隐藏列,也不为每个单元格创建常驻 QWidget。 -- 会产生外部后果的命令只有“获取任务”“重新执行”和“重新上报”:重新执行只允许单条采集任务;重新上报只重发既有结果 Outbox,不操作手机、不创建新结果。搜索、筛选、刷新和详情仍只读本地数据库。 +- 会产生外部后果的命令只有“获取任务”“重新执行”和“重新上报”:重新执行只允许单条采集任务;重新上报只重发既有 Outbox(优先未发送事件),不操作手机、不创建新事件。搜索、筛选、刷新和详情仍只读本地数据库。 - 本地只保存已领取的任务,不缓存 Admin 任务池;已完成任务永久保留,不得清理。 - 普通成功更新页面状态即可;可恢复错误使用 `InfoBar`,只有必须阻断决策时才使用模态对话框。 - 主要流程必须支持键盘;表单具有可见标签;状态和错误不能只依赖颜色。 diff --git a/client/src/pdd_ui_event.py b/client/src/pdd_ui_event.py index ec012fa..171d248 100644 --- a/client/src/pdd_ui_event.py +++ b/client/src/pdd_ui_event.py @@ -50,6 +50,7 @@ from .purchase_reconcile_service import PurchaseReconcileFactory from .selected_android_device_service import SelectedAndroidDeviceService from .settings_repository import SettingsRepository from .task_models import ( + OutboxEventType, OutboxStatus, TaskFilters, TaskStatus, @@ -213,7 +214,7 @@ class ClaimTaskWorker(QObject): class ResultResubmitWorker(QObject): - """在后台逐条重发既有结果 Outbox,不执行任何手机操作。""" + """在后台逐条重发既有 Outbox,不执行任何手机操作。""" progress = pyqtSignal(int, int, str) finished = pyqtSignal(object, object, object) @@ -248,7 +249,7 @@ class ResultResubmitWorker(QObject): break self.progress.emit(current, total, task_id) try: - event = self._repository.latest_result_outbox(task_id) + event = self._repository.outbox_for_resubmit(task_id) except Exception: skipped.append(task_id) continue @@ -258,11 +259,18 @@ class ResultResubmitWorker(QObject): self._repository.mark_outbox_sending(event.id) try: - receipt = self._gateway.submit_result( - task_id, - event.idempotency_key, - event.payload_json, - ) + if event.event_type is OutboxEventType.TASK_FAILURE: + receipt = self._gateway.submit_failure( + task_id, + event.idempotency_key, + event.payload_json, + ) + else: + receipt = self._gateway.submit_result( + task_id, + event.idempotency_key, + event.payload_json, + ) if not receipt.accepted: raise AdminGatewayError( "ADMIN_RESULT_NOT_ACCEPTED", @@ -469,7 +477,7 @@ class PDDTaskPageEvent(QObject): @pyqtSlot(object) def request_resubmit(self, task_ids) -> None: - """确认后在后台重发勾选任务的原始结果 Outbox。""" + """确认后在后台重发勾选任务尚未发送或最新的 Outbox。""" stable_ids = tuple( dict.fromkeys(str(value) for value in task_ids if value) @@ -490,8 +498,9 @@ class PDDTaskPageEvent(QObject): return dialog = MessageBox( - f"重新上报 {len(stable_ids)} 条任务结果?", - "只会重新提交本地已经保存的原始结果," + f"重新上报 {len(stable_ids)} 条任务数据?", + "会优先提交尚未发送的结果或失败信息;没有待发送数据时," + "才重新提交本地已经保存的最新结果。" "不会重新采集、采购或操作 Android 手机,也不会修改本地结果。", self._page.window(), ) @@ -504,13 +513,13 @@ class PDDTaskPageEvent(QObject): self._start_resubmit_worker(stable_ids) def _start_resubmit_worker(self, task_ids: tuple[str, ...]) -> None: - """启动只处理指定结果 Outbox 的工作线程。""" + """启动只处理指定任务 Outbox 的工作线程。""" assert self._claim_gateway is not None self._resubmit_busy = True self._page.set_resubmit_running(True) self._page.set_engine_status( - f"正在准备重新上报 {len(task_ids)} 条任务结果…" + f"正在准备重新上报 {len(task_ids)} 条任务数据…" ) thread = QThread(self) diff --git a/client/src/task_repository.py b/client/src/task_repository.py index 99817e1..3cc66f7 100644 --- a/client/src/task_repository.py +++ b/client/src/task_repository.py @@ -408,18 +408,30 @@ class TaskRepository: }.get(row["status"], row["status"]) raise CollectRerunError(f"任务当前为“{status_name}”,不能重新采集") unsent = connection.execute( - "SELECT status, last_error FROM outbox_events" + "SELECT event_type, status, last_error FROM outbox_events" " WHERE task_id = ? AND status != 'sent'" " ORDER BY id DESC LIMIT 1", (row["id"],), ).fetchone() if unsent is not None and unsent["status"] == OutboxStatus.FAILED.value: reason = unsent["last_error"] or "Admin 未接收上次结果" + event_name = ( + "失败信息" + if unsent["event_type"] == OutboxEventType.TASK_FAILURE.value + else "结果" + ) raise CollectRerunError( - f"任务上次结果提交失败:{reason};请先处理后再重新采集" + f"任务上次{event_name}提交失败:{reason};" + "请先勾选任务点击“重新上报”" ) if unsent is not None: - raise CollectRerunError("任务仍有未发送的结果,请先完成提交") + if unsent["event_type"] == OutboxEventType.TASK_FAILURE.value: + raise CollectRerunError( + "任务仍有未上报的失败信息,请先勾选任务点击“重新上报”" + ) + raise CollectRerunError( + "任务仍有未发送的结果,请先勾选任务点击“重新上报”" + ) def start_collect_run( self, remote_task_id: str, device_address: str @@ -1017,6 +1029,37 @@ class TaskRepository: connection.close() return self._to_outbox(row) if row is not None else None + def outbox_for_resubmit( + self, remote_task_id: str + ) -> Optional[OutboxEventRecord]: + """返回手工重新上报应发送的事件。 + + 先返回最早一条尚未发送的事件,保证失败信息不会被旧结果跳过; + 全部事件都已发送时,才返回最新的成功结果用于再次确认。 + """ + + connection = open_database(self._db_path) + try: + row = connection.execute( + "SELECT o.* FROM outbox_events o" + " JOIN pdd_tasks t ON t.id = o.task_id" + " WHERE t.remote_task_id = ? AND o.status != 'sent'" + " ORDER BY o.id ASC LIMIT 1", + (remote_task_id,), + ).fetchone() + if row is None: + row = connection.execute( + "SELECT o.* FROM outbox_events o" + " JOIN pdd_tasks t ON t.id = o.task_id" + " WHERE t.remote_task_id = ?" + " AND o.event_type IN ('collect_result', 'purchase_result')" + " ORDER BY o.id DESC LIMIT 1", + (remote_task_id,), + ).fetchone() + finally: + connection.close() + return self._to_outbox(row) if row is not None else None + def get_outbox_event(self, event_id: int) -> Optional[OutboxEventRecord]: connection = open_database(self._db_path) try: @@ -1051,34 +1094,133 @@ class TaskRepository: self._update_outbox(event_id, "failed", message, increment=True) def mark_outbox_sent(self, event_id: int) -> None: + """标记事件已发送,并只按当前最新执行更新任务状态。 + + 已经发送过的历史结果再次上报时,不得覆盖任务的最新状态。 + 最新失败信息发送成功时会恢复对应失败状态,用于修复旧版本 + 产生的“表格显示已完成、实际最新执行失败”记录。 + """ + now = utc_now_iso() connection = open_database(self._db_path) try: with connection: row = connection.execute( - "SELECT task_id, event_type FROM outbox_events WHERE id = ?", + "SELECT task_id, event_type, payload_json, sent_at" + " FROM outbox_events WHERE id = ?", (event_id,), ).fetchone() if row is None: raise ValueError(f"Outbox {event_id} 不存在") + was_already_sent = row["sent_at"] is not None connection.execute( "UPDATE outbox_events SET status = 'sent', sent_at = ?," " updated_at = ?, attempt_count = attempt_count + 1" " WHERE id = ?", (now, now, event_id), ) - if row["event_type"] in { + payload = self._load_outbox_payload(row["payload_json"]) + if row["event_type"] == OutboxEventType.TASK_FAILURE.value: + self._restore_current_failure_state( + connection, row["task_id"], payload, now + ) + elif not was_already_sent and row["event_type"] in { OutboxEventType.COLLECT_RESULT.value, OutboxEventType.PURCHASE_RESULT.value, }: - connection.execute( - "UPDATE pdd_tasks SET status = 'succeeded'," - " current_step = 'completed', updated_at = ? WHERE id = ?", - (now, row["task_id"]), + self._complete_current_result( + connection, row["task_id"], payload, now ) finally: connection.close() + @staticmethod + def _load_outbox_payload(payload_json: str) -> Dict[str, object]: + """读取本地 Outbox JSON;损坏时返回空对象,不猜测任务状态。""" + + try: + payload = json.loads(payload_json) + except (TypeError, ValueError): + return {} + return payload if isinstance(payload, dict) else {} + + @staticmethod + def _latest_run_for_task( + connection: sqlite3.Connection, task_id: int + ) -> Optional[sqlite3.Row]: + return connection.execute( + "SELECT attempt_id, finished_at FROM task_runs" + " WHERE task_id = ? ORDER BY attempt_no DESC LIMIT 1", + (task_id,), + ).fetchone() + + @classmethod + def _complete_current_result( + cls, + connection: sqlite3.Connection, + task_id: int, + payload: Dict[str, object], + now: str, + ) -> None: + """只有最新执行的待提交结果可以把任务改成已完成。""" + + latest_run = cls._latest_run_for_task(connection, task_id) + if ( + latest_run is None + or payload.get("attempt_id") != latest_run["attempt_id"] + ): + return + connection.execute( + "UPDATE pdd_tasks SET status = 'succeeded'," + " current_step = 'completed', updated_at = ?" + " WHERE id = ? AND status = 'result_pending'", + (now, task_id), + ) + + @classmethod + def _restore_current_failure_state( + cls, + connection: sqlite3.Connection, + task_id: int, + payload: Dict[str, object], + now: str, + ) -> None: + """最新失败事件上报后恢复任务失败状态,旧事件不覆盖新状态。""" + + latest_run = cls._latest_run_for_task(connection, task_id) + if ( + latest_run is None + or payload.get("attempt_id") != latest_run["attempt_id"] + ): + return + status = payload.get("status") + allowed_statuses = { + TaskStatus.RETRY_WAIT.value, + TaskStatus.MANUAL_REVIEW.value, + TaskStatus.FAILED.value, + TaskStatus.CANCELLED.value, + } + if status not in allowed_statuses: + return + error = payload.get("error") + error = error if isinstance(error, dict) else {} + finished_at = ( + latest_run["finished_at"] or payload.get("reported_at") or now + ) + connection.execute( + "UPDATE pdd_tasks SET status = ?, current_step = 'failed'," + " last_error_code = ?, last_error_message = ?," + " finished_at = ?, updated_at = ? WHERE id = ?", + ( + status, + error.get("code"), + error.get("message"), + finished_at, + now, + task_id, + ), + ) + def _update_outbox( self, event_id: int, status: str, message: Optional[str], increment: bool = False ) -> None: diff --git a/client/test/test_pdd_ui_event.py b/client/test/test_pdd_ui_event.py index f561044..1cc51f7 100644 --- a/client/test/test_pdd_ui_event.py +++ b/client/test/test_pdd_ui_event.py @@ -15,6 +15,7 @@ from PyQt5.QtWidgets import QApplication from qfluentwidgets import InfoBarPosition from src.android_device_service import AndroidDeviceSearchError +from src.db import open_database from src.pdd_ui import PDDTaskPage from src.admin_gateway import AdminGatewayError, AdminTask, SubmissionReceipt from src.pdd_ui_event import ( @@ -32,7 +33,13 @@ from src.pdd_purchase_adapter import PddPurchaseAdapter, PurchasePageState from src.pdd_collect_service import PddCollectError from src.pdd_u2_purchase_adapter import create_u2_purchase_adapter from src.settings_repository import SettingsRepository -from src.task_models import NewClaimedTask, TaskStatus, TaskSummary, TaskType +from src.task_models import ( + NewClaimedTask, + OutboxEventType, + TaskStatus, + TaskSummary, + TaskType, +) from src.task_repository import TaskRepository from src.ui_main import MainWindow @@ -136,6 +143,7 @@ class RecordingResubmitGateway(RecordingClaimGateway): self.failures = dict(failures or {}) self.delay = delay self.submit_calls = [] + self.failure_calls = [] def submit_result(self, task_id, idempotency_key, result): self.submit_calls.append((task_id, idempotency_key, result)) @@ -146,6 +154,15 @@ class RecordingResubmitGateway(RecordingClaimGateway): raise error return SubmissionReceipt(True, f"RESULT-{task_id}", "2026-08-10T08:00:00Z") + def submit_failure(self, task_id, idempotency_key, failure): + self.failure_calls.append((task_id, idempotency_key, failure)) + if self.delay: + time.sleep(self.delay) + error = self.failures.get(task_id) + if error is not None: + raise error + return SubmissionReceipt(True, f"FAILURE-{task_id}", "2026-08-10T08:00:00Z") + class FakeCollectResult: def to_pdd_data(self): @@ -457,6 +474,64 @@ class PDDTaskPageEventTest(unittest.TestCase): events.shutdown() page.deleteLater() + def test_resubmit_prioritizes_pending_failure_and_repairs_task_status(self): + self._add_task(1) + first = self.repository.start_collect_run("PDD-001", "USB-001") + result = self.repository.save_collect_result( + "PDD-001", first.attempt_id, FakeCollectResult().to_pdd_data() + ) + self.repository.mark_outbox_sent(result.id) + self.repository.prepare_collect_rerun("PDD-001") + second = self.repository.start_collect_run("PDD-001", "USB-001") + failure = self.repository.save_collect_failure( + "PDD-001", + second.attempt_id, + TaskStatus.FAILED, + "SKU_PANEL_NOT_FOUND", + "规格面板加载超时", + False, + ) + connection = open_database(self.db_path) + try: + with connection: + connection.execute( + "UPDATE pdd_tasks SET status = 'succeeded'," + " current_step = 'completed' WHERE remote_task_id = 'PDD-001'" + ) + finally: + connection.close() + + page = PDDTaskPage() + gateway = RecordingResubmitGateway() + events = PDDTaskPageEvent(page, self.repository, claim_gateway=gateway) + events.load_initial_tasks() + page.taskModel.setData( + page.taskModel.index(0, 0), Qt.Checked, Qt.CheckStateRole + ) + + with patch("src.pdd_ui_event.MessageBox") as message_box: + message_box.return_value.exec.return_value = True + page.resubmitButton.click() + self.assertTrue(wait_until(self.app, lambda: not events._resubmit_busy)) + + self.assertEqual(gateway.submit_calls, []) + self.assertEqual(len(gateway.failure_calls), 1) + task_id, idempotency_key, payload = gateway.failure_calls[0] + self.assertEqual(task_id, "PDD-001") + self.assertEqual(idempotency_key, failure.idempotency_key) + self.assertEqual(payload, failure.payload_json) + self.assertEqual(payload["status"], TaskStatus.FAILED.value) + self.assertEqual( + self.repository.get_outbox_event(failure.id).event_type, + OutboxEventType.TASK_FAILURE, + ) + repaired = self.repository.get_task("PDD-001") + self.assertEqual(repaired.status, TaskStatus.FAILED) + self.assertEqual(repaired.last_error_code, "SKU_PANEL_NOT_FOUND") + self.device_checker.assert_not_called() + events.shutdown() + page.deleteLater() + def test_resubmit_confirmation_cancel_does_not_submit(self): self._add_task(1) page = PDDTaskPage() diff --git a/client/test/test_task_repository.py b/client/test/test_task_repository.py index 1509382..efe1a83 100644 --- a/client/test/test_task_repository.py +++ b/client/test/test_task_repository.py @@ -234,7 +234,96 @@ class TaskRepositoryTests(unittest.TestCase): self.assertEqual(latest.id, result.id) self.assertEqual(latest.event_type, OutboxEventType.COLLECT_RESULT) self.assertNotEqual(latest.id, failure.id) + self.assertEqual( + self.repository.outbox_for_resubmit("TASK-REPORT").id, + failure.id, + ) + self.repository.mark_outbox_sent(failure.id) + self.assertEqual( + self.repository.outbox_for_resubmit("TASK-REPORT").id, + result.id, + ) self.assertIsNone(self.repository.latest_result_outbox("MISSING")) + self.assertIsNone(self.repository.outbox_for_resubmit("MISSING")) + + def test_replaying_old_result_does_not_hide_latest_failure(self): + self.repository.add_claimed_task(self._task("TASK-STATE")) + first = self.repository.start_collect_run("TASK-STATE", "USB-001") + result = self.repository.save_collect_result( + "TASK-STATE", + first.attempt_id, + {"goods_id": "10001", "title": "旧结果", "skus": []}, + ) + self.repository.mark_outbox_sent(result.id) + self.repository.prepare_collect_rerun("TASK-STATE") + second = self.repository.start_collect_run("TASK-STATE", "USB-001") + failure = self.repository.save_collect_failure( + "TASK-STATE", + second.attempt_id, + TaskStatus.FAILED, + "SKU_PANEL_NOT_FOUND", + "规格面板加载超时", + False, + ) + + self.repository.mark_outbox_sending(result.id) + self.repository.mark_outbox_sent(result.id) + + detail = self.repository.get_task("TASK-STATE") + self.assertEqual(detail.status, TaskStatus.FAILED) + self.assertEqual(detail.last_error_code, "SKU_PANEL_NOT_FOUND") + + # 模拟旧版本已经把主状态错误改成“已完成”。发送最新失败信息后 + # 应当依据最新执行记录恢复,而不是继续保留假完成状态。 + connection = open_database(self.db_path) + try: + with connection: + connection.execute( + "UPDATE pdd_tasks SET status = 'succeeded'," + " current_step = 'completed' WHERE remote_task_id = ?", + ("TASK-STATE",), + ) + finally: + connection.close() + self.repository.mark_outbox_sending(failure.id) + self.repository.mark_outbox_sent(failure.id) + + repaired = self.repository.get_task("TASK-STATE") + self.assertEqual(repaired.status, TaskStatus.FAILED) + self.assertEqual(repaired.current_step, "failed") + self.assertEqual(repaired.last_error_message, "规格面板加载超时") + + def test_replaying_old_failure_does_not_override_new_success(self): + self.repository.add_claimed_task(self._task("TASK-NEW-SUCCESS")) + first = self.repository.start_collect_run( + "TASK-NEW-SUCCESS", "USB-001" + ) + old_failure = self.repository.save_collect_failure( + "TASK-NEW-SUCCESS", + first.attempt_id, + TaskStatus.FAILED, + "OLD_FAILURE", + "旧执行失败", + False, + ) + self.repository.mark_outbox_sent(old_failure.id) + self.repository.prepare_collect_rerun("TASK-NEW-SUCCESS") + second = self.repository.start_collect_run( + "TASK-NEW-SUCCESS", "USB-001" + ) + new_result = self.repository.save_collect_result( + "TASK-NEW-SUCCESS", + second.attempt_id, + {"goods_id": "10001", "title": "新结果", "skus": []}, + ) + self.repository.mark_outbox_sent(new_result.id) + + self.repository.mark_outbox_sending(old_failure.id) + self.repository.mark_outbox_sent(old_failure.id) + + detail = self.repository.get_task("TASK-NEW-SUCCESS") + self.assertEqual(detail.status, TaskStatus.SUCCEEDED) + self.assertEqual(detail.current_step, "completed") def test_prepare_rerun_preserves_old_result_and_creates_new_attempt(self): self.repository.add_claimed_task(self._task("TASK-RERUN")) @@ -306,7 +395,7 @@ class TaskRepositoryTests(unittest.TestCase): True, ) - with self.assertRaisesRegex(CollectRerunError, "未发送"): + with self.assertRaisesRegex(CollectRerunError, "未上报的失败信息"): self.repository.validate_collect_rerun("RETRY-RERUN") self.repository.mark_outbox_sent(event.id) @@ -332,7 +421,7 @@ class TaskRepositoryTests(unittest.TestCase): self.repository.mark_outbox_failed(event.id, "任务不存在") with self.assertRaisesRegex( - CollectRerunError, "上次结果提交失败:任务不存在" + CollectRerunError, "上次失败信息提交失败:任务不存在" ): self.repository.validate_collect_rerun("RETRY-REJECTED") diff --git a/docs/client/03-data-model.md b/docs/client/03-data-model.md index 0aa692a..d78c809 100644 --- a/docs/client/03-data-model.md +++ b/docs/client/03-data-model.md @@ -373,6 +373,15 @@ def build_idempotency_key( 2. **键一旦入库就不能变。** 重试是拿库里存的键去发,不是现算一个。 3. **键相同,内容也必须相同。** 同一个键发不同内容,Admin 会返回 `409 IDEMPOTENCY_CONFLICT`。内容真的变了,说明这是一次新的执行,应该用新的 `attempt_id`。 +### 5.3 重新上报不能覆盖最新执行状态 + +“重新上报”只重发已经保存在 Outbox 的原始事件,不创建新事件。选择和状态更新遵守下面四条: + +1. 任务存在 `pending`、`sending` 或 `failed` 事件时,优先处理最早一条未发送事件,包括 `task_failure`;全部已发送时,才重发最新结果。 +2. 重发时继续使用事件原有的 `idempotency_key` 和 `payload_json`,不能重新组装。 +3. 已经发送过的历史结果再次得到 Admin 确认,只更新该 Outbox,不把任务主状态改成“已完成”。 +4. 只有事件的 `attempt_id` 等于最新 `task_runs.attempt_id` 时,事件才可以更新任务主状态。最新失败事件上报成功后恢复对应失败状态;旧失败事件不能覆盖新成功结果。 + ## 6. `app_settings` 保存非敏感设置和设置版本。 diff --git a/docs/client/05-ui-specification.md b/docs/client/05-ui-specification.md index 3e01e1b..6a99cad 100644 --- a/docs/client/05-ui-specification.md +++ b/docs/client/05-ui-specification.md @@ -9,7 +9,7 @@ ## 1. 设计目标 - 让操作人员在一个页面完成任务监控、搜索和异常定位。 -- “获取任务”“重新执行”和“重新上报”会产生外部后果;重新执行只允许单条采集任务,重新上报只重发既有结果 Outbox。 +- “获取任务”“重新执行”和“重新上报”会产生外部后果;重新执行只允许单条采集任务,重新上报只重发既有 Outbox,并优先处理未发送事件。 - 长任务状态始终可找到,不使用连续模态弹窗打断工作。 - 任务表格在数据增长后仍保持响应速度、稳定选择和可访问性。 - 界面只展示任务状态,不在 Qt 主线程执行 Admin 或手机自动化。 @@ -100,7 +100,8 @@ - “等待重试”当前没有倒计时。自动获取因可恢复采集错误停止时,底部状态显示 “重试已暂停”,并提示选择任务点击“重新执行”或重新启动获取任务。 - “重新上报”作用于当前已经加载并勾选的任务。执行前显示任务数量,并明确说明不会重新采集、采购或操作手机。 -- 重新上报只查找每条任务最新的 `collect_result` 或 `purchase_result` Outbox,使用原来的 `idempotency_key` 和 `payload_json`;不重新组装数据、不创建新 Outbox,也不发送 `task_failure`。 +- 重新上报先查找每条任务最早一条尚未发送的 Outbox,包括 `task_failure`;全部事件都已发送时,才重发最新的 `collect_result` 或 `purchase_result`。 +- 重新上报使用事件原来的 `idempotency_key` 和 `payload_json`,不重新组装数据、不创建新 Outbox。历史结果再次得到 Admin 确认时不得覆盖任务最新执行状态;最新失败信息上报成功后应恢复对应失败状态。 - 重新上报不依赖 Android 设备,在独立工作线程中逐条提交。自动获取、重新执行或另一批上报运行时不得启动。 - 批量完成后显示成功、失败和跳过数量。成功任务取消勾选;失败及没有结果 Outbox 的跳过任务保留勾选,方便继续处理。 - 相同幂等键和相同内容只用于让 Admin 再次确认已接收,不代表创建新结果或覆盖 Admin 数据。