feat: 实现受控真实下单安全边界 (#99)

This commit is contained in:
chengma
2026-08-10 16:35:38 +08:00
parent 7106f6b024
commit e960cab1fb
30 changed files with 1562 additions and 66 deletions
+95 -3
View File
@@ -11,7 +11,12 @@ from src.admin_gateway import (
ClientInfo,
)
from src.mock_admin_gateway import MockAdminGateway
from src.pdd_purchase_adapter import PddPurchaseAdapter, PurchasePageState
from src.pdd_purchase_adapter import (
PddLivePurchaseAdapter,
PddPurchaseAdapter,
PddPurchaseError,
PurchasePageState,
)
from src.purchase_task_service import PurchaseTaskService
from src.task_models import NewClaimedTask, TaskStatus, TaskType
from src.task_repository import TaskRepository
@@ -31,12 +36,16 @@ class RecordingDryRunAdapter(PddPurchaseAdapter):
candidate_count: int = 1,
forced_page: str = "",
wrong_options: bool = False,
in_stock: bool = True,
submit_candidate_count: int = 1,
) -> None:
self.goods_id = goods_id
self.price_cent = price_cent
self.candidate_count = candidate_count
self.forced_page = forced_page
self.wrong_options = wrong_options
self.in_stock = in_stock
self.submit_candidate_count = submit_candidate_count
self.options = {}
self.quantity = 0
self.page_kind = "goods"
@@ -57,6 +66,8 @@ class RecordingDryRunAdapter(PddPurchaseAdapter):
quantity=self.quantity,
price_cent=self.price_cent,
candidate_count=self.candidate_count,
in_stock=self.in_stock,
submit_candidate_count=self.submit_candidate_count,
)
def select_options(self, options) -> None:
@@ -78,6 +89,25 @@ class RecordingDryRunAdapter(PddPurchaseAdapter):
self.calls.append(("close",))
class RecordingLiveAdapter(RecordingDryRunAdapter, PddLivePurchaseAdapter):
"""只记录一次提交调用的 live 测试 Adapter。"""
def __init__(self, *, submit_error: bool = False, **kwargs) -> None:
super().__init__(**kwargs)
self.submit_error = submit_error
self.submit_count = 0
def submit_order_once(self) -> None:
self.submit_count += 1
self.calls.append(("submit_order_once",))
if self.submit_error:
raise PddPurchaseError(
"DEVICE_DISCONNECTED",
"点击后设备断开",
step="purchase_submit_once",
)
class PurchaseTaskServiceTest(unittest.TestCase):
def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory()
@@ -90,12 +120,18 @@ class PurchaseTaskServiceTest(unittest.TestCase):
def tearDown(self) -> None:
self.temp_dir.cleanup()
def _prepare_task(self, *, task_id: str = "PUR-001") -> None:
def _prepare_task(
self,
*,
task_id: str = "PUR-001",
execution_mode: str = "dry_run",
) -> None:
task = AdminTask(
task_id=task_id,
task_type=TaskType.PURCHASE,
version=1,
priority=10,
execution_mode=execution_mode,
payload={
"goods_url": (
"https://mobile.yangkeduo.com/goods.html?"
@@ -113,7 +149,9 @@ class PurchaseTaskServiceTest(unittest.TestCase):
ClaimCapabilities(
device=AndroidDeviceInfo("USB-001"),
supported_types=(TaskType.PURCHASE,),
purchase_mode="dry_run",
purchase_mode=(
"live" if execution_mode == "live" else "dry_run"
),
),
)
assert claimed is not None
@@ -122,6 +160,7 @@ class PurchaseTaskServiceTest(unittest.TestCase):
remote_task_id=claimed.task_id,
task_type=claimed.task_type,
goods_url=str(claimed.payload["goods_url"]),
execution_mode=claimed.execution_mode,
goods_id=str(claimed.payload["goods_id"]),
quantity=int(claimed.payload["quantity"]),
priority=claimed.priority,
@@ -236,6 +275,59 @@ class PurchaseTaskServiceTest(unittest.TestCase):
assert detail is not None
self.assertEqual(detail.last_error_code, expected_code)
def test_live_marks_irreversible_before_single_submit_and_enters_reconcile(self):
self._prepare_task(execution_mode="live")
adapter = RecordingLiveAdapter()
outcome = self._service(adapter).execute_one_local()
self.assertEqual(outcome.kind, "manual_review")
self.assertEqual(adapter.submit_count, 1)
detail = self.repository.get_task("PUR-001")
run = self.repository.latest_task_run("PUR-001")
self.assertEqual(detail.status, TaskStatus.MANUAL_REVIEW)
self.assertEqual(detail.current_step, "reconcile_purchase")
self.assertIsNotNone(run.irreversible_action_at)
self.assertIsNotNone(run.order_submitted_at)
self.assertEqual(run.current_step, "reconcile_purchase")
self.assertIsNone(self.repository.next_pending_outbox())
def test_live_ambiguous_submit_target_stops_before_irreversible(self):
self._prepare_task(execution_mode="live")
adapter = RecordingLiveAdapter(submit_candidate_count=2)
outcome = self._service(adapter).execute_one_local()
self.assertEqual(outcome.kind, "failed")
self.assertEqual(adapter.submit_count, 0)
run = self.repository.latest_task_run("PUR-001")
self.assertIsNone(run.irreversible_action_at)
def test_live_submit_error_never_retries_and_still_enters_reconcile(self):
self._prepare_task(execution_mode="live")
adapter = RecordingLiveAdapter(submit_error=True)
outcome = self._service(adapter).execute_one_local()
self.assertEqual(outcome.kind, "manual_review")
self.assertEqual(adapter.submit_count, 1)
run = self.repository.latest_task_run("PUR-001")
self.assertIsNotNone(run.irreversible_action_at)
self.assertIsNone(run.order_submitted_at)
self.assertEqual(run.current_step, "reconcile_purchase")
self.assertIsNone(self.repository.next_pending_outbox())
def test_live_task_rejects_dry_adapter_before_device_action(self):
self._prepare_task(execution_mode="live")
adapter = RecordingDryRunAdapter()
outcome = self._service(adapter).execute_one_local()
self.assertEqual(outcome.kind, "failed")
self.assertEqual(adapter.calls, [("close",)])
run = self.repository.latest_task_run("PUR-001")
self.assertIsNone(run.irreversible_action_at)
def test_purchase_task_with_missing_safety_fields_is_reported(self):
task_id = "PUR-INVALID"
task = AdminTask(