feat: 实现采购任务安全演练 (#71)
This commit is contained in:
@@ -0,0 +1,466 @@
|
||||
"""一条本地采购任务的安全演练流程。
|
||||
|
||||
本模块固定为 ``dry_run``。它会核对商品、动态规格、数量和价格,并停在最终
|
||||
提交订单之前;代码中没有提交订单或付款入口。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Callable, Mapping
|
||||
|
||||
from .admin_gateway import AdminGateway, AdminGatewayError, ClientInfo
|
||||
from .pdd_purchase_adapter import (
|
||||
PddPurchaseAdapter,
|
||||
PddPurchaseError,
|
||||
PurchasePageState,
|
||||
)
|
||||
from .task_models import OutboxEventRecord, OutboxEventType, TaskStatus
|
||||
from .task_repository import TaskRepository
|
||||
|
||||
|
||||
def utc_now_iso() -> str:
|
||||
"""返回精确到秒的 UTC ISO 8601 时间。"""
|
||||
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds").replace(
|
||||
"+00:00", "Z"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PurchaseTarget:
|
||||
"""从已领取任务中校验出的采购目标。"""
|
||||
|
||||
goods_id: str
|
||||
goods_url: str
|
||||
options: Mapping[str, str]
|
||||
quantity: int
|
||||
max_price_cent: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PurchaseTaskOutcome:
|
||||
"""采购工作线程返回给协调层的简短结果。"""
|
||||
|
||||
kind: str
|
||||
message: str
|
||||
task_id: str = ""
|
||||
|
||||
|
||||
PurchaseAdapterFactory = Callable[
|
||||
[str, Callable[[], bool]], PddPurchaseAdapter
|
||||
]
|
||||
|
||||
|
||||
class PurchaseTaskService:
|
||||
"""只执行本地已保存的采购任务,不领取新任务。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
gateway: AdminGateway,
|
||||
repository: TaskRepository,
|
||||
client: ClientInfo,
|
||||
device_address: str,
|
||||
adapter_factory: PurchaseAdapterFactory,
|
||||
*,
|
||||
cancelled: Callable[[], bool] = lambda: False,
|
||||
) -> None:
|
||||
self._gateway = gateway
|
||||
self._repository = repository
|
||||
self._client = client
|
||||
self._device_address = str(device_address or "").strip()
|
||||
self._factory = adapter_factory
|
||||
self._cancelled = cancelled
|
||||
|
||||
def execute_one_local(self) -> PurchaseTaskOutcome:
|
||||
"""先补交结果,再执行最早的本地待执行采购任务。"""
|
||||
|
||||
pending = self._repository.next_pending_outbox()
|
||||
if pending is not None:
|
||||
return self._submit(pending)
|
||||
if not self._device_address:
|
||||
raise ValueError("请先在设置页选择并保存 Android 设备")
|
||||
task = self._repository.next_purchase_task()
|
||||
if task is None:
|
||||
return PurchaseTaskOutcome("no_task", "暂无本地待执行的采购任务")
|
||||
return self.execute_selected(task.remote_task_id)
|
||||
|
||||
def execute_selected(self, remote_task_id: str) -> PurchaseTaskOutcome:
|
||||
"""对指定的本地采购任务执行一次演练。"""
|
||||
|
||||
if not self._device_address:
|
||||
raise ValueError("请先在设置页选择并保存 Android 设备")
|
||||
if self._cancelled():
|
||||
return PurchaseTaskOutcome(
|
||||
"cancelled", "采购演练已在开始前取消", remote_task_id
|
||||
)
|
||||
|
||||
started = self._repository.start_purchase_run(
|
||||
remote_task_id, self._device_address
|
||||
)
|
||||
adapter: PddPurchaseAdapter | None = None
|
||||
step = "purchase_prepare"
|
||||
try:
|
||||
target = self._target_from_task(started.task)
|
||||
adapter = self._factory(self._device_address, self._cancelled)
|
||||
|
||||
step = "purchase_open_goods"
|
||||
self._enter_step(remote_task_id, started.attempt_id, step)
|
||||
adapter.open_goods(target.goods_url)
|
||||
|
||||
step = "purchase_verify_goods"
|
||||
self._enter_step(remote_task_id, started.attempt_id, step)
|
||||
state = adapter.read_state()
|
||||
self._validate_identity(state, target, "goods")
|
||||
|
||||
step = "purchase_select_options"
|
||||
self._enter_step(remote_task_id, started.attempt_id, step)
|
||||
self._validate_identity(adapter.read_state(), target, "goods")
|
||||
adapter.select_options(target.options)
|
||||
|
||||
step = "purchase_verify_options"
|
||||
self._enter_step(remote_task_id, started.attempt_id, step)
|
||||
state = adapter.read_state()
|
||||
self._validate_selected_options(state, target)
|
||||
|
||||
step = "purchase_set_quantity"
|
||||
self._enter_step(remote_task_id, started.attempt_id, step)
|
||||
self._validate_selected_options(adapter.read_state(), target)
|
||||
adapter.set_quantity(target.quantity)
|
||||
|
||||
step = "purchase_verify_quantity_price"
|
||||
self._enter_step(remote_task_id, started.attempt_id, step)
|
||||
state = adapter.read_state()
|
||||
self._validate_checkout_values(state, target)
|
||||
|
||||
step = "purchase_enter_confirmation"
|
||||
self._enter_step(remote_task_id, started.attempt_id, step)
|
||||
self._validate_checkout_values(adapter.read_state(), target)
|
||||
adapter.enter_confirmation()
|
||||
|
||||
step = "purchase_verify_confirmation"
|
||||
self._enter_step(remote_task_id, started.attempt_id, step)
|
||||
state = adapter.read_state()
|
||||
if state.page_kind != "order_confirmation":
|
||||
raise PddPurchaseError(
|
||||
"PURCHASE_CONFIRMATION_NOT_REACHED",
|
||||
"没有到达最终提交前确认页,已停止演练",
|
||||
step=step,
|
||||
)
|
||||
self._validate_checkout_values(state, target)
|
||||
|
||||
step = "purchase_dry_run_stopped"
|
||||
self._enter_step(remote_task_id, started.attempt_id, step)
|
||||
adapter.stop_before_submit()
|
||||
result = self._result_data(target, state)
|
||||
event = self._repository.save_purchase_result(
|
||||
remote_task_id, started.attempt_id, result
|
||||
)
|
||||
except PddPurchaseError as exc:
|
||||
event = self._save_failure(
|
||||
remote_task_id,
|
||||
started.attempt_id,
|
||||
exc.code,
|
||||
exc.message,
|
||||
exc.retryable,
|
||||
exc.step or step,
|
||||
exc.diagnostics,
|
||||
)
|
||||
except Exception as exc:
|
||||
event = self._save_failure(
|
||||
remote_task_id,
|
||||
started.attempt_id,
|
||||
"PURCHASE_UNEXPECTED",
|
||||
f"采购演练在“{step}”发生未知错误:{exc}",
|
||||
False,
|
||||
step,
|
||||
{},
|
||||
)
|
||||
finally:
|
||||
if adapter is not None:
|
||||
adapter.close()
|
||||
return self._submit(event)
|
||||
|
||||
def _enter_step(
|
||||
self, remote_task_id: str, attempt_id: str, step: str
|
||||
) -> None:
|
||||
"""先持久化步骤,再检查停止请求。"""
|
||||
|
||||
self._repository.update_purchase_step(
|
||||
remote_task_id, attempt_id, step
|
||||
)
|
||||
if self._cancelled():
|
||||
raise PddPurchaseError(
|
||||
"PURCHASE_CANCELLED",
|
||||
"用户已请求停止采购演练",
|
||||
step=step,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _target_from_task(task: object) -> PurchaseTarget:
|
||||
payload_root = getattr(task, "admin_payload", None)
|
||||
if not isinstance(payload_root, Mapping):
|
||||
raise PddPurchaseError(
|
||||
"PURCHASE_TASK_INVALID",
|
||||
"采购任务原始数据不是对象",
|
||||
step="purchase_prepare",
|
||||
)
|
||||
payload = payload_root.get("payload")
|
||||
if not isinstance(payload, Mapping):
|
||||
raise PddPurchaseError(
|
||||
"PURCHASE_TASK_INVALID",
|
||||
"采购任务缺少 payload 对象",
|
||||
step="purchase_prepare",
|
||||
)
|
||||
goods_url = str(payload.get("goods_url") or "").strip()
|
||||
goods_id = str(payload.get("goods_id") or "").strip()
|
||||
options = payload.get("options")
|
||||
quantity = payload.get("quantity")
|
||||
max_price_cent = payload.get("max_price_cent")
|
||||
if not goods_url or not goods_id:
|
||||
raise PddPurchaseError(
|
||||
"PURCHASE_TASK_INVALID",
|
||||
"采购任务缺少商品链接或商品编号",
|
||||
step="purchase_prepare",
|
||||
)
|
||||
if not isinstance(options, Mapping) or not options:
|
||||
raise PddPurchaseError(
|
||||
"PURCHASE_TASK_INVALID",
|
||||
"采购任务缺少动态规格 options",
|
||||
step="purchase_prepare",
|
||||
)
|
||||
normalized_options: dict[str, str] = {}
|
||||
for key, value in options.items():
|
||||
checked_key = str(key or "").strip()
|
||||
checked_value = str(value or "").strip()
|
||||
if not checked_key or not checked_value:
|
||||
raise PddPurchaseError(
|
||||
"PURCHASE_TASK_INVALID",
|
||||
"采购任务 options 的名称和值都不能为空",
|
||||
step="purchase_prepare",
|
||||
)
|
||||
normalized_options[checked_key] = checked_value
|
||||
if isinstance(quantity, bool) or not isinstance(quantity, int) or quantity <= 0:
|
||||
raise PddPurchaseError(
|
||||
"PURCHASE_TASK_INVALID",
|
||||
"采购数量必须是大于 0 的整数",
|
||||
step="purchase_prepare",
|
||||
)
|
||||
if (
|
||||
isinstance(max_price_cent, bool)
|
||||
or not isinstance(max_price_cent, int)
|
||||
or max_price_cent <= 0
|
||||
):
|
||||
raise PddPurchaseError(
|
||||
"PURCHASE_TASK_INVALID",
|
||||
"人民币价格上限必须是大于 0 的整数分",
|
||||
step="purchase_prepare",
|
||||
)
|
||||
return PurchaseTarget(
|
||||
goods_id,
|
||||
goods_url,
|
||||
normalized_options,
|
||||
quantity,
|
||||
max_price_cent,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _validate_identity(
|
||||
state: PurchasePageState, target: PurchaseTarget, expected_page: str
|
||||
) -> None:
|
||||
PurchaseTaskService._validate_common_state(state)
|
||||
if state.page_kind != expected_page:
|
||||
raise PddPurchaseError(
|
||||
"PDD_PAGE_UNKNOWN",
|
||||
f"当前页面不是预期的 {expected_page} 页面,已停止演练",
|
||||
step="purchase_verify_goods",
|
||||
)
|
||||
if state.goods_id != target.goods_id:
|
||||
raise PddPurchaseError(
|
||||
"PURCHASE_GOODS_MISMATCH",
|
||||
"当前 PDD 商品与采购任务不一致,已停止演练",
|
||||
step="purchase_verify_goods",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _validate_selected_options(
|
||||
state: PurchasePageState, target: PurchaseTarget
|
||||
) -> None:
|
||||
PurchaseTaskService._validate_common_state(state)
|
||||
if state.goods_id != target.goods_id:
|
||||
raise PddPurchaseError(
|
||||
"PURCHASE_GOODS_MISMATCH",
|
||||
"选择规格后商品编号发生变化,已停止演练",
|
||||
step="purchase_verify_options",
|
||||
)
|
||||
if dict(state.selected_options) != dict(target.options):
|
||||
raise PddPurchaseError(
|
||||
"PURCHASE_OPTIONS_MISMATCH",
|
||||
"当前选中规格与采购任务不完全一致,已停止演练",
|
||||
step="purchase_verify_options",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _validate_checkout_values(
|
||||
state: PurchasePageState, target: PurchaseTarget
|
||||
) -> None:
|
||||
PurchaseTaskService._validate_selected_options(state, target)
|
||||
if state.quantity != target.quantity:
|
||||
raise PddPurchaseError(
|
||||
"PURCHASE_QUANTITY_MISMATCH",
|
||||
"当前数量与采购任务不一致,已停止演练",
|
||||
step="purchase_verify_quantity_price",
|
||||
)
|
||||
if state.price_cent <= 0:
|
||||
raise PddPurchaseError(
|
||||
"PURCHASE_PRICE_MISSING",
|
||||
"无法读取稳定的当前人民币价格,已停止演练",
|
||||
step="purchase_verify_quantity_price",
|
||||
)
|
||||
if state.price_cent > target.max_price_cent:
|
||||
raise PddPurchaseError(
|
||||
"PURCHASE_PRICE_EXCEEDED",
|
||||
(
|
||||
f"当前价格 {state.price_cent} 分超过上限 "
|
||||
f"{target.max_price_cent} 分,已停止演练"
|
||||
),
|
||||
step="purchase_verify_quantity_price",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _validate_common_state(state: PurchasePageState) -> None:
|
||||
if state.page_kind == "captcha":
|
||||
raise PddPurchaseError(
|
||||
"PDD_PAGE_CAPTCHA",
|
||||
"PDD 出现安全验证,请人工处理",
|
||||
step="purchase_page_check",
|
||||
)
|
||||
if state.page_kind == "login_required":
|
||||
raise PddPurchaseError(
|
||||
"PDD_PAGE_LOGIN_REQUIRED",
|
||||
"PDD 登录状态失效,请人工登录后再处理",
|
||||
step="purchase_page_check",
|
||||
)
|
||||
if state.page_kind in {"unknown", "risk_control", "payment"}:
|
||||
raise PddPurchaseError(
|
||||
"PDD_PAGE_UNKNOWN",
|
||||
"PDD 出现未知、风控或支付页面,已停止演练",
|
||||
step="purchase_page_check",
|
||||
)
|
||||
if state.candidate_count != 1:
|
||||
raise PddPurchaseError(
|
||||
"PURCHASE_AMBIGUOUS_TARGET",
|
||||
"页面存在多个候选目标,无法安全确认,已停止演练",
|
||||
step="purchase_page_check",
|
||||
)
|
||||
|
||||
def _save_failure(
|
||||
self,
|
||||
remote_task_id: str,
|
||||
attempt_id: str,
|
||||
code: str,
|
||||
message: str,
|
||||
retryable: bool,
|
||||
step: str,
|
||||
diagnostics: Mapping[str, object],
|
||||
) -> OutboxEventRecord:
|
||||
if code == "PURCHASE_CANCELLED":
|
||||
status = TaskStatus.CANCELLED
|
||||
elif retryable:
|
||||
status = TaskStatus.RETRY_WAIT
|
||||
else:
|
||||
status = TaskStatus.MANUAL_REVIEW
|
||||
return self._repository.save_purchase_failure(
|
||||
remote_task_id,
|
||||
attempt_id,
|
||||
status,
|
||||
code,
|
||||
message,
|
||||
retryable,
|
||||
step,
|
||||
dict(diagnostics),
|
||||
)
|
||||
|
||||
def _submit(self, event: OutboxEventRecord) -> PurchaseTaskOutcome:
|
||||
task_id = self._repository.outbox_task_id(event.id)
|
||||
self._repository.mark_outbox_sending(event.id)
|
||||
try:
|
||||
if event.event_type is OutboxEventType.TASK_FAILURE:
|
||||
receipt = self._gateway.submit_failure(
|
||||
task_id, event.idempotency_key, event.payload_json
|
||||
)
|
||||
else:
|
||||
receipt = self._gateway.submit_result(
|
||||
task_id, event.idempotency_key, event.payload_json
|
||||
)
|
||||
if not receipt.accepted:
|
||||
raise AdminGatewayError(
|
||||
"ADMIN_RESULT_NOT_ACCEPTED",
|
||||
"Admin 未确认接收采购演练结果",
|
||||
False,
|
||||
)
|
||||
except AdminGatewayError as exc:
|
||||
message = str(exc)
|
||||
if exc.retryable:
|
||||
self._repository.mark_outbox_retry(event.id, message)
|
||||
return PurchaseTaskOutcome(
|
||||
"result_pending",
|
||||
f"任务 {task_id} 演练结果已保存在本地,等待提交 Admin:{message}",
|
||||
task_id,
|
||||
)
|
||||
self._repository.mark_outbox_failed(event.id, message)
|
||||
return PurchaseTaskOutcome(
|
||||
"manual_review",
|
||||
f"任务 {task_id} 演练结果被 Admin 拒绝:{message}",
|
||||
task_id,
|
||||
)
|
||||
|
||||
self._repository.mark_outbox_sent(event.id)
|
||||
if event.event_type is OutboxEventType.TASK_FAILURE:
|
||||
return PurchaseTaskOutcome(
|
||||
"failed",
|
||||
f"任务 {task_id} 采购演练未完成,失败信息已提交 Admin",
|
||||
task_id,
|
||||
)
|
||||
return PurchaseTaskOutcome(
|
||||
"succeeded",
|
||||
f"任务 {task_id} 演练完成并已提交 Admin;没有提交订单",
|
||||
task_id,
|
||||
)
|
||||
|
||||
def _result_data(
|
||||
self, target: PurchaseTarget, state: PurchasePageState
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"goods_id": target.goods_id,
|
||||
"goods_url": target.goods_url,
|
||||
"purchase": {
|
||||
"mode": "dry_run",
|
||||
"requested": {
|
||||
"options": dict(target.options),
|
||||
"quantity": target.quantity,
|
||||
"max_price_cent": target.max_price_cent,
|
||||
},
|
||||
"confirmed": {
|
||||
"options": dict(state.selected_options),
|
||||
"quantity": state.quantity,
|
||||
"unit_price_cent": state.price_cent,
|
||||
"total_price_cent": state.price_cent * state.quantity,
|
||||
},
|
||||
"confirmation_reached": True,
|
||||
"order_submitted": False,
|
||||
"payment_attempted": False,
|
||||
"order_no": None,
|
||||
"ordered_at": None,
|
||||
"ordered_at_raw": None,
|
||||
"match_status": "not_submitted",
|
||||
},
|
||||
"captured_at": utc_now_iso(),
|
||||
"source": {
|
||||
"client_id": self._client.client_id,
|
||||
"device_address": self._device_address,
|
||||
"mode": "dry_run",
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user