feat: 增加采集任务重新执行入口 (#63)
This commit is contained in:
@@ -111,11 +111,26 @@ class CollectTaskService:
|
||||
if task is None:
|
||||
raise RuntimeError(f"任务 {remote.task_id} 未能保存到本地")
|
||||
|
||||
return self._execute_task(task.remote_task_id)
|
||||
|
||||
def execute_selected(self, remote_task_id: str) -> CollectTaskOutcome:
|
||||
"""只执行指定的本地采集任务,不领取任务或补交其他结果。"""
|
||||
|
||||
if not self._device_address.strip():
|
||||
raise ValueError("请先在设置页选择并保存 Android 设备")
|
||||
task = self._repository.get_task(remote_task_id)
|
||||
if task is None:
|
||||
raise ValueError(f"任务 {remote_task_id} 不存在")
|
||||
return self._execute_task(task.remote_task_id)
|
||||
|
||||
def _execute_task(self, remote_task_id: str) -> CollectTaskOutcome:
|
||||
"""执行一条已经保存在本地且处于待执行状态的采集任务。"""
|
||||
|
||||
if self._cancelled():
|
||||
return CollectTaskOutcome("cancelled", "本次采集已取消", task.remote_task_id)
|
||||
return CollectTaskOutcome("cancelled", "本次采集已取消", remote_task_id)
|
||||
|
||||
started = self._repository.start_collect_run(
|
||||
task.remote_task_id, self._device_address
|
||||
remote_task_id, self._device_address
|
||||
)
|
||||
collector = self._factory(
|
||||
self._device_address, self._client.client_id, self._cancelled
|
||||
@@ -123,13 +138,13 @@ class CollectTaskService:
|
||||
try:
|
||||
result = collector.collect(started.task)
|
||||
event = self._repository.save_collect_result(
|
||||
task.remote_task_id, started.attempt_id, result.to_pdd_data()
|
||||
remote_task_id, started.attempt_id, result.to_pdd_data()
|
||||
)
|
||||
except PddCollectError as exc:
|
||||
status, retryable = self._classify_error(exc.code)
|
||||
report_code = self._report_error_code(exc.code)
|
||||
event = self._repository.save_collect_failure(
|
||||
task.remote_task_id,
|
||||
remote_task_id,
|
||||
started.attempt_id,
|
||||
status,
|
||||
report_code,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
``MIGRATIONS`` 末尾增加版本,不能修改已经发布的迁移。
|
||||
"""
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
SCHEMA_VERSION = 2
|
||||
|
||||
|
||||
MIGRATION_1 = (
|
||||
@@ -127,6 +127,14 @@ MIGRATION_1 = (
|
||||
)
|
||||
|
||||
|
||||
MIGRATION_2 = (
|
||||
"""
|
||||
ALTER TABLE task_runs ADD COLUMN result_data TEXT
|
||||
""",
|
||||
)
|
||||
|
||||
|
||||
MIGRATIONS = {
|
||||
1: MIGRATION_1,
|
||||
2: MIGRATION_2,
|
||||
}
|
||||
|
||||
+31
-2
@@ -21,8 +21,8 @@
|
||||
- 图标用 `FluentIcon`,**不得用表情符号**。
|
||||
- 页面 `objectName` 固定为 `pddTaskPage`,不要改。
|
||||
|
||||
注意:本页唯一会产生外部后果(真的去操作手机、可能下单)的命令是
|
||||
“开始自动获取”。其余操作全部只读本地数据库。
|
||||
注意:“开始自动获取”和“重新执行”会真的操作手机。重新执行当前只允许
|
||||
采集任务,采购任务必须在事件层拦截,不能从本入口下单。
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
@@ -276,6 +276,7 @@ class PDDTaskPage(QWidget):
|
||||
autoFetchRequested = pyqtSignal()
|
||||
searchRequested = pyqtSignal(dict)
|
||||
refreshRequested = pyqtSignal()
|
||||
rerunRequested = pyqtSignal(str)
|
||||
detailRequested = pyqtSignal(str)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
@@ -323,6 +324,10 @@ class PDDTaskPage(QWidget):
|
||||
self.refreshButton = PushButton(FIF.SYNC, "刷新", self)
|
||||
self.refreshButton.setAccessibleName("刷新本地任务表格")
|
||||
|
||||
self.rerunButton = PushButton(FIF.UPDATE, "重新执行", self)
|
||||
self.rerunButton.setAccessibleName("重新执行当前选中的采集任务")
|
||||
self.rerunButton.setEnabled(False)
|
||||
|
||||
self.commandCard = CardWidget(self)
|
||||
commandLayout = QGridLayout(self.commandCard)
|
||||
commandLayout.setContentsMargins(20, 14, 20, 14)
|
||||
@@ -338,6 +343,7 @@ class PDDTaskPage(QWidget):
|
||||
commandLayout.setColumnStretch(6, 4)
|
||||
commandLayout.setColumnStretch(8, 1)
|
||||
commandLayout.addWidget(self.refreshButton, 0, 9)
|
||||
commandLayout.addWidget(self.rerunButton, 0, 10)
|
||||
|
||||
def _build_content_area(self) -> None:
|
||||
self.taskTable = TableView(self)
|
||||
@@ -410,9 +416,13 @@ class PDDTaskPage(QWidget):
|
||||
self.searchButton.clicked.connect(self._apply_filters)
|
||||
self.keywordInput.returnPressed.connect(self._apply_filters)
|
||||
self.refreshButton.clicked.connect(self.refreshRequested.emit)
|
||||
self.rerunButton.clicked.connect(self._request_rerun)
|
||||
self.clearFiltersButton.clicked.connect(self.clear_filters)
|
||||
self.taskTable.clicked.connect(self._on_table_clicked)
|
||||
self.taskTable.activated.connect(self._on_table_activated)
|
||||
self.taskTable.selectionModel().selectionChanged.connect(
|
||||
self._update_rerun_button
|
||||
)
|
||||
|
||||
self.findShortcut = QShortcut(QKeySequence.Find, self)
|
||||
self.findShortcut.activated.connect(self.keywordInput.setFocus)
|
||||
@@ -497,6 +507,25 @@ class PDDTaskPage(QWidget):
|
||||
task = self.taskModel.row_at(index.row()) if index.isValid() else None
|
||||
return task.remote_task_id if task else ""
|
||||
|
||||
def set_rerun_running(self, running: bool) -> None:
|
||||
"""重新采集期间锁定两个会操作手机的入口。"""
|
||||
|
||||
self.rerunButton.setText("正在重新采集" if running else "重新执行")
|
||||
self.autoFetchButton.setEnabled(not running)
|
||||
self._update_rerun_button()
|
||||
|
||||
def _request_rerun(self) -> None:
|
||||
task_id = self.current_task_id()
|
||||
if task_id:
|
||||
self.rerunRequested.emit(task_id)
|
||||
|
||||
def _update_rerun_button(self) -> None:
|
||||
running = self.rerunButton.text() == "正在重新采集"
|
||||
has_selection = self.taskTable.selectionModel().hasSelection()
|
||||
self.rerunButton.setEnabled(
|
||||
has_selection and bool(self.current_task_id()) and not running
|
||||
)
|
||||
|
||||
def _apply_filters(self) -> None:
|
||||
filters = {
|
||||
"task_type": self.taskTypeCombo.currentText(),
|
||||
|
||||
+150
-4
@@ -30,7 +30,7 @@ from PyQt5.QtCore import (
|
||||
pyqtSignal,
|
||||
pyqtSlot,
|
||||
)
|
||||
from qfluentwidgets import InfoBar, InfoBarPosition
|
||||
from qfluentwidgets import InfoBar, InfoBarPosition, MessageBox
|
||||
|
||||
from .admin_gateway import (
|
||||
AdminGatewayError,
|
||||
@@ -51,7 +51,7 @@ from .task_models import (
|
||||
TaskSummary,
|
||||
TaskType,
|
||||
)
|
||||
from .task_repository import TaskRepository
|
||||
from .task_repository import CollectRerunError, TaskRepository
|
||||
from .task_detail_view import TaskDetailWindow
|
||||
|
||||
|
||||
@@ -141,6 +141,7 @@ class ClaimTaskWorker(QObject):
|
||||
client_service: CurrentClientService,
|
||||
android_device_service: SelectedAndroidDeviceService,
|
||||
collect_service_factory: Optional[CollectServiceFactory] = None,
|
||||
selected_task_id: str = "",
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self._gateway = gateway
|
||||
@@ -149,6 +150,7 @@ class ClaimTaskWorker(QObject):
|
||||
self._android_device_service = android_device_service
|
||||
self._cancelled = False
|
||||
self._collect_service_factory = collect_service_factory
|
||||
self._selected_task_id = selected_task_id
|
||||
|
||||
def cancel(self) -> None:
|
||||
"""阻止尚未开始的领取;已领取的任务仍必须保存到本地。"""
|
||||
@@ -166,7 +168,7 @@ class ClaimTaskWorker(QObject):
|
||||
return
|
||||
android_serial = self._android_device_service.load()
|
||||
|
||||
result = CollectTaskService(
|
||||
service = CollectTaskService(
|
||||
self._gateway,
|
||||
self._task_repository,
|
||||
ClientInfo(
|
||||
@@ -176,7 +178,12 @@ class ClaimTaskWorker(QObject):
|
||||
android_serial or "",
|
||||
cancelled=lambda: self._cancelled,
|
||||
collect_service_factory=self._collect_service_factory,
|
||||
).execute_one()
|
||||
)
|
||||
result = (
|
||||
service.execute_selected(self._selected_task_id)
|
||||
if self._selected_task_id
|
||||
else service.execute_one()
|
||||
)
|
||||
if not self._cancelled or result.kind == "cancelled":
|
||||
self.outcome.emit(result.kind, result.message, result.task_id)
|
||||
except AdminGatewayError as exc:
|
||||
@@ -265,6 +272,7 @@ class PDDTaskPageEvent(QObject):
|
||||
|
||||
page.searchRequested.connect(self.search_tasks)
|
||||
page.refreshRequested.connect(self.refresh_tasks)
|
||||
page.rerunRequested.connect(self.request_rerun)
|
||||
page.autoFetchRequested.connect(self._request_claim_task)
|
||||
page.detailRequested.connect(self.show_task_detail)
|
||||
page.taskModel.loadMoreRequested.connect(self._load_page)
|
||||
@@ -294,6 +302,144 @@ class PDDTaskPageEvent(QObject):
|
||||
|
||||
self._reload()
|
||||
|
||||
@pyqtSlot(str)
|
||||
def request_rerun(self, task_id: str) -> None:
|
||||
"""确认后在后台重新采集当前选中的一条终态任务。"""
|
||||
|
||||
if self._closing or not task_id:
|
||||
return
|
||||
if self._auto_fetch_running or self._claim_busy:
|
||||
self._show_rerun_warning(
|
||||
"暂时不能重新执行",
|
||||
"自动获取或其他采集正在运行,请停止并等待当前任务结束。",
|
||||
)
|
||||
return
|
||||
if self._claim_gateway_error or self._claim_gateway is None:
|
||||
self._show_rerun_warning(
|
||||
"重新执行不可用",
|
||||
self._claim_gateway_error or "Admin 提交服务未初始化",
|
||||
)
|
||||
return
|
||||
try:
|
||||
client_settings = self._client_service.load()
|
||||
android_serial = self._selected_android_device_service.load()
|
||||
except Exception:
|
||||
self._show_rerun_warning(
|
||||
"重新执行不可用", "无法读取本地设备设置,请到设置页检查。"
|
||||
)
|
||||
return
|
||||
if not client_settings.client_id:
|
||||
self._show_rerun_warning(
|
||||
"重新执行不可用", "请先在设置页保存当前设备号和设备名。"
|
||||
)
|
||||
return
|
||||
if not android_serial:
|
||||
self._show_rerun_warning(
|
||||
"重新执行不可用", "请先在设置页选择并保存 Android 设备。"
|
||||
)
|
||||
return
|
||||
try:
|
||||
detail = self._repository.validate_collect_rerun(task_id)
|
||||
except (CollectRerunError, ValueError) as exc:
|
||||
self._show_rerun_warning("不能重新执行", str(exc))
|
||||
return
|
||||
except Exception:
|
||||
self._show_rerun_warning(
|
||||
"不能重新执行", "无法读取任务状态,请检查数据库后重试。"
|
||||
)
|
||||
return
|
||||
|
||||
title = detail.title or "尚未获取标题"
|
||||
dialog = MessageBox(
|
||||
"确认重新采集",
|
||||
f"任务:{detail.remote_task_id}\n商品:{title}\n\n"
|
||||
"新结果会覆盖 Client 和 Admin 的当前采集数据,旧结果仍保留在执行记录中。",
|
||||
self._page.window(),
|
||||
)
|
||||
dialog.yesButton.setText("重新采集")
|
||||
dialog.cancelButton.setText("取消")
|
||||
dialog.cancelButton.setFocus()
|
||||
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:
|
||||
"""启动只处理指定任务的工作线程。"""
|
||||
|
||||
assert self._claim_gateway is not None
|
||||
self._claim_busy = True
|
||||
self._page.set_rerun_running(True)
|
||||
self._page.set_engine_status(f"正在重新采集任务 {task_id}…")
|
||||
self._reload()
|
||||
|
||||
thread = QThread(self)
|
||||
worker = ClaimTaskWorker(
|
||||
self._claim_gateway,
|
||||
self._repository,
|
||||
self._client_service,
|
||||
self._selected_android_device_service,
|
||||
self._collect_service_factory,
|
||||
selected_task_id=task_id,
|
||||
)
|
||||
worker.moveToThread(thread)
|
||||
thread.started.connect(worker.run)
|
||||
worker.retryableFailed.connect(self._on_rerun_failed)
|
||||
worker.failed.connect(self._on_rerun_failed)
|
||||
worker.outcome.connect(self._on_rerun_outcome)
|
||||
worker.completed.connect(thread.quit)
|
||||
worker.completed.connect(worker.deleteLater)
|
||||
thread.finished.connect(thread.deleteLater)
|
||||
thread.finished.connect(self._on_rerun_thread_finished)
|
||||
self._claim_thread = thread
|
||||
self._claim_worker = worker
|
||||
thread.start()
|
||||
|
||||
@pyqtSlot(str, str, str)
|
||||
def _on_rerun_outcome(self, kind: str, message: str, _task_id: str) -> None:
|
||||
if self._closing:
|
||||
return
|
||||
self._reload()
|
||||
self._page.set_engine_status(message)
|
||||
if kind != "succeeded":
|
||||
self._show_claim_error("重新采集需要处理", message)
|
||||
|
||||
@pyqtSlot(str)
|
||||
def _on_rerun_failed(self, message: str) -> None:
|
||||
if self._closing:
|
||||
return
|
||||
self._page.set_engine_status(message)
|
||||
self._show_claim_error("重新采集失败", message)
|
||||
|
||||
@pyqtSlot()
|
||||
def _on_rerun_thread_finished(self) -> None:
|
||||
self._claim_worker = None
|
||||
self._claim_thread = None
|
||||
self._claim_busy = False
|
||||
if not self._closing:
|
||||
self._page.set_rerun_running(False)
|
||||
|
||||
def _show_rerun_warning(self, title: str, content: str) -> None:
|
||||
InfoBar.warning(
|
||||
title=title,
|
||||
content=content,
|
||||
isClosable=True,
|
||||
duration=5000,
|
||||
position=InfoBarPosition.TOP_RIGHT,
|
||||
parent=self._page,
|
||||
)
|
||||
|
||||
@pyqtSlot()
|
||||
def _request_claim_task(self) -> None:
|
||||
"""切换持续自动获取;每一轮仍只启动一个后台 Worker。"""
|
||||
|
||||
@@ -161,6 +161,7 @@ class TaskRunRecord:
|
||||
error_code: Optional[str]
|
||||
error_message: Optional[str]
|
||||
diagnostics_json: Optional[Dict[str, Any]]
|
||||
result_data: Optional[Dict[str, Any]]
|
||||
artifact_directory: Optional[str]
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
@@ -34,6 +34,10 @@ class DuplicateTaskError(ValueError):
|
||||
"""相同远程任务编号已经存在,不能覆盖。"""
|
||||
|
||||
|
||||
class CollectRerunError(ValueError):
|
||||
"""当前任务不满足重新采集条件。"""
|
||||
|
||||
|
||||
def utc_now_iso() -> str:
|
||||
"""返回精确到秒的 UTC ISO 8601 时间。"""
|
||||
|
||||
@@ -206,6 +210,81 @@ class TaskRepository:
|
||||
connection.close()
|
||||
return self._to_detail(row) if row is not None else None
|
||||
|
||||
def validate_collect_rerun(self, remote_task_id: str) -> TaskDetail:
|
||||
"""校验任务能否重新采集,成功时返回任务详情。"""
|
||||
|
||||
connection = open_database(self._db_path)
|
||||
try:
|
||||
row = connection.execute(
|
||||
"SELECT * FROM pdd_tasks WHERE remote_task_id = ?",
|
||||
(remote_task_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise CollectRerunError(f"任务 {remote_task_id} 不存在")
|
||||
self._check_collect_rerun(connection, row)
|
||||
finally:
|
||||
connection.close()
|
||||
return self._to_detail(row)
|
||||
|
||||
def prepare_collect_rerun(self, remote_task_id: str) -> TaskDetail:
|
||||
"""事务内把已结束的采集任务恢复为待执行,保留旧结果。"""
|
||||
|
||||
now = utc_now_iso()
|
||||
connection = open_database(self._db_path)
|
||||
try:
|
||||
with connection:
|
||||
row = connection.execute(
|
||||
"SELECT * FROM pdd_tasks WHERE remote_task_id = ?",
|
||||
(remote_task_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise CollectRerunError(f"任务 {remote_task_id} 不存在")
|
||||
self._check_collect_rerun(connection, row)
|
||||
connection.execute(
|
||||
"UPDATE pdd_tasks SET status = 'claimed',"
|
||||
" current_step = 'rerun_requested', finished_at = NULL,"
|
||||
" last_error_code = NULL, last_error_message = NULL,"
|
||||
" updated_at = ? WHERE id = ?",
|
||||
(now, row["id"]),
|
||||
)
|
||||
finally:
|
||||
connection.close()
|
||||
task = self.get_task(remote_task_id)
|
||||
assert task is not None
|
||||
return task
|
||||
|
||||
@staticmethod
|
||||
def _check_collect_rerun(
|
||||
connection: sqlite3.Connection, row: sqlite3.Row
|
||||
) -> None:
|
||||
"""检查重新采集的类型、终态和 Outbox 约束。"""
|
||||
|
||||
if row["task_type"] != TaskType.COLLECT.value:
|
||||
raise CollectRerunError("采购任务不能重新执行,以免重复下单")
|
||||
allowed_statuses = {
|
||||
TaskStatus.SUCCEEDED.value,
|
||||
TaskStatus.FAILED.value,
|
||||
TaskStatus.CANCELLED.value,
|
||||
TaskStatus.MANUAL_REVIEW.value,
|
||||
}
|
||||
if row["status"] not in allowed_statuses:
|
||||
status_name = {
|
||||
TaskStatus.CLAIMED.value: "待执行",
|
||||
TaskStatus.RUNNING.value: "执行中",
|
||||
TaskStatus.RESULT_PENDING.value: "结果待提交",
|
||||
TaskStatus.RETRY_WAIT.value: "等待重试",
|
||||
}.get(row["status"], row["status"])
|
||||
raise CollectRerunError(f"任务当前为“{status_name}”,不能重新采集")
|
||||
unsent_count = int(
|
||||
connection.execute(
|
||||
"SELECT COUNT(*) FROM outbox_events"
|
||||
" WHERE task_id = ? AND status != 'sent'",
|
||||
(row["id"],),
|
||||
).fetchone()[0]
|
||||
)
|
||||
if unsent_count:
|
||||
raise CollectRerunError("任务仍有未发送的结果,请先完成提交")
|
||||
|
||||
def start_collect_run(
|
||||
self, remote_task_id: str, device_address: str
|
||||
) -> StartedTaskRun:
|
||||
@@ -284,22 +363,24 @@ class TaskRepository:
|
||||
"pdd_data": pdd_data,
|
||||
}
|
||||
idempotency_key = f"{remote_task_id}:{attempt_id}:result-v1"
|
||||
result_json = json.dumps(pdd_data, ensure_ascii=False)
|
||||
connection.execute(
|
||||
"UPDATE pdd_tasks SET status = 'result_pending',"
|
||||
" current_step = 'submit_result', pdd_data = ?, goods_id = ?,"
|
||||
" title = ?, price_cent = ?, finished_at = ?, updated_at = ?"
|
||||
" WHERE id = ?",
|
||||
(
|
||||
json.dumps(pdd_data, ensure_ascii=False),
|
||||
result_json,
|
||||
pdd_data.get("goods_id"), pdd_data.get("title"),
|
||||
self._summary_price(pdd_data), now, now, task["id"],
|
||||
),
|
||||
)
|
||||
connection.execute(
|
||||
"UPDATE task_runs SET run_status = 'succeeded',"
|
||||
" current_step = 'submit_result', finished_at = ?, updated_at = ?"
|
||||
" current_step = 'submit_result', result_data = ?,"
|
||||
" finished_at = ?, updated_at = ?"
|
||||
" WHERE attempt_id = ?",
|
||||
(now, now, attempt_id),
|
||||
(result_json, now, now, attempt_id),
|
||||
)
|
||||
cursor = connection.execute(
|
||||
"INSERT INTO outbox_events (task_id, event_type, idempotency_key,"
|
||||
|
||||
Reference in New Issue
Block a user