feat: 任务失败后继续执行下一条 (#170)
This commit is contained in:
@@ -36,6 +36,20 @@ CollectServiceFactory = Callable[
|
||||
[str, str, Callable[[], bool]], PddCollectService
|
||||
]
|
||||
|
||||
_QUEUE_BLOCKING_ERROR_CODES = frozenset(
|
||||
{
|
||||
"DEVICE_APP_START_FAILED",
|
||||
"DEVICE_DISCONNECTED",
|
||||
"DEVICE_NOT_FOUND",
|
||||
"DEVICE_OFFLINE",
|
||||
"DEVICE_SESSION_CLOSED",
|
||||
"DEVICE_UNAUTHORIZED",
|
||||
"PDD_PAGE_CAPTCHA",
|
||||
"PDD_PAGE_LOGIN_REQUIRED",
|
||||
"PDD_PAGE_UNKNOWN",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class CollectTaskService:
|
||||
"""一次调用只处理一条本地工作或一个待提交事件。"""
|
||||
@@ -149,13 +163,16 @@ class CollectTaskService:
|
||||
collector = self._factory(
|
||||
self._device_address, self._client.client_id, self._cancelled
|
||||
)
|
||||
business_failure_message = ""
|
||||
failure_message = ""
|
||||
queue_blocked = False
|
||||
try:
|
||||
result = collector.collect(started.task)
|
||||
event = self._repository.save_collect_result(
|
||||
remote_task_id, started.attempt_id, result.to_pdd_data()
|
||||
)
|
||||
except PddCollectError as exc:
|
||||
failure_message = exc.message
|
||||
queue_blocked = exc.code in _QUEUE_BLOCKING_ERROR_CODES
|
||||
status, retryable = self._classify_error(exc.code)
|
||||
report_code = self._report_error_code(exc.code)
|
||||
event = self._repository.save_collect_failure(
|
||||
@@ -169,12 +186,12 @@ class CollectTaskService:
|
||||
)
|
||||
if exc.code == "PDD_CANCELLED":
|
||||
return self._submit_cancelled(event, remote_task_id)
|
||||
if exc.code == "PDD_GOODS_UNAVAILABLE":
|
||||
business_failure_message = exc.message
|
||||
outcome = self._submit(event)
|
||||
if business_failure_message and outcome.kind == "failed":
|
||||
if failure_message and outcome.kind == "failed":
|
||||
return CollectTaskOutcome(
|
||||
"business_failed", business_failure_message, remote_task_id
|
||||
"global_failed" if queue_blocked else "task_failed",
|
||||
f"任务 {remote_task_id} 采集失败:{failure_message}",
|
||||
remote_task_id,
|
||||
)
|
||||
return outcome
|
||||
|
||||
@@ -239,18 +256,10 @@ class CollectTaskService:
|
||||
return TaskStatus.CANCELLED, False
|
||||
if code == "PDD_GOODS_UNAVAILABLE":
|
||||
return TaskStatus.FAILED, False
|
||||
if code in {
|
||||
"PDD_PAGE_LOGIN_REQUIRED",
|
||||
"PDD_PAGE_CAPTCHA",
|
||||
"PDD_DATA_SPEC_INCOMPLETE",
|
||||
"PDD_DATA_TITLE_MISSING",
|
||||
"PDD_DATA_PRICE_MISSING",
|
||||
"PDD_DATA_SKU_NAME_TRUNCATED",
|
||||
}:
|
||||
if code in {"PDD_PAGE_LOGIN_REQUIRED", "PDD_PAGE_CAPTCHA"}:
|
||||
return TaskStatus.MANUAL_REVIEW, False
|
||||
if code.startswith("PDD_DATA_GOODS_"):
|
||||
return TaskStatus.FAILED, False
|
||||
return TaskStatus.RETRY_WAIT, True
|
||||
# 自动流程只执行一次。失败任务由用户明确点击“重新采集”后再运行。
|
||||
return TaskStatus.FAILED, False
|
||||
|
||||
@staticmethod
|
||||
def _report_error_code(code: str) -> str:
|
||||
|
||||
@@ -108,7 +108,7 @@ TASK_STATUS_TEXT = {
|
||||
TaskStatus.CLAIMED: "待执行",
|
||||
TaskStatus.RUNNING: "执行中",
|
||||
TaskStatus.RESULT_PENDING: "结果待提交",
|
||||
TaskStatus.RETRY_WAIT: "等待重试",
|
||||
TaskStatus.RETRY_WAIT: "失败",
|
||||
TaskStatus.MANUAL_REVIEW: "需要人工处理",
|
||||
TaskStatus.SUCCEEDED: "已完成",
|
||||
TaskStatus.FAILED: "失败",
|
||||
@@ -1420,7 +1420,7 @@ class PDDTaskPageEvent(QObject):
|
||||
self._reload()
|
||||
if kind == "no_task":
|
||||
self._on_no_claimed_task()
|
||||
elif kind in {"succeeded", "business_failed"}:
|
||||
elif kind in {"succeeded", "business_failed", "task_failed"}:
|
||||
self._retry_count = 0
|
||||
self._continue_after(
|
||||
self._next_task_delay_ms,
|
||||
@@ -1438,14 +1438,7 @@ class PDDTaskPageEvent(QObject):
|
||||
f"{seconds:g} 秒后自动核对订单"
|
||||
),
|
||||
)
|
||||
elif kind == "failed" and self._is_retry_wait_task(task_id):
|
||||
content = (
|
||||
f"自动获取:已停止 · 任务 {task_id} 重试已暂停;"
|
||||
"请选择该任务点击“重新执行”,或重新启动获取任务"
|
||||
)
|
||||
self._show_retry_paused(content)
|
||||
self._stop_after_current(content)
|
||||
elif kind in {"manual_review", "failed"}:
|
||||
elif kind in {"global_failed", "manual_review", "failed"}:
|
||||
self._show_claim_error("任务需要处理", message)
|
||||
self._stop_after_current(message)
|
||||
elif kind == "cancelled":
|
||||
@@ -1455,21 +1448,6 @@ class PDDTaskPageEvent(QObject):
|
||||
self._show_claim_error("自动获取已停止", content)
|
||||
self._stop_after_current(content)
|
||||
|
||||
def _is_retry_wait_task(self, task_id: str) -> bool:
|
||||
"""判断失败结果对应的任务是否仍可在本地重试。"""
|
||||
|
||||
if not task_id:
|
||||
return False
|
||||
try:
|
||||
task = self._repository.get_task(task_id)
|
||||
except Exception:
|
||||
return False
|
||||
return (
|
||||
task is not None
|
||||
and task.task_type is TaskType.COLLECT
|
||||
and task.status is TaskStatus.RETRY_WAIT
|
||||
)
|
||||
|
||||
def _show_retry_paused(self, content: str) -> None:
|
||||
"""用持久警告说明任务不会自行倒计时重试。"""
|
||||
|
||||
@@ -1750,6 +1728,10 @@ def summary_to_row(summary: TaskSummary) -> TaskRow:
|
||||
status_text = TASK_STATUS_TEXT[summary.status]
|
||||
if summary.status is TaskStatus.RUNNING:
|
||||
status_text = "采集中" if summary.task_type is TaskType.COLLECT else "采购中"
|
||||
elif summary.status in {TaskStatus.RETRY_WAIT, TaskStatus.FAILED}:
|
||||
status_text = (
|
||||
"采集失败" if summary.task_type is TaskType.COLLECT else "采购失败"
|
||||
)
|
||||
|
||||
return TaskRow(
|
||||
remote_task_id=summary.remote_task_id,
|
||||
|
||||
@@ -56,6 +56,20 @@ LivePurchaseAdapterFactory = Callable[
|
||||
[str, Callable[[], bool]], PddLivePurchaseAdapter
|
||||
]
|
||||
|
||||
_QUEUE_BLOCKING_ERROR_CODES = frozenset(
|
||||
{
|
||||
"DEVICE_APP_START_FAILED",
|
||||
"DEVICE_DISCONNECTED",
|
||||
"DEVICE_SESSION_CLOSED",
|
||||
"PDD_PAGE_CAPTCHA",
|
||||
"PDD_PAGE_LOGIN_REQUIRED",
|
||||
"PDD_PAGE_PAYMENT",
|
||||
"PDD_PAGE_RISK_CONTROL",
|
||||
"PDD_PAGE_UNKNOWN",
|
||||
"PURCHASE_ADAPTER_ERROR",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class PurchaseTaskService:
|
||||
"""只执行本地已保存的采购任务,不领取新任务。"""
|
||||
@@ -108,7 +122,8 @@ class PurchaseTaskService:
|
||||
self._started(remote_task_id)
|
||||
adapter: PddPurchaseAdapter | None = None
|
||||
step = "purchase_prepare"
|
||||
business_failure_message = ""
|
||||
failure_message = ""
|
||||
failure_outcome_kind = ""
|
||||
try:
|
||||
target = self._target_from_task(started.task)
|
||||
adapter = self._factory(self._device_address, self._cancelled)
|
||||
@@ -184,6 +199,13 @@ class PurchaseTaskService:
|
||||
remote_task_id, started.attempt_id, result
|
||||
)
|
||||
except PddPurchaseError as exc:
|
||||
failure_message = exc.message
|
||||
if exc.code == "PURCHASE_CANCELLED":
|
||||
failure_outcome_kind = "cancelled"
|
||||
elif exc.code in _QUEUE_BLOCKING_ERROR_CODES:
|
||||
failure_outcome_kind = "global_failed"
|
||||
else:
|
||||
failure_outcome_kind = "task_failed"
|
||||
event = self._save_failure(
|
||||
remote_task_id,
|
||||
started.attempt_id,
|
||||
@@ -193,9 +215,9 @@ class PurchaseTaskService:
|
||||
step,
|
||||
exc.diagnostics,
|
||||
)
|
||||
if exc.code == "PDD_GOODS_UNAVAILABLE":
|
||||
business_failure_message = exc.message
|
||||
except Exception as exc:
|
||||
failure_message = f"采购在“{step}”发生未知错误:{exc}"
|
||||
failure_outcome_kind = "global_failed"
|
||||
event = self._save_failure(
|
||||
remote_task_id,
|
||||
started.attempt_id,
|
||||
@@ -209,9 +231,11 @@ class PurchaseTaskService:
|
||||
if adapter is not None:
|
||||
adapter.close()
|
||||
outcome = self._submit(event)
|
||||
if business_failure_message and outcome.kind == "failed":
|
||||
if failure_message and outcome.kind == "failed":
|
||||
return PurchaseTaskOutcome(
|
||||
"business_failed", business_failure_message, remote_task_id
|
||||
failure_outcome_kind,
|
||||
f"任务 {remote_task_id} 采购失败:{failure_message}",
|
||||
remote_task_id,
|
||||
)
|
||||
return outcome
|
||||
|
||||
@@ -488,25 +512,25 @@ class PurchaseTaskService:
|
||||
attempt_id: str,
|
||||
code: str,
|
||||
message: str,
|
||||
retryable: bool,
|
||||
_retryable: bool,
|
||||
step: str,
|
||||
diagnostics: Mapping[str, object],
|
||||
) -> OutboxEventRecord:
|
||||
if code == "PURCHASE_CANCELLED":
|
||||
status = TaskStatus.CANCELLED
|
||||
elif code == "PDD_GOODS_UNAVAILABLE":
|
||||
status = TaskStatus.FAILED
|
||||
else:
|
||||
# 采购没有手动“重新执行”入口。即使错误属于
|
||||
# 技术上可重试,也先留给人工判断,避免隐式重复采购。
|
||||
elif code in _QUEUE_BLOCKING_ERROR_CODES:
|
||||
status = TaskStatus.MANUAL_REVIEW
|
||||
else:
|
||||
# 一次任务只自动执行一次;再次采购只能由用户明确发起,且仍需
|
||||
# 通过不可逆标记等现有安全预检。
|
||||
status = TaskStatus.FAILED
|
||||
return self._repository.save_purchase_failure(
|
||||
remote_task_id,
|
||||
attempt_id,
|
||||
status,
|
||||
code,
|
||||
message,
|
||||
retryable,
|
||||
False,
|
||||
step,
|
||||
dict(diagnostics),
|
||||
)
|
||||
|
||||
@@ -42,7 +42,7 @@ TASK_STATUS_TEXT = {
|
||||
TaskStatus.CLAIMED: "待执行",
|
||||
TaskStatus.RUNNING: "执行中",
|
||||
TaskStatus.RESULT_PENDING: "结果待提交",
|
||||
TaskStatus.RETRY_WAIT: "等待重试",
|
||||
TaskStatus.RETRY_WAIT: "失败",
|
||||
TaskStatus.MANUAL_REVIEW: "需要人工处理",
|
||||
TaskStatus.SUCCEEDED: "已完成",
|
||||
TaskStatus.FAILED: "失败",
|
||||
@@ -370,10 +370,14 @@ def build_task_detail_view_data(detail: TaskDetail) -> TaskDetailViewData:
|
||||
if value
|
||||
)
|
||||
|
||||
status_text = TASK_STATUS_TEXT.get(detail.status, detail.status.value)
|
||||
if detail.status in {TaskStatus.RETRY_WAIT, TaskStatus.FAILED}:
|
||||
status_text = "采集失败" if detail.task_type is TaskType.COLLECT else "采购失败"
|
||||
|
||||
return TaskDetailViewData(
|
||||
task_id=detail.remote_task_id,
|
||||
task_type=TASK_TYPE_TEXT.get(detail.task_type, detail.task_type.value),
|
||||
status=TASK_STATUS_TEXT.get(detail.status, detail.status.value),
|
||||
status=status_text,
|
||||
title=_non_empty_text(pdd_data.get("title")) or detail.title or "未采集",
|
||||
goods_id=_non_empty_text(pdd_data.get("goods_id")) or detail.goods_id or "—",
|
||||
goods_url=detail.goods_url,
|
||||
|
||||
@@ -379,7 +379,7 @@ class TaskRepository:
|
||||
try:
|
||||
row = connection.execute(
|
||||
"SELECT * FROM pdd_tasks"
|
||||
" WHERE task_type = 'collect' AND status IN ('claimed', 'retry_wait')"
|
||||
" WHERE task_type = 'collect' AND status = 'claimed'"
|
||||
" ORDER BY received_at ASC, id ASC LIMIT 1"
|
||||
).fetchone()
|
||||
finally:
|
||||
@@ -456,15 +456,11 @@ class TaskRepository:
|
||||
|
||||
if include_purchase:
|
||||
where = (
|
||||
"((task_type = 'collect'"
|
||||
" AND status IN ('claimed', 'retry_wait'))"
|
||||
"((task_type = 'collect' AND status = 'claimed')"
|
||||
" OR (task_type = 'purchase' AND status = 'claimed'))"
|
||||
)
|
||||
else:
|
||||
where = (
|
||||
"task_type = 'collect'"
|
||||
" AND status IN ('claimed', 'retry_wait')"
|
||||
)
|
||||
where = "task_type = 'collect' AND status = 'claimed'"
|
||||
connection = open_database(self._db_path)
|
||||
try:
|
||||
row = connection.execute(
|
||||
|
||||
@@ -157,7 +157,7 @@ class CollectTaskServiceTest(unittest.TestCase):
|
||||
calls, PddCollectError("PDD_PAGE_CAPTCHA", "需要验证")
|
||||
).execute_one()
|
||||
|
||||
self.assertEqual(outcome.kind, "failed")
|
||||
self.assertEqual(outcome.kind, "global_failed")
|
||||
self.assertEqual(
|
||||
self.repository.get_task("COL-001").status,
|
||||
TaskStatus.MANUAL_REVIEW,
|
||||
@@ -170,13 +170,75 @@ class CollectTaskServiceTest(unittest.TestCase):
|
||||
[], PddCollectError("PDD_GOODS_UNAVAILABLE", message)
|
||||
).execute_one()
|
||||
|
||||
self.assertEqual("business_failed", outcome.kind)
|
||||
self.assertEqual(message, outcome.message)
|
||||
self.assertEqual("task_failed", outcome.kind)
|
||||
self.assertIn(message, outcome.message)
|
||||
detail = self.repository.get_task("COL-001")
|
||||
self.assertEqual(TaskStatus.FAILED, detail.status)
|
||||
self.assertEqual("PDD_GOODS_UNAVAILABLE", detail.last_error_code)
|
||||
self.assertEqual(1, self.gateway.submission_count)
|
||||
|
||||
def test_page_timeout_fails_once_without_automatic_retry(self):
|
||||
message = "等待商品页加载超时"
|
||||
|
||||
outcome = self._service(
|
||||
[], PddCollectError("PDD_PAGE_TIMEOUT", message)
|
||||
).execute_one()
|
||||
|
||||
self.assertEqual("task_failed", outcome.kind)
|
||||
detail = self.repository.get_task("COL-001")
|
||||
self.assertEqual(TaskStatus.FAILED, detail.status)
|
||||
self.assertEqual(0, detail.retry_count)
|
||||
self.assertIn(message, outcome.message)
|
||||
|
||||
def test_task_failure_does_not_block_next_local_task(self):
|
||||
# 直接准备两条本地任务,验证失败任务不会再次被自动选中。
|
||||
for task_id in ("COL-FIRST", "COL-SECOND"):
|
||||
self.repository.add_claimed_task(
|
||||
NewClaimedTask(
|
||||
remote_task_id=task_id,
|
||||
task_type=TaskType.COLLECT,
|
||||
goods_url=f"https://example.test/{task_id}",
|
||||
)
|
||||
)
|
||||
calls = []
|
||||
|
||||
class AcceptGateway:
|
||||
def submit_failure(self, *_args):
|
||||
return SubmissionReceipt(
|
||||
True, "FAILURE-001", "2026-08-11T08:00:00Z"
|
||||
)
|
||||
|
||||
def submit_result(self, *_args):
|
||||
return SubmissionReceipt(
|
||||
True, "RESULT-001", "2026-08-11T08:00:01Z"
|
||||
)
|
||||
|
||||
def service(error=None):
|
||||
return CollectTaskService(
|
||||
AcceptGateway(),
|
||||
self.repository,
|
||||
self.client,
|
||||
"USB-001",
|
||||
collect_service_factory=lambda *_args: FakeCollector(calls, error),
|
||||
)
|
||||
|
||||
first = service(
|
||||
PddCollectError("PDD_PAGE_TIMEOUT", "第一页加载超时")
|
||||
).execute_one()
|
||||
second = service().execute_one()
|
||||
|
||||
self.assertEqual("task_failed", first.kind)
|
||||
self.assertEqual("succeeded", second.kind)
|
||||
self.assertEqual(calls, ["COL-FIRST", "COL-SECOND"])
|
||||
self.assertEqual(
|
||||
TaskStatus.FAILED,
|
||||
self.repository.get_task("COL-FIRST").status,
|
||||
)
|
||||
self.assertEqual(
|
||||
TaskStatus.SUCCEEDED,
|
||||
self.repository.get_task("COL-SECOND").status,
|
||||
)
|
||||
|
||||
def test_execute_selected_only_runs_requested_local_task(self):
|
||||
for task_id in ("COL-SELECTED", "COL-OTHER"):
|
||||
self.repository.add_claimed_task(
|
||||
|
||||
@@ -1128,6 +1128,37 @@ class PDDTaskPageEventTest(unittest.TestCase):
|
||||
self.assertEqual(collect.status, "采集中")
|
||||
self.assertEqual(purchase.status, "采购中")
|
||||
|
||||
def test_summary_to_row_uses_task_specific_failure_text(self):
|
||||
common = dict(
|
||||
id=1,
|
||||
goods_id=None,
|
||||
title=None,
|
||||
target_color=None,
|
||||
target_size=None,
|
||||
price_cent=None,
|
||||
quantity=None,
|
||||
updated_at="2026-08-11T08:00:00Z",
|
||||
)
|
||||
collect = summary_to_row(
|
||||
TaskSummary(
|
||||
remote_task_id="COL-FAILED",
|
||||
task_type=TaskType.COLLECT,
|
||||
status=TaskStatus.RETRY_WAIT,
|
||||
**common,
|
||||
)
|
||||
)
|
||||
purchase = summary_to_row(
|
||||
TaskSummary(
|
||||
remote_task_id="PUR-FAILED",
|
||||
task_type=TaskType.PURCHASE,
|
||||
status=TaskStatus.FAILED,
|
||||
**common,
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(collect.status, "采集失败")
|
||||
self.assertEqual(purchase.status, "采购失败")
|
||||
|
||||
def test_main_window_keeps_event_object_alive(self):
|
||||
window = MainWindow(
|
||||
task_repository=self.repository,
|
||||
@@ -1534,7 +1565,7 @@ class PDDTaskPageEventTest(unittest.TestCase):
|
||||
events.shutdown()
|
||||
page.deleteLater()
|
||||
|
||||
def test_retry_wait_failure_shows_paused_recovery_actions(self):
|
||||
def test_historical_retry_wait_no_longer_promises_automatic_retry(self):
|
||||
self._add_task(1)
|
||||
started = self.repository.start_collect_run("PDD-001", "USB-001")
|
||||
event = self.repository.save_collect_failure(
|
||||
@@ -1560,13 +1591,12 @@ class PDDTaskPageEventTest(unittest.TestCase):
|
||||
"PDD-001",
|
||||
)
|
||||
|
||||
self.assertIn("重试已暂停", events._stop_status)
|
||||
self.assertIn("重新执行", events._stop_status)
|
||||
self.assertIn("重新启动获取任务", events._stop_status)
|
||||
self.assertEqual(events._stop_status, "任务 PDD-001 采集未完成")
|
||||
self.assertNotIn("重试", events._stop_status)
|
||||
events.shutdown()
|
||||
page.deleteLater()
|
||||
|
||||
def test_failed_task_does_not_show_retry_paused_status(self):
|
||||
def test_task_failure_continues_auto_fetch_after_current_task(self):
|
||||
self._add_task(1)
|
||||
started = self.repository.start_collect_run("PDD-001", "USB-001")
|
||||
event = self.repository.save_collect_failure(
|
||||
@@ -1586,10 +1616,33 @@ class PDDTaskPageEventTest(unittest.TestCase):
|
||||
settings_repository=self._saved_settings(),
|
||||
)
|
||||
|
||||
events._on_collect_outcome("failed", "商品已下架", "PDD-001")
|
||||
events._auto_fetch_running = True
|
||||
events._on_collect_outcome("task_failed", "采集失败:商品已下架", "PDD-001")
|
||||
|
||||
self.assertEqual(events._stop_status, "商品已下架")
|
||||
self.assertNotIn("重试已暂停", events._stop_status)
|
||||
self.assertTrue(events._auto_fetch_running)
|
||||
self.assertFalse(events._stop_requested)
|
||||
self.assertEqual(events._next_task_delay_ms, events._cycle_next_delay_ms)
|
||||
self.assertIn("采集失败:商品已下架", page.statusLabel.text())
|
||||
events.shutdown()
|
||||
page.deleteLater()
|
||||
|
||||
def test_global_failure_stops_auto_fetch(self):
|
||||
page = PDDTaskPage()
|
||||
events = PDDTaskPageEvent(
|
||||
page,
|
||||
self.repository,
|
||||
claim_gateway=RecordingClaimGateway(None),
|
||||
settings_repository=self._saved_settings(),
|
||||
)
|
||||
events._auto_fetch_running = True
|
||||
|
||||
events._on_collect_outcome(
|
||||
"global_failed", "PDD 登录失效,需要人工登录", "COL-001"
|
||||
)
|
||||
|
||||
self.assertFalse(events._auto_fetch_running)
|
||||
self.assertTrue(events._stop_requested)
|
||||
self.assertIn("PDD 登录失效", events._stop_status)
|
||||
events.shutdown()
|
||||
page.deleteLater()
|
||||
|
||||
|
||||
@@ -223,7 +223,7 @@ class PurchaseRecoveryTest(unittest.TestCase):
|
||||
adapter, cancelled=lambda: next(checks, True)
|
||||
).execute_selected("PUR-STOP")
|
||||
|
||||
self.assertEqual(outcome.kind, "failed")
|
||||
self.assertEqual(outcome.kind, "cancelled")
|
||||
detail = self.repository.get_task("PUR-STOP")
|
||||
run = self.repository.latest_task_run("PUR-STOP")
|
||||
assert detail is not None and run is not None
|
||||
|
||||
@@ -241,11 +241,11 @@ class PurchaseTaskServiceTest(unittest.TestCase):
|
||||
|
||||
outcome = self._service(adapter).execute_one_local()
|
||||
|
||||
self.assertEqual(outcome.kind, "failed")
|
||||
self.assertEqual(outcome.kind, "task_failed")
|
||||
self.assertNotIn(("enter_confirmation",), adapter.calls)
|
||||
detail = self.repository.get_task("PUR-001")
|
||||
assert detail is not None
|
||||
self.assertEqual(detail.status, TaskStatus.MANUAL_REVIEW)
|
||||
self.assertEqual(detail.status, TaskStatus.FAILED)
|
||||
self.assertEqual(detail.last_error_code, "PURCHASE_PRICE_EXCEEDED")
|
||||
|
||||
def test_unavailable_goods_stops_before_specs_and_is_not_retryable(self):
|
||||
@@ -254,7 +254,7 @@ class PurchaseTaskServiceTest(unittest.TestCase):
|
||||
|
||||
outcome = self._service(adapter).execute_one_local()
|
||||
|
||||
self.assertEqual("business_failed", outcome.kind)
|
||||
self.assertEqual("task_failed", outcome.kind)
|
||||
self.assertNotIn(("select_options", OPTIONS), adapter.calls)
|
||||
detail = self.repository.get_task("PUR-001")
|
||||
self.assertEqual(TaskStatus.FAILED, detail.status)
|
||||
@@ -276,12 +276,12 @@ class PurchaseTaskServiceTest(unittest.TestCase):
|
||||
|
||||
def test_invalid_pages_and_ambiguous_target_fail_safely(self):
|
||||
cases = (
|
||||
("captcha", 1, "PDD_PAGE_CAPTCHA"),
|
||||
("login_required", 1, "PDD_PAGE_LOGIN_REQUIRED"),
|
||||
("unknown", 1, "PDD_PAGE_UNKNOWN"),
|
||||
("", 2, "PURCHASE_AMBIGUOUS_TARGET"),
|
||||
("captcha", 1, "PDD_PAGE_CAPTCHA", "global_failed"),
|
||||
("login_required", 1, "PDD_PAGE_LOGIN_REQUIRED", "global_failed"),
|
||||
("unknown", 1, "PDD_PAGE_UNKNOWN", "global_failed"),
|
||||
("", 2, "PURCHASE_AMBIGUOUS_TARGET", "task_failed"),
|
||||
)
|
||||
for index, (page, candidates, expected_code) in enumerate(cases):
|
||||
for index, (page, candidates, expected_code, expected_kind) in enumerate(cases):
|
||||
with self.subTest(page=page, candidates=candidates):
|
||||
task_id = f"PUR-BLOCK-{index}"
|
||||
self._prepare_task(task_id=task_id)
|
||||
@@ -292,7 +292,7 @@ class PurchaseTaskServiceTest(unittest.TestCase):
|
||||
|
||||
outcome = self._service(adapter).execute_selected(task_id)
|
||||
|
||||
self.assertEqual(outcome.kind, "failed")
|
||||
self.assertEqual(outcome.kind, expected_kind)
|
||||
self.assertNotIn(("enter_confirmation",), adapter.calls)
|
||||
detail = self.repository.get_task(task_id)
|
||||
assert detail is not None
|
||||
@@ -339,7 +339,7 @@ class PurchaseTaskServiceTest(unittest.TestCase):
|
||||
|
||||
outcome = self._service(adapter).execute_one_local()
|
||||
|
||||
self.assertEqual(outcome.kind, "failed")
|
||||
self.assertEqual(outcome.kind, "task_failed")
|
||||
self.assertEqual(adapter.submit_count, 0)
|
||||
run = self.repository.latest_task_run("PUR-001")
|
||||
self.assertIsNone(run.irreversible_action_at)
|
||||
@@ -364,7 +364,7 @@ class PurchaseTaskServiceTest(unittest.TestCase):
|
||||
|
||||
outcome = self._service(adapter).execute_one_local()
|
||||
|
||||
self.assertEqual(outcome.kind, "failed")
|
||||
self.assertEqual(outcome.kind, "task_failed")
|
||||
self.assertEqual(adapter.calls, [("close",)])
|
||||
run = self.repository.latest_task_run("PUR-001")
|
||||
self.assertIsNone(run.irreversible_action_at)
|
||||
@@ -400,7 +400,7 @@ class PurchaseTaskServiceTest(unittest.TestCase):
|
||||
|
||||
outcome = self._service(adapter).execute_selected(task_id)
|
||||
|
||||
self.assertEqual(outcome.kind, "failed")
|
||||
self.assertEqual(outcome.kind, "task_failed")
|
||||
self.assertNotIn(("open_goods", "https://example.test/goods"), adapter.calls)
|
||||
detail = self.repository.get_task(task_id)
|
||||
assert detail is not None
|
||||
|
||||
@@ -126,6 +126,30 @@ class TaskDetailViewTest(unittest.TestCase):
|
||||
|
||||
self.assertEqual(data.current_step, "未知步骤")
|
||||
|
||||
def test_failure_status_uses_task_type_and_keeps_reason(self):
|
||||
collect = replace(
|
||||
make_detail(),
|
||||
status=TaskStatus.RETRY_WAIT,
|
||||
last_error_code="PDD_PAGE_TIMEOUT",
|
||||
last_error_message="商品页面加载超时",
|
||||
)
|
||||
purchase = replace(
|
||||
make_detail(),
|
||||
remote_task_id="PUR-001",
|
||||
task_type=TaskType.PURCHASE,
|
||||
status=TaskStatus.FAILED,
|
||||
last_error_code="PURCHASE_PRICE_EXCEEDED",
|
||||
last_error_message="价格超过上限",
|
||||
)
|
||||
|
||||
collect_data = build_task_detail_view_data(collect)
|
||||
purchase_data = build_task_detail_view_data(purchase)
|
||||
|
||||
self.assertEqual(collect_data.status, "采集失败")
|
||||
self.assertIn("商品页面加载超时", collect_data.last_error)
|
||||
self.assertEqual(purchase_data.status, "采购失败")
|
||||
self.assertIn("价格超过上限", purchase_data.last_error)
|
||||
|
||||
def test_purchase_recovery_steps_are_clear_chinese(self):
|
||||
cases = {
|
||||
"purchase_dry_run_stopped": "采购演练已在提交前停止",
|
||||
|
||||
@@ -578,6 +578,25 @@ class TaskRepositoryTests(unittest.TestCase):
|
||||
self.assertEqual(prepared.status, TaskStatus.CLAIMED)
|
||||
self.assertEqual(second.attempt_no, 2)
|
||||
|
||||
def test_automatic_selection_skips_retry_wait_task(self):
|
||||
self.repository.add_claimed_task(self._task("RETRY-SKIPPED"))
|
||||
started = self.repository.start_collect_run("RETRY-SKIPPED", "USB-001")
|
||||
self.repository.save_collect_failure(
|
||||
"RETRY-SKIPPED",
|
||||
started.attempt_id,
|
||||
TaskStatus.RETRY_WAIT,
|
||||
"PDD_PAGE_TIMEOUT",
|
||||
"页面超时",
|
||||
True,
|
||||
)
|
||||
self.repository.add_claimed_task(self._task("NEXT-CLAIMED"))
|
||||
|
||||
collect_task = self.repository.next_collect_task()
|
||||
runnable = self.repository.next_runnable_task(include_purchase=True)
|
||||
|
||||
self.assertEqual("NEXT-CLAIMED", collect_task.remote_task_id)
|
||||
self.assertEqual("NEXT-CLAIMED", runnable.remote_task_id)
|
||||
|
||||
def test_retry_wait_explains_permanently_failed_outbox(self):
|
||||
self.repository.add_claimed_task(self._task("RETRY-REJECTED"))
|
||||
started = self.repository.start_collect_run(
|
||||
|
||||
@@ -136,7 +136,7 @@ Client 应执行:
|
||||
|
||||
1. **Admin:**服务地址、Client 编号、认证状态、请求超时和测试连接。
|
||||
2. **Android 设备:**ADB 地址、拼多多包名、设备测试连接。
|
||||
3. **自动化:**轮询周期、任务超时、最大重试次数和演练模式。
|
||||
3. **自动化:**轮询周期、任务超时和演练模式。
|
||||
4. **安全与诊断:**价格允许偏差、最大购买数量、日志目录、截图/XML 保留周期。
|
||||
5. **软件更新:**当前版本、清单地址、账号、密码、保存/检查按钮和稳定状态文字。
|
||||
|
||||
@@ -146,6 +146,9 @@ Client 应执行:
|
||||
|
||||
任务共有 8 个标准状态:`claimed`、`running`、`result_pending`、`retry_wait`、`manual_review`、`succeeded`、`failed`、`cancelled`。
|
||||
|
||||
`retry_wait` 只为兼容旧数据和采集进程异常退出保留;自动任务不会选择它,也不会
|
||||
自动重试失败任务。
|
||||
|
||||
没有"待领取"状态——任务在领取成功那一刻才写进本地库,见 [03 数据模型](03-data-model.md) §3.1。
|
||||
|
||||
**每个状态的中文显示、含义和允许的转换,以 [03 数据模型](03-data-model.md) §7 为准**(那是唯一权威定义,写代码和写测试都看那一张表)。
|
||||
|
||||
@@ -450,26 +450,25 @@ CREATE TABLE app_settings (
|
||||
|---|---|---|---|
|
||||
| (无记录) | `claimed` | `claim` 成功,任务首次写入本地库 | 任务协调器 |
|
||||
| `claimed` 已领取 | `running` | 工作线程开始操作设备 | 任务执行器 |
|
||||
| `claimed` | `retry_wait` | 设备连不上等可恢复错误,**还没碰过设备** | 任务执行器 |
|
||||
| `claimed` | `failed` | 任务数据不合法(缺必填字段、数量为 0 等) | 任务执行器 |
|
||||
| `claimed` | `cancelled` | 用户停止,且尚未开始执行 | 任务协调器 |
|
||||
| `running` 执行中 | `result_pending` | PDD 操作完成,**且结果已落库**(见 §5.1) | 任务执行器 |
|
||||
| `running` | `retry_wait` | 可恢复失败(页面超时、网络抖动),**且未进入不可逆阶段** | 任务执行器 |
|
||||
| `running` | `manual_review` | 需要人判断:多个订单候选、验证码、登录失效、价格超限、规格不确定 | 任务执行器 |
|
||||
| `running` | `failed` | 不可恢复且不需要人处理(商品下架、链接失效) | 任务执行器 |
|
||||
| `running` | `manual_review` | 登录、验证码、风控或不可逆采购等必须停队列并由人处理 | 任务执行器 |
|
||||
| `running` | `failed` | 本次采集或采购失败;失败上报成功后继续下一条,不自动重试当前任务 | 任务执行器 |
|
||||
| `running` | `cancelled` | 用户停止,且已到安全点、未进入不可逆阶段 | 任务协调器 |
|
||||
| `result_pending` 结果待提交 | `succeeded` | Admin 返回 `accepted: true` | 结果提交服务 |
|
||||
| `result_pending` | `manual_review` | 重试次数超上限仍提交不上去 | 结果提交服务 |
|
||||
| `manual_review / reconcile_purchase` | `result_pending` | 严格核对到唯一未付款订单,结果和 Outbox 已在同一事务落库 | 只读核单服务 |
|
||||
| `retry_wait` 重试等待 | `running` | 重新启动获取任务,或用户确认“重新执行”;**本地直接重跑**并新建 `task_runs` 记录 | 任务协调器 / 人 |
|
||||
| `retry_wait` | `failed` | 超过最大重试次数,且从未进入不可逆阶段 | 任务协调器 |
|
||||
| `retry_wait` | `manual_review` | 超过最大重试次数,但**曾经进入过不可逆阶段** | 任务协调器 |
|
||||
| `retry_wait` 旧数据/中断恢复 | `claimed` | 用户明确确认重新采集;自动获取不会选择该状态 | 人 |
|
||||
| `manual_review` / `succeeded` / `failed` / `cancelled` | `claimed` | 用户在 Client 明确确认重新采集;仅限采集任务,且没有未发送 Outbox | 人 |
|
||||
|
||||
**注意 `retry_wait` → `running` 是纯本地操作。** 任务已经在本地库里了,重试直接重跑就行,
|
||||
**不需要再向 Admin 要一次**。没有租约,也就没有"重新获取执行权"这回事。
|
||||
当前 `retry_wait` 不包含倒计时。自动获取因可恢复采集错误停止后,界面必须显示
|
||||
“重试已暂停”;用户可以选择任务点击“重新执行”,或重新启动获取任务。
|
||||
自动流程对一条任务只执行一次。任务自身失败时写入 `failed`,界面按类型显示
|
||||
“采集失败”或“采购失败”;失败原因保存在 `last_error_code` 和
|
||||
`last_error_message`。只有用户明确点击重新采集或重新采购,才创建新的
|
||||
`task_runs` 记录再次执行。
|
||||
|
||||
`retry_wait` 不再表示程序正在等待。它只兼容旧数据和“采集运行中异常退出”的恢复
|
||||
结果,界面同样显示为“采集失败”,并且不会被自动任务查询选中。
|
||||
|
||||
### 7.2 补充规则
|
||||
|
||||
@@ -478,7 +477,7 @@ CREATE TABLE app_settings (
|
||||
- `[必须]` 只有 Admin 返回 `accepted: true` 才能进入 `succeeded`,本地不许自己判定成功。
|
||||
- `[必须]` `manual_review` 不自动重新执行;只有用户在 Client 明确确认重新采集,才允许先回到 `claimed`。
|
||||
- `[必须]` 已经是 `succeeded`、`failed`、`cancelled` 的任务,不得被迟到的后台回调改回运行中状态;人工确认的重新采集除外。
|
||||
- `[必须]` 每次重试都要新建一条 `task_runs` 记录(`attempt_no` 加 1),不许覆盖上一次的记录。
|
||||
- `[必须]` 每次用户手动重新执行都要新建一条 `task_runs` 记录(`attempt_no` 加 1),不许覆盖上一次的记录。
|
||||
- `[必须]` 采购任务不能从“重新执行”入口启动;重新采集不处理任何采购动作。
|
||||
|
||||
### 7.3 崩溃重启后怎么恢复
|
||||
@@ -496,8 +495,9 @@ CREATE TABLE app_settings (
|
||||
|
||||
一句话记住:**`irreversible_action_at` 有值 = 只准查,不准买。**
|
||||
|
||||
可重试的设备错误在采购演练中也先进入 `manual_review`,不会因为技术上
|
||||
“可重试”就隐式重跑采购。这与采集任务的 `retry_wait` 规则不同。
|
||||
采集和采购都不自动重试。设备断开、登录、验证码、风控、Admin 不可用以及
|
||||
不可逆采购待核单属于全局阻塞,必须停止队列;普通任务自身失败并成功上报后继续
|
||||
下一条。
|
||||
|
||||
## 8. `pdd_data` JSON
|
||||
|
||||
|
||||
@@ -107,8 +107,9 @@ Client 首次显示主窗口时使用标准 Windows 最大化状态,不使用
|
||||
- 用户确认批量操作后立即刷新列表,并在状态区显示已加入队列的数量。排队中的任务保持原状态;
|
||||
只有工作线程已经把当前任务写成 `running` 后,才通知主线程刷新该行并显示“采集中”或
|
||||
“采购中”。每条完成、失败、跳过或取消后再次刷新,再开始下一条。
|
||||
- “等待重试”当前没有倒计时。自动获取因可恢复采集错误停止时,底部状态显示
|
||||
“重试已暂停”,并提示选择任务点击“重新执行”或重新启动获取任务。
|
||||
- 自动流程对每条任务只执行一次,不显示“等待重试”。普通失败按任务类型显示
|
||||
“采集失败”或“采购失败”,详情保留具体原因;失败信息成功上报后继续下一条。
|
||||
只有设备、登录、验证码、风控、Admin 或不可逆采购等全局问题才停止队列。
|
||||
- “重新上报”作用于当前已经加载并勾选的任务。执行前显示任务数量,并明确说明不会重新采集、采购或操作手机。
|
||||
- 重新上报先查找每条任务最早一条尚未发送的 Outbox,包括 `task_failure`;全部事件都已发送时,才重发最新的 `collect_result` 或 `purchase_result`。
|
||||
- 重新上报使用事件原来的 `idempotency_key` 和 `payload_json`,不重新组装数据、不创建新 Outbox。历史结果再次得到 Admin 确认时不得覆盖任务最新执行状态;最新失败信息上报成功后应恢复对应失败状态。
|
||||
|
||||
Reference in New Issue
Block a user