feat: 任务失败后继续执行下一条 (#170)
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user