From b2721071c98a556a6f5477793d4d0072de3e8495 Mon Sep 17 00:00:00 2001 From: chengma Date: Mon, 10 Aug 2026 17:23:24 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=89=E5=85=A8=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E4=BB=BB=E5=8A=A1=E8=AE=B0=E5=BD=95=20(#108)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/db_schema.py | 14 ++- client/src/pdd_ui.py | 48 ++++++-- client/src/pdd_ui_event.py | 163 +++++++++++++++++++++++++++- client/src/task_repository.py | 100 ++++++++++++++++- client/test/test_db.py | 51 ++++++++- client/test/test_pdd_ui.py | 23 ++++ client/test/test_pdd_ui_event.py | 99 +++++++++++++++++ client/test/test_task_repository.py | 120 +++++++++++++++++++- docs/client/03-data-model.md | 16 ++- docs/client/05-ui-specification.md | 15 ++- 10 files changed, 619 insertions(+), 30 deletions(-) diff --git a/client/src/db_schema.py b/client/src/db_schema.py index 0220da4..22073ac 100644 --- a/client/src/db_schema.py +++ b/client/src/db_schema.py @@ -4,7 +4,7 @@ ``MIGRATIONS`` 末尾增加版本,不能修改已经发布的迁移。 """ -SCHEMA_VERSION = 3 +SCHEMA_VERSION = 4 MIGRATION_1 = ( @@ -142,8 +142,20 @@ MIGRATION_3 = ( ) +MIGRATION_4 = ( + """ + ALTER TABLE pdd_tasks ADD COLUMN removed_at TEXT + """, + """ + CREATE INDEX idx_pdd_tasks_visible_list + ON pdd_tasks(removed_at, updated_at DESC, id DESC) + """, +) + + MIGRATIONS = { 1: MIGRATION_1, 2: MIGRATION_2, 3: MIGRATION_3, + 4: MIGRATION_4, } diff --git a/client/src/pdd_ui.py b/client/src/pdd_ui.py index beffb36..b4bb845 100644 --- a/client/src/pdd_ui.py +++ b/client/src/pdd_ui.py @@ -368,6 +368,7 @@ class PDDTaskPage(QWidget): rerunRequested = pyqtSignal(str) rerunCancelRequested = pyqtSignal() resubmitRequested = pyqtSignal(object) + removeRequested = pyqtSignal(object) openSettingsRequested = pyqtSignal() detailRequested = pyqtSignal(str) @@ -377,6 +378,7 @@ class PDDTaskPage(QWidget): self._rerun_state = "idle" self._resubmit_running = False + self._remove_running = False self.taskModel = TaskTableModel(self) self._build_command_area() self._build_content_area() @@ -429,6 +431,10 @@ class PDDTaskPage(QWidget): self.resubmitButton.setAccessibleName("重新上报勾选任务的已有结果") self.resubmitButton.setEnabled(False) + self.removeButton = PushButton(FIF.DELETE, "删除", self) + self.removeButton.setAccessibleName("从任务列表删除勾选的本地任务") + self.removeButton.setEnabled(False) + self.commandCard = CardWidget(self) self.checkedCountLabel = CaptionLabel("已选 0 条", self.commandCard) self.checkedCountLabel.setAccessibleName("已勾选任务数量") @@ -446,7 +452,7 @@ class PDDTaskPage(QWidget): if compact == self._compact_commands and self.commandLayout.count(): return self._compact_commands = compact - for column in range(13): + for column in range(14): self.commandLayout.setColumnStretch(column, 0) if compact: @@ -459,6 +465,7 @@ class PDDTaskPage(QWidget): (self.checkedCountLabel, 0, 6, 1, 1), (self.rerunButton, 0, 7, 1, 1), (self.resubmitButton, 0, 8, 1, 1), + (self.removeButton, 0, 9, 1, 1), (self.keywordLabel, 1, 0, 1, 1), (self.keywordInput, 1, 1, 1, 5), (self.searchButton, 1, 6, 1, 1), @@ -479,6 +486,7 @@ class PDDTaskPage(QWidget): (self.checkedCountLabel, 0, 10, 1, 1), (self.rerunButton, 0, 11, 1, 1), (self.resubmitButton, 0, 12, 1, 1), + (self.removeButton, 0, 13, 1, 1), ) self.commandLayout.setColumnStretch(6, 4) self.commandLayout.setColumnStretch(9, 1) @@ -562,6 +570,7 @@ class PDDTaskPage(QWidget): self.refreshButton.clicked.connect(self.refreshRequested.emit) self.rerunButton.clicked.connect(self._request_rerun) self.resubmitButton.clicked.connect(self._request_resubmit) + self.removeButton.clicked.connect(self._request_remove) self.clearFiltersButton.clicked.connect(self.clear_filters) self.taskTable.clicked.connect(self._on_table_clicked) self.taskTable.activated.connect(self._on_table_activated) @@ -634,7 +643,9 @@ class PDDTaskPage(QWidget): text, icon, enabled = states.get(state, states["stopped"]) self.autoFetchButton.setText(text) self.autoFetchButton.setIcon(icon) - self.autoFetchButton.setEnabled(enabled) + self.autoFetchButton.setEnabled( + enabled and not self._resubmit_running and not self._remove_running + ) self.autoFetchButton.setAccessibleName(f"{text} PDD 任务") def set_engine_status(self, text: str) -> None: @@ -669,6 +680,12 @@ class PDDTaskPage(QWidget): self._resubmit_running = running self._update_checked_actions(self.taskModel.checked_task_ids()) + def set_remove_running(self, running: bool) -> None: + """设置本地任务删除忙碌状态并防止重复操作。""" + + self._remove_running = running + self._update_checked_actions(self.taskModel.checked_task_ids()) + def set_rerun_cancelling(self) -> None: """显示取消请求已发出,防止用户重复点击。""" @@ -686,18 +703,26 @@ class PDDTaskPage(QWidget): self.rerunRequested.emit(task_ids[0]) def _request_resubmit(self) -> None: - if self._resubmit_running: + if self._resubmit_running or self._remove_running: return task_ids = self.taskModel.checked_task_ids() if task_ids: self.resubmitRequested.emit(task_ids) + def _request_remove(self) -> None: + if self._remove_running or self._resubmit_running: + return + task_ids = self.taskModel.checked_task_ids() + if task_ids: + self.removeRequested.emit(task_ids) + def _update_rerun_button(self) -> None: if self._rerun_state == "running": self.rerunButton.setText("停止重新采集") self.rerunButton.setAccessibleName("停止当前重新采集") self.rerunButton.setEnabled(True) self.resubmitButton.setEnabled(False) + self.removeButton.setEnabled(False) self.autoFetchButton.setEnabled(False) return if self._rerun_state == "cancelling": @@ -705,19 +730,22 @@ class PDDTaskPage(QWidget): self.rerunButton.setAccessibleName("正在停止重新采集") self.rerunButton.setEnabled(False) self.resubmitButton.setEnabled(False) + self.removeButton.setEnabled(False) self.autoFetchButton.setEnabled(False) return self.rerunButton.setText("重新执行") self.rerunButton.setAccessibleName("重新执行当前选中的采集任务") - self.autoFetchButton.setEnabled(not self._resubmit_running) + idle = not self._resubmit_running and not self._remove_running + self.autoFetchButton.setEnabled(idle) self.rerunButton.setEnabled( - not self._resubmit_running - and len(self.taskModel.checked_task_ids()) == 1 + idle and len(self.taskModel.checked_task_ids()) == 1 ) self.resubmitButton.setEnabled( - not self._resubmit_running - and bool(self.taskModel.checked_task_ids()) + idle and bool(self.taskModel.checked_task_ids()) + ) + self.removeButton.setEnabled( + idle and bool(self.taskModel.checked_task_ids()) ) def _update_checked_actions(self, task_ids=None) -> None: @@ -728,7 +756,9 @@ class PDDTaskPage(QWidget): self.resubmitButton.setText( "正在上报…" if self._resubmit_running else "重新上报" ) - self.resubmitButton.setEnabled(bool(checked) and not self._resubmit_running) + self.removeButton.setText( + "正在删除…" if self._remove_running else "删除" + ) self._update_rerun_button() def _apply_filters(self) -> None: diff --git a/client/src/pdd_ui_event.py b/client/src/pdd_ui_event.py index 3ea988a..a4c9e58 100644 --- a/client/src/pdd_ui_event.py +++ b/client/src/pdd_ui_event.py @@ -59,7 +59,11 @@ from .task_models import ( TaskSummary, TaskType, ) -from .task_repository import CollectRerunError, TaskRepository +from .task_repository import ( + CollectRerunError, + TaskRemovalError, + TaskRepository, +) from .task_dispatcher import ( TaskDispatcher, admin_task_to_new_claimed_task, @@ -322,6 +326,36 @@ class ResultResubmitWorker(QObject): self.completed.emit() +class TaskRemoveWorker(QObject): + """在后台原子校验并软移除勾选任务,不访问任何界面控件。""" + + succeeded = pyqtSignal(object) + failed = pyqtSignal(str) + completed = pyqtSignal() + + def __init__( + self, + repository: TaskRepository, + task_ids: tuple[str, ...], + ) -> None: + super().__init__() + self._repository = repository + self._task_ids = task_ids + + @pyqtSlot() + def run(self) -> None: + try: + self._repository.remove_tasks_from_list(self._task_ids) + except TaskRemovalError as exc: + self.failed.emit(str(exc)) + except Exception: + self.failed.emit("删除本地任务失败,请检查数据库后重试。") + else: + self.succeeded.emit(self._task_ids) + finally: + self.completed.emit() + + class PDDTaskPageEvent(QObject): """把 PDD 页面只读操作连接到本地任务 Repository。""" @@ -359,6 +393,9 @@ class PDDTaskPageEvent(QObject): self._resubmit_busy = False self._resubmit_thread: Optional[QThread] = None self._resubmit_worker: Optional[ResultResubmitWorker] = None + self._remove_busy = False + self._remove_thread: Optional[QThread] = None + self._remove_worker: Optional[TaskRemoveWorker] = None self._stop_requested = False self._cycle_next_delay_ms: Optional[int] = None self._stop_status = "自动获取:已停止 · 当前没有执行中的任务" @@ -417,6 +454,7 @@ class PDDTaskPageEvent(QObject): page.rerunRequested.connect(self.request_rerun) page.rerunCancelRequested.connect(self.request_cancel_rerun) page.resubmitRequested.connect(self.request_resubmit) + page.removeRequested.connect(self.request_remove) page.autoFetchRequested.connect(self._request_claim_task) page.detailRequested.connect(self.show_task_detail) page.taskModel.loadMoreRequested.connect(self._load_page) @@ -452,7 +490,12 @@ class PDDTaskPageEvent(QObject): if self._closing or not task_id: return - if self._auto_fetch_running or self._claim_busy or self._resubmit_busy: + if ( + self._auto_fetch_running + or self._claim_busy + or self._resubmit_busy + or self._remove_busy + ): self._show_rerun_warning( "暂时不能重新执行", "自动获取或其他采集正在运行,请停止并等待当前任务结束。", @@ -517,7 +560,12 @@ class PDDTaskPageEvent(QObject): ) if self._closing or not stable_ids: return - if self._auto_fetch_running or self._claim_busy or self._resubmit_busy: + if ( + self._auto_fetch_running + or self._claim_busy + or self._resubmit_busy + or self._remove_busy + ): self._show_rerun_warning( "暂时不能重新上报", "自动获取、重新执行或另一批上报正在运行,请等待当前操作结束。", @@ -545,6 +593,98 @@ class PDDTaskPageEvent(QObject): self._start_resubmit_worker(stable_ids) + @pyqtSlot(object) + def request_remove(self, task_ids) -> None: + """确认后在后台从普通列表软移除勾选的本地任务。""" + + stable_ids = tuple( + dict.fromkeys( + str(value).strip() + for value in task_ids + if value is not None and str(value).strip() + ) + ) + if self._closing or not stable_ids: + return + if ( + self._auto_fetch_running + or self._claim_busy + or self._resubmit_busy + or self._remove_busy + ): + self._show_rerun_warning( + "暂时不能删除", + "自动获取、重新执行、重新上报或另一批删除正在运行," + "请等待当前操作结束。", + ) + return + + count = len(stable_ids) + dialog = MessageBox( + f"从列表删除 {count} 条任务?", + "只会从普通任务列表中隐藏这些本地记录,不会删除 Admin 任务;" + "执行记录和上报历史仍会永久保留。\n\n" + "只有已结束、没有待上报数据且没有进入不可逆阶段的任务才允许删除。", + self._page.window(), + ) + dialog.yesButton.setText(f"删除 {count} 条") + dialog.cancelButton.setText("暂不删除") + dialog.cancelButton.setFocus() + if not dialog.exec(): + return + + self._start_remove_worker(stable_ids) + + def _start_remove_worker(self, task_ids: tuple[str, ...]) -> None: + """启动本地任务软删除工作线程。""" + + self._remove_busy = True + self._page.set_remove_running(True) + self._page.set_engine_status( + f"正在检查并删除 {len(task_ids)} 条本地任务…" + ) + + thread = QThread(self) + worker = TaskRemoveWorker(self._repository, task_ids) + worker.moveToThread(thread) + thread.started.connect(worker.run) + worker.succeeded.connect(self._on_remove_succeeded) + worker.failed.connect(self._on_remove_failed) + worker.completed.connect(thread.quit) + worker.completed.connect(worker.deleteLater) + thread.finished.connect(thread.deleteLater) + thread.finished.connect(self._on_remove_thread_finished) + self._remove_thread = thread + self._remove_worker = worker + thread.start() + + @pyqtSlot(object) + def _on_remove_succeeded(self, task_ids) -> None: + if self._closing: + return + stable_ids = tuple(task_ids) + self._page.taskModel.uncheck_tasks(stable_ids) + self._page.set_engine_status( + f"已从列表删除 {len(stable_ids)} 条本地任务;" + "执行记录和上报历史仍会永久保留。" + ) + self._reload() + + @pyqtSlot(str) + def _on_remove_failed(self, message: str) -> None: + if self._closing: + return + self._page.set_engine_status(f"删除未完成:{message}") + self._show_claim_error("不能删除所选任务", message) + + @pyqtSlot() + def _on_remove_thread_finished(self) -> None: + self._remove_worker = None + self._remove_thread = None + self._remove_busy = False + if not self._closing: + self._page.set_remove_running(False) + def _start_resubmit_worker(self, task_ids: tuple[str, ...]) -> None: """启动只处理指定任务 Outbox 的工作线程。""" @@ -774,10 +914,10 @@ class PDDTaskPageEvent(QObject): if self._closing: return - if self._resubmit_busy: + if self._resubmit_busy or self._remove_busy: self._show_rerun_warning( "暂时不能获取任务", - "任务结果正在重新上报,请等待当前操作结束。", + "本地任务正在重新上报或删除,请等待当前操作结束。", ) return if self._auto_fetch_running: @@ -1283,6 +1423,19 @@ class PDDTaskPageEvent(QObject): resubmit_thread.quit() resubmit_thread.wait(60_000) + remove_worker = self._remove_worker + remove_thread = self._remove_thread + if remove_worker is not None: + try: + remove_worker.succeeded.disconnect(self._on_remove_succeeded) + remove_worker.failed.disconnect(self._on_remove_failed) + except (TypeError, RuntimeError): + pass + if remove_thread is not None and remove_thread.isRunning(): + # 删除只包含一个很短的 SQLite 事务,不中途取消,避免部分更新。 + remove_thread.quit() + remove_thread.wait(60_000) + def summary_to_row(summary: TaskSummary) -> TaskRow: """把领域摘要转换成只供表格显示的轻量行。""" diff --git a/client/src/task_repository.py b/client/src/task_repository.py index 64fc10a..9a67489 100644 --- a/client/src/task_repository.py +++ b/client/src/task_repository.py @@ -7,7 +7,7 @@ import json import sqlite3 from datetime import datetime, timezone from pathlib import Path -from typing import Dict, List, Optional, Tuple, Union +from typing import Dict, Iterable, List, Optional, Tuple, Union from uuid import uuid4 from .db import initialize_database, open_database @@ -39,6 +39,10 @@ class CollectRerunError(ValueError): """当前任务不满足重新采集条件。""" +class TaskRemovalError(ValueError): + """勾选任务不满足从普通列表移除的安全条件。""" + + def utc_now_iso() -> str: """返回精确到秒的 UTC ISO 8601 时间。""" @@ -145,6 +149,98 @@ class TaskRepository: connection.close() return int(row[0]) + def remove_tasks_from_list(self, remote_task_ids: Iterable[str]) -> int: + """把符合安全条件的终态任务从普通列表中软移除。 + + 全部任务会在同一个事务中完成校验和更新;任一任务不安全时, + 所有任务都保持原样。执行记录和 Outbox 不会被删除或修改。 + """ + + task_ids = tuple( + dict.fromkeys( + str(value).strip() + for value in remote_task_ids + if value is not None and str(value).strip() + ) + ) + if not task_ids: + raise TaskRemovalError("没有可删除的任务,请重新勾选。") + + placeholders = ", ".join("?" for _ in task_ids) + terminal_statuses = { + TaskStatus.SUCCEEDED.value, + TaskStatus.FAILED.value, + TaskStatus.CANCELLED.value, + } + now = utc_now_iso() + connection = open_database(self._db_path) + try: + with connection: + rows = connection.execute( + "SELECT id, remote_task_id, status, removed_at FROM pdd_tasks" + f" WHERE remote_task_id IN ({placeholders})", + task_ids, + ).fetchall() + found = {row["remote_task_id"]: row for row in rows} + missing = [task_id for task_id in task_ids if task_id not in found] + if missing: + raise TaskRemovalError( + f"本地找不到任务 {missing[0]},请刷新列表后重试。" + ) + removed = next( + (row for row in rows if row["removed_at"] is not None), None + ) + if removed is not None: + raise TaskRemovalError( + f"任务 {removed['remote_task_id']} 已不在普通列表,请刷新后重试。" + ) + non_terminal = next( + (row for row in rows if row["status"] not in terminal_statuses), + None, + ) + if non_terminal is not None: + raise TaskRemovalError( + f"任务 {non_terminal['remote_task_id']} 尚未结束,不能删除。" + ) + + irreversible = connection.execute( + "SELECT t.remote_task_id FROM task_runs r" + " JOIN pdd_tasks t ON t.id = r.task_id" + f" WHERE t.remote_task_id IN ({placeholders})" + " AND r.irreversible_action_at IS NOT NULL LIMIT 1", + task_ids, + ).fetchone() + if irreversible is not None: + raise TaskRemovalError( + f"任务 {irreversible['remote_task_id']} 已进入不可逆阶段," + "必须保留在列表中核对订单。" + ) + + unsent = connection.execute( + "SELECT t.remote_task_id, o.status FROM outbox_events o" + " JOIN pdd_tasks t ON t.id = o.task_id" + f" WHERE t.remote_task_id IN ({placeholders})" + " AND o.status <> 'sent' LIMIT 1", + task_ids, + ).fetchone() + if unsent is not None: + raise TaskRemovalError( + f"任务 {unsent['remote_task_id']} 还有未发送完成的数据," + "请先重新上报。" + ) + + cursor = connection.execute( + "UPDATE pdd_tasks SET removed_at = ?, updated_at = ?" + f" WHERE remote_task_id IN ({placeholders})" + " AND removed_at IS NULL", + (now, now, *task_ids), + ) + if cursor.rowcount != len(task_ids): + raise TaskRemovalError("任务列表已发生变化,请刷新后重试。") + finally: + connection.close() + return len(task_ids) + def get_task(self, remote_task_id: str) -> Optional[TaskDetail]: """按稳定远程编号读取完整任务;不存在时返回 None。""" @@ -1594,7 +1690,7 @@ class TaskRepository: @staticmethod def _build_where(filters: TaskFilters) -> Tuple[str, List[object]]: - clauses = [] + clauses = ["removed_at IS NULL"] parameters: List[object] = [] if filters.task_type is not None: clauses.append("task_type = ?") diff --git a/client/test/test_db.py b/client/test/test_db.py index ed9a6ff..6007733 100644 --- a/client/test/test_db.py +++ b/client/test/test_db.py @@ -6,7 +6,7 @@ import unittest from pathlib import Path from src.db import DatabaseVersionError, initialize_database, open_database -from src.db_schema import MIGRATION_1, MIGRATION_2 +from src.db_schema import MIGRATION_1, MIGRATION_2, MIGRATION_3 EXPECTED_TABLES = { @@ -22,6 +22,7 @@ EXPECTED_INDEXES = { "idx_pdd_tasks_goods_id", "idx_task_runs_task", "idx_outbox_pending", + "idx_pdd_tasks_visible_list", } @@ -63,7 +64,7 @@ class DatabaseInitializationTests(unittest.TestCase): self.assertTrue(EXPECTED_TABLES.issubset(tables)) self.assertTrue(EXPECTED_INDEXES.issubset(indexes)) - self.assertEqual(version, 3) + self.assertEqual(version, 4) def test_v1_database_is_upgraded_without_losing_task_runs(self) -> None: connection = open_database(self.db_path) @@ -106,7 +107,7 @@ class DatabaseInitializationTests(unittest.TestCase): connection.close() self.assertIn("result_data", columns) self.assertEqual(attempt_id, "ATTEMPT-OLD") - self.assertEqual(version, 3) + self.assertEqual(version, 4) def test_initialize_can_run_twice_without_losing_data(self) -> None: initialize_database(self.db_path) @@ -169,7 +170,49 @@ class DatabaseInitializationTests(unittest.TestCase): finally: connection.close() self.assertEqual(row[0], "dry_run") - self.assertEqual(version, 3) + self.assertEqual(version, 4) + + def test_v3_database_adds_soft_remove_column_without_losing_tasks(self) -> None: + connection = open_database(self.db_path) + try: + with connection: + for statement in MIGRATION_1 + MIGRATION_2 + MIGRATION_3: + connection.execute(statement) + connection.execute("PRAGMA user_version = 3") + connection.execute( + "INSERT INTO pdd_tasks" + " (remote_task_id, task_type, goods_url, status, received_at," + " created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + ( + "COL-V3", + "collect", + "https://example.test/goods", + "succeeded", + "2026-08-10T00:00:00Z", + "2026-08-10T00:00:00Z", + "2026-08-10T00:00:00Z", + ), + ) + finally: + connection.close() + + initialize_database(self.db_path) + + connection = open_database(self.db_path) + try: + columns = { + row[1] for row in connection.execute("PRAGMA table_info(pdd_tasks)") + } + row = connection.execute( + "SELECT remote_task_id, removed_at FROM pdd_tasks" + " WHERE remote_task_id = 'COL-V3'" + ).fetchone() + version = connection.execute("PRAGMA user_version").fetchone()[0] + finally: + connection.close() + self.assertIn("removed_at", columns) + self.assertEqual(tuple(row), ("COL-V3", None)) + self.assertEqual(version, 4) def test_new_connection_uses_required_pragmas(self) -> None: initialize_database(self.db_path) diff --git a/client/test/test_pdd_ui.py b/client/test/test_pdd_ui.py index 1e2ccca..568a415 100644 --- a/client/test/test_pdd_ui.py +++ b/client/test/test_pdd_ui.py @@ -63,9 +63,12 @@ class TaskTableModelCheckTests(unittest.TestCase): refresh_position = layout.getItemPosition(layout.indexOf(page.refreshButton)) rerun_position = layout.getItemPosition(layout.indexOf(page.rerunButton)) resubmit_position = layout.getItemPosition(layout.indexOf(page.resubmitButton)) + remove_position = layout.getItemPosition(layout.indexOf(page.removeButton)) self.assertEqual(refresh_position[1], search_position[1] + 1) self.assertEqual(resubmit_position[1], rerun_position[1] + 1) + self.assertEqual(remove_position[1], resubmit_position[1] + 1) self.assertEqual(page.checkedCountLabel.text(), "已选 1 条") + self.assertTrue(page.removeButton.isEnabled()) page.resize(1000, 650) self.app.processEvents() @@ -78,6 +81,26 @@ class TaskTableModelCheckTests(unittest.TestCase): self.assertEqual(page.taskModel.checked_task_ids(), ()) self.assertEqual(page.checkedCountLabel.text(), "已选 0 条") + self.assertFalse(page.removeButton.isEnabled()) + page.deleteLater() + + def test_remove_emits_stable_checked_task_ids(self): + page = PDDTaskPage() + page.set_tasks( + [TaskRow("TASK-1", "采集"), TaskRow("TASK-2", "采集")] + ) + received = [] + page.removeRequested.connect(received.append) + for row in (0, 1): + page.taskModel.setData( + page.taskModel.index(row, CHECK_COLUMN), + Qt.Checked, + Qt.CheckStateRole, + ) + + page.removeButton.click() + + self.assertEqual(received, [("TASK-1", "TASK-2")]) page.deleteLater() def test_auto_fetch_text_and_keyword_width(self): diff --git a/client/test/test_pdd_ui_event.py b/client/test/test_pdd_ui_event.py index 66264fb..0b36ac5 100644 --- a/client/test/test_pdd_ui_event.py +++ b/client/test/test_pdd_ui_event.py @@ -627,6 +627,105 @@ class PDDTaskPageEventTest(unittest.TestCase): events.shutdown() page.deleteLater() + def test_remove_confirmation_cancel_keeps_local_task(self): + self._add_task(1) + connection = open_database(self.db_path) + try: + with connection: + connection.execute( + "UPDATE pdd_tasks SET status = 'succeeded'" + " WHERE remote_task_id = 'PDD-001'" + ) + finally: + connection.close() + page = PDDTaskPage() + gateway = RecordingClaimGateway() + 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: + dialog = message_box.return_value + dialog.exec.return_value = False + page.removeButton.click() + + message_box.assert_called_once() + self.assertIs(message_box.call_args.args[2], page.window()) + dialog.cancelButton.setFocus.assert_called_once() + self.assertEqual(self.repository.count_tasks(), 1) + self.assertFalse(events._remove_busy) + self.assertEqual(gateway.calls, []) + events.shutdown() + page.deleteLater() + + def test_remove_terminal_task_refreshes_list_and_keeps_audit_data(self): + self._add_task(1) + connection = open_database(self.db_path) + try: + with connection: + connection.execute( + "UPDATE pdd_tasks SET status = 'failed'" + " WHERE remote_task_id = 'PDD-001'" + ) + finally: + connection.close() + page = PDDTaskPage() + gateway = RecordingClaimGateway() + 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.removeButton.click() + self.assertTrue(wait_until(self.app, lambda: not events._remove_busy)) + + self.assertEqual(page.taskModel.data_row_count(), 0) + self.assertEqual(page.taskModel.checked_task_ids(), ()) + self.assertIsNotNone(self.repository.get_task("PDD-001")) + self.assertIn("已从列表删除 1 条", page.statusLabel.text()) + self.assertEqual(gateway.calls, []) + self.device_checker.assert_not_called() + events.shutdown() + page.deleteLater() + + def test_remove_mixed_batch_reports_reason_and_changes_nothing(self): + self._add_task(1) + self._add_task(2) + connection = open_database(self.db_path) + try: + with connection: + connection.execute( + "UPDATE pdd_tasks SET status = 'cancelled'" + " WHERE remote_task_id = 'PDD-001'" + ) + finally: + connection.close() + page = PDDTaskPage() + events = PDDTaskPageEvent( + page, self.repository, claim_gateway=RecordingClaimGateway() + ) + events.load_initial_tasks() + for row in range(2): + page.taskModel.setData( + page.taskModel.index(row, 0), Qt.Checked, Qt.CheckStateRole + ) + + with patch("src.pdd_ui_event.MessageBox") as message_box: + message_box.return_value.exec.return_value = True + page.removeButton.click() + self.assertTrue(wait_until(self.app, lambda: not events._remove_busy)) + + self.assertEqual(self.repository.count_tasks(), 2) + self.assertEqual(len(page.taskModel.checked_task_ids()), 2) + self.assertIn("尚未结束", page.statusLabel.text()) + events.shutdown() + page.deleteLater() + def test_rerun_confirmation_has_clear_safe_cancel_action(self): self._add_task(1) first = self.repository.start_collect_run("PDD-001", "USB-001") diff --git a/client/test/test_task_repository.py b/client/test/test_task_repository.py index 2015943..03bb470 100644 --- a/client/test/test_task_repository.py +++ b/client/test/test_task_repository.py @@ -14,7 +14,12 @@ from src.task_models import ( TaskStatus, TaskType, ) -from src.task_repository import CollectRerunError, DuplicateTaskError, TaskRepository +from src.task_repository import ( + CollectRerunError, + DuplicateTaskError, + TaskRemovalError, + TaskRepository, +) class TaskRepositoryTests(unittest.TestCase): @@ -26,6 +31,17 @@ class TaskRepositoryTests(unittest.TestCase): def tearDown(self) -> None: self._temporary_directory.cleanup() + def _set_task_status(self, task_id: str, status: TaskStatus) -> None: + connection = open_database(self.db_path) + try: + with connection: + connection.execute( + "UPDATE pdd_tasks SET status = ? WHERE remote_task_id = ?", + (status.value, task_id), + ) + finally: + connection.close() + @staticmethod def _task( remote_task_id: str, @@ -516,6 +532,108 @@ class TaskRepositoryTests(unittest.TestCase): OutboxStatus.PENDING, ) + def test_terminal_task_is_soft_removed_but_audit_record_remains(self): + self.repository.add_claimed_task(self._task("REMOVE-OK")) + self._set_task_status("REMOVE-OK", TaskStatus.SUCCEEDED) + + removed_count = self.repository.remove_tasks_from_list(("REMOVE-OK",)) + + self.assertEqual(removed_count, 1) + self.assertEqual(self.repository.list_tasks(), []) + self.assertEqual(self.repository.count_tasks(), 0) + self.assertIsNotNone(self.repository.get_task("REMOVE-OK")) + connection = open_database(self.db_path) + try: + row = connection.execute( + "SELECT removed_at FROM pdd_tasks" + " WHERE remote_task_id = 'REMOVE-OK'" + ).fetchone() + finally: + connection.close() + self.assertIsNotNone(row[0]) + with self.assertRaises(DuplicateTaskError): + self.repository.add_claimed_task(self._task("REMOVE-OK")) + + def test_batch_remove_is_all_or_nothing_for_non_terminal_task(self): + self.repository.add_claimed_task(self._task("REMOVE-ENDED")) + self.repository.add_claimed_task(self._task("REMOVE-RUNNING")) + self._set_task_status("REMOVE-ENDED", TaskStatus.FAILED) + self._set_task_status("REMOVE-RUNNING", TaskStatus.RUNNING) + + with self.assertRaisesRegex(TaskRemovalError, "尚未结束"): + self.repository.remove_tasks_from_list( + ("REMOVE-ENDED", "REMOVE-RUNNING") + ) + + self.assertEqual(self.repository.count_tasks(), 2) + + def test_task_with_unsent_outbox_cannot_be_removed(self): + self.repository.add_claimed_task(self._task("REMOVE-UNSENT")) + started = self.repository.start_collect_run("REMOVE-UNSENT", "USB-001") + self.repository.save_collect_failure( + "REMOVE-UNSENT", + started.attempt_id, + TaskStatus.FAILED, + "TEST_FAILURE", + "测试失败", + False, + ) + + with self.assertRaisesRegex(TaskRemovalError, "未发送完成"): + self.repository.remove_tasks_from_list(("REMOVE-UNSENT",)) + + self.assertEqual(self.repository.count_tasks(), 1) + + def test_irreversible_task_cannot_be_removed_even_when_terminal(self): + self.repository.add_claimed_task( + self._task( + "REMOVE-IRREVERSIBLE", + TaskType.PURCHASE, + execution_mode="live", + ) + ) + started = self.repository.start_purchase_run( + "REMOVE-IRREVERSIBLE", "USB-001" + ) + self.repository.mark_purchase_irreversible( + "REMOVE-IRREVERSIBLE", started.attempt_id + ) + self._set_task_status("REMOVE-IRREVERSIBLE", TaskStatus.SUCCEEDED) + + with self.assertRaisesRegex(TaskRemovalError, "不可逆阶段"): + self.repository.remove_tasks_from_list(("REMOVE-IRREVERSIBLE",)) + + self.assertEqual(self.repository.count_tasks(), 1) + + def test_sent_outbox_does_not_block_soft_remove(self): + self.repository.add_claimed_task(self._task("REMOVE-SENT")) + started = self.repository.start_collect_run("REMOVE-SENT", "USB-001") + event = self.repository.save_collect_result( + "REMOVE-SENT", + started.attempt_id, + {"goods_id": "10001", "title": "测试商品", "skus": []}, + ) + self.repository.mark_outbox_sent(event.id) + + self.assertEqual( + self.repository.remove_tasks_from_list(("REMOVE-SENT",)), 1 + ) + self.assertEqual(self.repository.count_tasks(), 0) + connection = open_database(self.db_path) + try: + run_count = connection.execute( + "SELECT COUNT(*) FROM task_runs r JOIN pdd_tasks t" + " ON t.id = r.task_id WHERE t.remote_task_id = 'REMOVE-SENT'" + ).fetchone()[0] + outbox_row = connection.execute( + "SELECT o.status FROM outbox_events o JOIN pdd_tasks t" + " ON t.id = o.task_id WHERE t.remote_task_id = 'REMOVE-SENT'" + ).fetchone() + finally: + connection.close() + self.assertEqual(run_count, 1) + self.assertEqual(outbox_row[0], OutboxStatus.SENT.value) + if __name__ == "__main__": unittest.main() diff --git a/docs/client/03-data-model.md b/docs/client/03-data-model.md index 4b268ef..e3a1547 100644 --- a/docs/client/03-data-model.md +++ b/docs/client/03-data-model.md @@ -152,6 +152,7 @@ CREATE TABLE pdd_tasks ( received_at TEXT NOT NULL, started_at TEXT, finished_at TEXT, + removed_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); @@ -164,6 +165,9 @@ CREATE INDEX idx_pdd_tasks_status_type CREATE INDEX idx_pdd_tasks_goods_id ON pdd_tasks(goods_id); + +CREATE INDEX idx_pdd_tasks_visible_list + ON pdd_tasks(removed_at, updated_at DESC, id DESC); ``` ### 字段语义 @@ -176,6 +180,7 @@ CREATE INDEX idx_pdd_tasks_goods_id - `admin_payload`:`claim` 时收到的原始 Admin 任务 JSON,用于审计和向前兼容。 - `pdd_data`:采集或采购结果 JSON,未产生结果时为空。 - `received_at`:**领取时间**,即 `claim` 成功的时刻。 +- `removed_at`:用户把终态任务从普通列表移除的时间。为空表示正常显示;有值只表示隐藏,数据库记录和关联审计数据仍永久保留。 - `updated_at`:本地更新时间,也是表格默认排序字段。 ### 3.1 本地只存已领取任务 @@ -196,7 +201,9 @@ CREATE INDEX idx_pdd_tasks_goods_id | `[必须]` 本地不保存 Admin 侧状态 | Client 拿到任务就做完,中途不查 Admin 怎么想。Admin 取消了、重派了,Client 一律不感知,照做完照提交,见 [04 接口契约](04-admin-api-contract.md) §1 | | `[必须]` 提交一定会被接受 | Admin 必须无条件接受已派发过的结果,见 [04](04-admin-api-contract.md) §6.1。所以本地不需要"提交被拒"的处理分支 | -**关于"永久保留":** 任务记录不设保留期,一直留着。但**诊断产物(截图、XML、长日志)仍然按 +**关于"永久保留":** 任务记录不设保留期,一直留着。界面上的“删除”只写入 +`removed_at`,不会执行 SQL `DELETE`,也不会删除 `task_runs` 或 `outbox_events`。 +但**诊断产物(截图、XML、长日志)仍然按 `diagnostics.retention_days` 定期清理**——两者是不同的东西,别搞混。清理诊断文件不影响任务记录和审计字段。 数据量增长靠索引和增量加载扛(见 §3 的索引和 [05 界面规范](05-ui-specification.md) §5.3)。 @@ -208,11 +215,16 @@ CREATE INDEX idx_pdd_tasks_goods_id SELECT id, remote_task_id, task_type, title, target_color, target_size, price_cent, quantity, status, updated_at FROM pdd_tasks -WHERE /* 本地搜索与筛选条件 */ +WHERE removed_at IS NULL + AND /* 本地搜索与筛选条件 */ ORDER BY updated_at DESC, id DESC LIMIT :limit OFFSET :offset; ``` +批量软删除必须在一个事务内先校验全部勾选任务,再统一写入 `removed_at`。 +只允许 `succeeded`、`failed`、`cancelled`,且所有 Outbox 都已发送、所有执行记录的 +`irreversible_action_at` 都为空。任一任务不满足时整体失败,不能只隐藏其中一部分。 + ## 4. `task_runs` 每次自动执行或人工确认的重新采集都生成一条记录。任务再次执行时创建新的 diff --git a/docs/client/05-ui-specification.md b/docs/client/05-ui-specification.md index 872c7e2..47ddcb5 100644 --- a/docs/client/05-ui-specification.md +++ b/docs/client/05-ui-specification.md @@ -9,7 +9,7 @@ ## 1. 设计目标 - 让操作人员在一个页面完成任务监控、搜索和异常定位。 -- “获取任务”“重新执行”和“重新上报”会产生外部后果;重新执行只允许单条采集任务,重新上报只重发既有 Outbox,并优先处理未发送事件。 +- “获取任务”“重新执行”和“重新上报”会产生外部后果;重新执行只允许单条采集任务,重新上报只重发既有 Outbox,并优先处理未发送事件。“删除”只软隐藏本地记录。 - 长任务状态始终可找到,不使用连续模态弹窗打断工作。 - 任务表格在数据增长后仍保持响应速度、稳定选择和可访问性。 - 界面只展示任务状态,不在 Qt 主线程执行 Admin 或手机自动化。 @@ -35,7 +35,7 @@ ```text ┌─────────────────────────────────────────────────────────────┐ │ PDD 任务 │ -│ [自动获取] [类型▼] [状态▼] [关键词...] [搜索] [刷新] [已选0条] [重新执行] [重新上报] │ +│ [自动获取] [类型▼] [状态▼] [关键词...] [搜索] [刷新] [已选0条] [重新执行] [重新上报] [删除] │ ├─────────────────────────────────────────────────────────────┤ │ 类型 │ 商品标题 │ 颜色 │ 尺码 │ 价格 │ 数量 │ 状态 │ 更新时间 │详情│ │ │ @@ -83,10 +83,10 @@ 筛选条件之间采用 AND。点击搜索或在关键词输入框按 Enter 执行本地数据库查询,不请求领取任务。活动筛选必须可见,筛选无结果时保留条件并提供清除入口。 -### 4.3 刷新、重新执行与重新上报 +### 4.3 刷新、重新执行、重新上报与删除 - “刷新”紧邻“搜索”右侧,只重新读取本地任务列表,不请求 Admin,也不操作手机。 -- 页面右侧显示“已选 N 条”,“重新执行”后面是“重新上报”。 +- 页面右侧显示“已选 N 条”,操作顺序是“重新执行”“重新上报”“删除”。 - “重新执行”只在恰好勾选一条任务时启用;多选不允许批量操作手机。 - 点击后先校验任务,再显示明确的“重新采集”确认弹窗。弹窗显示任务编号、商品标题,并说明新结果会覆盖 Client 和 Admin 的当前采集数据。“暂不重新采集”是默认聚焦的安全操作,点击该按钮、按 `Escape` 或关闭弹窗都不得启动任务。 - 用户确认后,工作线程必须先检查已保存 Android 设备的实际连接状态,再重置任务和创建执行记录。检查失败时保留原任务状态、采集结果和执行历史。 @@ -105,10 +105,13 @@ - 重新上报不依赖 Android 设备,在独立工作线程中逐条提交。自动获取、重新执行或另一批上报运行时不得启动。 - 批量完成后显示成功、失败和跳过数量。成功任务取消勾选;失败及没有结果 Outbox 的跳过任务保留勾选,方便继续处理。 - 相同幂等键和相同内容只用于让 Admin 再次确认已接收,不代表创建新结果或覆盖 Admin 数据。 +- “删除”作用于当前勾选集。未勾选时禁用;执行前用主窗口居中的确认框显示准确数量,“暂不删除”默认聚焦,按 `Escape` 或关闭弹窗不修改数据。 +- 删除只允许已完成、失败或已取消的任务。存在待发送、发送中、发送失败 Outbox,或任一执行记录已进入不可逆阶段时,整批拒绝并显示中文原因。 +- 删除只把任务从普通列表软隐藏,不请求 Admin、不操作 Android,也不修改任务状态、执行记录或 Outbox。成功后清除对应勾选并刷新当前筛选结果。 ## 5. 任务表格 -表格显示的是**本机已领取的全部任务**,包括正在做的和早已做完的。已完成任务永久保留,不会被清理,所以数据只增不减——增量加载和索引是必须的,不是优化。 +表格显示的是**本机已领取且未被软隐藏的任务**,包括正在做的和早已做完的。已完成任务和审计数据永久保留;用户删除后只是不再出现在普通列表,所以数据库数据仍只增不减——增量加载和索引是必须的,不是优化。 使用 `qfluentwidgets.TableView`、自定义 `QAbstractTableModel` 和 Repository 查询。不得使用行号作为任务身份,也不得把完整 `pdd_data` 放入模型。 @@ -399,7 +402,7 @@ self.show_recoverable_error( ## 10. 键盘与无障碍 -- Tab 顺序:自动获取 → 类型 → 状态 → 关键词 → 搜索 → 刷新 → 重新执行 → 重新上报 → 表格 → 状态区可操作项。 +- Tab 顺序:自动获取 → 类型 → 状态 → 关键词 → 搜索 → 刷新 → 重新执行 → 重新上报 → 删除 → 表格 → 状态区可操作项。 - `Ctrl+F` 聚焦关键词,Enter 打开当前行详情。不绑定 `F5`——界面上没有需要刷新的远端数据。 - 仅图标按钮必须设置准确的无障碍名称和工具提示。 - 表单具有可见标签,占位符不能替代标签。