feat: 任务失败后继续执行下一条 (#170)

This commit is contained in:
chengma
2026-08-11 18:21:25 +08:00
parent b2c99840b3
commit 8b8a542cc5
14 changed files with 280 additions and 103 deletions
+65 -3
View File
@@ -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(
+61 -8
View File
@@ -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()
+1 -1
View File
@@ -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
+12 -12
View File
@@ -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
+24
View File
@@ -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": "采购演练已在提交前停止",
+19
View File
@@ -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(