fix: 修复重新上报任务状态不一致 (#91)
This commit is contained in:
+1
-1
@@ -72,7 +72,7 @@
|
|||||||
- 优先使用 `FluentWindow`、Fluent 导航、主题和图标;不得使用表情符号充当结构图标。
|
- 优先使用 `FluentWindow`、Fluent 导航、主题和图标;不得使用表情符号充当结构图标。
|
||||||
- 使用布局、尺寸策略和伸缩项,不用固定坐标排列常规界面。
|
- 使用布局、尺寸策略和伸缩项,不用固定坐标排列常规界面。
|
||||||
- 任务表格使用模型/视图和稳定任务编号,不把完整 `pdd_data` 放入隐藏列,也不为每个单元格创建常驻 QWidget。
|
- 任务表格使用模型/视图和稳定任务编号,不把完整 `pdd_data` 放入隐藏列,也不为每个单元格创建常驻 QWidget。
|
||||||
- 会产生外部后果的命令只有“获取任务”“重新执行”和“重新上报”:重新执行只允许单条采集任务;重新上报只重发既有结果 Outbox,不操作手机、不创建新结果。搜索、筛选、刷新和详情仍只读本地数据库。
|
- 会产生外部后果的命令只有“获取任务”“重新执行”和“重新上报”:重新执行只允许单条采集任务;重新上报只重发既有 Outbox(优先未发送事件),不操作手机、不创建新事件。搜索、筛选、刷新和详情仍只读本地数据库。
|
||||||
- 本地只保存已领取的任务,不缓存 Admin 任务池;已完成任务永久保留,不得清理。
|
- 本地只保存已领取的任务,不缓存 Admin 任务池;已完成任务永久保留,不得清理。
|
||||||
- 普通成功更新页面状态即可;可恢复错误使用 `InfoBar`,只有必须阻断决策时才使用模态对话框。
|
- 普通成功更新页面状态即可;可恢复错误使用 `InfoBar`,只有必须阻断决策时才使用模态对话框。
|
||||||
- 主要流程必须支持键盘;表单具有可见标签;状态和错误不能只依赖颜色。
|
- 主要流程必须支持键盘;表单具有可见标签;状态和错误不能只依赖颜色。
|
||||||
|
|||||||
+21
-12
@@ -50,6 +50,7 @@ from .purchase_reconcile_service import PurchaseReconcileFactory
|
|||||||
from .selected_android_device_service import SelectedAndroidDeviceService
|
from .selected_android_device_service import SelectedAndroidDeviceService
|
||||||
from .settings_repository import SettingsRepository
|
from .settings_repository import SettingsRepository
|
||||||
from .task_models import (
|
from .task_models import (
|
||||||
|
OutboxEventType,
|
||||||
OutboxStatus,
|
OutboxStatus,
|
||||||
TaskFilters,
|
TaskFilters,
|
||||||
TaskStatus,
|
TaskStatus,
|
||||||
@@ -213,7 +214,7 @@ class ClaimTaskWorker(QObject):
|
|||||||
|
|
||||||
|
|
||||||
class ResultResubmitWorker(QObject):
|
class ResultResubmitWorker(QObject):
|
||||||
"""在后台逐条重发既有结果 Outbox,不执行任何手机操作。"""
|
"""在后台逐条重发既有 Outbox,不执行任何手机操作。"""
|
||||||
|
|
||||||
progress = pyqtSignal(int, int, str)
|
progress = pyqtSignal(int, int, str)
|
||||||
finished = pyqtSignal(object, object, object)
|
finished = pyqtSignal(object, object, object)
|
||||||
@@ -248,7 +249,7 @@ class ResultResubmitWorker(QObject):
|
|||||||
break
|
break
|
||||||
self.progress.emit(current, total, task_id)
|
self.progress.emit(current, total, task_id)
|
||||||
try:
|
try:
|
||||||
event = self._repository.latest_result_outbox(task_id)
|
event = self._repository.outbox_for_resubmit(task_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
skipped.append(task_id)
|
skipped.append(task_id)
|
||||||
continue
|
continue
|
||||||
@@ -258,11 +259,18 @@ class ResultResubmitWorker(QObject):
|
|||||||
|
|
||||||
self._repository.mark_outbox_sending(event.id)
|
self._repository.mark_outbox_sending(event.id)
|
||||||
try:
|
try:
|
||||||
receipt = self._gateway.submit_result(
|
if event.event_type is OutboxEventType.TASK_FAILURE:
|
||||||
task_id,
|
receipt = self._gateway.submit_failure(
|
||||||
event.idempotency_key,
|
task_id,
|
||||||
event.payload_json,
|
event.idempotency_key,
|
||||||
)
|
event.payload_json,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
receipt = self._gateway.submit_result(
|
||||||
|
task_id,
|
||||||
|
event.idempotency_key,
|
||||||
|
event.payload_json,
|
||||||
|
)
|
||||||
if not receipt.accepted:
|
if not receipt.accepted:
|
||||||
raise AdminGatewayError(
|
raise AdminGatewayError(
|
||||||
"ADMIN_RESULT_NOT_ACCEPTED",
|
"ADMIN_RESULT_NOT_ACCEPTED",
|
||||||
@@ -469,7 +477,7 @@ class PDDTaskPageEvent(QObject):
|
|||||||
|
|
||||||
@pyqtSlot(object)
|
@pyqtSlot(object)
|
||||||
def request_resubmit(self, task_ids) -> None:
|
def request_resubmit(self, task_ids) -> None:
|
||||||
"""确认后在后台重发勾选任务的原始结果 Outbox。"""
|
"""确认后在后台重发勾选任务尚未发送或最新的 Outbox。"""
|
||||||
|
|
||||||
stable_ids = tuple(
|
stable_ids = tuple(
|
||||||
dict.fromkeys(str(value) for value in task_ids if value)
|
dict.fromkeys(str(value) for value in task_ids if value)
|
||||||
@@ -490,8 +498,9 @@ class PDDTaskPageEvent(QObject):
|
|||||||
return
|
return
|
||||||
|
|
||||||
dialog = MessageBox(
|
dialog = MessageBox(
|
||||||
f"重新上报 {len(stable_ids)} 条任务结果?",
|
f"重新上报 {len(stable_ids)} 条任务数据?",
|
||||||
"只会重新提交本地已经保存的原始结果,"
|
"会优先提交尚未发送的结果或失败信息;没有待发送数据时,"
|
||||||
|
"才重新提交本地已经保存的最新结果。"
|
||||||
"不会重新采集、采购或操作 Android 手机,也不会修改本地结果。",
|
"不会重新采集、采购或操作 Android 手机,也不会修改本地结果。",
|
||||||
self._page.window(),
|
self._page.window(),
|
||||||
)
|
)
|
||||||
@@ -504,13 +513,13 @@ class PDDTaskPageEvent(QObject):
|
|||||||
self._start_resubmit_worker(stable_ids)
|
self._start_resubmit_worker(stable_ids)
|
||||||
|
|
||||||
def _start_resubmit_worker(self, task_ids: tuple[str, ...]) -> None:
|
def _start_resubmit_worker(self, task_ids: tuple[str, ...]) -> None:
|
||||||
"""启动只处理指定结果 Outbox 的工作线程。"""
|
"""启动只处理指定任务 Outbox 的工作线程。"""
|
||||||
|
|
||||||
assert self._claim_gateway is not None
|
assert self._claim_gateway is not None
|
||||||
self._resubmit_busy = True
|
self._resubmit_busy = True
|
||||||
self._page.set_resubmit_running(True)
|
self._page.set_resubmit_running(True)
|
||||||
self._page.set_engine_status(
|
self._page.set_engine_status(
|
||||||
f"正在准备重新上报 {len(task_ids)} 条任务结果…"
|
f"正在准备重新上报 {len(task_ids)} 条任务数据…"
|
||||||
)
|
)
|
||||||
|
|
||||||
thread = QThread(self)
|
thread = QThread(self)
|
||||||
|
|||||||
@@ -408,18 +408,30 @@ class TaskRepository:
|
|||||||
}.get(row["status"], row["status"])
|
}.get(row["status"], row["status"])
|
||||||
raise CollectRerunError(f"任务当前为“{status_name}”,不能重新采集")
|
raise CollectRerunError(f"任务当前为“{status_name}”,不能重新采集")
|
||||||
unsent = connection.execute(
|
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'"
|
" WHERE task_id = ? AND status != 'sent'"
|
||||||
" ORDER BY id DESC LIMIT 1",
|
" ORDER BY id DESC LIMIT 1",
|
||||||
(row["id"],),
|
(row["id"],),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if unsent is not None and unsent["status"] == OutboxStatus.FAILED.value:
|
if unsent is not None and unsent["status"] == OutboxStatus.FAILED.value:
|
||||||
reason = unsent["last_error"] or "Admin 未接收上次结果"
|
reason = unsent["last_error"] or "Admin 未接收上次结果"
|
||||||
|
event_name = (
|
||||||
|
"失败信息"
|
||||||
|
if unsent["event_type"] == OutboxEventType.TASK_FAILURE.value
|
||||||
|
else "结果"
|
||||||
|
)
|
||||||
raise CollectRerunError(
|
raise CollectRerunError(
|
||||||
f"任务上次结果提交失败:{reason};请先处理后再重新采集"
|
f"任务上次{event_name}提交失败:{reason};"
|
||||||
|
"请先勾选任务点击“重新上报”"
|
||||||
)
|
)
|
||||||
if unsent is not None:
|
if unsent is not None:
|
||||||
raise CollectRerunError("任务仍有未发送的结果,请先完成提交")
|
if unsent["event_type"] == OutboxEventType.TASK_FAILURE.value:
|
||||||
|
raise CollectRerunError(
|
||||||
|
"任务仍有未上报的失败信息,请先勾选任务点击“重新上报”"
|
||||||
|
)
|
||||||
|
raise CollectRerunError(
|
||||||
|
"任务仍有未发送的结果,请先勾选任务点击“重新上报”"
|
||||||
|
)
|
||||||
|
|
||||||
def start_collect_run(
|
def start_collect_run(
|
||||||
self, remote_task_id: str, device_address: str
|
self, remote_task_id: str, device_address: str
|
||||||
@@ -1017,6 +1029,37 @@ class TaskRepository:
|
|||||||
connection.close()
|
connection.close()
|
||||||
return self._to_outbox(row) if row is not None else None
|
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]:
|
def get_outbox_event(self, event_id: int) -> Optional[OutboxEventRecord]:
|
||||||
connection = open_database(self._db_path)
|
connection = open_database(self._db_path)
|
||||||
try:
|
try:
|
||||||
@@ -1051,34 +1094,133 @@ class TaskRepository:
|
|||||||
self._update_outbox(event_id, "failed", message, increment=True)
|
self._update_outbox(event_id, "failed", message, increment=True)
|
||||||
|
|
||||||
def mark_outbox_sent(self, event_id: int) -> None:
|
def mark_outbox_sent(self, event_id: int) -> None:
|
||||||
|
"""标记事件已发送,并只按当前最新执行更新任务状态。
|
||||||
|
|
||||||
|
已经发送过的历史结果再次上报时,不得覆盖任务的最新状态。
|
||||||
|
最新失败信息发送成功时会恢复对应失败状态,用于修复旧版本
|
||||||
|
产生的“表格显示已完成、实际最新执行失败”记录。
|
||||||
|
"""
|
||||||
|
|
||||||
now = utc_now_iso()
|
now = utc_now_iso()
|
||||||
connection = open_database(self._db_path)
|
connection = open_database(self._db_path)
|
||||||
try:
|
try:
|
||||||
with connection:
|
with connection:
|
||||||
row = connection.execute(
|
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,),
|
(event_id,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if row is None:
|
if row is None:
|
||||||
raise ValueError(f"Outbox {event_id} 不存在")
|
raise ValueError(f"Outbox {event_id} 不存在")
|
||||||
|
was_already_sent = row["sent_at"] is not None
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"UPDATE outbox_events SET status = 'sent', sent_at = ?,"
|
"UPDATE outbox_events SET status = 'sent', sent_at = ?,"
|
||||||
" updated_at = ?, attempt_count = attempt_count + 1"
|
" updated_at = ?, attempt_count = attempt_count + 1"
|
||||||
" WHERE id = ?",
|
" WHERE id = ?",
|
||||||
(now, now, event_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.COLLECT_RESULT.value,
|
||||||
OutboxEventType.PURCHASE_RESULT.value,
|
OutboxEventType.PURCHASE_RESULT.value,
|
||||||
}:
|
}:
|
||||||
connection.execute(
|
self._complete_current_result(
|
||||||
"UPDATE pdd_tasks SET status = 'succeeded',"
|
connection, row["task_id"], payload, now
|
||||||
" current_step = 'completed', updated_at = ? WHERE id = ?",
|
|
||||||
(now, row["task_id"]),
|
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
connection.close()
|
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(
|
def _update_outbox(
|
||||||
self, event_id: int, status: str, message: Optional[str], increment: bool = False
|
self, event_id: int, status: str, message: Optional[str], increment: bool = False
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from PyQt5.QtWidgets import QApplication
|
|||||||
from qfluentwidgets import InfoBarPosition
|
from qfluentwidgets import InfoBarPosition
|
||||||
|
|
||||||
from src.android_device_service import AndroidDeviceSearchError
|
from src.android_device_service import AndroidDeviceSearchError
|
||||||
|
from src.db import open_database
|
||||||
from src.pdd_ui import PDDTaskPage
|
from src.pdd_ui import PDDTaskPage
|
||||||
from src.admin_gateway import AdminGatewayError, AdminTask, SubmissionReceipt
|
from src.admin_gateway import AdminGatewayError, AdminTask, SubmissionReceipt
|
||||||
from src.pdd_ui_event import (
|
from src.pdd_ui_event import (
|
||||||
@@ -32,7 +33,13 @@ from src.pdd_purchase_adapter import PddPurchaseAdapter, PurchasePageState
|
|||||||
from src.pdd_collect_service import PddCollectError
|
from src.pdd_collect_service import PddCollectError
|
||||||
from src.pdd_u2_purchase_adapter import create_u2_purchase_adapter
|
from src.pdd_u2_purchase_adapter import create_u2_purchase_adapter
|
||||||
from src.settings_repository import SettingsRepository
|
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.task_repository import TaskRepository
|
||||||
from src.ui_main import MainWindow
|
from src.ui_main import MainWindow
|
||||||
|
|
||||||
@@ -136,6 +143,7 @@ class RecordingResubmitGateway(RecordingClaimGateway):
|
|||||||
self.failures = dict(failures or {})
|
self.failures = dict(failures or {})
|
||||||
self.delay = delay
|
self.delay = delay
|
||||||
self.submit_calls = []
|
self.submit_calls = []
|
||||||
|
self.failure_calls = []
|
||||||
|
|
||||||
def submit_result(self, task_id, idempotency_key, result):
|
def submit_result(self, task_id, idempotency_key, result):
|
||||||
self.submit_calls.append((task_id, idempotency_key, result))
|
self.submit_calls.append((task_id, idempotency_key, result))
|
||||||
@@ -146,6 +154,15 @@ class RecordingResubmitGateway(RecordingClaimGateway):
|
|||||||
raise error
|
raise error
|
||||||
return SubmissionReceipt(True, f"RESULT-{task_id}", "2026-08-10T08:00:00Z")
|
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:
|
class FakeCollectResult:
|
||||||
def to_pdd_data(self):
|
def to_pdd_data(self):
|
||||||
@@ -457,6 +474,64 @@ class PDDTaskPageEventTest(unittest.TestCase):
|
|||||||
events.shutdown()
|
events.shutdown()
|
||||||
page.deleteLater()
|
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):
|
def test_resubmit_confirmation_cancel_does_not_submit(self):
|
||||||
self._add_task(1)
|
self._add_task(1)
|
||||||
page = PDDTaskPage()
|
page = PDDTaskPage()
|
||||||
|
|||||||
@@ -234,7 +234,96 @@ class TaskRepositoryTests(unittest.TestCase):
|
|||||||
self.assertEqual(latest.id, result.id)
|
self.assertEqual(latest.id, result.id)
|
||||||
self.assertEqual(latest.event_type, OutboxEventType.COLLECT_RESULT)
|
self.assertEqual(latest.event_type, OutboxEventType.COLLECT_RESULT)
|
||||||
self.assertNotEqual(latest.id, failure.id)
|
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.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):
|
def test_prepare_rerun_preserves_old_result_and_creates_new_attempt(self):
|
||||||
self.repository.add_claimed_task(self._task("TASK-RERUN"))
|
self.repository.add_claimed_task(self._task("TASK-RERUN"))
|
||||||
@@ -306,7 +395,7 @@ class TaskRepositoryTests(unittest.TestCase):
|
|||||||
True,
|
True,
|
||||||
)
|
)
|
||||||
|
|
||||||
with self.assertRaisesRegex(CollectRerunError, "未发送"):
|
with self.assertRaisesRegex(CollectRerunError, "未上报的失败信息"):
|
||||||
self.repository.validate_collect_rerun("RETRY-RERUN")
|
self.repository.validate_collect_rerun("RETRY-RERUN")
|
||||||
|
|
||||||
self.repository.mark_outbox_sent(event.id)
|
self.repository.mark_outbox_sent(event.id)
|
||||||
@@ -332,7 +421,7 @@ class TaskRepositoryTests(unittest.TestCase):
|
|||||||
self.repository.mark_outbox_failed(event.id, "任务不存在")
|
self.repository.mark_outbox_failed(event.id, "任务不存在")
|
||||||
|
|
||||||
with self.assertRaisesRegex(
|
with self.assertRaisesRegex(
|
||||||
CollectRerunError, "上次结果提交失败:任务不存在"
|
CollectRerunError, "上次失败信息提交失败:任务不存在"
|
||||||
):
|
):
|
||||||
self.repository.validate_collect_rerun("RETRY-REJECTED")
|
self.repository.validate_collect_rerun("RETRY-REJECTED")
|
||||||
|
|
||||||
|
|||||||
@@ -373,6 +373,15 @@ def build_idempotency_key(
|
|||||||
2. **键一旦入库就不能变。** 重试是拿库里存的键去发,不是现算一个。
|
2. **键一旦入库就不能变。** 重试是拿库里存的键去发,不是现算一个。
|
||||||
3. **键相同,内容也必须相同。** 同一个键发不同内容,Admin 会返回 `409 IDEMPOTENCY_CONFLICT`。内容真的变了,说明这是一次新的执行,应该用新的 `attempt_id`。
|
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`
|
## 6. `app_settings`
|
||||||
|
|
||||||
保存非敏感设置和设置版本。
|
保存非敏感设置和设置版本。
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
## 1. 设计目标
|
## 1. 设计目标
|
||||||
|
|
||||||
- 让操作人员在一个页面完成任务监控、搜索和异常定位。
|
- 让操作人员在一个页面完成任务监控、搜索和异常定位。
|
||||||
- “获取任务”“重新执行”和“重新上报”会产生外部后果;重新执行只允许单条采集任务,重新上报只重发既有结果 Outbox。
|
- “获取任务”“重新执行”和“重新上报”会产生外部后果;重新执行只允许单条采集任务,重新上报只重发既有 Outbox,并优先处理未发送事件。
|
||||||
- 长任务状态始终可找到,不使用连续模态弹窗打断工作。
|
- 长任务状态始终可找到,不使用连续模态弹窗打断工作。
|
||||||
- 任务表格在数据增长后仍保持响应速度、稳定选择和可访问性。
|
- 任务表格在数据增长后仍保持响应速度、稳定选择和可访问性。
|
||||||
- 界面只展示任务状态,不在 Qt 主线程执行 Admin 或手机自动化。
|
- 界面只展示任务状态,不在 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 设备,在独立工作线程中逐条提交。自动获取、重新执行或另一批上报运行时不得启动。
|
- 重新上报不依赖 Android 设备,在独立工作线程中逐条提交。自动获取、重新执行或另一批上报运行时不得启动。
|
||||||
- 批量完成后显示成功、失败和跳过数量。成功任务取消勾选;失败及没有结果 Outbox 的跳过任务保留勾选,方便继续处理。
|
- 批量完成后显示成功、失败和跳过数量。成功任务取消勾选;失败及没有结果 Outbox 的跳过任务保留勾选,方便继续处理。
|
||||||
- 相同幂等键和相同内容只用于让 Admin 再次确认已接收,不代表创建新结果或覆盖 Admin 数据。
|
- 相同幂等键和相同内容只用于让 Admin 再次确认已接收,不代表创建新结果或覆盖 Admin 数据。
|
||||||
|
|||||||
Reference in New Issue
Block a user